feat: Built KonvaStage with artboard rendering, shape tools, selection…

- "app/src/components/canvas/KonvaStage.tsx"
- "app/src/views/DesignCanvas.tsx"
- "app/src/views/DesignCanvas.module.css"
- "app/src/App.tsx"
- "app/src/App.css"

GSD-Task: S02/T02
This commit is contained in:
jlightner 2026-03-26 05:36:19 +00:00
parent 59a034ab75
commit 6ec52ab7b6
10 changed files with 1274 additions and 16 deletions

View file

@ -20,3 +20,4 @@
{"cmd":"complete-slice","params":{"milestoneId":"M002","sliceId":"S01"},"ts":"2026-03-26T05:20:14.729Z","actor":"agent","hash":"4e07aca5d7cb85a5","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-slice","params":{"milestoneId":"M002","sliceId":"S01"},"ts":"2026-03-26T05:20:14.729Z","actor":"agent","hash":"4e07aca5d7cb85a5","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S02"},"ts":"2026-03-26T05:26:15.488Z","actor":"agent","hash":"b1dbe18979c01969","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S02"},"ts":"2026-03-26T05:26:15.488Z","actor":"agent","hash":"b1dbe18979c01969","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T01"},"ts":"2026-03-26T05:31:55.544Z","actor":"agent","hash":"4c3809e0b1681b4c","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T01"},"ts":"2026-03-26T05:31:55.544Z","actor":"agent","hash":"4c3809e0b1681b4c","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T02"},"ts":"2026-03-26T05:36:12.635Z","actor":"agent","hash":"8dd660d191cc3758","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}

View file

@ -14,7 +14,7 @@ Install konva, react-konva, and jest-canvas-mock as dependencies. Set up canvas
- Estimate: 2h - Estimate: 2h
- Files: 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, app/src/test-setup.ts, app/package.json - Files: 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, app/src/test-setup.ts, app/package.json
- Verify: cd app && npx vitest run --reporter=verbose && npx tsc --noEmit - Verify: cd app && npx vitest run --reporter=verbose && npx tsc --noEmit
- [ ] **T02: Konva stage with artboard rendering, imported SVG, selection handles, and shape tools** — Build the core Konva canvas rendering layer and wire it into the app. This task creates the KonvaStage component (Konva Stage + Layer with artboard background, all canvas objects, Transformer for selection handles, rubber-band selection rectangle), the DesignCanvas view container, and shape creation tools. - [x] **T02: Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container** — Build the core Konva canvas rendering layer and wire it into the app. This task creates the KonvaStage component (Konva Stage + Layer with artboard background, all canvas objects, Transformer for selection handles, rubber-band selection rectangle), the DesignCanvas view container, and shape creation tools.
Key implementation details: Key implementation details:
- DesignCanvas is the View 2 container. Layout: top toolbar area, left canvas with KonvaStage, right panel area (wired in T03). Receives svgData and traceMetadata props from App.tsx. - DesignCanvas is the View 2 container. Layout: top toolbar area, left canvas with KonvaStage, right panel area (wired in T03). Receives svgData and traceMetadata props from App.tsx.

View file

@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"taskId": "T01",
"unitId": "M002/S02/T01",
"timestamp": 1774503124410,
"passed": false,
"discoverySource": "task-plan",
"checks": [
{
"command": "cd app",
"exitCode": 0,
"durationMs": 4,
"verdict": "pass"
},
{
"command": "npx vitest run --reporter=verbose",
"exitCode": 1,
"durationMs": 4841,
"verdict": "fail"
},
{
"command": "npx tsc --noEmit",
"exitCode": 1,
"durationMs": 1064,
"verdict": "fail"
}
],
"retryAttempt": 1,
"maxRetries": 2
}

View file

@ -0,0 +1,85 @@
---
id: T02
parent: S02
milestone: M002
provides: []
requires: []
affects: []
key_files: ["app/src/components/canvas/KonvaStage.tsx", "app/src/views/DesignCanvas.tsx", "app/src/views/DesignCanvas.module.css", "app/src/App.tsx", "app/src/App.css"]
key_decisions: ["Artboard centered on stage via computed offsets; object positions stored relative to artboard origin", "SVG import uses Blob URL → Image element → Konva.Image with auto-scale to fit artboard", "Used :has() CSS selector on #app to override #root width constraint for canvas view"]
patterns_established: []
drill_down_paths: []
observability_surfaces: []
duration: ""
verification_result: "Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters. Ran `cd app && npx vitest run --reporter=verbose` — all 71 tests pass (6 test files, 0 failures)."
completed_at: 2026-03-26T05:36:12.587Z
blocker_discovered: false
---
# T02: Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container
> Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container
## What Happened
---
id: T02
parent: S02
milestone: M002
key_files:
- app/src/components/canvas/KonvaStage.tsx
- app/src/views/DesignCanvas.tsx
- app/src/views/DesignCanvas.module.css
- app/src/App.tsx
- app/src/App.css
key_decisions:
- Artboard centered on stage via computed offsets; object positions stored relative to artboard origin
- SVG import uses Blob URL → Image element → Konva.Image with auto-scale to fit artboard
- Used :has() CSS selector on #app to override #root width constraint for canvas view
duration: ""
verification_result: passed
completed_at: 2026-03-26T05:36:12.599Z
blocker_discovered: false
---
# T02: Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container
**Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container**
## What Happened
Created KonvaStage component rendering artboard backgrounds (Rect/Circle/Ellipse/Path based on ArtboardConfig shape), all five CanvasObject types mapped to Konva primitives with drag/transform handlers, Transformer synced to selected nodes, and rubber-band selection rectangle. Built DesignCanvas view container with top toolbar (tool buttons + undo/redo), ResizeObserver-driven canvas sizing, artboard setup flow, and SVG import that converts SVG string to Blob URL, auto-scales to fit artboard, and adds as ImageObject. Updated App.tsx to wire DesignCanvas with real props and added toolbar/artboard styles to App.css.
## Verification
Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters. Ran `cd app && npx vitest run --reporter=verbose` — all 71 tests pass (6 test files, 0 failures).
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2300ms |
| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2060ms |
## Deviations
None.
## Known Issues
None.
## Files Created/Modified
- `app/src/components/canvas/KonvaStage.tsx`
- `app/src/views/DesignCanvas.tsx`
- `app/src/views/DesignCanvas.module.css`
- `app/src/App.tsx`
- `app/src/App.css`
## Deviations
None.
## Known Issues
None.

View file

@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"exported_at": "2026-03-26T05:31:55.543Z", "exported_at": "2026-03-26T05:36:12.633Z",
"milestones": [ "milestones": [
{ {
"id": "M001", "id": "M001",
@ -1110,19 +1110,29 @@
"milestone_id": "M002", "milestone_id": "M002",
"slice_id": "S02", "slice_id": "S02",
"id": "T02", "id": "T02",
"title": "Konva stage with artboard rendering, imported SVG, selection handles, and shape tools", "title": "Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container",
"status": "pending", "status": "complete",
"one_liner": "", "one_liner": "Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container",
"narrative": "", "narrative": "Created KonvaStage component rendering artboard backgrounds (Rect/Circle/Ellipse/Path based on ArtboardConfig shape), all five CanvasObject types mapped to Konva primitives with drag/transform handlers, Transformer synced to selected nodes, and rubber-band selection rectangle. Built DesignCanvas view container with top toolbar (tool buttons + undo/redo), ResizeObserver-driven canvas sizing, artboard setup flow, and SVG import that converts SVG string to Blob URL, auto-scales to fit artboard, and adds as ImageObject. Updated App.tsx to wire DesignCanvas with real props and added toolbar/artboard styles to App.css.",
"verification_result": "", "verification_result": "Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters. Ran `cd app && npx vitest run --reporter=verbose` — all 71 tests pass (6 test files, 0 failures).",
"duration": "", "duration": "",
"completed_at": null, "completed_at": "2026-03-26T05:36:12.587Z",
"blocker_discovered": false, "blocker_discovered": false,
"deviations": "", "deviations": "None.",
"known_issues": "", "known_issues": "None.",
"key_files": [], "key_files": [
"key_decisions": [], "app/src/components/canvas/KonvaStage.tsx",
"full_summary_md": "", "app/src/views/DesignCanvas.tsx",
"app/src/views/DesignCanvas.module.css",
"app/src/App.tsx",
"app/src/App.css"
],
"key_decisions": [
"Artboard centered on stage via computed offsets; object positions stored relative to artboard origin",
"SVG import uses Blob URL → Image element → Konva.Image with auto-scale to fit artboard",
"Used :has() CSS selector on #app to override #root width constraint for canvas view"
],
"full_summary_md": "---\nid: T02\nparent: S02\nmilestone: M002\nkey_files:\n - app/src/components/canvas/KonvaStage.tsx\n - app/src/views/DesignCanvas.tsx\n - app/src/views/DesignCanvas.module.css\n - app/src/App.tsx\n - app/src/App.css\nkey_decisions:\n - Artboard centered on stage via computed offsets; object positions stored relative to artboard origin\n - SVG import uses Blob URL → Image element → Konva.Image with auto-scale to fit artboard\n - Used :has() CSS selector on #app to override #root width constraint for canvas view\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:36:12.599Z\nblocker_discovered: false\n---\n\n# T02: Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container\n\n**Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container**\n\n## What Happened\n\nCreated KonvaStage component rendering artboard backgrounds (Rect/Circle/Ellipse/Path based on ArtboardConfig shape), all five CanvasObject types mapped to Konva primitives with drag/transform handlers, Transformer synced to selected nodes, and rubber-band selection rectangle. Built DesignCanvas view container with top toolbar (tool buttons + undo/redo), ResizeObserver-driven canvas sizing, artboard setup flow, and SVG import that converts SVG string to Blob URL, auto-scales to fit artboard, and adds as ImageObject. Updated App.tsx to wire DesignCanvas with real props and added toolbar/artboard styles to App.css.\n\n## Verification\n\nRan `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters. Ran `cd app && npx vitest run --reporter=verbose` — all 71 tests pass (6 test files, 0 failures).\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd app && npx tsc --noEmit` | 0 | ✅ pass | 2300ms |\n| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2060ms |\n\n\n## Deviations\n\nNone.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/components/canvas/KonvaStage.tsx`\n- `app/src/views/DesignCanvas.tsx`\n- `app/src/views/DesignCanvas.module.css`\n- `app/src/App.tsx`\n- `app/src/App.css`\n",
"description": "Build the core Konva canvas rendering layer and wire it into the app. This task creates the KonvaStage component (Konva Stage + Layer with artboard background, all canvas objects, Transformer for selection handles, rubber-band selection rectangle), the DesignCanvas view container, and shape creation tools.\n\nKey implementation details:\n- DesignCanvas is the View 2 container. Layout: top toolbar area, left canvas with KonvaStage, right panel area (wired in T03). Receives svgData and traceMetadata props from App.tsx.\n- Wire DesignCanvas into App.tsx replacing the placeholder div. Remove underscore prefixes from _svgResult and _traceMetadata variables.\n- KonvaStage renders: (1) artboard background shape using Konva.Rect/Path/Circle based on ArtboardConfig, (2) all canvas objects from useCanvasState mapped to Konva components, (3) Transformer attached to selected nodes, (4) rubber-band selection rectangle for multi-select.\n- Imported SVG from View 1: convert SVG string → Blob URL → Image element → Konva.Image. Auto-add to canvas objects on mount if svgData is provided.\n- Shape tools: rect, circle, ellipse, line creation. Click canvas in tool mode to place shape at click position with default dimensions. Tool state tracked in DesignCanvas.\n- Selection: click shape to select (single), shift-click to add to selection, click empty area to deselect, rubber-band rectangle selects all intersecting shapes.\n- Transformer: on onTransformEnd, reset scaleX/scaleY to 1 and multiply into width/height to keep state model clean.\n- Line objects support dash arrays for style variants (solid, dashed, dotted).\n- Canvas view should fill available width. Override #root width constraint for canvas view or make canvas flex within it.\n\nIMPORTANT tsconfig constraints: noUnusedLocals and noUnusedParameters are strict. All variables must be used or prefixed with underscore.", "description": "Build the core Konva canvas rendering layer and wire it into the app. This task creates the KonvaStage component (Konva Stage + Layer with artboard background, all canvas objects, Transformer for selection handles, rubber-band selection rectangle), the DesignCanvas view container, and shape creation tools.\n\nKey implementation details:\n- DesignCanvas is the View 2 container. Layout: top toolbar area, left canvas with KonvaStage, right panel area (wired in T03). Receives svgData and traceMetadata props from App.tsx.\n- Wire DesignCanvas into App.tsx replacing the placeholder div. Remove underscore prefixes from _svgResult and _traceMetadata variables.\n- KonvaStage renders: (1) artboard background shape using Konva.Rect/Path/Circle based on ArtboardConfig, (2) all canvas objects from useCanvasState mapped to Konva components, (3) Transformer attached to selected nodes, (4) rubber-band selection rectangle for multi-select.\n- Imported SVG from View 1: convert SVG string → Blob URL → Image element → Konva.Image. Auto-add to canvas objects on mount if svgData is provided.\n- Shape tools: rect, circle, ellipse, line creation. Click canvas in tool mode to place shape at click position with default dimensions. Tool state tracked in DesignCanvas.\n- Selection: click shape to select (single), shift-click to add to selection, click empty area to deselect, rubber-band rectangle selects all intersecting shapes.\n- Transformer: on onTransformEnd, reset scaleX/scaleY to 1 and multiply into width/height to keep state model clean.\n- Line objects support dash arrays for style variants (solid, dashed, dotted).\n- Canvas view should fill available width. Override #root width constraint for canvas view or make canvas flex within it.\n\nIMPORTANT tsconfig constraints: noUnusedLocals and noUnusedParameters are strict. All variables must be used or prefixed with underscore.",
"estimate": "2h30m", "estimate": "2h30m",
"files": [ "files": [
@ -1581,6 +1591,28 @@
"verdict": "✅ pass", "verdict": "✅ pass",
"duration_ms": 2100, "duration_ms": 2100,
"created_at": "2026-03-26T05:31:55.492Z" "created_at": "2026-03-26T05:31:55.492Z"
},
{
"id": 25,
"task_id": "T02",
"slice_id": "S02",
"milestone_id": "M002",
"command": "cd app && npx tsc --noEmit",
"exit_code": 0,
"verdict": "✅ pass",
"duration_ms": 2300,
"created_at": "2026-03-26T05:36:12.587Z"
},
{
"id": 26,
"task_id": "T02",
"slice_id": "S02",
"milestone_id": "M002",
"command": "cd app && npx vitest run --reporter=verbose",
"exit_code": 0,
"verdict": "✅ pass",
"duration_ms": 2060,
"created_at": "2026-03-26T05:36:12.588Z"
} }
] ]
} }

View file

@ -404,3 +404,194 @@
font-size: 20px; font-size: 20px;
color: var(--text); color: var(--text);
} }
/* Canvas toolbar buttons */
.canvas-tool-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 5px;
background: var(--bg);
color: var(--text-h);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s;
user-select: none;
}
.canvas-tool-btn:hover {
border-color: var(--accent-border);
background: var(--accent-bg);
}
.canvas-tool-btn--active {
border-color: var(--accent);
background: var(--accent-bg);
color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.canvas-tool-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.canvas-tool-icon {
font-size: 16px;
line-height: 1;
}
.canvas-tool-label {
font-size: 12px;
}
/* Canvas view: override #root width constraint */
#app:has([data-testid="canvas-container"]) {
max-width: 100%;
}
/* Artboard setup styling */
.artboard-setup-overlay {
display: flex;
align-items: center;
justify-content: center;
min-height: 80vh;
padding: 24px;
}
.artboard-setup-modal {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 480px;
width: 100%;
padding: 24px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg);
box-shadow: var(--shadow);
}
.artboard-setup-modal h2 {
margin: 0 0 4px;
}
.artboard-setup-shapes {
border: none;
padding: 0;
margin: 0;
}
.artboard-setup-shapes legend {
font-size: 14px;
font-weight: 600;
color: var(--text-h);
margin-bottom: 8px;
}
.artboard-shape-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 6px;
}
.artboard-shape-btn {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text-h);
font-size: 13px;
font-family: inherit;
cursor: pointer;
transition: border-color 0.12s, background-color 0.12s;
}
.artboard-shape-btn:hover {
border-color: var(--accent-border);
background: var(--accent-bg);
}
.artboard-shape-btn.active {
border-color: var(--accent);
background: var(--accent-bg);
color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
.artboard-setup-dimensions {
display: flex;
gap: 12px;
}
.artboard-setup-dimensions label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
color: var(--text-h);
font-weight: 500;
flex: 1;
}
.artboard-setup-dimensions input {
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 14px;
font-family: inherit;
background: var(--bg);
color: var(--text-h);
}
.artboard-setup-dimensions input:disabled {
opacity: 0.5;
}
.artboard-setup-units {
border: none;
padding: 0;
margin: 0;
display: flex;
gap: 16px;
align-items: center;
}
.artboard-setup-units legend {
font-size: 14px;
font-weight: 600;
color: var(--text-h);
margin-right: 8px;
}
.artboard-setup-units label {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--text-h);
cursor: pointer;
}
.artboard-setup-confirm {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 20px;
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
font-family: inherit;
transition: opacity 0.15s;
}
.artboard-setup-confirm:hover {
opacity: 0.9;
}

View file

@ -1,14 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import type { TraceMetadata } from './types/engine'; import type { TraceMetadata } from './types/engine';
import ImportConvert from './views/ImportConvert'; import ImportConvert from './views/ImportConvert';
import DesignCanvas from './views/DesignCanvas';
import './App.css'; import './App.css';
type ViewState = 'import' | 'canvas' | 'export'; type ViewState = 'import' | 'canvas' | 'export';
function App() { function App() {
const [view, setView] = useState<ViewState>('import'); const [view, setView] = useState<ViewState>('import');
const [_svgResult, setSvgResult] = useState<string | null>(null); const [svgResult, setSvgResult] = useState<string | null>(null);
const [_traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null); const [traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => { const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => {
setSvgResult(svgOutput); setSvgResult(svgOutput);
@ -19,7 +20,9 @@ function App() {
return ( return (
<div id="app"> <div id="app">
{view === 'import' && <ImportConvert onUseThis={handleUseThis} />} {view === 'import' && <ImportConvert onUseThis={handleUseThis} />}
{view === 'canvas' && <div className="placeholder-view">View 2: Design Canvas</div>} {view === 'canvas' && (
<DesignCanvas svgData={svgResult} traceMetadata={traceMetadata} />
)}
{view === 'export' && <div className="placeholder-view">View 3: Export</div>} {view === 'export' && <div className="placeholder-view">View 3: Export</div>}
</div> </div>
); );

View file

@ -0,0 +1,618 @@
/**
* KonvaStage core Konva rendering layer.
*
* Renders:
* 1. Artboard background (Rect/Circle/Path based on ArtboardConfig)
* 2. All canvas objects mapped to Konva primitives
* 3. Transformer for selection handles
* 4. Rubber-band selection rectangle for multi-select
*/
import {
useRef,
useEffect,
useState,
useCallback,
type RefObject,
} from 'react';
import {
Stage,
Layer,
Rect,
Circle,
Ellipse,
Line,
Image as KonvaImage,
Transformer,
Path,
} from 'react-konva';
import type Konva from 'konva';
import type {
ArtboardConfig,
CanvasObject,
CanvasObjectType,
} from '../../types/canvas';
import { toPx, artboardClipPath } from '../../utils/artboardShapes';
// -- Types --------------------------------------------------------------------
export type CanvasTool = 'pointer' | 'rect' | 'circle' | 'ellipse' | 'line';
export interface KonvaStageProps {
width: number;
height: number;
artboard: ArtboardConfig | null;
objects: CanvasObject[];
selectedIds: string[];
activeTool: CanvasTool;
onSelect: (ids: string[], additive: boolean) => void;
onDeselectAll: () => void;
onAddObject: (obj: CanvasObject) => void;
onUpdateObject: (id: string, changes: Partial<CanvasObject>) => void;
stageRef: RefObject<Konva.Stage | null>;
}
// -- Helpers ------------------------------------------------------------------
let _nextId = 1;
function nextId(type: CanvasObjectType): string {
return `${type}-${Date.now()}-${_nextId++}`;
}
const DASH_MAP: Record<string, number[]> = {
solid: [],
dashed: [10, 5],
dotted: [2, 4],
};
function getLineDash(lineStyle: string): number[] {
return DASH_MAP[lineStyle] ?? [];
}
// -- Component ----------------------------------------------------------------
export default function KonvaStage({
width,
height,
artboard,
objects,
selectedIds,
activeTool,
onSelect,
onDeselectAll,
onAddObject,
onUpdateObject,
stageRef,
}: KonvaStageProps) {
const transformerRef = useRef<Konva.Transformer | null>(null);
const layerRef = useRef<Konva.Layer | null>(null);
// Rubber-band selection state
const [rubberBand, setRubberBand] = useState<{
x: number;
y: number;
width: number;
height: number;
visible: boolean;
}>({ x: 0, y: 0, width: 0, height: 0, visible: false });
const rubberStart = useRef<{ x: number; y: number } | null>(null);
// Artboard pixel dimensions
const artW = artboard ? toPx(artboard.width, artboard.unit) : width;
const artH = artboard ? toPx(artboard.height, artboard.unit) : height;
// Center artboard on stage
const offsetX = Math.max(0, (width - artW) / 2);
const offsetY = Math.max(0, (height - artH) / 2);
// -- Sync transformer with selection ------------------------------------
useEffect(() => {
const tr = transformerRef.current;
const stage = stageRef.current;
if (!tr || !stage) return;
const selectedNodes = selectedIds
.map((id) => stage.findOne(`#${id}`))
.filter((n): n is Konva.Node => n != null);
tr.nodes(selectedNodes);
tr.getLayer()?.batchDraw();
}, [selectedIds, stageRef]);
// -- Artboard background ------------------------------------------------
function renderArtboard() {
if (!artboard) return null;
const clipPathData = artboardClipPath(artboard);
// Common artboard background rect (for all shapes, acts as bounding box)
const bgRect = (
<Rect
key="artboard-bg"
x={offsetX}
y={offsetY}
width={artW}
height={artH}
fill="#ffffff"
stroke="#cccccc"
strokeWidth={1}
listening={false}
/>
);
if (clipPathData) {
// Render the clip path outline for shield/pennant/custom shapes
return (
<>
{bgRect}
<Path
key="artboard-clip"
x={offsetX}
y={offsetY}
data={clipPathData}
stroke="#999999"
strokeWidth={1}
fill="rgba(255,255,255,0.5)"
listening={false}
/>
</>
);
}
if (artboard.shape === 'circle') {
const r = Math.min(artW, artH) / 2;
return (
<>
{bgRect}
<Circle
key="artboard-circle"
x={offsetX + artW / 2}
y={offsetY + artH / 2}
radius={r}
stroke="#999999"
strokeWidth={1}
fill="rgba(255,255,255,0.5)"
listening={false}
/>
</>
);
}
if (artboard.shape === 'oval') {
return (
<>
{bgRect}
<Ellipse
key="artboard-oval"
x={offsetX + artW / 2}
y={offsetY + artH / 2}
radiusX={artW / 2}
radiusY={artH / 2}
stroke="#999999"
strokeWidth={1}
fill="rgba(255,255,255,0.5)"
listening={false}
/>
</>
);
}
// Default: rect/square — just the bg rect is enough
return bgRect;
}
// -- Render canvas objects ------------------------------------------------
function renderObject(obj: CanvasObject) {
if (!obj.visible) return null;
const commonProps = {
id: obj.id,
x: obj.x + offsetX,
y: obj.y + offsetY,
rotation: obj.rotation,
opacity: obj.opacity,
draggable: !obj.locked && activeTool === 'pointer',
onClick: (e: Konva.KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
const isShift = e.evt.shiftKey;
onSelect([obj.id], isShift);
},
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => {
onUpdateObject(obj.id, {
x: e.target.x() - offsetX,
y: e.target.y() - offsetY,
});
},
onTransformEnd: (e: Konva.KonvaEventObject<Event>) => {
const node = e.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
const changes: Partial<CanvasObject> = {
x: node.x() - offsetX,
y: node.y() - offsetY,
rotation: node.rotation(),
};
if (obj.type === 'rect' || obj.type === 'image') {
(changes as Record<string, unknown>).width = Math.max(5, node.width() * scaleX);
(changes as Record<string, unknown>).height = Math.max(5, node.height() * scaleY);
} else if (obj.type === 'circle') {
(changes as Record<string, unknown>).radius = Math.max(5, (node.width() * scaleX) / 2);
} else if (obj.type === 'ellipse') {
(changes as Record<string, unknown>).radiusX = Math.max(5, (node.width() * scaleX) / 2);
(changes as Record<string, unknown>).radiusY = Math.max(5, (node.height() * scaleY) / 2);
}
onUpdateObject(obj.id, changes);
},
};
switch (obj.type) {
case 'rect':
return (
<Rect
key={obj.id}
{...commonProps}
width={obj.width}
height={obj.height}
fill={obj.fill}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
/>
);
case 'circle':
return (
<Circle
key={obj.id}
{...commonProps}
radius={obj.radius}
fill={obj.fill}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
/>
);
case 'ellipse':
return (
<Ellipse
key={obj.id}
{...commonProps}
radiusX={obj.radiusX}
radiusY={obj.radiusY}
fill={obj.fill}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
/>
);
case 'line':
return (
<Line
key={obj.id}
{...commonProps}
points={obj.points}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
dash={getLineDash(obj.lineStyle)}
/>
);
case 'image':
return (
<KonvaImageWrapper
key={obj.id}
{...commonProps}
width={obj.width}
height={obj.height}
src={obj.src}
/>
);
default:
return null;
}
}
// -- Shape creation on click in tool mode --------------------------------
const handleStageMouseDown = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
// If clicking on empty stage area
const clickedOnEmpty = e.target === e.target.getStage();
if (activeTool === 'pointer') {
if (clickedOnEmpty) {
onDeselectAll();
// Start rubber-band selection
const pos = e.target.getStage()?.getPointerPosition();
if (pos) {
rubberStart.current = { x: pos.x, y: pos.y };
setRubberBand({ x: pos.x, y: pos.y, width: 0, height: 0, visible: true });
}
}
return;
}
// Tool mode: create a shape at click position
if (!clickedOnEmpty) return;
const pos = e.target.getStage()?.getPointerPosition();
if (!pos) return;
const x = pos.x - offsetX;
const y = pos.y - offsetY;
let newObj: CanvasObject | null = null;
switch (activeTool) {
case 'rect':
newObj = {
id: nextId('rect'),
type: 'rect',
name: 'Rectangle',
x,
y,
width: 100,
height: 80,
rotation: 0,
visible: true,
locked: false,
opacity: 1,
fill: 'transparent',
stroke: '#000000',
strokeWidth: 2,
};
break;
case 'circle':
newObj = {
id: nextId('circle'),
type: 'circle',
name: 'Circle',
x,
y,
radius: 50,
rotation: 0,
visible: true,
locked: false,
opacity: 1,
fill: 'transparent',
stroke: '#000000',
strokeWidth: 2,
};
break;
case 'ellipse':
newObj = {
id: nextId('ellipse'),
type: 'ellipse',
name: 'Ellipse',
x,
y,
radiusX: 60,
radiusY: 40,
rotation: 0,
visible: true,
locked: false,
opacity: 1,
fill: 'transparent',
stroke: '#000000',
strokeWidth: 2,
};
break;
case 'line':
newObj = {
id: nextId('line'),
type: 'line',
name: 'Line',
x,
y,
points: [0, 0, 100, 0],
rotation: 0,
visible: true,
locked: false,
opacity: 1,
stroke: '#000000',
strokeWidth: 2,
lineStyle: 'solid',
dash: [],
};
break;
}
if (newObj) {
onAddObject(newObj);
onSelect([newObj.id], false);
}
},
[activeTool, offsetX, offsetY, onDeselectAll, onAddObject, onSelect],
);
// -- Rubber-band selection mouse move / up --------------------------------
const handleStageMouseMove = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
if (!rubberStart.current) return;
const pos = e.target.getStage()?.getPointerPosition();
if (!pos) return;
const sx = rubberStart.current.x;
const sy = rubberStart.current.y;
setRubberBand({
x: Math.min(sx, pos.x),
y: Math.min(sy, pos.y),
width: Math.abs(pos.x - sx),
height: Math.abs(pos.y - sy),
visible: true,
});
},
[],
);
const handleStageMouseUp = useCallback(
(_e: Konva.KonvaEventObject<MouseEvent>) => {
if (!rubberStart.current) return;
// Find objects intersecting the rubber band
if (rubberBand.width > 5 || rubberBand.height > 5) {
const rb = {
x: rubberBand.x - offsetX,
y: rubberBand.y - offsetY,
width: rubberBand.width,
height: rubberBand.height,
};
const intersecting = objects
.filter((obj) => {
if (!obj.visible || obj.locked) return false;
const objW = getObjWidth(obj);
const objH = getObjHeight(obj);
return rectsIntersect(
rb.x, rb.y, rb.width, rb.height,
obj.x, obj.y, objW, objH,
);
})
.map((obj) => obj.id);
if (intersecting.length > 0) {
onSelect(intersecting, false);
}
}
rubberStart.current = null;
setRubberBand({ x: 0, y: 0, width: 0, height: 0, visible: false });
},
[rubberBand, objects, offsetX, offsetY, onSelect],
);
return (
<Stage
ref={stageRef}
width={width}
height={height}
onMouseDown={handleStageMouseDown}
onMouseMove={handleStageMouseMove}
onMouseUp={handleStageMouseUp}
style={{ cursor: activeTool === 'pointer' ? 'default' : 'crosshair' }}
>
<Layer ref={layerRef}>
{/* Artboard background */}
{renderArtboard()}
{/* Canvas objects */}
{objects.map(renderObject)}
{/* Transformer for selection handles */}
<Transformer
ref={transformerRef}
flipEnabled={false}
boundBoxFunc={(_oldBox, newBox) => {
if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) {
return _oldBox;
}
return newBox;
}}
/>
{/* Rubber-band selection rectangle */}
{rubberBand.visible && (
<Rect
x={rubberBand.x}
y={rubberBand.y}
width={rubberBand.width}
height={rubberBand.height}
fill="rgba(170, 59, 255, 0.1)"
stroke="rgba(170, 59, 255, 0.6)"
strokeWidth={1}
listening={false}
/>
)}
</Layer>
</Stage>
);
}
// -- KonvaImage wrapper (loads HTMLImageElement from src) --------------------
interface KonvaImageWrapperProps {
id: string;
src: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
opacity: number;
draggable: boolean;
onClick: (e: Konva.KonvaEventObject<MouseEvent>) => void;
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd: (e: Konva.KonvaEventObject<Event>) => void;
}
function KonvaImageWrapper({
src,
...restProps
}: KonvaImageWrapperProps) {
const [image, setImage] = useState<HTMLImageElement | null>(null);
useEffect(() => {
const img = new window.Image();
img.crossOrigin = 'anonymous';
img.onload = () => setImage(img);
img.src = src;
return () => {
img.onload = null;
};
}, [src]);
if (!image) return null;
return <KonvaImage {...restProps} image={image} />;
}
// -- Geometry helpers ---------------------------------------------------------
function getObjWidth(obj: CanvasObject): number {
switch (obj.type) {
case 'rect':
case 'image':
return obj.width;
case 'circle':
return obj.radius * 2;
case 'ellipse':
return obj.radiusX * 2;
case 'line':
return Math.max(...obj.points.filter((_, i) => i % 2 === 0)) -
Math.min(...obj.points.filter((_, i) => i % 2 === 0));
}
}
function getObjHeight(obj: CanvasObject): number {
switch (obj.type) {
case 'rect':
case 'image':
return obj.height;
case 'circle':
return obj.radius * 2;
case 'ellipse':
return obj.radiusY * 2;
case 'line':
return Math.max(...obj.points.filter((_, i) => i % 2 === 1)) -
Math.min(...obj.points.filter((_, i) => i % 2 === 1));
}
}
function rectsIntersect(
ax: number, ay: number, aw: number, ah: number,
bx: number, by: number, bw: number, bh: number,
): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}

View file

@ -0,0 +1,70 @@
/* Design Canvas (View 2) layout */
.container {
display: flex;
flex-direction: column;
width: 100%;
height: 100svh;
overflow: hidden;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
flex-shrink: 0;
}
.toolGroup {
display: flex;
gap: 4px;
}
.mainArea {
display: flex;
flex: 1;
min-height: 0;
}
.canvasArea {
flex: 1;
min-width: 0;
background: var(--code-bg);
position: relative;
overflow: hidden;
}
.panelArea {
flex: 0 0 260px;
border-left: 1px solid var(--border);
background: var(--bg);
overflow-y: auto;
padding: 12px;
}
.panelPlaceholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text);
font-size: 14px;
opacity: 0.6;
}
@media (max-width: 768px) {
.mainArea {
flex-direction: column;
}
.panelArea {
flex: none;
border-left: none;
border-top: 1px solid var(--border);
height: 200px;
}
}

View file

@ -0,0 +1,228 @@
/**
* DesignCanvas View 2 container.
*
* Layout: top toolbar area, left canvas (KonvaStage), right panel area.
* Manages tool state, artboard setup flow, and imported SVG loading.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import type Konva from 'konva';
import type { TraceMetadata } from '../types/engine';
import type { ArtboardConfig, CanvasObject } from '../types/canvas';
import { useCanvasState } from '../hooks/useCanvasState';
import ArtboardSetup from '../components/canvas/ArtboardSetup';
import KonvaStage from '../components/canvas/KonvaStage';
import type { CanvasTool } from '../components/canvas/KonvaStage';
import { toPx } from '../utils/artboardShapes';
import styles from './DesignCanvas.module.css';
interface DesignCanvasProps {
svgData: string | null;
traceMetadata: TraceMetadata | null;
}
const TOOLS: { tool: CanvasTool; label: string; icon: string }[] = [
{ tool: 'pointer', label: 'Select', icon: '↖' },
{ tool: 'rect', label: 'Rectangle', icon: '▭' },
{ tool: 'circle', label: 'Circle', icon: '○' },
{ tool: 'ellipse', label: 'Ellipse', icon: '⬯' },
{ tool: 'line', label: 'Line', icon: '' },
];
export default function DesignCanvas({
svgData,
traceMetadata,
}: DesignCanvasProps) {
const {
state,
addObject,
removeObject: _removeObject,
updateObject,
selectObjects,
deselectAll,
setArtboard,
undo,
redo,
canUndo,
canRedo,
} = useCanvasState(traceMetadata);
const [activeTool, setActiveTool] = useState<CanvasTool>('pointer');
const [showArtboardSetup, setShowArtboardSetup] = useState(true);
const [svgImported, setSvgImported] = useState(false);
const stageRef = useRef<Konva.Stage | null>(null);
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
const [stageSize, setStageSize] = useState({ width: 800, height: 600 });
// -- Resize observer for canvas container --------------------------------
useEffect(() => {
const container = canvasContainerRef.current;
if (!container) return;
const updateSize = () => {
setStageSize({
width: container.clientWidth,
height: container.clientHeight,
});
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(container);
return () => observer.disconnect();
}, [showArtboardSetup]);
// -- Artboard setup -------------------------------------------------------
const handleArtboardConfirm = useCallback(
(config: ArtboardConfig) => {
setArtboard(config);
setShowArtboardSetup(false);
},
[setArtboard],
);
// -- Import SVG from View 1 -----------------------------------------------
useEffect(() => {
if (!svgData || svgImported || !state.artboard) return;
const blob = new Blob([svgData], { type: 'image/svg+xml' });
const url = URL.createObjectURL(blob);
// Load the image to get natural dimensions
const img = new window.Image();
img.onload = () => {
const artW = toPx(state.artboard!.width, state.artboard!.unit);
const artH = toPx(state.artboard!.height, state.artboard!.unit);
// Scale SVG to fit within artboard
const scale = Math.min(artW / img.width, artH / img.height, 1);
const scaledW = img.width * scale;
const scaledH = img.height * scale;
const imageObj: CanvasObject = {
type: 'image',
id: `imported-svg-${Date.now()}`,
name: 'Imported SVG',
x: (artW - scaledW) / 2,
y: (artH - scaledH) / 2,
width: scaledW,
height: scaledH,
rotation: 0,
visible: true,
locked: false,
opacity: 1,
src: url,
};
addObject(imageObj);
setSvgImported(true);
};
img.src = url;
}, [svgData, svgImported, state.artboard, addObject]);
// -- Selection handling ---------------------------------------------------
const handleSelect = useCallback(
(ids: string[], additive: boolean) => {
if (additive) {
const current = new Set(state.selectedIds);
for (const id of ids) {
if (current.has(id)) {
current.delete(id);
} else {
current.add(id);
}
}
selectObjects([...current]);
} else {
selectObjects(ids);
}
},
[state.selectedIds, selectObjects],
);
// -- Render ---------------------------------------------------------------
if (showArtboardSetup) {
return <ArtboardSetup onConfirm={handleArtboardConfirm} />;
}
return (
<div className={styles.container}>
{/* Top toolbar */}
<div className={styles.toolbar}>
<div className={styles.toolGroup}>
{TOOLS.map(({ tool, label, icon }) => (
<button
key={tool}
type="button"
className={`canvas-tool-btn${activeTool === tool ? ' canvas-tool-btn--active' : ''}`}
onClick={() => setActiveTool(tool)}
title={label}
aria-pressed={activeTool === tool}
>
<span className="canvas-tool-icon">{icon}</span>
<span className="canvas-tool-label">{label}</span>
</button>
))}
</div>
<div className={styles.toolGroup}>
<button
type="button"
className="canvas-tool-btn"
onClick={undo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
>
Undo
</button>
<button
type="button"
className="canvas-tool-btn"
onClick={redo}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z)"
>
Redo
</button>
</div>
</div>
{/* Main area: canvas + right panel */}
<div className={styles.mainArea}>
{/* Canvas */}
<div
className={styles.canvasArea}
ref={canvasContainerRef}
data-testid="canvas-container"
>
<KonvaStage
width={stageSize.width}
height={stageSize.height}
artboard={state.artboard}
objects={state.objects}
selectedIds={state.selectedIds}
activeTool={activeTool}
onSelect={handleSelect}
onDeselectAll={deselectAll}
onAddObject={addObject}
onUpdateObject={updateObject}
stageRef={stageRef}
/>
</div>
{/* Right panel placeholder (wired in T03) */}
<div className={styles.panelArea} data-testid="panel-area">
<div className={styles.panelPlaceholder}>
Object &amp; Properties Panel
</div>
</div>
</div>
</div>
);
}