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:
parent
59a034ab75
commit
6ec52ab7b6
10 changed files with 1274 additions and 16 deletions
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
30
.gsd/milestones/M002/slices/S02/tasks/T01-VERIFY.json
Normal file
30
.gsd/milestones/M002/slices/S02/tasks/T01-VERIFY.json
Normal 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
|
||||||
|
}
|
||||||
85
.gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md
Normal file
85
.gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md
Normal 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.
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
191
app/src/App.css
191
app/src/App.css
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
618
app/src/components/canvas/KonvaStage.tsx
Normal file
618
app/src/components/canvas/KonvaStage.tsx
Normal 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;
|
||||||
|
}
|
||||||
70
app/src/views/DesignCanvas.module.css
Normal file
70
app/src/views/DesignCanvas.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
app/src/views/DesignCanvas.tsx
Normal file
228
app/src/views/DesignCanvas.tsx
Normal 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 & Properties Panel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue