diff --git a/app/src/App.css b/app/src/App.css index a3db9a7..6e2539c 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -404,3 +404,194 @@ font-size: 20px; color: var(--text); } + +/* Canvas toolbar buttons */ +.canvas-tool-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--text-h); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: border-color 0.12s, background-color 0.12s; + user-select: none; +} + +.canvas-tool-btn:hover { + border-color: var(--accent-border); + background: var(--accent-bg); +} + +.canvas-tool-btn--active { + border-color: var(--accent); + background: var(--accent-bg); + color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.canvas-tool-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.canvas-tool-icon { + font-size: 16px; + line-height: 1; +} + +.canvas-tool-label { + font-size: 12px; +} + +/* Canvas view: override #root width constraint */ +#app:has([data-testid="canvas-container"]) { + max-width: 100%; +} + +/* Artboard setup styling */ +.artboard-setup-overlay { + display: flex; + align-items: center; + justify-content: center; + min-height: 80vh; + padding: 24px; +} + +.artboard-setup-modal { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 480px; + width: 100%; + padding: 24px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg); + box-shadow: var(--shadow); +} + +.artboard-setup-modal h2 { + margin: 0 0 4px; +} + +.artboard-setup-shapes { + border: none; + padding: 0; + margin: 0; +} + +.artboard-setup-shapes legend { + font-size: 14px; + font-weight: 600; + color: var(--text-h); + margin-bottom: 8px; +} + +.artboard-shape-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(90px, 1fr)); + gap: 6px; +} + +.artboard-shape-btn { + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text-h); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: border-color 0.12s, background-color 0.12s; +} + +.artboard-shape-btn:hover { + border-color: var(--accent-border); + background: var(--accent-bg); +} + +.artboard-shape-btn.active { + border-color: var(--accent); + background: var(--accent-bg); + color: var(--accent); + box-shadow: 0 0 0 1px var(--accent); +} + +.artboard-setup-dimensions { + display: flex; + gap: 12px; +} + +.artboard-setup-dimensions label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--text-h); + font-weight: 500; + flex: 1; +} + +.artboard-setup-dimensions input { + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 4px; + font-size: 14px; + font-family: inherit; + background: var(--bg); + color: var(--text-h); +} + +.artboard-setup-dimensions input:disabled { + opacity: 0.5; +} + +.artboard-setup-units { + border: none; + padding: 0; + margin: 0; + display: flex; + gap: 16px; + align-items: center; +} + +.artboard-setup-units legend { + font-size: 14px; + font-weight: 600; + color: var(--text-h); + margin-right: 8px; +} + +.artboard-setup-units label { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + color: var(--text-h); + cursor: pointer; +} + +.artboard-setup-confirm { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 20px; + background: var(--accent); + color: #fff; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + font-family: inherit; + transition: opacity 0.15s; +} + +.artboard-setup-confirm:hover { + opacity: 0.9; +} diff --git a/app/src/App.tsx b/app/src/App.tsx index 771b53b..13ab741 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,14 +1,15 @@ import { useState } from 'react'; import type { TraceMetadata } from './types/engine'; import ImportConvert from './views/ImportConvert'; +import DesignCanvas from './views/DesignCanvas'; import './App.css'; type ViewState = 'import' | 'canvas' | 'export'; function App() { const [view, setView] = useState('import'); - const [_svgResult, setSvgResult] = useState(null); - const [_traceMetadata, setTraceMetadata] = useState(null); + const [svgResult, setSvgResult] = useState(null); + const [traceMetadata, setTraceMetadata] = useState(null); const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => { setSvgResult(svgOutput); @@ -19,7 +20,9 @@ function App() { return (
{view === 'import' && } - {view === 'canvas' &&
View 2: Design Canvas
} + {view === 'canvas' && ( + + )} {view === 'export' &&
View 3: Export
}
); diff --git a/app/src/components/canvas/KonvaStage.tsx b/app/src/components/canvas/KonvaStage.tsx new file mode 100644 index 0000000..394ed1e --- /dev/null +++ b/app/src/components/canvas/KonvaStage.tsx @@ -0,0 +1,618 @@ +/** + * KonvaStage — core Konva rendering layer. + * + * Renders: + * 1. Artboard background (Rect/Circle/Path based on ArtboardConfig) + * 2. All canvas objects mapped to Konva primitives + * 3. Transformer for selection handles + * 4. Rubber-band selection rectangle for multi-select + */ + +import { + useRef, + useEffect, + useState, + useCallback, + type RefObject, +} from 'react'; +import { + Stage, + Layer, + Rect, + Circle, + Ellipse, + Line, + Image as KonvaImage, + Transformer, + Path, +} from 'react-konva'; +import type Konva from 'konva'; +import type { + ArtboardConfig, + CanvasObject, + CanvasObjectType, +} from '../../types/canvas'; +import { toPx, artboardClipPath } from '../../utils/artboardShapes'; + +// -- Types -------------------------------------------------------------------- + +export type CanvasTool = 'pointer' | 'rect' | 'circle' | 'ellipse' | 'line'; + +export interface KonvaStageProps { + width: number; + height: number; + artboard: ArtboardConfig | null; + objects: CanvasObject[]; + selectedIds: string[]; + activeTool: CanvasTool; + onSelect: (ids: string[], additive: boolean) => void; + onDeselectAll: () => void; + onAddObject: (obj: CanvasObject) => void; + onUpdateObject: (id: string, changes: Partial) => void; + stageRef: RefObject; +} + +// -- Helpers ------------------------------------------------------------------ + +let _nextId = 1; +function nextId(type: CanvasObjectType): string { + return `${type}-${Date.now()}-${_nextId++}`; +} + +const DASH_MAP: Record = { + solid: [], + dashed: [10, 5], + dotted: [2, 4], +}; + +function getLineDash(lineStyle: string): number[] { + return DASH_MAP[lineStyle] ?? []; +} + +// -- Component ---------------------------------------------------------------- + +export default function KonvaStage({ + width, + height, + artboard, + objects, + selectedIds, + activeTool, + onSelect, + onDeselectAll, + onAddObject, + onUpdateObject, + stageRef, +}: KonvaStageProps) { + const transformerRef = useRef(null); + const layerRef = useRef(null); + + // Rubber-band selection state + const [rubberBand, setRubberBand] = useState<{ + x: number; + y: number; + width: number; + height: number; + visible: boolean; + }>({ x: 0, y: 0, width: 0, height: 0, visible: false }); + const rubberStart = useRef<{ x: number; y: number } | null>(null); + + // Artboard pixel dimensions + const artW = artboard ? toPx(artboard.width, artboard.unit) : width; + const artH = artboard ? toPx(artboard.height, artboard.unit) : height; + + // Center artboard on stage + const offsetX = Math.max(0, (width - artW) / 2); + const offsetY = Math.max(0, (height - artH) / 2); + + // -- Sync transformer with selection ------------------------------------ + + useEffect(() => { + const tr = transformerRef.current; + const stage = stageRef.current; + if (!tr || !stage) return; + + const selectedNodes = selectedIds + .map((id) => stage.findOne(`#${id}`)) + .filter((n): n is Konva.Node => n != null); + + tr.nodes(selectedNodes); + tr.getLayer()?.batchDraw(); + }, [selectedIds, stageRef]); + + // -- Artboard background ------------------------------------------------ + + function renderArtboard() { + if (!artboard) return null; + + const clipPathData = artboardClipPath(artboard); + + // Common artboard background rect (for all shapes, acts as bounding box) + const bgRect = ( + + ); + + if (clipPathData) { + // Render the clip path outline for shield/pennant/custom shapes + return ( + <> + {bgRect} + + + ); + } + + if (artboard.shape === 'circle') { + const r = Math.min(artW, artH) / 2; + return ( + <> + {bgRect} + + + ); + } + + if (artboard.shape === 'oval') { + return ( + <> + {bgRect} + + + ); + } + + // Default: rect/square — just the bg rect is enough + return bgRect; + } + + // -- Render canvas objects ------------------------------------------------ + + function renderObject(obj: CanvasObject) { + if (!obj.visible) return null; + + const commonProps = { + id: obj.id, + x: obj.x + offsetX, + y: obj.y + offsetY, + rotation: obj.rotation, + opacity: obj.opacity, + draggable: !obj.locked && activeTool === 'pointer', + onClick: (e: Konva.KonvaEventObject) => { + e.cancelBubble = true; + const isShift = e.evt.shiftKey; + onSelect([obj.id], isShift); + }, + onDragEnd: (e: Konva.KonvaEventObject) => { + onUpdateObject(obj.id, { + x: e.target.x() - offsetX, + y: e.target.y() - offsetY, + }); + }, + onTransformEnd: (e: Konva.KonvaEventObject) => { + const node = e.target; + const scaleX = node.scaleX(); + const scaleY = node.scaleY(); + node.scaleX(1); + node.scaleY(1); + + const changes: Partial = { + x: node.x() - offsetX, + y: node.y() - offsetY, + rotation: node.rotation(), + }; + + if (obj.type === 'rect' || obj.type === 'image') { + (changes as Record).width = Math.max(5, node.width() * scaleX); + (changes as Record).height = Math.max(5, node.height() * scaleY); + } else if (obj.type === 'circle') { + (changes as Record).radius = Math.max(5, (node.width() * scaleX) / 2); + } else if (obj.type === 'ellipse') { + (changes as Record).radiusX = Math.max(5, (node.width() * scaleX) / 2); + (changes as Record).radiusY = Math.max(5, (node.height() * scaleY) / 2); + } + + onUpdateObject(obj.id, changes); + }, + }; + + switch (obj.type) { + case 'rect': + return ( + + ); + + case 'circle': + return ( + + ); + + case 'ellipse': + return ( + + ); + + case 'line': + return ( + + ); + + case 'image': + return ( + + ); + + default: + return null; + } + } + + // -- Shape creation on click in tool mode -------------------------------- + + const handleStageMouseDown = useCallback( + (e: Konva.KonvaEventObject) => { + // If clicking on empty stage area + const clickedOnEmpty = e.target === e.target.getStage(); + + if (activeTool === 'pointer') { + if (clickedOnEmpty) { + onDeselectAll(); + // Start rubber-band selection + const pos = e.target.getStage()?.getPointerPosition(); + if (pos) { + rubberStart.current = { x: pos.x, y: pos.y }; + setRubberBand({ x: pos.x, y: pos.y, width: 0, height: 0, visible: true }); + } + } + return; + } + + // Tool mode: create a shape at click position + if (!clickedOnEmpty) return; + + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + const x = pos.x - offsetX; + const y = pos.y - offsetY; + + let newObj: CanvasObject | null = null; + + switch (activeTool) { + case 'rect': + newObj = { + id: nextId('rect'), + type: 'rect', + name: 'Rectangle', + x, + y, + width: 100, + height: 80, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: 'transparent', + stroke: '#000000', + strokeWidth: 2, + }; + break; + + case 'circle': + newObj = { + id: nextId('circle'), + type: 'circle', + name: 'Circle', + x, + y, + radius: 50, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: 'transparent', + stroke: '#000000', + strokeWidth: 2, + }; + break; + + case 'ellipse': + newObj = { + id: nextId('ellipse'), + type: 'ellipse', + name: 'Ellipse', + x, + y, + radiusX: 60, + radiusY: 40, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: 'transparent', + stroke: '#000000', + strokeWidth: 2, + }; + break; + + case 'line': + newObj = { + id: nextId('line'), + type: 'line', + name: 'Line', + x, + y, + points: [0, 0, 100, 0], + rotation: 0, + visible: true, + locked: false, + opacity: 1, + stroke: '#000000', + strokeWidth: 2, + lineStyle: 'solid', + dash: [], + }; + break; + } + + if (newObj) { + onAddObject(newObj); + onSelect([newObj.id], false); + } + }, + [activeTool, offsetX, offsetY, onDeselectAll, onAddObject, onSelect], + ); + + // -- Rubber-band selection mouse move / up -------------------------------- + + const handleStageMouseMove = useCallback( + (e: Konva.KonvaEventObject) => { + if (!rubberStart.current) return; + const pos = e.target.getStage()?.getPointerPosition(); + if (!pos) return; + + const sx = rubberStart.current.x; + const sy = rubberStart.current.y; + setRubberBand({ + x: Math.min(sx, pos.x), + y: Math.min(sy, pos.y), + width: Math.abs(pos.x - sx), + height: Math.abs(pos.y - sy), + visible: true, + }); + }, + [], + ); + + const handleStageMouseUp = useCallback( + (_e: Konva.KonvaEventObject) => { + if (!rubberStart.current) return; + + // Find objects intersecting the rubber band + if (rubberBand.width > 5 || rubberBand.height > 5) { + const rb = { + x: rubberBand.x - offsetX, + y: rubberBand.y - offsetY, + width: rubberBand.width, + height: rubberBand.height, + }; + + const intersecting = objects + .filter((obj) => { + if (!obj.visible || obj.locked) return false; + const objW = getObjWidth(obj); + const objH = getObjHeight(obj); + return rectsIntersect( + rb.x, rb.y, rb.width, rb.height, + obj.x, obj.y, objW, objH, + ); + }) + .map((obj) => obj.id); + + if (intersecting.length > 0) { + onSelect(intersecting, false); + } + } + + rubberStart.current = null; + setRubberBand({ x: 0, y: 0, width: 0, height: 0, visible: false }); + }, + [rubberBand, objects, offsetX, offsetY, onSelect], + ); + + return ( + + + {/* Artboard background */} + {renderArtboard()} + + {/* Canvas objects */} + {objects.map(renderObject)} + + {/* Transformer for selection handles */} + { + if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) { + return _oldBox; + } + return newBox; + }} + /> + + {/* Rubber-band selection rectangle */} + {rubberBand.visible && ( + + )} + + + ); +} + +// -- KonvaImage wrapper (loads HTMLImageElement from src) -------------------- + +interface KonvaImageWrapperProps { + id: string; + src: string; + x: number; + y: number; + width: number; + height: number; + rotation: number; + opacity: number; + draggable: boolean; + onClick: (e: Konva.KonvaEventObject) => void; + onDragEnd: (e: Konva.KonvaEventObject) => void; + onTransformEnd: (e: Konva.KonvaEventObject) => void; +} + +function KonvaImageWrapper({ + src, + ...restProps +}: KonvaImageWrapperProps) { + const [image, setImage] = useState(null); + + useEffect(() => { + const img = new window.Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => setImage(img); + img.src = src; + return () => { + img.onload = null; + }; + }, [src]); + + if (!image) return null; + + return ; +} + +// -- Geometry helpers --------------------------------------------------------- + +function getObjWidth(obj: CanvasObject): number { + switch (obj.type) { + case 'rect': + case 'image': + return obj.width; + case 'circle': + return obj.radius * 2; + case 'ellipse': + return obj.radiusX * 2; + case 'line': + return Math.max(...obj.points.filter((_, i) => i % 2 === 0)) - + Math.min(...obj.points.filter((_, i) => i % 2 === 0)); + } +} + +function getObjHeight(obj: CanvasObject): number { + switch (obj.type) { + case 'rect': + case 'image': + return obj.height; + case 'circle': + return obj.radius * 2; + case 'ellipse': + return obj.radiusY * 2; + case 'line': + return Math.max(...obj.points.filter((_, i) => i % 2 === 1)) - + Math.min(...obj.points.filter((_, i) => i % 2 === 1)); + } +} + +function rectsIntersect( + ax: number, ay: number, aw: number, ah: number, + bx: number, by: number, bw: number, bh: number, +): boolean { + return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; +} diff --git a/app/src/views/DesignCanvas.module.css b/app/src/views/DesignCanvas.module.css new file mode 100644 index 0000000..f3ea012 --- /dev/null +++ b/app/src/views/DesignCanvas.module.css @@ -0,0 +1,70 @@ +/* Design Canvas (View 2) layout */ + +.container { + display: flex; + flex-direction: column; + width: 100%; + height: 100svh; + overflow: hidden; +} + +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 8px 16px; + border-bottom: 1px solid var(--border); + background: var(--bg); + flex-shrink: 0; +} + +.toolGroup { + display: flex; + gap: 4px; +} + +.mainArea { + display: flex; + flex: 1; + min-height: 0; +} + +.canvasArea { + flex: 1; + min-width: 0; + background: var(--code-bg); + position: relative; + overflow: hidden; +} + +.panelArea { + flex: 0 0 260px; + border-left: 1px solid var(--border); + background: var(--bg); + overflow-y: auto; + padding: 12px; +} + +.panelPlaceholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text); + font-size: 14px; + opacity: 0.6; +} + +@media (max-width: 768px) { + .mainArea { + flex-direction: column; + } + + .panelArea { + flex: none; + border-left: none; + border-top: 1px solid var(--border); + height: 200px; + } +} diff --git a/app/src/views/DesignCanvas.tsx b/app/src/views/DesignCanvas.tsx new file mode 100644 index 0000000..46d9382 --- /dev/null +++ b/app/src/views/DesignCanvas.tsx @@ -0,0 +1,228 @@ +/** + * DesignCanvas — View 2 container. + * + * Layout: top toolbar area, left canvas (KonvaStage), right panel area. + * Manages tool state, artboard setup flow, and imported SVG loading. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type Konva from 'konva'; +import type { TraceMetadata } from '../types/engine'; +import type { ArtboardConfig, CanvasObject } from '../types/canvas'; +import { useCanvasState } from '../hooks/useCanvasState'; +import ArtboardSetup from '../components/canvas/ArtboardSetup'; +import KonvaStage from '../components/canvas/KonvaStage'; +import type { CanvasTool } from '../components/canvas/KonvaStage'; +import { toPx } from '../utils/artboardShapes'; +import styles from './DesignCanvas.module.css'; + +interface DesignCanvasProps { + svgData: string | null; + traceMetadata: TraceMetadata | null; +} + +const TOOLS: { tool: CanvasTool; label: string; icon: string }[] = [ + { tool: 'pointer', label: 'Select', icon: '↖' }, + { tool: 'rect', label: 'Rectangle', icon: '▭' }, + { tool: 'circle', label: 'Circle', icon: '○' }, + { tool: 'ellipse', label: 'Ellipse', icon: '⬯' }, + { tool: 'line', label: 'Line', icon: '╱' }, +]; + +export default function DesignCanvas({ + svgData, + traceMetadata, +}: DesignCanvasProps) { + const { + state, + addObject, + removeObject: _removeObject, + updateObject, + selectObjects, + deselectAll, + setArtboard, + undo, + redo, + canUndo, + canRedo, + } = useCanvasState(traceMetadata); + + const [activeTool, setActiveTool] = useState('pointer'); + const [showArtboardSetup, setShowArtboardSetup] = useState(true); + const [svgImported, setSvgImported] = useState(false); + + const stageRef = useRef(null); + const canvasContainerRef = useRef(null); + const [stageSize, setStageSize] = useState({ width: 800, height: 600 }); + + // -- Resize observer for canvas container -------------------------------- + + useEffect(() => { + const container = canvasContainerRef.current; + if (!container) return; + + const updateSize = () => { + setStageSize({ + width: container.clientWidth, + height: container.clientHeight, + }); + }; + + updateSize(); + const observer = new ResizeObserver(updateSize); + observer.observe(container); + return () => observer.disconnect(); + }, [showArtboardSetup]); + + // -- Artboard setup ------------------------------------------------------- + + const handleArtboardConfirm = useCallback( + (config: ArtboardConfig) => { + setArtboard(config); + setShowArtboardSetup(false); + }, + [setArtboard], + ); + + // -- Import SVG from View 1 ----------------------------------------------- + + useEffect(() => { + if (!svgData || svgImported || !state.artboard) return; + + const blob = new Blob([svgData], { type: 'image/svg+xml' }); + const url = URL.createObjectURL(blob); + + // Load the image to get natural dimensions + const img = new window.Image(); + img.onload = () => { + const artW = toPx(state.artboard!.width, state.artboard!.unit); + const artH = toPx(state.artboard!.height, state.artboard!.unit); + + // Scale SVG to fit within artboard + const scale = Math.min(artW / img.width, artH / img.height, 1); + const scaledW = img.width * scale; + const scaledH = img.height * scale; + + const imageObj: CanvasObject = { + type: 'image', + id: `imported-svg-${Date.now()}`, + name: 'Imported SVG', + x: (artW - scaledW) / 2, + y: (artH - scaledH) / 2, + width: scaledW, + height: scaledH, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + src: url, + }; + + addObject(imageObj); + setSvgImported(true); + }; + img.src = url; + }, [svgData, svgImported, state.artboard, addObject]); + + // -- Selection handling --------------------------------------------------- + + const handleSelect = useCallback( + (ids: string[], additive: boolean) => { + if (additive) { + const current = new Set(state.selectedIds); + for (const id of ids) { + if (current.has(id)) { + current.delete(id); + } else { + current.add(id); + } + } + selectObjects([...current]); + } else { + selectObjects(ids); + } + }, + [state.selectedIds, selectObjects], + ); + + // -- Render --------------------------------------------------------------- + + if (showArtboardSetup) { + return ; + } + + return ( +
+ {/* Top toolbar */} +
+
+ {TOOLS.map(({ tool, label, icon }) => ( + + ))} +
+ +
+ + +
+
+ + {/* Main area: canvas + right panel */} +
+ {/* Canvas */} +
+ +
+ + {/* Right panel placeholder (wired in T03) */} +
+
+ Object & Properties Panel +
+
+
+
+ ); +}