| 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 |