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