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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-31 16:47:51 +08:00
parent 8c5c2a0913
commit ed574777e3
3 changed files with 421 additions and 46 deletions

View File

@ -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('<script')
expect(surface.innerHTML).not.toContain('onerror')
})

View File

@ -27,6 +27,88 @@ describe('MermaidRenderer', () => {
})
})
it('normalizes large SVGs so edges stay visible when the diagram is scaled', async () => {
renderMock.mockResolvedValue({
svg: [
'<svg viewBox="0 0 1200 300" width="100%" height="100%" style="max-width: 1200px;">',
'<path data-edge="true" class="flowchart-link" d="M0 0L1200 300"></path>',
'<marker id="arrow"><path class="arrowMarkerPath" d="M0 0L10 5L0 10z"></path></marker>',
'</svg>',
].join(''),
})
render(<MermaidRenderer code={'graph LR\nA-->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: [
'<svg viewBox="0 0 300 120" width="100%" height="100%">',
'<path data-edge="true" class="flowchart-link" stroke="#ff0000" stroke-width="4" fill="none" d="M0 0L300 120"></path>',
'<marker id="arrow"><path class="arrowMarkerPath" fill="#ff0000" stroke="#ff0000" d="M0 0L10 5L0 10z"></path></marker>',
'</svg>',
].join(''),
})
render(<MermaidRenderer code={'graph LR\nA-->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: '<svg viewBox="0 0 1200 300"><path data-edge="true" d="M0 0L1200 300"></path></svg>',
})
try {
render(<MermaidRenderer code={'graph LR\nA-->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(<MermaidRenderer code={'graph TB\nA-->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: '<svg viewBox="0 0 1200 300"><path data-edge="true" d="M0 0L1200 300"></path></svg>',
})
try {
render(<MermaidRenderer code={'graph LR\nA-->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' })

View File

@ -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<React.PointerEvent<HTMLDivElement>, 'clientX' | 'clientY' | 'pageX' | 'pageY'>,
) {
@ -180,20 +295,45 @@ export function MermaidRenderer({ code }: Props) {
const [error, setError] = useState<string | null>(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<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))
}, [])
setPreviewFitMode(false)
setPreviewZoomAroundCenter(previewZoom + direction)
}, [previewZoom, setPreviewZoomAroundCenter])
const handlePreviewPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
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) {
<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"
className="overflow-auto bg-[var(--color-surface-container-lowest)] p-4 cursor-pointer"
style={{ maxHeight: 400 }}
onClick={handlePreview}
dangerouslySetInnerHTML={{ __html: sanitizeMermaidSvg(svg) }}
/>
>
<div className="mx-auto shrink-0 select-none" style={inlineFrameStyle}>
<div
style={inlineCanvasStyle}
aria-label="Mermaid inline canvas"
dangerouslySetInnerHTML={{ __html: sanitizedSvg ?? '' }}
/>
</div>
</div>
</div>
{/* Fullscreen preview modal */}
@ -392,6 +618,14 @@ export function MermaidRenderer({ code }: Props) {
>
{Math.round(previewZoom * 100)}%
</button>
<button
type="button"
onClick={fitPreview}
aria-label="Fit diagram"
className="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)]"
>
Fit
</button>
<button
type="button"
onClick={zoomIn}
@ -421,18 +655,22 @@ export function MermaidRenderer({ code }: Props) {
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 className="min-h-full min-w-full p-6">
<div
className="mx-auto shrink-0 select-none"
style={previewFrameStyle}
>
<div
ref={previewContentRef}
style={previewCanvasStyle}
data-dragging={isDraggingPreview ? 'true' : 'false'}
aria-label="Mermaid preview canvas"
dangerouslySetInnerHTML={{ __html: sanitizedSvg ?? '' }}
/>
</div>
</div>
</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>