Mermaid diagrams

A use case of all the supported and tested mermaid diagrams our tldraw/mermaid package can support so far.

import { useCallback } from 'react'
import {
	Editor,
	TLComponents,
	TLShapeId,
	Tldraw,
	TldrawUiButton,
	createShapeId,
	useAtom,
	useEditor,
	useValue,
} from 'tldraw'
import mermaidDefinitions from './mermaids'

const GAP = 100
const PAIR_GAP = 40

const components: TLComponents = {
	TopPanel: TopPanel,
}

export default function MermaidDiagramsExample() {
	return (
		<div className="tldraw__editor">
			<Tldraw components={components} />
		</div>
	)
}

function getNewShapeIds(editor: Editor, shapesBefore: Set<TLShapeId>): TLShapeId[] {
	return [...editor.getCurrentPageShapeIds()].filter((id) => !shapesBefore.has(id))
}

function measureSelection(editor: Editor) {
	const bounds = editor.getSelectionPageBounds()
	return bounds ? { w: bounds.width, h: bounds.height } : { w: 0, h: 0 }
}

function parseSvgSize(svgText: string): { w: number; h: number } {
	const svg = new DOMParser().parseFromString(svgText, 'image/svg+xml').querySelector('svg')
	if (!svg) return { w: 100, h: 100 }
	let w = parseFloat(svg.getAttribute('width') || '0')
	let h = parseFloat(svg.getAttribute('height') || '0')
	if (!(w && h)) {
		const vb = svg.getAttribute('viewBox')?.split(/\s+/).map(Number)
		if (vb && vb.length === 4) {
			w = vb[2]
			h = vb[3]
		}
	}
	return { w: w || 100, h: h || 100 }
}

function TopPanel() {
	const editor = useEditor()
	const isGeneratingAtom = useAtom<boolean>('isGenerating', false)
	const isGenerating = useValue(isGeneratingAtom)
	const countAtom = useAtom<number>('mermaidCount', 0)
	const count = useValue(countAtom)
	const handleClick = useCallback(async () => {
		if (isGeneratingAtom.get()) {
			return
		}
		countAtom.set(0)
		isGeneratingAtom.set(true)
		const [{ createMermaidDiagram }, { default: mermaid }] = await Promise.all([
			import('@tldraw/mermaid'),
			import('mermaid'),
		])
		const FONT_INFLATE = 1.4
		mermaid.initialize({
			startOnLoad: false,
			flowchart: { useMaxWidth: false, nodeSpacing: 80, rankSpacing: 80, padding: 20 },
			state: { useMaxWidth: false, nodeSpacing: 80, rankSpacing: 80, padding: 20 },
			sequence: { useMaxWidth: false, actorMargin: 50, noteMargin: 20 },
			themeVariables: { fontSize: `${18 * FONT_INFLATE}px` },
		})

		const offscreen = document.createElement('div')
		offscreen.style.cssText = 'position:absolute;left:-9999px;top:-9999px;overflow:hidden'
		document.body.appendChild(offscreen)

		await deleteShapes(editor)

		let currentX = 0,
			currentY = 0

		try {
			for (const group of mermaidDefinitions) {
				currentX = 0
				let maxRowHeight = 0

				for (const def of group) {
					const shapesBefore = new Set(editor.getCurrentPageShapeIds())
					let nativeSize = { w: 0, h: 0 }

					try {
						await createMermaidDiagram(editor, def, {
							blueprintRender: {
								position: { x: currentX, y: currentY },
								centerOnPosition: false,
							},
						})

						const nativeIds = getNewShapeIds(editor, shapesBefore)
						if (nativeIds.length) {
							editor.setSelectedShapes(nativeIds)
						}
						nativeSize = measureSelection(editor)
						currentX += nativeSize.w + PAIR_GAP
					} catch (e) {
						console.warn('[mermaid] blueprint failed:', e, '\n---\n' + def)
					}

					try {
						const { svg } = await mermaid.render(
							`mmd-svg-${Date.now()}-${Math.random().toString(36).slice(2)}`,
							def,
							offscreen
						)

						const { w: svgW, h: svgH } = parseSvgSize(svg)
						const scale = nativeSize.h > 0 && svgH > 0 ? nativeSize.h / svgH : 1
						const scaledW = svgW * scale
						const scaledH = svgH * scale

						const asset = await editor.getAssetForExternalContent({
							type: 'file',
							file: new File([svg], 'diagram.svg', { type: 'image/svg+xml' }),
						})
						if (asset) {
							const shapeId = createShapeId()
							if (!editor.getAsset(asset.id)) {
								editor.createAssets([asset])
							}
							editor.createShape({
								id: shapeId,
								type: 'image',
								x: currentX,
								y: currentY,
								props: { assetId: asset.id, w: scaledW, h: scaledH },
							})
						}

						currentX += scaledW + GAP
						maxRowHeight = Math.max(maxRowHeight, nativeSize.h, scaledH)
					} catch (e) {
						console.warn('[mermaid] svg render failed:', e, '\n---\n' + def)
						currentX += GAP - PAIR_GAP
						maxRowHeight = Math.max(maxRowHeight, nativeSize.h)
					}

					countAtom.set(countAtom.get() + 1)
				}

				currentY += maxRowHeight + GAP
			}
		} finally {
			offscreen.remove()
			isGeneratingAtom.set(false)
		}

		editor.selectNone()
	}, [editor, isGeneratingAtom, countAtom])

	return (
		<div
			style={{
				position: 'fixed',
				top: 0,
				left: '50%',
				transform: 'translateX(-50%)',
				padding: '8px',
				background: '#eee',
				borderRadius: '0 0 8px 8px',
				display: 'flex',
				gap: '8px',
				zIndex: 1000,
				opacity: isGenerating ? 0 : 1,
			}}
		>
			<TldrawUiButton type="low" onClick={handleClick}>
				Click to see a thousand mermaids
				{count > 0 && <>({count} actually…)</>}
			</TldrawUiButton>
		</div>
	)
}

async function deleteShapes(editor: Editor): Promise<void> {
	editor.selectAll()
	editor.deleteShapes(editor.getSelectedShapes())

	return new Promise((resolve) =>
		setTimeout(function check() {
			editor.selectAll()
			if (editor.getSelectedShapes().length == 0) {
				resolve()
				return
			}
			setTimeout(check, 500)
		}, 500)
	)
}

This example shows how to import mermaid diagrams and translate them to tldraw shapes

Is this page helpful?
Prev
Many shapes
Next
Snowstorm