From 00415de4630eee17b5fe2fd1fb23f88c19ba9bdf Mon Sep 17 00:00:00 2001 From: jiawen134 Date: Sun, 10 May 2026 07:11:20 +0200 Subject: [PATCH] feat(editor): render mermaid diagrams inside issue descriptions (#2297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 `` 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../editor/extensions/code-block-view.tsx | 33 +- packages/views/editor/mermaid-diagram.tsx | 294 ++++++++++++++++++ packages/views/editor/readonly-content.tsx | 277 +---------------- 3 files changed, 328 insertions(+), 276 deletions(-) create mode 100644 packages/views/editor/mermaid-diagram.tsx diff --git a/packages/views/editor/extensions/code-block-view.tsx b/packages/views/editor/extensions/code-block-view.tsx index 0923e889d..a44b98dff 100644 --- a/packages/views/editor/extensions/code-block-view.tsx +++ b/packages/views/editor/extensions/code-block-view.tsx @@ -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(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 ( + {isMermaid && debouncedChart.trim() && ( +
+ +
+ )}
| null = null; + +function getMermaid(): Promise { + 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 `${svg}`; +} + +function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string { + const cssVariables = getSandboxCssVariables(host); + + return `${svg}`; +} + +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( +
+