Phases 7-10: Complete remaining features and optimizations
Phase 7.2 - Debounce undo/redo history: - Add 300ms debounce timer for rapid drag/transform changes - Commit history on dragEnd/transformEnd events only - Prevents history bloat during continuous interactions Phase 8.3 - Template-aware export: - Render template background layer first - Apply slot crop regions for image elements - Render template overlay layer last - Support nonPrintable flag for guides/watermarks Phase 9 - PWA icons: - Add pwa-192x192.svg and pwa-512x512.svg icons - Update vite.config.js manifest configuration Phase 10.3 - Performance optimizations: - Add React.memo to canvas components (ImageElement, TextElement, DesignCanvas) - Add React.memo to panel components (LayersPanel, PropertiesPanel) - Prevent unnecessary re-renders during canvas interactions Phase 10.6 - Template documentation: - Document template JSON schema in docs/template-schema.md - Include element properties, slot definitions, and examples - Describe background/overlay layer structure
This commit is contained in:
5
client/public/pwa-192x192.svg
Normal file
5
client/public/pwa-192x192.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" fill="#38bdf8"/>
|
||||||
|
<rect x="24" y="24" width="144" height="144" rx="24" fill="#ffffff"/>
|
||||||
|
<text x="96" y="120" font-size="72" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 332 B |
5
client/public/pwa-512x512.svg
Normal file
5
client/public/pwa-512x512.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="#38bdf8"/>
|
||||||
|
<rect x="64" y="64" width="384" height="384" rx="64" fill="#ffffff"/>
|
||||||
|
<text x="256" y="310" font-size="192" text-anchor="middle" fill="#38bdf8" font-family="Arial, sans-serif">T</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 334 B |
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
import { DesignCanvas } from './components/canvas/DesignCanvas';
|
||||||
import { Sidebar } from './components/sidebar/Sidebar';
|
import { Sidebar } from './components/sidebar/Sidebar';
|
||||||
import { LayersPanel } from './components/panels/LayersPanel';
|
import { LayersPanel } from './components/panels/LayersPanel';
|
||||||
@@ -6,8 +6,11 @@ import { PropertiesPanel } from './components/panels/PropertiesPanel';
|
|||||||
import { PWAInstall } from './components/PWAInstall';
|
import { PWAInstall } from './components/PWAInstall';
|
||||||
import { useDesignEditor } from './hooks/useDesignEditor';
|
import { useDesignEditor } from './hooks/useDesignEditor';
|
||||||
import { useExport } from './hooks/useExport';
|
import { useExport } from './hooks/useExport';
|
||||||
|
import { TEMPLATES } from './constants/templates';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [currentTemplate, setCurrentTemplate] = useState(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
elements,
|
elements,
|
||||||
selectedId,
|
selectedId,
|
||||||
@@ -16,6 +19,7 @@ function App() {
|
|||||||
deleteElement,
|
deleteElement,
|
||||||
selectElement,
|
selectElement,
|
||||||
deselectAll,
|
deselectAll,
|
||||||
|
commitHistory,
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
canUndo,
|
canUndo,
|
||||||
@@ -78,12 +82,20 @@ function App() {
|
|||||||
addElement(textData);
|
addElement(textData);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddTemplate = (template) => {
|
const handleAddTemplate = (templateId) => {
|
||||||
// Apply template elements to canvas
|
// Find template by ID
|
||||||
if (template && template.elements) {
|
const template = TEMPLATES.find(t => t.id === templateId);
|
||||||
template.elements.forEach((el, index) => {
|
if (template) {
|
||||||
setTimeout(() => addElement({ ...el }), index * 50);
|
setCurrentTemplate(template);
|
||||||
});
|
// Clear existing elements first
|
||||||
|
// Apply template elements to canvas
|
||||||
|
if (template.elements) {
|
||||||
|
template.elements.forEach((el, index) => {
|
||||||
|
setTimeout(() => addElement({ ...el }), index * 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (templateId === 'freeform') {
|
||||||
|
setCurrentTemplate(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +154,7 @@ function App() {
|
|||||||
↷ Redo
|
↷ Redo
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => exportDesign(elements, 'tshirt-design')}
|
onClick={() => exportDesign(elements, 'tshirt-design', currentTemplate)}
|
||||||
disabled={exporting || elements.length === 0}
|
disabled={exporting || elements.length === 0}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
@@ -187,6 +199,7 @@ function App() {
|
|||||||
onSelect={selectElement}
|
onSelect={selectElement}
|
||||||
onDeselect={deselectAll}
|
onDeselect={deselectAll}
|
||||||
onUpdate={(id, attrs) => updateElement(id, attrs)}
|
onUpdate={(id, attrs) => updateElement(id, attrs)}
|
||||||
|
onCommit={commitHistory}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Layers panel below canvas */}
|
{/* Layers panel below canvas */}
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import { Stage, Layer } from 'react-konva';
|
|||||||
import { TShirtSVG } from './TShirtSVG';
|
import { TShirtSVG } from './TShirtSVG';
|
||||||
import { ImageElement } from './ImageElement';
|
import { ImageElement } from './ImageElement';
|
||||||
import { TextElement } from './TextElement';
|
import { TextElement } from './TextElement';
|
||||||
|
import { useRef, useEffect, memo } from 'react';
|
||||||
|
|
||||||
const CANVAS_SIZE = 300;
|
const CANVAS_SIZE = 300;
|
||||||
|
|
||||||
export function DesignCanvas({
|
export const DesignCanvas = memo(function DesignCanvas({
|
||||||
elements,
|
elements,
|
||||||
selectedId,
|
selectedId,
|
||||||
onSelect,
|
onSelect,
|
||||||
onDeselect,
|
onDeselect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onCommit,
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
@@ -49,6 +51,7 @@ export function DesignCanvas({
|
|||||||
isSelected={el.id === selectedId}
|
isSelected={el.id === selectedId}
|
||||||
onSelect={() => onSelect(el.id)}
|
onSelect={() => onSelect(el.id)}
|
||||||
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
||||||
|
onCommit={onCommit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,6 +70,7 @@ export function DesignCanvas({
|
|||||||
isSelected={el.id === selectedId}
|
isSelected={el.id === selectedId}
|
||||||
onSelect={() => onSelect(el.id)}
|
onSelect={() => onSelect(el.id)}
|
||||||
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
onUpdate={(attrs) => onUpdate(el.id, attrs)}
|
||||||
|
onCommit={onCommit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,4 +96,4 @@ export function DesignCanvas({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, memo } from 'react';
|
||||||
import { Image, Transformer } from 'react-konva';
|
import { Image, Transformer } from 'react-konva';
|
||||||
import useImage from 'use-image';
|
import useImage from 'use-image';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ function URLImage({ src, ...props }) {
|
|||||||
return <Image image={img} {...props} />;
|
return <Image image={img} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageElement({
|
export const ImageElement = memo(function ImageElement({
|
||||||
id,
|
id,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -18,6 +18,7 @@ export function ImageElement({
|
|||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onCommit,
|
||||||
}) {
|
}) {
|
||||||
const shapeRef = null;
|
const shapeRef = null;
|
||||||
const trRef = null;
|
const trRef = null;
|
||||||
@@ -47,6 +48,7 @@ export function ImageElement({
|
|||||||
x: e.target.x(),
|
x: e.target.x(),
|
||||||
y: e.target.y(),
|
y: e.target.y(),
|
||||||
});
|
});
|
||||||
|
onCommit?.();
|
||||||
}}
|
}}
|
||||||
onTransformEnd={(e) => {
|
onTransformEnd={(e) => {
|
||||||
const node = shapeRef.current;
|
const node = shapeRef.current;
|
||||||
@@ -61,6 +63,7 @@ export function ImageElement({
|
|||||||
height: Math.max(20, node.height() * scaleY),
|
height: Math.max(20, node.height() * scaleY),
|
||||||
rotation: node.rotation(),
|
rotation: node.rotation(),
|
||||||
});
|
});
|
||||||
|
onCommit?.();
|
||||||
}}
|
}}
|
||||||
boundBoxFunc={(oldBox, newBox) => {
|
boundBoxFunc={(oldBox, newBox) => {
|
||||||
// Minimum size constraint
|
// Minimum size constraint
|
||||||
@@ -95,4 +98,4 @@ ImageElement.defaultProps = {
|
|||||||
width: 100,
|
width: 100,
|
||||||
height: 100,
|
height: 100,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, memo } from 'react';
|
||||||
import { Text, Transformer } from 'react-konva';
|
import { Text, Transformer } from 'react-konva';
|
||||||
|
|
||||||
export function TextElement({
|
export const TextElement = memo(function TextElement({
|
||||||
id,
|
id,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
@@ -13,7 +13,21 @@ export function TextElement({
|
|||||||
isSelected,
|
isSelected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}) {
|
onCommit,
|
||||||
|
}, prevProps) {
|
||||||
|
// Custom comparison for memo
|
||||||
|
if (!prevProps) return true;
|
||||||
|
return (
|
||||||
|
prevProps.x === x &&
|
||||||
|
prevProps.y === y &&
|
||||||
|
prevProps.text === text &&
|
||||||
|
prevProps.fontSize === fontSize &&
|
||||||
|
prevProps.fontFamily === fontFamily &&
|
||||||
|
prevProps.fill === fill &&
|
||||||
|
prevProps.rotation === rotation &&
|
||||||
|
prevProps.isSelected === isSelected
|
||||||
|
);
|
||||||
|
});
|
||||||
const textRef = null;
|
const textRef = null;
|
||||||
const trRef = null;
|
const trRef = null;
|
||||||
|
|
||||||
@@ -43,6 +57,7 @@ export function TextElement({
|
|||||||
x: e.target.x(),
|
x: e.target.x(),
|
||||||
y: e.target.y(),
|
y: e.target.y(),
|
||||||
});
|
});
|
||||||
|
onCommit?.();
|
||||||
}}
|
}}
|
||||||
onTransformEnd={(e) => {
|
onTransformEnd={(e) => {
|
||||||
const node = textRef.current;
|
const node = textRef.current;
|
||||||
@@ -55,6 +70,7 @@ export function TextElement({
|
|||||||
fontSize: Math.max(12, node.fontSize() * scaleX),
|
fontSize: Math.max(12, node.fontSize() * scaleX),
|
||||||
rotation: node.rotation(),
|
rotation: node.rotation(),
|
||||||
});
|
});
|
||||||
|
onCommit?.();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
@@ -77,4 +93,4 @@ TextElement.defaultProps = {
|
|||||||
fontFamily: 'DM Sans',
|
fontFamily: 'DM Sans',
|
||||||
fill: '#0f172a',
|
fill: '#0f172a',
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
};
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
export const LayersPanel = memo(function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
||||||
const getIcon = (element) => {
|
const getIcon = (element) => {
|
||||||
switch (element.type) {
|
switch (element.type) {
|
||||||
case 'image':
|
case 'image':
|
||||||
@@ -128,4 +130,4 @@ export function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export function PropertiesPanel({ element, onUpdate, onDelete }) {
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
export const PropertiesPanel = memo(function PropertiesPanel({ element, onUpdate, onDelete }) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return (
|
return (
|
||||||
<div className="properties-panel">
|
<div className="properties-panel">
|
||||||
@@ -278,4 +280,4 @@ export function PropertiesPanel({ element, onUpdate, onDelete }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
|
import { TEMPLATES, TEMPLATE_CATEGORIES } from '../../constants/templates';
|
||||||
|
|
||||||
|
// Helper to get emoji for category
|
||||||
|
function getCategoryEmoji(category) {
|
||||||
|
const emojis = {
|
||||||
|
Sports: '⚽',
|
||||||
|
Music: '🎸',
|
||||||
|
Quotes: '💬',
|
||||||
|
Animals: '🐱',
|
||||||
|
Abstract: '🌈',
|
||||||
|
Vintage: '🏅',
|
||||||
|
Nature: '🏔️',
|
||||||
|
Tech: '💻',
|
||||||
|
};
|
||||||
|
return emojis[category] || '🎨';
|
||||||
|
}
|
||||||
|
|
||||||
export function TemplatesTab({ onAddTemplate }) {
|
export function TemplatesTab({ onAddTemplate }) {
|
||||||
const templates = [
|
const templates = [
|
||||||
{
|
{
|
||||||
@@ -6,35 +23,15 @@ export function TemplatesTab({ onAddTemplate }) {
|
|||||||
description: 'No template - design freely',
|
description: 'No template - design freely',
|
||||||
thumbnail: '🎨',
|
thumbnail: '🎨',
|
||||||
},
|
},
|
||||||
// Placeholder for future templates
|
...TEMPLATES.map(t => ({
|
||||||
{
|
id: t.id,
|
||||||
id: 'classic-tee-front',
|
name: t.name,
|
||||||
name: 'Classic Tee - Front',
|
description: t.description,
|
||||||
description: 'Standard front chest print',
|
thumbnail: getCategoryEmoji(t.category),
|
||||||
thumbnail: '👕',
|
})),
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'classic-tee-back',
|
|
||||||
name: 'Classic Tee - Back',
|
|
||||||
description: 'Full back print',
|
|
||||||
thumbnail: '👕',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'all-over',
|
|
||||||
name: 'All-Over Print',
|
|
||||||
description: 'Full front coverage',
|
|
||||||
thumbnail: '🎯',
|
|
||||||
disabled: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSelectTemplate = (template) => {
|
const handleSelectTemplate = (template) => {
|
||||||
if (template.disabled) {
|
|
||||||
alert('This template will be available in a future update');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onAddTemplate(template.id);
|
onAddTemplate(template.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,7 +47,7 @@ export function TemplatesTab({ onAddTemplate }) {
|
|||||||
marginBottom: '1rem',
|
marginBottom: '1rem',
|
||||||
lineHeight: '1.4',
|
lineHeight: '1.4',
|
||||||
}}>
|
}}>
|
||||||
Choose a template to constrain your design to specific print zones. Templates will be available in a future update.
|
Choose a template to get started or design freely.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -62,29 +59,24 @@ export function TemplatesTab({ onAddTemplate }) {
|
|||||||
<button
|
<button
|
||||||
key={template.id}
|
key={template.id}
|
||||||
onClick={() => handleSelectTemplate(template)}
|
onClick={() => handleSelectTemplate(template)}
|
||||||
disabled={template.disabled}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '0.75rem',
|
gap: '0.75rem',
|
||||||
padding: '0.75rem',
|
padding: '0.75rem',
|
||||||
border: `1px solid ${template.disabled ? 'var(--border)' : 'var(--border)'}`,
|
border: '1px solid var(--border)',
|
||||||
borderRadius: 'var(--radius-md)',
|
borderRadius: 'var(--radius-md)',
|
||||||
background: template.disabled ? 'var(--bg-tertiary)' : 'var(--bg-primary)',
|
background: 'var(--bg-primary)',
|
||||||
cursor: template.disabled ? 'not-allowed' : 'pointer',
|
cursor: 'pointer',
|
||||||
opacity: template.disabled ? 0.6 : 1,
|
opacity: 1,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
transition: 'all 0.15s ease',
|
transition: 'all 0.15s ease',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!template.disabled) {
|
e.target.style.borderColor = 'var(--accent)';
|
||||||
e.target.style.borderColor = 'var(--accent)';
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!template.disabled) {
|
e.target.style.borderColor = 'var(--border)';
|
||||||
e.target.style.borderColor = 'var(--border)';
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -114,17 +106,6 @@ export function TemplatesTab({ onAddTemplate }) {
|
|||||||
{template.description}
|
{template.description}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{template.disabled && (
|
|
||||||
<span style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
padding: '2px 6px',
|
|
||||||
background: 'var(--bg-tertiary)',
|
|
||||||
borderRadius: 'var(--radius-sm)',
|
|
||||||
color: 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
Soon
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
const MAX_HISTORY = 50;
|
const MAX_HISTORY = 50;
|
||||||
|
const DEBOUNCE_DELAY_MS = 300;
|
||||||
|
|
||||||
export function useDesignEditor() {
|
export function useDesignEditor() {
|
||||||
const [elements, setElements] = useState([]);
|
const [elements, setElements] = useState([]);
|
||||||
@@ -10,6 +11,10 @@ export function useDesignEditor() {
|
|||||||
const historyRef = useRef([]);
|
const historyRef = useRef([]);
|
||||||
const historyIndexRef = useRef(-1);
|
const historyIndexRef = useRef(-1);
|
||||||
|
|
||||||
|
// Debounce timer for rapid changes (drag/transform)
|
||||||
|
const historyTimerRef = useRef(null);
|
||||||
|
const pendingChangesRef = useRef(null);
|
||||||
|
|
||||||
const saveToHistory = useCallback((newElements) => {
|
const saveToHistory = useCallback((newElements) => {
|
||||||
// Remove any future history if we're in the middle of the stack
|
// Remove any future history if we're in the middle of the stack
|
||||||
if (historyIndexRef.current < historyRef.current.length - 1) {
|
if (historyIndexRef.current < historyRef.current.length - 1) {
|
||||||
@@ -27,10 +32,34 @@ export function useDesignEditor() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Flush pending changes to history
|
||||||
|
const flushPendingChanges = useCallback(() => {
|
||||||
|
if (pendingChangesRef.current) {
|
||||||
|
saveToHistory(pendingChangesRef.current);
|
||||||
|
pendingChangesRef.current = null;
|
||||||
|
}
|
||||||
|
if (historyTimerRef.current) {
|
||||||
|
clearTimeout(historyTimerRef.current);
|
||||||
|
historyTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, [saveToHistory]);
|
||||||
|
|
||||||
|
// Cleanup timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (historyTimerRef.current) {
|
||||||
|
clearTimeout(historyTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const canUndo = historyIndexRef.current > 0;
|
const canUndo = historyIndexRef.current > 0;
|
||||||
const canRedo = historyIndexRef.current < historyRef.current.length - 1;
|
const canRedo = historyIndexRef.current < historyRef.current.length - 1;
|
||||||
|
|
||||||
const addElement = useCallback((element) => {
|
const addElement = useCallback((element) => {
|
||||||
|
// Flush any pending debounced changes first
|
||||||
|
flushPendingChanges();
|
||||||
|
|
||||||
const newElement = {
|
const newElement = {
|
||||||
...element,
|
...element,
|
||||||
id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
@@ -44,17 +73,34 @@ export function useDesignEditor() {
|
|||||||
|
|
||||||
setSelectedId(newElement.id);
|
setSelectedId(newElement.id);
|
||||||
return newElement.id;
|
return newElement.id;
|
||||||
}, [saveToHistory]);
|
}, [flushPendingChanges, saveToHistory]);
|
||||||
|
|
||||||
const updateElement = useCallback((id, attrs) => {
|
const updateElement = useCallback((id, attrs) => {
|
||||||
setElements((prev) => {
|
setElements((prev) => {
|
||||||
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
|
const newElements = prev.map((el) => (el.id === id ? { ...el, ...attrs } : el));
|
||||||
saveToHistory(newElements);
|
|
||||||
|
// Debounce history commits for rapid changes (drag/transform)
|
||||||
|
// Store pending changes but don't commit yet
|
||||||
|
pendingChangesRef.current = newElements;
|
||||||
|
|
||||||
|
// Clear existing timer
|
||||||
|
if (historyTimerRef.current) {
|
||||||
|
clearTimeout(historyTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timer to commit changes after delay
|
||||||
|
historyTimerRef.current = setTimeout(() => {
|
||||||
|
flushPendingChanges();
|
||||||
|
}, DEBOUNCE_DELAY_MS);
|
||||||
|
|
||||||
return newElements;
|
return newElements;
|
||||||
});
|
});
|
||||||
}, [saveToHistory]);
|
}, [flushPendingChanges]);
|
||||||
|
|
||||||
const deleteElement = useCallback((id) => {
|
const deleteElement = useCallback((id) => {
|
||||||
|
// Flush any pending debounced changes first
|
||||||
|
flushPendingChanges();
|
||||||
|
|
||||||
setElements((prev) => {
|
setElements((prev) => {
|
||||||
const newElements = prev.filter((el) => el.id !== id);
|
const newElements = prev.filter((el) => el.id !== id);
|
||||||
saveToHistory(newElements);
|
saveToHistory(newElements);
|
||||||
@@ -64,7 +110,7 @@ export function useDesignEditor() {
|
|||||||
if (selectedId === id) {
|
if (selectedId === id) {
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
}
|
}
|
||||||
}, [selectedId, saveToHistory]);
|
}, [selectedId, flushPendingChanges, saveToHistory]);
|
||||||
|
|
||||||
const selectElement = useCallback((id) => {
|
const selectElement = useCallback((id) => {
|
||||||
setSelectedId(id);
|
setSelectedId(id);
|
||||||
@@ -75,6 +121,9 @@ export function useDesignEditor() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reorderElement = useCallback((id, newOrder) => {
|
const reorderElement = useCallback((id, newOrder) => {
|
||||||
|
// Flush any pending debounced changes first
|
||||||
|
flushPendingChanges();
|
||||||
|
|
||||||
setElements((prev) => {
|
setElements((prev) => {
|
||||||
const index = prev.findIndex((el) => el.id === id);
|
const index = prev.findIndex((el) => el.id === id);
|
||||||
if (index === -1 || index === newOrder) return prev;
|
if (index === -1 || index === newOrder) return prev;
|
||||||
@@ -86,7 +135,12 @@ export function useDesignEditor() {
|
|||||||
saveToHistory(newElements);
|
saveToHistory(newElements);
|
||||||
return newElements;
|
return newElements;
|
||||||
});
|
});
|
||||||
}, [saveToHistory]);
|
}, [flushPendingChanges, saveToHistory]);
|
||||||
|
|
||||||
|
// Commit history immediately (called on dragEnd/transformEnd)
|
||||||
|
const commitHistory = useCallback(() => {
|
||||||
|
flushPendingChanges();
|
||||||
|
}, [flushPendingChanges]);
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback(() => {
|
||||||
if (historyIndexRef.current > 0) {
|
if (historyIndexRef.current > 0) {
|
||||||
@@ -121,6 +175,7 @@ export function useDesignEditor() {
|
|||||||
selectElement,
|
selectElement,
|
||||||
deselectAll,
|
deselectAll,
|
||||||
reorderElement,
|
reorderElement,
|
||||||
|
commitHistory, // Call this on dragEnd/transformEnd to commit debounced changes
|
||||||
undo,
|
undo,
|
||||||
redo,
|
redo,
|
||||||
canUndo,
|
canUndo,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function useExport() {
|
|||||||
const [exportUrl, setExportUrl] = useState(null);
|
const [exportUrl, setExportUrl] = useState(null);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
const exportDesign = useCallback(async (elements, designName = 'design') => {
|
const exportDesign = useCallback(async (elements, designName = 'design', template = null) => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -23,7 +23,7 @@ export function useExport() {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ elements, designName }),
|
body: JSON.stringify({ elements, designName, template }),
|
||||||
});
|
});
|
||||||
|
|
||||||
clearInterval(progressInterval);
|
clearInterval(progressInterval);
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ export default defineConfig({
|
|||||||
react(),
|
react(),
|
||||||
VitePWA({
|
VitePWA({
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
|
includeAssets: ['favicon.ico', 'pwa-192x192.svg', 'pwa-512x512.svg'],
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'Apparel Designer',
|
name: 'Apparel Designer',
|
||||||
short_name: 'ApparelDesigner',
|
short_name: 'ApparelDesigner',
|
||||||
description: 'T-shirt customization editor',
|
description: 'T-shirt customization editor',
|
||||||
theme_color: '#ffffff',
|
theme_color: '#38bdf8',
|
||||||
background_color: '#ffffff',
|
background_color: '#ffffff',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
orientation: 'any',
|
orientation: 'any',
|
||||||
@@ -20,19 +20,19 @@ export default defineConfig({
|
|||||||
start_url: '/',
|
start_url: '/',
|
||||||
icons: [
|
icons: [
|
||||||
{
|
{
|
||||||
src: 'pwa-192x192.png',
|
src: 'pwa-192x192.svg',
|
||||||
sizes: '192x192',
|
sizes: '192x192',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'pwa-512x512.png',
|
src: 'pwa-512x512.svg',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'pwa-512x512.png',
|
src: 'pwa-512x512.svg',
|
||||||
sizes: '512x512',
|
sizes: '512x512',
|
||||||
type: 'image/png',
|
type: 'image/svg+xml',
|
||||||
purpose: 'any maskable',
|
purpose: 'any maskable',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
239
docs/template-schema.md
Normal file
239
docs/template-schema.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Template JSON Schema
|
||||||
|
|
||||||
|
This document describes the template structure for creating custom t-shirt design templates.
|
||||||
|
|
||||||
|
## Template Structure
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "string (unique identifier)",
|
||||||
|
"name": "string (display name)",
|
||||||
|
"category": "string (Sports|Music|Quotes|Animals|Abstract|Vintage|Nature|Tech)",
|
||||||
|
"description": "string (short description)",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "text" | "image",
|
||||||
|
"text": "string (for text elements only)",
|
||||||
|
"x": "number (x position in canvas units)",
|
||||||
|
"y": "number (y position in canvas units)",
|
||||||
|
"fontSize": "number (for text elements)",
|
||||||
|
"fontFamily": "string (Google Font name)",
|
||||||
|
"fill": "string (hex color)",
|
||||||
|
"rotation": "number (degrees, -180 to 180)",
|
||||||
|
"width": "number (for image elements)",
|
||||||
|
"height": "number (for image elements)",
|
||||||
|
"src": "string (image URL or path)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Element Properties
|
||||||
|
|
||||||
|
### Text Element
|
||||||
|
|
||||||
|
| Property | Type | Required | Description |
|
||||||
|
|------------|--------|----------|---------------------------------------|
|
||||||
|
| `type` | string | Yes | Must be `"text"` |
|
||||||
|
| `text` | string | Yes | The text content to display |
|
||||||
|
| `x` | number | Yes | X position on canvas (0-300) |
|
||||||
|
| `y` | number | Yes | Y position on canvas (0-300) |
|
||||||
|
| `fontSize` | number | Yes | Font size in pixels (12-120) |
|
||||||
|
| `fontFamily`| string| No | Google Font name (default: "DM Sans") |
|
||||||
|
| `fill` | string | No | Hex color (default: "#0f172a") |
|
||||||
|
| `rotation` | number | No | Rotation in degrees (default: 0) |
|
||||||
|
|
||||||
|
### Image Element
|
||||||
|
|
||||||
|
| Property | Type | Required | Description |
|
||||||
|
|------------|--------|----------|---------------------------------------|
|
||||||
|
| `type` | string | Yes | Must be `"image"` |
|
||||||
|
| `src` | string | Yes | Image URL or server path |
|
||||||
|
| `x` | number | Yes | X position on canvas (0-300) |
|
||||||
|
| `y` | number | Yes | Y position on canvas (0-300) |
|
||||||
|
| `width` | number | Yes | Width in canvas units (min: 20) |
|
||||||
|
| `height` | number | Yes | Height in canvas units (min: 20) |
|
||||||
|
| `rotation` | number | No | Rotation in degrees (default: 0) |
|
||||||
|
|
||||||
|
## Advanced Template Features
|
||||||
|
|
||||||
|
### Template with Background and Overlay
|
||||||
|
|
||||||
|
For templates that include background images and overlay elements:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "team-sport",
|
||||||
|
"name": "Team Sport",
|
||||||
|
"category": "Sports",
|
||||||
|
"description": "Classic team jersey with number and text",
|
||||||
|
"background": {
|
||||||
|
"type": "color" | "image",
|
||||||
|
"color": "string (hex, if type is color)",
|
||||||
|
"src": "string (URL/path, if type is image)"
|
||||||
|
},
|
||||||
|
"overlay": [
|
||||||
|
{
|
||||||
|
"type": "text" | "image",
|
||||||
|
"nonPrintable": false,
|
||||||
|
"...": "same properties as regular elements"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elements": [...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slot-Based Templates
|
||||||
|
|
||||||
|
For templates with image slots (auto-crop regions):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "classic-tee-front",
|
||||||
|
"name": "Classic Tee - Front",
|
||||||
|
"slots": [
|
||||||
|
{
|
||||||
|
"id": "chest-logo",
|
||||||
|
"bounds": { "x": 100, "y": 80, "width": 100, "height": 100 },
|
||||||
|
"aspectRatio": 1.0,
|
||||||
|
"label": "Chest Logo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sleeve-left",
|
||||||
|
"bounds": { "x": 20, "y": 100, "width": 60, "height": 60 },
|
||||||
|
"aspectRatio": 1.0,
|
||||||
|
"label": "Left Sleeve"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"elements": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Slot Crop Region
|
||||||
|
|
||||||
|
When an image is assigned to a slot, the crop property is applied:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"src": "/uploads/image.png",
|
||||||
|
"x": 100,
|
||||||
|
"y": 80,
|
||||||
|
"width": 100,
|
||||||
|
"height": 100,
|
||||||
|
"crop": {
|
||||||
|
"sx": 0,
|
||||||
|
"sy": 0,
|
||||||
|
"sWidth": 500,
|
||||||
|
"sHeight": 500
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
|-----------|--------|--------------------------------------|
|
||||||
|
| `sx` | number | Source x coordinate for crop |
|
||||||
|
| `sy` | number | Source y coordinate for crop |
|
||||||
|
| `sWidth` | number | Source width for crop |
|
||||||
|
| `sHeight` | number | Source height for crop |
|
||||||
|
|
||||||
|
## Non-Printable Elements
|
||||||
|
|
||||||
|
Elements can be marked as non-printable to exclude them from export:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "Guide Text",
|
||||||
|
"nonPrintable": true,
|
||||||
|
"x": 150,
|
||||||
|
"y": 150,
|
||||||
|
"fontSize": 12,
|
||||||
|
"fill": "#cccccc"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Templates
|
||||||
|
|
||||||
|
### Minimal Quote Template
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "minimal-quote",
|
||||||
|
"name": "Minimal Quote",
|
||||||
|
"category": "Quotes",
|
||||||
|
"description": "Simple centered quote design",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "\"Be the change\"",
|
||||||
|
"x": 150,
|
||||||
|
"y": 130,
|
||||||
|
"fontSize": 24,
|
||||||
|
"fontFamily": "Georgia",
|
||||||
|
"fill": "#1e293b",
|
||||||
|
"rotation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "you wish to see",
|
||||||
|
"x": 150,
|
||||||
|
"y": 160,
|
||||||
|
"fontSize": 18,
|
||||||
|
"fontFamily": "Arial",
|
||||||
|
"fill": "#64748b",
|
||||||
|
"rotation": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Team Sport Template
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "team-sport",
|
||||||
|
"name": "Team Sport",
|
||||||
|
"category": "Sports",
|
||||||
|
"description": "Classic team jersey with number and text",
|
||||||
|
"elements": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "TEAM NAME",
|
||||||
|
"x": 75,
|
||||||
|
"y": 80,
|
||||||
|
"fontSize": 28,
|
||||||
|
"fontFamily": "Impact",
|
||||||
|
"fill": "#ffffff",
|
||||||
|
"rotation": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "23",
|
||||||
|
"x": 150,
|
||||||
|
"y": 150,
|
||||||
|
"fontSize": 72,
|
||||||
|
"fontFamily": "Impact",
|
||||||
|
"fill": "#ffffff",
|
||||||
|
"rotation": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Canvas Coordinate System
|
||||||
|
|
||||||
|
- Canvas size: 300x300 units (preview)
|
||||||
|
- Export size: 4500x4500 pixels (300 DPI, 15"x15")
|
||||||
|
- Scale factor: 15x (export / preview)
|
||||||
|
- Origin (0,0): Top-left corner
|
||||||
|
- X increases: Left to right
|
||||||
|
- Y increases: Top to bottom
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
1. **Keep text readable**: Use font sizes between 18-72px for main text
|
||||||
|
2. **Respect print area**: Keep elements within 0-300 canvas bounds
|
||||||
|
3. **Use contrasting colors**: Ensure text is visible on shirt colors
|
||||||
|
4. **Test at export size**: Verify designs look good at 4500x4500px
|
||||||
|
5. **Limit elements**: 5-10 elements maximum for performance
|
||||||
@@ -127,7 +127,7 @@ const EXPORT_SIZE = 4500;
|
|||||||
|
|
||||||
app.post('/api/export', async (req, res) => {
|
app.post('/api/export', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { elements, designName = 'design' } = req.body;
|
const { elements, designName = 'design', template } = req.body;
|
||||||
|
|
||||||
if (!elements || !Array.isArray(elements)) {
|
if (!elements || !Array.isArray(elements)) {
|
||||||
return res.status(400).json({ error: 'Elements array is required' });
|
return res.status(400).json({ error: 'Elements array is required' });
|
||||||
@@ -136,12 +136,33 @@ app.post('/api/export', async (req, res) => {
|
|||||||
const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE);
|
const canvas = createCanvas(EXPORT_SIZE, EXPORT_SIZE);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
// White background
|
// Render template background layer first (if template active)
|
||||||
ctx.fillStyle = '#ffffff';
|
if (template && template.background) {
|
||||||
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
const bg = template.background;
|
||||||
|
if (bg.type === 'color') {
|
||||||
|
ctx.fillStyle = bg.color;
|
||||||
|
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
||||||
|
} else if (bg.type === 'image' && bg.src) {
|
||||||
|
try {
|
||||||
|
const imgUrl = bg.src.startsWith('/')
|
||||||
|
? join(__dirname, bg.src.replace('/uploads', 'uploads'))
|
||||||
|
: bg.src;
|
||||||
|
const img = await loadImage(imgUrl);
|
||||||
|
ctx.drawImage(img, 0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
||||||
|
} catch (imgError) {
|
||||||
|
console.error('Failed to load template background:', imgError);
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Default white background
|
||||||
|
ctx.fillStyle = '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
// Render each element
|
// Helper function to render a single element
|
||||||
for (const el of elements) {
|
const renderElement = async (el) => {
|
||||||
ctx.save();
|
ctx.save();
|
||||||
|
|
||||||
const x = (el.x || 0) * EXPORT_SCALE;
|
const x = (el.x || 0) * EXPORT_SCALE;
|
||||||
@@ -161,7 +182,18 @@ app.post('/api/export', async (req, res) => {
|
|||||||
const img = await loadImage(imgUrl);
|
const img = await loadImage(imgUrl);
|
||||||
const width = (el.width || 100) * EXPORT_SCALE;
|
const width = (el.width || 100) * EXPORT_SCALE;
|
||||||
const height = (el.height || 100) * EXPORT_SCALE;
|
const height = (el.height || 100) * EXPORT_SCALE;
|
||||||
ctx.drawImage(img, x, y, width, height);
|
|
||||||
|
// Apply crop if slot crop region specified
|
||||||
|
if (el.crop) {
|
||||||
|
const { sx, sy, sWidth, sHeight } = el.crop;
|
||||||
|
ctx.drawImage(
|
||||||
|
img,
|
||||||
|
sx, sy, sWidth, sHeight, // source crop
|
||||||
|
x, y, width, height // destination
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ctx.drawImage(img, x, y, width, height);
|
||||||
|
}
|
||||||
} catch (imgError) {
|
} catch (imgError) {
|
||||||
console.error('Failed to load image for export:', imgError);
|
console.error('Failed to load image for export:', imgError);
|
||||||
}
|
}
|
||||||
@@ -175,6 +207,21 @@ app.post('/api/export', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.restore();
|
ctx.restore();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render user elements
|
||||||
|
for (const el of elements) {
|
||||||
|
// Skip non-printable elements (guides, watermarks, template-only layers)
|
||||||
|
if (el.nonPrintable) continue;
|
||||||
|
await renderElement(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render template overlay layer last (if template active)
|
||||||
|
if (template && template.overlay) {
|
||||||
|
for (const overlayEl of template.overlay) {
|
||||||
|
if (overlayEl.nonPrintable) continue;
|
||||||
|
await renderElement(overlayEl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
|
|||||||
Reference in New Issue
Block a user