Files
tolaria/src/components/MermaidDiagram.tsx
2026-05-07 16:29:11 +02:00

160 lines
4.2 KiB
TypeScript

import { useEffect, useId, useMemo, useState } from 'react'
import { Maximize2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { SafeSvgDiv } from './SafeMarkup'
type MermaidApi = typeof import('mermaid')['default']
interface MermaidDiagramProps {
diagram: string
source: string
}
interface MermaidSvgViewportProps {
ariaLabel: string
className: string
svg: string
testId: string
}
interface RenderState {
diagram: string
svg: string
error: boolean
}
let initialized = false
let renderQueue = Promise.resolve()
function renderIdFromReactId(reactId: string): string {
const safeId = reactId.replace(/[^a-zA-Z0-9_-]/g, '')
return `tolaria-mermaid-${safeId || 'diagram'}`
}
function initializeMermaid(mermaid: MermaidApi) {
if (initialized) return
mermaid.initialize({
startOnLoad: false,
securityLevel: 'strict',
htmlLabels: false,
theme: 'default',
themeVariables: {
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
},
})
initialized = true
}
async function renderMermaidDiagram({
diagram,
renderId,
}: {
diagram: string
renderId: string
}): Promise<string> {
const render = async () => {
const mermaid = (await import('mermaid')).default
initializeMermaid(mermaid)
const result = await mermaid.render(renderId, diagram)
return result.svg
}
const nextRender = renderQueue.then(render, render)
renderQueue = nextRender.then(() => undefined, () => undefined)
return nextRender
}
function MermaidSvgViewport({ ariaLabel, className, svg, testId }: MermaidSvgViewportProps) {
return (
<SafeSvgDiv
aria-label={ariaLabel}
className={className}
data-testid={testId}
role="img"
svg={svg}
tabIndex={0}
/>
)
}
function MermaidLightbox({ svg }: { svg: string }) {
return (
<Dialog>
<DialogTrigger asChild>
<Button
aria-label="Open Mermaid diagram"
className="mermaid-diagram__expand-button"
size="icon-sm"
title="Open diagram"
type="button"
variant="outline"
>
<Maximize2 aria-hidden="true" />
</Button>
</DialogTrigger>
<DialogContent className="mermaid-diagram__dialog" showCloseButton>
<DialogTitle className="sr-only">Mermaid diagram</DialogTitle>
<DialogDescription className="sr-only">
Expanded view of the rendered Mermaid diagram.
</DialogDescription>
<MermaidSvgViewport
ariaLabel="Expanded Mermaid diagram"
className="mermaid-diagram__dialog-viewport"
svg={svg}
testId="mermaid-diagram-dialog-viewport"
/>
</DialogContent>
</Dialog>
)
}
export function MermaidDiagram({ diagram, source }: MermaidDiagramProps) {
const reactId = useId()
const renderId = useMemo(() => renderIdFromReactId(reactId), [reactId])
const [state, setState] = useState<RenderState>({ diagram: '', svg: '', error: false })
useEffect(() => {
let active = true
if (!diagram.trim()) return () => { active = false }
renderMermaidDiagram({ diagram, renderId })
.then((svg) => {
if (active) setState({ diagram, svg, error: false })
})
.catch(() => {
if (active) setState({ diagram, svg: '', error: true })
})
return () => { active = false }
}, [diagram, renderId])
const currentState = state.diagram === diagram ? state : { diagram, svg: '', error: false }
if (!diagram.trim() || currentState.error) {
return (
<figure className="mermaid-diagram mermaid-diagram--error" data-testid="mermaid-diagram-error">
<figcaption>Mermaid diagram unavailable</figcaption>
<pre aria-label="Mermaid source"><code>{source}</code></pre>
</figure>
)
}
return (
<figure className="mermaid-diagram" data-testid="mermaid-diagram">
<MermaidLightbox svg={currentState.svg} />
<MermaidSvgViewport
ariaLabel="Mermaid diagram"
className="mermaid-diagram__viewport"
svg={currentState.svg}
testId="mermaid-diagram-viewport"
/>
</figure>
)
}