import { describe, expect, it } from 'vitest'; import { createMinimapModel, fitViewportToBounds, fitViewportToLayers, getCanvasDropPoint, getCanvasPointFromClient, getWorldPointFromClient, moveGenerationFrameFromDrag, moveGenerationFramesFromDrag, moveDragSelectedLayers, moveLayersFromDrag, moveViewportFromMinimapDrag, moveViewportFromMinimapPointer, moveViewportFromPan, scaleViewportFromScreenPoint, scrollViewportVertically, selectCanvasObjectsInsideMarquee, selectLayersInsideMarquee, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; import type { CanvasGenerationDialogState, CanvasLayer, DragState, } from './ImageCanvasEditorTypes'; import { getCanvasGenerationSelectionId } from './ImageCanvasSelectionModel'; function createLayer(overrides: Partial): CanvasLayer { const id = overrides.id ?? 'layer-a'; return { id, resourceId: `resource-${id}`, title: id, src: `data:image/png;base64,${id}`, x: 100, y: 120, width: 200, height: 100, originalWidth: 200, originalHeight: 100, zIndex: 1, sourceType: 'uploaded', ...overrides, }; } function createGenerationDialog( overrides: Partial = {}, ): CanvasGenerationDialogState { const id = overrides.id ?? 'dialog-a'; return { id, mode: 'generate', prompt: id, status: 'idle', placeholder: { x: 100, y: 120, width: 200, height: 100, originalWidth: 200, originalHeight: 100, }, ...overrides, }; } describe('ImageCanvasInteractionModel', () => { it('fits layers into the visible canvas without upscaling past 100%', () => { const viewport = fitViewportToLayers({ layers: [ createLayer({ id: 'left', x: 0, y: 0, width: 400, height: 300 }), createLayer({ id: 'right', x: 600, y: 100, width: 200, height: 200 }), ], canvasSize: { width: 900, height: 640 }, }); expect(viewport).toEqual({ x: 50, y: 170, scale: 1, }); expect( fitViewportToLayers({ layers: [createLayer({ width: 50000, height: 30000 })], canvasSize: { width: 900, height: 640 }, })?.scale, ).toBe(0.025); }); it('fits bounds inside reserved viewport insets', () => { const viewport = fitViewportToBounds({ bounds: { x: 100, y: 100, width: 400, height: 300 }, canvasSize: { width: 900, height: 640 }, insets: { top: 80 }, padding: 20, }); expect(viewport).toEqual({ x: 150, y: 110, scale: 1, }); }); it('maps client points to canvas and world coordinates with safe fallbacks', () => { const rect = { left: 100, top: 50, right: 700, bottom: 450 }; expect( getCanvasPointFromClient({ clientX: 260, clientY: 190, rect }), ).toEqual({ x: 160, y: 140 }); expect( getCanvasPointFromClient({ clientX: 80, clientY: 190, rect }), ).toBeNull(); expect( getCanvasDropPoint({ clientX: 80, clientY: 190, rect, canvasSize: { width: 900, height: 640 }, }), ).toEqual({ x: 450, y: 320 }); expect( getWorldPointFromClient({ clientX: 260, clientY: 190, rect, viewport: { x: -40, y: 20, scale: 2 }, }), ).toEqual({ x: 100, y: 60 }); }); it('scrolls vertically and zooms around a screen point', () => { const viewport = { x: 10, y: 20, scale: 1 }; expect(scrollViewportVertically(viewport, 120)).toEqual({ x: 10, y: -100, scale: 1, }); expect( scaleViewportFromScreenPoint({ viewport, nextScale: 2, screenPoint: { x: 210, y: 120 }, }), ).toEqual({ x: -190, y: -80, scale: 2 }); const zoomedViewport = zoomViewportFromWheel({ viewport, deltaY: -120, screenPoint: { x: 210, y: 120 }, }); expect(zoomedViewport.x).toBeCloseTo(-10); expect(zoomedViewport.y).toBeCloseTo(10); expect(zoomedViewport.scale).toBe(1.1); expect( zoomViewportFromWheel({ viewport: { x: 10, y: 20, scale: 0.03 }, deltaY: 120, screenPoint: { x: 210, y: 120 }, }).scale, ).toBe(0.027); expect( zoomViewportFromWheel({ viewport: { x: 10, y: 20, scale: 0.027 }, deltaY: 120, screenPoint: { x: 210, y: 120 }, }).scale, ).toBe(0.025); }); it('selects layers intersecting the marquee in screen space', () => { const selectedIds = selectLayersInsideMarquee({ marquee: { pointerId: 1, startX: 90, startY: 90, currentX: 90, currentY: 90, }, currentPoint: { x: 360, y: 260 }, viewport: { x: 20, y: 10, scale: 1 }, layers: [ createLayer({ id: 'inside', x: 100, y: 120 }), createLayer({ id: 'outside', x: 600, y: 600 }), ], }); expect(selectedIds).toEqual(['inside']); expect( selectCanvasObjectsInsideMarquee({ marquee: { pointerId: 1, startX: 90, startY: 90, currentX: 90, currentY: 90, }, currentPoint: { x: 360, y: 260 }, viewport: { x: 20, y: 10, scale: 1 }, layers: [createLayer({ id: 'inside', x: 100, y: 120 })], generationDialogs: [ createGenerationDialog({ id: 'dialog-inside', placeholder: { x: 130, y: 130, width: 80, height: 80, originalWidth: 80, originalHeight: 80, }, }), createGenerationDialog({ id: 'dialog-outside', placeholder: { x: 900, y: 900, width: 80, height: 80, originalWidth: 80, originalHeight: 80, }, }), ], }), ).toEqual(['inside', getCanvasGenerationSelectionId('dialog-inside')]); }); it('moves pan, generation frames, and layer groups from drag state', () => { expect( moveViewportFromPan( { kind: 'pan', pointerId: 1, startClientX: 100, startClientY: 200, startViewport: { x: 10, y: 20, scale: 1 }, }, { x: 140, y: 170 }, ), ).toEqual({ x: 50, y: -10, scale: 1 }); expect( moveGenerationFrameFromDrag( { kind: 'generation-frame', dialogId: 'dialog-1', dialogIds: ['dialog-1'], layerIds: [], pointerId: 1, startClientX: 100, startClientY: 200, startFrameX: 300, startFrameY: 400, startFrames: [{ id: 'dialog-1', x: 300, y: 400 }], startLayers: [], startScale: 2, }, { x: 140, y: 170 }, ), ).toEqual({ x: 320, y: 385 }); const layers = [ createLayer({ id: 'moving', x: 90, y: 90, width: 100, height: 100 }), createLayer({ id: 'follower', x: 220, y: 90, width: 100, height: 100 }), createLayer({ id: 'anchor', x: 300, y: 100, width: 100, height: 100 }), ]; const result = moveLayersFromDrag({ layers, pointer: { x: 210, y: 100 }, dragState: { kind: 'layer', pointerId: 1, layerId: 'moving', layerIds: ['moving', 'follower'], startClientX: 100, startClientY: 100, startLayerX: 90, startLayerY: 90, startLayers: [ { id: 'moving', x: 90, y: 90 }, { id: 'follower', x: 220, y: 90 }, ], startScale: 1, }, }); expect(result?.layers.find((layer) => layer.id === 'moving')).toMatchObject({ x: 200, y: 100, }); expect(result?.layers.find((layer) => layer.id === 'follower')).toMatchObject({ x: 330, y: 100, }); expect(result?.snapGuide).toEqual({ vertical: 300, horizontal: 100, }); expect(result?.snapOffset).toEqual({ x: 0, y: 10, }); const generationDialogs = [ createGenerationDialog({ id: 'dialog-1' }), createGenerationDialog({ id: 'dialog-2', placeholder: { x: 400, y: 120, width: 200, height: 100, originalWidth: 200, originalHeight: 100, }, }), ]; expect( moveGenerationFramesFromDrag({ generationDialogs, pointer: { x: 140, y: 170 }, snapOffset: { x: 6, y: -4 }, dragState: { kind: 'generation-frame', dialogId: 'dialog-1', dialogIds: ['dialog-1', 'dialog-2'], layerIds: ['moving'], pointerId: 1, startClientX: 100, startClientY: 200, startFrameX: 100, startFrameY: 120, startFrames: [ { id: 'dialog-1', x: 100, y: 120 }, { id: 'dialog-2', x: 400, y: 120 }, ], startLayers: [{ id: 'moving', x: 90, y: 90 }], startScale: 2, }, }).generationDialogs.map((dialog) => [ dialog.id, dialog.placeholder?.x, dialog.placeholder?.y, ]), ).toEqual([ ['dialog-1', 126, 101], ['dialog-2', 426, 101], ]); expect( moveDragSelectedLayers({ layers, pointer: { x: 140, y: 170 }, snapOffset: { x: 6, y: -4 }, dragState: { kind: 'generation-frame', dialogId: 'dialog-1', dialogIds: ['dialog-1'], layerIds: ['moving'], pointerId: 1, startClientX: 100, startClientY: 200, startFrameX: 100, startFrameY: 120, startFrames: [{ id: 'dialog-1', x: 100, y: 120 }], startLayers: [{ id: 'moving', x: 90, y: 90 }], startScale: 2, }, }).find((layer) => layer.id === 'moving'), ).toMatchObject({ x: 116, y: 71 }); }); it('snaps generation frames to nearby layer guides while dragging', () => { const [anchorLayer] = [ createLayer({ id: 'anchor', x: 420, y: 300, width: 120, height: 100 }), ]; const [movingDialog] = [ createGenerationDialog({ id: 'dialog-moving', placeholder: { x: 100, y: 120, width: 120, height: 100, originalWidth: 120, originalHeight: 100, }, }), ]; const result = moveGenerationFramesFromDrag({ layers: [anchorLayer], generationDialogs: [movingDialog], pointer: { x: 414, y: 292 }, dragState: { kind: 'generation-frame', dialogId: 'dialog-moving', dialogIds: ['dialog-moving'], layerIds: [], pointerId: 1, startClientX: 100, startClientY: 120, startFrameX: 100, startFrameY: 120, startFrames: [{ id: 'dialog-moving', x: 100, y: 120 }], startLayers: [], startScale: 1, }, }); expect(result.generationDialogs[0]?.placeholder).toMatchObject({ x: 420, y: 300, }); expect(result.snapGuide).toEqual({ vertical: 420, horizontal: 300, }); }); it('builds minimap projection and moves the viewport from minimap interactions', () => { const layers = [ createLayer({ id: 'one', x: 100, y: 100, width: 200, height: 100 }), createLayer({ id: 'two', x: 700, y: 400, width: 100, height: 100 }), ]; const minimap = createMinimapModel({ layers, viewport: { x: -100, y: -50, scale: 1 }, canvasSize: { width: 900, height: 640 }, }); expect(minimap?.layers).toHaveLength(2); expect(minimap?.viewport.width).toBeGreaterThan(2); const nextViewport = moveViewportFromMinimapPointer({ viewport: { x: -100, y: -50, scale: 1 }, canvasSize: { width: 900, height: 640 }, minimapModel: minimap!, pointer: { x: 100, y: 66 }, }); expect(nextViewport.x).not.toBe(-100); expect(nextViewport.y).not.toBe(-50); const dragState: Extract = { kind: 'minimap', pointerId: 1, startClientX: 100, startClientY: 100, startViewport: { x: -100, y: -50, scale: 1 }, minimapScale: minimap!.scale, moved: true, }; const draggedViewport = moveViewportFromMinimapDrag(dragState, { x: 103, y: 102, }); expect(draggedViewport.x).toBeLessThan(-100); expect(draggedViewport.y).toBeLessThan(-50); }); });