/** * 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), }; }