diff --git a/app/src/App.css b/app/src/App.css index 6e2539c..2ab5cb6 100644 --- a/app/src/App.css +++ b/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; +} diff --git a/app/src/components/canvas/AlignmentBar.tsx b/app/src/components/canvas/AlignmentBar.tsx new file mode 100644 index 0000000..ef2fd63 --- /dev/null +++ b/app/src/components/canvas/AlignmentBar.tsx @@ -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) => 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 ( +
+ {/* Alignment buttons */} +
+ + + + + + +
+ + {/* Distribute buttons (only when 2+ selected) */} + {hasMultiple && ( +
+ + +
+ )} + + {/* Center on artboard */} + {artboard && ( +
+ +
+ )} +
+ ); +} diff --git a/app/src/components/canvas/CanvasToolbar.tsx b/app/src/components/canvas/CanvasToolbar.tsx new file mode 100644 index 0000000..d39961e --- /dev/null +++ b/app/src/components/canvas/CanvasToolbar.tsx @@ -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 ( +
+ {/* Tool buttons */} +
+ {TOOLS.map(({ tool, label, icon }) => ( + + ))} +
+ + {/* Undo / Redo */} +
+ + +
+ + {/* Grid toggle */} +
+ +
+ + {/* Zoom controls */} +
+ + + {Math.round(zoomLevel * 100)}% + + + +
+
+ ); +} diff --git a/app/src/components/canvas/ObjectPanel.tsx b/app/src/components/canvas/ObjectPanel.tsx new file mode 100644 index 0000000..7037a70 --- /dev/null +++ b/app/src/components/canvas/ObjectPanel.tsx @@ -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 = { + 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(null); + const [editValue, setEditValue] = useState(''); + const inputRef = useRef(null); + const dragItemRef = useRef(null); + const dragOverRef = useRef(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 ( +
+
Layers
+ {displayObjects.length === 0 ? ( +
No objects on canvas
+ ) : ( +
+ {displayObjects.map((obj, displayIdx) => { + const isSelected = selectedIds.includes(obj.id); + const isEditing = editingId === obj.id; + + return ( +
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 */} + + + {/* Type icon */} + + {TYPE_ICONS[obj.type]} + + + {/* Name (or edit input) */} + {isEditing ? ( + 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}`} + /> + ) : ( + + {obj.name} + + )} + + {/* Visibility toggle */} + + + {/* Lock toggle */} + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/src/components/canvas/ShapeProperties.tsx b/app/src/components/canvas/ShapeProperties.tsx new file mode 100644 index 0000000..d761fe4 --- /dev/null +++ b/app/src/components/canvas/ShapeProperties.tsx @@ -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 = { + solid: [], + dashed: [10, 5], + dotted: [2, 4], +}; + +// -- Props -------------------------------------------------------------------- + +export interface ShapePropertiesProps { + object: CanvasObject; + onUpdate: (id: string, changes: Partial) => 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) => { + onUpdate(object.id, changes); + }, + [object.id, onUpdate], + ); + + return ( +
+
Properties
+ + {/* Dimensions (read-only display) */} +
+
Dimensions
+
+ W: {getWidth(object)} + H: {getHeight(object)} +
+
+ + {/* Position */} +
+
Position
+
+ X: {Math.round(object.x * 100) / 100} + Y: {Math.round(object.y * 100) / 100} +
+
+ + {/* Stroke color */} + {hasStroke && ( +
+ + handleChange({ stroke: e.target.value } as Partial)} + data-testid="stroke-color-input" + /> +
+ )} + + {/* Stroke weight */} + {hasStroke && ( +
+ + + handleChange({ strokeWidth: Number(e.target.value) } as Partial) + } + data-testid="stroke-width-input" + /> +
+ )} + + {/* Fill color */} + {hasFill && ( +
+ +
+ handleChange({ fill: e.target.value } as Partial)} + data-testid="fill-color-input" + /> + +
+
+ )} + + {/* Line style (only for line objects) */} + {isLine && object.type === 'line' && ( +
+ + +
+ )} + + {/* Opacity */} +
+ + handleChange({ opacity: Number(e.target.value) })} + data-testid="opacity-input" + /> + + {Math.round(object.opacity * 100)}% + +
+ + {/* Rotation */} +
+ +
+ {Math.round(object.rotation)}° +
+
+
+ ); +} diff --git a/app/src/views/DesignCanvas.tsx b/app/src/views/DesignCanvas.tsx index 46d9382..26dbfc7 100644 --- a/app/src/views/DesignCanvas.tsx +++ b/app/src/views/DesignCanvas.tsx @@ -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('pointer'); const [showArtboardSetup, setShowArtboardSetup] = useState(true); const [svgImported, setSvgImported] = useState(false); + const [showGrid, setShowGrid] = useState(false); + const [zoomLevel, setZoomLevel] = useState(1); const stageRef = useRef(null); const canvasContainerRef = useRef(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({
{/* Top toolbar */}
-
- {TOOLS.map(({ tool, label, icon }) => ( - - ))} -
- -
- - -
+ setShowGrid((g) => !g)} + zoomLevel={zoomLevel} + onZoomIn={handleZoomIn} + onZoomOut={handleZoomOut} + onZoomFit={handleZoomFit} + />
{/* Main area: canvas + right panel */} @@ -216,11 +226,34 @@ export default function DesignCanvas({ />
- {/* Right panel placeholder (wired in T03) */} + {/* Right panel: alignment bar, object panel, shape properties */}
-
- Object & Properties Panel -
+ {/* Alignment bar — visible when selection exists */} + + + {/* Object / layer panel */} + + + {/* Shape properties — visible when exactly 1 object selected */} + {selectedObject && ( + + )}