- "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
261 lines
7.8 KiB
TypeScript
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>
|
|
);
|
|
}
|