kerf-engine/app/src/views/DesignCanvas.tsx
jlightner e38a7c5cf2 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
2026-03-26 05:40:13 +00:00

261 lines
7.8 KiB
TypeScript

/**
* DesignCanvas — View 2 container.
*
* 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, useMemo, 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 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';
interface DesignCanvasProps {
svgData: string | null;
traceMetadata: TraceMetadata | null;
}
export default function DesignCanvas({
svgData,
traceMetadata,
}: DesignCanvasProps) {
const {
state,
addObject,
removeObject: _removeObject,
updateObject,
selectObjects,
deselectAll,
reorderObject,
toggleVisibility,
toggleLock,
setArtboard,
undo,
redo,
canUndo,
canRedo,
} = useCanvasState(traceMetadata);
const [activeTool, setActiveTool] = useState<CanvasTool>('pointer');
const [showArtboardSetup, setShowArtboardSetup] = useState(true);
const [svgImported, setSvgImported] = useState(false);
const [showGrid, setShowGrid] = useState(false);
const [zoomLevel, setZoomLevel] = useState(1);
const stageRef = useRef<Konva.Stage | null>(null);
const canvasContainerRef = useRef<HTMLDivElement | null>(null);
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],
);
// -- 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) {
return <ArtboardSetup onConfirm={handleArtboardConfirm} />;
}
return (
<div className={styles.container}>
{/* Top toolbar */}
<div className={styles.toolbar}>
<CanvasToolbar
activeTool={activeTool}
onToolChange={setActiveTool}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
showGrid={showGrid}
onToggleGrid={() => setShowGrid((g) => !g)}
zoomLevel={zoomLevel}
onZoomIn={handleZoomIn}
onZoomOut={handleZoomOut}
onZoomFit={handleZoomFit}
/>
</div>
{/* Main area: canvas + right panel */}
<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: alignment bar, object panel, shape properties */}
<div className={styles.panelArea} data-testid="panel-area">
{/* Alignment bar — visible when selection exists */}
<AlignmentBar
objects={state.objects}
selectedIds={state.selectedIds}
artboard={state.artboard}
onUpdateObject={updateObject}
/>
{/* Object / layer panel */}
<ObjectPanel
objects={state.objects}
selectedIds={state.selectedIds}
onSelect={handleSelect}
onReorder={reorderObject}
onToggleVisibility={toggleVisibility}
onToggleLock={toggleLock}
onRename={handleRename}
/>
{/* Shape properties — visible when exactly 1 object selected */}
{selectedObject && (
<ShapeProperties
object={selectedObject}
onUpdate={updateObject}
/>
)}
</div>
</div>
</div>
);
}