feat: Built KonvaStage with artboard rendering, shape tools, selection…

- "app/src/components/canvas/KonvaStage.tsx"
- "app/src/views/DesignCanvas.tsx"
- "app/src/views/DesignCanvas.module.css"
- "app/src/App.tsx"
- "app/src/App.css"

GSD-Task: S02/T02
This commit is contained in:
jlightner 2026-03-26 05:36:19 +00:00
parent 62f79110e8
commit 4b049c9c0f
5 changed files with 1113 additions and 3 deletions

View file

@ -404,3 +404,194 @@
font-size: 20px; font-size: 20px;
color: var(--text); 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;
}

View file

@ -1,14 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import type { TraceMetadata } from './types/engine'; import type { TraceMetadata } from './types/engine';
import ImportConvert from './views/ImportConvert'; import ImportConvert from './views/ImportConvert';
import DesignCanvas from './views/DesignCanvas';
import './App.css'; import './App.css';
type ViewState = 'import' | 'canvas' | 'export'; type ViewState = 'import' | 'canvas' | 'export';
function App() { function App() {
const [view, setView] = useState<ViewState>('import'); const [view, setView] = useState<ViewState>('import');
const [_svgResult, setSvgResult] = useState<string | null>(null); const [svgResult, setSvgResult] = useState<string | null>(null);
const [_traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null); const [traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => { const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => {
setSvgResult(svgOutput); setSvgResult(svgOutput);
@ -19,7 +20,9 @@ function App() {
return ( return (
<div id="app"> <div id="app">
{view === 'import' && <ImportConvert onUseThis={handleUseThis} />} {view === 'import' && <ImportConvert onUseThis={handleUseThis} />}
{view === 'canvas' && <div className="placeholder-view">View 2: Design Canvas</div>} {view === 'canvas' && (
<DesignCanvas svgData={svgResult} traceMetadata={traceMetadata} />
)}
{view === 'export' && <div className="placeholder-view">View 3: Export</div>} {view === 'export' && <div className="placeholder-view">View 3: Export</div>}
</div> </div>
); );

View file

@ -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<CanvasObject>) => void;
stageRef: RefObject<Konva.Stage | null>;
}
// -- Helpers ------------------------------------------------------------------
let _nextId = 1;
function nextId(type: CanvasObjectType): string {
return `${type}-${Date.now()}-${_nextId++}`;
}
const DASH_MAP: Record<string, number[]> = {
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<Konva.Transformer | null>(null);
const layerRef = useRef<Konva.Layer | null>(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 = (
<Rect
key="artboard-bg"
x={offsetX}
y={offsetY}
width={artW}
height={artH}
fill="#ffffff"
stroke="#cccccc"
strokeWidth={1}
listening={false}
/>
);
if (clipPathData) {
// Render the clip path outline for shield/pennant/custom shapes
return (
<>
{bgRect}
<Path
key="artboard-clip"
x={offsetX}
y={offsetY}
data={clipPathData}
stroke="#999999"
strokeWidth={1}
fill="rgba(255,255,255,0.5)"
listening={false}
/>
</>
);
}
if (artboard.shape === 'circle') {
const r = Math.min(artW, artH) / 2;
return (
<>
{bgRect}
<Circle
key="artboard-circle"
x={offsetX + artW / 2}
y={offsetY + artH / 2}
radius={r}
stroke="#999999"
strokeWidth={1}
fill="rgba(255,255,255,0.5)"
listening={false}
/>
</>
);
}
if (artboard.shape === 'oval') {
return (
<>
{bgRect}
<Ellipse
key="artboard-oval"
x={offsetX + artW / 2}
y={offsetY + artH / 2}
radiusX={artW / 2}
radiusY={artH / 2}
stroke="#999999"
strokeWidth={1}
fill="rgba(255,255,255,0.5)"
listening={false}
/>
</>
);
}
// 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<MouseEvent>) => {
e.cancelBubble = true;
const isShift = e.evt.shiftKey;
onSelect([obj.id], isShift);
},
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => {
onUpdateObject(obj.id, {
x: e.target.x() - offsetX,
y: e.target.y() - offsetY,
});
},
onTransformEnd: (e: Konva.KonvaEventObject<Event>) => {
const node = e.target;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
const changes: Partial<CanvasObject> = {
x: node.x() - offsetX,
y: node.y() - offsetY,
rotation: node.rotation(),
};
if (obj.type === 'rect' || obj.type === 'image') {
(changes as Record<string, unknown>).width = Math.max(5, node.width() * scaleX);
(changes as Record<string, unknown>).height = Math.max(5, node.height() * scaleY);
} else if (obj.type === 'circle') {
(changes as Record<string, unknown>).radius = Math.max(5, (node.width() * scaleX) / 2);
} else if (obj.type === 'ellipse') {
(changes as Record<string, unknown>).radiusX = Math.max(5, (node.width() * scaleX) / 2);
(changes as Record<string, unknown>).radiusY = Math.max(5, (node.height() * scaleY) / 2);
}
onUpdateObject(obj.id, changes);
},
};
switch (obj.type) {
case 'rect':
return (
<Rect
key={obj.id}
{...commonProps}
width={obj.width}
height={obj.height}
fill={obj.fill}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
/>
);
case 'circle':
return (
<Circle
key={obj.id}
{...commonProps}
radius={obj.radius}
fill={obj.fill}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
/>
);
case 'ellipse':
return (
<Ellipse
key={obj.id}
{...commonProps}
radiusX={obj.radiusX}
radiusY={obj.radiusY}
fill={obj.fill}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
/>
);
case 'line':
return (
<Line
key={obj.id}
{...commonProps}
points={obj.points}
stroke={obj.stroke}
strokeWidth={obj.strokeWidth}
dash={getLineDash(obj.lineStyle)}
/>
);
case 'image':
return (
<KonvaImageWrapper
key={obj.id}
{...commonProps}
width={obj.width}
height={obj.height}
src={obj.src}
/>
);
default:
return null;
}
}
// -- Shape creation on click in tool mode --------------------------------
const handleStageMouseDown = useCallback(
(e: Konva.KonvaEventObject<MouseEvent>) => {
// 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<MouseEvent>) => {
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<MouseEvent>) => {
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 (
<Stage
ref={stageRef}
width={width}
height={height}
onMouseDown={handleStageMouseDown}
onMouseMove={handleStageMouseMove}
onMouseUp={handleStageMouseUp}
style={{ cursor: activeTool === 'pointer' ? 'default' : 'crosshair' }}
>
<Layer ref={layerRef}>
{/* Artboard background */}
{renderArtboard()}
{/* Canvas objects */}
{objects.map(renderObject)}
{/* Transformer for selection handles */}
<Transformer
ref={transformerRef}
flipEnabled={false}
boundBoxFunc={(_oldBox, newBox) => {
if (Math.abs(newBox.width) < 5 || Math.abs(newBox.height) < 5) {
return _oldBox;
}
return newBox;
}}
/>
{/* Rubber-band selection rectangle */}
{rubberBand.visible && (
<Rect
x={rubberBand.x}
y={rubberBand.y}
width={rubberBand.width}
height={rubberBand.height}
fill="rgba(170, 59, 255, 0.1)"
stroke="rgba(170, 59, 255, 0.6)"
strokeWidth={1}
listening={false}
/>
)}
</Layer>
</Stage>
);
}
// -- 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<MouseEvent>) => void;
onDragEnd: (e: Konva.KonvaEventObject<DragEvent>) => void;
onTransformEnd: (e: Konva.KonvaEventObject<Event>) => void;
}
function KonvaImageWrapper({
src,
...restProps
}: KonvaImageWrapperProps) {
const [image, setImage] = useState<HTMLImageElement | null>(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 <KonvaImage {...restProps} image={image} />;
}
// -- 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;
}

View file

@ -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;
}
}

View file

@ -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<CanvasTool>('pointer');
const [showArtboardSetup, setShowArtboardSetup] = useState(true);
const [svgImported, setSvgImported] = useState(false);
const stageRef = useRef<Konva.Stage | null>(null);
const canvasContainerRef = useRef<HTMLDivElement | null>(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 <ArtboardSetup onConfirm={handleArtboardConfirm} />;
}
return (
<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>
</div>
{/* Main area: canvas + right panel */}
<div className={styles.mainArea}>
{/* Canvas */}
<div
className={styles.canvasArea}
ref={canvasContainerRef}
data-testid="canvas-container"
>
<KonvaStage
width={stageSize.width}
height={stageSize.height}
artboard={state.artboard}
objects={state.objects}
selectedIds={state.selectedIds}
activeTool={activeTool}
onSelect={handleSelect}
onDeselectAll={deselectAll}
onAddObject={addObject}
onUpdateObject={updateObject}
stageRef={stageRef}
/>
</div>
{/* Right panel placeholder (wired in T03) */}
<div className={styles.panelArea} data-testid="panel-area">
<div className={styles.panelPlaceholder}>
Object &amp; Properties Panel
</div>
</div>
</div>
</div>
);
}