Phase 8: High-Resolution Export

- Server-side export endpoint using node-canvas
- 300x300px preview -> 4500x4500px export (15"x15" @ 300 DPI)
- Exports images and text elements with proper scaling
- useExport hook with progress indicator
- Export button in canvas header
- Automatic download of exported PNG
- Error handling with dismissible error banner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Khalid A
2026-04-21 01:29:41 -05:00
parent 537cfd572d
commit 8a4b653019
4 changed files with 260 additions and 15 deletions

View File

@@ -3,6 +3,7 @@ import { DesignCanvas } from './components/canvas/DesignCanvas';
import { Sidebar } from './components/sidebar/Sidebar'; import { Sidebar } from './components/sidebar/Sidebar';
import { PropertiesPanel } from './components/properties/PropertiesPanel'; import { PropertiesPanel } from './components/properties/PropertiesPanel';
import { useDesignEditor } from './hooks/useDesignEditor'; import { useDesignEditor } from './hooks/useDesignEditor';
import { useExport } from './hooks/useExport';
function App() { function App() {
const { const {
@@ -20,6 +21,8 @@ function App() {
initializeHistory, initializeHistory,
} = useDesignEditor(); } = useDesignEditor();
const { exporting, progress, exportDesign, error, clearExport } = useExport();
const selectedElement = elements.find((el) => el.id === selectedId); const selectedElement = elements.find((el) => el.id === selectedId);
// Initialize history on mount // Initialize history on mount
@@ -116,25 +119,47 @@ function App() {
<h1 className="app-title">Apparel Designer</h1> <h1 className="app-title">Apparel Designer</h1>
<p className="app-subtitle">T-shirt customization editor</p> <p className="app-subtitle">T-shirt customization editor</p>
</div> </div>
<div className="undo-redo-buttons"> <div className="canvas-actions">
<div className="undo-redo-buttons">
<button
className={`icon-btn ${!canUndo ? 'disabled' : ''}`}
onClick={undo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
>
Undo
</button>
<button
className={`icon-btn ${!canRedo ? 'disabled' : ''}`}
onClick={redo}
disabled={!canRedo}
title="Redo (Ctrl+Y)"
>
Redo
</button>
</div>
<button <button
className={`icon-btn ${!canUndo ? 'disabled' : ''}`} className="export-btn"
onClick={undo} onClick={() => exportDesign(elements, 'tshirt-design')}
disabled={!canUndo} disabled={exporting || elements.length === 0}
title="Undo (Ctrl+Z)"
> >
Undo {exporting ? (
</button> <>
<button <span className="spinner-small" />
className={`icon-btn ${!canRedo ? 'disabled' : ''}`} Exporting... {progress}%
onClick={redo} </>
disabled={!canRedo} ) : (
title="Redo (Ctrl+Y)" <> Export HD</>
> )}
Redo
</button> </button>
</div> </div>
</div> </div>
{error && (
<div className="export-error">
<p> Export failed: {error}</p>
<button onClick={clearExport} className="close-error"></button>
</div>
)}
<div className="canvas-wrapper"> <div className="canvas-wrapper">
<DesignCanvas <DesignCanvas
elements={elements} elements={elements}

View File

@@ -0,0 +1,69 @@
import { useState, useCallback } from 'react';
export function useExport() {
const [exporting, setExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [exportUrl, setExportUrl] = useState(null);
const [error, setError] = useState(null);
const exportDesign = useCallback(async (elements, designName = 'design') => {
setExporting(true);
setProgress(0);
setError(null);
setExportUrl(null);
try {
// Simulate progress during export
const progressInterval = setInterval(() => {
setProgress((prev) => Math.min(prev + 10, 90));
}, 200);
const response = await fetch('/api/export', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ elements, designName }),
});
clearInterval(progressInterval);
setProgress(100);
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Export failed');
}
const data = await response.json();
setExportUrl(data.export.url);
// Trigger download
const link = document.createElement('a');
link.href = data.export.url;
link.download = data.export.filename;
link.click();
setExporting(false);
return data;
} catch (err) {
console.error('Export failed:', err);
setError(err.message);
setExporting(false);
throw err;
}
}, []);
const clearExport = useCallback(() => {
setExportUrl(null);
setError(null);
}, []);
return {
exporting,
progress,
exportUrl,
error,
exportDesign,
clearExport,
};
}

View File

@@ -121,6 +121,12 @@ input, textarea, select {
max-width: 400px; max-width: 400px;
} }
.canvas-actions {
display: flex;
gap: 0.75rem;
align-items: center;
}
.undo-redo-buttons { .undo-redo-buttons {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -153,6 +159,64 @@ input, textarea, select {
cursor: not-allowed; cursor: not-allowed;
} }
.export-btn {
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #22c55e, #16a34a);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.5rem;
}
.export-btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
.export-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.export-error {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: var(--radius-md);
color: #dc2626;
font-size: 0.875rem;
margin-bottom: 1rem;
width: 100%;
max-width: 400px;
}
.export-error p {
margin: 0;
}
.close-error {
background: transparent;
border: none;
color: #dc2626;
cursor: pointer;
font-size: 1.25rem;
padding: 0;
line-height: 1;
}
.close-error:hover {
color: #991b1b;
}
.canvas-wrapper { .canvas-wrapper {
margin-bottom: 1rem; margin-bottom: 1rem;
} }

View File

@@ -3,9 +3,10 @@ import cors from 'cors';
import multer from 'multer'; import multer from 'multer';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp'; import sharp from 'sharp';
import { createCanvas, loadImage, registerFont } from 'canvas';
import { fileURLToPath } from 'module'; import { fileURLToPath } from 'module';
import { dirname, join } from 'path'; import { dirname, join } from 'path';
import { mkdirSync, existsSync } from 'fs'; import { mkdirSync, existsSync, writeFileSync } from 'fs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -120,6 +121,92 @@ app.use((err, req, res, next) => {
next(err); next(err);
}); });
// High-resolution export endpoint
// Canvas: 300x300px preview -> 4500x4500px export (15"x15" @ 300 DPI)
const EXPORT_SCALE = 15; // 300px * 15 = 4500px
const EXPORT_SIZE = 4500;
app.post('/api/export', async (req, res) => {
try {
const { elements, designName = 'design' } = req.body;
if (!elements || !Array.isArray(elements)) {
return res.status(400).json({ error: 'Elements array is required' });
}
// Create high-resolution canvas
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 each element
for (const el of elements) {
ctx.save();
// Transform: translate to element position, rotate, then draw
const x = (el.x || 0) * EXPORT_SCALE;
const y = (el.y || 0) * EXPORT_SCALE;
const centerX = x + ((el.width || el.fontSize || 100) * EXPORT_SCALE) / 2;
const centerY = y + ((el.height || el.fontSize || 100) * EXPORT_SCALE) / 2;
ctx.translate(centerX, centerY);
ctx.rotate((el.rotation || 0) * Math.PI / 180);
ctx.translate(-centerX, -centerY);
if (el.type === 'image' && el.src) {
try {
// Load image from URL or local path
const imgUrl = el.src.startsWith('/')
? join(__dirname, el.src.replace('/uploads', 'uploads'))
: el.src;
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);
} catch (imgError) {
console.error('Failed to load image for export:', imgError);
}
} else if (el.type === 'text') {
const fontSize = (el.fontSize || 32) * EXPORT_SCALE / 32;
ctx.font = `${fontSize}px "${el.fontFamily || 'Arial'}"`;
ctx.fillStyle = el.fill || '#000000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(el.text || '', centerX, centerY);
}
ctx.restore();
}
// Save to file
const exportFilename = `${designName.replace(/[^a-z0-9]/gi, '_')}_${uuidv4()}.png`;
const exportPath = join(exportsDir, exportFilename);
const buffer = canvas.toBuffer('image/png');
writeFileSync(exportPath, buffer);
res.json({
success: true,
export: {
url: `/exports/${exportFilename}`,
path: exportPath,
filename: exportFilename,
width: EXPORT_SIZE,
height: EXPORT_SIZE,
dpi: 300,
sizeInches: '15x15'
}
});
} catch (error) {
console.error('Export error:', error);
res.status(500).json({ error: 'Failed to export design', details: error.message });
}
});
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
console.log(`Health check: http://localhost:${PORT}/api/health`); console.log(`Health check: http://localhost:${PORT}/api/health`);