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;
|
showLayers?: boolean;
|
||||||
onToggleHelp?: () => void;
|
onToggleHelp?: () => void;
|
||||||
onMmImport?: () => void;
|
onMmImport?: () => void;
|
||||||
|
onExport?: () => void;
|
||||||
boardName?: string;
|
boardName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +149,7 @@ export default function Toolbar({
|
|||||||
showLayers,
|
showLayers,
|
||||||
onToggleHelp,
|
onToggleHelp,
|
||||||
onMmImport,
|
onMmImport,
|
||||||
|
onExport,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
const showStroke = activeTool === ToolType.PEN;
|
const showStroke = activeTool === ToolType.PEN;
|
||||||
const showFontSize = activeTool === ToolType.TEXT;
|
const showFontSize = activeTool === ToolType.TEXT;
|
||||||
@@ -297,6 +299,16 @@ export default function Toolbar({
|
|||||||
</ActionBtn>
|
</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 */}
|
{/* Spacer */}
|
||||||
<div style={{ flex: 1 }} />
|
<div style={{ flex: 1 }} />
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { SyncHandle } from '../canvas/sync';
|
|||||||
import { UndoManager } from '../canvas/history';
|
import { UndoManager } from '../canvas/history';
|
||||||
import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions';
|
import { shortcuts as shortcutDefs } from '../canvas/shortcut-definitions';
|
||||||
import { writeCanvasToClipboard as writeClipboard } from '../canvas/clipboard';
|
import { writeCanvasToClipboard as writeClipboard } from '../canvas/clipboard';
|
||||||
|
import { exportAsImage } from '../canvas/export';
|
||||||
import { buildContextMenuItems } from '../canvas/context-menu-items';
|
import { buildContextMenuItems } from '../canvas/context-menu-items';
|
||||||
import { groupItems, ungroupItems } from '../canvas/grouping';
|
import { groupItems, ungroupItems } from '../canvas/grouping';
|
||||||
import { getSocket } from '../socket';
|
import { getSocket } from '../socket';
|
||||||
@@ -24,6 +25,7 @@ import ShortcutsHelp from '../components/ShortcutsHelp';
|
|||||||
import MattermostImport from '../components/MattermostImport';
|
import MattermostImport from '../components/MattermostImport';
|
||||||
import Minimap from '../components/Minimap';
|
import Minimap from '../components/Minimap';
|
||||||
import UploadPanel from '../components/UploadPanel';
|
import UploadPanel from '../components/UploadPanel';
|
||||||
|
import ExportDialog from '../components/ExportDialog';
|
||||||
import { UploadManager } from '../stores/uploadManager';
|
import { UploadManager } from '../stores/uploadManager';
|
||||||
import { InboxZone } from '../canvas/InboxZone';
|
import { InboxZone } from '../canvas/InboxZone';
|
||||||
import { getItemWorldBounds } from '../canvas/SceneManager';
|
import { getItemWorldBounds } from '../canvas/SceneManager';
|
||||||
@@ -87,6 +89,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
const [showGrid, setShowGrid] = useState(true);
|
const [showGrid, setShowGrid] = useState(true);
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
const [showMmImport, setShowMmImport] = useState(false);
|
const [showMmImport, setShowMmImport] = useState(false);
|
||||||
|
const [showExport, setShowExport] = useState(false);
|
||||||
const [focusMode, setFocusMode] = useState(false);
|
const [focusMode, setFocusMode] = useState(false);
|
||||||
const [layerList, setLayerList] = useState<any[]>([]);
|
const [layerList, setLayerList] = useState<any[]>([]);
|
||||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||||
@@ -486,6 +489,7 @@ export default function Editor({ isPublicView }: EditorProps) {
|
|||||||
showLayers={showLayers}
|
showLayers={showLayers}
|
||||||
onToggleHelp={() => setShowHelp((v) => !v)}
|
onToggleHelp={() => setShowHelp((v) => !v)}
|
||||||
onMmImport={() => setShowMmImport(true)}
|
onMmImport={() => setShowMmImport(true)}
|
||||||
|
onExport={() => setShowExport(true)}
|
||||||
boardName={board?.name}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user