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