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:
parent
62f79110e8
commit
4b049c9c0f
5 changed files with 1113 additions and 3 deletions
191
app/src/App.css
191
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ViewState>('import');
|
||||
const [_svgResult, setSvgResult] = useState<string | null>(null);
|
||||
const [_traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
|
||||
const [svgResult, setSvgResult] = useState<string | null>(null);
|
||||
const [traceMetadata, setTraceMetadata] = useState<TraceMetadata | null>(null);
|
||||
|
||||
const handleUseThis = (svgOutput: string, metadata: TraceMetadata) => {
|
||||
setSvgResult(svgOutput);
|
||||
|
|
@ -19,7 +20,9 @@ function App() {
|
|||
return (
|
||||
<div id="app">
|
||||
{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>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
618
app/src/components/canvas/KonvaStage.tsx
Normal file
618
app/src/components/canvas/KonvaStage.tsx
Normal 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;
|
||||
}
|
||||
70
app/src/views/DesignCanvas.module.css
Normal file
70
app/src/views/DesignCanvas.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
228
app/src/views/DesignCanvas.tsx
Normal file
228
app/src/views/DesignCanvas.tsx
Normal 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 & Properties Panel
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue