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 - z A - Z 0 - 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