返回 DeepSeek-Reasonix
MarkdownSvgBlock.tsx
根目录 / desktop / frontend / src / components / MarkdownSvgBlock.tsx
1 // An SVG code block that renders as a picture.
2 //
3 // The model writes SVG as a fence far more often than as a file, so the block
4 // opens as a preview and keeps its source one click away. The markup is never
5 // injected into the app DOM: the host returns sanitized bytes, and those bytes
6 // become an <img> source, which is what keeps a model-authored script or
7 // event handler inert.
8 //
9 // A block that cannot be previewed — too large, too deeply nested, malformed,
10 // or not a single SVG root at all — simply stays source. Nothing here rewrites
11 // the answer text.
12
13 import { memo, useEffect, useMemo, useRef, useState } from "react";
14 import "./MarkdownSvgBlock.css";
15 import { Code2, Play } from "lucide-react";
16 import { CodeViewer } from "./CodeViewer";
17 import { CopyButton } from "./CopyButton";
18 import { app } from "../lib/bridge";
19 import { t } from "../lib/i18n";
20 import { svgAspectRatio } from "../lib/svgDocument";
21 import type { MarkdownSVGView } from "../generated/desktopContract.generated";
22
23 const PREVIEW_MAX_HEIGHT = "min(60vh, 32rem)";
24 const CACHE_BUDGET_BYTES = 4 << 20;
25
26 const cache = new Map<string, MarkdownSVGView>();
27 let cacheBytes = 0;
28
29 function cachedSanitize(value: string): MarkdownSVGView | undefined {
30 return cache.get(value);
31 }
32
33 function remember(value: string, view: MarkdownSVGView): void {
34 if (!cache.has(value)) {
35 cacheBytes += value.length * 2;
36 cache.set(value, view);
37 while (cacheBytes > CACHE_BUDGET_BYTES && cache.size > 1) {
38 const oldest = cache.keys().next().value as string;
39 cacheBytes -= oldest.length * 2;
40 cache.delete(oldest);
41 }
42 }
43 }
44
45 function imageSource(svg: string): string {
46 if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") {
47 return URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
48 }
49 return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
50 }
51
52 export const MarkdownSvgBlock = memo(function MarkdownSvgBlock({ value }: { value: string }) {
53 const [view, setView] = useState<MarkdownSVGView | undefined>(() => cachedSanitize(value));
54 const [mode, setMode] = useState<"preview" | "source">(() => cachedSanitize(value)?.ok ? "preview" : "source");
55 // An explicit choice belongs to the reader: a later render must not undo it.
56 const chosen = useRef(false);
57 const [src, setSrc] = useState<string | null>(null);
58
59 useEffect(() => {
60 let live = true;
61 const hit = cachedSanitize(value);
62 if (hit) { setView(hit); return; }
63 void app.SanitizeMarkdownSVG(value).then(next => {
64 if (!live) return;
65 remember(value, next);
66 setView(next);
67 }).catch(() => {
68 if (live) setView({ ok: false, reason: "invalid" });
69 });
70 return () => { live = false; };
71 }, [value]);
72
73 useEffect(() => {
74 if (!view?.ok || !view.svg) { setSrc(null); return; }
75 const next = imageSource(view.svg);
76 setSrc(next);
77 // Release the picture this block is replacing, and the one it leaves behind
78 // on unmount. A superseded sanitize result never reaches the DOM because
79 // this effect replaces the source in the same commit.
80 return () => { if (next.startsWith("blob:")) URL.revokeObjectURL(next); };
81 }, [view]);
82
83 useEffect(() => {
84 if (chosen.current || !view) return;
85 setMode(view.ok ? "preview" : "source");
86 }, [view]);
87
88 const select = (next: "preview" | "source") => { chosen.current = true; setMode(next); };
89 const ratio = useMemo(() => (view?.ok && view.svg ? svgAspectRatio(view.svg) : undefined), [view]);
90 const previewable = Boolean(view?.ok && src);
91
92 return <div className="md-svg" data-svg-mode={previewable ? mode : "source"}>
93 <div className="md-svg__toolbar">
94 <div className="md-svg__title" aria-hidden="true">SVG</div>
95 <div className="md-svg__actions">
96 <button type="button" className={`md-svg__icon-btn${mode === "preview" ? " md-svg__icon-btn--active" : ""}`}
97 disabled={!previewable} onClick={() => select("preview")} aria-label={t("chat.svgPreview")} title={t("chat.svgPreview")}><Play size={14} /></button>
98 <button type="button" className={`md-svg__icon-btn${mode === "source" ? " md-svg__icon-btn--active" : ""}`}
99 onClick={() => select("source")} aria-label={t("chat.svgSource")} title={t("chat.svgSource")}><Code2 size={14} /></button>
100 <CopyButton getText={() => value} label={t("chat.svgCopy")} showInlineLabel={false} className="md-svg__copy" />
101 </div>
102 </div>
103 {!previewable || mode === "source"
104 ? <>
105 <CodeViewer value={value} copyValue={value} language="svg" scrollMode="bounded" maxHeight={PREVIEW_MAX_HEIGHT} />
106 {view && !view.ok && <p className="md-svg__note" role="status">{t("chat.svgPreviewBlocked")}</p>}
107 </>
108 : <div className="md-svg__preview" style={{ aspectRatio: ratio }}>
109 <img src={src!} alt="SVG preview" referrerPolicy="no-referrer" style={{ maxHeight: PREVIEW_MAX_HEIGHT }} />
110 </div>}
111 </div>;
112 });
113
114 export default MarkdownSvgBlock;
115
115 lines Plain Text