Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions apps/web/src/components/ui/markdown-mermaid-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { useEffect, useMemo, useState } from "react";

export type MermaidRenderTheme = {
darkMode: boolean;
fontFamily: string;
themeVariables: Record<string, string | boolean>;
};

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]);
}
194 changes: 194 additions & 0 deletions apps/web/src/components/ui/markdown-mermaid.tsx
Original file line number Diff line number Diff line change
@@ -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<void> = 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<string | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="not-prose my-4 rounded-md border border-destructive/40 bg-destructive/10 p-3">
<div className="flex items-start gap-2">
<p className="m-0 flex-1 text-sm font-medium text-destructive">
Mermaid render failed
</p>
<Button
aria-label="Copy Mermaid source"
data-testid="copy-mermaid-source"
title="Copy Mermaid source"
size="icon"
type="button"
variant="ghost"
className="h-11 w-11 text-destructive hover:bg-destructive/10 hover:text-destructive sm:h-7 sm:w-7"
onClick={() => copySource(code)}
>
{sourceCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Code2 className="h-3.5 w-3.5" />
)}
</Button>
</div>
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words text-xs text-destructive">
<code>{code}</code>
</pre>
</div>
);
}

if (!svg) {
return (
<div className="not-prose my-4 rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary/70 animate-pulse" />
<span>Rendering diagram...</span>
</div>
</div>
);
}

return (
<div
className="not-prose my-4 overflow-x-auto rounded-md border border-border bg-background p-3"
data-testid="mermaid-diagram"
>
<div className="mb-2 flex items-center justify-end gap-2 sm:gap-1">
<Button
aria-label="Copy Mermaid source"
data-testid="copy-mermaid-source"
title="Copy Mermaid source"
size="icon"
type="button"
variant="ghost"
className="h-11 w-11 text-muted-foreground hover:text-foreground sm:h-7 sm:w-7"
onClick={() => copySource(code)}
>
{sourceCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Code2 className="h-3.5 w-3.5" />
)}
</Button>
<Button
aria-label="Copy Mermaid SVG"
data-testid="copy-mermaid-svg"
title="Copy Mermaid SVG"
size="icon"
type="button"
variant="ghost"
className="h-11 w-11 text-muted-foreground hover:text-foreground sm:h-7 sm:w-7"
onClick={() => {
if (svg) copySvg(svg);
}}
>
{svgCopied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Image className="h-3.5 w-3.5" />
)}
</Button>
</div>
<div
className="[&_svg]:h-auto [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
);
}
Loading
Loading