Files
multica/packages/views/editor/utils/mermaid-export.ts
Naiyuan Qing 17ee59ccc5 feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908) (#5564)
* feat(editor): standard Mermaid viewer with pan/zoom, exports and a real dialog (MUL-4908)

Mermaid's fullscreen view was a bespoke lightbox: no visible close button, no
canvas interaction, and a toolbar that scrolled away with wide diagrams. Escape
also stopped working once the iframe took focus, which is why it read as
"impossible to close".

Replace it with a viewer built on the shared Dialog, so it inherits the
backdrop, focus trap, scroll lock, focus restore and Escape handling that HTML
preview already had.

The diagram stays in an `sandbox=""` iframe — isolation is not relaxed to buy
interactivity. Instead the iframe is `pointer-events: none` and pan/zoom is
applied from the host document as a transform on its wrapper. That inversion is
also what keeps Escape working: the iframe can never take focus.

- Viewer: fit-on-open, drag pan, wheel/pinch/keyboard zoom (25%-400%) anchored
  at the pointer, Fit / 100% / Reset, and a header toolbar that cannot scroll
  away. Panning is clamped so the canvas can never be lost.
- Export: `.mmd`, `.svg` and `.png`. Requires `htmlLabels: false` — browsers do
  not rasterize Mermaid's default HTML-in-foreignObject labels through an
  `<img>`; verified in Chromium that they paint zero pixels AND taint the
  canvas, so PNG export silently produced nothing.
- Inline: toolbar lifted out of the scroll container, natural-size rendering
  (small diagrams centered, wide ones scroll at a readable size with edge
  fades), copy source, and a parser message on the error fallback.
- Theme switches no longer close the viewer or discard zoom/position; the
  previous diagram stays on screen until its replacement is ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): stop Mermaid drags selecting text and misfiring the viewer (MUL-4908)

Two gesture defects on the inline diagram, reported by Naiyuan.

1. Dragging a diagram started a native text selection. The iframe is a replaced
   element, so the whole diagram box got painted with the selection highlight
   and the drag ran on into the surrounding comment text. Fixed with
   `user-select: none` on the inline scroller and the viewer canvas.

   Deliberately NOT by preventDefault-ing pointerdown: verified in Chromium that
   it also drops the default focus, which silently kills the viewer's keyboard
   controls (+/-, 0, arrows).

2. Any drag ended in a `click`, so trying to look along a wide diagram opened
   the viewer on top of the user. The inline diagram had no drag handling at
   all — only `onClick`. Suppressing the selection alone would have made this
   fire more reliably, not less, so both had to land together.

   Past a 5px threshold the gesture is a drag for good: releasing no longer
   taps, and a horizontal drag pans the scroller instead. This holds even when
   the diagram has no room to scroll, so an unscrollable diagram cannot open on
   release either. A still click, a sub-threshold jitter, and the expand button
   all still open the viewer.

Touch is left alone — it already pans natively and its vertical drags belong to
the page; the browser announces its takeover with pointercancel. Mouse and pen
have no native drag-to-scroll and are panned here.

Verified in Chromium: still click opens; a 150px drag pans and does not open; a
drag on an unscrollable diagram does not open; 3px jitter still opens; and no
gesture selects text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Walt <walt@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-17 16:07:37 +08:00

209 lines
7.1 KiB
TypeScript

/**
* Export helpers for Mermaid diagrams (`.mmd` source, `.svg`, `.png`).
*
* Export runs in the host document against the SVG markup Mermaid returned —
* never by reaching into the preview iframe, which is `sandbox=""` and
* deliberately unreachable. The markup therefore has to be made standalone
* first: Mermaid renders for embedding, so it emits a transparent background,
* a `max-width` style tuned to the host container, and `font-family: inherit`,
* none of which survive being opened as a file on their own.
*/
const SVG_NAMESPACE = "http://www.w3.org/2000/svg";
// Rasterize above CSS pixels so PNGs stay legible when zoomed or pasted into a
// doc. 2 matches a typical HiDPI screen without producing huge files.
const PNG_PIXEL_RATIO = 2;
export interface ExportSvgOptions {
/** Opaque page color, so an exported file is not transparent-on-transparent. */
background: string;
/** Concrete stack to replace Mermaid's `inherit`, which has nothing to inherit from in a file. */
fontFamily: string;
width: number;
height: number;
}
interface ViewBox {
minX: number;
minY: number;
width: number;
height: number;
}
function readViewBox(root: SVGElement, fallback: Size): ViewBox {
const parts = (root.getAttribute("viewBox") ?? "")
.split(/[\s,]+/)
.map((value) => Number.parseFloat(value));
if (parts.length === 4 && parts.every((value) => Number.isFinite(value))) {
const [minX, minY, width, height] = parts as [number, number, number, number];
if (width > 0 && height > 0) return { minX, minY, width, height };
}
return { minX: 0, minY: 0, width: fallback.width, height: fallback.height };
}
interface Size {
width: number;
height: number;
}
/**
* Rewrites a `style` attribute's declarations.
*
* Done at the attribute level rather than through the `.style` CSSStyleDeclaration
* API: an SVG root parsed out of an XML document does not reliably expose that
* API (jsdom does not implement it at all), so touching `.style` here would
* throw outside a real browser.
*/
function mergeStyleAttribute(
existing: string | null,
overrides: Record<string, string>,
remove: string[],
): string {
const declarations = new Map<string, string>();
for (const part of (existing ?? "").split(";")) {
const separator = part.indexOf(":");
if (separator === -1) continue;
const property = part.slice(0, separator).trim().toLowerCase();
if (property) declarations.set(property, part.slice(separator + 1).trim());
}
for (const property of remove) declarations.delete(property);
for (const [property, value] of Object.entries(overrides)) {
declarations.set(property, value);
}
return [...declarations]
.map(([property, value]) => `${property}: ${value}`)
.join("; ");
}
/**
* Rewrites embedded Mermaid SVG markup into a standalone document.
*
* @returns serialized SVG, or `null` when the markup does not parse.
*/
export function buildExportSvg(
svgMarkup: string,
{ background, fontFamily, width, height }: ExportSvgOptions,
): string | null {
const parsed = new DOMParser().parseFromString(svgMarkup, "image/svg+xml");
if (parsed.querySelector("parsererror")) return null;
const root = parsed.documentElement as unknown as SVGElement;
if (root.nodeName.toLowerCase() !== "svg") return null;
root.setAttribute("xmlns", SVG_NAMESPACE);
// Concrete pixel dimensions: `width="100%"` (Mermaid's default) has no
// percentage basis in a standalone file, and an <img> loading it for the PNG
// path would have no intrinsic size to draw.
root.setAttribute("width", String(width));
root.setAttribute("height", String(height));
root.setAttribute(
"style",
mergeStyleAttribute(
root.getAttribute("style"),
{ "background-color": background, "font-family": fontFamily },
// Mermaid sizes the embedded SVG to the host container; kept, it would
// clip the export to whatever width the page happened to have.
["max-width"],
),
);
const viewBox = readViewBox(root, { width, height });
const backgroundRect = parsed.createElementNS(SVG_NAMESPACE, "rect");
// Cover the viewBox rather than 0,0 100%x100%: Mermaid emits negative
// viewBox origins for diagrams with padding, and a 0,0-anchored rect would
// leave those margins transparent.
backgroundRect.setAttribute("x", String(viewBox.minX));
backgroundRect.setAttribute("y", String(viewBox.minY));
backgroundRect.setAttribute("width", String(viewBox.width));
backgroundRect.setAttribute("height", String(viewBox.height));
backgroundRect.setAttribute("fill", background);
root.insertBefore(backgroundRect, root.firstChild);
return new XMLSerializer().serializeToString(root);
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Failed to load SVG for PNG export"));
image.src = url;
});
}
/**
* Rasterizes standalone SVG markup to a PNG blob.
*
* The blob URL is same-origin and the markup carries no external references
* (fonts are resolved to a concrete stack by `buildExportSvg`), so the canvas
* stays untainted and `toBlob` is allowed to read it back.
*/
export async function renderSvgToPngBlob(
standaloneSvg: string,
{ width, height }: Size,
pixelRatio: number = PNG_PIXEL_RATIO,
): Promise<Blob> {
const svgBlob = new Blob([standaloneSvg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
try {
const image = await loadImage(url);
const canvas = document.createElement("canvas");
canvas.width = Math.max(1, Math.round(width * pixelRatio));
canvas.height = Math.max(1, Math.round(height * pixelRatio));
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas 2D context unavailable for PNG export");
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/png"),
);
if (!blob) throw new Error("Failed to encode PNG");
return blob;
} finally {
URL.revokeObjectURL(url);
}
}
export function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
// Revoke on the next task: Safari aborts the download if the URL dies while
// the click is still being processed synchronously.
setTimeout(() => URL.revokeObjectURL(url), 0);
}
/**
* Filename stem derived from the diagram's first meaningful line, so a folder
* of exports is scannable instead of being `diagram (3).png` all the way down.
*/
export function diagramFilenameStem(chart: string): string {
const firstLine = chart
.split("\n")
.map((line) => line.trim())
.find((line) => line.length > 0 && !line.startsWith("%%"));
const slug = (firstLine ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40)
.replace(/-+$/g, "");
return slug || "diagram";
}