cc-haha/desktop/src/components/chat/MermaidRenderer.tsx
程序员阿江(Relakkes) 66a0d45627 fix: hold Mermaid rendering until streams finish #636
Streaming assistant output can expose incomplete Mermaid fences before the
model has closed the diagram syntax. Keep those blocks in a generating state
until the assistant message finalizes, then render the completed diagram. Also
preserve Mermaid label content in sanitized SVG output so preview and expanded
views keep node text visible.

Constraint: marked treats unclosed fenced code as a code block during streaming
Rejected: Keep retrying Mermaid render on partial syntax | users still see parse errors while valid output is incomplete
Confidence: high
Scope-risk: narrow
Directive: Do not render parser-backed diagram components from streaming markdown without an explicit pending state
Tested: bun run check:desktop
Not-tested: live provider smoke after Codex host Crashpad renderer/utility dumps were observed
2026-05-31 17:42:09 +08:00

444 lines
16 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react'
import DOMPurify from 'dompurify'
import mermaid from 'mermaid'
import { Modal } from '../shared/Modal'
import { CopyButton } from '../shared/CopyButton'
import { useUIStore } from '../../stores/uiStore'
import type { ThemeMode } from '../../types/settings'
type Props = {
code: string
}
const MIN_PREVIEW_ZOOM = 0.5
const MAX_PREVIEW_ZOOM = 3
const PREVIEW_ZOOM_STEP = 0.25
type SvgMetrics = {
width: number
height: number
}
type DragState = {
pointerId: number
startX: number
startY: number
scrollLeft: number
scrollTop: number
}
function rgbToHex(color: string, fallback: string) {
const trimmed = color.trim()
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed
const shortHex = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/i.exec(trimmed)
if (shortHex) {
return `#${shortHex[1]}${shortHex[1]}${shortHex[2]}${shortHex[2]}${shortHex[3]}${shortHex[3]}`
}
const rgb = /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i.exec(trimmed)
if (!rgb) return fallback
return [rgb[1], rgb[2], rgb[3]]
.map((value) => {
const channel = Math.max(0, Math.min(255, Math.round(Number.parseFloat(value ?? '0'))))
return channel.toString(16).padStart(2, '0')
})
.join('')
.replace(/^/, '#')
}
function resolveThemeColor(token: string, fallback: string) {
if (typeof document === 'undefined') return fallback
const probe = document.createElement('span')
probe.style.color = `var(${token})`
probe.style.position = 'absolute'
probe.style.pointerEvents = 'none'
probe.style.opacity = '0'
document.body.appendChild(probe)
const resolved = getComputedStyle(probe).color
probe.remove()
return rgbToHex(resolved, fallback)
}
function initMermaid(theme: ThemeMode) {
const isDark = theme === 'dark'
const textColor = resolveThemeColor('--color-text-primary', isDark ? '#E5E2E1' : '#1B1C1A')
const mutedTextColor = resolveThemeColor('--color-text-secondary', isDark ? '#B7AAA5' : '#61514B')
const surfaceColor = resolveThemeColor('--color-surface-container-lowest', isDark ? '#0E0E0E' : '#FFFFFF')
const nodeColor = resolveThemeColor('--color-surface-container-low', isDark ? '#1C1B1B' : '#F4EFEA')
const accentColor = resolveThemeColor('--color-primary', isDark ? '#FFB59F' : '#8F482F')
const lineColor = resolveThemeColor('--color-outline', isDark ? '#8D7F7A' : '#87736D')
mermaid.initialize({
startOnLoad: false,
theme: 'base',
flowchart: {
htmlLabels: false,
},
themeVariables: {
darkMode: isDark,
background: surfaceColor,
mainBkg: nodeColor,
primaryColor: nodeColor,
primaryTextColor: textColor,
primaryBorderColor: lineColor,
secondaryColor: surfaceColor,
tertiaryColor: surfaceColor,
textColor,
lineColor,
edgeLabelBackground: surfaceColor,
clusterBkg: surfaceColor,
clusterBorder: lineColor,
titleColor: textColor,
labelTextColor: textColor,
nodeTextColor: textColor,
noteTextColor: textColor,
noteBkgColor: surfaceColor,
noteBorderColor: lineColor,
actorTextColor: textColor,
actorLineColor: lineColor,
signalTextColor: textColor,
signalColor: mutedTextColor,
activationBkgColor: nodeColor,
activationBorderColor: accentColor,
},
securityLevel: 'strict',
suppressErrorRendering: true,
fontFamily: 'var(--font-sans)',
})
}
let mermaidIdCounter = 0
function sanitizeMermaidSvg(svg: string) {
return DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true, html: true },
ADD_TAGS: ['foreignObject'],
})
}
function clampZoom(value: number) {
return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value))
}
function getPointerPosition(
event: Pick<React.PointerEvent<HTMLDivElement>, 'clientX' | 'clientY' | 'pageX' | 'pageY'>,
) {
const x = Number.isFinite(event.clientX) ? event.clientX : event.pageX
const y = Number.isFinite(event.clientY) ? event.clientY : event.pageY
return {
x: Number.isFinite(x) ? x : 0,
y: Number.isFinite(y) ? y : 0,
}
}
function parseSvgMetrics(svg: string): SvgMetrics | null {
const viewBoxMatch = svg.match(/viewBox="([^"]+)"/i)
if (viewBoxMatch) {
const viewBox = viewBoxMatch[1]
if (!viewBox) return null
const values = viewBox
.split(/[\s,]+/)
.map((part) => Number.parseFloat(part))
if (values.length === 4 && values.every((value) => Number.isFinite(value))) {
const [, , width, height] = values
if (width !== undefined && height !== undefined) {
return { width, height }
}
}
}
const widthMatch = svg.match(/\bwidth="([0-9.]+)(?:px)?"/i)
const heightMatch = svg.match(/\bheight="([0-9.]+)(?:px)?"/i)
if (widthMatch && heightMatch) {
const widthValue = widthMatch[1]
const heightValue = heightMatch[1]
if (!widthValue || !heightValue) return null
const width = Number.parseFloat(widthValue)
const height = Number.parseFloat(heightValue)
if (Number.isFinite(width) && Number.isFinite(height)) {
return { width, height }
}
}
return null
}
export function MermaidRenderer({ code }: Props) {
const theme = useUIStore((state) => state.theme)
const containerRef = useRef<HTMLDivElement>(null)
const previewViewportRef = useRef<HTMLDivElement>(null)
const previewContentRef = useRef<HTMLDivElement>(null)
const dragStateRef = useRef<DragState | null>(null)
const [svg, setSvg] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewZoom, setPreviewZoom] = useState(1)
const [isDraggingPreview, setIsDraggingPreview] = useState(false)
const svgMetrics = svg ? parseSvgMetrics(svg) : null
useEffect(() => {
let cancelled = false
initMermaid(theme)
const id = `mermaid-${++mermaidIdCounter}`
mermaid.render(id, code).then(
({ svg: renderedSvg }) => {
if (!cancelled) {
setSvg(renderedSvg)
setError(null)
}
},
(err) => {
if (!cancelled) {
setError(String(err?.message || err))
setSvg(null)
}
},
)
return () => { cancelled = true }
}, [code, theme])
const handlePreview = useCallback(() => setPreviewOpen(true), [])
const handlePreviewClose = useCallback(() => setPreviewOpen(false), [])
const zoomIn = useCallback(
() => setPreviewZoom((value) => clampZoom(value + PREVIEW_ZOOM_STEP)),
[],
)
const zoomOut = useCallback(
() => setPreviewZoom((value) => clampZoom(value - PREVIEW_ZOOM_STEP)),
[],
)
const resetZoom = useCallback(() => setPreviewZoom(1), [])
useEffect(() => {
if (!previewOpen) {
setPreviewZoom(1)
setIsDraggingPreview(false)
dragStateRef.current = null
}
}, [previewOpen, svg])
const stopDraggingPreview = useCallback(() => {
const viewport = previewViewportRef.current
const dragState = dragStateRef.current
if (viewport && dragState) {
try {
viewport.releasePointerCapture(dragState.pointerId)
} catch {
// Ignore capture release failures from synthetic test events.
}
}
dragStateRef.current = null
setIsDraggingPreview(false)
}, [])
useEffect(() => stopDraggingPreview, [stopDraggingPreview])
useEffect(() => {
if (!previewOpen || !previewContentRef.current) return
const renderedSvg = previewContentRef.current.querySelector('svg')
if (!renderedSvg) return
renderedSvg.setAttribute('width', '100%')
renderedSvg.setAttribute('height', '100%')
renderedSvg.style.width = '100%'
renderedSvg.style.height = '100%'
renderedSvg.style.display = 'block'
}, [previewOpen, svg, previewZoom])
const handlePreviewWheel = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
const direction = event.deltaY < 0 ? PREVIEW_ZOOM_STEP : -PREVIEW_ZOOM_STEP
setPreviewZoom((value) => clampZoom(value + direction))
}, [])
const handlePreviewPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === 'mouse' && event.button !== 0) return
const viewport = previewViewportRef.current
if (!viewport) return
const { x, y } = getPointerPosition(event)
dragStateRef.current = {
pointerId: event.pointerId,
startX: x,
startY: y,
scrollLeft: viewport.scrollLeft,
scrollTop: viewport.scrollTop,
}
setIsDraggingPreview(true)
viewport.setPointerCapture(event.pointerId)
}, [])
const handlePreviewPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const viewport = previewViewportRef.current
const dragState = dragStateRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) return
event.preventDefault()
const { x, y } = getPointerPosition(event)
viewport.scrollLeft = dragState.scrollLeft - (x - dragState.startX)
viewport.scrollTop = dragState.scrollTop - (y - dragState.startY)
}, [])
const handlePreviewPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current
if (!dragState || dragState.pointerId !== event.pointerId) return
stopDraggingPreview()
}, [stopDraggingPreview])
const previewCanvasStyle = svgMetrics
? {
width: `${svgMetrics.width * previewZoom}px`,
height: `${svgMetrics.height * previewZoom}px`,
}
: undefined
if (error) {
return (
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-error)]/30">
<div className="flex items-center gap-2 border-b border-[var(--color-error)]/20 bg-[var(--color-error-container)] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">error</span>
Mermaid Error
</div>
<div className="bg-[var(--color-error-container)]/30 px-3 py-2 font-[var(--font-mono)] text-[11px] text-[var(--color-error)]">
{error}
</div>
</div>
)
}
if (!svg) {
return (
<div className="my-4 flex items-center justify-center rounded-[var(--radius-lg)] border border-[var(--color-border)]/50 bg-[var(--color-surface-container-low)] py-8">
<div className="flex items-center gap-2 text-[11px] text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
Rendering diagram...
</div>
</div>
)
}
return (
<>
<div className="my-4 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-outline-variant)]/50 bg-[var(--color-surface-container-low)]">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container)] px-3 py-1.5 text-[11px] text-[var(--color-text-tertiary)]">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[14px]">account_tree</span>
<span className="font-semibold uppercase tracking-[0.14em]">Mermaid</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={handlePreview}
className="flex items-center gap-1 rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[12px]">fullscreen</span>
Preview
</button>
<CopyButton
text={code}
className="rounded-md border border-[var(--color-outline-variant)]/40 bg-[var(--color-surface-container-lowest)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]"
/>
</div>
</div>
{/* Diagram */}
<div
ref={containerRef}
data-testid="mermaid-diagram-surface"
className="flex items-center justify-center overflow-auto bg-[var(--color-surface-container-lowest)] p-4 cursor-pointer"
style={{ maxHeight: 400 }}
onClick={handlePreview}
dangerouslySetInnerHTML={{ __html: sanitizeMermaidSvg(svg) }}
/>
</div>
{/* Fullscreen preview modal */}
<Modal open={previewOpen} onClose={handlePreviewClose} width={1100}>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
<span className="material-symbols-outlined text-[18px]">account_tree</span>
Mermaid Diagram
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1 py-1">
<button
type="button"
onClick={zoomOut}
aria-label="Zoom out"
className="flex h-8 w-8 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[16px]">remove</span>
</button>
<button
type="button"
onClick={resetZoom}
className="min-w-[68px] rounded-md px-2 py-1 text-[11px] font-semibold text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{Math.round(previewZoom * 100)}%
</button>
<button
type="button"
onClick={zoomIn}
aria-label="Zoom in"
className="flex h-8 w-8 items-center justify-center rounded-md text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[16px]">add</span>
</button>
</div>
<CopyButton
text={code}
className="rounded-md border border-[var(--color-border)] px-2.5 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
</div>
<div
ref={previewViewportRef}
data-testid="mermaid-preview-viewport"
className="overflow-auto rounded-xl bg-[var(--color-surface-container-lowest)]"
style={{
maxHeight: '75vh',
cursor: isDraggingPreview ? 'grabbing' : 'grab',
}}
onWheel={handlePreviewWheel}
onPointerDown={handlePreviewPointerDown}
onPointerMove={handlePreviewPointerMove}
onPointerUp={handlePreviewPointerUp}
onPointerCancel={handlePreviewPointerUp}
onPointerLeave={handlePreviewPointerUp}
>
<div className="min-h-full min-w-full p-6">
<div
ref={previewContentRef}
className="mx-auto shrink-0 select-none"
style={previewCanvasStyle}
data-dragging={isDraggingPreview ? 'true' : 'false'}
aria-label="Mermaid preview canvas"
dangerouslySetInnerHTML={{ __html: sanitizeMermaidSvg(svg) }}
/>
</div>
</div>
<div className="text-[11px] text-[var(--color-text-tertiary)]">
Use the zoom controls to enlarge the diagram. Drag inside the preview to pan, or use the trackpad, mouse wheel, and scrollbars. Hold Ctrl/Command while scrolling to zoom.
</div>
</div>
</Modal>
</>
)
}