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:
2026-05-22 21:12:54 +03:00
parent 3918d5b8d2
commit 6f23c620c1
4 changed files with 77 additions and 7 deletions

View File

@@ -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
graph without changing it. Every rewrite lands in the editor and is reversible
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
SQLite database so you can jump back in from the start screen.
- **Themes** — switch the Mermaid theme (default / neutral / dark / forest /

View File

@@ -1,8 +1,18 @@
<script lang="ts">
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';
// 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'];
async function doExport(kind: 'svg' | 'png') {
@@ -14,13 +24,28 @@
try {
const ok =
kind === 'svg'
? await exportSvg(store.lastSvg, name)
: await exportPng(store.lastSvg, name);
? await exportSvg(store.lastSvg, name) // vector: keep rich labels
: await exportPng(await rasterSvg(), name);
if (ok) store.notify(`Exported ${kind.toUpperCase()}`, 'success');
} catch (e) {
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>
<header class="toolbar">
@@ -63,6 +88,13 @@
<div class="export">
<button onclick={() => doExport('svg')} disabled={!store.lastSvg}>Export SVG</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>
<button

View File

@@ -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 sized = withExplicitSize(svg, w, h);
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.fillRect(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')
);
return new Uint8Array(await pngBlob.arrayBuffer());
} finally {
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> {
const path = await save({
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));
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]);
}

View File

@@ -1,5 +1,6 @@
import mermaid from 'mermaid';
import elkLayouts from '@mermaid-js/layout-elk';
import { mergeInit } from './optimize';
// 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.
@@ -68,3 +69,17 @@ export async function renderMermaid(code: string, theme: string): Promise<Render
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);
}