返回 DeepSeek-Reasonix
MermaidDiagram.tsx
根目录 / desktop / frontend / src / components / MermaidDiagram.tsx
1 import { memo, type RefObject, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
2 import { createPortal } from "react-dom";
3 import { AlertCircle, Code2, Maximize2, Minimize2, Play, RotateCcw, ZoomIn, ZoomOut } from "lucide-react";
4 import { CopyButton } from "./CopyButton";
5 import { createMermaidPanZoom, type MermaidPanZoomInstance, type MermaidPanZoomOptions } from "./mermaidPanZoom";
6 import { openExternal } from "../lib/bridge";
7 import { markdownImageSource } from "../lib/markdownImage";
8
9 interface MermaidDiagramProps {
10 definition: string;
11 }
12
13 type DiagramState =
14 | { status: "loading" }
15 | { status: "rendered"; svg: string }
16 | { status: "error"; message: string };
17
18 type DiagramTab = "preview" | "code";
19 type MermaidThemeName = "dark" | "light";
20 type MermaidModule = typeof import("mermaid");
21 type MermaidApi = MermaidModule["default"];
22 type MermaidRenderAdapter = (
23 svgId: string,
24 definition: string,
25 theme: MermaidThemeName,
26 signal: AbortSignal,
27 ) => Promise<string>;
28
29 type PanZoomInstance = MermaidPanZoomInstance;
30
31 type PanZoomFactory = (svg: SVGSVGElement, options?: MermaidPanZoomOptions) => PanZoomInstance;
32
33 const MAX_TEXT_SIZE = 100000;
34 const MIN_ZOOM = 0.3;
35 const MAX_ZOOM = 8;
36 const SAFE_LINK_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
37 const XLINK_NS = "http://www.w3.org/1999/xlink";
38
39 let mermaidApi: MermaidApi | null = null;
40 let initPromise: Promise<MermaidApi> | null = null;
41 let renderQueue: Promise<void> = Promise.resolve();
42 let renderAdapterForTest: MermaidRenderAdapter | null = null;
43 let panZoomFactoryForTest: PanZoomFactory | null | undefined;
44
45 export function __setMermaidRenderAdapterForTest(adapter: MermaidRenderAdapter | null): void {
46 renderAdapterForTest = adapter;
47 renderQueue = Promise.resolve();
48 }
49
50 export function __setMermaidPanZoomFactoryForTest(factory: PanZoomFactory | null | undefined): void {
51 panZoomFactoryForTest = factory;
52 }
53
54 function mermaidThemeVariables(theme: MermaidThemeName): Record<string, string | number | boolean> {
55 if (theme === "dark") {
56 return {
57 darkMode: true,
58 background: "#111319",
59 fontSize: "13px",
60 fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
61 primaryColor: "#1f2937",
62 primaryTextColor: "#f4f5f7",
63 primaryBorderColor: "#374151",
64 mainBkg: "#1f2937",
65 nodeBkg: "#1f2937",
66 nodeBorder: "#374151",
67 nodeTextColor: "#f4f5f7",
68 stateBkg: "#1f2937",
69 stateBorder: "#374151",
70 stateLabelColor: "#f4f5f7",
71 labelColor: "#d1d5db",
72 lineColor: "#6b7280",
73 textColor: "#d1d5db",
74 defaultLinkColor: "#6b7280",
75 edgeLabelBackground: "#111319",
76 clusterBkg: "#0f172a",
77 clusterBorder: "#374151",
78 actorBkg: "#1f2937",
79 actorBorder: "#374151",
80 actorTextColor: "#f4f5f7",
81 actorLineColor: "#4b5563",
82 signalColor: "#d1d5db",
83 signalTextColor: "#f4f5f7",
84 labelBoxBkgColor: "#1f2937",
85 labelBoxBorderColor: "#374151",
86 labelTextColor: "#f4f5f7",
87 loopTextColor: "#f4f5f7",
88 noteBkgColor: "#0f172a",
89 noteBorderColor: "#374151",
90 noteTextColor: "#e5e7eb",
91 classText: "#f4f5f7",
92 classBorder: "#374151",
93 classBkg: "#1f2937",
94 secondaryColor: "#111319",
95 tertiaryColor: "#1f2937",
96 };
97 }
98 return {
99 darkMode: false,
100 background: "#ffffff",
101 fontSize: "13px",
102 fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
103 primaryColor: "#f8fafc",
104 primaryTextColor: "#0f172a",
105 primaryBorderColor: "#cbd5e1",
106 mainBkg: "#f8fafc",
107 nodeBkg: "#f8fafc",
108 nodeBorder: "#cbd5e1",
109 nodeTextColor: "#0f172a",
110 stateBkg: "#f8fafc",
111 stateBorder: "#cbd5e1",
112 stateLabelColor: "#0f172a",
113 labelColor: "#475569",
114 lineColor: "#94a3b8",
115 textColor: "#475569",
116 defaultLinkColor: "#94a3b8",
117 edgeLabelBackground: "#ffffff",
118 clusterBkg: "#f1f5f9",
119 clusterBorder: "#e2e8f0",
120 actorBkg: "#f8fafc",
121 actorBorder: "#cbd5e1",
122 actorTextColor: "#0f172a",
123 actorLineColor: "#cbd5e1",
124 signalColor: "#475569",
125 signalTextColor: "#0f172a",
126 labelBoxBkgColor: "#f8fafc",
127 labelBoxBorderColor: "#cbd5e1",
128 labelTextColor: "#0f172a",
129 loopTextColor: "#0f172a",
130 noteBkgColor: "#f1f5f9",
131 noteBorderColor: "#e2e8f0",
132 noteTextColor: "#334155",
133 classText: "#0f172a",
134 classBorder: "#cbd5e1",
135 classBkg: "#f8fafc",
136 secondaryColor: "#f1f5f9",
137 tertiaryColor: "#f8fafc",
138 };
139 }
140
141 function mermaidConfigForTheme(theme: MermaidThemeName) {
142 return {
143 startOnLoad: false,
144 theme: "base" as const,
145 securityLevel: "antiscript" as const,
146 maxTextSize: MAX_TEXT_SIZE,
147 fontFamily: "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
148 flowchart: { htmlLabels: false },
149 themeVariables: mermaidThemeVariables(theme),
150 };
151 }
152
153 async function ensureMermaid(theme: MermaidThemeName): Promise<MermaidApi> {
154 if (mermaidApi) {
155 mermaidApi.initialize(mermaidConfigForTheme(theme));
156 return mermaidApi;
157 }
158 if (!initPromise) {
159 initPromise = import("mermaid").then((mod) => {
160 mermaidApi = mod.default;
161 mermaidApi.initialize(mermaidConfigForTheme(theme));
162 return mermaidApi;
163 }).catch((err) => {
164 initPromise = null;
165 mermaidApi = null;
166 throw err;
167 });
168 }
169 const api = await initPromise;
170 api.initialize(mermaidConfigForTheme(theme));
171 return api;
172 }
173
174 function queuedRender(
175 svgId: string,
176 definition: string,
177 theme: MermaidThemeName,
178 signal: AbortSignal,
179 ): Promise<string> {
180 const promise = renderQueue.then(async () => {
181 if (signal.aborted) throw new DOMException("Aborted", "AbortError");
182 const api = await ensureMermaid(theme);
183 if (signal.aborted) throw new DOMException("Aborted", "AbortError");
184 const { svg } = await api.render(svgId, definition);
185 if (signal.aborted) throw new DOMException("Aborted", "AbortError");
186 return svg;
187 });
188 renderQueue = promise.then(() => {}, () => {});
189 return promise;
190 }
191
192 async function renderMermaid(
193 svgId: string,
194 definition: string,
195 theme: MermaidThemeName,
196 signal: AbortSignal,
197 ): Promise<string> {
198 if (renderAdapterForTest) return renderAdapterForTest(svgId, definition, theme, signal);
199 return queuedRender(svgId, definition, theme, signal);
200 }
201
202 async function ensurePanZoomFactory(): Promise<PanZoomFactory | null> {
203 if (panZoomFactoryForTest !== undefined) return panZoomFactoryForTest;
204 return createMermaidPanZoom;
205 }
206
207 export function isSafeMermaidHref(href: string | null | undefined): boolean {
208 const value = href?.trim();
209 if (!value) return false;
210 if (value.startsWith("#")) return true;
211 try {
212 return SAFE_LINK_PROTOCOLS.has(new URL(value).protocol);
213 } catch {
214 return false;
215 }
216 }
217
218 export function isOpenableMermaidHref(href: string | null | undefined): boolean {
219 const value = href?.trim();
220 return Boolean(value && !value.startsWith("#") && isSafeMermaidHref(value));
221 }
222
223 export function getMermaidAnchorHref(anchor: Element): string | null {
224 return anchor.getAttribute("href")
225 ?? anchor.getAttribute("xlink:href")
226 ?? anchor.getAttributeNS(XLINK_NS, "href");
227 }
228
229 export function sanitizeMermaidSvg(svg: string): string {
230 if (typeof DOMParser === "undefined" || typeof XMLSerializer === "undefined") return svg;
231 const doc = new DOMParser().parseFromString(svg, "image/svg+xml");
232 const svgEl = doc.documentElement;
233 if (svgEl.localName.toLowerCase() !== "svg") return svg;
234
235 const elements = [svgEl, ...Array.from(svgEl.querySelectorAll("*"))];
236 const toRemove: Element[] = [];
237
238 for (const element of elements) {
239 const name = element.localName.toLowerCase();
240 if (name === "script" || name === "iframe" || name === "object" || name === "embed") {
241 toRemove.push(element);
242 continue;
243 }
244 for (const attr of Array.from(element.attributes)) {
245 const attrName = attr.name;
246 const localName = attr.localName.toLowerCase();
247 if (/^on/i.test(attrName)) {
248 element.removeAttribute(attrName);
249 continue;
250 }
251 if (localName === "href") {
252 if (!isSafeMermaidHref(attr.value)) {
253 element.removeAttribute(attrName);
254 continue;
255 }
256 if (name === "image" || name === "feimage") {
257 attr.value = markdownImageSource(attr.value);
258 } else if (name === "use" && !attr.value.trim().startsWith("#")) {
259 // External <use> references can load arbitrary SVG documents. Mermaid
260 // only needs local fragment references, so keep those and drop the rest.
261 element.removeAttribute(attrName);
262 }
263 }
264 }
265 }
266
267 for (const element of toRemove) element.parentNode?.removeChild(element);
268 return new XMLSerializer().serializeToString(svgEl);
269 }
270
271 export function resolveMermaidTheme(): MermaidThemeName {
272 if (typeof document === "undefined") return "dark";
273 const forced = document.documentElement.getAttribute("data-theme");
274 if (forced === "light" || forced === "dark") return forced;
275 return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
276 }
277
278 function useMermaidTheme(): MermaidThemeName {
279 const [theme, setTheme] = useState<MermaidThemeName>(resolveMermaidTheme);
280
281 useEffect(() => {
282 const html = document.documentElement;
283 const updateTheme = () => setTheme(resolveMermaidTheme());
284 const observer = new MutationObserver(updateTheme);
285 observer.observe(html, { attributeFilter: ["data-theme", "data-theme-mode"] });
286
287 const mq = window.matchMedia("(prefers-color-scheme: light)");
288 mq.addEventListener("change", updateTheme);
289 return () => {
290 observer.disconnect();
291 mq.removeEventListener("change", updateTheme);
292 };
293 }, []);
294
295 return theme;
296 }
297
298 function destroyPanZoom(instance: PanZoomInstance | null): void {
299 if (!instance) return;
300 try {
301 instance.destroy();
302 } catch {
303 /* pan/zoom listener cleanup is best-effort across browser and test DOMs. */
304 }
305 }
306
307 function panZoomLayoutReady(container: HTMLElement): boolean {
308 const rect = container.getBoundingClientRect();
309 return Number.isFinite(rect.width) && Number.isFinite(rect.height) && rect.width > 0 && rect.height > 0;
310 }
311
312 export function safelyRunPanZoom(instance: PanZoomInstance | null, action: () => void): boolean {
313 if (!instance) return false;
314 try {
315 action();
316 return true;
317 } catch {
318 // Pan/zoom math reads live layout (getBoundingClientRect); a detached or
319 // zero-sized surface must never reach the global crash surface (#8068).
320 return false;
321 }
322 }
323
324 export function safelySyncPanZoom(instance: PanZoomInstance | null, container: HTMLElement | null): boolean {
325 if (!instance || !container || !panZoomLayoutReady(container)) return false;
326 return safelyRunPanZoom(instance, () => {
327 instance.resize();
328 instance.fit();
329 instance.center();
330 });
331 }
332
333 const MermaidDiagram = memo(function MermaidDiagram({ definition }: MermaidDiagramProps) {
334 const [state, setState] = useState<DiagramState>({ status: "loading" });
335 const [tab, setTab] = useState<DiagramTab>("preview");
336 const [fullscreen, setFullscreen] = useState(false);
337 const [portalTarget, setPortalTarget] = useState<Element | null>(null);
338 const theme = useMermaidTheme();
339 const instanceId = useId().replace(/[^a-zA-Z0-9_-]/g, "-");
340 const svgId = `mermaid-${instanceId}`;
341 const previewRef = useRef<HTMLDivElement>(null);
342 const panZoomRef = useRef<PanZoomInstance | null>(null);
343 const mountedRef = useRef(true);
344 const source = useMemo(() => definition.replace(/\n$/, ""), [definition]);
345
346 useEffect(() => {
347 mountedRef.current = true;
348 const controller = new AbortController();
349 setState({ status: "loading" });
350
351 (async () => {
352 try {
353 const trimmed = source.trim();
354 if (!trimmed) {
355 setState({ status: "error", message: "Empty diagram definition" });
356 return;
357 }
358 const rendered = await renderMermaid(svgId, trimmed, theme, controller.signal);
359 if (controller.signal.aborted || !mountedRef.current) return;
360 setState({ status: "rendered", svg: sanitizeMermaidSvg(rendered) });
361 } catch (err) {
362 if (err instanceof DOMException && err.name === "AbortError") return;
363 if (controller.signal.aborted || !mountedRef.current) return;
364 const message = err instanceof Error ? err.message : String(err);
365 setState({ status: "error", message });
366 }
367 })();
368
369 return () => {
370 controller.abort();
371 mountedRef.current = false;
372 };
373 }, [source, svgId, theme]);
374
375 useLayoutEffect(() => {
376 if (tab !== "preview" || state.status !== "rendered") return;
377 const container = previewRef.current;
378 if (!container) return;
379
380 let cancelled = false;
381 let raf = 0;
382 let syncRaf = 0;
383 let resizeObserver: ResizeObserver | null = null;
384 let instance: PanZoomInstance | null = null;
385
386 const queueSync = () => {
387 if (syncRaf) window.cancelAnimationFrame(syncRaf);
388 let attempts = 0;
389 const sync = () => {
390 syncRaf = 0;
391 if (cancelled || panZoomRef.current !== instance) return;
392 attempts += 1;
393 if (!safelySyncPanZoom(instance, container) && attempts < 4) {
394 syncRaf = window.requestAnimationFrame(sync);
395 }
396 };
397 syncRaf = window.requestAnimationFrame(sync);
398 };
399
400 void ensurePanZoomFactory().then((factory) => {
401 if (cancelled || !factory) return;
402 raf = window.requestAnimationFrame(() => {
403 const svg = container.querySelector("svg") as SVGSVGElement | null;
404 if (cancelled || !svg) return;
405 svg.removeAttribute("width");
406 svg.removeAttribute("height");
407 svg.style.width = "100%";
408 svg.style.height = "100%";
409 svg.style.maxWidth = "none";
410
411 destroyPanZoom(panZoomRef.current);
412 try {
413 instance = factory(svg, {
414 minZoom: MIN_ZOOM,
415 maxZoom: MAX_ZOOM,
416 zoomScaleSensitivity: 0.3,
417 });
418 panZoomRef.current = instance;
419 queueSync();
420 if (typeof ResizeObserver !== "undefined") {
421 resizeObserver = new ResizeObserver(queueSync);
422 resizeObserver.observe(container);
423 }
424 } catch {
425 instance = null;
426 panZoomRef.current = null;
427 }
428 });
429 });
430
431 return () => {
432 cancelled = true;
433 if (raf) window.cancelAnimationFrame(raf);
434 if (syncRaf) window.cancelAnimationFrame(syncRaf);
435 resizeObserver?.disconnect();
436 if (panZoomRef.current === instance) panZoomRef.current = null;
437 destroyPanZoom(instance);
438 };
439 }, [state, tab, fullscreen]);
440
441 useEffect(() => {
442 if (!fullscreen) return;
443 const onKeyDown = (event: KeyboardEvent) => {
444 if (event.key === "Escape") {
445 setFullscreen(false);
446 setPortalTarget(null);
447 }
448 };
449 document.addEventListener("keydown", onKeyDown);
450 return () => document.removeEventListener("keydown", onKeyDown);
451 }, [fullscreen]);
452
453 useEffect(() => {
454 if (!fullscreen || !panZoomRef.current) return;
455 const raf = window.requestAnimationFrame(() => {
456 safelySyncPanZoom(panZoomRef.current, previewRef.current);
457 });
458 return () => window.cancelAnimationFrame(raf);
459 }, [fullscreen]);
460
461 const toggleFullscreen = useCallback(() => {
462 setFullscreen((current) => {
463 const next = !current;
464 setPortalTarget(next ? document.querySelector(".chat-pane") ?? document.body : null);
465 return next;
466 });
467 }, []);
468
469 const zoomIn = useCallback(() => {
470 const instance = panZoomRef.current;
471 safelyRunPanZoom(instance, () => instance?.zoomIn());
472 }, []);
473
474 const zoomOut = useCallback(() => {
475 const instance = panZoomRef.current;
476 safelyRunPanZoom(instance, () => instance?.zoomOut());
477 }, []);
478
479 const resetZoom = useCallback(() => {
480 const instance = panZoomRef.current;
481 if (!instance) return;
482 safelyRunPanZoom(instance, () => instance.reset());
483 safelySyncPanZoom(instance, previewRef.current);
484 }, []);
485
486 const body = (
487 <MermaidBody
488 state={state}
489 source={source}
490 tab={tab}
491 previewRef={previewRef}
492 />
493 );
494
495 const content = (
496 <div data-app-overlay={fullscreen ? "" : undefined} className={[
497 "mermaid-diagram",
498 state.status === "error" ? "mermaid-diagram--error" : "",
499 fullscreen ? "mermaid-diagram--fullscreen" : "",
500 ].filter(Boolean).join(" ")}>
501 <MermaidToolbar
502 tab={tab}
503 source={source}
504 fullscreen={fullscreen}
505 onTabChange={setTab}
506 onFullscreenToggle={toggleFullscreen}
507 onZoomIn={zoomIn}
508 onZoomOut={zoomOut}
509 onResetZoom={resetZoom}
510 />
511 {body}
512 </div>
513 );
514
515 if (fullscreen && portalTarget) {
516 return (
517 <>
518 <div className="mermaid-diagram mermaid-diagram--placeholder" aria-hidden="true" />
519 {createPortal(content, portalTarget)}
520 </>
521 );
522 }
523
524 return content;
525 });
526
527 function MermaidToolbar({
528 tab,
529 source,
530 fullscreen,
531 onTabChange,
532 onFullscreenToggle,
533 onZoomIn,
534 onZoomOut,
535 onResetZoom,
536 }: {
537 tab: DiagramTab;
538 source: string;
539 fullscreen: boolean;
540 onTabChange: (tab: DiagramTab) => void;
541 onFullscreenToggle: () => void;
542 onZoomIn: () => void;
543 onZoomOut: () => void;
544 onResetZoom: () => void;
545 }) {
546 return (
547 <div className="mermaid-diagram__toolbar">
548 <div className="mermaid-diagram__title" aria-hidden="true">Mermaid</div>
549 <div className="mermaid-diagram__actions">
550 <button
551 type="button"
552 className={`mermaid-diagram__icon-btn${tab === "preview" ? " mermaid-diagram__icon-btn--active" : ""}`}
553 onClick={() => onTabChange("preview")}
554 aria-label="Preview diagram"
555 title="Preview diagram"
556 >
557 <Play size={14} />
558 </button>
559 <button
560 type="button"
561 className={`mermaid-diagram__icon-btn${tab === "code" ? " mermaid-diagram__icon-btn--active" : ""}`}
562 onClick={() => onTabChange("code")}
563 aria-label="Show diagram source"
564 title="Show diagram source"
565 >
566 <Code2 size={14} />
567 </button>
568 <button
569 type="button"
570 className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
571 onClick={onZoomOut}
572 aria-label="Zoom out"
573 title="Zoom out"
574 >
575 <ZoomOut size={14} />
576 </button>
577 <button
578 type="button"
579 className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
580 onClick={onZoomIn}
581 aria-label="Zoom in"
582 title="Zoom in"
583 >
584 <ZoomIn size={14} />
585 </button>
586 <button
587 type="button"
588 className="mermaid-diagram__icon-btn mermaid-diagram__zoom-action"
589 onClick={onResetZoom}
590 aria-label="Reset zoom"
591 title="Reset zoom"
592 >
593 <RotateCcw size={14} />
594 </button>
595 <CopyButton text={source} className="mermaid-diagram__copy-btn" showInlineLabel={false} />
596 <button
597 type="button"
598 className="mermaid-diagram__icon-btn"
599 onClick={onFullscreenToggle}
600 aria-label={fullscreen ? "Exit fullscreen" : "Open fullscreen"}
601 title={fullscreen ? "Exit fullscreen" : "Open fullscreen"}
602 >
603 {fullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
604 </button>
605 </div>
606 </div>
607 );
608 }
609
610 function MermaidBody({
611 state,
612 source,
613 tab,
614 previewRef,
615 }: {
616 state: DiagramState;
617 source: string;
618 tab: DiagramTab;
619 previewRef: RefObject<HTMLDivElement | null>;
620 }) {
621 if (tab === "code") {
622 return (
623 <pre className="code hljs mermaid-diagram__code" data-lang="mermaid">
624 <code>{source}</code>
625 </pre>
626 );
627 }
628
629 if (state.status === "loading") {
630 return (
631 <div className="mermaid-diagram__loading">
632 <span className="mermaid-diagram__spinner" />
633 <span>Rendering diagram...</span>
634 </div>
635 );
636 }
637
638 if (state.status === "error") {
639 return (
640 <div className="mermaid-diagram__error">
641 <div className="mermaid-diagram__error-bar">
642 <AlertCircle size={14} className="mermaid-diagram__error-icon" />
643 <span>Diagram syntax error</span>
644 </div>
645 <pre className="code hljs mermaid-diagram__error-source" data-lang="mermaid">
646 <code>{source}</code>
647 </pre>
648 <details className="mermaid-diagram__error-details">
649 <summary>Error details</summary>
650 <pre className="mermaid-diagram__error-detail-text">{state.message}</pre>
651 </details>
652 </div>
653 );
654 }
655
656 return <SvgPreview refEl={previewRef} svg={state.svg} />;
657 }
658
659 function SvgPreview({ refEl, svg }: { refEl: RefObject<HTMLDivElement | null>; svg: string }) {
660 const openAnchor = useCallback((event: React.MouseEvent) => {
661 const target = event.target;
662 if (!(target instanceof Element)) return;
663 const anchor = target.closest("a");
664 const href = anchor ? getMermaidAnchorHref(anchor) : null;
665 if (!href) return;
666 event.preventDefault();
667 if (isOpenableMermaidHref(href)) openExternal(href);
668 }, []);
669
670 const preventMiddleButtonNavigation = useCallback((event: React.MouseEvent) => {
671 if (event.button !== 1) return;
672 const target = event.target;
673 if (target instanceof Element && target.closest("a")) event.preventDefault();
674 }, []);
675
676 return (
677 <div className="mermaid-diagram__preview-wrap">
678 <div
679 className="mermaid-diagram__preview"
680 ref={refEl}
681 dangerouslySetInnerHTML={{ __html: svg }}
682 onClick={openAnchor}
683 onAuxClick={openAnchor}
684 onMouseDown={preventMiddleButtonNavigation}
685 />
686 </div>
687 );
688 }
689
690 export default MermaidDiagram;
691
691 lines Plain Text