From ed574777e3020533538d68d0fc9544ef89adc5ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 31 May 2026 16:47:51 +0800 Subject: [PATCH] fix(desktop): make Mermaid diagrams readable at scale #636 Large Mermaid flowcharts were still hard to inspect in the desktop chat surface because ultra-wide SVGs could be squeezed or opened at an unhelpful crop. Normalize the rendered SVG, keep edge strokes visible while preserving author styles, and add fit-first overview rendering for both inline chat cards and the preview modal. Constraint: Mermaid emits SVG sized for generic web pages, while desktop chat needs readable SVG behavior inside constrained WebView panels. Rejected: Replace Mermaid with a node-canvas library | it would not be a drop-in renderer for Mermaid syntax and would add unnecessary dependency and migration risk. Confidence: high Scope-risk: moderate Directive: Keep SVG normalization additive; do not override explicit linkStyle or marker paint without adding a regression test. Tested: bun run check:desktop Tested: Real desktop frontend visual QA with complex Mermaid screenshots at Fit, 25%, 50%, 75%, 100%, and 125%. Not-tested: Packaged Tauri WebView smoke. --- .../chat/MermaidRenderer.integration.test.tsx | 2 + .../components/chat/MermaidRenderer.test.tsx | 135 +++++++ .../src/components/chat/MermaidRenderer.tsx | 330 +++++++++++++++--- 3 files changed, 421 insertions(+), 46 deletions(-) diff --git a/desktop/src/components/chat/MermaidRenderer.integration.test.tsx b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx index bb8564de..00d1ef75 100644 --- a/desktop/src/components/chat/MermaidRenderer.integration.test.tsx +++ b/desktop/src/components/chat/MermaidRenderer.integration.test.tsx @@ -52,6 +52,8 @@ describe('MermaidRenderer Mermaid integration', () => { expect(surface).toHaveTextContent('企业建站') expect(surface).toHaveTextContent('主旨系统') expect(surface).toHaveTextContent('Codex 初期') + expect(surface.querySelector('svg')).not.toHaveAttribute('width', '100%') + expect(surface.querySelector('[data-edge="true"]')?.getAttribute('style')).toContain('vector-effect: non-scaling-stroke') expect(surface.innerHTML).not.toContain(' { }) }) + it('normalizes large SVGs so edges stay visible when the diagram is scaled', async () => { + renderMock.mockResolvedValue({ + svg: [ + '', + '', + '', + '', + ].join(''), + }) + + render(B'} />) + + const surface = await screen.findByTestId('mermaid-diagram-surface') + const renderedSvg = surface.querySelector('svg') + const edge = surface.querySelector('[data-edge="true"]') + const arrow = surface.querySelector('.arrowMarkerPath') + + expect(renderedSvg).toHaveAttribute('width', '1200') + expect(renderedSvg).toHaveAttribute('height', '300') + expect(renderedSvg).toHaveStyle({ maxWidth: 'none' }) + expect(edge).toHaveStyle({ fill: 'none' }) + expect(edge?.getAttribute('style')).toContain('vector-effect: non-scaling-stroke') + expect(arrow?.getAttribute('style')).toContain('stroke:') + }) + + it('preserves explicit Mermaid edge and marker paint while adding visibility safeguards', async () => { + renderMock.mockResolvedValue({ + svg: [ + '', + '', + '', + '', + ].join(''), + }) + + render(B\nlinkStyle 0 stroke:#ff0000,stroke-width:4px'} />) + + const surface = await screen.findByTestId('mermaid-diagram-surface') + const edge = surface.querySelector('[data-edge="true"]') + const arrow = surface.querySelector('.arrowMarkerPath') + + expect(edge).toHaveAttribute('stroke', '#ff0000') + expect(edge).toHaveAttribute('stroke-width', '4') + expect(edge).toHaveAttribute('fill', 'none') + expect(edge?.getAttribute('style')).toContain('vector-effect: non-scaling-stroke') + expect(arrow).toHaveAttribute('fill', '#ff0000') + expect(arrow).toHaveAttribute('stroke', '#ff0000') + expect(arrow?.getAttribute('style') ?? '').not.toContain('fill:') + expect(arrow?.getAttribute('style') ?? '').not.toContain('stroke:') + }) + + it('fits oversized diagrams inside the chat message surface', async () => { + const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get() { + return this instanceof HTMLElement && this.dataset.testid === 'mermaid-diagram-surface' ? 800 : 0 + }, + }) + renderMock.mockResolvedValue({ + svg: '', + }) + + try { + render(B'} />) + + const canvas = await screen.findByLabelText('Mermaid inline canvas') + + await waitFor(() => { + expect(canvas).toHaveStyle({ + width: '1200px', + height: '300px', + transform: 'scale(0.64)', + }) + }) + } finally { + if (originalClientWidth) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth) + } + } + }) + it('opens preview with zoom controls and updates the zoom label', async () => { render(B'} />) @@ -49,11 +131,19 @@ describe('MermaidRenderer', () => { const zoomButton = screen.getByRole('button', { name: '100%' }) expect(zoomButton).toBeInTheDocument() + const canvas = screen.getByLabelText('Mermaid preview canvas') + fireEvent.click(screen.getByRole('button', { name: 'Zoom in' })) await waitFor(() => { expect(screen.getByRole('button', { name: '125%' })).toBeInTheDocument() }) + expect(canvas).toHaveStyle({ + position: 'absolute', + width: '200px', + height: '100px', + transform: 'scale(1.25)', + }) fireEvent.click(screen.getByRole('button', { name: '125%' })) @@ -62,6 +152,51 @@ describe('MermaidRenderer', () => { }) }) + it('fits oversized diagrams to the preview viewport by default', async () => { + const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth') + const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight') + Object.defineProperty(HTMLElement.prototype, 'clientWidth', { + configurable: true, + get() { + return this instanceof HTMLElement && this.dataset.testid === 'mermaid-preview-viewport' ? 800 : 0 + }, + }) + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + configurable: true, + get() { + return this instanceof HTMLElement && this.dataset.testid === 'mermaid-preview-viewport' ? 500 : 0 + }, + }) + renderMock.mockResolvedValue({ + svg: '', + }) + + try { + render(B'} />) + + fireEvent.click(await screen.findByRole('button', { name: /preview/i })) + + await waitFor(() => { + expect(screen.getByRole('button', { name: '63%' })).toBeInTheDocument() + }) + + fireEvent.click(screen.getByRole('button', { name: '63%' })) + expect(screen.getByRole('button', { name: '100%' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Fit diagram' })) + await waitFor(() => { + expect(screen.getByRole('button', { name: '63%' })).toBeInTheDocument() + }) + } finally { + if (originalClientWidth) { + Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth) + } + if (originalClientHeight) { + Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight) + } + } + }) + it('uses dark Mermaid theme variables when the app is in dark mode', async () => { useUIStore.setState({ theme: 'dark' }) diff --git a/desktop/src/components/chat/MermaidRenderer.tsx b/desktop/src/components/chat/MermaidRenderer.tsx index 16cb1f36..d562f9d8 100644 --- a/desktop/src/components/chat/MermaidRenderer.tsx +++ b/desktop/src/components/chat/MermaidRenderer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, useCallback } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react' import DOMPurify from 'dompurify' import mermaid from 'mermaid' import { Modal } from '../shared/Modal' @@ -10,9 +10,10 @@ type Props = { code: string } -const MIN_PREVIEW_ZOOM = 0.5 +const MIN_PREVIEW_ZOOM = 0.05 const MAX_PREVIEW_ZOOM = 3 const PREVIEW_ZOOM_STEP = 0.25 +const PREVIEW_FIT_PADDING = 48 type SvgMetrics = { width: number @@ -27,6 +28,16 @@ type DragState = { scrollTop: number } +type MermaidThemeColors = { + textColor: string + mutedTextColor: string + surfaceColor: string + nodeColor: string + accentColor: string + lineColor: string + isDark: boolean +} + function rgbToHex(color: string, fallback: string) { const trimmed = color.trim() if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed @@ -62,20 +73,37 @@ function resolveThemeColor(token: string, fallback: string) { return rgbToHex(resolved, fallback) } -function initMermaid(theme: ThemeMode) { +function getMermaidThemeColors(theme: ThemeMode): MermaidThemeColors { 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') + return { + textColor: resolveThemeColor('--color-text-primary', isDark ? '#E5E2E1' : '#1B1C1A'), + mutedTextColor: resolveThemeColor('--color-text-secondary', isDark ? '#B7AAA5' : '#61514B'), + surfaceColor: resolveThemeColor('--color-surface-container-lowest', isDark ? '#0E0E0E' : '#FFFFFF'), + nodeColor: resolveThemeColor('--color-surface-container-low', isDark ? '#1C1B1B' : '#F4EFEA'), + accentColor: resolveThemeColor('--color-primary', isDark ? '#FFB59F' : '#8F482F'), + lineColor: resolveThemeColor('--color-outline', isDark ? '#BFAEAA' : '#667485'), + isDark, + } +} + +function initMermaid(theme: ThemeMode) { + const { + textColor, + mutedTextColor, + surfaceColor, + nodeColor, + accentColor, + lineColor, + isDark, + } = getMermaidThemeColors(theme) mermaid.initialize({ startOnLoad: false, theme: 'base', + htmlLabels: false, flowchart: { htmlLabels: false, + arrowMarkerAbsolute: true, }, themeVariables: { darkMode: isDark, @@ -108,6 +136,8 @@ function initMermaid(theme: ThemeMode) { suppressErrorRendering: true, fontFamily: 'var(--font-sans)', }) + + return { lineColor } } let mermaidIdCounter = 0 @@ -119,10 +149,95 @@ function sanitizeMermaidSvg(svg: string) { }) } +function formatSvgDimension(value: number) { + return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3))) +} + +function getSvgStyleProperty(element: Element, property: string) { + const style = element.getAttribute('style') ?? '' + const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, 'i').exec(style) + return match?.[1]?.trim() ?? '' +} + +function setSvgStyle(element: Element, property: string, value: string, overwrite = true) { + if (!overwrite && getSvgStyleProperty(element, property)) return + + const style = element.getAttribute('style') ?? '' + const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const declaration = `${property}: ${value}` + const pattern = new RegExp(`(^|;)\\s*${escapedProperty}\\s*:[^;]*`, 'i') + const nextStyle = pattern.test(style) + ? style.replace(pattern, (_, prefix: string) => `${prefix}${declaration}`) + : `${style.trim().replace(/;$/, '')}${style.trim() ? '; ' : ''}${declaration}` + + element.setAttribute('style', nextStyle) +} + +function setSvgFallbackStyle(element: Element, property: string, value: string) { + if (element.hasAttribute(property)) return + setSvgStyle(element, property, value, false) +} + +function normalizeMermaidSvg(svg: string, lineColor: string) { + if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') { + return svg + } + + const parsed = new DOMParser().parseFromString(svg, 'image/svg+xml') + if (parsed.querySelector('parsererror')) return svg + + const root = parsed.querySelector('svg') + if (!root) return svg + + const metrics = parseSvgMetrics(svg) + if (metrics) { + root.setAttribute('width', formatSvgDimension(metrics.width)) + root.setAttribute('height', formatSvgDimension(metrics.height)) + } + + root.setAttribute('preserveAspectRatio', 'xMidYMid meet') + setSvgStyle(root, 'display', 'block') + setSvgStyle(root, 'max-width', 'none') + setSvgStyle(root, 'height', metrics ? `${formatSvgDimension(metrics.height)}px` : 'auto') + setSvgStyle(root, 'background', 'transparent') + setSvgStyle(root, 'overflow', 'visible') + + root + .querySelectorAll('[data-edge="true"], .flowchart-link, .edgePath .path') + .forEach((edge) => { + setSvgFallbackStyle(edge, 'stroke', lineColor) + setSvgFallbackStyle(edge, 'stroke-width', '1.6px') + setSvgFallbackStyle(edge, 'fill', 'none') + setSvgStyle(edge, 'vector-effect', 'non-scaling-stroke') + }) + + root + .querySelectorAll('.marker, .arrowMarkerPath, marker path') + .forEach((marker) => { + setSvgFallbackStyle(marker, 'fill', lineColor) + setSvgFallbackStyle(marker, 'stroke', lineColor) + }) + + return new XMLSerializer().serializeToString(root) +} + function clampZoom(value: number) { return Math.min(MAX_PREVIEW_ZOOM, Math.max(MIN_PREVIEW_ZOOM, value)) } +function calculateFitZoom(metrics: SvgMetrics, viewport: HTMLElement | null) { + if (!viewport) return 1 + + const viewportWidth = viewport.clientWidth + const viewportHeight = viewport.clientHeight + if (viewportWidth <= 0 || viewportHeight <= 0) return 1 + + const availableWidth = Math.max(1, viewportWidth - PREVIEW_FIT_PADDING) + const availableHeight = Math.max(1, viewportHeight - PREVIEW_FIT_PADDING) + return clampZoom(Math.min(1, availableWidth / metrics.width, availableHeight / metrics.height)) +} + function getPointerPosition( event: Pick, 'clientX' | 'clientY' | 'pageX' | 'pageY'>, ) { @@ -180,20 +295,45 @@ export function MermaidRenderer({ code }: Props) { const [error, setError] = useState(null) const [previewOpen, setPreviewOpen] = useState(false) const [previewZoom, setPreviewZoom] = useState(1) + const [previewFitMode, setPreviewFitMode] = useState(true) const [isDraggingPreview, setIsDraggingPreview] = useState(false) + const [inlineViewportWidth, setInlineViewportWidth] = useState(0) const svgMetrics = svg ? parseSvgMetrics(svg) : null + const sanitizedSvg = useMemo(() => (svg ? sanitizeMermaidSvg(svg) : null), [svg]) + const inlineZoom = svgMetrics && inlineViewportWidth > 0 + ? clampZoom(Math.min(1, Math.max(1, inlineViewportWidth - 32) / svgMetrics.width)) + : 1 + + const inlineFrameStyle = svgMetrics + ? { + position: 'relative' as const, + width: `${svgMetrics.width * inlineZoom}px`, + height: `${svgMetrics.height * inlineZoom}px`, + } + : undefined + const inlineCanvasStyle = svgMetrics + ? { + position: 'absolute' as const, + left: 0, + top: 0, + width: `${svgMetrics.width}px`, + height: `${svgMetrics.height}px`, + transform: `scale(${inlineZoom})`, + transformOrigin: 'top left', + } + : undefined useEffect(() => { let cancelled = false - initMermaid(theme) + const { lineColor } = initMermaid(theme) const id = `mermaid-${++mermaidIdCounter}` mermaid.render(id, code).then( ({ svg: renderedSvg }) => { if (!cancelled) { - setSvg(renderedSvg) + setSvg(normalizeMermaidSvg(renderedSvg, lineColor)) setError(null) } }, @@ -208,26 +348,104 @@ export function MermaidRenderer({ code }: Props) { return () => { cancelled = true } }, [code, theme]) + useLayoutEffect(() => { + const container = containerRef.current + if (!container) return undefined + + const updateInlineWidth = () => setInlineViewportWidth(container.clientWidth) + updateInlineWidth() + + if (typeof ResizeObserver === 'undefined') return undefined + + const resizeObserver = new ResizeObserver(updateInlineWidth) + resizeObserver.observe(container) + return () => resizeObserver.disconnect() + }, [svg]) + const handlePreview = useCallback(() => setPreviewOpen(true), []) const handlePreviewClose = useCallback(() => setPreviewOpen(false), []) + const applyPreviewFit = useCallback(() => { + if (!svgMetrics) return + setPreviewZoom(calculateFitZoom(svgMetrics, previewViewportRef.current)) + }, [svgMetrics]) + const setPreviewZoomAroundCenter = useCallback((nextZoom: number) => { + const viewport = previewViewportRef.current + const previousZoom = previewZoom + const clampedZoom = clampZoom(nextZoom) + + if (!viewport || previousZoom <= 0) { + setPreviewZoom(clampedZoom) + return + } + + const sourceCenterX = (viewport.scrollLeft + viewport.clientWidth / 2) / previousZoom + const sourceCenterY = (viewport.scrollTop + viewport.clientHeight / 2) / previousZoom + setPreviewZoom(clampedZoom) + + window.requestAnimationFrame(() => { + viewport.scrollLeft = Math.max(0, sourceCenterX * clampedZoom - viewport.clientWidth / 2) + viewport.scrollTop = Math.max(0, sourceCenterY * clampedZoom - viewport.clientHeight / 2) + }) + }, [previewZoom]) + const fitPreview = useCallback(() => { + setPreviewFitMode(true) + applyPreviewFit() + const viewport = previewViewportRef.current + if (viewport) { + viewport.scrollLeft = 0 + viewport.scrollTop = 0 + } + }, [applyPreviewFit]) const zoomIn = useCallback( - () => setPreviewZoom((value) => clampZoom(value + PREVIEW_ZOOM_STEP)), - [], + () => { + setPreviewFitMode(false) + setPreviewZoomAroundCenter(previewZoom + PREVIEW_ZOOM_STEP) + }, + [previewZoom, setPreviewZoomAroundCenter], ) const zoomOut = useCallback( - () => setPreviewZoom((value) => clampZoom(value - PREVIEW_ZOOM_STEP)), - [], + () => { + setPreviewFitMode(false) + setPreviewZoomAroundCenter(previewZoom - PREVIEW_ZOOM_STEP) + }, + [previewZoom, setPreviewZoomAroundCenter], ) - const resetZoom = useCallback(() => setPreviewZoom(1), []) + const resetZoom = useCallback(() => { + setPreviewFitMode(false) + setPreviewZoomAroundCenter(1) + }, [setPreviewZoomAroundCenter]) useEffect(() => { if (!previewOpen) { setPreviewZoom(1) + setPreviewFitMode(true) setIsDraggingPreview(false) dragStateRef.current = null } }, [previewOpen, svg]) + useLayoutEffect(() => { + if (!previewOpen || !svgMetrics || !previewFitMode) return undefined + + let animationFrame = window.requestAnimationFrame(applyPreviewFit) + let resizeObserver: ResizeObserver | null = null + const viewport = previewViewportRef.current + + if (viewport && typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(() => { + if (!previewFitMode) return + window.cancelAnimationFrame(animationFrame) + animationFrame = window.requestAnimationFrame(applyPreviewFit) + }) + resizeObserver.observe(viewport) + } + + return () => { + window.cancelAnimationFrame(animationFrame) + resizeObserver?.disconnect() + } + }, [applyPreviewFit, previewFitMode, previewOpen, svgMetrics]) + const stopDraggingPreview = useCallback(() => { const viewport = previewViewportRef.current const dragState = dragStateRef.current @@ -244,26 +462,14 @@ export function MermaidRenderer({ code }: Props) { 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)) - }, []) + setPreviewFitMode(false) + setPreviewZoomAroundCenter(previewZoom + direction) + }, [previewZoom, setPreviewZoomAroundCenter]) const handlePreviewPointerDown = useCallback((event: React.PointerEvent) => { if (event.pointerType === 'mouse' && event.button !== 0) return @@ -300,12 +506,25 @@ export function MermaidRenderer({ code }: Props) { stopDraggingPreview() }, [stopDraggingPreview]) - const previewCanvasStyle = svgMetrics + const previewFrameStyle = svgMetrics ? { + position: 'relative' as const, width: `${svgMetrics.width * previewZoom}px`, height: `${svgMetrics.height * previewZoom}px`, } : undefined + const previewCanvasStyle = svgMetrics + ? { + position: 'absolute' as const, + left: 0, + top: 0, + width: `${svgMetrics.width}px`, + height: `${svgMetrics.height}px`, + transform: `scale(${previewZoom})`, + transformOrigin: 'top left', + willChange: 'transform', + } + : undefined if (error) { return ( @@ -360,11 +579,18 @@ export function MermaidRenderer({ code }: Props) {
+ > +
+
+
+
{/* Fullscreen preview modal */} @@ -392,6 +618,14 @@ export function MermaidRenderer({ code }: Props) { > {Math.round(previewZoom * 100)}% +