Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ describe('simpleMermaidStringTest', () => {
const text = '```mermaid\n%%{init: {"theme":"dark"}}%%\nflowchart LR\n A --> B\n```'
expect(simpleMermaidStringTest(text)).toBe(true)
})

it('does not consume later non-mermaid fences in the same paste', () => {
const text = [
'```mermaid',
'flowchart TD',
' A --> B',
'```',
'',
'```javascript',
'const x = 1',
'```',
].join('\n')
expect(simpleMermaidStringTest(text)).toBe(true)
})
})

describe('negative cases', () => {
Expand Down Expand Up @@ -127,6 +141,11 @@ describe('simpleMermaidStringTest', () => {
const text = '```javascript\nconst x = 1\n```'
expect(simpleMermaidStringTest(text)).toBe(false)
})

it('rejects a fence label that is not lowercase mermaid', () => {
const text = '```MERMAID\npie\n "A" : 1\n```'
expect(simpleMermaidStringTest(text)).toBe(false)
})
})
})

Expand All @@ -150,4 +169,34 @@ describe('stripMarkdownMermaidFence', () => {
const text = '````mermaid\ngantt\n title Plan\n````'
expect(stripMarkdownMermaidFence(text)).toBe('gantt\n title Plan')
})

it('stops at the first closing fence when more blocks follow', () => {
const text = [
'```mermaid',
'flowchart TD',
' A --> B',
'```',
'',
'# More',
'',
'```js',
'x',
'```',
].join('\n')
expect(stripMarkdownMermaidFence(text)).toBe('flowchart TD\n A --> B')
})

it('does not close on a shorter inner fence than the opening run', () => {
const text = ['````mermaid', 'flowchart TD', ' A --> B', '```', ' note text', '````'].join(
'\n'
)
expect(stripMarkdownMermaidFence(text)).toBe(
['flowchart TD', ' A --> B', '```', ' note text'].join('\n')
)
})

it('handles CRLF line endings in the fence', () => {
const text = '```mermaid\r\nflowchart TD\r\n A --> B\r\n```'
expect(stripMarkdownMermaidFence(text)).toBe('flowchart TD\r\n A --> B')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,13 @@ const DIAGRAM_KEYWORD_REGEX =
/^\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)/

/**
* Captures the inner content of a mermaid code blog marked by ```mermaid
* Leading ```mermaid (or longer run) fence, closed by the first line that ends
* the same run length (CommonMark-style: inner shorter ``` lines do not close a
* longer fence). Trailing markdown after the block is allowed so multi-block
* pastes do not pull in later fences. Group 1 = fence run, group 2 = diagram body.
*/
const MARKDOWN_MERMAID_FENCE_REGEX = /^\s*```+\s*mermaid\s*\n([\s\S]*?)\n\s*```+\s*$/
const MARKDOWN_MERMAID_FENCE_REGEX =
/^\s*(```+)\s*mermaid\s*\r?\n([\s\S]*?)\r?\n\s*\1\s*(?:[\s\S]*)$/

/**
* Strip mermaid boilerplate (frontmatter, directives, comments) so only the
Expand All @@ -34,7 +38,7 @@ function stripMermaidBoilerplate(text: string): string {

export function stripMarkdownMermaidFence(text: string): string {
const match = text.match(MARKDOWN_MERMAID_FENCE_REGEX)
return match ? match[1] : text
return match ? match[2] : text
}

export function simpleMermaidStringTest(text: string): boolean {
Expand Down
2 changes: 1 addition & 1 deletion apps/examples/e2e/tests/export-mermaid-snapshots.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Page, expect } from '@playwright/test'
import assert from 'assert'
import { rename, writeFile } from 'fs/promises'
import { Page, expect } from '@playwright/test'
import { Editor } from 'tldraw'
import mermaidDefinitions from '../../src/examples/use-cases/hundred-mermaids/mermaids'
import test, { ApiFixture } from '../fixtures/fixtures'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/* eslint-disable react-hooks/rules-of-hooks */
/**
* Mermaid flowchart → DAG pipeline demo: paste flowchart/graph source, apply to the canvas, run simulated steps.
* - `blueprintRender.mapNodeToRenderSpec` maps each flowchart vertex to `flowchart-util` + `mermaidNodeId`.
* - After import, graph and layer badges come from arrows + bindings (`extractFlowchartPipelineFromEditor`).
*/
import { useCallback, useState } from 'react'
import { TLComponents, Tldraw, TldrawUiButton, useEditor, useValue } from 'tldraw'
import 'tldraw/tldraw.css'
import { FlowchartShapeUtil } from './customMermaidShapeUtil'
import './custom-shape-mermaid.css'
import { getFlowchartSourceError } from './flowchartSourceGuard'
import { mapNodeToRenderSpec } from './mermaidPipelineBlueprint'
import { type StepStatus, pipelineStateAtom, runFullPipeline } from './mermaidPipelineState'
import { applyPipelineStepIndices, extractFlowchartPipelineFromEditor } from './pipelineFromEditor'

const components: TLComponents = {
TopPanel: TopPanel,
}

const customShapes = [FlowchartShapeUtil]

const DEFAULT_MERMAID = `flowchart LR
s1[Checkout] --> s2[Build]
s2 --> s3[Unit tests]
s2 --> s4[Integration tests]
s3 --> s5[Deploy]
s4 --> s5`

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

function TopPanel() {
const editor = useEditor()
const [mermaidText, setMermaidText] = useState(DEFAULT_MERMAID)
const [isApplying, setIsApplying] = useState(false)
const pipeline = useValue(pipelineStateAtom)

const canRun =
!pipeline.parseError && pipeline.nodeIds.length > 0 && !pipeline.isRunning && !isApplying

const applyWorkflow = useCallback(async () => {
if (isApplying) return
setIsApplying(true)

try {
const sourceError = getFlowchartSourceError(mermaidText)
if (sourceError) {
pipelineStateAtom.set({
nodeIds: [],
edges: [],
statusByNodeId: {},
parseError: sourceError,
isRunning: false,
})
return
}

const [{ createMermaidDiagram }, { default: mermaid }] = await Promise.all([
import('@tldraw/mermaid'),
import('mermaid'),
])
mermaid.initialize({
startOnLoad: false,
flowchart: { useMaxWidth: false, nodeSpacing: 80, rankSpacing: 80, padding: 20 },
})

editor.selectAll()
editor.deleteShapes(editor.getSelectedShapes())

try {
await createMermaidDiagram(editor, mermaidText, {
blueprintRender: {
position: { x: 200, y: 400 },
centerOnPosition: false,
mapNodeToRenderSpec,
},
})
} catch {
pipelineStateAtom.set({
nodeIds: [],
edges: [],
statusByNodeId: {},
parseError: `An error occurred; please make sure your diagram is valid.`,
isRunning: false,
})
return
}

const parsed = extractFlowchartPipelineFromEditor(editor)
if (!parsed.ok) {
pipelineStateAtom.set({
nodeIds: [],
edges: [],
statusByNodeId: {},
parseError: parsed.error,
isRunning: false,
})
} else {
pipelineStateAtom.set({
nodeIds: parsed.nodeIds,
edges: parsed.edges,
statusByNodeId: Object.fromEntries(
parsed.nodeIds.map((id) => [id, 'pending' as const])
) as Record<string, StepStatus>,
parseError: null,
isRunning: false,
})
applyPipelineStepIndices(editor, parsed.stepIndexByNodeId)
}

editor.selectNone()
} catch {
pipelineStateAtom.update((s) => ({
...s,
parseError: `An error occurred; please make sure your diagram is valid.`,
}))
} finally {
setIsApplying(false)
}
}, [editor, isApplying, mermaidText])

const runPipeline = useCallback(() => {
void runFullPipeline()
}, [])

return (
<div className="custom-shape-mermaid">
<div>
Paste a Mermaid <strong>flowchart</strong> or <strong>graph</strong> (branching is ok; merge
nodes run after <strong>all</strong> incoming steps pass). Apply runs Mermaid import, then
builds the DAG from <strong>arrows on the canvas</strong>. Run simulates steps; failures can
be retried on the shape. Step badges are Kahn layers, not a global sequence. Status is only
in memory for this demo.
</div>
<textarea
value={mermaidText}
onChange={(e) => setMermaidText(e.target.value)}
rows={7}
spellCheck={false}
className="custom-shape-mermaid__textarea"
/>
{pipeline.parseError && (
<div className="custom-shape-mermaid__error">{pipeline.parseError}</div>
)}
<div className="custom-shape-mermaid__controls">
<TldrawUiButton type="normal" onClick={applyWorkflow} disabled={isApplying}>
{isApplying ? 'Applying…' : 'Apply workflow'}
</TldrawUiButton>
<TldrawUiButton type="low" onClick={runPipeline} disabled={!canRun}>
Run pipeline
</TldrawUiButton>
</div>
{pipeline.isRunning && (
<div className="custom-shape-mermaid__notice">Running simulated steps…</div>
)}
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: Mermaid sequential pipeline with custom shapes
component: ./CustomShapeMermaids.tsx
priority: 10
keywords: [mermaid, diagram, custom, pipeline, workflow]
---

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.

---

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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
.custom-shape-mermaid {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 10px 12px;
background: #f1f3f4;
border-radius: 0 0 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 440px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
font-size: 12px;
color: #444;
line-height: 1.4;
}

.custom-shape-mermaid__textarea {
width: 100%;
font-family: ui-monospace, monospace;
padding: 8px;
border-radius: 4px;
border: 1px solid #ccc;
resize: vertical;
box-sizing: border-box;
pointer-events: auto;
}

.custom-shape-mermaid__error {
color: #842029;
background: #f8d7da;
padding: 8px;
border-radius: 4px;
}

.custom-shape-mermaid__controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

.custom-shape-mermaid__notice {
font-size: 12px;
color: #666;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* Custom flowchart node rendered by FlowchartShapeUtil (canvas HTML overlay). */

.flowchart-util-shape {
position: relative;
/* `.tl-html-container` uses pointer-events: none; override so Retry and label interact. */
pointer-events: all;
border-radius: 6px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 6px;
overflow: hidden;
}

.flowchart-util-shape--pending {
background-color: #e8eaed;
border: 1px solid rgba(0, 0, 0, 0.12);
}

.flowchart-util-shape--running {
background-color: #fff3cd;
border: 2px solid #856404;
}

.flowchart-util-shape--passed {
background-color: #d1e7dd;
border: 1px solid rgba(0, 0, 0, 0.12);
}

.flowchart-util-shape--failed {
background-color: #f8d7da;
border: 2px solid #842029;
}

.flowchart-util-shape__step-badge {
position: absolute;
top: 4px;
right: 4px;
z-index: 2;
font-size: 10px;
font-weight: 600;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.06);
color: #333;
pointer-events: none;
}

.flowchart-util-shape__label {
position: relative;
flex: 1;
align-self: stretch;
min-height: 0;
width: 100%;
}

.flowchart-util-shape__retry {
margin-bottom: 6px;
cursor: pointer;
}
Loading
Loading