import { describe, expect, it } from 'vitest'; import { createMinimapModel, fitViewportToLayers, getCanvasDropPoint, getCanvasPointFromClient, getWorldPointFromClient, moveGenerationFrameFromDrag, moveLayersFromDrag, moveViewportFromMinimapDrag, moveViewportFromMinimapPointer, moveViewportFromPan, scaleViewportFromScreenPoint, scrollViewportVertically, selectLayersInsideMarquee, zoomViewportFromWheel, } from './ImageCanvasInteractionModel'; import type { CanvasLayer, DragState } from './ImageCanvasEditorTypes'; 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, }; } 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: 5000, height: 3000 })], canvasSize: { width: 900, height: 640 }, })?.scale, ).toBe(0.24); }); 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); }); 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']); }); 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', pointerId: 1, startClientX: 100, startClientY: 200, startFrameX: 300, startFrameY: 400, 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: 90, }); expect(result?.layers.find((layer) => layer.id === 'follower')).toMatchObject({ x: 330, y: 90, }); expect(result?.snapGuide).toEqual({ vertical: 300, horizontal: 90, }); }); 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); }); });