diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index 32ddb1c..2ff6fb9 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -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"} diff --git a/.gsd/milestones/M002/slices/S02/S02-PLAN.md b/.gsd/milestones/M002/slices/S02/S02-PLAN.md index 00b327a..8387e4f 100644 --- a/.gsd/milestones/M002/slices/S02/S02-PLAN.md +++ b/.gsd/milestones/M002/slices/S02/S02-PLAN.md @@ -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. diff --git a/.gsd/milestones/M002/slices/S02/tasks/T01-VERIFY.json b/.gsd/milestones/M002/slices/S02/tasks/T01-VERIFY.json new file mode 100644 index 0000000..b82cce0 --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/tasks/T01-VERIFY.json @@ -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 +} diff --git a/.gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md b/.gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md new file mode 100644 index 0000000..79722fc --- /dev/null +++ b/.gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md @@ -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. diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json index 12b9be0..bb1063c 100644 --- a/.gsd/state-manifest.json +++ b/.gsd/state-manifest.json @@ -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" } ] } \ No newline at end of file diff --git a/app/src/App.css b/app/src/App.css index a3db9a7..6e2539c 100644 --- a/app/src/App.css +++ b/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; +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 771b53b..13ab741 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -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('import'); - const [_svgResult, setSvgResult] = useState(null); - const [_traceMetadata, setTraceMetadata] = useState(null); + const [svgResult, setSvgResult] = useState(null); + const [traceMetadata, setTraceMetadata] = useState(null); const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => { setSvgResult(svgOutput); @@ -19,7 +20,9 @@ function App() { return (
{view === 'import' && } - {view === 'canvas' &&
View 2: Design Canvas
} + {view === 'canvas' && ( + + )} {view === 'export' &&
View 3: Export
}
); diff --git a/app/src/components/canvas/KonvaStage.tsx b/app/src/components/canvas/KonvaStage.tsx new file mode 100644 index 0000000..394ed1e --- /dev/null +++ b/app/src/components/canvas/KonvaStage.tsx @@ -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) => void; + stageRef: RefObject; +} + +// -- Helpers ------------------------------------------------------------------ + +let _nextId = 1; +function nextId(type: CanvasObjectType): string { + return `${type}-${Date.now()}-${_nextId++}`; +} + +const DASH_MAP: Record = { + 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(null); + const layerRef = useRef(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 = ( + + ); + + if (clipPathData) { + // Render the clip path outline for shield/pennant/custom shapes + return ( + <> + {bgRect} + + + ); + } + + if (artboard.shape === 'circle') { + const r = Math.min(artW, artH) / 2; + return ( + <> + {bgRect} + + + ); + } + + if (artboard.shape === 'oval') { + return ( + <> + {bgRect} + + + ); + } + + // 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) => { + e.cancelBubble = true; + const isShift = e.evt.shiftKey; + onSelect([obj.id], isShift); + }, + onDragEnd: (e: Konva.KonvaEventObject) => { + onUpdateObject(obj.id, { + x: e.target.x() - offsetX, + y: e.target.y() - offsetY, + }); + }, + onTransformEnd: (e: Konva.KonvaEventObject) => { + const node = e.target; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + node.scaleX(1); + node.scaleY(1); + + const changes: Partial = { + x: node.x() - offsetX, + y: node.y() - offsetY, + rotation: node.rotation(), + }; + + if (obj.type === 'rect' || obj.type === 'image') { + (changes as Record).width = Math.max(5, node.width() * scaleX); + (changes as Record).height = Math.max(5, node.height() * scaleY); + } else if (obj.type === 'circle') { + (changes as Record).radius = Math.max(5, (node.width() * scaleX) / 2); + } else if (obj.type === 'ellipse') { + (changes as Record).radiusX = Math.max(5, (node.width() * scaleX) / 2); + (changes as Record).radiusY = Math.max(5, (node.height() * scaleY) / 2); + } + + onUpdateObject(obj.id, changes); + }, + }; + + switch (obj.type) { + case 'rect': + return ( + + ); + + case 'circle': + return ( + + ); + + case 'ellipse': + return ( + + ); + + case 'line': + return ( + + ); + + case 'image': + return ( + + ); + + default: + return null; + } + } + + // -- Shape creation on click in tool mode -------------------------------- + + const handleStageMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + // 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) => { + 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) => { + 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 ( + + + {/* Artboard background */} + {renderArtboard()} + + {/* Canvas objects */} + {objects.map(renderObject)} + + {/* Transformer for selection handles */} + { + if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { + return _oldBox; + } + return newBox; + }} + /> + + {/* Rubber-band selection rectangle */} + {rubberBand.visible && ( + + )} + + + ); +} + +// -- 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) => void; + onDragEnd: (e: Konva.KonvaEventObject) => void; + onTransformEnd: (e: Konva.KonvaEventObject) => void; +} + +function KonvaImageWrapper({ + src, + ...restProps +}: KonvaImageWrapperProps) { + const [image, setImage] = useState(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 ; +} + +// -- 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; +} diff --git a/app/src/views/DesignCanvas.module.css b/app/src/views/DesignCanvas.module.css new file mode 100644 index 0000000..f3ea012 --- /dev/null +++ b/app/src/views/DesignCanvas.module.css @@ -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; + } +} diff --git a/app/src/views/DesignCanvas.tsx b/app/src/views/DesignCanvas.tsx new file mode 100644 index 0000000..46d9382 --- /dev/null +++ b/app/src/views/DesignCanvas.tsx @@ -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('pointer'); + const [showArtboardSetup, setShowArtboardSetup] = useState(true); + const [svgImported, setSvgImported] = useState(false); + + const stageRef = useRef(null); + const canvasContainerRef = useRef(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 ; + } + + return ( +
+ {/* Top toolbar */} +
+
+ {TOOLS.map(({ tool, label, icon }) => ( + + ))} +
+ +
+ + +
+
+ + {/* Main area: canvas + right panel */} +
+ {/* Canvas */} +
+ +
+ + {/* Right panel placeholder (wired in T03) */} +
+
+ Object & Properties Panel +
+
+
+
+ ); +}