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, '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(null) const previewViewportRef = useRef(null) const previewContentRef = useRef(null) const dragStateRef = useRef(null) const [svg, setSvg] = useState(null) const [error, setError] = useState(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) => { 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) => { 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) => { 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) => { 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 (
error Mermaid Error
{error}
) } if (!svg) { return (
progress_activity Rendering diagram...
) } return ( <>
{/* Header */}
account_tree Mermaid
{/* Diagram */}
{/* Fullscreen preview modal */}
account_tree Mermaid Diagram
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.
) }