diff --git a/frontend/features/chat/components/sections/chat-artifact-svg-preview.tsx b/frontend/features/chat/components/sections/chat-artifact-svg-preview.tsx new file mode 100644 index 00000000..6661f858 --- /dev/null +++ b/frontend/features/chat/components/sections/chat-artifact-svg-preview.tsx @@ -0,0 +1,107 @@ +"use client"; + +import * as React from "react"; + +import type { HTMLVisualThemeSnapshot } from "@/shared/lib/html-visual-theme"; + +type ChatArtifactSVGPreviewProps = { + complete: boolean; + invalidMessage: string; + source: string; + theme: HTMLVisualThemeSnapshot; + title: string; +}; + +type SVGPreview = { + source: string; + theme: HTMLVisualThemeSnapshot; +} & ( + | { status: "invalid" } + | { + status: "ready"; + url: string; + } +); + +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + +function applyPreviewTheme(source: string, theme: HTMLVisualThemeSnapshot): string | null { + const parser = new DOMParser(); + const xmlDocument = parser.parseFromString(source, "image/svg+xml"); + let root: Element = xmlDocument.documentElement; + if (root.localName !== "svg") return null; + + if (root.namespaceURI === null) { + const htmlDocument = parser.parseFromString(source, "text/html"); + const [htmlRoot] = Array.from(htmlDocument.body.children); + if (htmlDocument.body.children.length !== 1 || htmlRoot?.localName !== "svg") return null; + root = htmlRoot; + } else if (root.namespaceURI !== SVG_NAMESPACE) { + return null; + } + + const style = root.ownerDocument.createElementNS(SVG_NAMESPACE, "style"); + const variables = theme.variables.map(([name, value]) => `${name}:${value}`).join(";"); + style.setAttribute("data-deeix-artifact-theme", ""); + style.textContent = `:root{color-scheme:${theme.colorScheme};${variables}}`; + root.prepend(style); + + return new XMLSerializer().serializeToString(root); +} + +export function ChatArtifactSVGPreview({ + complete, + invalidMessage, + source, + theme, + title, +}: ChatArtifactSVGPreviewProps) { + const [preview, setPreview] = React.useState(null); + const [failedURL, setFailedURL] = React.useState(null); + + React.useEffect(() => { + setFailedURL(null); + if (!complete) { + setPreview(null); + return; + } + + const previewSource = applyPreviewTheme(source, theme); + if (!previewSource) { + setPreview({ source, status: "invalid", theme }); + return; + } + + // SVG image documents do not expose scripts, event handlers, or links as active page DOM. + const url = URL.createObjectURL( + new Blob([previewSource], { type: "image/svg+xml;charset=utf-8" }), + ); + setPreview({ source, status: "ready", theme, url }); + + return () => URL.revokeObjectURL(url); + }, [complete, source, theme]); + + const currentPreview = + complete && preview?.source === source && preview.theme === theme ? preview : null; + const previewURL = currentPreview?.status === "ready" ? currentPreview.url : null; + const failed = + currentPreview?.status === "invalid" || Boolean(previewURL && failedURL === previewURL); + + return ( +
+ {previewURL && !failed ? ( + {title} setFailedURL(previewURL)} + /> + ) : complete && failed ? ( +

{invalidMessage}

+ ) : null} +
+ ); +} diff --git a/frontend/features/chat/components/sections/chat-artifact.tsx b/frontend/features/chat/components/sections/chat-artifact.tsx index 6bb9a2f0..60da4537 100644 --- a/frontend/features/chat/components/sections/chat-artifact.tsx +++ b/frontend/features/chat/components/sections/chat-artifact.tsx @@ -15,10 +15,10 @@ import { } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { ChatArtifactSVGPreview } from "@/features/chat/components/sections/chat-artifact-svg-preview"; import { buildArtifactPreviewDocument, type ChatArtifact, - downloadArtifactHTML, resolveArtifactDownloadName, } from "@/features/chat/model/chat-artifacts"; import { @@ -29,6 +29,7 @@ import { useFontSizePreference } from "@/features/settings/utils/font-size"; import { cn } from "@/lib/utils"; import { CopyActionButton } from "@/shared/components/copy-action"; import { useTheme } from "@/shared/components/theme-provider"; +import { downloadBlob } from "@/shared/lib/export-download"; import { captureHTMLVisualThemeSnapshot, type HTMLVisualThemeSnapshot, @@ -165,8 +166,18 @@ function ChatArtifactPanel({ setPreviewTheme(captureHTMLVisualThemeSnapshot(resolvedTheme)); }, [chatFont, chatFontWeight, fontSize, preset, resolvedTheme]); - const previewHTML = React.useMemo( - () => buildArtifactPreviewDocument(artifact.kind, artifact.code, previewTheme), + const artifactPreview = React.useMemo( + () => + artifact.kind === "svg" + ? ({ mode: "svg" } as const) + : ({ + documentHTML: buildArtifactPreviewDocument( + artifact.kind, + artifact.code, + previewTheme, + ), + mode: "frame", + } as const), [artifact.code, artifact.kind, previewTheme], ); const canPreview = artifact.code.trim().length > 0; @@ -177,8 +188,18 @@ function ChatArtifactPanel({ const handleDownload = React.useCallback(() => { if (!canPreview) return; - downloadArtifactHTML(resolveArtifactDownloadName(artifact.kind), previewHTML); - }, [artifact.kind, canPreview, previewHTML]); + if (artifactPreview.mode === "svg") { + downloadBlob( + new Blob([artifact.code], { type: "image/svg+xml;charset=utf-8" }), + resolveArtifactDownloadName(artifact.kind), + ); + return; + } + downloadBlob( + new Blob([artifactPreview.documentHTML], { type: "text/html;charset=utf-8" }), + resolveArtifactDownloadName(artifact.kind), + ); + }, [artifact.kind, artifact.code, artifactPreview, canPreview]); return (