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:
Khalid A
2026-04-21 21:50:33 -05:00
parent a02f020d4c
commit 4ca7910465
14 changed files with 465 additions and 93 deletions

View 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

View 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

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { DesignCanvas } from './components/canvas/DesignCanvas';
import { Sidebar } from './components/sidebar/Sidebar';
import { LayersPanel } from './components/panels/LayersPanel';
@@ -6,8 +6,11 @@ import { PropertiesPanel } from './components/panels/PropertiesPanel';
import { PWAInstall } from './components/PWAInstall';
import { useDesignEditor } from './hooks/useDesignEditor';
import { useExport } from './hooks/useExport';
import { TEMPLATES } from './constants/templates';
function App() {
const [currentTemplate, setCurrentTemplate] = useState(null);
const {
elements,
selectedId,
@@ -16,6 +19,7 @@ function App() {
deleteElement,
selectElement,
deselectAll,
commitHistory,
undo,
redo,
canUndo,
@@ -78,12 +82,20 @@ function App() {
addElement(textData);
};
const handleAddTemplate = (template) => {
// Apply template elements to canvas
if (template && template.elements) {
template.elements.forEach((el, index) => {
setTimeout(() => addElement({ ...el }), index * 50);
});
const handleAddTemplate = (templateId) => {
// Find template by ID
const template = TEMPLATES.find(t => t.id === templateId);
if (template) {
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
</button>
<button
onClick={() => exportDesign(elements, 'tshirt-design')}
onClick={() => exportDesign(elements, 'tshirt-design', currentTemplate)}
disabled={exporting || elements.length === 0}
style={{
padding: '0.5rem 1rem',
@@ -187,6 +199,7 @@ function App() {
onSelect={selectElement}
onDeselect={deselectAll}
onUpdate={(id, attrs) => updateElement(id, attrs)}
onCommit={commitHistory}
/>
{/* Layers panel below canvas */}

View File

@@ -2,15 +2,17 @@ import { Stage, Layer } from 'react-konva';
import { TShirtSVG } from './TShirtSVG';
import { ImageElement } from './ImageElement';
import { TextElement } from './TextElement';
import { useRef, useEffect, memo } from 'react';
const CANVAS_SIZE = 300;
export function DesignCanvas({
export const DesignCanvas = memo(function DesignCanvas({
elements,
selectedId,
onSelect,
onDeselect,
onUpdate,
onCommit,
}) {
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
@@ -49,6 +51,7 @@ export function DesignCanvas({
isSelected={el.id === selectedId}
onSelect={() => onSelect(el.id)}
onUpdate={(attrs) => onUpdate(el.id, attrs)}
onCommit={onCommit}
/>
);
}
@@ -67,6 +70,7 @@ export function DesignCanvas({
isSelected={el.id === selectedId}
onSelect={() => onSelect(el.id)}
onUpdate={(attrs) => onUpdate(el.id, attrs)}
onCommit={onCommit}
/>
);
}
@@ -92,4 +96,4 @@ export function DesignCanvas({
</div>
</div>
);
}
});

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, memo } from 'react';
import { Image, Transformer } from 'react-konva';
import useImage from 'use-image';
@@ -7,7 +7,7 @@ function URLImage({ src, ...props }) {
return <Image image={img} {...props} />;
}
export function ImageElement({
export const ImageElement = memo(function ImageElement({
id,
x,
y,
@@ -18,6 +18,7 @@ export function ImageElement({
isSelected,
onSelect,
onUpdate,
onCommit,
}) {
const shapeRef = null;
const trRef = null;
@@ -47,6 +48,7 @@ export function ImageElement({
x: e.target.x(),
y: e.target.y(),
});
onCommit?.();
}}
onTransformEnd={(e) => {
const node = shapeRef.current;
@@ -61,6 +63,7 @@ export function ImageElement({
height: Math.max(20, node.height() * scaleY),
rotation: node.rotation(),
});
onCommit?.();
}}
boundBoxFunc={(oldBox, newBox) => {
// Minimum size constraint
@@ -95,4 +98,4 @@ ImageElement.defaultProps = {
width: 100,
height: 100,
rotation: 0,
};
});

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import { useEffect, memo } from 'react';
import { Text, Transformer } from 'react-konva';
export function TextElement({
export const TextElement = memo(function TextElement({
id,
x,
y,
@@ -13,7 +13,21 @@ export function TextElement({
isSelected,
onSelect,
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 trRef = null;
@@ -43,6 +57,7 @@ export function TextElement({
x: e.target.x(),
y: e.target.y(),
});
onCommit?.();
}}
onTransformEnd={(e) => {
const node = textRef.current;
@@ -55,6 +70,7 @@ export function TextElement({
fontSize: Math.max(12, node.fontSize() * scaleX),
rotation: node.rotation(),
});
onCommit?.();
}}
/>
{isSelected && (
@@ -77,4 +93,4 @@ TextElement.defaultProps = {
fontFamily: 'DM Sans',
fill: '#0f172a',
rotation: 0,
};
});

View File

@@ -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) => {
switch (element.type) {
case 'image':
@@ -128,4 +130,4 @@ export function LayersPanel({ elements, selectedId, onSelect, onDelete }) {
</div>
</div>
);
}
});

View File

@@ -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) {
return (
<div className="properties-panel">
@@ -278,4 +280,4 @@ export function PropertiesPanel({ element, onUpdate, onDelete }) {
</div>
</div>
);
}
});

View File

@@ -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 }) {
const templates = [
{
@@ -6,35 +23,15 @@ export function TemplatesTab({ onAddTemplate }) {
description: 'No template - design freely',
thumbnail: '🎨',
},
// Placeholder for future templates
{
id: 'classic-tee-front',
name: 'Classic Tee - Front',
description: 'Standard front chest print',
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,
},
...TEMPLATES.map(t => ({
id: t.id,
name: t.name,
description: t.description,
thumbnail: getCategoryEmoji(t.category),
})),
];
const handleSelectTemplate = (template) => {
if (template.disabled) {
alert('This template will be available in a future update');
return;
}
onAddTemplate(template.id);
};
@@ -50,7 +47,7 @@ export function TemplatesTab({ onAddTemplate }) {
marginBottom: '1rem',
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 style={{
@@ -62,29 +59,24 @@ export function TemplatesTab({ onAddTemplate }) {
<button
key={template.id}
onClick={() => handleSelectTemplate(template)}
disabled={template.disabled}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.75rem',
border: `1px solid ${template.disabled ? 'var(--border)' : 'var(--border)'}`,
border: '1px solid var(--border)',
borderRadius: 'var(--radius-md)',
background: template.disabled ? 'var(--bg-tertiary)' : 'var(--bg-primary)',
cursor: template.disabled ? 'not-allowed' : 'pointer',
opacity: template.disabled ? 0.6 : 1,
background: 'var(--bg-primary)',
cursor: 'pointer',
opacity: 1,
textAlign: 'left',
transition: 'all 0.15s ease',
}}
onMouseEnter={(e) => {
if (!template.disabled) {
e.target.style.borderColor = 'var(--accent)';
}
e.target.style.borderColor = 'var(--accent)';
}}
onMouseLeave={(e) => {
if (!template.disabled) {
e.target.style.borderColor = 'var(--border)';
}
e.target.style.borderColor = 'var(--border)';
}}
>
<div style={{
@@ -114,17 +106,6 @@ export function TemplatesTab({ onAddTemplate }) {
{template.description}
</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>
))}
</div>

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
const MAX_HISTORY = 50;
const DEBOUNCE_DELAY_MS = 300;
export function useDesignEditor() {
const [elements, setElements] = useState([]);
@@ -10,6 +11,10 @@ export function useDesignEditor() {
const historyRef = useRef([]);
const historyIndexRef = useRef(-1);
// Debounce timer for rapid changes (drag/transform)
const historyTimerRef = useRef(null);
const pendingChangesRef = useRef(null);
const saveToHistory = useCallback((newElements) => {
// Remove any future history if we're in the middle of the stack
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 canRedo = historyIndexRef.current < historyRef.current.length - 1;
const addElement = useCallback((element) => {
// Flush any pending debounced changes first
flushPendingChanges();
const newElement = {
...element,
id: `element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
@@ -44,17 +73,34 @@ export function useDesignEditor() {
setSelectedId(newElement.id);
return newElement.id;
}, [saveToHistory]);
}, [flushPendingChanges, saveToHistory]);
const updateElement = useCallback((id, attrs) => {
setElements((prev) => {
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;
});
}, [saveToHistory]);
}, [flushPendingChanges]);
const deleteElement = useCallback((id) => {
// Flush any pending debounced changes first
flushPendingChanges();
setElements((prev) => {
const newElements = prev.filter((el) => el.id !== id);
saveToHistory(newElements);
@@ -64,7 +110,7 @@ export function useDesignEditor() {
if (selectedId === id) {
setSelectedId(null);
}
}, [selectedId, saveToHistory]);
}, [selectedId, flushPendingChanges, saveToHistory]);
const selectElement = useCallback((id) => {
setSelectedId(id);
@@ -75,6 +121,9 @@ export function useDesignEditor() {
}, []);
const reorderElement = useCallback((id, newOrder) => {
// Flush any pending debounced changes first
flushPendingChanges();
setElements((prev) => {
const index = prev.findIndex((el) => el.id === id);
if (index === -1 || index === newOrder) return prev;
@@ -86,7 +135,12 @@ export function useDesignEditor() {
saveToHistory(newElements);
return newElements;
});
}, [saveToHistory]);
}, [flushPendingChanges, saveToHistory]);
// Commit history immediately (called on dragEnd/transformEnd)
const commitHistory = useCallback(() => {
flushPendingChanges();
}, [flushPendingChanges]);
const undo = useCallback(() => {
if (historyIndexRef.current > 0) {
@@ -121,6 +175,7 @@ export function useDesignEditor() {
selectElement,
deselectAll,
reorderElement,
commitHistory, // Call this on dragEnd/transformEnd to commit debounced changes
undo,
redo,
canUndo,

View File

@@ -6,7 +6,7 @@ export function useExport() {
const [exportUrl, setExportUrl] = 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);
setProgress(0);
setError(null);
@@ -23,7 +23,7 @@ export function useExport() {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ elements, designName }),
body: JSON.stringify({ elements, designName, template }),
});
clearInterval(progressInterval);

View File

@@ -7,12 +7,12 @@ export default defineConfig({
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'apple-touch-icon.png', 'masked-icon.svg'],
includeAssets: ['favicon.ico', 'pwa-192x192.svg', 'pwa-512x512.svg'],
manifest: {
name: 'Apparel Designer',
short_name: 'ApparelDesigner',
description: 'T-shirt customization editor',
theme_color: '#ffffff',
theme_color: '#38bdf8',
background_color: '#ffffff',
display: 'standalone',
orientation: 'any',
@@ -20,19 +20,19 @@ export default defineConfig({
start_url: '/',
icons: [
{
src: 'pwa-192x192.png',
src: 'pwa-192x192.svg',
sizes: '192x192',
type: 'image/png',
type: 'image/svg+xml',
},
{
src: 'pwa-512x512.png',
src: 'pwa-512x512.svg',
sizes: '512x512',
type: 'image/png',
type: 'image/svg+xml',
},
{
src: 'pwa-512x512.png',
src: 'pwa-512x512.svg',
sizes: '512x512',
type: 'image/png',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],