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