feat(markdown): Render Mermaid diagrams in the preview (#82)

Fenced ```mermaid blocks in the Markdown preview render as diagrams. mermaid (12.1.0) is loaded lazily, only when a preview contains a diagram, with securityLevel "strict"; colours come from the theme tokens and diagrams re-render on theme changes. Syntax errors show inline under the source. Rendered diagrams are cached by theme + source, so editing elsewhere in the file doesn't redraw them.

Fixes #79

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
This commit is contained in:
l0ng-ai
2026-10-27 16:23:35 +08:00
committed by GitHub
parent fdd98fbfe4
commit 4100ab3341
7 changed files with 1245 additions and 4 deletions
+1
View File
@@ -52,6 +52,7 @@
"emmet": "2.4.11",
"immer": "^11.1.21",
"marked": "^18.1.1",
"mermaid": "12.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-resizable-panels": "^4.14.6",
+850
View File
File diff suppressed because it is too large Load Diff
@@ -188,3 +188,30 @@
.preview :global(.tok-operator) {
color: var(--syn-punct);
}
/* Mermaid diagrams: the source stays until the diagram is drawn, and next to an error. */
.preview :global([data-mermaid]) {
margin: 0 0 1em;
}
.preview :global([data-mermaid] > pre) {
margin: 0;
}
.preview :global([data-mermaid-state="ok"] > pre) {
display: none;
}
.preview :global(.mermaid-output) {
overflow-x: auto;
text-align: center;
}
.preview :global([data-mermaid-state="error"] > .mermaid-output) {
margin-top: 6px;
padding: 6px 10px;
border-left: 3px solid var(--danger);
border-radius: var(--radius-small);
background: var(--bg-elev);
color: var(--danger);
font-family: var(--font-mono);
font-size: var(--fs-code);
text-align: left;
white-space: pre-wrap;
}
@@ -3,13 +3,24 @@
* side by side, switched from the floating toolbar at the top right. The
* preview follows the editor's scroll position in the split layout.
*/
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, type MouseEvent, type ReactNode } from "react";
import {
useDeferredValue,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type MouseEvent,
type ReactNode,
} from "react";
import clsx from "clsx";
import { call, commands, isTauri } from "@/platform/ipc";
import { maybeEditorService } from "@/workbench/contributions";
import { highlightInto } from "@/editor/lsp/markdown";
import { IconButton, selectableText } from "@/ui";
import { useLiveMarkdown } from "./live";
import { hasMermaid, MERMAID_SELECTOR, renderMermaidIn, subscribeTheme, themeKey } from "./mermaid";
import { modeOf, useMarkdownModes } from "./modes";
import { ANCHOR_PREFIX, previewOffsetForLine, renderMarkdown, resolveLink, slugify } from "./render";
import styles from "./Markdown.module.css";
@@ -74,6 +85,22 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
const deferred = useDeferredValue(text ?? "");
const html = useMemo(() => renderMarkdown(deferred), [deferred]);
const ref = useRef<HTMLDivElement>(null);
const theme = useSyncExternalStore(subscribeTheme, themeKey, () => "");
const diagrams = useRef<Node[]>([]);
const [diagramsDrawn, setDiagramsDrawn] = useState(0);
// Mermaid diagrams: before paint, so cached ones don't flash their source.
useLayoutEffect(() => {
const root = ref.current;
if (!root || !hasMermaid(root)) return;
const pass = renderMermaidIn(root, diagrams.current);
let live = true;
if (pass.async) void pass.done.then(() => live && setDiagramsDrawn((n) => n + 1));
return () => {
live = false;
pass.cancel();
};
}, [html, theme]);
// Images and code highlighting after each render.
useEffect(() => {
@@ -98,6 +125,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
);
});
root.querySelectorAll<HTMLElement>("pre > code").forEach((code) => {
if (code.closest(MERMAID_SELECTOR)) return;
const lang = [...code.classList].find((c) => c.startsWith("language-"))?.slice("language-".length) ?? null;
const src = (code.textContent ?? "").replace(/\n+$/, "");
code.textContent = src;
@@ -117,7 +145,7 @@ function MarkdownPreview({ path, sync }: { path: string; sync: boolean }) {
top: el.offsetTop,
}));
root.scrollTop = topLine <= 0 ? 0 : previewOffsetForLine(blocks, topLine, root.scrollHeight);
}, [html, topLine, scrollSeq, sync]);
}, [html, topLine, scrollSeq, sync, diagramsDrawn]);
const onClick = (e: MouseEvent) => {
const a = (e.target as Element).closest("a");
@@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderMarkdown } from "./render";
import { hasMermaid, renderMermaidIn, resetMermaidForTests } from "./mermaid";
const m = vi.hoisted(() => ({
loads: 0,
initialize: vi.fn(),
render: vi.fn(async (id: string, text: string) => {
// Like mermaid: a measuring node under <body> while rendering.
const tmp = document.createElement("div");
tmp.id = `d${id}`;
document.body.append(tmp);
if (text.includes("bad")) throw new Error("Parse error on line 2:\nbad\n^");
return { svg: `<svg id="${id}"><text>${text.split("\n")[0]}</text></svg>` };
}),
}));
vi.mock("mermaid", () => {
m.loads++;
return { default: { initialize: m.initialize, render: m.render } };
});
function preview(md: string): HTMLElement {
const root = document.createElement("div");
root.innerHTML = renderMarkdown(md);
document.body.append(root);
return root;
}
const SEQ = "```mermaid\nsequenceDiagram\n A->B:test\n```\n";
describe("mermaid in the markdown preview", () => {
beforeEach(() => {
resetMermaidForTests();
m.render.mockClear();
m.initialize.mockClear();
document.body.innerHTML = "";
});
it("emits a placeholder holding the escaped source, other code blocks unchanged", () => {
const root = preview(
`# T\n\n${SEQ}\n\`\`\`js\nlet a = 1;\n\`\`\`\n\n\`\`\`Mermaid title\ngraph TD\n a["<b>x</b>"]-->b\n\`\`\`\n`,
);
const blocks = root.querySelectorAll<HTMLElement>("[data-mermaid]");
expect(blocks).toHaveLength(2);
expect(blocks[0]!.parentElement?.dataset.line).toBe("2");
expect(blocks[0]!.querySelector("pre > code.language-mermaid")?.textContent).toBe("sequenceDiagram\n A->B:test");
expect(blocks[1]!.querySelector("code")?.textContent).toContain('a["<b>x</b>"]');
expect(blocks[1]!.querySelector("b")).toBeNull();
expect(root.querySelector("code.language-js")?.closest("[data-mermaid]")).toBeNull();
});
it("loads mermaid only for previews with a diagram", async () => {
const plain = preview("# Plain\n\n```js\n1\n```\n");
expect(hasMermaid(plain)).toBe(false);
const pass = renderMermaidIn(plain);
expect(pass.async).toBe(false);
await pass.done;
expect(m.loads).toBe(0);
const root = preview(SEQ);
expect(hasMermaid(root)).toBe(true);
await renderMermaidIn(root).done;
expect(m.loads).toBe(1);
expect(m.initialize).toHaveBeenCalledWith(
expect.objectContaining({ startOnLoad: false, securityLevel: "strict", theme: "base" }),
);
const block = root.querySelector<HTMLElement>("[data-mermaid]")!;
expect(block.getAttribute("data-mermaid-state")).toBe("ok");
expect(block.querySelector(".mermaid-output svg")).not.toBeNull();
// Temporary nodes mermaid leaves under <body> are removed.
expect(document.body.querySelector(":scope > [id^='d']")).toBeNull();
});
it("shows syntax errors inline next to the source without throwing", async () => {
const root = preview("```mermaid\nsequenceDiagram\nbad\n```\n");
await expect(renderMermaidIn(root).done).resolves.toBeUndefined();
const block = root.querySelector<HTMLElement>("[data-mermaid]")!;
expect(block.getAttribute("data-mermaid-state")).toBe("error");
expect(block.querySelector("pre code")?.textContent).toContain("bad");
const error = block.querySelector(".mermaid-output")!;
expect(error.getAttribute("role")).toBe("alert");
expect(error.textContent).toContain("Parse error on line 2");
expect(error.querySelector("svg")).toBeNull();
});
it("renders with unique ids, reuses results and keeps the old diagram while a new one renders", async () => {
const first = preview(SEQ);
await renderMermaidIn(first, []).done;
const ids = new Set(m.render.mock.calls.map(([id]) => id));
// Same source again (e.g. an edit elsewhere): drawn synchronously from the cache.
const previous: Node[] = [];
const again = preview(SEQ);
const pass = renderMermaidIn(again, previous);
expect(pass.async).toBe(false);
expect(again.querySelector("[data-mermaid-state='ok'] svg")).not.toBeNull();
expect(m.render).toHaveBeenCalledTimes(1);
// Edited diagram: the previous drawing stands in until the new one is ready.
const edited = preview(SEQ.replace("test", "edited"));
const next = renderMermaidIn(edited, previous);
expect(next.async).toBe(true);
expect(edited.querySelector(".mermaid-output svg")?.textContent).toBe("sequenceDiagram");
await next.done;
expect(m.render).toHaveBeenCalledTimes(2);
ids.add(m.render.mock.calls[1]![0]);
expect(ids.size).toBe(2);
});
it("skips superseded renders", async () => {
const root = preview(SEQ.replace("test", "stale"));
const pass = renderMermaidIn(root);
pass.cancel();
await pass.done;
expect(m.render).not.toHaveBeenCalled();
expect(root.querySelector("[data-mermaid]")?.getAttribute("data-mermaid-state")).toBe("pending");
});
});
+203
View File
@@ -0,0 +1,203 @@
/**
* Mermaid diagrams in the Markdown preview. `renderMarkdown` emits a
* `[data-mermaid]` placeholder holding the source in `pre > code`; this module
* turns placeholders into SVG. The library is imported on first use only.
*/
import type { Mermaid } from "mermaid";
export const MERMAID_SELECTOR = "[data-mermaid]";
const OUTPUT_CLASS = "mermaid-output";
const RENDERED_ATTR = "data-mermaid-state";
type Result = { svg: string } | { error: string };
let loading: Promise<Mermaid> | null = null;
let configured = "";
let seq = 0;
/** Results by theme + source, so edits elsewhere in the file don't re-render diagrams. */
const cache = new Map<string, Result>();
const CACHE_LIMIT = 64;
/** mermaid's render isn't re-entrant across configuration changes: one at a time. */
let queue: Promise<unknown> = Promise.resolve();
function loadMermaid(): Promise<Mermaid> {
loading ??= import("mermaid").then((m) => m.default);
loading.catch(() => (loading = null));
return loading;
}
/** Theme identity: anything that changes the colours read below. */
export function themeKey(): string {
const root = document.documentElement;
return `${root.dataset.theme ?? ""}|${root.dataset.contrast ?? ""}|${root.style.getPropertyValue("--accent")}`;
}
/** Calls `cb` when the theme attributes or the accent on the root change. */
export function subscribeTheme(cb: () => void): () => void {
if (typeof MutationObserver === "undefined") return () => {};
const observer = new MutationObserver(cb);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme", "data-contrast", "style"],
});
return () => observer.disconnect();
}
/** Diagram colours from the IDE tokens; values mermaid can't parse (e.g. `color-mix`) are skipped. */
function themeVariables(): Record<string, string | boolean> {
const css = getComputedStyle(document.documentElement);
const token = (...names: string[]) => {
for (const n of names) {
const v = css.getPropertyValue(n).trim();
if (/^(#[0-9a-f]{3,8}|rgba?\([^)]*\))$/i.test(v)) return v;
}
return undefined;
};
const vars: Record<string, string | undefined> = {
background: token("--bg-editor", "--bg-app"),
mainBkg: token("--bg-elev"),
primaryColor: token("--bg-elev"),
primaryBorderColor: token("--border-strong"),
primaryTextColor: token("--fg-strong", "--fg"),
secondaryColor: token("--bg-popup"),
tertiaryColor: token("--bg-hover"),
lineColor: token("--fg-secondary"),
textColor: token("--fg"),
edgeLabelBackground: token("--bg-editor", "--bg-app"),
errorBkgColor: token("--bg-elev"),
errorTextColor: token("--danger"),
};
const out: Record<string, string | boolean> = { darkMode: document.documentElement.dataset.theme !== "light" };
for (const [k, v] of Object.entries(vars)) if (v) out[k] = v;
const font = css.getPropertyValue("--font-ui").trim();
if (font) out.fontFamily = font;
return out;
}
function configure(mermaid: Mermaid, key: string) {
if (configured === key) return;
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: "base",
themeVariables: themeVariables(),
});
configured = key;
}
function removeTemp(id: string) {
// mermaid measures in detached-from-preview nodes under <body>: `d<id>` (and `i<id>` when sandboxed).
for (const tmp of [`d${id}`, `i${id}`, id]) document.getElementById(tmp)?.remove();
}
async function renderOne(source: string, key: string): Promise<Result> {
const mermaid = await loadMermaid();
configure(mermaid, key);
const id = `openidea-mermaid-${++seq}`;
try {
const { svg } = await mermaid.render(id, source);
return { svg };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return { error: msg.trim() || "Invalid diagram" };
} finally {
removeTemp(id);
}
}
/** Resolves `null` when the pass was superseded before its turn came. */
function enqueue(source: string, key: string, live: () => boolean): Promise<Result | null> {
const p = queue.then(() => (live() ? renderOne(source, key) : null));
queue = p.catch(() => undefined);
return p;
}
function remember(k: string, r: Result) {
if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value!);
cache.set(k, r);
}
function show(block: HTMLElement, result: Result | null, stale?: Node) {
block.querySelector(`:scope > .${OUTPUT_CLASS}`)?.remove();
const out = document.createElement("div");
out.className = OUTPUT_CLASS;
if (result && "svg" in result) {
out.innerHTML = result.svg;
block.setAttribute(RENDERED_ATTR, "ok");
} else if (result) {
out.setAttribute("role", "alert");
out.textContent = result.error;
block.setAttribute(RENDERED_ATTR, "error");
} else if (stale) {
// The diagram before this edit, until the new one is ready (no flicker while typing).
out.append(stale.cloneNode(true));
block.setAttribute(RENDERED_ATTR, "ok");
} else {
block.setAttribute(RENDERED_ATTR, "pending");
return;
}
block.append(out);
}
export const hasMermaid = (root: ParentNode) => root.querySelector(MERMAID_SELECTOR) !== null;
export interface MermaidPass {
/** Whether some diagram renders asynchronously (it changes the layout when `done`). */
async: boolean;
/** Settles when every diagram of the pass is in place (or the pass was cancelled). */
done: Promise<void>;
cancel(): void;
}
/**
* Renders the placeholders under `root`. `previous` holds the last rendered
* output per block index of the same preview (updated in place) and stands in
* while a changed diagram renders. Cached results are applied synchronously.
*/
export function renderMermaidIn(root: HTMLElement, previous: Node[] = []): MermaidPass {
const blocks = [...root.querySelectorAll<HTMLElement>(MERMAID_SELECTOR)];
let live = true;
if (!blocks.length) return { async: false, done: Promise.resolve(), cancel: () => {} };
const key = themeKey();
const pending: Promise<void>[] = [];
blocks.forEach((block, i) => {
const source = (block.querySelector("code")?.textContent ?? "").replace(/\n+$/, "");
const k = `${key}\n${source}`;
const cached = cache.get(k);
const keep = () => {
const out = block.querySelector(`:scope > .${OUTPUT_CLASS}`);
if (out && block.getAttribute(RENDERED_ATTR) === "ok") previous[i] = out.firstChild ?? out;
};
if (cached) {
show(block, cached);
keep();
return;
}
show(block, null, previous[i]);
pending.push(
enqueue(source, key, () => live).then((r) => {
if (!r) return;
remember(k, r);
if (!live) return;
show(block, r);
keep();
}),
);
});
previous.length = blocks.length;
return {
async: pending.length > 0,
done: Promise.all(pending).then(() => undefined),
cancel: () => {
live = false;
},
};
}
/** For tests. */
export function resetMermaidForTests() {
cache.clear();
configured = "";
loading = null;
}
+15 -2
View File
@@ -2,12 +2,25 @@
* Markdown → sanitized HTML for the preview. Every top-level block is wrapped
* in an element carrying its 0-based source line (`data-line`) for scroll
* sync; images keep their source in `data-src` and are loaded by the preview
* (local files through the backend, as the CSP blocks file URLs).
* (local files through the backend, as the CSP blocks file URLs). Mermaid
* blocks become `[data-mermaid]` placeholders, drawn by `./mermaid`.
*/
import { Marked, type Token, type TokensList } from "marked";
import DOMPurify from "dompurify";
const marked = new Marked({ gfm: true, breaks: false });
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
const marked = new Marked({
gfm: true,
breaks: false,
renderer: {
code({ text, lang }) {
if (lang?.trim().split(/\s+/)[0]?.toLowerCase() !== "mermaid") return false;
return `<div data-mermaid=""><pre><code class="language-mermaid">${escapeHtml(text)}</code></pre></div>\n`;
},
},
});
// Own instance: hooks must not leak into the hover / documentation renderer.
let purifier: ReturnType<typeof DOMPurify> | null = null;