feat(editor): render mermaid diagrams inside issue descriptions (#2297)

* feat(editor): render mermaid diagrams inside issue descriptions

Issue descriptions are rendered through the Tiptap-based ContentEditor
(not ReadonlyContent), so the mermaid handler that PR #1888 added to
ReadonlyContent never reached them. Comments worked because comment-card
toggles between ContentEditor (edit mode) and ReadonlyContent (display
mode); issue descriptions stay in ContentEditor permanently.

This patch teaches the Tiptap CodeBlock NodeView to render a Mermaid
preview when the language is `mermaid`, giving issue descriptions a
split view: live diagram on top, editable source below. Theme variables
(light/dark), the sandboxed iframe, the lightbox and error fallback all
come from the existing implementation — only the location moved.

Changes:
- Extract MermaidDiagram + helpers (theme detection, sandbox iframe,
  lightbox, useThemeVersion) from `readonly-content.tsx` into a new
  `editor/mermaid-diagram.tsx`. ReadonlyContent (~200 lines lighter)
  imports the same component, so comment-card / inbox rendering is
  unchanged byte-for-byte.
- Update `code-block-view.tsx` (the Tiptap CodeBlock NodeView) to render
  `<MermaidDiagram>` above the editable source whenever the block's
  language is `mermaid` and the source is non-empty.

Tested:
- pnpm --filter @multica/views typecheck — clean
- pnpm --filter @multica/views test — 327 tests pass (43 files)
- Manually verified a mermaid block in an issue description renders as
  an SVG flowchart while staying editable underneath.

Closes #2079

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(editor): debounce mermaid preview re-renders during edits

Addresses review feedback on #2297. Previously every keystroke in a
Mermaid code block triggered `mermaid.initialize() + render()` on the
CodeBlockView preview. Because `mermaid.initialize()` mutates a
process-global config, those bursts could race a concurrent
ReadonlyContent render (e.g. a comment card) and clobber its theme
variables.

200ms is short enough that the preview still feels live during typing
but long enough to make concurrent inits unlikely in practice. The
ReadonlyContent path is unchanged: chart there is the saved markdown
and never changes after mount, so the race only existed on the new
edit-time path this PR introduced.

A small `useDebouncedValue` hook local to the file gates `chart` so
that it only flows into MermaidDiagram after 200ms of stable input.
When the language is non-Mermaid the hook short-circuits to "", so
non-Mermaid blocks pay no extra cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jiawen134
2026-05-10 07:11:20 +02:00
committed by GitHub
parent 448e75ce53
commit 00415de463
3 changed files with 328 additions and 276 deletions

View File

@@ -1,15 +1,38 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Copy, Check } from "lucide-react";
import { useT } from "../../i18n";
import { MermaidDiagram } from "../mermaid-diagram";
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
// `mermaid.initialize()` mutates a process-global config, so back-to-back
// renders during typing can race a concurrent ReadonlyContent render
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
// "live preview" feel while making concurrent inits unlikely in practice.
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
function CodeBlockView({ node }: NodeViewProps) {
const { t } = useT("editor");
const [copied, setCopied] = useState(false);
const language = node.attrs.language || "";
const isMermaid = language === "mermaid";
const chart = node.textContent;
const debouncedChart = useDebouncedValue(
isMermaid ? chart : "",
MERMAID_PREVIEW_DEBOUNCE_MS,
);
const handleCopy = async () => {
const text = node.textContent;
@@ -21,6 +44,14 @@ function CodeBlockView({ node }: NodeViewProps) {
return (
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
{isMermaid && debouncedChart.trim() && (
<div
contentEditable={false}
className="mermaid-diagram-preview mb-1"
>
<MermaidDiagram chart={debouncedChart} />
</div>
)}
<div
contentEditable={false}
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"

View File

@@ -0,0 +1,294 @@
"use client";
/**
* MermaidDiagram — sandboxed Mermaid diagram renderer.
*
* Extracted from `readonly-content.tsx` so the Tiptap CodeBlock NodeView
* (`code-block-view.tsx`) can render the same component when a code block's
* language is `mermaid`. Previously Mermaid only worked in read-only
* markdown surfaces (comment cards) — issue descriptions, which always
* stay in the Tiptap editor, never rendered diagrams.
*
* Theme variables are detected from the host's CSS custom properties so the
* diagram colors match light/dark mode. The SVG is rendered inside a
* sandboxed iframe to keep Mermaid's runtime stylesheet from leaking into
* the page.
*/
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Maximize2 } from "lucide-react";
import { useT } from "../i18n";
type MermaidAPI = typeof import("mermaid").default;
type MermaidLayout = {
width?: number;
height?: number;
};
let mermaidPromise: Promise<MermaidAPI> | null = null;
function getMermaid(): Promise<MermaidAPI> {
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
return mermaidPromise;
}
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
const canvas = ownerDocument.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return fallback;
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
// 8-bit sRGB bytes that Mermaid can consume safely.
context.fillStyle = "#000";
context.fillStyle = color || fallback;
context.fillRect(0, 0, 1, 1);
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
return `rgb(${red}, ${green}, ${blue})`;
}
function resolveCssColor(
host: HTMLElement,
variableName: string,
fallback: string,
): string {
const probe = host.ownerDocument.createElement("span");
probe.style.color = `var(${variableName})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
}
function getMermaidThemeVariables(host: HTMLElement | null) {
if (!host) {
return {
primaryColor: "rgb(245, 245, 245)",
primaryBorderColor: "rgb(59, 130, 246)",
primaryTextColor: "rgb(17, 24, 39)",
lineColor: "rgb(107, 114, 128)",
fontFamily: "inherit",
};
}
return {
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
fontFamily: "inherit",
};
}
function getSandboxCssVariables(host: HTMLElement | null): string {
const styles = host ? getComputedStyle(host) : null;
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
.join(" ");
}
function getMermaidLayout(svg: string): MermaidLayout {
const viewBoxMatch = svg.match(
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
);
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
if (width && height && width > 0 && height > 0) {
return {
width: Math.ceil(width),
height: Math.ceil(height),
};
}
return {};
}
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
}
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
}
function useThemeVersion() {
const [themeVersion, setThemeVersion] = useState(0);
useEffect(() => {
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
const observer = new MutationObserver(bumpThemeVersion);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", bumpThemeVersion);
return () => {
observer.disconnect();
mediaQuery.removeEventListener("change", bumpThemeVersion);
};
}, []);
return themeVersion;
}
function MermaidLightbox({
srcDoc,
onClose,
}: {
srcDoc: string;
onClose: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
<div
className="mermaid-diagram-lightbox"
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram fullscreen view"
onClick={onClose}
>
<iframe
className="mermaid-diagram-lightbox-frame"
sandbox=""
srcDoc={srcDoc}
title="Mermaid diagram fullscreen"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}
export function MermaidDiagram({ chart }: { chart: string }) {
const { t } = useT("editor");
const reactId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const diagramId = useMemo(
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
[reactId],
);
const themeVersion = useThemeVersion();
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
const [layout, setLayout] = useState<MermaidLayout>({});
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
async function renderDiagram() {
try {
setError(null);
setSandboxedDocument(null);
setExpandedDocument(null);
setLayout({});
const mermaid = await getMermaid();
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: getMermaidThemeVariables(containerRef.current),
});
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
if (!cancelled) {
setLayout(getMermaidLayout(renderedSvg));
setSandboxedDocument(
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
);
setExpandedDocument(
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
}
}
}
void renderDiagram();
return () => {
cancelled = true;
};
}, [chart, diagramId, themeVersion]);
if (error) {
return (
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
<p>{t(($) => $.mermaid.render_error)}</p>
<pre>
<code>{chart}</code>
</pre>
</div>
);
}
return (
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
{sandboxedDocument ? (
<>
<iframe
className="mermaid-diagram-frame"
sandbox=""
srcDoc={sandboxedDocument}
style={{
height: layout.height ? `${layout.height}px` : undefined,
width: layout.width ? `${layout.width}px` : undefined,
}}
title="Mermaid diagram"
/>
<div className="mermaid-diagram-toolbar">
<button
type="button"
onClick={() => setLightboxOpen(true)}
title="Open fullscreen"
aria-label="Open Mermaid diagram fullscreen"
>
<Maximize2 className="size-3.5" />
</button>
</div>
{lightboxOpen && expandedDocument && (
<MermaidLightbox
srcDoc={expandedDocument}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
) : (
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
)}
</div>
);
}

View File

@@ -16,8 +16,7 @@
* - Rendering mentions with the same IssueMentionCard component and .mention class
*/
import { isValidElement, memo, useEffect, useId, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { isValidElement, memo, useMemo, useRef, useState } from "react";
import ReactMarkdown, {
defaultUrlTransform,
type Components,
@@ -42,6 +41,7 @@ import { ImageLightbox } from "./extensions/image-view";
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
import { openLink, isMentionHref } from "./utils/link-handler";
import { preprocessMarkdown } from "./utils/preprocess";
import { MermaidDiagram } from "./mermaid-diagram";
import "katex/dist/katex.min.css";
import "./content-editor.css";
@@ -51,140 +51,6 @@ import "./content-editor.css";
const lowlight = createLowlight(common);
type MermaidAPI = typeof import("mermaid").default;
type MermaidLayout = {
width?: number;
height?: number;
};
let mermaidPromise: Promise<MermaidAPI> | null = null;
function getMermaid(): Promise<MermaidAPI> {
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
return mermaidPromise;
}
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
const canvas = ownerDocument.createElement("canvas");
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) return fallback;
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
// 8-bit sRGB bytes that Mermaid can consume safely.
context.fillStyle = "#000";
context.fillStyle = color || fallback;
context.fillRect(0, 0, 1, 1);
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
return `rgb(${red}, ${green}, ${blue})`;
}
function resolveCssColor(
host: HTMLElement,
variableName: string,
fallback: string,
): string {
const probe = host.ownerDocument.createElement("span");
probe.style.color = `var(${variableName})`;
probe.style.display = "none";
host.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
}
function getMermaidThemeVariables(host: HTMLElement | null) {
if (!host) {
return {
primaryColor: "rgb(245, 245, 245)",
primaryBorderColor: "rgb(59, 130, 246)",
primaryTextColor: "rgb(17, 24, 39)",
lineColor: "rgb(107, 114, 128)",
fontFamily: "inherit",
};
}
return {
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
fontFamily: "inherit",
};
}
function getSandboxCssVariables(host: HTMLElement | null): string {
const styles = host ? getComputedStyle(host) : null;
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
.join(" ");
}
function getMermaidLayout(svg: string): MermaidLayout {
const viewBoxMatch = svg.match(
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
);
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
if (width && height && width > 0 && height > 0) {
return {
width: Math.ceil(width),
height: Math.ceil(height),
};
}
return {};
}
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
}
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
const cssVariables = getSandboxCssVariables(host);
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
}
function useThemeVersion() {
const [themeVersion, setThemeVersion] = useState(0);
useEffect(() => {
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
const observer = new MutationObserver(bumpThemeVersion);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
if (document.body) {
observer.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style", "data-theme"],
});
}
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", bumpThemeVersion);
return () => {
observer.disconnect();
mediaQuery.removeEventListener("change", bumpThemeVersion);
};
}, []);
return themeVersion;
}
// ---------------------------------------------------------------------------
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
// ---------------------------------------------------------------------------
@@ -294,145 +160,6 @@ function ReadonlyLink({
);
}
function MermaidLightbox({
srcDoc,
onClose,
}: {
srcDoc: string;
onClose: () => void;
}) {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
return createPortal(
<div
className="mermaid-diagram-lightbox"
role="dialog"
aria-modal="true"
aria-label="Mermaid diagram fullscreen view"
onClick={onClose}
>
<iframe
className="mermaid-diagram-lightbox-frame"
sandbox=""
srcDoc={srcDoc}
title="Mermaid diagram fullscreen"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}
function MermaidDiagram({ chart }: { chart: string }) {
const { t } = useT("editor");
const reactId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const diagramId = useMemo(
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
[reactId],
);
const themeVersion = useThemeVersion();
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
const [layout, setLayout] = useState<MermaidLayout>({});
const [error, setError] = useState<string | null>(null);
const [lightboxOpen, setLightboxOpen] = useState(false);
useEffect(() => {
let cancelled = false;
async function renderDiagram() {
try {
setError(null);
setSandboxedDocument(null);
setExpandedDocument(null);
setLayout({});
const mermaid = await getMermaid();
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: "base",
themeVariables: getMermaidThemeVariables(containerRef.current),
});
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
if (!cancelled) {
setLayout(getMermaidLayout(renderedSvg));
setSandboxedDocument(
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
);
setExpandedDocument(
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
}
}
}
void renderDiagram();
return () => {
cancelled = true;
};
}, [chart, diagramId, themeVersion]);
if (error) {
return (
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
<p>{t(($) => $.mermaid.render_error)}</p>
<pre>
<code>{chart}</code>
</pre>
</div>
);
}
return (
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
{sandboxedDocument ? (
<>
<iframe
className="mermaid-diagram-frame"
sandbox=""
srcDoc={sandboxedDocument}
style={{
height: layout.height ? `${layout.height}px` : undefined,
width: layout.width ? `${layout.width}px` : undefined,
}}
title="Mermaid diagram"
/>
<div className="mermaid-diagram-toolbar">
<button
type="button"
onClick={() => setLightboxOpen(true)}
title="Open fullscreen"
aria-label="Open Mermaid diagram fullscreen"
>
<Maximize2 className="size-3.5" />
</button>
</div>
{lightboxOpen && expandedDocument && (
<MermaidLightbox
srcDoc={expandedDocument}
onClose={() => setLightboxOpen(false)}
/>
)}
</>
) : (
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
)}
</div>
);
}
const components: Partial<Components> = {
// Links — route mention:// to mention components, others show preview card
a: ReadonlyLink,