Skip to content

Commit 0bcbb3e

Browse files
kaneelGuillaume
andauthored
feat(mermaid): add @tldraw/mermaid package for diagram-to-shape conversion (tldraw#8194)
In order to let users paste Mermaid diagram syntax and have it converted into native tldraw shapes, this PR introduces the `@tldraw/mermaid` package and integrates it into the dotcom app. Closes tldraw#4353 ### How it works The system has three layers: **detection**, **parsing + layout extraction**, and **blueprint rendering**. **Detection (dotcom integration).** When text is pasted on dotcom, `SneakyMermaidHandler` registers an external text content handler on the editor. Before pulling in the heavy mermaid library, it runs a lightweight regex-based check (`simpleMermaidStringTest`) that strips YAML frontmatter, `%%{...}%%` directives, and `%%` comments, then tests whether the cleaned text starts with a known diagram keyword. This keeps the mermaid library out of the main bundle — it's only `import()`-ed when a paste actually looks like a diagram. If the text doesn't match, it falls through to `defaultHandleExternalTextContent`. If the diagram type is unsupported (e.g. pie charts), the handler falls back to SVG import via `putExternalContent({ type: 'svg-text' })` and shows a toast. **Parsing + layout extraction (`createMermaidDiagram`).** This is the main entry point. It initializes mermaid with inflated font sizes to compensate for tldraw's hand-drawn font being wider, parses and validates the syntax, renders to an offscreen DOM element for layout extraction via `getBBox()`, then extracts the diagram's semantic data (vertices, edges, actors, states, etc.) and dispatches to the appropriate diagram-specific converter. **Diagram-specific converters.** Each converter combines the semantic data (from mermaid's DB) with layout data (from the rendered SVG) to produce a `DiagramMermaidBlueprint`: - *Flowcharts* — Node positions/dimensions from SVG `.node` elements, cluster bounds from `.cluster` elements, edge waypoints from `path[data-points]` attributes. Maps mermaid shape types to tldraw geo types. Builds subgraph hierarchy as frames with `parentId`. Computes arrow bend from waypoint geometry. Parses `classDef` CSS colors and maps to nearest tldraw palette color via Euclidean RGB distance. - *Sequence diagrams* — Actor layouts from SVG rect elements with fixed spacing. Creates top/bottom actor shapes, vertical lifelines, signal arrows with precise bindings, note shapes, and fragment frames for loop/alt/opt/par/critical blocks. - *State diagrams* — Recursively flattens the state hierarchy. Creates frames for compound states, handles special types (start, end, fork/join, choice). Reuses the same SVG node/edge parsing strategy as flowcharts. **Blueprint rendering (`renderBlueprint`).** The blueprint is a diagram-type-agnostic intermediate representation with `nodes`, `edges`, `lines`, and `groups`. The renderer centers the diagram at the paste point, creates shapes in parent-first order with proper coordinate transforms, creates arrows with bindings (precise anchored, self-loop, and standard center-to-center), groups shapes, and applies a final position correction pass. ### Change type - [x] `feature` - [x] `api` ### Test plan 1. Paste a flowchart mermaid diagram (e.g. `graph TD; A-->B; B-->C;`) into tldraw.com — shapes should appear 2. Paste a sequence diagram — shapes with lifelines and arrows should appear 3. Paste a state diagram — shapes with transitions should appear 4. Paste an unsupported diagram type (e.g. pie chart) — should fall back to SVG with a warning toast 5. Paste normal text — should behave as before (no mermaid handling) - [x] Unit tests ### API changes - Added `createMermaidDiagram(editor, text, options?)` — main entry point to parse and render a Mermaid diagram - Added `renderBlueprint(editor, blueprint, opts?)` — renders a `DiagramMermaidBlueprint` into tldraw shapes - Added `MermaidDiagramError` — error class for parse/unsupported diagram errors - Added types: `DiagramMermaidBlueprint`, `MermaidBlueprintGeoNode`, `MermaidBlueprintEdge`, `MermaidBlueprintLineNode`, `BlueprintRenderingOptions`, `MermaidDiagramOptions` ### Release notes - Add Mermaid diagram support: paste Mermaid syntax to create native tldraw shapes (flowcharts, sequence diagrams, state diagrams) ### Code changes | Section | LOC change | | --------------- | ------------- | | Core code | +2661 / -0 | | Tests | +964 / -0 | | Automated files | +176 / -0 | | Documentation | +1246 / -0 | | Apps | +99 / -0 | | Config/tooling | +1252 / -43 | <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds a new diagram parsing/rendering subsystem and hooks it into editor paste handling, which could impact editor stability/perf and external-content security surface (SVG/text parsing). Scope is large but mostly additive behind lightweight detection and fallback behavior. > > **Overview** > Adds Mermaid-to-tldraw conversion support via a new `@tldraw/mermaid` package that parses Mermaid text, extracts layout from rendered SVG, and renders a diagram-agnostic “blueprint” into native tldraw shapes (nodes/lines/arrows/groups) for supported diagram types. > > Integrates this into dotcom editors (`LocalEditor` and `TlaEditor`) with a `SneakyMermaidHandler` that detects Mermaid-ish pasted text without bundling Mermaid eagerly, lazily `import()`s the converter on demand, and falls back to importing SVG + showing a warning toast for unsupported diagram types. > > Updates app deps/tsconfig references, adds i18n strings for the new warning toast, and introduces an examples use-case showcasing large-scale Mermaid rendering. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6d3b07f. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Guillaume <guillaume@tldraw.com>
1 parent a115bf3 commit 0bcbb3e

32 files changed

Lines changed: 6530 additions & 120 deletions

apps/dotcom/client/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@sentry/react": "^7.120.3",
3737
"@tldraw/assets": "workspace:*",
3838
"@tldraw/dotcom-shared": "workspace:*",
39+
"@tldraw/mermaid": "workspace:*",
3940
"@tldraw/sync": "workspace:*",
4041
"@tldraw/sync-core": "workspace:*",
4142
"@tldraw/utils": "workspace:*",

apps/dotcom/client/public/tla/locales-compiled/en.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@
213213
"value": "Accept all"
214214
}
215215
],
216+
"3e2ae04ff0": [
217+
{
218+
"type": 0,
219+
"value": "Unsupported Mermaid diagram"
220+
}
221+
],
216222
"42e53c47c1": [
217223
{
218224
"type": 0,
@@ -297,6 +303,12 @@
297303
"value": "Are you sure you want to delete this group? This action cannot be undone."
298304
}
299305
],
306+
"5097d89907": [
307+
{
308+
"type": 0,
309+
"value": "Unsupported diagram, will generate SVG instead"
310+
}
311+
],
300312
"50db1b7e1e": [
301313
{
302314
"type": 0,

apps/dotcom/client/public/tla/locales/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@
104104
"3d0238bf16": {
105105
"translation": "Accept all"
106106
},
107+
"3e2ae04ff0": {
108+
"translation": "Unsupported Mermaid diagram"
109+
},
107110
"42e53c47c1": {
108111
"translation": "Contact the owner to request access."
109112
},
@@ -146,6 +149,9 @@
146149
"4e6fbf032b": {
147150
"translation": "Are you sure you want to delete this group? This action cannot be undone."
148151
},
152+
"5097d89907": {
153+
"translation": "Unsupported diagram, will generate SVG instead"
154+
},
149155
"50db1b7e1e": {
150156
"translation": "Unable to unpublish the file."
151157
},

apps/dotcom/client/src/components/LocalEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useHandleUiEvents } from '../utils/analytics'
88
import { assetUrls } from '../utils/assetUrls'
99
import { createAssetFromUrl } from '../utils/createAssetFromUrl'
1010
import { getScratchPersistenceKey } from '../utils/scratch-persistence-key'
11+
import { SneakyMermaidHandler } from './SneakyMermaidHandler/SneakyMermaidHandler'
1112
import { SneakyOnDropOverride } from './SneakyOnDropOverride'
1213
import { ThemeUpdater } from './ThemeUpdater/ThemeUpdater'
1314

@@ -51,6 +52,7 @@ export function LocalEditor({
5152
>
5253
<SneakyOnDropOverride isMultiplayer={false} />
5354
<SneakyToolSwitcher />
55+
<SneakyMermaidHandler />
5456
<ThemeUpdater />
5557
{children}
5658
</Tldraw>
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useEffect } from 'react'
2+
import { defaultHandleExternalTextContent, useEditor, useToasts } from 'tldraw'
3+
import { defineMessages, useMsg } from '../../tla/utils/i18n'
4+
import { simpleMermaidStringTest } from './simpleMermaidStringTest'
5+
6+
const messages = defineMessages({
7+
unsupportedTitle: { defaultMessage: 'Unsupported Mermaid diagram' },
8+
unsupportedDescription: { defaultMessage: 'Unsupported diagram, will generate SVG instead' },
9+
})
10+
11+
export function SneakyMermaidHandler() {
12+
const editor = useEditor()
13+
const { addToast } = useToasts()
14+
const unsupportedTitle = useMsg(messages.unsupportedTitle)
15+
const unsupportedDescription = useMsg(messages.unsupportedDescription)
16+
17+
useEffect(() => {
18+
editor.registerExternalContentHandler('text', async (content) => {
19+
if (!simpleMermaidStringTest(content.text)) {
20+
await defaultHandleExternalTextContent(editor, content)
21+
return
22+
}
23+
const { createMermaidDiagram } = await import('@tldraw/mermaid')
24+
const shapesBefore = new Set(editor.getCurrentPageShapeIds())
25+
26+
const selectNewShapes = () => {
27+
const newShapeIds = [...editor.getCurrentPageShapeIds()].filter(
28+
(id) => !shapesBefore.has(id)
29+
)
30+
if (newShapeIds.length) {
31+
editor.setSelectedShapes(newShapeIds)
32+
}
33+
}
34+
35+
try {
36+
const onUnsupportedDiagram = async (svgString: string) => {
37+
await editor.putExternalContent({
38+
type: 'svg-text',
39+
text: svgString,
40+
point: content.point,
41+
sources: content.sources,
42+
})
43+
addToast({
44+
id: 'unsupported-mermaid-diagram',
45+
title: unsupportedTitle,
46+
description: unsupportedDescription,
47+
severity: 'warning',
48+
})
49+
}
50+
51+
await createMermaidDiagram(editor, content.text, { onUnsupportedDiagram })
52+
selectNewShapes()
53+
} catch (e) {
54+
console.error(e)
55+
await defaultHandleExternalTextContent(editor, content)
56+
}
57+
})
58+
}, [editor, addToast, unsupportedTitle, unsupportedDescription])
59+
60+
return null
61+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { simpleMermaidStringTest } from './simpleMermaidStringTest'
2+
3+
describe('simpleMermaidStringTest', () => {
4+
describe('bare keywords', () => {
5+
const keywords = [
6+
['flowchart', 'flowchart TD\n A --> B'],
7+
['graph', 'graph LR\n A --> B'],
8+
['sequenceDiagram', 'sequenceDiagram\n Alice->>Bob: Hi'],
9+
['stateDiagram', 'stateDiagram-v2\n [*] --> Idle'],
10+
['classDiagram', 'classDiagram\n class Animal'],
11+
['erDiagram', 'erDiagram\n CUSTOMER ||--o{ ORDER : places'],
12+
['journey', 'journey\n title My day'],
13+
['gantt', 'gantt\n title A Gantt\n dateFormat YYYY-MM-DD'],
14+
['pie', 'pie\n "Dogs" : 386'],
15+
['gitGraph', 'gitGraph\n commit'],
16+
['mindmap', 'mindmap\n root((Central))'],
17+
['timeline', 'timeline\n title History'],
18+
['sankey', 'sankey-beta'],
19+
['xychart', 'xychart-beta'],
20+
['block', 'block-beta'],
21+
['quadrantChart', 'quadrantChart'],
22+
['requirement', 'requirement test_req'],
23+
['C4Context', 'C4Context\n Person(user, "User")'],
24+
['C4Container', 'C4Container'],
25+
['C4Component', 'C4Component'],
26+
['C4Dynamic', 'C4Dynamic'],
27+
['C4Deployment', 'C4Deployment'],
28+
['packet', 'packet-beta'],
29+
['kanban', 'kanban'],
30+
['architecture', 'architecture-beta'],
31+
['treemap', 'treemap-beta'],
32+
['radar', 'radar-beta'],
33+
['info', 'info'],
34+
] as const
35+
36+
for (const [keyword, input] of keywords) {
37+
it(`detects "${keyword}"`, () => {
38+
expect(simpleMermaidStringTest(input)).toBe(true)
39+
})
40+
}
41+
})
42+
43+
describe('with boilerplate stripped', () => {
44+
it('detects keyword after YAML frontmatter', () => {
45+
const text = '---\ntitle: test\n---\nflowchart TD\n A --> B'
46+
expect(simpleMermaidStringTest(text)).toBe(true)
47+
})
48+
49+
it('detects keyword after %%{init}%% directive', () => {
50+
const text = '%%{init: {"theme":"dark"}}%%\nsequenceDiagram\n Alice->>Bob: Hi'
51+
expect(simpleMermaidStringTest(text)).toBe(true)
52+
})
53+
54+
it('detects keyword after %% line comments', () => {
55+
const text = '%% this is a comment\nflowchart LR\n A --> B'
56+
expect(simpleMermaidStringTest(text)).toBe(true)
57+
})
58+
59+
it('detects keyword after frontmatter + directive + comment combined', () => {
60+
const text = [
61+
'---',
62+
'title: combo',
63+
'---',
64+
'%%{init: {"theme":"forest"}}%%',
65+
'%% a comment',
66+
'stateDiagram-v2',
67+
' [*] --> Idle',
68+
].join('\n')
69+
expect(simpleMermaidStringTest(text)).toBe(true)
70+
})
71+
72+
it('detects keyword with leading whitespace', () => {
73+
expect(simpleMermaidStringTest(' flowchart TD\n A --> B')).toBe(true)
74+
expect(simpleMermaidStringTest('\t\tgantt\n title Plan')).toBe(true)
75+
})
76+
})
77+
78+
describe('negative cases', () => {
79+
it('rejects plain English text', () => {
80+
expect(simpleMermaidStringTest('Hello world')).toBe(false)
81+
})
82+
83+
it('rejects text that merely mentions a keyword', () => {
84+
expect(simpleMermaidStringTest('Let me think about flowcharts')).toBe(false)
85+
expect(simpleMermaidStringTest('My flowchart TD\n A --> B')).toBe(false)
86+
})
87+
88+
it('rejects empty string', () => {
89+
expect(simpleMermaidStringTest('')).toBe(false)
90+
})
91+
92+
it('rejects whitespace-only string', () => {
93+
expect(simpleMermaidStringTest(' \n\t\n ')).toBe(false)
94+
})
95+
96+
it('rejects JSON containing a keyword', () => {
97+
expect(simpleMermaidStringTest('{"type": "flowchart", "nodes": []}')).toBe(false)
98+
})
99+
100+
it('rejects HTML containing a keyword', () => {
101+
expect(simpleMermaidStringTest('<div class="flowchart">content</div>')).toBe(false)
102+
})
103+
104+
it('rejects a markdown code block wrapping mermaid', () => {
105+
const text = '```mermaid\nflowchart TD\n A --> B\n```'
106+
expect(simpleMermaidStringTest(text)).toBe(false)
107+
})
108+
})
109+
})
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Lightweight mermaid detection replicating mermaid's own detectType preprocessing
3+
* (from mermaid v11.12.2 src/diagram-api/detectType.ts and src/diagram-api/regexes.ts).
4+
*
5+
* https://github.com/mermaid-js/mermaid/blob/277c4967f97405e9bb172c0a2f67f462a672b162/packages/mermaid/src/diagram-api/detectType.ts
6+
* https://github.com/mermaid-js/mermaid/blob/277c4967f97405e9bb172c0a2f67f462a672b162/packages/mermaid/src/diagram-api/regexes.ts
7+
*
8+
* Strips YAML frontmatter, %%{...}%% directives, and %% comments, then tests for
9+
* a known diagram keyword at the start of the cleaned text.
10+
*
11+
* This file intentionally has zero imports so it can be loaded statically without
12+
* pulling in the heavy mermaid library.
13+
*/
14+
const FRONTMATTER_REGEX = /^-{3}\s*[\n\r]([\s\S]*?)[\n\r]-{3}\s*[\n\r]+/
15+
const DIAGRAM_KEYWORD_REGEX =
16+
/^\s*(flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|erDiagram|journey|gantt|pie|gitGraph|mindmap|timeline|sankey|xychart|block|quadrantChart|requirement|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|packet|kanban|architecture|treemap|radar|info)/
17+
18+
/**
19+
* Strip mermaid boilerplate (frontmatter, directives, comments) so only the
20+
* diagram body remains. The two global regexes are created as fresh literals
21+
* each call to avoid the stateful-lastIndex footgun of module-level /g regexes.
22+
*/
23+
function stripMermaidBoilerplate(text: string): string {
24+
return text
25+
.replace(FRONTMATTER_REGEX, '')
26+
.replace(/%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi, '')
27+
.replace(/\s*%%.*\n/gm, '\n')
28+
}
29+
30+
export function simpleMermaidStringTest(text: string): boolean {
31+
return DIAGRAM_KEYWORD_REGEX.test(stripMermaidBoilerplate(text))
32+
}

apps/dotcom/client/src/tla/components/TlaEditor/TlaEditor.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
} from 'tldraw'
2424
import { ThemeUpdater } from '../../../components/ThemeUpdater/ThemeUpdater'
2525

26+
import { SneakyMermaidHandler } from '../../../components/SneakyMermaidHandler/SneakyMermaidHandler'
2627
import { useOpenUrlAndTrack } from '../../../hooks/useOpenUrlAndTrack'
2728
import { useRoomLoadTracking } from '../../../hooks/useRoomLoadTracking'
2829
import { trackEvent, useHandleUiEvents } from '../../../utils/analytics'
@@ -261,6 +262,7 @@ function TlaEditorInner({ fileSlug, deepLinks }: TlaEditorProps) {
261262
<ThemeUpdater />
262263
<SneakyDarkModeSync />
263264
<SneakyToolSwitcher />
265+
<SneakyMermaidHandler />
264266
{app && <SneakyTldrawFileDropHandler />}
265267
<SneakyLargeFileHander />
266268
<SneakyDebugModeToast />

apps/dotcom/client/tsconfig.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"references": [
2525
{ "path": "../../../packages/assets" },
2626
{ "path": "../../../packages/dotcom-shared" },
27+
{ "path": "../../../packages/mermaid" },
2728
{ "path": "../../../packages/sync" },
2829
{ "path": "../../../packages/sync-core" },
2930
{ "path": "../../../packages/tldraw" },

apps/examples/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"@tldraw/assets": "workspace:*",
5050
"@tldraw/dotcom-shared": "workspace:*",
5151
"@tldraw/driver": "workspace:*",
52+
"@tldraw/mermaid": "workspace:*",
5253
"@tldraw/state": "workspace:*",
5354
"@tldraw/sync": "workspace:*",
5455
"ag-grid-community": "^32.3.3",
@@ -58,6 +59,7 @@
5859
"d3-geo": "^3.1.1",
5960
"lazyrepo": "0.0.0-alpha.27",
6061
"lodash": "^4.17.21",
62+
"mermaid": "11.12.2",
6163
"pdf-lib": "^1.17.1",
6264
"pdfjs-dist": "^4.10.38",
6365
"radix-ui": "^1.4.2",

0 commit comments

Comments
 (0)