Phase 2: Canvas Editor Core

Implements the core canvas editor with react-konva:

- Added dependencies: react-konva, konva, use-image
- DesignCanvas component: 300×300px Stage with T-shirt SVG overlay
- TShirtSVG component: Visual t-shirt outline with print zone indicator
- ImageElement: Draggable/resizable image with Transformer handles
- TextElement: Draggable/resizable text with Transformer handles
- useDesignEditor hook: Element CRUD, selection, reordering
- Keyboard shortcut: Delete/Backspace removes selected element
- Test image added on mount for Phase 2 verification

Canvas info bar shows: "Design Area: 15" × 15" • Export: 4500 × 4500px @ 300 DPI"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-21 01:03:30 -05:00
parent 1af0e6152d
commit e67017b259
10 changed files with 476 additions and 28 deletions

View File

@@ -11,7 +11,10 @@
},
"dependencies": {
"react": "^19.2.5",
"react-dom": "^19.2.5"
"react-dom": "^19.2.5",
"react-konva": "^18.2.10",
"konva": "^9.3.18",
"use-image": "^1.1.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",

View File

@@ -1,12 +1,82 @@
import { useEffect, useCallback } from 'react';
import { DesignCanvas } from './components/canvas/DesignCanvas';
import { useDesignEditor } from './hooks/useDesignEditor';
function App() {
const {
elements,
selectedId,
addElement,
updateElement,
deleteElement,
selectElement,
deselectAll,
} = useDesignEditor();
// Keyboard shortcut: Delete/Backspace removes selected element
useEffect(() => {
const handleKeyDown = (e) => {
// Don't delete if user is typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedId) {
deleteElement(selectedId);
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedId, deleteElement]);
// Test: Add sample image on mount (for Phase 2 testing)
useEffect(() => {
// Add a test image element
const testImageId = addElement({
type: 'image',
x: 75,
y: 75,
width: 150,
height: 150,
rotation: 0,
src: 'https://placehold.co/150x150/38bdf8/ffffff?text=Test',
});
console.log('Added test image with ID:', testImageId);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h1>Apparel Designer</h1>
<p style={{ color: 'var(--text-secondary)' }}>
<p style={{ color: 'var(--text-secondary)', marginBottom: '2rem' }}>
T-shirt customization editor
</p>
<div style={{ marginTop: '2rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)' }}>
<p>Server Status: <code id="server-status">Checking...</code></p>
{/* Canvas container */}
<div style={{ marginBottom: '2rem' }}>
<DesignCanvas
elements={elements}
selectedId={selectedId}
onSelect={selectElement}
onDeselect={deselectAll}
onUpdate={updateElement}
/>
</div>
{/* Debug info */}
<div style={{
padding: '1rem',
background: 'var(--bg-secondary)',
borderRadius: 'var(--radius-md)',
maxWidth: '400px',
margin: '0 auto',
}}>
<p>Elements: {elements.length}</p>
<p>Selected: {selectedId || 'None'}</p>
<p style={{ fontSize: '12px', color: 'var(--text-muted)' }}>
Tip: Click to select, drag to move, use handles to resize. Press Delete to remove.
</p>
</div>
</div>
);

View File

@@ -0,0 +1,95 @@
import { Stage, Layer } from 'react-konva';
import { TShirtSVG } from './TShirtSVG';
import { ImageElement } from './ImageElement';
import { TextElement } from './TextElement';
const CANVAS_SIZE = 300;
export function DesignCanvas({
elements,
selectedId,
onSelect,
onDeselect,
onUpdate,
}) {
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
{/* T-shirt SVG background */}
<TShirtSVG size={CANVAS_SIZE} />
{/* Canvas Stage */}
<Stage
width={CANVAS_SIZE}
height={CANVAS_SIZE}
onClick={onDeselect}
onTap={onDeselect}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
border: selectedId ? '2px solid #38bdf8' : '1px dashed #cbd5e1',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.5)',
}}
>
<Layer>
{elements.map((el) => {
if (el.type === 'image') {
return (
<ImageElement
key={el.id}
id={el.id}
x={el.x}
y={el.y}
width={el.width}
height={el.height}
rotation={el.rotation}
src={el.src}
isSelected={el.id === selectedId}
onSelect={() => onSelect(el.id)}
onUpdate={(attrs) => onUpdate(el.id, attrs)}
/>
);
}
if (el.type === 'text') {
return (
<TextElement
key={el.id}
id={el.id}
x={el.x}
y={el.y}
text={el.text}
fontSize={el.fontSize}
fontFamily={el.fontFamily}
fill={el.fill}
rotation={el.rotation}
isSelected={el.id === selectedId}
onSelect={() => onSelect(el.id)}
onUpdate={(attrs) => onUpdate(el.id, attrs)}
/>
);
}
return null;
})}
</Layer>
</Stage>
{/* Canvas info bar */}
<div
style={{
position: 'absolute',
bottom: '-40px',
left: '50%',
transform: 'translateX(-50%)',
fontSize: '12px',
color: 'var(--text-secondary)',
textAlign: 'center',
whiteSpace: 'nowrap',
}}
>
Design Area: 15" × 15" Export: 4500 × 4500px @ 300 DPI
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Image, Transformer } from 'react-konva';
import useImage from 'use-image';
function URLImage({ src, ...props }) {
const [img] = useImage(src, 'anonymous');
return <Image image={img} {...props} />;
}
export function ImageElement({
id,
x,
y,
width,
height,
rotation,
src,
isSelected,
onSelect,
onUpdate,
}) {
const shapeRef = null;
const trRef = null;
useEffect(() => {
if (isSelected && trRef.current) {
trRef.current.nodes([shapeRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<>
<URLImage
ref={shapeRef}
x={x}
y={y}
width={width}
height={height}
rotation={rotation}
src={src}
draggable
onClick={onSelect}
onTap={onSelect}
onDragEnd={(e) => {
onUpdate({
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={(e) => {
const node = shapeRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
onUpdate({
x: node.x(),
y: node.y(),
width: Math.max(20, node.width() * scaleX),
height: Math.max(20, node.height() * scaleY),
rotation: node.rotation(),
});
}}
boundBoxFunc={(oldBox, newBox) => {
// Minimum size constraint
if (newBox.width < 20 || newBox.height < 20) {
return oldBox;
}
return newBox;
}}
/>
{isSelected && (
<Transformer
ref={trRef}
boundBoxFunc={(oldBox, newBox) => {
// Limit resize to minimum size
if (newBox.width < 20 || newBox.height < 20) {
return oldBox;
}
return newBox;
}}
anchorSize={8}
anchorCornerRadius={4}
borderStroke="#38bdf8"
anchorStroke="#38bdf8"
anchorFill="#ffffff"
/>
)}
</>
);
}
ImageElement.defaultProps = {
width: 100,
height: 100,
rotation: 0,
};

View File

@@ -0,0 +1,59 @@
export function TShirtSVG({ size = 300 }) {
const padding = size * 0.1;
const innerSize = size - padding * 2;
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 0,
}}
>
{/* T-shirt outline */}
<path
d={`
M ${padding} ${padding + innerSize * 0.15}
L ${padding + innerSize * 0.15} ${padding}
L ${size - padding - innerSize * 0.15} ${padding}
L ${size - padding} ${padding + innerSize * 0.15}
L ${size - padding} ${size - padding}
L ${padding} ${size - padding}
Z
`}
fill="none"
stroke="var(--border)"
strokeWidth="2"
strokeDasharray="4,4"
/>
{/* Chest area indicator (design zone) */}
<rect
x={size * 0.3}
y={size * 0.25}
width={size * 0.4}
height={size * 0.35}
fill="none"
stroke="var(--accent)"
strokeWidth="1.5"
opacity="0.5"
/>
{/* Label */}
<text
x={size / 2}
y={size * 0.45}
textAnchor="middle"
fill="var(--text-muted)"
fontSize="10"
fontFamily="var(--font-mono)"
>
Print Zone
</text>
</svg>
);
}

View File

@@ -0,0 +1,80 @@
import { useEffect } from 'react';
import { Text, Transformer } from 'react-konva';
export function TextElement({
id,
x,
y,
text,
fontSize,
fontFamily,
fill,
rotation,
isSelected,
onSelect,
onUpdate,
}) {
const textRef = null;
const trRef = null;
useEffect(() => {
if (isSelected && trRef.current) {
trRef.current.nodes([textRef.current]);
trRef.current.getLayer().batchDraw();
}
}, [isSelected]);
return (
<>
<Text
ref={textRef}
x={x}
y={y}
text={text}
fontSize={fontSize}
fontFamily={fontFamily}
fill={fill}
rotation={rotation}
draggable
onClick={onSelect}
onTap={onSelect}
onDragEnd={(e) => {
onUpdate({
x: e.target.x(),
y: e.target.y(),
});
}}
onTransformEnd={(e) => {
const node = textRef.current;
const scaleX = node.scaleX();
node.scaleX(1);
node.scaleY(1);
onUpdate({
x: node.x(),
y: node.y(),
fontSize: Math.max(12, node.fontSize() * scaleX),
rotation: node.rotation(),
});
}}
/>
{isSelected && (
<Transformer
ref={trRef}
enabledAnchors={['top-left', 'top-right', 'bottom-left', 'bottom-right']}
anchorSize={8}
anchorCornerRadius={4}
borderStroke="#38bdf8"
anchorStroke="#38bdf8"
anchorFill="#ffffff"
/>
)}
</>
);
}
TextElement.defaultProps = {
fontSize: 24,
fontFamily: 'DM Sans',
fill: '#0f172a',
rotation: 0,
};

View File

@@ -0,0 +1,4 @@
export { DesignCanvas } from './DesignCanvas';
export { TShirtSVG } from './TShirtSVG';
export { ImageElement } from './ImageElement';
export { TextElement } from './TextElement';

View File

@@ -0,0 +1 @@
export { useDesignEditor } from './useDesignEditor';

View File

@@ -0,0 +1,59 @@
import { useState, useCallback } from 'react';
export function useDesignEditor() {
const [elements, setElements] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const addElement = useCallback((element) => {
const newElement = {
...element,
id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
};
setElements((prev) => [...prev, newElement]);
setSelectedId(newElement.id);
return newElement.id;
}, []);
const updateElement = useCallback((id, attrs) => {
setElements((prev) =>
prev.map((el) => (el.id === id ? { ...el, ...attrs } : el))
);
}, []);
const deleteElement = useCallback((id) => {
setElements((prev) => prev.filter((el) => el.id !== id));
if (selectedId === id) {
setSelectedId(null);
}
}, [selectedId]);
const selectElement = useCallback((id) => {
setSelectedId(id);
}, []);
const deselectAll = useCallback(() => {
setSelectedId(null);
}, []);
const reorderElement = useCallback((id, newOrder) => {
setElements((prev) => {
const index = prev.findIndex((el) => el.id === id);
if (index === -1 || index === newOrder) return prev;
const newElements = [...prev];
const [removed] = newElements.splice(index, 1);
newElements.splice(newOrder, 0, removed);
return newElements;
});
}, []);
return {
elements,
selectedId,
addElement,
updateElement,
deleteElement,
selectElement,
deselectAll,
reorderElement,
};
}

View File

@@ -1,31 +1,10 @@
import { StrictMode, useEffect, useState } from 'react'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
function AppWithHealth() {
const [serverStatus, setServerStatus] = useState('Checking...');
useEffect(() => {
fetch('/api/health')
.then(res => res.ok ? setServerStatus('Connected ✓') : setServerStatus('Error'))
.catch(() => setServerStatus('Offline'));
}, []);
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h1>Apparel Designer</h1>
<p style={{ color: 'var(--text-secondary)' }}>
T-shirt customization editor
</p>
<div style={{ marginTop: '2rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-md)' }}>
<p>Server Status: <code id="server-status">{serverStatus}</code></p>
</div>
</div>
);
}
import App from './App'
createRoot(document.getElementById('root')).render(
<StrictMode>
<AppWithHealth />
<App />
</StrictMode>,
)