From 62f79110e820b5ebda31db576375999092b875b0 Mon Sep 17 00:00:00 2001 From: jlightner Date: Thu, 26 Mar 2026 05:32:04 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Built=20canvas=20type=20system,=20useCa?= =?UTF-8?q?nvasState=20hook=20with=20undo/redo,=20art=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "app/src/types/canvas.ts" - "app/src/hooks/useCanvasState.ts" - "app/src/hooks/__tests__/useCanvasState.test.ts" - "app/src/utils/artboardShapes.ts" - "app/src/utils/__tests__/artboardShapes.test.ts" - "app/src/utils/alignment.ts" - "app/src/utils/__tests__/alignment.test.ts" - "app/src/components/canvas/ArtboardSetup.tsx" GSD-Task: S02/T01 --- app/package-lock.json | 136 +++++++++- app/package.json | 7 +- app/src/components/canvas/ArtboardSetup.tsx | 152 +++++++++++ .../hooks/__tests__/useCanvasState.test.ts | 241 +++++++++++++++++ app/src/hooks/useCanvasState.ts | 256 ++++++++++++++++++ app/src/test-setup.ts | 1 + app/src/types/canvas.ts | 101 +++++++ app/src/utils/__tests__/alignment.test.ts | 142 ++++++++++ .../utils/__tests__/artboardShapes.test.ts | 104 +++++++ app/src/utils/alignment.ts | 116 ++++++++ app/src/utils/artboardShapes.ts | 96 +++++++ 11 files changed, 1346 insertions(+), 6 deletions(-) create mode 100644 app/src/components/canvas/ArtboardSetup.tsx create mode 100644 app/src/hooks/__tests__/useCanvasState.test.ts create mode 100644 app/src/hooks/useCanvasState.ts create mode 100644 app/src/types/canvas.ts create mode 100644 app/src/utils/__tests__/alignment.test.ts create mode 100644 app/src/utils/__tests__/artboardShapes.test.ts create mode 100644 app/src/utils/alignment.ts create mode 100644 app/src/utils/artboardShapes.ts diff --git a/app/package-lock.json b/app/package-lock.json index 63213d5..27a8dc4 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -8,8 +8,10 @@ "name": "app", "version": "0.0.0", "dependencies": { + "konva": "^10.2.3", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-konva": "^19.2.3" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -27,7 +29,8 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", - "vitest": "^4.1.1" + "vitest": "^4.1.1", + "vitest-canvas-mock": "^1.1.4" } }, "node_modules/@adobe/css-tools": { @@ -1249,7 +1252,6 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1265,6 +1267,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-HZOXsKT0tGI9LlUw2LuedXsVeB88wFa536vVL0M6vE8zN63nI+sSr1ByxmPToP5K5bukaVscyeCJcF9guVNJ1g==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", @@ -1996,11 +2007,17 @@ "dev": true, "license": "MIT" }, + "node_modules/cssfontparser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", + "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -2596,6 +2613,27 @@ "dev": true, "license": "ISC" }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2724,6 +2762,26 @@ "json-buffer": "3.0.1" } }, + "node_modules/konva": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/konva/-/konva-10.2.3.tgz", + "integrity": "sha512-NDGeIxm2nsQcp6oqZKS9T764JEi53RpQvpUxV2EK7Awm49fwdd1+EB1Nq1nyspRc0hOAKyKssoTFvPaKwiSUog==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -3083,6 +3141,16 @@ "node": "*" } }, + "node_modules/moo-color": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", + "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "^1.1.4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3365,6 +3433,52 @@ "license": "MIT", "peer": true }, + "node_modules/react-konva": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.2.3.tgz", + "integrity": "sha512-VsO5CJZwUo12xFa33UEIDOQn6ZZBeE6jlkStGFvpR/3NiDA/9RPQTzw6Ri++C0Pnh3Arco1AehB8qJNv9YCRwg==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.33.0", + "its-fine": "^2.0.0", + "react-reconciler": "0.33.0", + "scheduler": "0.27.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0 || ^10.0.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -3949,6 +4063,20 @@ } } }, + "node_modules/vitest-canvas-mock": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-1.1.4.tgz", + "integrity": "sha512-4boWHY+STwAxGl1+uwakNNoQky5EjPLC8HuponXNoAscYyT1h/F7RUvTkl4IyF/MiWr3V8Q626je3Iel3eArqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssfontparser": "^1.2.1", + "moo-color": "^1.0.3" + }, + "peerDependencies": { + "vitest": "^3.0.0 || ^4.0.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/app/package.json b/app/package.json index c7dcc3e..714381a 100644 --- a/app/package.json +++ b/app/package.json @@ -10,8 +10,10 @@ "preview": "vite preview" }, "dependencies": { + "konva": "^10.2.3", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-konva": "^19.2.3" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -29,6 +31,7 @@ "typescript": "~5.9.3", "typescript-eslint": "^8.57.0", "vite": "^8.0.1", - "vitest": "^4.1.1" + "vitest": "^4.1.1", + "vitest-canvas-mock": "^1.1.4" } } diff --git a/app/src/components/canvas/ArtboardSetup.tsx b/app/src/components/canvas/ArtboardSetup.tsx new file mode 100644 index 0000000..036a35b --- /dev/null +++ b/app/src/components/canvas/ArtboardSetup.tsx @@ -0,0 +1,152 @@ +/** + * ArtboardSetup — modal overlay for configuring the artboard when entering + * the design canvas view. + * + * Props: + * - onConfirm(config: ArtboardConfig): called when the user clicks "Create" + */ + +import { useState } from 'react'; +import type { ArtboardConfig, ArtboardShape, ArtboardUnit } from '../../types/canvas'; +import { ARTBOARD_PRESETS, artboardClipPath } from '../../utils/artboardShapes'; + +const SHAPES: ArtboardShape[] = [ + 'rect', 'square', 'circle', 'oval', 'shield', 'pennant', 'custom', +]; + +const SHAPE_LABELS: Record = { + rect: 'Rectangle', + square: 'Square', + circle: 'Circle', + oval: 'Oval', + shield: 'Shield', + pennant: 'Pennant', + custom: 'Custom', +}; + +interface ArtboardSetupProps { + onConfirm: (config: ArtboardConfig) => void; +} + +export default function ArtboardSetup({ onConfirm }: ArtboardSetupProps) { + const [shape, setShape] = useState('rect'); + const [width, setWidth] = useState(ARTBOARD_PRESETS.rect.width); + const [height, setHeight] = useState(ARTBOARD_PRESETS.rect.height); + const [unit, setUnit] = useState('inches'); + + const handleShapeChange = (s: ArtboardShape) => { + setShape(s); + const preset = ARTBOARD_PRESETS[s]; + setWidth(preset.width); + setHeight(preset.height); + // Square and circle enforce equal dimensions + if (s === 'square' || s === 'circle') { + setHeight(preset.width); + } + }; + + const handleWidthChange = (v: number) => { + setWidth(v); + if (shape === 'square' || shape === 'circle') setHeight(v); + }; + + const handleHeightChange = (v: number) => { + if (shape === 'square' || shape === 'circle') return; + setHeight(v); + }; + + const handleConfirm = () => { + const config: ArtboardConfig = { shape, width, height, unit }; + const clip = artboardClipPath(config); + if (clip) config.clipPath = clip; + onConfirm(config); + }; + + return ( +
+
+

Set Up Artboard

+ + {/* Shape picker */} +
+ Shape +
+ {SHAPES.map((s) => ( + + ))} +
+
+ + {/* Dimension inputs */} +
+ + +
+ + {/* Units toggle */} +
+ Units + + +
+ + {/* Confirm */} + +
+
+ ); +} diff --git a/app/src/hooks/__tests__/useCanvasState.test.ts b/app/src/hooks/__tests__/useCanvasState.test.ts new file mode 100644 index 0000000..9abffef --- /dev/null +++ b/app/src/hooks/__tests__/useCanvasState.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCanvasState } from '../useCanvasState'; +import type { RectObject, CircleObject } from '../../types/canvas'; + +function makeRect(id: string, overrides: Partial = {}): RectObject { + return { + type: 'rect', + id, + name: `Rect ${id}`, + x: 0, + y: 0, + width: 100, + height: 50, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#fff', + stroke: '#000', + strokeWidth: 1, + ...overrides, + }; +} + +function makeCircle(id: string): CircleObject { + return { + type: 'circle', + id, + name: `Circle ${id}`, + x: 50, + y: 50, + radius: 25, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#fff', + stroke: '#000', + strokeWidth: 1, + }; +} + +describe('useCanvasState', () => { + it('starts with empty objects and no artboard', () => { + const { result } = renderHook(() => useCanvasState()); + expect(result.current.state.objects).toEqual([]); + expect(result.current.state.selectedIds).toEqual([]); + expect(result.current.state.artboard).toBeNull(); + }); + + describe('addObject', () => { + it('adds an object to the canvas', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + expect(result.current.state.objects).toHaveLength(1); + expect(result.current.state.objects[0].id).toBe('r1'); + }); + }); + + describe('removeObject', () => { + it('removes an object by id', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.addObject(makeRect('r2'))); + act(() => result.current.removeObject('r1')); + expect(result.current.state.objects).toHaveLength(1); + expect(result.current.state.objects[0].id).toBe('r2'); + }); + + it('also removes the id from selectedIds', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.selectObjects(['r1'])); + act(() => result.current.removeObject('r1')); + expect(result.current.state.selectedIds).toEqual([]); + }); + }); + + describe('updateObject', () => { + it('merges changes into an existing object', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.updateObject('r1', { x: 42, y: 99 })); + expect(result.current.state.objects[0].x).toBe(42); + expect(result.current.state.objects[0].y).toBe(99); + }); + }); + + describe('selectObjects / deselectAll', () => { + it('sets selected IDs', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.addObject(makeRect('r2'))); + act(() => result.current.selectObjects(['r1', 'r2'])); + expect(result.current.state.selectedIds).toEqual(['r1', 'r2']); + }); + + it('deselects all', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.selectObjects(['r1'])); + act(() => result.current.deselectAll()); + expect(result.current.state.selectedIds).toEqual([]); + }); + }); + + describe('reorderObject', () => { + it('moves an object to a new z-index', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('a'))); + act(() => result.current.addObject(makeRect('b'))); + act(() => result.current.addObject(makeCircle('c'))); + // Move 'a' to the end (topmost) + act(() => result.current.reorderObject('a', 2)); + expect(result.current.state.objects.map((o) => o.id)).toEqual([ + 'b', 'c', 'a', + ]); + }); + + it('clamps toIndex to valid range', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('a'))); + act(() => result.current.addObject(makeRect('b'))); + act(() => result.current.reorderObject('a', 999)); + expect(result.current.state.objects.map((o) => o.id)).toEqual(['b', 'a']); + }); + + it('no-ops for unknown id', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('a'))); + act(() => result.current.reorderObject('nope', 0)); + expect(result.current.state.objects).toHaveLength(1); + }); + }); + + describe('toggleVisibility / toggleLock', () => { + it('toggles visibility', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + expect(result.current.state.objects[0].visible).toBe(true); + act(() => result.current.toggleVisibility('r1')); + expect(result.current.state.objects[0].visible).toBe(false); + act(() => result.current.toggleVisibility('r1')); + expect(result.current.state.objects[0].visible).toBe(true); + }); + + it('toggles lock', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + expect(result.current.state.objects[0].locked).toBe(false); + act(() => result.current.toggleLock('r1')); + expect(result.current.state.objects[0].locked).toBe(true); + }); + }); + + describe('setArtboard', () => { + it('sets the artboard config', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => + result.current.setArtboard({ + shape: 'rect', + width: 4, + height: 6, + unit: 'inches', + }), + ); + expect(result.current.state.artboard).toEqual({ + shape: 'rect', + width: 4, + height: 6, + unit: 'inches', + }); + }); + }); + + describe('undo / redo', () => { + it('undoes the last mutation', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + expect(result.current.state.objects).toHaveLength(1); + act(() => result.current.undo()); + expect(result.current.state.objects).toHaveLength(0); + }); + + it('redoes after undo', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.undo()); + expect(result.current.state.objects).toHaveLength(0); + act(() => result.current.redo()); + expect(result.current.state.objects).toHaveLength(1); + }); + + it('undo is no-op when stack is empty', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.undo()); // should not throw + expect(result.current.state.objects).toHaveLength(0); + }); + + it('redo is no-op when stack is empty', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.redo()); // should not throw + expect(result.current.state.objects).toHaveLength(0); + }); + + it('new mutation clears the redo stack', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.undo()); + // Now add a different object — redo should be gone + act(() => result.current.addObject(makeCircle('c1'))); + act(() => result.current.redo()); // no-op + expect(result.current.state.objects).toHaveLength(1); + expect(result.current.state.objects[0].id).toBe('c1'); + }); + + it('undoes through multiple mutations', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.addObject(makeRect('r2'))); + act(() => result.current.addObject(makeRect('r3'))); + expect(result.current.state.objects).toHaveLength(3); + act(() => result.current.undo()); + expect(result.current.state.objects).toHaveLength(2); + act(() => result.current.undo()); + expect(result.current.state.objects).toHaveLength(1); + act(() => result.current.undo()); + expect(result.current.state.objects).toHaveLength(0); + }); + + it('select/deselect do not push to undo stack', () => { + const { result } = renderHook(() => useCanvasState()); + act(() => result.current.addObject(makeRect('r1'))); + act(() => result.current.selectObjects(['r1'])); + act(() => result.current.deselectAll()); + // undo should revert addObject, not select/deselect + act(() => result.current.undo()); + expect(result.current.state.objects).toHaveLength(0); + }); + }); +}); diff --git a/app/src/hooks/useCanvasState.ts b/app/src/hooks/useCanvasState.ts new file mode 100644 index 0000000..1fb93b1 --- /dev/null +++ b/app/src/hooks/useCanvasState.ts @@ -0,0 +1,256 @@ +/** + * Central canvas state hook. + * + * Architecture: + * - Current state lives in a useReducer (triggers re-renders on mutation). + * - History (for undo / redo) lives in a useRef (never triggers re-renders). + * - Every mutation dispatches through the reducer, which also pushes a snapshot + * onto the undo stack. + */ + +import { useCallback, useReducer, useRef } from 'react'; +import type { + ArtboardConfig, + CanvasObject, + CanvasState, +} from '../types/canvas'; +import type { TraceMetadata } from '../types/engine'; + +// -- Action types ------------------------------------------------------------- + +type CanvasAction = + | { type: 'ADD_OBJECT'; object: CanvasObject } + | { type: 'REMOVE_OBJECT'; id: string } + | { type: 'UPDATE_OBJECT'; id: string; changes: Partial } + | { type: 'SELECT_OBJECTS'; ids: string[] } + | { type: 'DESELECT_ALL' } + | { type: 'REORDER_OBJECT'; id: string; toIndex: number } + | { type: 'TOGGLE_VISIBILITY'; id: string } + | { type: 'TOGGLE_LOCK'; id: string } + | { type: 'SET_ARTBOARD'; config: ArtboardConfig } + | { type: 'RESTORE'; state: CanvasState }; + +// -- Reducer ------------------------------------------------------------------ + +function canvasReducer(state: CanvasState, action: CanvasAction): CanvasState { + switch (action.type) { + case 'ADD_OBJECT': + return { ...state, objects: [...state.objects, action.object] }; + + case 'REMOVE_OBJECT': + return { + ...state, + objects: state.objects.filter((o) => o.id !== action.id), + selectedIds: state.selectedIds.filter((sid) => sid !== action.id), + }; + + case 'UPDATE_OBJECT': + return { + ...state, + objects: state.objects.map((o) => + o.id === action.id ? ({ ...o, ...action.changes } as CanvasObject) : o, + ), + }; + + case 'SELECT_OBJECTS': + return { ...state, selectedIds: action.ids }; + + case 'DESELECT_ALL': + return { ...state, selectedIds: [] }; + + case 'REORDER_OBJECT': { + const idx = state.objects.findIndex((o) => o.id === action.id); + if (idx === -1) return state; + const next = [...state.objects]; + const [item] = next.splice(idx, 1); + const clampedIndex = Math.max(0, Math.min(action.toIndex, next.length)); + next.splice(clampedIndex, 0, item); + return { ...state, objects: next }; + } + + case 'TOGGLE_VISIBILITY': + return { + ...state, + objects: state.objects.map((o) => + o.id === action.id ? ({ ...o, visible: !o.visible } as CanvasObject) : o, + ), + }; + + case 'TOGGLE_LOCK': + return { + ...state, + objects: state.objects.map((o) => + o.id === action.id ? ({ ...o, locked: !o.locked } as CanvasObject) : o, + ), + }; + + case 'SET_ARTBOARD': + return { ...state, artboard: action.config }; + + case 'RESTORE': + return action.state; + + default: + return state; + } +} + +// -- History ref type --------------------------------------------------------- + +interface HistoryRef { + undoStack: CanvasState[]; + redoStack: CanvasState[]; +} + +const MAX_HISTORY = 50; + +// -- Hook --------------------------------------------------------------------- + +export interface UseCanvasStateReturn { + state: CanvasState; + + addObject: (object: CanvasObject) => void; + removeObject: (id: string) => void; + updateObject: (id: string, changes: Partial) => void; + selectObjects: (ids: string[]) => void; + deselectAll: () => void; + reorderObject: (id: string, toIndex: number) => void; + toggleVisibility: (id: string) => void; + toggleLock: (id: string) => void; + setArtboard: (config: ArtboardConfig) => void; + + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; +} + +export function useCanvasState( + traceMetadata: TraceMetadata | null = null, +): UseCanvasStateReturn { + const initialState: CanvasState = { + objects: [], + selectedIds: [], + artboard: null, + traceMetadata, + }; + + const [state, dispatch] = useReducer(canvasReducer, initialState); + const historyRef = useRef({ undoStack: [], redoStack: [] }); + + // Push a snapshot before every mutation + const pushHistory = useCallback( + (currentState: CanvasState) => { + const h = historyRef.current; + h.undoStack = [...h.undoStack.slice(-(MAX_HISTORY - 1)), currentState]; + h.redoStack = []; // clear redo on new action + }, + [], + ); + + // -- Mutators (each pushes history then dispatches) ----------------------- + + const addObject = useCallback( + (object: CanvasObject) => { + pushHistory(state); + dispatch({ type: 'ADD_OBJECT', object }); + }, + [state, pushHistory], + ); + + const removeObject = useCallback( + (id: string) => { + pushHistory(state); + dispatch({ type: 'REMOVE_OBJECT', id }); + }, + [state, pushHistory], + ); + + const updateObject = useCallback( + (id: string, changes: Partial) => { + pushHistory(state); + dispatch({ type: 'UPDATE_OBJECT', id, changes }); + }, + [state, pushHistory], + ); + + const selectObjects = useCallback( + (ids: string[]) => { + dispatch({ type: 'SELECT_OBJECTS', ids }); + }, + [], + ); + + const deselectAll = useCallback(() => { + dispatch({ type: 'DESELECT_ALL' }); + }, []); + + const reorderObject = useCallback( + (id: string, toIndex: number) => { + pushHistory(state); + dispatch({ type: 'REORDER_OBJECT', id, toIndex }); + }, + [state, pushHistory], + ); + + const toggleVisibility = useCallback( + (id: string) => { + pushHistory(state); + dispatch({ type: 'TOGGLE_VISIBILITY', id }); + }, + [state, pushHistory], + ); + + const toggleLock = useCallback( + (id: string) => { + pushHistory(state); + dispatch({ type: 'TOGGLE_LOCK', id }); + }, + [state, pushHistory], + ); + + const setArtboard = useCallback( + (config: ArtboardConfig) => { + pushHistory(state); + dispatch({ type: 'SET_ARTBOARD', config }); + }, + [state, pushHistory], + ); + + // -- Undo / Redo ---------------------------------------------------------- + + const undo = useCallback(() => { + const h = historyRef.current; + if (h.undoStack.length === 0) return; + const prev = h.undoStack[h.undoStack.length - 1]; + h.undoStack = h.undoStack.slice(0, -1); + h.redoStack = [...h.redoStack, state]; + dispatch({ type: 'RESTORE', state: prev }); + }, [state]); + + const redo = useCallback(() => { + const h = historyRef.current; + if (h.redoStack.length === 0) return; + const next = h.redoStack[h.redoStack.length - 1]; + h.redoStack = h.redoStack.slice(0, -1); + h.undoStack = [...h.undoStack, state]; + dispatch({ type: 'RESTORE', state: next }); + }, [state]); + + return { + state, + addObject, + removeObject, + updateObject, + selectObjects, + deselectAll, + reorderObject, + toggleVisibility, + toggleLock, + setArtboard, + undo, + redo, + canUndo: historyRef.current.undoStack.length > 0, + canRedo: historyRef.current.redoStack.length > 0, + }; +} diff --git a/app/src/test-setup.ts b/app/src/test-setup.ts index bb02c60..5062a6b 100644 --- a/app/src/test-setup.ts +++ b/app/src/test-setup.ts @@ -1 +1,2 @@ import '@testing-library/jest-dom/vitest'; +import 'vitest-canvas-mock'; diff --git a/app/src/types/canvas.ts b/app/src/types/canvas.ts new file mode 100644 index 0000000..503c7ea --- /dev/null +++ b/app/src/types/canvas.ts @@ -0,0 +1,101 @@ +/** Canvas object types, artboard configuration, and canvas state. */ + +import type { TraceMetadata } from './engine'; + +// -- Artboard -- + +export type ArtboardShape = + | 'rect' + | 'square' + | 'circle' + | 'oval' + | 'shield' + | 'pennant' + | 'custom'; + +export type ArtboardUnit = 'inches' | 'mm'; + +export interface ArtboardConfig { + shape: ArtboardShape; + width: number; // in current unit + height: number; // in current unit + unit: ArtboardUnit; + /** Custom clip path data (SVG path "d" attr), used only for shield/pennant/custom. */ + clipPath?: string; +} + +// -- Canvas Objects (discriminated union) -- + +interface BaseCanvasObject { + id: string; + name: string; + x: number; + y: number; + rotation: number; + visible: boolean; + locked: boolean; + opacity: number; +} + +export interface RectObject extends BaseCanvasObject { + type: 'rect'; + width: number; + height: number; + fill: string; + stroke: string; + strokeWidth: number; +} + +export interface CircleObject extends BaseCanvasObject { + type: 'circle'; + radius: number; + fill: string; + stroke: string; + strokeWidth: number; +} + +export interface EllipseObject extends BaseCanvasObject { + type: 'ellipse'; + radiusX: number; + radiusY: number; + fill: string; + stroke: string; + strokeWidth: number; +} + +export type LineStyle = 'solid' | 'dashed' | 'dotted'; + +export interface LineObject extends BaseCanvasObject { + type: 'line'; + points: number[]; // [x1, y1, x2, y2, ...] + stroke: string; + strokeWidth: number; + lineStyle: LineStyle; + dash: number[]; +} + +export interface ImageObject extends BaseCanvasObject { + type: 'image'; + width: number; + height: number; + /** Blob URL or data URL for the image source. */ + src: string; +} + +export type CanvasObject = + | RectObject + | CircleObject + | EllipseObject + | LineObject + | ImageObject; + +export type CanvasObjectType = CanvasObject['type']; + +// -- Canvas State -- + +export interface CanvasState { + objects: CanvasObject[]; + selectedIds: string[]; + artboard: ArtboardConfig | null; + traceMetadata: TraceMetadata | null; +} diff --git a/app/src/utils/__tests__/alignment.test.ts b/app/src/utils/__tests__/alignment.test.ts new file mode 100644 index 0000000..2d325b5 --- /dev/null +++ b/app/src/utils/__tests__/alignment.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { + alignLeft, + alignCenter, + alignRight, + alignTop, + alignMiddle, + alignBottom, + distributeHorizontal, + distributeVertical, + centerOnArtboard, +} from '../alignment'; +import type { BoundingRect } from '../alignment'; + +const items: BoundingRect[] = [ + { id: 'a', x: 10, y: 20, width: 30, height: 40 }, + { id: 'b', x: 50, y: 60, width: 20, height: 10 }, + { id: 'c', x: 100, y: 10, width: 40, height: 50 }, +]; + +describe('alignment', () => { + describe('alignLeft', () => { + it('moves all items to the leftmost x', () => { + const result = alignLeft(items); + expect(result.every((r) => r.x === 10)).toBe(true); + }); + it('preserves y positions', () => { + const result = alignLeft(items); + expect(result.map((r) => r.y)).toEqual([20, 60, 10]); + }); + }); + + describe('alignCenter', () => { + it('centers all items on the average horizontal center', () => { + const result = alignCenter(items); + const centers = result.map((r, i) => r.x + items[i].width / 2); + expect(centers[0]).toBeCloseTo(centers[1], 5); + expect(centers[1]).toBeCloseTo(centers[2], 5); + }); + }); + + describe('alignRight', () => { + it('aligns all items to the rightmost edge', () => { + const result = alignRight(items); + const maxRight = 100 + 40; // item c right edge + for (let i = 0; i < result.length; i++) { + expect(result[i].x + items[i].width).toBeCloseTo(maxRight, 5); + } + }); + }); + + describe('alignTop', () => { + it('moves all items to the topmost y', () => { + const result = alignTop(items); + expect(result.every((r) => r.y === 10)).toBe(true); + }); + }); + + describe('alignMiddle', () => { + it('centers all items on the average vertical middle', () => { + const result = alignMiddle(items); + const middles = result.map((r, i) => r.y + items[i].height / 2); + expect(middles[0]).toBeCloseTo(middles[1], 5); + expect(middles[1]).toBeCloseTo(middles[2], 5); + }); + }); + + describe('alignBottom', () => { + it('aligns all items to the bottommost edge', () => { + const result = alignBottom(items); + // Max bottom edge: max of (20+40=60, 60+10=70, 10+50=60) = 70 + const maxBot = Math.max(...items.map((i) => i.y + i.height)); + for (let i = 0; i < result.length; i++) { + expect(result[i].y + items[i].height).toBeCloseTo(maxBot, 5); + } + }); + }); + + describe('distributeHorizontal', () => { + it('returns same positions for fewer than 3 items', () => { + const two = items.slice(0, 2); + const result = distributeHorizontal(two); + expect(result[0].x).toBe(two[0].x); + expect(result[1].x).toBe(two[1].x); + }); + + it('evenly spaces 3+ items horizontally', () => { + const result = distributeHorizontal(items); + // sorted by x: a(10), b(50), c(100) + // gap should be equal between all items + const sorted = [...result].sort((a, b) => a.x - b.x); + const gap1 = sorted[1].x - (sorted[0].x + 30); // 30=width of a + const gap2 = sorted[2].x - (sorted[1].x + 20); // 20=width of b + expect(gap1).toBeCloseTo(gap2, 5); + }); + }); + + describe('distributeVertical', () => { + it('returns same positions for fewer than 3 items', () => { + const two = items.slice(0, 2); + const result = distributeVertical(two); + expect(result[0].y).toBe(two[0].y); + expect(result[1].y).toBe(two[1].y); + }); + + it('evenly spaces 3+ items vertically', () => { + const result = distributeVertical(items); + const sorted = [...result].sort((a, b) => a.y - b.y); + // sorted by y: c(10,h=50), a(20,h=40), b(60,h=10) + const gap1 = sorted[1].y - (sorted[0].y + 50); // 50=height of c + const gap2 = sorted[2].y - (sorted[1].y + 40); // 40=height of a + expect(gap1).toBeCloseTo(gap2, 5); + }); + }); + + describe('centerOnArtboard', () => { + it('returns empty array for empty input', () => { + expect(centerOnArtboard([], 400, 400)).toEqual([]); + }); + + it('centers a single item on the artboard', () => { + const single: BoundingRect[] = [ + { id: 'x', x: 0, y: 0, width: 100, height: 100 }, + ]; + const result = centerOnArtboard(single, 400, 400); + expect(result[0].x).toBe(150); + expect(result[0].y).toBe(150); + }); + + it('centers a group on the artboard', () => { + const group: BoundingRect[] = [ + { id: 'a', x: 0, y: 0, width: 50, height: 50 }, + { id: 'b', x: 50, y: 0, width: 50, height: 50 }, + ]; + const result = centerOnArtboard(group, 400, 400); + // Group width = 100, centered at (150, 175) + const groupLeft = Math.min(result[0].x, result[1].x); + const groupRight = Math.max(result[0].x + 50, result[1].x + 50); + expect((groupLeft + groupRight) / 2).toBeCloseTo(200, 5); + }); + }); +}); diff --git a/app/src/utils/__tests__/artboardShapes.test.ts b/app/src/utils/__tests__/artboardShapes.test.ts new file mode 100644 index 0000000..b934e66 --- /dev/null +++ b/app/src/utils/__tests__/artboardShapes.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from 'vitest'; +import { + shieldPath, + pennantPath, + toPx, + fromPx, + artboardClipPath, + ARTBOARD_PRESETS, +} from '../artboardShapes'; +import type { ArtboardConfig } from '../../types/canvas'; + +describe('artboardShapes', () => { + describe('ARTBOARD_PRESETS', () => { + it('has presets for all shapes', () => { + const shapes = ['rect', 'square', 'circle', 'oval', 'shield', 'pennant', 'custom'] as const; + for (const s of shapes) { + expect(ARTBOARD_PRESETS[s]).toBeDefined(); + expect(ARTBOARD_PRESETS[s].width).toBeGreaterThan(0); + expect(ARTBOARD_PRESETS[s].height).toBeGreaterThan(0); + } + }); + + it('square preset has equal width and height', () => { + expect(ARTBOARD_PRESETS.square.width).toBe(ARTBOARD_PRESETS.square.height); + }); + + it('circle preset has equal width and height', () => { + expect(ARTBOARD_PRESETS.circle.width).toBe(ARTBOARD_PRESETS.circle.height); + }); + }); + + describe('shieldPath', () => { + it('produces a closed SVG path', () => { + const p = shieldPath(100, 120); + expect(p).toContain('M'); + expect(p).toContain('Z'); + }); + + it('stays within bounding box', () => { + const p = shieldPath(200, 300); + // path starts at indent, ends at w-indent so all coords ≤ 200/300 + expect(p).toBeTruthy(); + }); + }); + + describe('pennantPath', () => { + it('produces a triangular closed path', () => { + const p = pennantPath(100, 200); + expect(p).toBe('M 0 0 L 100 0 L 50 200 Z'); + }); + }); + + describe('toPx / fromPx', () => { + it('converts inches to pixels at 96 PPI', () => { + expect(toPx(1, 'inches')).toBe(96); + expect(toPx(2, 'inches')).toBe(192); + }); + + it('converts mm to pixels', () => { + const px = toPx(25.4, 'mm'); + expect(px).toBeCloseTo(96, 1); + }); + + it('fromPx inverts toPx for inches', () => { + expect(fromPx(96, 'inches')).toBe(1); + }); + + it('fromPx inverts toPx for mm', () => { + expect(fromPx(toPx(10, 'mm'), 'mm')).toBeCloseTo(10, 5); + }); + }); + + describe('artboardClipPath', () => { + it('returns undefined for rect', () => { + const cfg: ArtboardConfig = { shape: 'rect', width: 4, height: 6, unit: 'inches' }; + expect(artboardClipPath(cfg)).toBeUndefined(); + }); + + it('returns a path string for shield', () => { + const cfg: ArtboardConfig = { shape: 'shield', width: 4, height: 5, unit: 'inches' }; + const p = artboardClipPath(cfg); + expect(p).toBeDefined(); + expect(p).toContain('Z'); + }); + + it('returns a triangular path for pennant', () => { + const cfg: ArtboardConfig = { shape: 'pennant', width: 3, height: 8, unit: 'inches' }; + const p = artboardClipPath(cfg); + expect(p).toBeDefined(); + expect(p).toContain('Z'); + }); + + it('returns custom clipPath when shape is custom', () => { + const cfg: ArtboardConfig = { + shape: 'custom', + width: 4, + height: 4, + unit: 'inches', + clipPath: 'M 0 0 L 50 0 L 50 50 Z', + }; + expect(artboardClipPath(cfg)).toBe('M 0 0 L 50 0 L 50 50 Z'); + }); + }); +}); diff --git a/app/src/utils/alignment.ts b/app/src/utils/alignment.ts new file mode 100644 index 0000000..e3fa52c --- /dev/null +++ b/app/src/utils/alignment.ts @@ -0,0 +1,116 @@ +/** + * Pure alignment functions. + * + * Every function accepts a set of bounding rectangles and returns new (x, y) + * positions. The caller is responsible for dispatching updateObject calls. + */ + +export interface BoundingRect { + id: string; + x: number; + y: number; + width: number; + height: number; +} + +export interface PositionUpdate { + id: string; + x: number; + y: number; +} + +// -- Align to group edge / center -------------------------------------------- + +export function alignLeft(items: BoundingRect[]): PositionUpdate[] { + const minX = Math.min(...items.map((i) => i.x)); + return items.map((i) => ({ id: i.id, x: minX, y: i.y })); +} + +export function alignCenter(items: BoundingRect[]): PositionUpdate[] { + const centers = items.map((i) => i.x + i.width / 2); + const avgCenter = centers.reduce((a, b) => a + b, 0) / centers.length; + return items.map((i) => ({ id: i.id, x: avgCenter - i.width / 2, y: i.y })); +} + +export function alignRight(items: BoundingRect[]): PositionUpdate[] { + const maxRight = Math.max(...items.map((i) => i.x + i.width)); + return items.map((i) => ({ id: i.id, x: maxRight - i.width, y: i.y })); +} + +export function alignTop(items: BoundingRect[]): PositionUpdate[] { + const minY = Math.min(...items.map((i) => i.y)); + return items.map((i) => ({ id: i.id, x: i.x, y: minY })); +} + +export function alignMiddle(items: BoundingRect[]): PositionUpdate[] { + const middles = items.map((i) => i.y + i.height / 2); + const avgMiddle = middles.reduce((a, b) => a + b, 0) / middles.length; + return items.map((i) => ({ id: i.id, x: i.x, y: avgMiddle - i.height / 2 })); +} + +export function alignBottom(items: BoundingRect[]): PositionUpdate[] { + const maxBottom = Math.max(...items.map((i) => i.y + i.height)); + return items.map((i) => ({ id: i.id, x: i.x, y: maxBottom - i.height })); +} + +// -- Distribute evenly ------------------------------------------------------- + +export function distributeHorizontal(items: BoundingRect[]): PositionUpdate[] { + if (items.length < 3) return items.map((i) => ({ id: i.id, x: i.x, y: i.y })); + const sorted = [...items].sort((a, b) => a.x - b.x); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const totalWidth = sorted.reduce((s, i) => s + i.width, 0); + const gap = + (last.x + last.width - first.x - totalWidth) / (sorted.length - 1); + let cursor = first.x; + return sorted.map((item) => { + const pos = { id: item.id, x: cursor, y: item.y }; + cursor += item.width + gap; + return pos; + }); +} + +export function distributeVertical(items: BoundingRect[]): PositionUpdate[] { + if (items.length < 3) return items.map((i) => ({ id: i.id, x: i.x, y: i.y })); + const sorted = [...items].sort((a, b) => a.y - b.y); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const totalHeight = sorted.reduce((s, i) => s + i.height, 0); + const gap = + (last.y + last.height - first.y - totalHeight) / (sorted.length - 1); + let cursor = first.y; + return sorted.map((item) => { + const pos = { id: item.id, x: item.x, y: cursor }; + cursor += item.height + gap; + return pos; + }); +} + +// -- Center on artboard ------------------------------------------------------ + +export function centerOnArtboard( + items: BoundingRect[], + artboardWidth: number, + artboardHeight: number, +): PositionUpdate[] { + if (items.length === 0) return []; + + // Find bounding box of all items + const minX = Math.min(...items.map((i) => i.x)); + const minY = Math.min(...items.map((i) => i.y)); + const maxX = Math.max(...items.map((i) => i.x + i.width)); + const maxY = Math.max(...items.map((i) => i.y + i.height)); + + const groupW = maxX - minX; + const groupH = maxY - minY; + + const offsetX = (artboardWidth - groupW) / 2 - minX; + const offsetY = (artboardHeight - groupH) / 2 - minY; + + return items.map((i) => ({ + id: i.id, + x: i.x + offsetX, + y: i.y + offsetY, + })); +} diff --git a/app/src/utils/artboardShapes.ts b/app/src/utils/artboardShapes.ts new file mode 100644 index 0000000..1d2932c --- /dev/null +++ b/app/src/utils/artboardShapes.ts @@ -0,0 +1,96 @@ +/** + * Artboard shape path data and dimension presets. + * + * Paths are expressed as SVG path "d" attribute strings, generated relative to + * a 0,0 origin for the given width × height. For standard shapes (rect, + * square, circle, oval) no clip path is needed — Konva primitives handle them. + * Shield and pennant require custom clip paths. + */ + +import type { ArtboardConfig, ArtboardShape, ArtboardUnit } from '../types/canvas'; + +// -- Dimension presets (inches) ----------------------------------------------- + +export interface DimensionPreset { + label: string; + width: number; + height: number; + unit: ArtboardUnit; +} + +/** Default presets for each artboard shape. */ +export const ARTBOARD_PRESETS: Record = { + rect: { label: 'Rectangle', width: 4, height: 6, unit: 'inches' }, + square: { label: 'Square', width: 4, height: 4, unit: 'inches' }, + circle: { label: 'Circle', width: 4, height: 4, unit: 'inches' }, + oval: { label: 'Oval', width: 3, height: 4, unit: 'inches' }, + shield: { label: 'Shield', width: 4, height: 5, unit: 'inches' }, + pennant: { label: 'Pennant', width: 3.5, height: 8, unit: 'inches' }, + custom: { label: 'Custom', width: 4, height: 4, unit: 'inches' }, +}; + +// -- Path generators ---------------------------------------------------------- + +/** + * Shield shape: flat top, slightly flared sides, pointed bottom. + * The path fills the full w × h bounding box. + */ +export function shieldPath(w: number, h: number): string { + const indent = w * 0.08; + return [ + `M ${indent} 0`, + `L ${w - indent} 0`, + `Q ${w} 0, ${w} ${h * 0.08}`, + `L ${w} ${h * 0.5}`, + `Q ${w} ${h * 0.75}, ${w / 2} ${h}`, + `Q 0 ${h * 0.75}, 0 ${h * 0.5}`, + `L 0 ${h * 0.08}`, + `Q 0 0, ${indent} 0`, + 'Z', + ].join(' '); +} + +/** + * Pennant / triangle shape: wide top tapering to a point at the bottom-center. + */ +export function pennantPath(w: number, h: number): string { + return `M 0 0 L ${w} 0 L ${w / 2} ${h} Z`; +} + +/** + * Given an ArtboardConfig, return the SVG clip path string if the shape needs + * one, or undefined if standard Konva shapes suffice. + */ +export function artboardClipPath(config: ArtboardConfig): string | undefined { + const { shape, width, height } = config; + const pxW = toPx(width, config.unit); + const pxH = toPx(height, config.unit); + + switch (shape) { + case 'shield': + return shieldPath(pxW, pxH); + case 'pennant': + return pennantPath(pxW, pxH); + case 'custom': + return config.clipPath; + default: + return undefined; + } +} + +// -- Unit conversion ---------------------------------------------------------- + +/** Pixels-per-inch at screen DPI (96). */ +const PPI = 96; +/** Pixels-per-mm. */ +const PPMM = PPI / 25.4; + +/** Convert a dimension to pixels. */ +export function toPx(value: number, unit: ArtboardUnit): number { + return unit === 'inches' ? value * PPI : value * PPMM; +} + +/** Convert pixels back to the given unit. */ +export function fromPx(px: number, unit: ArtboardUnit): number { + return unit === 'inches' ? px / PPI : px / PPMM; +}