feat: Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToo…
- "app/src/components/canvas/ObjectPanel.tsx" - "app/src/components/canvas/AlignmentBar.tsx" - "app/src/components/canvas/CanvasToolbar.tsx" - "app/src/components/canvas/ShapeProperties.tsx" - "app/src/views/DesignCanvas.tsx" - "app/src/App.css" GSD-Task: S02/T03
This commit is contained in:
parent
6ec52ab7b6
commit
a37b52eefa
11 changed files with 1331 additions and 64 deletions
|
|
@ -21,3 +21,4 @@
|
|||
{"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"}
|
||||
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T03"},"ts":"2026-03-26T05:40:11.226Z","actor":"agent","hash":"0db7c0c1fa2fd555","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ IMPORTANT tsconfig constraints: noUnusedLocals and noUnusedParameters are strict
|
|||
- Estimate: 2h30m
|
||||
- Files: app/src/views/DesignCanvas.tsx, app/src/views/DesignCanvas.module.css, app/src/components/canvas/KonvaStage.tsx, app/src/App.tsx, app/src/App.css
|
||||
- Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
|
||||
- [ ] **T03: Object panel, alignment bar, canvas toolbar, and shape properties panel** — Build the UI panels that surround the canvas: the right-side ObjectPanel for layer management, the AlignmentBar for spatial arrangement, the CanvasToolbar for tool switching and canvas controls, and the ShapeProperties panel for editing selected objects.
|
||||
- [x] **T03: Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view** — Build the UI panels that surround the canvas: the right-side ObjectPanel for layer management, the AlignmentBar for spatial arrangement, the CanvasToolbar for tool switching and canvas controls, and the ShapeProperties panel for editing selected objects.
|
||||
|
||||
ObjectPanel:
|
||||
- Lists canvas objects by z-order (top of list = frontmost layer)
|
||||
|
|
|
|||
30
.gsd/milestones/M002/slices/S02/tasks/T02-VERIFY.json
Normal file
30
.gsd/milestones/M002/slices/S02/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T02",
|
||||
"unitId": "M002/S02/T02",
|
||||
"timestamp": 1774503379746,
|
||||
"passed": false,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd app",
|
||||
"exitCode": 0,
|
||||
"durationMs": 4,
|
||||
"verdict": "pass"
|
||||
},
|
||||
{
|
||||
"command": "npx tsc --noEmit",
|
||||
"exitCode": 1,
|
||||
"durationMs": 769,
|
||||
"verdict": "fail"
|
||||
},
|
||||
{
|
||||
"command": "npx vitest run --reporter=verbose",
|
||||
"exitCode": 1,
|
||||
"durationMs": 1313,
|
||||
"verdict": "fail"
|
||||
}
|
||||
],
|
||||
"retryAttempt": 1,
|
||||
"maxRetries": 2
|
||||
}
|
||||
87
.gsd/milestones/M002/slices/S02/tasks/T03-SUMMARY.md
Normal file
87
.gsd/milestones/M002/slices/S02/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
id: T03
|
||||
parent: S02
|
||||
milestone: M002
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["app/src/components/canvas/ObjectPanel.tsx", "app/src/components/canvas/AlignmentBar.tsx", "app/src/components/canvas/CanvasToolbar.tsx", "app/src/components/canvas/ShapeProperties.tsx", "app/src/views/DesignCanvas.tsx", "app/src/App.css"]
|
||||
key_decisions: ["Extracted inline toolbar from DesignCanvas into standalone CanvasToolbar component with zoom and grid state", "ObjectPanel displays objects in reverse z-order (frontmost layer at top) matching standard design tool UX", "ShapeProperties uses type narrowing via discriminated union to show type-specific fields"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "Ran `cd app && npx tsc --noEmit` — zero type errors. Ran `cd app && npx vitest run --reporter=verbose` — all 71 tests pass (6 test files, 0 failures)."
|
||||
completed_at: 2026-03-26T05:40:11.171Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view
|
||||
|
||||
> Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T03
|
||||
parent: S02
|
||||
milestone: M002
|
||||
key_files:
|
||||
- app/src/components/canvas/ObjectPanel.tsx
|
||||
- app/src/components/canvas/AlignmentBar.tsx
|
||||
- app/src/components/canvas/CanvasToolbar.tsx
|
||||
- app/src/components/canvas/ShapeProperties.tsx
|
||||
- app/src/views/DesignCanvas.tsx
|
||||
- app/src/App.css
|
||||
key_decisions:
|
||||
- Extracted inline toolbar from DesignCanvas into standalone CanvasToolbar component with zoom and grid state
|
||||
- ObjectPanel displays objects in reverse z-order (frontmost layer at top) matching standard design tool UX
|
||||
- ShapeProperties uses type narrowing via discriminated union to show type-specific fields
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-03-26T05:40:11.186Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view
|
||||
|
||||
**Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view**
|
||||
|
||||
## What Happened
|
||||
|
||||
Created four React components forming the complete panel system around the canvas. ObjectPanel shows layers in reverse z-order with drag-to-reorder, visibility/lock toggles, and double-click rename. AlignmentBar provides 6 alignment + 2 distribute + center-on-artboard buttons using alignment utils from T01. CanvasToolbar replaces the inline toolbar with tool switcher, undo/redo, grid toggle, and zoom controls. ShapeProperties shows editable stroke/fill/dimensions/line-style/opacity for the selected shape. Updated DesignCanvas.tsx to wire all panels to useCanvasState and added comprehensive CSS to App.css.
|
||||
|
||||
## Verification
|
||||
|
||||
Ran `cd app && npx tsc --noEmit` — zero type errors. 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 | 2000ms |
|
||||
| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2030ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `app/src/components/canvas/ObjectPanel.tsx`
|
||||
- `app/src/components/canvas/AlignmentBar.tsx`
|
||||
- `app/src/components/canvas/CanvasToolbar.tsx`
|
||||
- `app/src/components/canvas/ShapeProperties.tsx`
|
||||
- `app/src/views/DesignCanvas.tsx`
|
||||
- `app/src/App.css`
|
||||
|
||||
|
||||
## Deviations
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"version": 1,
|
||||
"exported_at": "2026-03-26T05:36:12.633Z",
|
||||
"exported_at": "2026-03-26T05:40:11.224Z",
|
||||
"milestones": [
|
||||
{
|
||||
"id": "M001",
|
||||
|
|
@ -1167,19 +1167,30 @@
|
|||
"milestone_id": "M002",
|
||||
"slice_id": "S02",
|
||||
"id": "T03",
|
||||
"title": "Object panel, alignment bar, canvas toolbar, and shape properties panel",
|
||||
"status": "pending",
|
||||
"one_liner": "",
|
||||
"narrative": "",
|
||||
"verification_result": "",
|
||||
"title": "Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view",
|
||||
"status": "complete",
|
||||
"one_liner": "Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view",
|
||||
"narrative": "Created four React components forming the complete panel system around the canvas. ObjectPanel shows layers in reverse z-order with drag-to-reorder, visibility/lock toggles, and double-click rename. AlignmentBar provides 6 alignment + 2 distribute + center-on-artboard buttons using alignment utils from T01. CanvasToolbar replaces the inline toolbar with tool switcher, undo/redo, grid toggle, and zoom controls. ShapeProperties shows editable stroke/fill/dimensions/line-style/opacity for the selected shape. Updated DesignCanvas.tsx to wire all panels to useCanvasState and added comprehensive CSS to App.css.",
|
||||
"verification_result": "Ran `cd app && npx tsc --noEmit` — zero type errors. 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:40:11.171Z",
|
||||
"blocker_discovered": false,
|
||||
"deviations": "",
|
||||
"known_issues": "",
|
||||
"key_files": [],
|
||||
"key_decisions": [],
|
||||
"full_summary_md": "",
|
||||
"deviations": "None.",
|
||||
"known_issues": "None.",
|
||||
"key_files": [
|
||||
"app/src/components/canvas/ObjectPanel.tsx",
|
||||
"app/src/components/canvas/AlignmentBar.tsx",
|
||||
"app/src/components/canvas/CanvasToolbar.tsx",
|
||||
"app/src/components/canvas/ShapeProperties.tsx",
|
||||
"app/src/views/DesignCanvas.tsx",
|
||||
"app/src/App.css"
|
||||
],
|
||||
"key_decisions": [
|
||||
"Extracted inline toolbar from DesignCanvas into standalone CanvasToolbar component with zoom and grid state",
|
||||
"ObjectPanel displays objects in reverse z-order (frontmost layer at top) matching standard design tool UX",
|
||||
"ShapeProperties uses type narrowing via discriminated union to show type-specific fields"
|
||||
],
|
||||
"full_summary_md": "---\nid: T03\nparent: S02\nmilestone: M002\nkey_files:\n - app/src/components/canvas/ObjectPanel.tsx\n - app/src/components/canvas/AlignmentBar.tsx\n - app/src/components/canvas/CanvasToolbar.tsx\n - app/src/components/canvas/ShapeProperties.tsx\n - app/src/views/DesignCanvas.tsx\n - app/src/App.css\nkey_decisions:\n - Extracted inline toolbar from DesignCanvas into standalone CanvasToolbar component with zoom and grid state\n - ObjectPanel displays objects in reverse z-order (frontmost layer at top) matching standard design tool UX\n - ShapeProperties uses type narrowing via discriminated union to show type-specific fields\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T05:40:11.186Z\nblocker_discovered: false\n---\n\n# T03: Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view\n\n**Built four canvas UI panels (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties) with full state wiring into DesignCanvas view**\n\n## What Happened\n\nCreated four React components forming the complete panel system around the canvas. ObjectPanel shows layers in reverse z-order with drag-to-reorder, visibility/lock toggles, and double-click rename. AlignmentBar provides 6 alignment + 2 distribute + center-on-artboard buttons using alignment utils from T01. CanvasToolbar replaces the inline toolbar with tool switcher, undo/redo, grid toggle, and zoom controls. ShapeProperties shows editable stroke/fill/dimensions/line-style/opacity for the selected shape. Updated DesignCanvas.tsx to wire all panels to useCanvasState and added comprehensive CSS to App.css.\n\n## Verification\n\nRan `cd app && npx tsc --noEmit` — zero type errors. 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 | 2000ms |\n| 2 | `cd app && npx vitest run --reporter=verbose` | 0 | ✅ pass | 2030ms |\n\n\n## Deviations\n\nNone.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/components/canvas/ObjectPanel.tsx`\n- `app/src/components/canvas/AlignmentBar.tsx`\n- `app/src/components/canvas/CanvasToolbar.tsx`\n- `app/src/components/canvas/ShapeProperties.tsx`\n- `app/src/views/DesignCanvas.tsx`\n- `app/src/App.css`\n",
|
||||
"description": "Build the UI panels that surround the canvas: the right-side ObjectPanel for layer management, the AlignmentBar for spatial arrangement, the CanvasToolbar for tool switching and canvas controls, and the ShapeProperties panel for editing selected objects.\n\nObjectPanel:\n- Lists canvas objects by z-order (top of list = frontmost layer)\n- Each row shows: drag handle, object type icon, name (double-click to rename), visibility toggle (eye icon), lock toggle (lock icon)\n- Click row to select object on canvas, shift-click for multi-select\n- Drag-to-reorder changes z-order in state (which changes render order in KonvaStage)\n- Wire to useCanvasState: reorderObject, toggleVisibility, toggleLock, selectObjects\n\nAlignmentBar:\n- Appears when 1+ objects selected\n- Buttons: align left, align center, align right, align top, align middle, align bottom\n- When 2+ selected: distribute horizontally, distribute vertically\n- Center on artboard button (works with 1+ selected)\n- Consumes alignment utility functions from utils/alignment.ts\n- Dispatches batch updateObject calls through useCanvasState\n\nCanvasToolbar:\n- Tool buttons: pointer/select (default), rectangle, circle, ellipse, line\n- Active tool highlighted with accent color\n- Undo/redo buttons (disabled when stack empty) — connected to useCanvasState undo/redo\n- Grid toggle button\n- Zoom controls (zoom in, zoom out, fit to artboard)\n\nShapeProperties:\n- Shown when exactly 1 shape selected\n- Fields: stroke color (color input), stroke weight (number input), fill color (color input with toggle), width/height display\n- For line objects: line style dropdown (solid, dashed, dotted)\n- Changes dispatch updateObject through useCanvasState\n\nWire all panels into DesignCanvas.tsx layout slots.",
|
||||
"estimate": "2h",
|
||||
"files": [
|
||||
|
|
@ -1613,6 +1624,28 @@
|
|||
"verdict": "✅ pass",
|
||||
"duration_ms": 2060,
|
||||
"created_at": "2026-03-26T05:36:12.588Z"
|
||||
},
|
||||
{
|
||||
"id": 27,
|
||||
"task_id": "T03",
|
||||
"slice_id": "S02",
|
||||
"milestone_id": "M002",
|
||||
"command": "cd app && npx tsc --noEmit",
|
||||
"exit_code": 0,
|
||||
"verdict": "✅ pass",
|
||||
"duration_ms": 2000,
|
||||
"created_at": "2026-03-26T05:40:11.171Z"
|
||||
},
|
||||
{
|
||||
"id": 28,
|
||||
"task_id": "T03",
|
||||
"slice_id": "S02",
|
||||
"milestone_id": "M002",
|
||||
"command": "cd app && npx vitest run --reporter=verbose",
|
||||
"exit_code": 0,
|
||||
"verdict": "✅ pass",
|
||||
"duration_ms": 2030,
|
||||
"created_at": "2026-03-26T05:40:11.171Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
304
app/src/App.css
304
app/src/App.css
|
|
@ -595,3 +595,307 @@
|
|||
.artboard-setup-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ── Canvas Toolbar (CanvasToolbar component) ── */
|
||||
|
||||
.canvas-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.canvas-toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.canvas-toolbar-zoom-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-h);
|
||||
min-width: 42px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* ── Object Panel (layer list) ── */
|
||||
|
||||
.object-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.object-panel-header {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text);
|
||||
padding: 8px 0 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.object-panel-empty {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
opacity: 0.6;
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.object-panel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.object-panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.1s;
|
||||
font-size: 13px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.object-panel-row:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.object-panel-row--selected {
|
||||
background: var(--accent-bg);
|
||||
border-left: 2px solid var(--accent);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.object-panel-row--hidden {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.object-panel-drag {
|
||||
cursor: grab;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.object-panel-type-icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.object-panel-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.object-panel-name-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.object-panel-icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
border-radius: 3px;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.object-panel-icon-btn:hover {
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.object-panel-icon-btn--off {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.object-panel-icon-btn--on {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Alignment Bar ── */
|
||||
|
||||
.alignment-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.alignment-bar-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.alignment-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: var(--text-h);
|
||||
padding: 0;
|
||||
transition: border-color 0.1s, background-color 0.1s;
|
||||
}
|
||||
|
||||
.alignment-btn:hover {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-bg);
|
||||
}
|
||||
|
||||
.alignment-btn:active {
|
||||
background: var(--accent-bg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* ── Shape Properties ── */
|
||||
|
||||
.shape-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.shape-properties-header {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text);
|
||||
padding: 0 0 6px;
|
||||
}
|
||||
|
||||
.shape-prop-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.shape-prop-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.shape-prop-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.shape-prop-dims {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.shape-prop-color-input {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.shape-prop-number-input {
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
.shape-prop-fill-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shape-prop-fill-row .shape-prop-color-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shape-prop-fill-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-h);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.shape-prop-select {
|
||||
width: 100%;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
background: var(--bg);
|
||||
color: var(--text-h);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shape-prop-range-input {
|
||||
width: 100%;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.shape-prop-range-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
|
|
|||
199
app/src/components/canvas/AlignmentBar.tsx
Normal file
199
app/src/components/canvas/AlignmentBar.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* AlignmentBar — spatial alignment and distribution tools.
|
||||
*
|
||||
* Appears when 1+ objects are selected.
|
||||
* Provides align (left/center/right/top/middle/bottom),
|
||||
* distribute (horizontal/vertical, 2+ selected),
|
||||
* and center-on-artboard.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ArtboardConfig, CanvasObject } from '../../types/canvas';
|
||||
import type { BoundingRect, PositionUpdate } from '../../utils/alignment';
|
||||
import {
|
||||
alignLeft,
|
||||
alignCenter,
|
||||
alignRight,
|
||||
alignTop,
|
||||
alignMiddle,
|
||||
alignBottom,
|
||||
distributeHorizontal,
|
||||
distributeVertical,
|
||||
centerOnArtboard,
|
||||
} from '../../utils/alignment';
|
||||
import { toPx } from '../../utils/artboardShapes';
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
function toBoundingRect(obj: CanvasObject): BoundingRect {
|
||||
let w: number, h: number;
|
||||
switch (obj.type) {
|
||||
case 'rect':
|
||||
case 'image':
|
||||
w = obj.width;
|
||||
h = obj.height;
|
||||
break;
|
||||
case 'circle':
|
||||
w = obj.radius * 2;
|
||||
h = obj.radius * 2;
|
||||
break;
|
||||
case 'ellipse':
|
||||
w = obj.radiusX * 2;
|
||||
h = obj.radiusY * 2;
|
||||
break;
|
||||
case 'line': {
|
||||
const xs = obj.points.filter((_, i) => i % 2 === 0);
|
||||
const ys = obj.points.filter((_, i) => i % 2 === 1);
|
||||
w = Math.max(...xs) - Math.min(...xs);
|
||||
h = Math.max(...ys) - Math.min(...ys);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { id: obj.id, x: obj.x, y: obj.y, width: w, height: h };
|
||||
}
|
||||
|
||||
// -- Props --------------------------------------------------------------------
|
||||
|
||||
export interface AlignmentBarProps {
|
||||
objects: CanvasObject[];
|
||||
selectedIds: string[];
|
||||
artboard: ArtboardConfig | null;
|
||||
onUpdateObject: (id: string, changes: Partial<CanvasObject>) => void;
|
||||
}
|
||||
|
||||
// -- Component ----------------------------------------------------------------
|
||||
|
||||
export default function AlignmentBar({
|
||||
objects,
|
||||
selectedIds,
|
||||
artboard,
|
||||
onUpdateObject,
|
||||
}: AlignmentBarProps) {
|
||||
const selectedObjects = useMemo(
|
||||
() => objects.filter((o) => selectedIds.includes(o.id)),
|
||||
[objects, selectedIds],
|
||||
);
|
||||
|
||||
const applyUpdates = useCallback(
|
||||
(updates: PositionUpdate[]) => {
|
||||
for (const u of updates) {
|
||||
onUpdateObject(u.id, { x: u.x, y: u.y });
|
||||
}
|
||||
},
|
||||
[onUpdateObject],
|
||||
);
|
||||
|
||||
const rects = useMemo(
|
||||
() => selectedObjects.map(toBoundingRect),
|
||||
[selectedObjects],
|
||||
);
|
||||
|
||||
if (selectedIds.length === 0) return null;
|
||||
|
||||
const hasMultiple = selectedIds.length >= 2;
|
||||
|
||||
return (
|
||||
<div className="alignment-bar" data-testid="alignment-bar">
|
||||
{/* Alignment buttons */}
|
||||
<div className="alignment-bar-group" data-testid="align-group">
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(alignLeft(rects))}
|
||||
title="Align left"
|
||||
aria-label="Align left"
|
||||
>
|
||||
⬱
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(alignCenter(rects))}
|
||||
title="Align center"
|
||||
aria-label="Align center horizontally"
|
||||
>
|
||||
⬌
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(alignRight(rects))}
|
||||
title="Align right"
|
||||
aria-label="Align right"
|
||||
>
|
||||
⬲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(alignTop(rects))}
|
||||
title="Align top"
|
||||
aria-label="Align top"
|
||||
>
|
||||
⬑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(alignMiddle(rects))}
|
||||
title="Align middle"
|
||||
aria-label="Align middle vertically"
|
||||
>
|
||||
⬍
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(alignBottom(rects))}
|
||||
title="Align bottom"
|
||||
aria-label="Align bottom"
|
||||
>
|
||||
⬐
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Distribute buttons (only when 2+ selected) */}
|
||||
{hasMultiple && (
|
||||
<div className="alignment-bar-group" data-testid="distribute-group">
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(distributeHorizontal(rects))}
|
||||
title="Distribute horizontally"
|
||||
aria-label="Distribute horizontally"
|
||||
>
|
||||
⟺
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => applyUpdates(distributeVertical(rects))}
|
||||
title="Distribute vertically"
|
||||
aria-label="Distribute vertically"
|
||||
>
|
||||
⟷
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center on artboard */}
|
||||
{artboard && (
|
||||
<div className="alignment-bar-group" data-testid="center-group">
|
||||
<button
|
||||
type="button"
|
||||
className="alignment-btn"
|
||||
onClick={() => {
|
||||
const artW = toPx(artboard.width, artboard.unit);
|
||||
const artH = toPx(artboard.height, artboard.unit);
|
||||
applyUpdates(centerOnArtboard(rects, artW, artH));
|
||||
}}
|
||||
title="Center on artboard"
|
||||
aria-label="Center on artboard"
|
||||
>
|
||||
⊹
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
app/src/components/canvas/CanvasToolbar.tsx
Normal file
145
app/src/components/canvas/CanvasToolbar.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* CanvasToolbar — tool switcher, undo/redo, grid toggle, zoom controls.
|
||||
*
|
||||
* Rendered at the top of the DesignCanvas view.
|
||||
*/
|
||||
|
||||
import type { CanvasTool } from './KonvaStage';
|
||||
|
||||
// -- Tool definitions --------------------------------------------------------
|
||||
|
||||
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: '╱' },
|
||||
];
|
||||
|
||||
// -- Props --------------------------------------------------------------------
|
||||
|
||||
export interface CanvasToolbarProps {
|
||||
activeTool: CanvasTool;
|
||||
onToolChange: (tool: CanvasTool) => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
showGrid: boolean;
|
||||
onToggleGrid: () => void;
|
||||
zoomLevel: number;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onZoomFit: () => void;
|
||||
}
|
||||
|
||||
// -- Component ----------------------------------------------------------------
|
||||
|
||||
export default function CanvasToolbar({
|
||||
activeTool,
|
||||
onToolChange,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
showGrid,
|
||||
onToggleGrid,
|
||||
zoomLevel,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onZoomFit,
|
||||
}: CanvasToolbarProps) {
|
||||
return (
|
||||
<div className="canvas-toolbar" data-testid="canvas-toolbar">
|
||||
{/* Tool buttons */}
|
||||
<div className="canvas-toolbar-group" data-testid="tool-group">
|
||||
{TOOLS.map(({ tool, label, icon }) => (
|
||||
<button
|
||||
key={tool}
|
||||
type="button"
|
||||
className={`canvas-tool-btn${activeTool === tool ? ' canvas-tool-btn--active' : ''}`}
|
||||
onClick={() => onToolChange(tool)}
|
||||
title={label}
|
||||
aria-pressed={activeTool === tool}
|
||||
data-testid={`tool-btn-${tool}`}
|
||||
>
|
||||
<span className="canvas-tool-icon">{icon}</span>
|
||||
<span className="canvas-tool-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Undo / Redo */}
|
||||
<div className="canvas-toolbar-group" data-testid="undo-redo-group">
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title="Undo (Ctrl+Z)"
|
||||
data-testid="undo-btn"
|
||||
>
|
||||
↩ Undo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
data-testid="redo-btn"
|
||||
>
|
||||
↪ Redo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid toggle */}
|
||||
<div className="canvas-toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`canvas-tool-btn${showGrid ? ' canvas-tool-btn--active' : ''}`}
|
||||
onClick={onToggleGrid}
|
||||
title={showGrid ? 'Hide grid' : 'Show grid'}
|
||||
aria-pressed={showGrid}
|
||||
data-testid="grid-toggle-btn"
|
||||
>
|
||||
# Grid
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="canvas-toolbar-group" data-testid="zoom-group">
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
onClick={onZoomOut}
|
||||
title="Zoom out"
|
||||
data-testid="zoom-out-btn"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="canvas-toolbar-zoom-label" data-testid="zoom-level">
|
||||
{Math.round(zoomLevel * 100)}%
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
onClick={onZoomIn}
|
||||
title="Zoom in"
|
||||
data-testid="zoom-in-btn"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-tool-btn"
|
||||
onClick={onZoomFit}
|
||||
title="Fit to artboard"
|
||||
data-testid="zoom-fit-btn"
|
||||
>
|
||||
⊞ Fit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
app/src/components/canvas/ObjectPanel.tsx
Normal file
203
app/src/components/canvas/ObjectPanel.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* ObjectPanel — right-side layer list for canvas objects.
|
||||
*
|
||||
* Shows objects ordered by z-index (top of list = frontmost).
|
||||
* Supports: row select, shift-click multi-select, double-click rename,
|
||||
* visibility toggle, lock toggle, and drag-to-reorder.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { CanvasObject } from '../../types/canvas';
|
||||
|
||||
// -- Icons (simple text-based) -----------------------------------------------
|
||||
|
||||
const TYPE_ICONS: Record<CanvasObject['type'], string> = {
|
||||
rect: '▭',
|
||||
circle: '○',
|
||||
ellipse: '⬯',
|
||||
line: '╱',
|
||||
image: '🖼',
|
||||
};
|
||||
|
||||
// -- Props --------------------------------------------------------------------
|
||||
|
||||
export interface ObjectPanelProps {
|
||||
objects: CanvasObject[];
|
||||
selectedIds: string[];
|
||||
onSelect: (ids: string[], additive: boolean) => void;
|
||||
onReorder: (id: string, toIndex: number) => void;
|
||||
onToggleVisibility: (id: string) => void;
|
||||
onToggleLock: (id: string) => void;
|
||||
onRename: (id: string, newName: string) => void;
|
||||
}
|
||||
|
||||
// -- Component ----------------------------------------------------------------
|
||||
|
||||
export default function ObjectPanel({
|
||||
objects,
|
||||
selectedIds,
|
||||
onSelect,
|
||||
onReorder,
|
||||
onToggleVisibility,
|
||||
onToggleLock,
|
||||
onRename,
|
||||
}: ObjectPanelProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragItemRef = useRef<string | null>(null);
|
||||
const dragOverRef = useRef<number | null>(null);
|
||||
|
||||
// Show list in reverse z-order so frontmost layer is at top
|
||||
const displayObjects = [...objects].reverse();
|
||||
|
||||
// -- Row click → select ---------------------------------------------------
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
onSelect([id], e.shiftKey);
|
||||
},
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
// -- Double-click → rename ------------------------------------------------
|
||||
|
||||
const handleDoubleClick = useCallback((id: string, currentName: string) => {
|
||||
setEditingId(id);
|
||||
setEditValue(currentName);
|
||||
// Focus input on next tick
|
||||
setTimeout(() => inputRef.current?.select(), 0);
|
||||
}, []);
|
||||
|
||||
const handleRenameSubmit = useCallback(
|
||||
(id: string) => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed) {
|
||||
onRename(id, trimmed);
|
||||
}
|
||||
setEditingId(null);
|
||||
},
|
||||
[editValue, onRename],
|
||||
);
|
||||
|
||||
// -- Drag to reorder ------------------------------------------------------
|
||||
|
||||
const handleDragStart = useCallback((id: string) => {
|
||||
dragItemRef.current = id;
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, displayIndex: number) => {
|
||||
e.preventDefault();
|
||||
dragOverRef.current = displayIndex;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(_e: React.DragEvent) => {
|
||||
const dragId = dragItemRef.current;
|
||||
const overDisplayIdx = dragOverRef.current;
|
||||
if (dragId == null || overDisplayIdx == null) return;
|
||||
|
||||
// Convert display index (reversed) back to objects array index
|
||||
const toIndex = objects.length - 1 - overDisplayIdx;
|
||||
onReorder(dragId, toIndex);
|
||||
|
||||
dragItemRef.current = null;
|
||||
dragOverRef.current = null;
|
||||
},
|
||||
[objects.length, onReorder],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="object-panel" data-testid="object-panel">
|
||||
<div className="object-panel-header">Layers</div>
|
||||
{displayObjects.length === 0 ? (
|
||||
<div className="object-panel-empty">No objects on canvas</div>
|
||||
) : (
|
||||
<div className="object-panel-list" role="listbox" aria-label="Layer list">
|
||||
{displayObjects.map((obj, displayIdx) => {
|
||||
const isSelected = selectedIds.includes(obj.id);
|
||||
const isEditing = editingId === obj.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`object-panel-row${isSelected ? ' object-panel-row--selected' : ''}${!obj.visible ? ' object-panel-row--hidden' : ''}`}
|
||||
onClick={(e) => handleRowClick(obj.id, e)}
|
||||
onDoubleClick={() => handleDoubleClick(obj.id, obj.name)}
|
||||
draggable={!isEditing}
|
||||
onDragStart={() => handleDragStart(obj.id)}
|
||||
onDragOver={(e) => handleDragOver(e, displayIdx)}
|
||||
onDrop={handleDrop}
|
||||
data-testid={`object-row-${obj.id}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<span className="object-panel-drag" aria-hidden="true">⠿</span>
|
||||
|
||||
{/* Type icon */}
|
||||
<span className="object-panel-type-icon" title={obj.type}>
|
||||
{TYPE_ICONS[obj.type]}
|
||||
</span>
|
||||
|
||||
{/* Name (or edit input) */}
|
||||
{isEditing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="object-panel-name-input"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={() => handleRenameSubmit(obj.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameSubmit(obj.id);
|
||||
if (e.key === 'Escape') setEditingId(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid={`rename-input-${obj.id}`}
|
||||
/>
|
||||
) : (
|
||||
<span className="object-panel-name" title={obj.name}>
|
||||
{obj.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Visibility toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className={`object-panel-icon-btn${obj.visible ? '' : ' object-panel-icon-btn--off'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility(obj.id);
|
||||
}}
|
||||
title={obj.visible ? 'Hide' : 'Show'}
|
||||
aria-label={obj.visible ? 'Hide layer' : 'Show layer'}
|
||||
data-testid={`visibility-toggle-${obj.id}`}
|
||||
>
|
||||
{obj.visible ? '👁' : '👁🗨'}
|
||||
</button>
|
||||
|
||||
{/* Lock toggle */}
|
||||
<button
|
||||
type="button"
|
||||
className={`object-panel-icon-btn${obj.locked ? ' object-panel-icon-btn--on' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleLock(obj.id);
|
||||
}}
|
||||
title={obj.locked ? 'Unlock' : 'Lock'}
|
||||
aria-label={obj.locked ? 'Unlock layer' : 'Lock layer'}
|
||||
data-testid={`lock-toggle-${obj.id}`}
|
||||
>
|
||||
{obj.locked ? '🔒' : '🔓'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
232
app/src/components/canvas/ShapeProperties.tsx
Normal file
232
app/src/components/canvas/ShapeProperties.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* ShapeProperties — property editor for a single selected shape.
|
||||
*
|
||||
* Shows stroke color, stroke weight, fill color (with toggle), dimensions.
|
||||
* For line objects: line style dropdown (solid, dashed, dotted).
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import type { CanvasObject, LineStyle } from '../../types/canvas';
|
||||
|
||||
// -- Helpers ------------------------------------------------------------------
|
||||
|
||||
function getWidth(obj: CanvasObject): number {
|
||||
switch (obj.type) {
|
||||
case 'rect':
|
||||
case 'image':
|
||||
return Math.round(obj.width * 100) / 100;
|
||||
case 'circle':
|
||||
return Math.round(obj.radius * 2 * 100) / 100;
|
||||
case 'ellipse':
|
||||
return Math.round(obj.radiusX * 2 * 100) / 100;
|
||||
case 'line': {
|
||||
const xs = obj.points.filter((_, i) => i % 2 === 0);
|
||||
return Math.round((Math.max(...xs) - Math.min(...xs)) * 100) / 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHeight(obj: CanvasObject): number {
|
||||
switch (obj.type) {
|
||||
case 'rect':
|
||||
case 'image':
|
||||
return Math.round(obj.height * 100) / 100;
|
||||
case 'circle':
|
||||
return Math.round(obj.radius * 2 * 100) / 100;
|
||||
case 'ellipse':
|
||||
return Math.round(obj.radiusY * 2 * 100) / 100;
|
||||
case 'line': {
|
||||
const ys = obj.points.filter((_, i) => i % 2 === 1);
|
||||
return Math.round((Math.max(...ys) - Math.min(...ys)) * 100) / 100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const DASH_PRESETS: Record<LineStyle, number[]> = {
|
||||
solid: [],
|
||||
dashed: [10, 5],
|
||||
dotted: [2, 4],
|
||||
};
|
||||
|
||||
// -- Props --------------------------------------------------------------------
|
||||
|
||||
export interface ShapePropertiesProps {
|
||||
object: CanvasObject;
|
||||
onUpdate: (id: string, changes: Partial<CanvasObject>) => void;
|
||||
}
|
||||
|
||||
// -- Component ----------------------------------------------------------------
|
||||
|
||||
export default function ShapeProperties({
|
||||
object,
|
||||
onUpdate,
|
||||
}: ShapePropertiesProps) {
|
||||
const hasStroke = object.type !== 'image';
|
||||
const hasFill = object.type === 'rect' || object.type === 'circle' || object.type === 'ellipse';
|
||||
const isLine = object.type === 'line';
|
||||
|
||||
const handleChange = useCallback(
|
||||
(changes: Partial<CanvasObject>) => {
|
||||
onUpdate(object.id, changes);
|
||||
},
|
||||
[object.id, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shape-properties" data-testid="shape-properties">
|
||||
<div className="shape-properties-header">Properties</div>
|
||||
|
||||
{/* Dimensions (read-only display) */}
|
||||
<div className="shape-prop-section">
|
||||
<div className="shape-prop-label">Dimensions</div>
|
||||
<div className="shape-prop-dims" data-testid="shape-dims">
|
||||
<span>W: {getWidth(object)}</span>
|
||||
<span>H: {getHeight(object)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Position */}
|
||||
<div className="shape-prop-section">
|
||||
<div className="shape-prop-label">Position</div>
|
||||
<div className="shape-prop-dims">
|
||||
<span>X: {Math.round(object.x * 100) / 100}</span>
|
||||
<span>Y: {Math.round(object.y * 100) / 100}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stroke color */}
|
||||
{hasStroke && (
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="stroke-color">
|
||||
Stroke Color
|
||||
</label>
|
||||
<input
|
||||
id="stroke-color"
|
||||
type="color"
|
||||
className="shape-prop-color-input"
|
||||
value={(object as { stroke?: string }).stroke ?? '#000000'}
|
||||
onChange={(e) => handleChange({ stroke: e.target.value } as Partial<CanvasObject>)}
|
||||
data-testid="stroke-color-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stroke weight */}
|
||||
{hasStroke && (
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="stroke-width">
|
||||
Stroke Weight
|
||||
</label>
|
||||
<input
|
||||
id="stroke-width"
|
||||
type="number"
|
||||
className="shape-prop-number-input"
|
||||
min={0}
|
||||
max={50}
|
||||
step={0.5}
|
||||
value={(object as { strokeWidth?: number }).strokeWidth ?? 2}
|
||||
onChange={(e) =>
|
||||
handleChange({ strokeWidth: Number(e.target.value) } as Partial<CanvasObject>)
|
||||
}
|
||||
data-testid="stroke-width-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fill color */}
|
||||
{hasFill && (
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="fill-color">
|
||||
Fill Color
|
||||
</label>
|
||||
<div className="shape-prop-fill-row">
|
||||
<input
|
||||
id="fill-color"
|
||||
type="color"
|
||||
className="shape-prop-color-input"
|
||||
value={
|
||||
(object as { fill?: string }).fill === 'transparent'
|
||||
? '#ffffff'
|
||||
: ((object as { fill?: string }).fill ?? '#ffffff')
|
||||
}
|
||||
onChange={(e) => handleChange({ fill: e.target.value } as Partial<CanvasObject>)}
|
||||
data-testid="fill-color-input"
|
||||
/>
|
||||
<label className="shape-prop-fill-toggle" title="Toggle fill">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(object as { fill?: string }).fill !== 'transparent'}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
fill: e.target.checked
|
||||
? '#ffffff'
|
||||
: 'transparent',
|
||||
} as Partial<CanvasObject>)
|
||||
}
|
||||
data-testid="fill-toggle"
|
||||
/>
|
||||
Fill
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Line style (only for line objects) */}
|
||||
{isLine && object.type === 'line' && (
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="line-style">
|
||||
Line Style
|
||||
</label>
|
||||
<select
|
||||
id="line-style"
|
||||
className="shape-prop-select"
|
||||
value={object.lineStyle}
|
||||
onChange={(e) => {
|
||||
const style = e.target.value as LineStyle;
|
||||
handleChange({
|
||||
lineStyle: style,
|
||||
dash: DASH_PRESETS[style],
|
||||
} as Partial<CanvasObject>);
|
||||
}}
|
||||
data-testid="line-style-select"
|
||||
>
|
||||
<option value="solid">Solid</option>
|
||||
<option value="dashed">Dashed</option>
|
||||
<option value="dotted">Dotted</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opacity */}
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="opacity">
|
||||
Opacity
|
||||
</label>
|
||||
<input
|
||||
id="opacity"
|
||||
type="range"
|
||||
className="shape-prop-range-input"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={object.opacity}
|
||||
onChange={(e) => handleChange({ opacity: Number(e.target.value) })}
|
||||
data-testid="opacity-input"
|
||||
/>
|
||||
<span className="shape-prop-range-value">
|
||||
{Math.round(object.opacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rotation */}
|
||||
<div className="shape-prop-section">
|
||||
<label className="shape-prop-label" htmlFor="rotation">
|
||||
Rotation
|
||||
</label>
|
||||
<div className="shape-prop-dims">
|
||||
<span>{Math.round(object.rotation)}°</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,12 @@
|
|||
/**
|
||||
* DesignCanvas — View 2 container.
|
||||
*
|
||||
* Layout: top toolbar area, left canvas (KonvaStage), right panel area.
|
||||
* Manages tool state, artboard setup flow, and imported SVG loading.
|
||||
* Layout: top CanvasToolbar, left canvas (KonvaStage), right panel area
|
||||
* containing AlignmentBar, ObjectPanel, and ShapeProperties.
|
||||
* Manages tool state, artboard setup flow, zoom, grid, and imported SVG loading.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type Konva from 'konva';
|
||||
import type { TraceMetadata } from '../types/engine';
|
||||
import type { ArtboardConfig, CanvasObject } from '../types/canvas';
|
||||
|
|
@ -13,6 +14,10 @@ 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 CanvasToolbar from '../components/canvas/CanvasToolbar';
|
||||
import ObjectPanel from '../components/canvas/ObjectPanel';
|
||||
import AlignmentBar from '../components/canvas/AlignmentBar';
|
||||
import ShapeProperties from '../components/canvas/ShapeProperties';
|
||||
import { toPx } from '../utils/artboardShapes';
|
||||
import styles from './DesignCanvas.module.css';
|
||||
|
||||
|
|
@ -21,14 +26,6 @@ interface DesignCanvasProps {
|
|||
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,
|
||||
|
|
@ -40,6 +37,9 @@ export default function DesignCanvas({
|
|||
updateObject,
|
||||
selectObjects,
|
||||
deselectAll,
|
||||
reorderObject,
|
||||
toggleVisibility,
|
||||
toggleLock,
|
||||
setArtboard,
|
||||
undo,
|
||||
redo,
|
||||
|
|
@ -50,6 +50,8 @@ export default function DesignCanvas({
|
|||
const [activeTool, setActiveTool] = useState<CanvasTool>('pointer');
|
||||
const [showArtboardSetup, setShowArtboardSetup] = useState(true);
|
||||
const [svgImported, setSvgImported] = useState(false);
|
||||
const [showGrid, setShowGrid] = useState(false);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
|
||||
const stageRef = useRef<Konva.Stage | null>(null);
|
||||
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -145,6 +147,36 @@ export default function DesignCanvas({
|
|||
[state.selectedIds, selectObjects],
|
||||
);
|
||||
|
||||
// -- Rename handler (dispatches updateObject with name change) -----------
|
||||
|
||||
const handleRename = useCallback(
|
||||
(id: string, newName: string) => {
|
||||
updateObject(id, { name: newName });
|
||||
},
|
||||
[updateObject],
|
||||
);
|
||||
|
||||
// -- Zoom controls --------------------------------------------------------
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoomLevel((z) => Math.min(z + 0.25, 4));
|
||||
}, []);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoomLevel((z) => Math.max(z - 0.25, 0.25));
|
||||
}, []);
|
||||
|
||||
const handleZoomFit = useCallback(() => {
|
||||
setZoomLevel(1);
|
||||
}, []);
|
||||
|
||||
// -- Selected object for properties panel ---------------------------------
|
||||
|
||||
const selectedObject = useMemo(() => {
|
||||
if (state.selectedIds.length !== 1) return null;
|
||||
return state.objects.find((o) => o.id === state.selectedIds[0]) ?? null;
|
||||
}, [state.objects, state.selectedIds]);
|
||||
|
||||
// -- Render ---------------------------------------------------------------
|
||||
|
||||
if (showArtboardSetup) {
|
||||
|
|
@ -155,42 +187,20 @@ export default function DesignCanvas({
|
|||
<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>
|
||||
<CanvasToolbar
|
||||
activeTool={activeTool}
|
||||
onToolChange={setActiveTool}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
showGrid={showGrid}
|
||||
onToggleGrid={() => setShowGrid((g) => !g)}
|
||||
zoomLevel={zoomLevel}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onZoomFit={handleZoomFit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main area: canvas + right panel */}
|
||||
|
|
@ -216,11 +226,34 @@ export default function DesignCanvas({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panel placeholder (wired in T03) */}
|
||||
{/* Right panel: alignment bar, object panel, shape properties */}
|
||||
<div className={styles.panelArea} data-testid="panel-area">
|
||||
<div className={styles.panelPlaceholder}>
|
||||
Object & Properties Panel
|
||||
</div>
|
||||
{/* Alignment bar — visible when selection exists */}
|
||||
<AlignmentBar
|
||||
objects={state.objects}
|
||||
selectedIds={state.selectedIds}
|
||||
artboard={state.artboard}
|
||||
onUpdateObject={updateObject}
|
||||
/>
|
||||
|
||||
{/* Object / layer panel */}
|
||||
<ObjectPanel
|
||||
objects={state.objects}
|
||||
selectedIds={state.selectedIds}
|
||||
onSelect={handleSelect}
|
||||
onReorder={reorderObject}
|
||||
onToggleVisibility={toggleVisibility}
|
||||
onToggleLock={toggleLock}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
|
||||
{/* Shape properties — visible when exactly 1 object selected */}
|
||||
{selectedObject && (
|
||||
<ShapeProperties
|
||||
object={selectedObject}
|
||||
onUpdate={updateObject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue