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