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