diff --git a/e2e/playwright/snapshot-tests.spec.ts-snapshots/03-sketch-drawn-light.png b/e2e/playwright/snapshot-tests.spec.ts-snapshots/03-sketch-drawn-light.png index bb5fe442ec4..bb5a12cd020 100644 Binary files a/e2e/playwright/snapshot-tests.spec.ts-snapshots/03-sketch-drawn-light.png and b/e2e/playwright/snapshot-tests.spec.ts-snapshots/03-sketch-drawn-light.png differ diff --git a/src/components/MlEphantConversation.spec.tsx b/src/components/MlEphantConversation.spec.tsx index b0688db7e83..e2395c758f6 100644 --- a/src/components/MlEphantConversation.spec.tsx +++ b/src/components/MlEphantConversation.spec.tsx @@ -65,6 +65,7 @@ import { engineSceneRuntimeExtensionsSlot, engineSceneStreamLayersValueSpec, } from '@src/registry/contracts/engineScene' +import { ZOODLE_BRUSH_SIZE_DEFAULT_PX } from '@src/registry/contracts/zoodle' const configureTestRegistry = () => { const registry = new Registry() @@ -1065,10 +1066,34 @@ describe('MlEphantConversation', () => { test('displays screenshot annotation button', () => { renderConversation() + const zoodleButton = screen.getByTestId( + 'ml-ephant-annotate-screenshot-button' + ) + expect(zoodleButton).toBeInTheDocument() + expect(zoodleButton).toHaveAttribute('aria-pressed', 'false') + expect(screen.getByText('Zoodle')).toBeInTheDocument() + }) + + test('marks screenshot annotation button active and cancels on second click', () => { + renderConversation() + + const zoodleButton = screen.getByTestId( + 'ml-ephant-annotate-screenshot-button' + ) + + fireEvent.click(zoodleButton) + + expect(zoodleButton).toHaveAttribute('aria-pressed', 'true') expect( - screen.getByTestId('ml-ephant-annotate-screenshot-button') + screen.getByTestId('viewport-annotation-overlay') ).toBeInTheDocument() - expect(screen.getByText('Zoodle')).toBeInTheDocument() + + fireEvent.click(zoodleButton) + + expect(zoodleButton).toHaveAttribute('aria-pressed', 'false') + expect( + screen.queryByTestId('viewport-annotation-overlay') + ).not.toBeInTheDocument() }) test('adds annotated viewport screenshot as an attachment', async () => { @@ -1101,6 +1126,38 @@ describe('MlEphantConversation', () => { expect( screen.getByTestId('viewport-annotation-overlay') ).toBeInTheDocument() + expect( + screen.getByTestId('viewport-annotation-tool-drawOrange') + ).toHaveAttribute('aria-pressed', 'true') + expect( + screen.getByTestId('viewport-annotation-brush-size-slider') + ).toHaveValue(String(ZOODLE_BRUSH_SIZE_DEFAULT_PX)) + expect( + screen.getByTestId('viewport-annotation-brush-size-dot') + ).toHaveStyle({ + width: `${ZOODLE_BRUSH_SIZE_DEFAULT_PX}px`, + height: `${ZOODLE_BRUSH_SIZE_DEFAULT_PX}px`, + }) + + fireEvent.change( + screen.getByTestId('viewport-annotation-brush-size-slider'), + { + target: { value: '6' }, + } + ) + + expect( + screen.getByTestId('viewport-annotation-brush-size-slider') + ).toHaveValue('6') + expect( + screen.getByTestId('viewport-annotation-brush-size-dot') + ).toHaveStyle({ width: '6px', height: '6px' }) + + fireEvent.click(screen.getByTestId('viewport-annotation-tool-erase')) + + expect( + screen.getByTestId('viewport-annotation-tool-erase') + ).toHaveAttribute('aria-pressed', 'true') const sendButton = screen.getByTestId('viewport-annotation-send-button') await waitFor(() => expect(sendButton).not.toBeDisabled()) diff --git a/src/components/MlEphantConversation.tsx b/src/components/MlEphantConversation.tsx index 69964448465..72bf69bb523 100644 --- a/src/components/MlEphantConversation.tsx +++ b/src/components/MlEphantConversation.tsx @@ -163,6 +163,7 @@ export interface MlEphantExtraInputsProps { onCaptureScreenshot: () => void onAnnotateScreenshot: () => void attachmentsDisabled?: boolean + isZoodleActive?: boolean modeOptions?: MlCopilotModeOption[] } @@ -216,8 +217,13 @@ export const MlEphantExtraInputs = (props: MlEphantExtraInputsProps) => { type="button" data-testid="ml-ephant-annotate-screenshot-button" onClick={props.onAnnotateScreenshot} - disabled={props.attachmentsDisabled} - className="h-7 w-7 bg-default flex items-center justify-center rounded-sm m-0 p-0 flex-none disabled:opacity-60" + disabled={props.attachmentsDisabled && !props.isZoodleActive} + aria-pressed={props.isZoodleActive ?? false} + className={`h-7 w-7 flex items-center justify-center rounded-sm m-0 p-0 flex-none disabled:opacity-60 ${ + props.isZoodleActive + ? 'bg-ml-green text-chalkboard-100 hover:bg-ml-green' + : 'bg-default' + }`} aria-label="Zoodle" > @@ -278,14 +284,10 @@ export const MlEphantConversationInput = ( const lastModeScopeKey = useRef(props.modeScopeKey) const [attachments, setAttachments] = useState([]) const [isDraggingOver, setIsDraggingOver] = useState(false) - const zoodleRuntimeExtensionActive = useRef(false) + const [isZoodleActive, setIsZoodleActive] = useState(false) const stopZoodleRuntimeExtension = useCallback(() => { - if (!zoodleRuntimeExtensionActive.current) { - return - } - - zoodleRuntimeExtensionActive.current = false + setIsZoodleActive(false) deactivateZoodleRuntimeExtension(registry) }, [registry]) @@ -409,11 +411,16 @@ export const MlEphantConversationInput = ( } const onAnnotateScreenshot = () => { + if (isZoodleActive) { + stopZoodleRuntimeExtension() + return + } + if (props.disabled) return try { const dataUrl = takeViewportScreenshot() if (!dataUrl) return - zoodleRuntimeExtensionActive.current = true + setIsZoodleActive(true) activateZoodleRuntimeExtension(registry, { imageDataUrl: dataUrl, onCancel: stopZoodleRuntimeExtension, @@ -426,6 +433,7 @@ export const MlEphantConversationInput = ( }, }) } catch (e) { + setIsZoodleActive(false) console.error('Failed to capture viewport screenshot for annotation', e) } } @@ -577,6 +585,7 @@ export const MlEphantConversationInput = ( onCaptureScreenshot={onCaptureScreenshot} onAnnotateScreenshot={onAnnotateScreenshot} attachmentsDisabled={props.disabled} + isZoodleActive={isZoodleActive} modeOptions={props.modeOptions} />
diff --git a/src/components/ViewportAnnotationOverlay.tsx b/src/components/ViewportAnnotationOverlay.tsx index 9767e1c91b9..6d3a09825e5 100644 --- a/src/components/ViewportAnnotationOverlay.tsx +++ b/src/components/ViewportAnnotationOverlay.tsx @@ -1,5 +1,13 @@ -import { TRIM_PREVIEW_LINE_COLOR } from '@src/lib/constants' -import { getTrimPreviewLineWidth } from '@src/lib/freehandLineDrawing' +import { useSignals } from '@preact/signals-react/runtime' +import { + ZOODLE_BRUSH_SIZE_MAX_PX, + ZOODLE_BRUSH_SIZE_MIN_PX, + type ZoodleDrawToolDefinition, + type ZoodleService, + type ZoodleToolDefinition, + type ZoodleToolKey, + zoodleToolKeys, +} from '@src/registry/contracts/zoodle' import type { PointerEvent as ReactPointerEvent, SyntheticEvent } from 'react' import { useEffect, useRef, useState } from 'react' @@ -45,17 +53,159 @@ const stopOverlayEventPropagation = (event: SyntheticEvent) => { event.stopPropagation() } +const getDrawToolStrokeStyle = ( + tool: ZoodleDrawToolDefinition, + canvas: HTMLCanvasElement +) => { + if (tool.color === 'currentColor') { + return getComputedStyle(canvas).color + } + + return tool.color +} + +const getDrawToolColorClassName = (tool: ZoodleDrawToolDefinition) => + tool.colorClassName || '' + +const toolButtonClassName = (tool: ZoodleToolDefinition, isActive: boolean) => + `m-0 flex h-7 items-center justify-center rounded-sm border p-0 text-xs ${ + tool.type === 'erase' ? 'w-auto px-2' : 'w-7' + } ${ + isActive + ? 'border-chalkboard-50 bg-chalkboard-20 dark:border-chalkboard-60 dark:bg-chalkboard-80' + : 'border-transparent bg-transparent hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80' + }` + +const toolbarButtonClassName = + 'm-0 h-7 whitespace-nowrap rounded-sm border-none bg-transparent px-2 text-xs hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80' + +interface ZoodleToolbarProps { + activeToolKey: ZoodleToolKey + activeTool: ZoodleToolDefinition + brushSize: number + disabledSend: boolean + onCancel: () => void + onClear: () => void + onSend: () => void + zoodle: ZoodleService +} + +const ZoodleToolbar = (props: ZoodleToolbarProps) => ( +
+
+
+ {zoodleToolKeys.map((toolKey: ZoodleToolKey) => { + const tool = props.zoodle.toolDefinitions[toolKey] + const isActive = props.activeToolKey === toolKey + + return ( + + ) + })} +
+
+
+
+ + + +
+
+
+) + interface ViewportAnnotationOverlayProps { imageDataUrl: string onCancel: () => void onSend: (annotatedDataUrl: string) => void + zoodle: ZoodleService } export const ViewportAnnotationOverlay = ( props: ViewportAnnotationOverlayProps ) => { + useSignals() const canvasRef = useRef(null) const lastPoint = useRef<{ x: number; y: number } | null>(null) + const activeToolKey = props.zoodle.activeToolKey.value + const activeTool = props.zoodle.toolDefinitions[activeToolKey] + const brushSize = props.zoodle.brushSize.value const [imageSize, setImageSize] = useState<{ width: number height: number @@ -105,8 +255,15 @@ export const ViewportAnnotationOverlay = ( const nextPoint = getCanvasPoint(canvas, event) const rect = canvas.getBoundingClientRect() const pixelRatio = rect.width > 0 ? canvas.width / rect.width : 1 - ctx.strokeStyle = TRIM_PREVIEW_LINE_COLOR - ctx.lineWidth = getTrimPreviewLineWidth(pixelRatio) + ctx.globalCompositeOperation = + activeTool.type === 'erase' ? 'destination-out' : 'source-over' + ctx.strokeStyle = + activeTool.type === 'draw' + ? getDrawToolStrokeStyle(activeTool, canvas) + : '#000000' + const lineWidthMultiplier = + 'lineWidthMultiplier' in activeTool ? activeTool.lineWidthMultiplier : 1 + ctx.lineWidth = brushSize * pixelRatio * lineWidthMultiplier ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.beginPath() @@ -184,11 +341,14 @@ export const ViewportAnnotationOverlay = ( }) } + const activeDrawToolClassName = + activeTool.type === 'draw' ? getDrawToolColorClassName(activeTool) : '' + return (
-
- - - -
+
) } diff --git a/src/registry/contracts/zoodle.ts b/src/registry/contracts/zoodle.ts new file mode 100644 index 00000000000..04be218fb88 --- /dev/null +++ b/src/registry/contracts/zoodle.ts @@ -0,0 +1,77 @@ +import { defineContract, defineService } from '@kittycad/registry' +import type { ReadonlySignal } from '@preact/signals-core' +import { TRIM_PREVIEW_LINE_COLOR } from '@src/lib/constants' + +export const ZOODLE_BRUSH_SIZE_MIN_PX = 1 +export const ZOODLE_BRUSH_SIZE_DEFAULT_PX = 2 +export const ZOODLE_BRUSH_SIZE_MAX_PX = 12 + +export type ZoodleDrawToolDefinition = { + type: 'draw' + label: string + color: string + colorClassName?: string + lineWidthMultiplier?: number +} + +export type ZoodleEraseToolDefinition = { + type: 'erase' + label: string + lineWidthMultiplier: number +} + +export type ZoodleToolDefinition = + | ZoodleDrawToolDefinition + | ZoodleEraseToolDefinition + +export const zoodleToolDefinitions = { + drawOrange: { + type: 'draw', + label: 'Orange', + color: TRIM_PREVIEW_LINE_COLOR, + }, + drawGreen: { + type: 'draw', + label: 'Green', + color: '#29FFA4', + }, + drawZooBlue: { + type: 'draw', + label: 'Zoo blue', + color: 'currentColor', + colorClassName: 'text-primary', + }, + drawDefault: { + type: 'draw', + label: 'Default', + color: 'currentColor', + colorClassName: 'text-default', + }, + erase: { + type: 'erase', + label: 'Erase', + lineWidthMultiplier: 4, + }, +} as const satisfies Record + +export type ZoodleToolKey = keyof typeof zoodleToolDefinitions + +export const defaultZoodleToolKey: ZoodleToolKey = 'drawOrange' + +export const zoodleToolKeys = Object.keys( + zoodleToolDefinitions +) as ZoodleToolKey[] + +export interface ZoodleService { + readonly toolDefinitions: typeof zoodleToolDefinitions + readonly activeToolKey: ReadonlySignal + readonly brushSize: ReadonlySignal + equipTool(toolKey: ZoodleToolKey): void + setBrushSize(brushSize: number): void +} + +export const zoodleContract = defineContract({ + zoodleService: defineService('zoodle'), +}) + +export const { zoodleService } = zoodleContract diff --git a/src/registry/extensions/engineScene/zoodleRuntimeExtension.spec.ts b/src/registry/extensions/engineScene/zoodleRuntimeExtension.spec.ts new file mode 100644 index 00000000000..25546afbf6e --- /dev/null +++ b/src/registry/extensions/engineScene/zoodleRuntimeExtension.spec.ts @@ -0,0 +1,88 @@ +import { Registry } from '@kittycad/registry' +import { + engineSceneRuntimeExtensionsSlot, + engineSceneStreamClassNamesValueSpec, + engineSceneStreamLayersValueSpec, + mergeEngineSceneClassNames, +} from '@src/registry/contracts/engineScene' +import { + ZOODLE_BRUSH_SIZE_DEFAULT_PX, + ZOODLE_BRUSH_SIZE_MAX_PX, + ZOODLE_BRUSH_SIZE_MIN_PX, + zoodleService, +} from '@src/registry/contracts/zoodle' +import { describe, expect, it, vi } from 'vitest' +import engineSceneExtension from '.' +import { activateZoodleRuntimeExtension } from './zoodleRuntimeExtension' + +describe('zoodle runtime extension', () => { + it('insets the engine stream while Zoodle is active', () => { + const registry = new Registry() + registry.configure([ + engineSceneExtension, + engineSceneRuntimeExtensionsSlot.of(), + ]) + + activateZoodleRuntimeExtension(registry, { + imageDataUrl: 'data:image/png;base64,aGVsbG8=', + onCancel: vi.fn(), + onSend: vi.fn(), + }) + + expect(registry.get(engineSceneStreamLayersValueSpec)).toHaveLength(1) + expect( + mergeEngineSceneClassNames( + registry.get(engineSceneStreamClassNamesValueSpec) + ) + ).toBe( + 'absolute inset-4 z-20 rounded-lg transition-all duration-150 ease-out before:content-[""] before:absolute before:-inset-4 before:bg-ml-green' + ) + }) + + it('provides a session-scoped Zoodle tool service', () => { + const registry = new Registry() + registry.configure([ + engineSceneExtension, + engineSceneRuntimeExtensionsSlot.of(), + ]) + + activateZoodleRuntimeExtension(registry, { + imageDataUrl: 'data:image/png;base64,aGVsbG8=', + onCancel: vi.fn(), + onSend: vi.fn(), + }) + + const zoodle = registry.get(zoodleService) + + expect(zoodle.activeToolKey.value).toBe('drawOrange') + expect(zoodle.brushSize.value).toBe(ZOODLE_BRUSH_SIZE_DEFAULT_PX) + expect(zoodle.toolDefinitions.drawOrange).toMatchObject({ + type: 'draw', + color: '#ff8800', + }) + expect(zoodle.toolDefinitions.drawZooBlue).toMatchObject({ + type: 'draw', + color: 'currentColor', + colorClassName: 'text-primary', + }) + expect(zoodle.toolDefinitions.drawDefault).toMatchObject({ + type: 'draw', + color: 'currentColor', + colorClassName: 'text-default', + }) + expect(zoodle.toolDefinitions.erase).toMatchObject({ + type: 'erase', + lineWidthMultiplier: 4, + }) + + zoodle.equipTool('erase') + + expect(zoodle.activeToolKey.value).toBe('erase') + + zoodle.setBrushSize(ZOODLE_BRUSH_SIZE_MAX_PX + 1) + expect(zoodle.brushSize.value).toBe(ZOODLE_BRUSH_SIZE_MAX_PX) + + zoodle.setBrushSize(ZOODLE_BRUSH_SIZE_MIN_PX - 1) + expect(zoodle.brushSize.value).toBe(ZOODLE_BRUSH_SIZE_MIN_PX) + }) +}) diff --git a/src/registry/extensions/engineScene/zoodleRuntimeExtension.tsx b/src/registry/extensions/engineScene/zoodleRuntimeExtension.tsx index eff951687c4..d303a608669 100644 --- a/src/registry/extensions/engineScene/zoodleRuntimeExtension.tsx +++ b/src/registry/extensions/engineScene/zoodleRuntimeExtension.tsx @@ -2,8 +2,11 @@ import { type RegistryLike, defineRegistryItem, provide, + provideService, } from '@kittycad/registry' +import { signal } from '@preact/signals-core' import { ViewportAnnotationOverlay } from '@src/components/ViewportAnnotationOverlay' +import { clamp } from '@src/lib/utils' import { defineEngineSceneStreamClassName, defineEngineSceneStreamLayer, @@ -11,6 +14,16 @@ import { engineSceneStreamClassNamesValueSpec, engineSceneStreamLayersValueSpec, } from '@src/registry/contracts/engineScene' +import { + ZOODLE_BRUSH_SIZE_DEFAULT_PX, + ZOODLE_BRUSH_SIZE_MAX_PX, + ZOODLE_BRUSH_SIZE_MIN_PX, + type ZoodleService, + type ZoodleToolKey, + defaultZoodleToolKey, + zoodleService, + zoodleToolDefinitions, +} from '@src/registry/contracts/zoodle' export interface ZoodleRuntimeExtensionSession { imageDataUrl: string @@ -21,21 +34,53 @@ export interface ZoodleRuntimeExtensionSession { const zoodleStreamStackClassName = defineEngineSceneStreamClassName({ id: 'zookeeper.zoodle.stream-stack', order: 100, - className: 'z-20', + className: + 'inset-4 z-20 rounded-lg transition-all duration-150 ease-out before:content-[""] before:absolute before:-inset-4 before:bg-ml-green', }) +const clampZoodleBrushSize = (brushSize: number) => { + const finiteBrushSize = Number.isFinite(brushSize) + ? brushSize + : ZOODLE_BRUSH_SIZE_DEFAULT_PX + + return clamp( + finiteBrushSize, + ZOODLE_BRUSH_SIZE_MIN_PX, + ZOODLE_BRUSH_SIZE_MAX_PX + ) +} + +function createZoodleService(): ZoodleService { + const activeToolKey = signal(defaultZoodleToolKey) + const brushSize = signal(ZOODLE_BRUSH_SIZE_DEFAULT_PX) + + return { + toolDefinitions: zoodleToolDefinitions, + activeToolKey, + brushSize, + equipTool(toolKey) { + activeToolKey.value = toolKey + }, + setBrushSize(nextBrushSize) { + brushSize.value = clampZoodleBrushSize(nextBrushSize) + }, + } +} + export function createZoodleRuntimeExtension( session: ZoodleRuntimeExtensionSession ) { + const zoodle = createZoodleService() const zoodleAnnotationLayer = defineEngineSceneStreamLayer({ id: 'zookeeper.zoodle.annotation-layer', order: 100, - wrapperClassName: 'z-50', + wrapperClassName: 'z-50 rounded-lg overflow-hidden', Component: () => ( ), }) @@ -54,6 +99,7 @@ export function createZoodleRuntimeExtension( key: zoodleAnnotationLayer.id, }), ], + providesServices: [provideService(zoodleService, zoodle)], }) }