feat(toolbar): copy diagram to clipboard as PNG
Add a "⧉ Copy" button that places the current diagram on the system clipboard as a PNG via the async Clipboard API (ClipboardItem). The write is invoked synchronously with the PNG handed over as a promise, so it stays inside the click gesture WebKit requires. PNG export and clipboard copy now re-render through renderRaster(), which forces htmlLabels off via an injected init directive. Flowcharts otherwise emit <foreignObject> HTML labels that taint the canvas and make toBlob()/clipboard writes fail — this also fixes pre-existing flowchart PNG export. The live preview and SVG export keep the richer foreignObject labels. Verified end-to-end: renderRaster yields a foreignObject-free SVG, it rasterizes without tainting, and clipboard.write resolves on a real click.
This commit is contained in:
@@ -28,7 +28,9 @@ TypeScript** frontend using **CodeMirror 6** and **Mermaid 11**.
|
|||||||
**focus mode** spotlights any node and its neighbours so you can read a dense
|
**focus mode** spotlights any node and its neighbours so you can read a dense
|
||||||
graph without changing it. Every rewrite lands in the editor and is reversible
|
graph without changing it. Every rewrite lands in the editor and is reversible
|
||||||
with ⌘Z.
|
with ⌘Z.
|
||||||
- **Export** the current diagram to **SVG** or **PNG** (2× scale).
|
- **Export** the current diagram to **SVG** or **PNG** (2× scale), or **copy a
|
||||||
|
PNG straight to the clipboard**. (PNG/clipboard re-render flowcharts with
|
||||||
|
text labels so the bitmap rasterizes cleanly across platforms.)
|
||||||
- **Project registry** — recently opened projects are remembered in a local
|
- **Project registry** — recently opened projects are remembered in a local
|
||||||
SQLite database so you can jump back in from the start screen.
|
SQLite database so you can jump back in from the start screen.
|
||||||
- **Themes** — switch the Mermaid theme (default / neutral / dark / forest /
|
- **Themes** — switch the Mermaid theme (default / neutral / dark / forest /
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { store } from '../store.svelte';
|
import { store } from '../store.svelte';
|
||||||
import { exportSvg, exportPng } from '../export';
|
import { exportSvg, exportPng, copyPng } from '../export';
|
||||||
|
import { renderRaster } from '../mermaid';
|
||||||
import type { MermaidTheme } from '../types';
|
import type { MermaidTheme } from '../types';
|
||||||
|
|
||||||
|
// PNG and clipboard need a raster-safe SVG (text labels, not foreignObject),
|
||||||
|
// so re-render the current source rather than reuse the rich preview SVG.
|
||||||
|
async function rasterSvg(): Promise<string> {
|
||||||
|
const r = await renderRaster(store.content, store.theme);
|
||||||
|
if ('error' in r) throw new Error(r.error);
|
||||||
|
if (!r.svg) throw new Error('Nothing to render yet');
|
||||||
|
return r.svg;
|
||||||
|
}
|
||||||
|
|
||||||
const themes: MermaidTheme[] = ['default', 'neutral', 'dark', 'forest', 'base'];
|
const themes: MermaidTheme[] = ['default', 'neutral', 'dark', 'forest', 'base'];
|
||||||
|
|
||||||
async function doExport(kind: 'svg' | 'png') {
|
async function doExport(kind: 'svg' | 'png') {
|
||||||
@@ -14,13 +24,28 @@
|
|||||||
try {
|
try {
|
||||||
const ok =
|
const ok =
|
||||||
kind === 'svg'
|
kind === 'svg'
|
||||||
? await exportSvg(store.lastSvg, name)
|
? await exportSvg(store.lastSvg, name) // vector: keep rich labels
|
||||||
: await exportPng(store.lastSvg, name);
|
: await exportPng(await rasterSvg(), name);
|
||||||
if (ok) store.notify(`Exported ${kind.toUpperCase()}`, 'success');
|
if (ok) store.notify(`Exported ${kind.toUpperCase()}`, 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
store.notify(e instanceof Error ? e.message : String(e), 'error');
|
store.notify(e instanceof Error ? e.message : String(e), 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let copying = $state(false);
|
||||||
|
function doCopy() {
|
||||||
|
if (!store.lastSvg) {
|
||||||
|
store.notify('Nothing to copy yet', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copying = true;
|
||||||
|
// Call copyPng synchronously (passing the render as a promise) so the
|
||||||
|
// clipboard write stays inside the click gesture WebKit requires.
|
||||||
|
copyPng(rasterSvg())
|
||||||
|
.then(() => store.notify('Copied PNG to clipboard', 'success'))
|
||||||
|
.catch((e) => store.notify(e instanceof Error ? e.message : String(e), 'error'))
|
||||||
|
.finally(() => (copying = false));
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="toolbar">
|
<header class="toolbar">
|
||||||
@@ -63,6 +88,13 @@
|
|||||||
<div class="export">
|
<div class="export">
|
||||||
<button onclick={() => doExport('svg')} disabled={!store.lastSvg}>Export SVG</button>
|
<button onclick={() => doExport('svg')} disabled={!store.lastSvg}>Export SVG</button>
|
||||||
<button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button>
|
<button onclick={() => doExport('png')} disabled={!store.lastSvg}>PNG</button>
|
||||||
|
<button
|
||||||
|
onclick={doCopy}
|
||||||
|
disabled={!store.lastSvg || copying}
|
||||||
|
title="Copy PNG to clipboard"
|
||||||
|
>
|
||||||
|
{copying ? 'Copying…' : '⧉ Copy'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function svgToPng(svg: string, scale: number): Promise<Uint8Array> {
|
async function svgToPngBlob(svg: string, scale: number): Promise<Blob> {
|
||||||
const { w, h } = svgDimensions(svg);
|
const { w, h } = svgDimensions(svg);
|
||||||
const sized = withExplicitSize(svg, w, h);
|
const sized = withExplicitSize(svg, w, h);
|
||||||
const blob = new Blob([sized], { type: 'image/svg+xml;charset=utf-8' });
|
const blob = new Blob([sized], { type: 'image/svg+xml;charset=utf-8' });
|
||||||
@@ -44,15 +44,19 @@ async function svgToPng(svg: string, scale: number): Promise<Uint8Array> {
|
|||||||
ctx.fillStyle = '#ffffff';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||||
const pngBlob: Blob = await new Promise((res, rej) =>
|
return await new Promise<Blob>((res, rej) =>
|
||||||
canvas.toBlob((b) => (b ? res(b) : rej(new Error('PNG encode failed'))), 'image/png')
|
canvas.toBlob((b) => (b ? res(b) : rej(new Error('PNG encode failed'))), 'image/png')
|
||||||
);
|
);
|
||||||
return new Uint8Array(await pngBlob.arrayBuffer());
|
|
||||||
} finally {
|
} finally {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function svgToPng(svg: string, scale: number): Promise<Uint8Array> {
|
||||||
|
const blob = await svgToPngBlob(svg, scale);
|
||||||
|
return new Uint8Array(await blob.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportSvg(svg: string, name: string): Promise<boolean> {
|
export async function exportSvg(svg: string, name: string): Promise<boolean> {
|
||||||
const path = await save({
|
const path = await save({
|
||||||
defaultPath: `${name}.svg`,
|
defaultPath: `${name}.svg`,
|
||||||
@@ -73,3 +77,20 @@ export async function exportPng(svg: string, name: string, scale = 2): Promise<b
|
|||||||
await api.writeBinaryFile(path, Array.from(bytes));
|
await api.writeBinaryFile(path, Array.from(bytes));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rasterize the diagram and place it on the system clipboard as a PNG image.
|
||||||
|
*
|
||||||
|
* Accepts the SVG as a value or a promise: the PNG `Blob` is handed to
|
||||||
|
* `ClipboardItem` as a promise and `clipboard.write` is invoked synchronously,
|
||||||
|
* so WebKit still treats the write as part of the originating click gesture
|
||||||
|
* (required) while awaiting the async render/rasterization.
|
||||||
|
*/
|
||||||
|
export async function copyPng(svg: string | Promise<string>, scale = 2): Promise<void> {
|
||||||
|
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard?.write) {
|
||||||
|
throw new Error('Copying images to the clipboard is not supported here');
|
||||||
|
}
|
||||||
|
const blob = Promise.resolve(svg).then((s) => svgToPngBlob(s, scale));
|
||||||
|
const item = new ClipboardItem({ 'image/png': blob });
|
||||||
|
await navigator.clipboard.write([item]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import mermaid from 'mermaid';
|
import mermaid from 'mermaid';
|
||||||
import elkLayouts from '@mermaid-js/layout-elk';
|
import elkLayouts from '@mermaid-js/layout-elk';
|
||||||
|
import { mergeInit } from './optimize';
|
||||||
|
|
||||||
// Register the ELK layout engine so diagrams (and the Optimize panel's "Switch
|
// Register the ELK layout engine so diagrams (and the Optimize panel's "Switch
|
||||||
// to ELK layout" fix) can request `layout: elk` for far cleaner dense graphs.
|
// to ELK layout" fix) can request `layout: elk` for far cleaner dense graphs.
|
||||||
@@ -68,3 +69,17 @@ export async function renderMermaid(code: string, theme: string): Promise<Render
|
|||||||
document.getElementById(`d${id}`)?.remove();
|
document.getElementById(`d${id}`)?.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render an SVG suitable for raster export (PNG / clipboard).
|
||||||
|
*
|
||||||
|
* Flowcharts render labels as `<foreignObject>` HTML by default, which taints
|
||||||
|
* the `<canvas>` they're drawn onto and makes `toBlob()` / clipboard image
|
||||||
|
* writes fail. Forcing `htmlLabels: false` (per-render, via an injected init
|
||||||
|
* directive — global preview config stays rich) emits plain `<text>` labels
|
||||||
|
* that rasterize cleanly across engines. Other diagram types are unaffected.
|
||||||
|
*/
|
||||||
|
export function renderRaster(code: string, theme: string): Promise<RenderResult> {
|
||||||
|
if (!code.trim()) return Promise.resolve({ svg: '' });
|
||||||
|
return renderMermaid(mergeInit(code, { htmlLabels: false }), theme);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user