mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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",
|
||||
|
||||
Generated
+850
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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user