diff --git a/apps/web/src/components/ui/markdown-mermaid-theme.ts b/apps/web/src/components/ui/markdown-mermaid-theme.ts new file mode 100644 index 00000000..995ae753 --- /dev/null +++ b/apps/web/src/components/ui/markdown-mermaid-theme.ts @@ -0,0 +1,109 @@ +import { useEffect, useMemo, useState } from "react"; + +export type MermaidRenderTheme = { + darkMode: boolean; + fontFamily: string; + themeVariables: Record; +}; + +function hslToken(name: string, styles: CSSStyleDeclaration): string { + return `hsl(${styles.getPropertyValue(name).trim()})`; +} + +function parseHslChannels(color: string): [number, number, number] | null { + const match = color.match( + /^hsl\(\s*(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%\s*\)$/i + ); + if (!match) return null; + return [ + Number(match[1]) % 360, + Number(match[2]) / 100, + Number(match[3]) / 100, + ]; +} + +function lightnessFromHsl(color: string): number | null { + return parseHslChannels(color)?.[2] ?? null; +} + +export function useMermaidTheme(): MermaidRenderTheme | null { + const [themeVersion, setThemeVersion] = useState(0); + + useEffect(() => { + const root = document.documentElement; + const observer = new MutationObserver(() => { + setThemeVersion((version) => version + 1); + }); + + observer.observe(root, { + attributes: true, + attributeFilter: ["data-theme", "style", "class"], + }); + + return () => observer.disconnect(); + }, []); + + return useMemo(() => { + void themeVersion; + if (typeof window === "undefined") return null; + + const styles = window.getComputedStyle(document.documentElement); + const background = hslToken("--background", styles); + const foreground = hslToken("--foreground", styles); + const primary = hslToken("--primary", styles); + const primaryForeground = hslToken("--primary-foreground", styles); + const muted = hslToken("--muted", styles); + const mutedForeground = hslToken("--muted-foreground", styles); + const border = hslToken("--border", styles); + const card = hslToken("--card", styles); + const cardForeground = hslToken("--card-foreground", styles); + const destructive = hslToken("--destructive", styles); + const destructiveForeground = hslToken("--destructive-foreground", styles); + const fontFamily = + styles.getPropertyValue("--font-terminal").trim() || + "system-ui, sans-serif"; + + const lightness = lightnessFromHsl(background); + const darkMode = lightness === null ? true : lightness < 0.5; + + return { + darkMode, + fontFamily, + themeVariables: { + darkMode, + background, + mainBkg: card, + secondBkg: muted, + tertiaryColor: muted, + primaryColor: primary, + primaryTextColor: primaryForeground, + primaryBorderColor: border, + secondaryColor: muted, + secondaryTextColor: foreground, + secondaryBorderColor: border, + tertiaryTextColor: mutedForeground, + tertiaryBorderColor: border, + lineColor: mutedForeground, + textColor: foreground, + nodeTextColor: cardForeground, + clusterBkg: background, + clusterBorder: border, + defaultLinkColor: mutedForeground, + titleColor: foreground, + edgeLabelBackground: background, + labelBackground: background, + actorBkg: card, + actorBorder: border, + actorTextColor: foreground, + actorLineColor: mutedForeground, + signalColor: mutedForeground, + signalTextColor: foreground, + c0: card, + c1: muted, + c2: background, + errorBkgColor: destructive, + errorTextColor: destructiveForeground, + }, + }; + }, [themeVersion]); +} diff --git a/apps/web/src/components/ui/markdown-mermaid.tsx b/apps/web/src/components/ui/markdown-mermaid.tsx new file mode 100644 index 00000000..d51993ea --- /dev/null +++ b/apps/web/src/components/ui/markdown-mermaid.tsx @@ -0,0 +1,194 @@ +import { useEffect, useId, useState } from "react"; +import { Check, Code2, Image } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import type { MermaidRenderTheme } from "@/components/ui/markdown-mermaid-theme"; +import { useCopyText } from "@/hooks/use-copy"; +import { errorMessage } from "@/lib/errors"; + +let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | null = + null; +let mermaidConfigKey: string | null = null; +let mermaidRenderQueue: Promise = Promise.resolve(); + +function getMermaidConfig(theme: MermaidRenderTheme | null) { + return { + startOnLoad: false, + securityLevel: "strict" as const, + theme: "base" as const, + fontFamily: theme?.fontFamily, + darkMode: theme?.darkMode, + themeVariables: theme?.themeVariables, + }; +} + +async function getMermaid() { + mermaidPromise ??= import("mermaid") + .then(({ default: mermaid }) => mermaid) + .catch((error) => { + mermaidPromise = null; + throw error; + }); + return mermaidPromise; +} + +async function ensureMermaidInitialized(theme: MermaidRenderTheme | null) { + const mermaid = await getMermaid(); + const config = getMermaidConfig(theme); + const nextConfigKey = JSON.stringify(config); + if (mermaidConfigKey !== nextConfigKey) { + mermaid.initialize(config); + mermaidConfigKey = nextConfigKey; + } + return mermaid; +} + +async function renderMermaidDiagram({ + code, + id, + theme, +}: { + code: string; + id: string; + theme: MermaidRenderTheme | null; +}) { + const renderTask = mermaidRenderQueue + .catch(() => {}) + .then(async () => { + const mermaid = await ensureMermaidInitialized(theme); + return mermaid + .render(`dispatch-mermaid-${id}`, code) + .then(({ svg }) => svg); + }); + + mermaidRenderQueue = renderTask.then(() => undefined); + return renderTask; +} + +export function MermaidBlock({ + code, + theme, +}: { + code: string; + theme: MermaidRenderTheme | null; +}): JSX.Element { + const [svg, setSvg] = useState(null); + const [error, setError] = useState(null); + const [sourceCopied, copySource] = useCopyText(); + const [svgCopied, copySvg] = useCopyText(); + const id = useId().replace(/:/g, "-"); + + useEffect(() => { + let cancelled = false; + + async function renderDiagram() { + setSvg(null); + setError(null); + + try { + const renderedSvg = await renderMermaidDiagram({ code, id, theme }); + if (!cancelled) setSvg(renderedSvg); + } catch (err) { + if (!cancelled) { + setError(errorMessage(err)); + } + } + } + + void renderDiagram(); + + return () => { + cancelled = true; + }; + }, [code, id, theme]); + + if (error) { + return ( +
+
+

+ Mermaid render failed +

+ +
+
+          {code}
+        
+
+ ); + } + + if (!svg) { + return ( +
+
+ + Rendering diagram... +
+
+ ); + } + + return ( +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/ui/markdown.tsx b/apps/web/src/components/ui/markdown.tsx index 7991bc0a..06d5b34e 100644 --- a/apps/web/src/components/ui/markdown.tsx +++ b/apps/web/src/components/ui/markdown.tsx @@ -1,317 +1,12 @@ -import { - Children, - isValidElement, - useEffect, - useId, - useMemo, - useState, - type ReactNode, -} from "react"; -import { Check, Code2, Image } from "lucide-react"; +import { Children, isValidElement, type ReactNode } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { Button } from "@/components/ui/button"; import { highlightCodeLanguage } from "@/components/app/media-lightbox-syntax"; -import { useCopyText } from "@/hooks/use-copy"; -import { errorMessage } from "@/lib/errors"; +import { MermaidBlock } from "@/components/ui/markdown-mermaid"; +import { useMermaidTheme } from "@/components/ui/markdown-mermaid-theme"; import { cn } from "@/lib/utils"; -let mermaidPromise: Promise<(typeof import("mermaid"))["default"]> | null = - null; -let mermaidConfigKey: string | null = null; -let mermaidRenderQueue: Promise = Promise.resolve(); - -type MermaidRenderTheme = { - darkMode: boolean; - fontFamily: string; - themeVariables: Record; -}; - -function hslToken(name: string, styles: CSSStyleDeclaration): string { - return `hsl(${styles.getPropertyValue(name).trim()})`; -} - -function parseHslChannels(color: string): [number, number, number] | null { - const match = color.match( - /^hsl\(\s*(-?\d+(?:\.\d+)?)\s+(\d+(?:\.\d+)?)%\s+(\d+(?:\.\d+)?)%\s*\)$/i - ); - if (!match) return null; - return [ - Number(match[1]) % 360, - Number(match[2]) / 100, - Number(match[3]) / 100, - ]; -} - -function lightnessFromHsl(color: string): number | null { - return parseHslChannels(color)?.[2] ?? null; -} - -function useMermaidTheme(): MermaidRenderTheme | null { - const [themeVersion, setThemeVersion] = useState(0); - - useEffect(() => { - const root = document.documentElement; - const observer = new MutationObserver(() => { - setThemeVersion((version) => version + 1); - }); - - observer.observe(root, { - attributes: true, - attributeFilter: ["data-theme", "style", "class"], - }); - - return () => observer.disconnect(); - }, []); - - return useMemo(() => { - void themeVersion; - if (typeof window === "undefined") return null; - - const styles = window.getComputedStyle(document.documentElement); - const background = hslToken("--background", styles); - const foreground = hslToken("--foreground", styles); - const primary = hslToken("--primary", styles); - const primaryForeground = hslToken("--primary-foreground", styles); - const muted = hslToken("--muted", styles); - const mutedForeground = hslToken("--muted-foreground", styles); - const border = hslToken("--border", styles); - const card = hslToken("--card", styles); - const cardForeground = hslToken("--card-foreground", styles); - const destructive = hslToken("--destructive", styles); - const destructiveForeground = hslToken("--destructive-foreground", styles); - const fontFamily = - styles.getPropertyValue("--font-terminal").trim() || - "system-ui, sans-serif"; - - const lightness = lightnessFromHsl(background); - const darkMode = lightness === null ? true : lightness < 0.5; - - return { - darkMode, - fontFamily, - themeVariables: { - darkMode, - background, - mainBkg: card, - secondBkg: muted, - tertiaryColor: muted, - primaryColor: primary, - primaryTextColor: primaryForeground, - primaryBorderColor: border, - secondaryColor: muted, - secondaryTextColor: foreground, - secondaryBorderColor: border, - tertiaryTextColor: mutedForeground, - tertiaryBorderColor: border, - lineColor: mutedForeground, - textColor: foreground, - nodeTextColor: cardForeground, - clusterBkg: background, - clusterBorder: border, - defaultLinkColor: mutedForeground, - titleColor: foreground, - edgeLabelBackground: background, - labelBackground: background, - actorBkg: card, - actorBorder: border, - actorTextColor: foreground, - actorLineColor: mutedForeground, - signalColor: mutedForeground, - signalTextColor: foreground, - c0: card, - c1: muted, - c2: background, - errorBkgColor: destructive, - errorTextColor: destructiveForeground, - }, - }; - }, [themeVersion]); -} - -function getMermaidConfig(theme: MermaidRenderTheme | null) { - return { - startOnLoad: false, - securityLevel: "strict" as const, - theme: "base" as const, - fontFamily: theme?.fontFamily, - darkMode: theme?.darkMode, - themeVariables: theme?.themeVariables, - }; -} - -async function getMermaid() { - mermaidPromise ??= import("mermaid") - .then(({ default: mermaid }) => mermaid) - .catch((error) => { - mermaidPromise = null; - throw error; - }); - return mermaidPromise; -} - -async function ensureMermaidInitialized(theme: MermaidRenderTheme | null) { - const mermaid = await getMermaid(); - const config = getMermaidConfig(theme); - const nextConfigKey = JSON.stringify(config); - if (mermaidConfigKey !== nextConfigKey) { - mermaid.initialize(config); - mermaidConfigKey = nextConfigKey; - } - return mermaid; -} - -async function renderMermaidDiagram({ - code, - id, - theme, -}: { - code: string; - id: string; - theme: MermaidRenderTheme | null; -}) { - const renderTask = mermaidRenderQueue - .catch(() => {}) - .then(async () => { - const mermaid = await ensureMermaidInitialized(theme); - return mermaid - .render(`dispatch-mermaid-${id}`, code) - .then(({ svg }) => svg); - }); - - mermaidRenderQueue = renderTask.then(() => undefined); - return renderTask; -} - -function MermaidBlock({ - code, - theme, -}: { - code: string; - theme: MermaidRenderTheme | null; -}): JSX.Element { - const [svg, setSvg] = useState(null); - const [error, setError] = useState(null); - const [sourceCopied, copySource] = useCopyText(); - const [svgCopied, copySvg] = useCopyText(); - const id = useId().replace(/:/g, "-"); - - useEffect(() => { - let cancelled = false; - - async function renderDiagram() { - setSvg(null); - setError(null); - - try { - const renderedSvg = await renderMermaidDiagram({ code, id, theme }); - if (!cancelled) setSvg(renderedSvg); - } catch (err) { - if (!cancelled) { - setError(errorMessage(err)); - } - } - } - - void renderDiagram(); - - return () => { - cancelled = true; - }; - }, [code, id, theme]); - - if (error) { - return ( -
-
-

- Mermaid render failed -

- -
-
-          {code}
-        
-
- ); - } - - if (!svg) { - return ( -
-
- - Rendering diagram... -
-
- ); - } - - return ( -
-
- - -
-
-
- ); -} - function getCodeBlock( children: ReactNode ): { className?: string; code: string } | null {