"use client"; import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { vs } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { vscDarkPlus } from "react-syntax-highlighter/dist/cjs/styles/prism"; import { useTheme } from "@/hooks/useTheme"; import { useI18n } from "@/hooks/useI18n"; import { copyText } from "@/lib/clipboard"; interface MermaidBlockProps { code: string; isStreaming?: boolean; defaultPreview?: boolean; } const ZOOM_STEP = 0.25; const ZOOM_MIN = 0.5; const ZOOM_MAX = 3; type RenderState = | { key: string; status: "loading" } | { key: string; status: "error" } | { key: string; status: "ready"; svg: string }; export function MermaidBlock({ code, isStreaming, defaultPreview = false }: MermaidBlockProps) { const { isDark } = useTheme(); const { t } = useI18n(); const [showPreview, setShowPreview] = useState(defaultPreview); const [renderState, setRenderState] = useState(null); const [zoomOpen, setZoomOpen] = useState(false); const currentKey = `${isDark ? "dark" : "light"}\n${code}`; const previewVisible = showPreview && !isStreaming; useEffect(() => { if (!previewVisible) return; let cancelled = false; setRenderState({ key: currentKey, status: "loading" }); const render = async () => { const { default: mermaid } = await import("mermaid"); mermaid.initialize({ startOnLoad: false, securityLevel: "strict", suppressErrorRendering: true, theme: isDark ? "dark" : "default", }); const parsed = await mermaid.parse(code, { suppressErrors: true }); if (!parsed) throw new Error("Invalid Mermaid diagram"); const id = typeof crypto !== "undefined" && "randomUUID" in crypto ? `mermaid-${crypto.randomUUID()}` : `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`; const result = await mermaid.render(id, code); if (!cancelled) { setRenderState({ key: currentKey, status: "ready", svg: result.svg }); } }; render().catch(() => { if (!cancelled) setRenderState({ key: currentKey, status: "error" }); }); return () => { cancelled = true; }; }, [code, currentKey, isDark, previewVisible]); const previewButton = useMemo(() => ( ), [isStreaming, previewVisible, t]); if (!previewVisible) { return ; } const body = renderState?.key === currentKey && renderState.status === "error" ? (
{t("i18n.invalidMermaid")}
) : renderState?.key !== currentKey || renderState.status !== "ready" ? (
) : ( <> {!zoomOpen && ( {Math.round(zoom * 100)}%
{ if (event.target === event.currentTarget) onClose(); }} >
); } interface CodeBlockProps { code: string; lang: string; headerAction?: ReactNode; isStreaming?: boolean; } /** * Syntax-highlighted code block with copy button. * Used as the "source" view for mermaid blocks and for all non-mermaid code fences. * * Memoized: parent markdown re-renders (e.g. streaming updates elsewhere in * the message list) must not re-run Prism tokenization on unchanged code. * While the owning message is still streaming, the block renders as plain * monospace text — highlighting a growing block re-tokenizes all of it on * every chunk, which is the single most expensive part of streamed rendering. */ export const CodeBlock = memo(function CodeBlock({ code, lang, headerAction, isStreaming }: CodeBlockProps) { const { isDark } = useTheme(); const { t } = useI18n(); const [copied, setCopied] = useState(false); const copy = () => { copyText(code).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500); }); }; return (
{lang || "text"}
{headerAction}
{isStreaming ? (
          {code}
        
) : ( {code} )}
); });