Skip to content

Commit 045ee32

Browse files
committed
Refactor Zoodle into runtime scene extension
1 parent cd990f3 commit 045ee32

7 files changed

Lines changed: 203 additions & 71 deletions

File tree

src/components/MlEphantConversation.spec.tsx

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
} from '@testing-library/react'
99
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
1010

11+
const bootMockState = vi.hoisted(() => ({
12+
registry: undefined as unknown,
13+
}))
14+
1115
// Mock modules that access localStorage at import time
1216
vi.mock('@src/routes/utils', () => ({
1317
getAppVersion: () => 'test',
@@ -23,6 +27,13 @@ vi.mock('@src/lib/desktop', () => ({
2327

2428
// Mock useSingletons which requires heavy initialization
2529
vi.mock('@src/lib/boot', () => ({
30+
useApp: () => {
31+
if (!bootMockState.registry) {
32+
throw new Error('Test registry has not been configured')
33+
}
34+
35+
return { registry: bootMockState.registry }
36+
},
2637
useSingletons: () => ({
2738
kclManager: {
2839
astSignal: { value: null },
@@ -39,6 +50,8 @@ vi.mock('@src/lib/screenshot', async (importOriginal) => {
3950
}
4051
})
4152

53+
import { Registry } from '@kittycad/registry'
54+
import { useSignals } from '@preact/signals-react/runtime'
4255
import { MAKEATHON_ANNOUNCEMENT_DISMISSED_STORAGE_KEY } from '@src/components/MakeathonAnnouncement'
4356
import { MlEphantConversation } from '@src/components/MlEphantConversation'
4457
import { takeViewportScreenshot } from '@src/lib/screenshot'
@@ -49,6 +62,40 @@ import type {
4962
MlCopilotModeId,
5063
MlCopilotModeOption,
5164
} from '@src/machines/mlEphantManagerMachine'
65+
import {
66+
type EngineSceneExtensionContext,
67+
engineSceneRuntimeExtensionsSlot,
68+
engineSceneStreamLayersValueSpec,
69+
} from '@src/registry/contracts/engineScene'
70+
71+
const configureTestRegistry = () => {
72+
const registry = new Registry()
73+
registry.configure([engineSceneRuntimeExtensionsSlot.of()])
74+
bootMockState.registry = registry
75+
return registry
76+
}
77+
78+
const testEngineSceneContext = {
79+
modelingState: { matches: () => false },
80+
modelingSend: vi.fn(),
81+
sketchSolveStreamDimming: 0.8,
82+
setSketchSolveStreamDimming: vi.fn(),
83+
} as unknown as EngineSceneExtensionContext
84+
85+
const TestEngineSceneStreamLayers = () => {
86+
useSignals()
87+
const registry = bootMockState.registry as Registry
88+
const layers = registry.signal(engineSceneStreamLayersValueSpec).value
89+
90+
return (
91+
<>
92+
{layers.map((layer) => {
93+
const Component = layer.Component
94+
return <Component key={layer.id} {...testEngineSceneContext} />
95+
})}
96+
</>
97+
)
98+
}
5299

53100
const SERVER_MODE_OPTIONS: MlCopilotModeOption[] = [
54101
{
@@ -69,6 +116,7 @@ const SERVER_MODE_OPTIONS: MlCopilotModeOption[] = [
69116

70117
describe('MlEphantConversation', () => {
71118
beforeEach(() => {
119+
configureTestRegistry()
72120
window.localStorage.removeItem(MAKEATHON_ANNOUNCEMENT_DISMISSED_STORAGE_KEY)
73121
})
74122

@@ -977,22 +1025,25 @@ describe('MlEphantConversation', () => {
9771025

9781026
const renderConversation = (handleProcess = vi.fn(), disabled = false) => {
9791027
return render(
980-
<MlEphantConversation
981-
isLoading={false}
982-
conversation={{ exchanges: [] }}
983-
onProcess={handleProcess}
984-
onClickClearChat={() => {}}
985-
onReconnect={() => {}}
986-
onCancel={() => {}}
987-
needsReconnect={false}
988-
disabled={disabled}
989-
hasPromptCompleted={true}
990-
contexts={[]}
991-
isProcessing={false}
992-
queue={[]}
993-
onRemoveFromQueue={() => {}}
994-
onSteer={() => {}}
995-
/>
1028+
<>
1029+
<MlEphantConversation
1030+
isLoading={false}
1031+
conversation={{ exchanges: [] }}
1032+
onProcess={handleProcess}
1033+
onClickClearChat={() => {}}
1034+
onReconnect={() => {}}
1035+
onCancel={() => {}}
1036+
needsReconnect={false}
1037+
disabled={disabled}
1038+
hasPromptCompleted={true}
1039+
contexts={[]}
1040+
isProcessing={false}
1041+
queue={[]}
1042+
onRemoveFromQueue={() => {}}
1043+
onSteer={() => {}}
1044+
/>
1045+
<TestEngineSceneStreamLayers />
1046+
</>
9961047
)
9971048
}
9981049

src/components/MlEphantConversation.tsx

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { isExternalFileDrag } from '@src/components/Explorer/utils'
55
import Loading from '@src/components/Loading'
66
import { MakeathonAnnouncement } from '@src/components/MakeathonAnnouncement'
77
import Tooltip from '@src/components/Tooltip'
8-
import { ViewportAnnotationOverlay } from '@src/components/ViewportAnnotationOverlay'
98
import { noAutofillInputProps } from '@src/lib/autofill'
9+
import { useApp } from '@src/lib/boot'
1010
import { dataUrlToFile, takeViewportScreenshot } from '@src/lib/screenshot'
1111
import { err } from '@src/lib/trap'
1212
import { isNonNullable } from '@src/lib/utils'
@@ -17,8 +17,12 @@ import type {
1717
MlCopilotModeOption,
1818
} from '@src/machines/mlEphantManagerMachine'
1919
import type { Selections } from '@src/machines/modelingSharedTypes'
20+
import {
21+
activateZoodleRuntimeExtension,
22+
deactivateZoodleRuntimeExtension,
23+
} from '@src/registry/extensions/engineScene/zoodleRuntimeExtension'
2024
import type { ChangeEvent, ReactNode } from 'react'
21-
import { useEffect, useRef, useState } from 'react'
25+
import { useCallback, useEffect, useRef, useState } from 'react'
2226

2327
const noop = () => {}
2428

@@ -263,6 +267,7 @@ interface MlEphantConversationInputProps {
263267
export const MlEphantConversationInput = (
264268
props: MlEphantConversationInputProps
265269
) => {
270+
const { registry } = useApp()
266271
const refDiv = useRef<HTMLTextAreaElement>(null)
267272
const fileInputRef = useRef<HTMLInputElement>(null)
268273
const [value, setValue] = useState<string>('')
@@ -273,9 +278,22 @@ export const MlEphantConversationInput = (
273278
const lastModeScopeKey = useRef(props.modeScopeKey)
274279
const [attachments, setAttachments] = useState<File[]>([])
275280
const [isDraggingOver, setIsDraggingOver] = useState(false)
276-
const [annotationImageDataUrl, setAnnotationImageDataUrl] = useState<
277-
string | null
278-
>(null)
281+
const zoodleRuntimeExtensionActive = useRef(false)
282+
283+
const stopZoodleRuntimeExtension = useCallback(() => {
284+
if (!zoodleRuntimeExtensionActive.current) {
285+
return
286+
}
287+
288+
zoodleRuntimeExtensionActive.current = false
289+
deactivateZoodleRuntimeExtension(registry)
290+
}, [registry])
291+
292+
useEffect(() => {
293+
return () => {
294+
stopZoodleRuntimeExtension()
295+
}
296+
}, [stopZoodleRuntimeExtension])
279297

280298
// Without this the cursor ends up at the start of the text
281299
useEffect(() => setValue(props.defaultPrompt || ''), [props.defaultPrompt])
@@ -395,7 +413,18 @@ export const MlEphantConversationInput = (
395413
try {
396414
const dataUrl = takeViewportScreenshot()
397415
if (!dataUrl) return
398-
setAnnotationImageDataUrl(dataUrl)
416+
zoodleRuntimeExtensionActive.current = true
417+
activateZoodleRuntimeExtension(registry, {
418+
imageDataUrl: dataUrl,
419+
onCancel: stopZoodleRuntimeExtension,
420+
onSend: (annotatedDataUrl) => {
421+
appendDataUrlAttachment(
422+
annotatedDataUrl,
423+
'annotated-viewport-screenshot.png'
424+
)
425+
stopZoodleRuntimeExtension()
426+
},
427+
})
399428
} catch (e) {
400429
console.error('Failed to capture viewport screenshot for annotation', e)
401430
}
@@ -590,19 +619,6 @@ export const MlEphantConversationInput = (
590619
Zookeeper can make mistakes. We send selection context to help. Always
591620
verify information.
592621
</div>
593-
{annotationImageDataUrl && (
594-
<ViewportAnnotationOverlay
595-
imageDataUrl={annotationImageDataUrl}
596-
onCancel={() => setAnnotationImageDataUrl(null)}
597-
onSend={(annotatedDataUrl) => {
598-
appendDataUrlAttachment(
599-
annotatedDataUrl,
600-
'annotated-viewport-screenshot.png'
601-
)
602-
setAnnotationImageDataUrl(null)
603-
}}
604-
/>
605-
)}
606622
</div>
607623
)
608624
}

src/components/ViewportAnnotationOverlay.tsx

Lines changed: 18 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { TRIM_PREVIEW_LINE_COLOR } from '@src/lib/constants'
22
import { getTrimPreviewLineWidth } from '@src/lib/freehandLineDrawing'
3-
import { getVisibleViewportRect } from '@src/lib/viewportElement'
4-
import type { PointerEvent as ReactPointerEvent } from 'react'
3+
import type { PointerEvent as ReactPointerEvent, SyntheticEvent } from 'react'
54
import { useEffect, useRef, useState } from 'react'
6-
import { createPortal } from 'react-dom'
75

86
const loadImage = (src: string): Promise<HTMLImageElement> =>
97
new Promise((resolve, reject) => {
@@ -43,6 +41,10 @@ const getCanvasPoint = (
4341
}
4442
}
4543

44+
const stopOverlayEventPropagation = (event: SyntheticEvent) => {
45+
event.stopPropagation()
46+
}
47+
4648
interface ViewportAnnotationOverlayProps {
4749
imageDataUrl: string
4850
onCancel: () => void
@@ -54,7 +56,6 @@ export const ViewportAnnotationOverlay = (
5456
) => {
5557
const canvasRef = useRef<HTMLCanvasElement>(null)
5658
const lastPoint = useRef<{ x: number; y: number } | null>(null)
57-
const [viewportRect, setViewportRect] = useState(getVisibleViewportRect)
5859
const [imageSize, setImageSize] = useState<{
5960
width: number
6061
height: number
@@ -90,27 +91,6 @@ export const ViewportAnnotationOverlay = (
9091
}
9192
}, [props.imageDataUrl])
9293

93-
useEffect(() => {
94-
const updateViewportRect = () => setViewportRect(getVisibleViewportRect())
95-
const viewport = document.querySelector('[data-engine]')
96-
const resizeObserver =
97-
typeof ResizeObserver === 'undefined'
98-
? undefined
99-
: new ResizeObserver(updateViewportRect)
100-
101-
if (viewport instanceof HTMLElement) {
102-
resizeObserver?.observe(viewport)
103-
}
104-
window.addEventListener('resize', updateViewportRect)
105-
window.addEventListener('scroll', updateViewportRect, true)
106-
107-
return () => {
108-
resizeObserver?.disconnect()
109-
window.removeEventListener('resize', updateViewportRect)
110-
window.removeEventListener('scroll', updateViewportRect, true)
111-
}
112-
}, [])
113-
11494
const drawTo = (event: ReactPointerEvent<HTMLCanvasElement>) => {
11595
const canvas = canvasRef.current
11696
if (!canvas || !lastPoint.current) {
@@ -204,16 +184,20 @@ export const ViewportAnnotationOverlay = (
204184
})
205185
}
206186

207-
return createPortal(
187+
return (
208188
<div
209189
data-testid="viewport-annotation-overlay"
210-
className="fixed z-50 bg-chalkboard-100"
211-
style={{
212-
left: viewportRect.left,
213-
top: viewportRect.top,
214-
width: viewportRect.width,
215-
height: viewportRect.height,
216-
}}
190+
className="absolute inset-0 z-50 bg-chalkboard-100"
191+
onPointerDown={stopOverlayEventPropagation}
192+
onPointerMove={stopOverlayEventPropagation}
193+
onPointerUp={stopOverlayEventPropagation}
194+
onPointerCancel={stopOverlayEventPropagation}
195+
onMouseDown={stopOverlayEventPropagation}
196+
onMouseMove={stopOverlayEventPropagation}
197+
onMouseUp={stopOverlayEventPropagation}
198+
onClick={stopOverlayEventPropagation}
199+
onDoubleClick={stopOverlayEventPropagation}
200+
onWheel={stopOverlayEventPropagation}
217201
>
218202
<img
219203
src={props.imageDataUrl}
@@ -257,7 +241,6 @@ export const ViewportAnnotationOverlay = (
257241
Send
258242
</button>
259243
</div>
260-
</div>,
261-
document.body
244+
</div>
262245
)
263246
}

src/components/layout/areas/MlEphantConversationPane.spec.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ vi.mock('@src/lib/desktop', () => ({
1717
}))
1818

1919
vi.mock('@src/lib/boot', () => ({
20+
useApp: () => ({
21+
registry: {
22+
reconfigure: vi.fn(),
23+
},
24+
}),
2025
useSingletons: () => ({
2126
kclManager: {
2227
astSignal: { value: null },

src/lib/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ import {
7676
commandSystemService,
7777
provideCommand,
7878
} from '@src/registry/contracts/commands'
79+
import { engineSceneRuntimeExtensionsSlot } from '@src/registry/contracts/engineScene'
7980
import { executingEditorService } from '@src/registry/contracts/executingEditor'
8081
import { keymapService } from '@src/registry/contracts/keymap'
8182
import { layoutContributionsValueSpec } from '@src/registry/contracts/layout'
@@ -163,6 +164,7 @@ function createAppRegistryItems({
163164
: []),
164165
appCommandsSlot.of(),
165166
appRegistryServicesSlot.of(),
167+
engineSceneRuntimeExtensionsSlot.of(),
166168
...coreRegistryItems,
167169
]
168170
}

src/registry/contracts/engineScene.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineContract, defineValueSpec } from '@kittycad/registry'
1+
import { Slot, defineContract, defineValueSpec } from '@kittycad/registry'
22
import type { modelingMachine } from '@src/machines/modelingMachine'
33
import type { ComponentType, Dispatch, SetStateAction } from 'react'
44
import type { EventFrom, StateFrom } from 'xstate'
@@ -18,6 +18,8 @@ export const engineSceneViewExtensionZones = [
1818
export type EngineSceneViewExtensionZone =
1919
(typeof engineSceneViewExtensionZones)[number]
2020

21+
export const engineSceneRuntimeExtensionsSlot = new Slot()
22+
2123
export type EngineSceneExtensionContext = {
2224
modelingState: StateFrom<typeof modelingMachine>
2325
modelingSend: (event: EventFrom<typeof modelingMachine>) => void

0 commit comments

Comments
 (0)