feat: add export-as-image with format, scale, and background options

New ExportDialog component with scope (selection/all), filename,
format (PNG/JPEG/WebP), quality slider, scale multiplier, and
background color picker. Wired into Toolbar and Editor.
This commit is contained in:
Hiren Kangad
2026-03-10 19:16:05 +05:30
parent c126885e53
commit 51e53a323b
4 changed files with 402 additions and 0 deletions
+189
View File
@@ -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<string, { parent: Container; x: number; y: number; sx: number; sy: number }>();
const worldBoundsMap = new Map<string, { x: number; y: number; w: number; h: number }>();
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<void> {
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<Blob>((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),
};
}
+176
View File
@@ -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<Scope>(hasSelection ? 'selection' : 'all');
const [format, setFormat] = useState<Format>('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 (
<div style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
}} onClick={onClose}>
<div style={{
background: '#1a1a1a', border: '1px solid #333', borderRadius: '12px',
padding: '20px', width: '320px',
boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
}} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 16px', color: '#ddd', fontSize: '14px', fontWeight: 600 }}>
Export as Image
</h3>
{/* Scope */}
<FieldLabel>Scope</FieldLabel>
<div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
{hasSelection && (
<ToggleBtn active={scope === 'selection'} onClick={() => setScope('selection')}>
Selection ({selectedItems.length})
</ToggleBtn>
)}
<ToggleBtn active={scope === 'all'} onClick={() => setScope('all')}>
Full board ({allItems.length})
</ToggleBtn>
</div>
{/* Filename */}
<FieldLabel>Filename</FieldLabel>
<input
value={filename}
onChange={(e) => 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 */}
<FieldLabel>Format</FieldLabel>
<div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
{(['png', 'jpeg', 'webp'] as Format[]).map((f) => (
<ToggleBtn key={f} active={format === f} onClick={() => setFormat(f)}>
{f.toUpperCase()}
</ToggleBtn>
))}
</div>
{/* Quality (jpeg/webp only) */}
{format !== 'png' && (
<div style={{ marginBottom: '12px' }}>
<FieldLabel>Quality: {Math.round(quality * 100)}%</FieldLabel>
<input
type="range" min={0.1} max={1} step={0.05} value={quality}
onChange={(e) => setQuality(Number(e.target.value))}
style={{ width: '100%', accentColor: '#4a9eff', cursor: 'pointer' }}
/>
</div>
)}
{/* Scale */}
<FieldLabel>Scale</FieldLabel>
<div style={{ display: 'flex', gap: '4px', marginBottom: '12px' }}>
{[0.5, 1, 2].map((s) => (
<ToggleBtn key={s} active={scale === s} onClick={() => setScale(s)}>
{s}x
</ToggleBtn>
))}
</div>
{/* Background */}
<FieldLabel>Background</FieldLabel>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center', marginBottom: '12px' }}>
{['#1e1e1e', '#ffffff', '#000000', 'transparent'].map((bg) => (
<button key={bg} onClick={() => setBackground(bg)} style={{
width: '24px', height: '24px', borderRadius: '4px', cursor: 'pointer',
border: background === bg ? '2px solid #4a9eff' : '2px solid #333',
background: bg === 'transparent'
? 'repeating-conic-gradient(#555 0% 25%, #333 0% 50%) 0 0 / 8px 8px'
: bg,
}} title={bg} />
))}
</div>
{/* Dimensions preview */}
<div style={{
padding: '8px', background: '#111', borderRadius: '6px', marginBottom: '16px',
fontSize: '11px', color: '#666', textAlign: 'center',
}}>
{canExport
? `${dims.width} x ${dims.height} px`
: 'No items to export'}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: '8px', justifyContent: 'flex-end' }}>
<button onClick={onClose} style={{
padding: '6px 16px', background: 'transparent', border: '1px solid #333',
borderRadius: '6px', color: '#888', cursor: 'pointer', fontSize: '12px',
}}>Cancel</button>
<button
onClick={() => onExport(items, { format, quality, scale, background, filename })}
disabled={!canExport}
style={{
padding: '6px 16px',
background: canExport ? 'linear-gradient(135deg, #4a9eff, #3d7dd8)' : '#333',
border: 'none', borderRadius: '6px',
color: canExport ? '#fff' : '#666',
cursor: canExport ? 'pointer' : 'default',
fontSize: '12px', fontWeight: 600,
}}
>Export</button>
</div>
</div>
</div>
);
}
function FieldLabel({ children }: { children: React.ReactNode }) {
return <div style={{ fontSize: '10px', color: '#555', marginBottom: '4px', fontWeight: 500 }}>{children}</div>;
}
function ToggleBtn({ active, onClick, children }: {
active: boolean; onClick: () => void; children: React.ReactNode;
}) {
return (
<button onClick={onClick} style={{
padding: '4px 10px', borderRadius: '4px', cursor: 'pointer', fontSize: '11px',
background: active ? '#2a3a50' : '#222',
border: active ? '1px solid #4a9eff44' : '1px solid #333',
color: active ? '#4a9eff' : '#777',
}}>{children}</button>
);
}
+12
View File
@@ -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({
</ActionBtn>
)}
{/* Export */}
{onExport && (
<ActionBtn onClick={onExport} title="Export as image (Ctrl+Shift+E)">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3">
<path d="M2 9v3h10V9" strokeLinecap="round" strokeLinejoin="round" />
<path d="M7 2v7M4 6l3 3 3-3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</ActionBtn>
)}
{/* Spacer */}
<div style={{ flex: 1 }} />
+25
View File
@@ -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<any[]>([]);
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
@@ -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 && (
<ExportDialog
selectedItems={selectionRef.current?.getSelectedItems() ?? []}
allItems={canvasRef.current?.getScene()?.getAllItems() ?? []}
boardName={board?.name || 'export'}
onExport={async (items, options) => {
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)}
/>
)}
</div>
);
}