Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

180 lines
5.3 KiB
TypeScript

"use client";
import React, { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { subscribeToThemeChanges } from "@/lib/theme";
interface MermaidProps {
chart: string;
className?: string;
}
let mermaidLoader: Promise<(typeof import("mermaid"))["default"]> | null = null;
// Read a CSS custom property from :root. We re-derive these on every render
// so the diagram colors track the active theme rather than freezing to the
// first-render palette.
function cssVar(name: string, fallback: string): string {
if (typeof window === "undefined") return fallback;
const value = getComputedStyle(document.documentElement)
.getPropertyValue(name)
.trim();
return value || fallback;
}
function themeVariablesFromCss() {
// Mermaid expects opaque colors; we pull from the theme's --foreground /
// --card / --border / --primary tokens so diagrams blend with the chat
// surface in every theme (light, dark, snow, glass).
return {
primaryColor: cssVar("--card", "#ffffff"),
primaryTextColor: cssVar("--foreground", "#1f1d1b"),
primaryBorderColor: cssVar("--border", "#dbd4c8"),
lineColor: cssVar("--muted-foreground", "#6b655f"),
secondaryColor: cssVar("--muted", "#ece7dd"),
tertiaryColor: cssVar("--background", "#faf9f6"),
textColor: cssVar("--foreground", "#1f1d1b"),
mainBkg: cssVar("--card", "#ffffff"),
};
}
async function loadMermaid() {
if (!mermaidLoader) {
mermaidLoader = import("mermaid").then((module) => module.default);
}
return mermaidLoader;
}
// Re-applied on every render so theme changes pick up. mermaid.initialize()
// is idempotent and cheap; the heavy work is the dynamic import which the
// loader above only runs once.
function applyMermaidTheme(mermaid: (typeof import("mermaid"))["default"]) {
mermaid.initialize({
startOnLoad: false,
theme: "base",
securityLevel: "strict",
fontFamily: "ui-sans-serif, system-ui, sans-serif",
flowchart: {
useMaxWidth: true,
htmlLabels: false,
curve: "basis",
},
themeVariables: themeVariablesFromCss(),
});
}
function cleanupMermaidOrphans(id: string) {
try {
document.getElementById(id)?.remove();
document.getElementById(`d${id}`)?.remove();
} catch {
/* ignore */
}
}
let mermaidIdCounter = 0;
const DEBOUNCE_MS = 600;
export const Mermaid: React.FC<MermaidProps> = ({ chart, className = "" }) => {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string | null>(null);
const [stable, setStable] = useState(false);
const [id] = useState(() => `mermaid-${++mermaidIdCounter}`);
const [themeToken, setThemeToken] = useState(0);
const lastChartRef = useRef(chart);
useEffect(() => {
lastChartRef.current = chart;
setStable(false);
const timer = window.setTimeout(() => {
if (lastChartRef.current === chart) setStable(true);
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [chart]);
// Bump a token whenever the app's theme changes so the render effect below
// re-runs with fresh theme variables. Without this the diagram would keep
// its initial palette across light/dark/glass/snow switches.
useEffect(() => {
return subscribeToThemeChanges(() => setThemeToken((t) => t + 1));
}, []);
useEffect(() => {
if (!stable) return;
let cancelled = false;
const renderChart = async () => {
if (!chart.trim() || !containerRef.current) return;
try {
const mermaid = await loadMermaid();
applyMermaidTheme(mermaid);
cleanupMermaidOrphans(id);
const { svg: renderedSvg } = await mermaid.render(id, chart.trim());
if (!cancelled) {
setSvg(renderedSvg);
setError(null);
}
} catch (err) {
cleanupMermaidOrphans(id);
if (!cancelled) {
setError(
err instanceof Error ? err.message : t("Failed to render diagram"),
);
}
}
};
void renderChart();
return () => {
cancelled = true;
};
}, [stable, chart, id, t, themeToken]);
if (error) {
return (
<div
className={`my-4 p-4 bg-red-50 border border-red-200 rounded-lg ${className}`}
>
<p className="text-red-600 text-sm font-medium mb-2">
{t("Diagram rendering error")}
</p>
<pre className="text-xs text-red-500 whitespace-pre-wrap">{error}</pre>
<details className="mt-2">
<summary className="text-xs text-[var(--muted-foreground)] cursor-pointer">
{t("Show source")}
</summary>
<pre className="mt-2 p-2 bg-[var(--muted)] rounded text-xs overflow-x-auto text-[var(--foreground)]">
{chart}
</pre>
</details>
</div>
);
}
if (!stable && !svg) {
return (
<div
className={`my-4 rounded-xl border border-[var(--border)] bg-[var(--muted)]/50 px-4 py-3 text-sm text-[var(--muted-foreground)] ${className}`}
>
{t("Rendering diagram...")}
</div>
);
}
return (
<div
ref={containerRef}
className={`my-6 flex justify-center overflow-x-auto ${className}`}
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
};
export default Mermaid;