diff --git a/client/public/pwa-192x192.svg b/client/public/pwa-192x192.svg new file mode 100644 index 0000000..13d04d5 --- /dev/null +++ b/client/public/pwa-192x192.svg @@ -0,0 +1,5 @@ + + + + T + diff --git a/client/public/pwa-512x512.svg b/client/public/pwa-512x512.svg new file mode 100644 index 0000000..f2dbd73 --- /dev/null +++ b/client/public/pwa-512x512.svg @@ -0,0 +1,5 @@ + + + + T + diff --git a/client/src/App.jsx b/client/src/App.jsx index 4f3f532..7e4786b 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -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 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 */} diff --git a/client/src/components/canvas/DesignCanvas.jsx b/client/src/components/canvas/DesignCanvas.jsx index 0721d4e..a3f2d99 100644 --- a/client/src/components/canvas/DesignCanvas.jsx +++ b/client/src/components/canvas/DesignCanvas.jsx @@ -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 ( @@ -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({ ); -} +}); diff --git a/client/src/components/canvas/ImageElement.jsx b/client/src/components/canvas/ImageElement.jsx index 6d06b4d..5971bb3 100644 --- a/client/src/components/canvas/ImageElement.jsx +++ b/client/src/components/canvas/ImageElement.jsx @@ -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 ; } -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, -}; +}); diff --git a/client/src/components/canvas/TextElement.jsx b/client/src/components/canvas/TextElement.jsx index 742b9ff..9cd3cdf 100644 --- a/client/src/components/canvas/TextElement.jsx +++ b/client/src/components/canvas/TextElement.jsx @@ -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, -}; +}); diff --git a/client/src/components/panels/LayersPanel.jsx b/client/src/components/panels/LayersPanel.jsx index bff5f82..ff3979c 100644 --- a/client/src/components/panels/LayersPanel.jsx +++ b/client/src/components/panels/LayersPanel.jsx @@ -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 }) { ); -} +}); diff --git a/client/src/components/panels/PropertiesPanel.jsx b/client/src/components/panels/PropertiesPanel.jsx index 316ffd6..6e637b0 100644 --- a/client/src/components/panels/PropertiesPanel.jsx +++ b/client/src/components/panels/PropertiesPanel.jsx @@ -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 ( @@ -278,4 +280,4 @@ export function PropertiesPanel({ element, onUpdate, onDelete }) { ); -} +}); diff --git a/client/src/components/sidebar/TemplatesTab.jsx b/client/src/components/sidebar/TemplatesTab.jsx index 70d9500..8079f9a 100644 --- a/client/src/components/sidebar/TemplatesTab.jsx +++ b/client/src/components/sidebar/TemplatesTab.jsx @@ -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. 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)'; }} > - {template.disabled && ( - - Soon - - )} ))} diff --git a/client/src/hooks/useDesignEditor.js b/client/src/hooks/useDesignEditor.js index 0cdd4ca..17a62e6 100644 --- a/client/src/hooks/useDesignEditor.js +++ b/client/src/hooks/useDesignEditor.js @@ -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, diff --git a/client/src/hooks/useExport.js b/client/src/hooks/useExport.js index 0c5d16e..6b5f459 100644 --- a/client/src/hooks/useExport.js +++ b/client/src/hooks/useExport.js @@ -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); diff --git a/client/vite.config.js b/client/vite.config.js index f00e4c1..cc6d2c2 100644 --- a/client/vite.config.js +++ b/client/vite.config.js @@ -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', }, ], diff --git a/docs/template-schema.md b/docs/template-schema.md new file mode 100644 index 0000000..9d1b1b5 --- /dev/null +++ b/docs/template-schema.md @@ -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 diff --git a/server/index.js b/server/index.js index 843121b..16881b3 100644 --- a/server/index.js +++ b/server/index.js @@ -127,7 +127,7 @@ const EXPORT_SIZE = 4500; app.post('/api/export', async (req, res) => { try { - const { elements, designName = 'design' } = req.body; + const { elements, designName = 'design', template } = req.body; if (!elements || !Array.isArray(elements)) { 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 ctx = canvas.getContext('2d'); - // White background - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, EXPORT_SIZE, EXPORT_SIZE); + // Render template background layer first (if template active) + if (template && template.background) { + 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 - for (const el of elements) { + // Helper function to render a single element + const renderElement = async (el) => { ctx.save(); const x = (el.x || 0) * EXPORT_SCALE; @@ -161,7 +182,18 @@ app.post('/api/export', async (req, res) => { const img = await loadImage(imgUrl); const width = (el.width || 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) { console.error('Failed to load image for export:', imgError); } @@ -175,6 +207,21 @@ app.post('/api/export', async (req, res) => { } 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