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:
parent
383825e242
commit
62f79110e8
11 changed files with 1346 additions and 6 deletions
136
app/package-lock.json
generated
136
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
app/src/components/canvas/ArtboardSetup.tsx
Normal file
152
app/src/components/canvas/ArtboardSetup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
app/src/hooks/__tests__/useCanvasState.test.ts
Normal file
241
app/src/hooks/__tests__/useCanvasState.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
256
app/src/hooks/useCanvasState.ts
Normal file
256
app/src/hooks/useCanvasState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
import '@testing-library/jest-dom/vitest';
|
||||
import 'vitest-canvas-mock';
|
||||
|
|
|
|||
101
app/src/types/canvas.ts
Normal file
101
app/src/types/canvas.ts
Normal 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;
|
||||
}
|
||||
142
app/src/utils/__tests__/alignment.test.ts
Normal file
142
app/src/utils/__tests__/alignment.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
app/src/utils/__tests__/artboardShapes.test.ts
Normal file
104
app/src/utils/__tests__/artboardShapes.test.ts
Normal 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
116
app/src/utils/alignment.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
96
app/src/utils/artboardShapes.ts
Normal file
96
app/src/utils/artboardShapes.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue