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
4b049c9c0f
commit
e38a7c5cf2
6 changed files with 1167 additions and 51 deletions
304
app/src/App.css
304
app/src/App.css
|
|
@ -595,3 +595,307 @@
|
||||||
.artboard-setup-confirm:hover {
|
.artboard-setup-confirm:hover {
|
||||||
opacity: 0.9;
|
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.
|
* DesignCanvas — View 2 container.
|
||||||
*
|
*
|
||||||
* Layout: top toolbar area, left canvas (KonvaStage), right panel area.
|
* Layout: top CanvasToolbar, left canvas (KonvaStage), right panel area
|
||||||
* Manages tool state, artboard setup flow, and imported SVG loading.
|
* 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 Konva from 'konva';
|
||||||
import type { TraceMetadata } from '../types/engine';
|
import type { TraceMetadata } from '../types/engine';
|
||||||
import type { ArtboardConfig, CanvasObject } from '../types/canvas';
|
import type { ArtboardConfig, CanvasObject } from '../types/canvas';
|
||||||
|
|
@ -13,6 +14,10 @@ import { useCanvasState } from '../hooks/useCanvasState';
|
||||||
import ArtboardSetup from '../components/canvas/ArtboardSetup';
|
import ArtboardSetup from '../components/canvas/ArtboardSetup';
|
||||||
import KonvaStage from '../components/canvas/KonvaStage';
|
import KonvaStage from '../components/canvas/KonvaStage';
|
||||||
import type { CanvasTool } 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 { toPx } from '../utils/artboardShapes';
|
||||||
import styles from './DesignCanvas.module.css';
|
import styles from './DesignCanvas.module.css';
|
||||||
|
|
||||||
|
|
@ -21,14 +26,6 @@ interface DesignCanvasProps {
|
||||||
traceMetadata: TraceMetadata | 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({
|
export default function DesignCanvas({
|
||||||
svgData,
|
svgData,
|
||||||
traceMetadata,
|
traceMetadata,
|
||||||
|
|
@ -40,6 +37,9 @@ export default function DesignCanvas({
|
||||||
updateObject,
|
updateObject,
|
||||||
selectObjects,
|
selectObjects,
|
||||||
deselectAll,
|
deselectAll,
|
||||||
|
reorderObject,
|
||||||
|
toggleVisibility,
|
||||||
|
toggleLock,
|
||||||
setArtboard,
|
setArtboard,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
|
|
@ -50,6 +50,8 @@ export default function DesignCanvas({
|
||||||
const [activeTool, setActiveTool] = useState<CanvasTool>('pointer');
|
const [activeTool, setActiveTool] = useState<CanvasTool>('pointer');
|
||||||
const [showArtboardSetup, setShowArtboardSetup] = useState(true);
|
const [showArtboardSetup, setShowArtboardSetup] = useState(true);
|
||||||
const [svgImported, setSvgImported] = useState(false);
|
const [svgImported, setSvgImported] = useState(false);
|
||||||
|
const [showGrid, setShowGrid] = useState(false);
|
||||||
|
const [zoomLevel, setZoomLevel] = useState(1);
|
||||||
|
|
||||||
const stageRef = useRef<Konva.Stage | null>(null);
|
const stageRef = useRef<Konva.Stage | null>(null);
|
||||||
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
@ -145,6 +147,36 @@ export default function DesignCanvas({
|
||||||
[state.selectedIds, selectObjects],
|
[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 ---------------------------------------------------------------
|
// -- Render ---------------------------------------------------------------
|
||||||
|
|
||||||
if (showArtboardSetup) {
|
if (showArtboardSetup) {
|
||||||
|
|
@ -155,42 +187,20 @@ export default function DesignCanvas({
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Top toolbar */}
|
{/* Top toolbar */}
|
||||||
<div className={styles.toolbar}>
|
<div className={styles.toolbar}>
|
||||||
<div className={styles.toolGroup}>
|
<CanvasToolbar
|
||||||
{TOOLS.map(({ tool, label, icon }) => (
|
activeTool={activeTool}
|
||||||
<button
|
onToolChange={setActiveTool}
|
||||||
key={tool}
|
canUndo={canUndo}
|
||||||
type="button"
|
canRedo={canRedo}
|
||||||
className={`canvas-tool-btn${activeTool === tool ? ' canvas-tool-btn--active' : ''}`}
|
onUndo={undo}
|
||||||
onClick={() => setActiveTool(tool)}
|
onRedo={redo}
|
||||||
title={label}
|
showGrid={showGrid}
|
||||||
aria-pressed={activeTool === tool}
|
onToggleGrid={() => setShowGrid((g) => !g)}
|
||||||
>
|
zoomLevel={zoomLevel}
|
||||||
<span className="canvas-tool-icon">{icon}</span>
|
onZoomIn={handleZoomIn}
|
||||||
<span className="canvas-tool-label">{label}</span>
|
onZoomOut={handleZoomOut}
|
||||||
</button>
|
onZoomFit={handleZoomFit}
|
||||||
))}
|
/>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Main area: canvas + right panel */}
|
{/* Main area: canvas + right panel */}
|
||||||
|
|
@ -216,11 +226,34 @@ export default function DesignCanvas({
|
||||||
/>
|
/>
|
||||||
</div>
|
</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.panelArea} data-testid="panel-area">
|
||||||
<div className={styles.panelPlaceholder}>
|
{/* Alignment bar — visible when selection exists */}
|
||||||
Object & Properties Panel
|
<AlignmentBar
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue