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,9 +3,10 @@ import cors from 'cors';
import multer from 'multer';
import { v4 as uuidv4 } from 'uuid';
import sharp from 'sharp';
import { createCanvas, loadImage, registerFont } from 'canvas';
import { fileURLToPath } from 'module';
import { dirname, join } from 'path';
import { mkdirSync, existsSync } from 'fs';
import { mkdirSync, existsSync, writeFileSync } from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -120,6 +121,92 @@ app.use((err, req, res, next) => {
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, () => {
console.log(`Server running on http://localhost:${PORT}`);
console.log(`Health check: http://localhost:${PORT}/api/health`);