nexus/ui/src/components/DiagramPreview.tsx

65 lines
2.1 KiB
TypeScript

// Security: SVG content rendered here is server-sanitized by DOMPurify (DIAG-05).
// Pattern mirrors MarkdownBody.tsx mermaid rendering using dangerouslySetInnerHTML.
import type { DiagramBundle } from "@/types/content-bundles";
import { Button } from "@/components/ui/button";
interface DiagramPreviewProps {
bundle: DiagramBundle | null;
className?: string;
}
function base64ToBlob(base64: string, mimeType: string): Blob {
const byteString = atob(base64);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: mimeType });
}
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export function DiagramPreview({ bundle, className }: DiagramPreviewProps) {
if (!bundle) return null;
const svgHtml = atob(bundle.svgBase64);
function handleDownloadSvg() {
if (!bundle) return;
const blob = base64ToBlob(bundle.svgBase64, "image/svg+xml");
triggerDownload(blob, "diagram.svg");
}
function handleDownloadPng() {
if (!bundle) return;
const blob = base64ToBlob(bundle.pngBase64, "image/png");
triggerDownload(blob, "diagram.png");
}
return (
<div className={className}>
{/* SVG is pre-sanitized by DOMPurify on the server (diagram-renderer.ts, DIAG-05).
This mirrors MarkdownBody.tsx mermaid SVG rendering. */}
{/* eslint-disable-next-line react/no-danger */}
<div className="paperclip-mermaid overflow-x-auto" dangerouslySetInnerHTML={{ __html: svgHtml }} />
<div className="flex gap-2 mt-3">
<Button type="button" variant="ghost" size="sm" onClick={handleDownloadSvg}>
Download SVG
</Button>
<Button type="button" variant="ghost" size="sm" onClick={handleDownloadPng}>
Download PNG
</Button>
</div>
</div>
);
}