test: Built canvas type system, useCanvasState hook with undo/redo, art…

- "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
This commit is contained in:
jlightner 2026-03-26 05:32:04 +00:00
parent 383825e242
commit 62f79110e8
11 changed files with 1346 additions and 6 deletions

136
app/package-lock.json generated
View file

@ -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",

View file

@ -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"
}
}

View file

@ -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<ArtboardShape, string> = {
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<ArtboardShape>('rect');
const [width, setWidth] = useState(ARTBOARD_PRESETS.rect.width);
const [height, setHeight] = useState(ARTBOARD_PRESETS.rect.height);
const [unit, setUnit] = useState<ArtboardUnit>('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 (
<div className="artboard-setup-overlay" data-testid="artboard-setup">
<div className="artboard-setup-modal">
<h2>Set Up Artboard</h2>
{/* Shape picker */}
<fieldset className="artboard-setup-shapes">
<legend>Shape</legend>
<div className="artboard-shape-grid">
{SHAPES.map((s) => (
<button
key={s}
type="button"
className={`artboard-shape-btn${s === shape ? ' active' : ''}`}
onClick={() => handleShapeChange(s)}
aria-pressed={s === shape}
>
{SHAPE_LABELS[s]}
</button>
))}
</div>
</fieldset>
{/* Dimension inputs */}
<div className="artboard-setup-dimensions">
<label>
Width
<input
type="number"
min={0.5}
step={0.25}
value={width}
onChange={(e) => handleWidthChange(Number(e.target.value))}
data-testid="artboard-width"
/>
</label>
<label>
Height
<input
type="number"
min={0.5}
step={0.25}
value={height}
onChange={(e) => handleHeightChange(Number(e.target.value))}
disabled={shape === 'square' || shape === 'circle'}
data-testid="artboard-height"
/>
</label>
</div>
{/* Units toggle */}
<fieldset className="artboard-setup-units">
<legend>Units</legend>
<label>
<input
type="radio"
name="unit"
value="inches"
checked={unit === 'inches'}
onChange={() => setUnit('inches')}
/>
Inches
</label>
<label>
<input
type="radio"
name="unit"
value="mm"
checked={unit === 'mm'}
onChange={() => setUnit('mm')}
/>
Millimeters
</label>
</fieldset>
{/* Confirm */}
<button
type="button"
className="artboard-setup-confirm"
onClick={handleConfirm}
data-testid="artboard-confirm"
>
Create Artboard
</button>
</div>
</div>
);
}

View file

@ -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> = {}): 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);
});
});
});

View file

@ -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<CanvasObject> }
| { 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<CanvasObject>) => 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<HistoryRef>({ 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<CanvasObject>) => {
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,
};
}

View file

@ -1 +1,2 @@
import '@testing-library/jest-dom/vitest';
import 'vitest-canvas-mock';

101
app/src/types/canvas.ts Normal file
View file

@ -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;
}

View file

@ -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);
});
});
});

View file

@ -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');
});
});
});

116
app/src/utils/alignment.ts Normal file
View file

@ -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,
}));
}

View file

@ -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<ArtboardShape, DimensionPreset> = {
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;
}