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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
95
client/src/components/canvas/DesignCanvas.jsx
Normal file
95
client/src/components/canvas/DesignCanvas.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
client/src/components/canvas/ImageElement.jsx
Normal file
98
client/src/components/canvas/ImageElement.jsx
Normal 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,
|
||||
};
|
||||
59
client/src/components/canvas/TShirtSVG.jsx
Normal file
59
client/src/components/canvas/TShirtSVG.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
client/src/components/canvas/TextElement.jsx
Normal file
80
client/src/components/canvas/TextElement.jsx
Normal 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,
|
||||
};
|
||||
4
client/src/components/canvas/index.js
Normal file
4
client/src/components/canvas/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DesignCanvas } from './DesignCanvas';
|
||||
export { TShirtSVG } from './TShirtSVG';
|
||||
export { ImageElement } from './ImageElement';
|
||||
export { TextElement } from './TextElement';
|
||||
1
client/src/hooks/index.js
Normal file
1
client/src/hooks/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { useDesignEditor } from './useDesignEditor';
|
||||
59
client/src/hooks/useDesignEditor.js
Normal file
59
client/src/hooks/useDesignEditor.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user