Skip to content

Commit e3e9618

Browse files
authored
refactor(@tldraw/mermaid): streamline node creation for extensibility (tldraw#8322)
Refactor the code around blueprint creation as well as the rendering by making sure shape types are defined at one level only, the blueprint creation, so we can easily allow extensibility through passing a function that can map each diagram node types to different, preferred shapes, or simply custom shapes. Also allows to pass a function to take full control of the node creation, if ever needed. ### Boring bullet points! - Add MermaidBlueprintNode.render (geo vs custom shape type + props) and diagramKind on the blueprint IR. - Optional per-diagram mapNodeToRenderSpec when building blueprints; default mapping matches previous geo behavior. - renderBlueprint creates nodes from node.render; optional blueprintRender.createShape for full override (shapeId contract preserved for arrows). - Export helpers: defaultMermaidNodeRenderSpec, resolveMermaidNodeRender, defaultCreateMermaidNodeFromBlueprint, MERMAID_MINDMAP_NODE_TYPE. - Update README, api-report, and tests. ### Example page Added an example page which show how custom shapes can be used to create "on the fly" CI pipeline workflows with mermaid diagram. https://github.com/user-attachments/assets/861179c2-7757-4657-b39c-e071a14e8756 ### API changes - createMermaidDiagram options: add flowchart, state, sequence, mindmap with optional mapNodeToRenderSpec each. - BlueprintRenderingOptions: add optional createShape (MermaidNodeCreateFunction) to override default node creation from the blueprint. ### Change type - [ ] `bugfix` - [x] `improvement` - [ ] `feature` - [x] `api` - [ ] `other` ### Test plan - [x] Unit tests - [ ] End to end tests ### Release notes - createMermaidDiagram options: add flowchart, state, sequence, mindmap with optional mapNodeToRenderSpec each. - BlueprintRenderingOptions: add optional createShape (MermaidNodeCreateFunction) to override default node creation from the blueprint.
1 parent 06045e9 commit e3e9618

24 files changed

Lines changed: 1414 additions & 222 deletions

apps/examples/e2e/tests/export-mermaid-snapshots.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Page, expect } from '@playwright/test'
21
import assert from 'assert'
32
import { rename, writeFile } from 'fs/promises'
3+
import { Page, expect } from '@playwright/test'
44
import { Editor } from 'tldraw'
55
import mermaidDefinitions from '../../src/examples/use-cases/hundred-mermaids/mermaids'
66
import test, { ApiFixture } from '../fixtures/fixtures'
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/* eslint-disable react-hooks/rules-of-hooks */
2+
/**
3+
* Mermaid flowchart → DAG pipeline demo: paste flowchart/graph source, apply to the canvas, run simulated steps.
4+
* - `blueprintRender.mapNodeToRenderSpec` maps each flowchart vertex to `flowchart-util` + `mermaidNodeId`.
5+
* - After import, graph and layer badges come from arrows + bindings (`extractFlowchartPipelineFromEditor`).
6+
*/
7+
import { useCallback, useState } from 'react'
8+
import { TLComponents, Tldraw, TldrawUiButton, useEditor, useValue } from 'tldraw'
9+
import 'tldraw/tldraw.css'
10+
import { FlowchartShapeUtil } from './customMermaidShapeUtil'
11+
import './custom-shape-mermaid.css'
12+
import { getFlowchartSourceError } from './flowchartSourceGuard'
13+
import { mapNodeToRenderSpec } from './mermaidPipelineBlueprint'
14+
import { type StepStatus, pipelineStateAtom, runFullPipeline } from './mermaidPipelineState'
15+
import { applyPipelineStepIndices, extractFlowchartPipelineFromEditor } from './pipelineFromEditor'
16+
17+
const components: TLComponents = {
18+
TopPanel: TopPanel,
19+
}
20+
21+
const customShapes = [FlowchartShapeUtil]
22+
23+
const DEFAULT_MERMAID = `flowchart LR
24+
s1[Checkout] --> s2[Build]
25+
s2 --> s3[Unit tests]
26+
s2 --> s4[Integration tests]
27+
s3 --> s5[Deploy]
28+
s4 --> s5`
29+
30+
export default function MermaidDiagramsCustomShapes() {
31+
return (
32+
<div className="tldraw__editor">
33+
<Tldraw components={components} shapeUtils={customShapes} />
34+
</div>
35+
)
36+
}
37+
38+
function TopPanel() {
39+
const editor = useEditor()
40+
const [mermaidText, setMermaidText] = useState(DEFAULT_MERMAID)
41+
const [isApplying, setIsApplying] = useState(false)
42+
const pipeline = useValue(pipelineStateAtom)
43+
44+
const canRun =
45+
!pipeline.parseError && pipeline.nodeIds.length > 0 && !pipeline.isRunning && !isApplying
46+
47+
const applyWorkflow = useCallback(async () => {
48+
if (isApplying) return
49+
setIsApplying(true)
50+
51+
try {
52+
const sourceError = getFlowchartSourceError(mermaidText)
53+
if (sourceError) {
54+
pipelineStateAtom.set({
55+
nodeIds: [],
56+
edges: [],
57+
statusByNodeId: {},
58+
parseError: sourceError,
59+
isRunning: false,
60+
})
61+
return
62+
}
63+
64+
const [{ createMermaidDiagram }, { default: mermaid }] = await Promise.all([
65+
import('@tldraw/mermaid'),
66+
import('mermaid'),
67+
])
68+
mermaid.initialize({
69+
startOnLoad: false,
70+
flowchart: { useMaxWidth: false, nodeSpacing: 80, rankSpacing: 80, padding: 20 },
71+
})
72+
73+
editor.selectAll()
74+
editor.deleteShapes(editor.getSelectedShapes())
75+
76+
try {
77+
await createMermaidDiagram(editor, mermaidText, {
78+
blueprintRender: {
79+
position: { x: 200, y: 400 },
80+
centerOnPosition: false,
81+
mapNodeToRenderSpec,
82+
},
83+
})
84+
} catch {
85+
pipelineStateAtom.set({
86+
nodeIds: [],
87+
edges: [],
88+
statusByNodeId: {},
89+
parseError: `An error occurred; please make sure your diagram is valid.`,
90+
isRunning: false,
91+
})
92+
return
93+
}
94+
95+
const parsed = extractFlowchartPipelineFromEditor(editor)
96+
if (!parsed.ok) {
97+
pipelineStateAtom.set({
98+
nodeIds: [],
99+
edges: [],
100+
statusByNodeId: {},
101+
parseError: parsed.error,
102+
isRunning: false,
103+
})
104+
} else {
105+
pipelineStateAtom.set({
106+
nodeIds: parsed.nodeIds,
107+
edges: parsed.edges,
108+
statusByNodeId: Object.fromEntries(
109+
parsed.nodeIds.map((id) => [id, 'pending' as const])
110+
) as Record<string, StepStatus>,
111+
parseError: null,
112+
isRunning: false,
113+
})
114+
applyPipelineStepIndices(editor, parsed.stepIndexByNodeId)
115+
}
116+
117+
editor.selectNone()
118+
} catch {
119+
pipelineStateAtom.update((s) => ({
120+
...s,
121+
parseError: `An error occurred; please make sure your diagram is valid.`,
122+
}))
123+
} finally {
124+
setIsApplying(false)
125+
}
126+
}, [editor, isApplying, mermaidText])
127+
128+
const runPipeline = useCallback(() => {
129+
void runFullPipeline()
130+
}, [])
131+
132+
return (
133+
<div className="custom-shape-mermaid">
134+
<div>
135+
Paste a Mermaid <strong>flowchart</strong> or <strong>graph</strong> (branching is ok; merge
136+
nodes run after <strong>all</strong> incoming steps pass). Apply runs Mermaid import, then
137+
builds the DAG from <strong>arrows on the canvas</strong>. Run simulates steps; failures can
138+
be retried on the shape. Step badges are Kahn layers, not a global sequence. Status is only
139+
in memory for this demo.
140+
</div>
141+
<textarea
142+
value={mermaidText}
143+
onChange={(e) => setMermaidText(e.target.value)}
144+
rows={7}
145+
spellCheck={false}
146+
className="custom-shape-mermaid__textarea"
147+
/>
148+
{pipeline.parseError && (
149+
<div className="custom-shape-mermaid__error">{pipeline.parseError}</div>
150+
)}
151+
<div className="custom-shape-mermaid__controls">
152+
<TldrawUiButton type="normal" onClick={applyWorkflow} disabled={isApplying}>
153+
{isApplying ? 'Applying…' : 'Apply workflow'}
154+
</TldrawUiButton>
155+
<TldrawUiButton type="low" onClick={runPipeline} disabled={!canRun}>
156+
Run pipeline
157+
</TldrawUiButton>
158+
</div>
159+
{pipeline.isRunning && (
160+
<div className="custom-shape-mermaid__notice">Running simulated steps…</div>
161+
)}
162+
</div>
163+
)
164+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
title: Mermaid sequential pipeline with custom shapes
3+
component: ./CustomShapeMermaids.tsx
4+
priority: 10
5+
keywords: [mermaid, diagram, custom, pipeline, workflow]
6+
---
7+
8+
Paste a Mermaid **flowchart** or **graph**, run a simulated CI-style **DAG** on the nodes (merge = wait for all parents), and retry failed steps from the canvas.
9+
10+
---
11+
12+
This example accepts **flowchart / graph** source only (validated before import). It uses `@tldraw/mermaid` with `blueprintRender.mapNodeToRenderSpec` so vertices become custom `flowchart-util` shapes with `mermaidNodeId`. After import, **edges** must form a **DAG** (no cycles). Step **schedule** follows **AND-join** semantics: a node runs when all its predecessors have **passed**. **Step badges** are **Kahn layers** (same layer = same badge number). The graph is read from **tldraw arrow bindings** (`extractFlowchartPipelineFromEditor`), not from parsing edge syntax in the text box.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
.custom-shape-mermaid {
2+
position: fixed;
3+
top: 0;
4+
left: 50%;
5+
transform: translateX(-50%);
6+
z-index: 1000;
7+
padding: 10px 12px;
8+
background: #f1f3f4;
9+
border-radius: 0 0 8px 8px;
10+
display: flex;
11+
flex-direction: column;
12+
gap: 8px;
13+
max-width: 440px;
14+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
15+
font-size: 12px;
16+
color: #444;
17+
line-height: 1.4;
18+
}
19+
20+
.custom-shape-mermaid__textarea {
21+
width: 100%;
22+
font-family: ui-monospace, monospace;
23+
padding: 8px;
24+
border-radius: 4px;
25+
border: 1px solid #ccc;
26+
resize: vertical;
27+
box-sizing: border-box;
28+
pointer-events: auto;
29+
}
30+
31+
.custom-shape-mermaid__error {
32+
color: #842029;
33+
background: #f8d7da;
34+
padding: 8px;
35+
border-radius: 4px;
36+
}
37+
38+
.custom-shape-mermaid__controls {
39+
display: flex;
40+
flex-wrap: wrap;
41+
gap: 8px;
42+
}
43+
44+
.custom-shape-mermaid__notice {
45+
font-size: 12px;
46+
color: #666;
47+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/* Custom flowchart node rendered by FlowchartShapeUtil (canvas HTML overlay). */
2+
3+
.flowchart-util-shape {
4+
position: relative;
5+
/* `.tl-html-container` uses pointer-events: none; override so Retry and label interact. */
6+
pointer-events: all;
7+
border-radius: 6px;
8+
display: flex;
9+
flex-direction: column;
10+
justify-content: center;
11+
align-items: center;
12+
gap: 6px;
13+
overflow: hidden;
14+
}
15+
16+
.flowchart-util-shape--pending {
17+
background-color: #e8eaed;
18+
border: 1px solid rgba(0, 0, 0, 0.12);
19+
}
20+
21+
.flowchart-util-shape--running {
22+
background-color: #fff3cd;
23+
border: 2px solid #856404;
24+
}
25+
26+
.flowchart-util-shape--passed {
27+
background-color: #d1e7dd;
28+
border: 1px solid rgba(0, 0, 0, 0.12);
29+
}
30+
31+
.flowchart-util-shape--failed {
32+
background-color: #f8d7da;
33+
border: 2px solid #842029;
34+
}
35+
36+
.flowchart-util-shape__step-badge {
37+
position: absolute;
38+
top: 4px;
39+
right: 4px;
40+
z-index: 2;
41+
font-size: 10px;
42+
font-weight: 600;
43+
line-height: 1;
44+
padding: 2px 6px;
45+
border-radius: 4px;
46+
background: rgba(0, 0, 0, 0.06);
47+
color: #333;
48+
pointer-events: none;
49+
}
50+
51+
.flowchart-util-shape__label {
52+
position: relative;
53+
flex: 1;
54+
align-self: stretch;
55+
min-height: 0;
56+
width: 100%;
57+
}
58+
59+
.flowchart-util-shape__retry {
60+
margin-bottom: 6px;
61+
cursor: pointer;
62+
}

0 commit comments

Comments
 (0)