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":"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":"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
|
||||
- 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
|
||||
- [ ] **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:
|
||||
- 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,
|
||||
"exported_at": "2026-03-26T05:31:55.543Z",
|
||||
"exported_at": "2026-03-26T05:36:12.633Z",
|
||||
"milestones": [
|
||||
{
|
||||
"id": "M001",
|
||||
|
|
@ -1110,19 +1110,29 @@
|
|||
"milestone_id": "M002",
|
||||
"slice_id": "S02",
|
||||
"id": "T02",
|
||||
"title": "Konva stage with artboard rendering, imported SVG, selection handles, and shape tools",
|
||||
"status": "pending",
|
||||
"one_liner": "",
|
||||
"narrative": "",
|
||||
"verification_result": "",
|
||||
"title": "Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container",
|
||||
"status": "complete",
|
||||
"one_liner": "Built KonvaStage with artboard rendering, shape tools, selection handles, rubber-band multi-select, SVG import, and DesignCanvas view container",
|
||||
"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": "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": "",
|
||||
"completed_at": null,
|
||||
"completed_at": "2026-03-26T05:36:12.587Z",
|
||||
"blocker_discovered": false,
|
||||
"deviations": "",
|
||||
"known_issues": "",
|
||||
"key_files": [],
|
||||
"key_decisions": [],
|
||||
"full_summary_md": "",
|
||||
"deviations": "None.",
|
||||
"known_issues": "None.",
|
||||
"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"
|
||||
],
|
||||
"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.",
|
||||
"estimate": "2h30m",
|
||||
"files": [
|
||||
|
|
@ -1581,6 +1591,28 @@
|
|||
"verdict": "✅ pass",
|
||||
"duration_ms": 2100,
|
||||
"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;
|
||||
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 type { TraceMetadata } from './types/engine';
|
||||
import ImportConvert from './views/ImportConvert';
|
||||
import DesignCanvas from './views/DesignCanvas';
|
||||
import './App.css';
|
||||
|
||||
type ViewState = 'import' | 'canvas' | 'export';
|
||||
|
||||
function App() {
|
||||
const [view, setView] = useState<ViewState>('import');
|
||||
const [_svgResult, setSvgResult] = useState<string | null>(null);
|
||||
const [_traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
|
||||
const [svgResult, setSvgResult] = useState<string | null>(null);
|
||||
const [traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
|
||||
|
||||
const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => {
|
||||
setSvgResult(svgOutput);
|
||||
|
|
@ -19,7 +20,9 @@ function App() {
|
|||
return (
|
||||
<div id="app">
|
||||
{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>}
|
||||
</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