Skip to content

Commit bbd095b

Browse files
authored
Merge pull request #1534 from Ryan-PG/fix/1497-mermaid-rendering
feat(markdown): add Mermaid diagram rendering support (#1497)
2 parents cb9da1b + e70d2e2 commit bbd095b

2 files changed

Lines changed: 143 additions & 1 deletion

File tree

surfsense_web/components/assistant-ui/markdown-code-block.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { memo, useEffect, useState } from "react";
66
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
77
import { materialDark, materialLight } from "react-syntax-highlighter/dist/esm/styles/prism";
88

9+
import { MermaidDiagram } from "@/components/assistant-ui/mermaid-diagram";
910
import { Button } from "@/components/ui/button";
1011
import { cn, copyToClipboard } from "@/lib/utils";
1112

@@ -40,14 +41,15 @@ function MarkdownCodeBlockComponent({
4041
isDarkMode,
4142
}: MarkdownCodeBlockProps) {
4243
const [hasCopied, setHasCopied] = useState(false);
44+
const normalizedLanguage = language.toLowerCase();
4345

4446
useEffect(() => {
4547
if (!hasCopied) return;
4648
const timer = setTimeout(() => setHasCopied(false), 2000);
4749
return () => clearTimeout(timer);
4850
}, [hasCopied]);
4951

50-
return (
52+
const codeBlock = (
5153
<div className="mt-4 overflow-hidden rounded-md bg-accent">
5254
<div className="flex items-center justify-between gap-4 px-4 py-2 font-semibold text-muted-foreground text-sm">
5355
<span className="lowercase text-xs">{language}</span>
@@ -78,6 +80,12 @@ function MarkdownCodeBlockComponent({
7880
</SyntaxHighlighter>
7981
</div>
8082
);
83+
84+
if (normalizedLanguage === "mermaid") {
85+
return <MermaidDiagram source={codeText} isDarkMode={isDarkMode} fallback={codeBlock} />;
86+
}
87+
88+
return codeBlock;
8189
}
8290

8391
export const MarkdownCodeBlock = memo(MarkdownCodeBlockComponent);
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"use client";
2+
3+
import { CheckIcon, CopyIcon } from "lucide-react";
4+
import mermaid from "mermaid";
5+
import { memo, type ReactNode, useEffect, useId, useState } from "react";
6+
import { Button } from "@/components/ui/button";
7+
import { copyToClipboard } from "@/lib/utils";
8+
9+
type MermaidDiagramProps = {
10+
source: string;
11+
isDarkMode: boolean;
12+
fallback: ReactNode;
13+
};
14+
15+
let mermaidInitialized = false;
16+
17+
function initializeMermaid() {
18+
if (mermaidInitialized) return;
19+
20+
mermaid.initialize({
21+
startOnLoad: false,
22+
securityLevel: "strict",
23+
htmlLabels: false,
24+
flowchart: { htmlLabels: false },
25+
sequence: { useMaxWidth: true },
26+
});
27+
28+
mermaidInitialized = true;
29+
}
30+
31+
function MermaidDiagramComponent({
32+
source,
33+
isDarkMode,
34+
fallback,
35+
}: MermaidDiagramProps) {
36+
const id = useId();
37+
const [svg, setSvg] = useState<string | null>(null);
38+
const [hasError, setHasError] = useState(false);
39+
const [hasCopied, setHasCopied] = useState(false);
40+
41+
useEffect(() => {
42+
let isCurrent = true;
43+
44+
const renderId = `mermaid-${id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
45+
46+
setSvg(null);
47+
setHasError(false);
48+
49+
(async () => {
50+
try {
51+
initializeMermaid();
52+
53+
// فقط theme اینجا تنظیم میشه (نه re-init کامل)
54+
mermaid.initialize({
55+
startOnLoad: false,
56+
securityLevel: "strict",
57+
htmlLabels: false,
58+
theme: isDarkMode ? "dark" : "default",
59+
flowchart: { htmlLabels: false },
60+
sequence: { useMaxWidth: true },
61+
});
62+
63+
await mermaid.parse(source);
64+
65+
const { svg } = await mermaid.render(renderId, source);
66+
67+
if (isCurrent) {
68+
setSvg(svg);
69+
}
70+
} catch (error) {
71+
console.error("[mermaid] Failed to render diagram", error);
72+
73+
if (isCurrent) {
74+
setHasError(true);
75+
}
76+
}
77+
})();
78+
79+
return () => {
80+
isCurrent = false;
81+
};
82+
}, [id, isDarkMode, source]);
83+
84+
useEffect(() => {
85+
if (!hasCopied) return;
86+
87+
const timer = setTimeout(() => setHasCopied(false), 2000);
88+
return () => clearTimeout(timer);
89+
}, [hasCopied]);
90+
91+
if (hasError) return fallback;
92+
93+
return (
94+
<div className="mt-4 overflow-hidden rounded-md bg-accent">
95+
<div className="flex items-center justify-between gap-4 px-4 py-2 text-sm font-semibold text-muted-foreground">
96+
<span className="text-xs lowercase">mermaid</span>
97+
98+
<Button
99+
variant="ghost"
100+
size="sm"
101+
className="h-8 w-8 p-0"
102+
type="button"
103+
onClick={async () => {
104+
const ok = await copyToClipboard(source);
105+
if (ok) setHasCopied(true);
106+
}}
107+
aria-label={hasCopied ? "Copied Mermaid source" : "Copy Mermaid source"}
108+
>
109+
<span className="sr-only">Copy Source</span>
110+
{hasCopied ? (
111+
<CheckIcon className="!size-3" />
112+
) : (
113+
<CopyIcon className="!size-3" />
114+
)}
115+
</Button>
116+
</div>
117+
118+
<div className="bg-background/60 p-4 overflow-x-auto">
119+
{svg ? (
120+
// biome-ignore lint/performance/noImgElement: svg is in-memory string
121+
<img
122+
src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`}
123+
alt="Mermaid diagram"
124+
className="mx-auto h-auto max-w-full"
125+
/>
126+
) : (
127+
<div className="h-32 animate-pulse rounded bg-muted" />
128+
)}
129+
</div>
130+
</div>
131+
);
132+
}
133+
134+
export const MermaidDiagram = memo(MermaidDiagramComponent);

0 commit comments

Comments
 (0)