Compare commits

...
Author SHA1 Message Date
l0ng-ai 75c88a993f feat(markdown): Render Mermaid diagrams in the preview
Fenced ```mermaid blocks in the Markdown preview are drawn as diagrams.
The mermaid library is loaded on demand, only for previews that contain
a diagram, and renders with securityLevel "strict". Diagram colours come
from the theme tokens and are redrawn when the theme changes.

A block with a syntax error keeps its source visible and shows the
parser message below it. Results are cached per source and theme, so
edits elsewhere in the file don't redraw diagrams, and an edited diagram
keeps its previous drawing until the new one is ready.

Fixes #79

Claude-Session: https://claude.ai/code/session_01VUYsdXA2nkF5v6zsC93dpn
2026-10-27 16:22:28 +08:00
7 changed files with 1245 additions and 4 deletions

No files matched your search

+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;