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:
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} />
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user