diff --git a/frontend/src/canvas/export.ts b/frontend/src/canvas/export.ts new file mode 100644 index 0000000..8492efc --- /dev/null +++ b/frontend/src/canvas/export.ts @@ -0,0 +1,189 @@ +/** + * Export canvas content as a downloadable image file. + * Reuses the same rendering pipeline as clipboard.ts. + */ + +import { Container, Rectangle } from 'pixi.js'; +import type { Viewport } from 'pixi-viewport'; +import type { Application } from 'pixi.js'; +import type { SceneItem } from './SceneManager'; +import { getItemWorldBounds } from './SceneManager'; + +export interface ExportOptions { + format: 'png' | 'jpeg' | 'webp'; + quality: number; // 0-1, only used for jpeg/webp + scale: number; // 1 = native, 0.5 = half, 2 = double + background: string; // hex color + filename: string; +} + +/** + * Render selected items (or full board content) to a canvas at the given scale. + * Returns an HTMLCanvasElement ready for blob conversion. + */ +function renderToCanvas( + app: Application, + viewport: Viewport, + items: SceneItem[], + scale: number, + background: string, +): HTMLCanvasElement { + // Compute world-space bounding box + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const item of items) { + const { x, y, w, h } = getItemWorldBounds(item); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + } + + const pad = 10; + const totalW = Math.ceil(maxX - minX) + pad * 2; + const totalH = Math.ceil(maxY - minY) + pad * 2; + + // Reparent items into temp container + const tempContainer = new Container(); + const saved = new Map(); + const worldBoundsMap = new Map(); + + for (const item of items) { + worldBoundsMap.set(item.id, getItemWorldBounds(item)); + } + + for (const item of items) { + const obj = item.displayObject; + saved.set(item.id, { + parent: obj.parent as Container, + x: obj.x, y: obj.y, + sx: obj.scale.x, sy: obj.scale.y, + }); + obj.parent?.removeChild(obj); + const wb = worldBoundsMap.get(item.id)!; + obj.position.set(wb.x - minX + pad, wb.y - minY + pad); + obj.scale.set(item.data.sx, item.data.sy); + tempContainer.addChild(obj); + } + + // Compute resolution: use native texture ratio, then apply user scale + let maxRatio = 1; + for (const item of items) { + const tex = (item.displayObject as any)?.texture; + if (tex && tex.width > 1 && item.data.w > 1) { + maxRatio = Math.max(maxRatio, tex.width / item.data.w); + } + } + const resolution = Math.min(maxRatio * scale, 8); // cap at 8x to avoid GPU limits + + let texture; + let extractedCanvas: HTMLCanvasElement; + try { + texture = app.renderer.generateTexture({ + target: tempContainer, + resolution, + frame: new Rectangle(0, 0, totalW, totalH), + }); + extractedCanvas = app.renderer.extract.canvas(texture) as HTMLCanvasElement; + } finally { + for (const item of items) { + const s = saved.get(item.id)!; + tempContainer.removeChild(item.displayObject); + s.parent.addChild(item.displayObject); + item.displayObject.position.set(s.x, s.y); + item.displayObject.scale.set(s.sx, s.sy); + } + tempContainer.destroy(); + texture?.destroy(true); + } + + // Add background + const outputCanvas = document.createElement('canvas'); + outputCanvas.width = extractedCanvas.width; + outputCanvas.height = extractedCanvas.height; + const ctx = outputCanvas.getContext('2d')!; + ctx.fillStyle = background; + ctx.fillRect(0, 0, outputCanvas.width, outputCanvas.height); + ctx.drawImage(extractedCanvas, 0, 0); + + return outputCanvas; +} + +const MIME_MAP = { + png: 'image/png', + jpeg: 'image/jpeg', + webp: 'image/webp', +} as const; + +/** + * Export items as a downloadable image. + */ +export async function exportAsImage( + app: Application | null, + viewport: Viewport | null, + items: SceneItem[], + options: ExportOptions, +): Promise { + if (!app?.renderer?.extract || !viewport) throw new Error('Renderer not available'); + if (items.length === 0) throw new Error('No items to export'); + + const canvas = renderToCanvas(app, viewport, items, options.scale, options.background); + const mimeType = MIME_MAP[options.format]; + const quality = options.format === 'png' ? undefined : options.quality; + + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (b) => (b ? resolve(b) : reject(new Error('Export failed'))), + mimeType, + quality, + ); + }); + + // Trigger browser download + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${options.filename}.${options.format}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +/** + * Get the pixel dimensions of the export at a given scale, + * so the dialog can show a preview of the output size. + */ +export function getExportDimensions( + items: SceneItem[], + scale: number, +): { width: number; height: number } { + if (items.length === 0) return { width: 0, height: 0 }; + + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const item of items) { + const { x, y, w, h } = getItemWorldBounds(item); + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x + w); + maxY = Math.max(maxY, y + h); + } + + const pad = 10; + const w = Math.ceil(maxX - minX) + pad * 2; + const h = Math.ceil(maxY - minY) + pad * 2; + + // Estimate native resolution ratio + let maxRatio = 1; + for (const item of items) { + const tex = (item.displayObject as any)?.texture; + if (tex && tex.width > 1 && item.data.w > 1) { + maxRatio = Math.max(maxRatio, tex.width / item.data.w); + } + } + const resolution = Math.min(maxRatio * scale, 8); + + return { + width: Math.round(w * resolution), + height: Math.round(h * resolution), + }; +} diff --git a/frontend/src/components/ExportDialog.tsx b/frontend/src/components/ExportDialog.tsx new file mode 100644 index 0000000..50d28ad --- /dev/null +++ b/frontend/src/components/ExportDialog.tsx @@ -0,0 +1,176 @@ +import React, { useState, useMemo } from 'react'; +import type { SceneItem } from '../canvas/SceneManager'; +import { getExportDimensions } from '../canvas/export'; + +type Format = 'png' | 'jpeg' | 'webp'; +type Scope = 'selection' | 'all'; + +interface ExportDialogProps { + selectedItems: SceneItem[]; + allItems: SceneItem[]; + boardName: string; + onExport: (items: SceneItem[], options: { + format: Format; + quality: number; + scale: number; + background: string; + filename: string; + }) => void; + onClose: () => void; +} + +export default function ExportDialog({ + selectedItems, allItems, boardName, onExport, onClose, +}: ExportDialogProps) { + const hasSelection = selectedItems.length > 0; + const [scope, setScope] = useState(hasSelection ? 'selection' : 'all'); + const [format, setFormat] = useState('png'); + const [quality, setQuality] = useState(0.9); + const [scale, setScale] = useState(1); + const [background, setBackground] = useState('#1e1e1e'); + const [filename, setFilename] = useState( + () => `${boardName || 'export'}-${new Date().toISOString().slice(0, 10)}` + ); + + const items = scope === 'selection' ? selectedItems : allItems; + const dims = useMemo(() => getExportDimensions(items, scale), [items, scale]); + + const canExport = items.length > 0; + + return ( +
+
e.stopPropagation()}> +

+ Export as Image +

+ + {/* Scope */} + Scope +
+ {hasSelection && ( + setScope('selection')}> + Selection ({selectedItems.length}) + + )} + setScope('all')}> + Full board ({allItems.length}) + +
+ + {/* Filename */} + Filename + setFilename(e.target.value)} + style={{ + width: '100%', boxSizing: 'border-box', + background: '#111', border: '1px solid #333', borderRadius: '6px', + color: '#ddd', padding: '6px 8px', fontSize: '12px', + marginBottom: '12px', + }} + /> + + {/* Format */} + Format +
+ {(['png', 'jpeg', 'webp'] as Format[]).map((f) => ( + setFormat(f)}> + {f.toUpperCase()} + + ))} +
+ + {/* Quality (jpeg/webp only) */} + {format !== 'png' && ( +
+ Quality: {Math.round(quality * 100)}% + setQuality(Number(e.target.value))} + style={{ width: '100%', accentColor: '#4a9eff', cursor: 'pointer' }} + /> +
+ )} + + {/* Scale */} + Scale +
+ {[0.5, 1, 2].map((s) => ( + setScale(s)}> + {s}x + + ))} +
+ + {/* Background */} + Background +
+ {['#1e1e1e', '#ffffff', '#000000', 'transparent'].map((bg) => ( +
+ + {/* Dimensions preview */} +
+ {canExport + ? `${dims.width} x ${dims.height} px` + : 'No items to export'} +
+ + {/* Actions */} +
+ + +
+
+
+ ); +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +function ToggleBtn({ active, onClick, children }: { + active: boolean; onClick: () => void; children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 3f4b0d5..70980ba 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -33,6 +33,7 @@ interface ToolbarProps { showLayers?: boolean; onToggleHelp?: () => void; onMmImport?: () => void; + onExport?: () => void; boardName?: string; } @@ -148,6 +149,7 @@ export default function Toolbar({ showLayers, onToggleHelp, onMmImport, + onExport, }: ToolbarProps) { const showStroke = activeTool === ToolType.PEN; const showFontSize = activeTool === ToolType.TEXT; @@ -297,6 +299,16 @@ export default function Toolbar({ )} + {/* Export */} + {onExport && ( + + + + + + + )} + {/* Spacer */}
diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 841fd8e..f7d43da 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -9,6 +9,7 @@ import { SyncHandle } from '../canvas/sync'; import { UndoManager } from '../canvas/history'; import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions'; import { writeCanvasToClipboard as writeClipboard } from '../canvas/clipboard'; +import { exportAsImage } from '../canvas/export'; import { buildContextMenuItems } from '../canvas/context-menu-items'; import { groupItems, ungroupItems } from '../canvas/grouping'; import { getSocket } from '../socket'; @@ -24,6 +25,7 @@ import ShortcutsHelp from '../components/ShortcutsHelp'; import MattermostImport from '../components/MattermostImport'; import Minimap from '../components/Minimap'; import UploadPanel from '../components/UploadPanel'; +import ExportDialog from '../components/ExportDialog'; import { UploadManager } from '../stores/uploadManager'; import { InboxZone } from '../canvas/InboxZone'; import { getItemWorldBounds } from '../canvas/SceneManager'; @@ -87,6 +89,7 @@ export default function Editor({ isPublicView }: EditorProps) { const [showGrid, setShowGrid] = useState(true); const [showHelp, setShowHelp] = useState(false); const [showMmImport, setShowMmImport] = useState(false); + const [showExport, setShowExport] = useState(false); const [focusMode, setFocusMode] = useState(false); const [layerList, setLayerList] = useState([]); const [selectedLayerIds, setSelectedLayerIds] = useState([]); @@ -486,6 +489,7 @@ export default function Editor({ isPublicView }: EditorProps) { showLayers={showLayers} onToggleHelp={() => setShowHelp((v) => !v)} onMmImport={() => setShowMmImport(true)} + onExport={() => setShowExport(true)} boardName={board?.name} />} @@ -684,6 +688,27 @@ export default function Editor({ isPublicView }: EditorProps) { }} /> )} + + {/* Export dialog */} + {showExport && ( + { + setShowExport(false); + try { + const app = canvasRef.current?.getApp() ?? null; + const viewport = canvasRef.current?.getViewport() ?? null; + await exportAsImage(app, viewport, items, options); + showToast('Exported successfully'); + } catch (err: any) { + showToast('Export failed: ' + (err.message || 'unknown error')); + } + }} + onClose={() => setShowExport(false)} + /> + )}
); }