snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

214
packages/mermaid/README.md Normal file
View File

@@ -0,0 +1,214 @@
# @tldraw/mermaid
Convert [Mermaid](https://mermaid.js.org/) diagram syntax into native, editable tldraw shapes.
Instead of rendering a static SVG, `@tldraw/mermaid` parses Mermaid text and creates real geo shapes, arrows, frames, and groups on the tldraw canvas — so users can move, resize, restyle, and connect them like any other shape.
## Installation
```bash
npm i @tldraw/mermaid
```
**Peer dependencies:** your app must also include compatible versions of:
- **`tldraw`** — the SDK this package plugs into
- **`react`** — `^18.2.0` or `^19.2.1`
- **`react-dom`** — `^18.2.0` or `^19.2.1`
## Quick start
```tsx
import { createMermaidDiagram } from '@tldraw/mermaid'
// Inside a component or callback with access to the editor:
await createMermaidDiagram(
editor,
`
flowchart TD
A[Start] --> B{Decision}
B -->|Yes| C[Do something]
B -->|No| D[Do something else]
`
)
```
By default, shapes are centered on the viewport. You can control placement with `blueprintRender`:
```tsx
// Center the diagram on a specific point (default behavior)
await createMermaidDiagram(editor, diagramText, {
blueprintRender: {
position: { x: 500, y: 300 },
centerOnPosition: true,
},
})
// Place the diagram's top-left corner at the given point
await createMermaidDiagram(editor, diagramText, {
blueprintRender: {
position: { x: 0, y: 0 },
centerOnPosition: false,
},
})
```
When `centerOnPosition` is `true` (the default), the diagram's center aligns with the given position. When `false`, the diagram's top-left corner aligns with it instead. If no `position` is provided, the diagram uses the viewport center — or the cursor position if the user has "paste at cursor" mode enabled.
## Supported diagram types
| Diagram type | Mermaid keyword | What you get |
| ---------------- | -------------------- | -------------------------------------------------------------------------- |
| Flowchart | `flowchart`, `graph` | Geo shapes, arrows, subgraph frames |
| Sequence diagram | `sequenceDiagram` | Actor shapes, lifelines, signal arrows, fragment frames, participant boxes |
| State diagram | `stateDiagram-v2` | State shapes, transitions, compound state frames, fork/join, choice |
| Mindmap | `mindmap` | Colored geo shapes, parent-child edges, tree hierarchy |
Unsupported diagram types (pie, gantt, class, ER, etc.) can be handled with the `onUnsupportedDiagram` callback — for example, to fall back to SVG import:
```tsx
await createMermaidDiagram(editor, text, {
onUnsupportedDiagram(svgString) {
editor.putExternalContent({ type: 'svg-text', text: svgString })
},
})
```
## API
### `createMermaidDiagram(editor, text, options?)`
Parses Mermaid text, extracts layout from the rendered SVG, builds a blueprint, and creates tldraw shapes.
- **`editor`** — a tldraw `Editor` instance.
- **`text`** — Mermaid diagram source text.
- **`options`** — optional `MermaidDiagramOptions`:
- `mermaidConfig` — Mermaid configuration overrides (theme, spacing, etc.).
- `blueprintRender` — positioning (`position`, `centerOnPosition`) and optional `mapNodeToRenderSpec` (see below).
- `onUnsupportedDiagram(svg)` — callback when the diagram type isn't natively supported.
Throws `MermaidDiagramError` on parse failure or unsupported diagram type (if no callback is provided).
### `renderBlueprint(editor, blueprint, opts?)`
Renders a pre-built `DiagramMermaidBlueprint` into the editor. Useful if you want to construct or modify a blueprint programmatically before rendering. Options include `mapNodeToRenderSpec` to customize how each node is materialized (see below).
### `MermaidDiagramError`
Error class with `type: 'parse' | 'unsupported'` and `diagramType` properties.
### Customizing node shapes
Blueprint nodes carry layout and semantic `kind` only. At **`renderBlueprint`** time, each node is mapped to a **`MermaidBlueprintNodeRenderSpec`**: either `{ variant: 'geo', geo }` (defaults use the same built-in tables as before) or `{ variant: 'shape', type, props }` for any shape type registered on your editor.
Pass **`mapNodeToRenderSpec`** on **`blueprintRender`** (or to `renderBlueprint` directly) with a callback `(input) => spec | undefined`. **`input`** includes `diagramKind`, `nodeId`, `kind`, and the full **`node`**. Return `undefined` to keep the package default for that node. **`defaultCreateMermaidNodeFromBlueprint`** then merges the spec with layout (size, colors, label text) and calls **`editor.createShape`** once per node.
Custom `type` values must exist on the editor before import. Exotic shapes may need compatible geometry for arrow bindings to feel like built-in geo nodes.
Helpers: **`defaultMermaidNodeRenderSpec`**, **`resolveMermaidNodeRender`**, **`MERMAID_MINDMAP_NODE_TYPE`** (mindmap `kind` strings match `String` of these integers).
### Types
- **`DiagramMermaidBlueprint`** — `{ diagramKind, nodes, edges, lines?, groups? }`.
- **`MermaidBlueprintNode`** — layout, semantic `kind`, and style fields (materialization is chosen at render time).
- **`MermaidBlueprintNodeRenderSpec`** — `variant: 'geo'` + `geo`, or `variant: 'shape'` + `type` + `props`.
- **`MermaidDiagramKind`** — `'flowchart' | 'state' | 'sequence' | 'mindmap'`.
- **`MermaidBlueprintEdge`** — an arrow with start/end node IDs, bend, arrowheads, dash style, and anchor positions.
- **`MermaidBlueprintLineNode`** — a vertical or horizontal line (used for sequence diagram lifelines).
- **`BlueprintRenderingOptions`** — position, centering, and optional `mapNodeToRenderSpec` for `renderBlueprint` / `createMermaidDiagram`.
- **`MermaidNodeCreateFunctionArgs`** — argument object for **`defaultCreateMermaidNodeFromBlueprint`** (includes resolved **`render`** spec).
## How it works
The conversion happens in three stages:
```mermaid
flowchart LR
MermaidText["Mermaid text"] --> ParseRender["Parse and render SVG"]
ParseRender --> ExtractBlueprint["Extract blueprint"]
ExtractBlueprint --> CreateShapes["Create tldraw shapes"]
```
1. **Parse and render** — Mermaid parses the text and renders an SVG into an offscreen DOM element, giving us accurate layout positions via `getBBox()`.
2. **Extract blueprint** — a diagram-specific converter reads positions from the SVG and semantic data from Mermaid's internal database, producing a `DiagramMermaidBlueprint`.
3. **Create shapes** — `renderBlueprint` resolves each node’s materialization spec (mapper or default), then creates tldraw shapes (geo or custom), plus arrows, lines, frames, and groups.
## Example: paste handler
A common integration is converting Mermaid text on paste. Here's a React component that registers itself as a text content handler — based on the pattern used on [tldraw.com](https://github.com/tldraw/tldraw/blob/main/apps/dotcom/client/src/components/SneakyMermaidHandler/SneakyMermaidHandler.tsx):
```tsx
import { useEffect } from 'react'
import { defaultHandleExternalTextContent, useEditor } from 'tldraw'
const MERMAID_KEYWORD =
/^\s*(flowchart|graph|sequenceDiagram|stateDiagram|classDiagram|erDiagram|gantt|pie|gitGraph|mindmap)/
export function MermaidPasteHandler() {
const editor = useEditor()
useEffect(() => {
editor.registerExternalContentHandler('text', async (content) => {
if (!MERMAID_KEYWORD.test(content.text)) {
await defaultHandleExternalTextContent(editor, content)
return
}
try {
const { createMermaidDiagram } = await import('@tldraw/mermaid')
await createMermaidDiagram(editor, content.text, {
async onUnsupportedDiagram(svgString) {
await editor.putExternalContent({ type: 'svg-text', text: svgString })
},
})
} catch {
await defaultHandleExternalTextContent(editor, content)
}
})
}, [editor])
return null
}
```
Drop `<MermaidPasteHandler />` inside your `<Tldraw>` component and users can paste Mermaid text directly onto the canvas.
The regex above is a simplified check. On tldraw.com the detection is handled by [`simpleMermaidStringTest`](https://github.com/tldraw/tldraw/blob/main/apps/dotcom/client/src/components/SneakyMermaidHandler/simpleMermaidStringTest.ts), which also strips YAML frontmatter (`---...---`), `%%{...}%%` directives, and `%%` comments before testing for the diagram keyword — making it more robust for real-world pasted content.
## Lazy loading
The `mermaid` dependency is roughly 2 MB. The paste handler above already lazy-loads with `await import('@tldraw/mermaid')` — the package is only fetched when the pasted text matches a Mermaid keyword. For your own integration, the same pattern applies: pre-screen with a lightweight regex, then dynamic-import the package only when needed.
## Examples
See the [Mermaid diagrams example](https://github.com/tldraw/tldraw/tree/main/apps/examples/src/examples/use-cases/hundred-mermaids) in the examples app for a runnable demo that renders many diagram types at once. Run it locally with `yarn dev` from the repo root and visit `localhost:5420`.
## Documentation
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
## License
This project is part of the tldraw SDK. It is provided under the [tldraw SDK license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

View File

@@ -0,0 +1,225 @@
## API Report File for "@tldraw/mermaid"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Editor } from 'tldraw';
import type { TLArrowShapeArrowheadStyle } from 'tldraw';
import type { TLDefaultColorStyle } from 'tldraw';
import type { TLDefaultDashStyle } from 'tldraw';
import type { TLDefaultFillStyle } from 'tldraw';
import type { TLDefaultHorizontalAlignStyle } from 'tldraw';
import type { TLDefaultSizeStyle } from 'tldraw';
import type { TLDefaultVerticalAlignStyle } from 'tldraw';
import type { TLGeoShapeGeoStyle } from 'tldraw';
import type { TLShape } from 'tldraw';
import type { TLShapeId } from 'tldraw';
// @public (undocumented)
export interface BlueprintRenderingOptions {
// (undocumented)
centerOnPosition?: boolean;
mapNodeToRenderSpec?: MermaidNodeRenderMapper;
// (undocumented)
position?: {
x: number;
y: number;
};
}
// @public
export function createMermaidDiagram(editor: Editor, text: string, options?: MermaidDiagramOptions): Promise<void>;
// @public
export function defaultCreateMermaidNodeFromBlueprint(args: MermaidNodeCreateFunctionArgs): TLShape;
// @public
export function defaultMermaidNodeRenderSpec(diagramKind: MermaidDiagramKind, kind: string): MermaidBlueprintNodeRenderSpec;
// @public
export interface DiagramMermaidBlueprint {
// (undocumented)
diagramKind: MermaidDiagramKind;
// (undocumented)
edges: MermaidBlueprintEdge[];
// (undocumented)
groups?: string[][];
// (undocumented)
lines?: MermaidBlueprintLineNode[];
// (undocumented)
nodes: MermaidBlueprintNode[];
}
// @public
export const MERMAID_MINDMAP_NODE_TYPE: {
readonly BANG: 5;
readonly CIRCLE: 3;
readonly CLOUD: 4;
readonly DEFAULT: 0;
readonly HEXAGON: 6;
readonly RECT: 2;
readonly ROUNDED_RECT: 1;
};
// @public (undocumented)
export interface MermaidBlueprintEdge {
anchorEndX?: number;
// (undocumented)
anchorEndY?: number;
anchorStartX?: number;
// (undocumented)
anchorStartY?: number;
// (undocumented)
arrowheadEnd?: TLArrowShapeArrowheadStyle;
// (undocumented)
arrowheadStart?: TLArrowShapeArrowheadStyle;
// (undocumented)
bend: number;
// (undocumented)
color?: TLDefaultColorStyle;
// (undocumented)
dash?: TLDefaultDashStyle;
// (undocumented)
decoration?: {
type: 'autonumber';
value: string;
};
// (undocumented)
endNodeId: string;
// (undocumented)
isExact?: boolean;
// (undocumented)
isExactEnd?: boolean;
// (undocumented)
isPrecise?: boolean;
// (undocumented)
isPreciseEnd?: boolean;
// (undocumented)
label?: string;
// (undocumented)
size?: TLDefaultSizeStyle;
// (undocumented)
startNodeId: string;
}
// @public (undocumented)
export interface MermaidBlueprintLineNode {
// (undocumented)
color?: TLDefaultColorStyle;
// (undocumented)
dash?: TLDefaultDashStyle;
// (undocumented)
endX?: number;
// (undocumented)
endY: number;
// (undocumented)
id: string;
// (undocumented)
size?: TLDefaultSizeStyle;
// (undocumented)
x: number;
// (undocumented)
y: number;
}
// @public
export interface MermaidBlueprintNode {
// (undocumented)
align?: TLDefaultHorizontalAlignStyle;
background?: boolean;
// (undocumented)
color?: TLDefaultColorStyle;
// (undocumented)
dash?: TLDefaultDashStyle;
// (undocumented)
fill?: TLDefaultFillStyle;
// (undocumented)
h: number;
// (undocumented)
id: string;
kind: string;
// (undocumented)
label?: string;
// (undocumented)
parentId?: string;
// (undocumented)
size?: TLDefaultSizeStyle;
// (undocumented)
verticalAlign?: TLDefaultVerticalAlignStyle;
// (undocumented)
w: number;
// (undocumented)
x: number;
// (undocumented)
y: number;
}
// @public
export type MermaidBlueprintNodeRenderSpec = {
type: string;
props: Record<string, unknown>;
variant: 'shape';
} | {
variant: 'geo';
geo: TLGeoShapeGeoStyle;
};
// @public (undocumented)
export class MermaidDiagramError extends Error {
constructor(diagramType: string, type: 'parse' | 'unsupported');
// (undocumented)
diagramType: string;
// (undocumented)
type: 'parse' | 'unsupported';
}
// @public
export type MermaidDiagramKind = 'flowchart' | 'mindmap' | 'sequence' | 'state';
// @public (undocumented)
export interface MermaidDiagramOptions {
// (undocumented)
blueprintRender?: BlueprintRenderingOptions;
// (undocumented)
mermaidConfig?: Record<string, any>;
// (undocumented)
onUnsupportedDiagram?(svg: string): Promise<void>;
}
// @public
export interface MermaidNodeCreateFunctionArgs {
// (undocumented)
diagramKind: MermaidDiagramKind;
// (undocumented)
editor: Editor;
// (undocumented)
node: MermaidBlueprintNode;
// (undocumented)
parentShapeId?: TLShapeId;
render: MermaidBlueprintNodeRenderSpec;
// (undocumented)
shapeId: TLShapeId;
// (undocumented)
x: number;
// (undocumented)
y: number;
}
// @public
export type MermaidNodeRenderMapper = (input: {
nodeId: string;
node: MermaidBlueprintNode;
diagramKind: MermaidDiagramKind;
kind: string;
}) => MermaidBlueprintNodeRenderSpec | undefined;
// @public (undocumented)
export function renderBlueprint(editor: Editor, blueprint: DiagramMermaidBlueprint, opts?: BlueprintRenderingOptions): void;
// @public
export function resolveMermaidNodeRender(diagramKind: MermaidDiagramKind, node: MermaidBlueprintNode, mapper?: MermaidNodeRenderMapper | undefined): MermaidBlueprintNodeRenderSpec;
// (No @packageDocumentation comment for this package)
```

View File

@@ -0,0 +1,67 @@
{
"name": "@tldraw/mermaid",
"description": "Mermaid diagram to tldraw shape conversion.",
"version": "5.4.2",
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "SEE LICENSE IN LICENSE.md",
"tldraw_product": {
"stableId": "tldraw:mermaid",
"name": "Mermaid",
"type": "feature",
"parent": "tldraw:sdk-core",
"premium": false
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"mermaid",
"diagram",
"canvas",
"infinite"
],
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"type": "module",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"dependencies": {
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*",
"mermaid": "11.16.1"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1",
"tldraw": "workspace:*"
},
"devDependencies": {
"lazyrepo": "0.0.0-alpha.27",
"tldraw": "workspace:*",
"vitest": "^5.0.0"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@@ -0,0 +1,132 @@
import type {
TLArrowShapeArrowheadStyle,
TLDefaultColorStyle,
TLDefaultDashStyle,
TLDefaultFillStyle,
TLDefaultHorizontalAlignStyle,
TLDefaultSizeStyle,
TLDefaultVerticalAlignStyle,
TLGeoShapeGeoStyle,
} from 'tldraw'
/**
* Which Mermaid diagram family produced this blueprint (used by default geo tables and mapper dispatch).
* @public
*/
export type MermaidDiagramKind = 'flowchart' | 'state' | 'sequence' | 'mindmap'
/**
* Optional hook: return a custom render spec for this node, or `undefined` to use the package default mapper.
* Invoked from {@link renderBlueprint}.
* @public
*/
export type MermaidNodeRenderMapper = (input: {
/** Same as {@link DiagramMermaidBlueprint.diagramKind} for this blueprint. */
diagramKind: MermaidDiagramKind
/** Blueprint node id (stable binding key). */
nodeId: string
/** Semantic kind for this node in its diagram family. */
kind: string
/** Layout and style fields for this node (no materialization spec — that is what you return). */
node: MermaidBlueprintNode
}) => MermaidBlueprintNodeRenderSpec | undefined
/**
* Instructions for creating one blueprint node on the canvas: geo variant or custom shape type + props.
* Merged with layout-derived props in {@link defaultCreateMermaidNodeFromBlueprint}.
* @public
*/
export type MermaidBlueprintNodeRenderSpec =
| {
/** Use tldraw’s built-in geo shape; `geo` selects the variant (rectangle, diamond, …). */
variant: 'geo'
geo: TLGeoShapeGeoStyle
}
| {
/** Use any shape type registered on the editor (including custom ShapeUtils). */
variant: 'shape'
/** Must match `editor.createShape({ type })` — same string as in the app’s shape schema. */
type: string
/** Partial props merged with layout-derived props (size, colors, label text, …) in {@link defaultCreateMermaidNodeFromBlueprint}. */
props: Record<string, unknown>
}
/**
* Intermediate representation of a parsed Mermaid diagram: nodes, edges, optional lifelines and groups.
* Produced by diagram-specific `*ToBlueprint` functions and consumed by {@link renderBlueprint}.
* @public
*/
export interface DiagramMermaidBlueprint {
diagramKind: MermaidDiagramKind
nodes: MermaidBlueprintNode[]
edges: MermaidBlueprintEdge[]
lines?: MermaidBlueprintLineNode[]
groups?: string[][]
}
/**
* One node in the blueprint: layout and semantic kind from Mermaid (materialization is resolved at {@link renderBlueprint} via `mapNodeToRenderSpec` or defaults).
* @public
*/
export interface MermaidBlueprintNode {
id: string
x: number
y: number
w: number
h: number
/**
* Stable semantic key from Mermaid (flowchart vertex type, `subgraph`, state kind, actor type,
* internal sequence kinds such as `sequence_note`, mindmap type as decimal string, …).
*/
kind: string
parentId?: string
label?: string
fill?: TLDefaultFillStyle
color?: TLDefaultColorStyle
dash?: TLDefaultDashStyle
size?: TLDefaultSizeStyle
align?: TLDefaultHorizontalAlignStyle
verticalAlign?: TLDefaultVerticalAlignStyle
/**
* Draw this node beneath the blueprint's lines as well as its other nodes. Lines are otherwise
* drawn beneath every node, so a filled node that spans them, such as a sequence diagram's
* participant box, would hide them.
*/
background?: boolean
}
/** @public */
export interface MermaidBlueprintEdge {
startNodeId: string
endNodeId: string
label?: string
bend: number
arrowheadEnd?: TLArrowShapeArrowheadStyle
arrowheadStart?: TLArrowShapeArrowheadStyle
dash?: TLDefaultDashStyle
size?: TLDefaultSizeStyle
color?: TLDefaultColorStyle
/** Where the arrow meets its start shape, as a 0–1 fraction of that shape's width. Defaults to the center. */
anchorStartX?: number
anchorStartY?: number
/** Where the arrow meets its end shape, as a 0–1 fraction of that shape's width. Defaults to the center. */
anchorEndX?: number
anchorEndY?: number
isExact?: boolean
isPrecise?: boolean
isExactEnd?: boolean
isPreciseEnd?: boolean
decoration?: { type: 'autonumber'; value: string }
}
/** @public */
export interface MermaidBlueprintLineNode {
id: string
x: number
y: number
endX?: number
endY: number
dash?: TLDefaultDashStyle
size?: TLDefaultSizeStyle
color?: TLDefaultColorStyle
}

View File

@@ -0,0 +1,392 @@
import { hasOwnProperty } from '@tldraw/utils'
import { DEFAULT_THEME, TLDefaultColorStyle, TLDefaultDashStyle, TLDefaultSizeStyle } from 'tldraw'
import type { MermaidBlueprintNode } from './blueprint'
type Color = [number, number, number, number]
export interface ParsedNodeColors {
fillColor?: TLDefaultColorStyle
strokeColor?: TLDefaultColorStyle
}
/**
* Build a map of node id → parsed fill/stroke colors from Mermaid's classDef definitions.
*
* Uses the structured data from `db.getClasses()` and each node's `classes`
* array. For each node, looks up its applied classDef styles and maps fill and
* stroke independently to the nearest tldraw palette color.
*/
export function buildClassDefColorMap(
classDefs: Map<string, { styles: string[] }>,
items: Iterable<[string, { classes?: string[] }]>
): Map<string, ParsedNodeColors> {
const result = new Map<string, ParsedNodeColors>()
if (classDefs.size === 0) return result
for (const [nodeId, item] of items) {
for (const className of item.classes ?? []) {
const colors = parseNodeInlineColor(classDefs.get(className)?.styles)
if (colors) {
result.set(nodeId, colors)
break
}
}
}
return result
}
/** Blueprint node style props for parsed fill/stroke colors: solid fill when a fill was set, stroke color preferred. */
export function toNodeColorProps(
colors: ParsedNodeColors | undefined
): Pick<MermaidBlueprintNode, 'fill' | 'color'> {
if (!colors) return {}
return {
...(colors.fillColor && { fill: 'solid' as const }),
color: colors.strokeColor ?? colors.fillColor,
}
}
export function parseRgbToTldrawColor(
text: string
): { color: TLDefaultColorStyle; hasAlpha: boolean } | null {
const color = toColor(text)
if (!color) return null
return { color: nearestTldrawColor(color), hasAlpha: color[3] < 255 }
}
interface ParsedCssOverrides {
color?: TLDefaultColorStyle
dashOverride?: TLDefaultDashStyle
sizeOverride?: TLDefaultSizeStyle
}
function parseCssProps(styles: string[]): Map<string, string> {
const props = new Map<string, string>()
for (const entry of styles) {
for (const part of entry.split(';')) {
const colon = part.indexOf(':')
if (colon < 0) continue
const key = part.slice(0, colon).trim().toLowerCase()
const value = part.slice(colon + 1).trim()
if (key && value) props.set(key, value)
}
}
return props
}
/**
* Parse a Mermaid CSS style array from an edge (FlowEdge.style / linkStyle)
* and return tldraw-compatible overrides.
*/
export function parseCssStyles(styles: string[] | undefined): ParsedCssOverrides {
if (!styles || styles.length === 0) return {}
const props = parseCssProps(styles)
const result: ParsedCssOverrides = {}
const stroke = toColor(props.get('stroke'))
if (stroke) {
result.color = nearestTldrawColor(stroke)
}
if (props.has('stroke-dasharray')) {
result.dashOverride = 'dashed'
}
const strokeWidth = props.get('stroke-width')
if (strokeWidth) {
const pixels = parseFloat(strokeWidth)
if (Number.isFinite(pixels)) {
// Mermaid's unstyled edge is 1px, drawn at 'm'. A lower cut-off would draw
// `stroke-width:1px` thinner than no style at all.
if (pixels < 1) result.sizeOverride = 's'
else if (pixels < 2) result.sizeOverride = 'm'
else result.sizeOverride = 'l'
}
}
return result
}
/**
* Parse inline `style nodeId fill:…,stroke:…` directives from a FlowVertex.styles
* array and return fill and stroke as independent tldraw colors.
*/
export function parseNodeInlineColor(styles: string[] | undefined): ParsedNodeColors | undefined {
if (!styles || styles.length === 0) return undefined
const props = parseCssProps(styles)
const fill = toColor(props.get('fill'))
const stroke = toColor(props.get('stroke'))
if (!fill && !stroke) return undefined
const colors: ParsedNodeColors = {}
if (fill) colors.fillColor = nearestTldrawColor(fill)
if (stroke) colors.strokeColor = nearestTldrawColor(stroke)
return colors
}
function parseHexToRgb(hex: string): [number, number, number] | null {
const stripped = hex.replace(/^#/, '')
if (stripped.length === 3 || stripped.length === 4) {
return [
parseInt(stripped[0] + stripped[0], 16),
parseInt(stripped[1] + stripped[1], 16),
parseInt(stripped[2] + stripped[2], 16),
]
}
if (stripped.length === 6 || stripped.length === 8) {
return [
parseInt(stripped.slice(0, 2), 16),
parseInt(stripped.slice(2, 4), 16),
parseInt(stripped.slice(4, 6), 16),
]
}
return null
}
const defaultColorNames: TLDefaultColorStyle[] = [
'black',
'grey',
'light-violet',
'violet',
'blue',
'light-blue',
'yellow',
'orange',
'green',
'light-green',
'light-red',
'red',
'white',
]
const TLDRAW_PALETTE: [TLDefaultColorStyle, number, number, number][] = defaultColorNames.map(
(name) => {
const { solid } = DEFAULT_THEME.colors.light[name]!
const rgb = parseHexToRgb(solid)!
return [name, rgb[0], rgb[1], rgb[2]]
}
)
/** Map an arbitrary Color tuple to the nearest tldraw named color (best-effort). */
function nearestTldrawColor(rgb: Color): TLDefaultColorStyle {
let [r, g, b] = rgb
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const lightness = (max + min) / 2 / 255
const chroma = max - min
// For very light pastels, strip the white base and amplify the
// chromatic signal so the distance metric can see the hue.
if (lightness > 0.75 && chroma > 5) {
const target = 200
r = Math.round(((r - min) / chroma) * target)
g = Math.round(((g - min) / chroma) * target)
b = Math.round(((b - min) / chroma) * target)
}
let best: TLDefaultColorStyle = 'black'
let bestDistance = Infinity
for (const [name, red, green, blue] of TLDRAW_PALETTE) {
// "Redmean" weighted Euclidean distance (Compuphase approximation).
// Weights RGB channels by the average red value of the two colors to
// approximate human perception, which is more sensitive to green and
// varies in red/blue sensitivity depending on the color's warmth.
const rMean = (r + red) / 2
const dR = r - red
const dG = g - green
const dB = b - blue
const distance = (2 + rMean / 256) * dR * dR + 4 * dG * dG + (2 + (255 - rMean) / 256) * dB * dB
if (distance < bestDistance) {
bestDistance = distance
best = name
}
}
return best
}
function toColor(value: string | undefined): Color | undefined {
if (!value) return undefined
const trimmed = value.trim()
if (!trimmed || trimmed === 'none' || trimmed === 'transparent') return undefined
if (trimmed.startsWith('rgb')) {
const match = trimmed.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)/)
if (!match) return undefined
return [
parseInt(match[1], 10),
parseInt(match[2], 10),
parseInt(match[3], 10),
match[4] !== undefined ? Math.round(parseFloat(match[4]) * 255) : 255,
]
}
if (trimmed.startsWith('#')) {
const rgb = parseHexToRgb(trimmed)
if (!rgb) return undefined
return [rgb[0], rgb[1], rgb[2], 255]
}
const keyword = trimmed.toLowerCase()
if (hasOwnProperty(CSS_NAMED_COLORS, keyword)) {
const rgb = parseHexToRgb(CSS_NAMED_COLORS[keyword])!
return [rgb[0], rgb[1], rgb[2], 255]
}
return undefined
}
// Mermaid accepts CSS color keywords wherever it takes a color, and sequence `box` statements
// can't take a hex color at all, so without these a box's color is usually lost.
const CSS_NAMED_COLORS: Record<string, string> = {
aliceblue: 'f0f8ff',
antiquewhite: 'faebd7',
aqua: '00ffff',
aquamarine: '7fffd4',
azure: 'f0ffff',
beige: 'f5f5dc',
bisque: 'ffe4c4',
black: '000000',
blanchedalmond: 'ffebcd',
blue: '0000ff',
blueviolet: '8a2be2',
brown: 'a52a2a',
burlywood: 'deb887',
cadetblue: '5f9ea0',
chartreuse: '7fff00',
chocolate: 'd2691e',
coral: 'ff7f50',
cornflowerblue: '6495ed',
cornsilk: 'fff8dc',
crimson: 'dc143c',
cyan: '00ffff',
darkblue: '00008b',
darkcyan: '008b8b',
darkgoldenrod: 'b8860b',
darkgray: 'a9a9a9',
darkgreen: '006400',
darkgrey: 'a9a9a9',
darkkhaki: 'bdb76b',
darkmagenta: '8b008b',
darkolivegreen: '556b2f',
darkorange: 'ff8c00',
darkorchid: '9932cc',
darkred: '8b0000',
darksalmon: 'e9967a',
darkseagreen: '8fbc8f',
darkslateblue: '483d8b',
darkslategray: '2f4f4f',
darkslategrey: '2f4f4f',
darkturquoise: '00ced1',
darkviolet: '9400d3',
deeppink: 'ff1493',
deepskyblue: '00bfff',
dimgray: '696969',
dimgrey: '696969',
dodgerblue: '1e90ff',
firebrick: 'b22222',
floralwhite: 'fffaf0',
forestgreen: '228b22',
fuchsia: 'ff00ff',
gainsboro: 'dcdcdc',
ghostwhite: 'f8f8ff',
gold: 'ffd700',
goldenrod: 'daa520',
gray: '808080',
green: '008000',
greenyellow: 'adff2f',
grey: '808080',
honeydew: 'f0fff0',
hotpink: 'ff69b4',
indianred: 'cd5c5c',
indigo: '4b0082',
ivory: 'fffff0',
khaki: 'f0e68c',
lavender: 'e6e6fa',
lavenderblush: 'fff0f5',
lawngreen: '7cfc00',
lemonchiffon: 'fffacd',
lightblue: 'add8e6',
lightcoral: 'f08080',
lightcyan: 'e0ffff',
lightgoldenrodyellow: 'fafad2',
lightgray: 'd3d3d3',
lightgreen: '90ee90',
lightgrey: 'd3d3d3',
lightpink: 'ffb6c1',
lightsalmon: 'ffa07a',
lightseagreen: '20b2aa',
lightskyblue: '87cefa',
lightslategray: '778899',
lightslategrey: '778899',
lightsteelblue: 'b0c4de',
lightyellow: 'ffffe0',
lime: '00ff00',
limegreen: '32cd32',
linen: 'faf0e6',
magenta: 'ff00ff',
maroon: '800000',
mediumaquamarine: '66cdaa',
mediumblue: '0000cd',
mediumorchid: 'ba55d3',
mediumpurple: '9370db',
mediumseagreen: '3cb371',
mediumslateblue: '7b68ee',
mediumspringgreen: '00fa9a',
mediumturquoise: '48d1cc',
mediumvioletred: 'c71585',
midnightblue: '191970',
mintcream: 'f5fffa',
mistyrose: 'ffe4e1',
moccasin: 'ffe4b5',
navajowhite: 'ffdead',
navy: '000080',
oldlace: 'fdf5e6',
olive: '808000',
olivedrab: '6b8e23',
orange: 'ffa500',
orangered: 'ff4500',
orchid: 'da70d6',
palegoldenrod: 'eee8aa',
palegreen: '98fb98',
paleturquoise: 'afeeee',
palevioletred: 'db7093',
papayawhip: 'ffefd5',
peachpuff: 'ffdab9',
peru: 'cd853f',
pink: 'ffc0cb',
plum: 'dda0dd',
powderblue: 'b0e0e6',
purple: '800080',
rebeccapurple: '663399',
red: 'ff0000',
rosybrown: 'bc8f8f',
royalblue: '4169e1',
saddlebrown: '8b4513',
salmon: 'fa8072',
sandybrown: 'f4a460',
seagreen: '2e8b57',
seashell: 'fff5ee',
sienna: 'a0522d',
silver: 'c0c0c0',
skyblue: '87ceeb',
slateblue: '6a5acd',
slategray: '708090',
slategrey: '708090',
snow: 'fffafa',
springgreen: '00ff7f',
steelblue: '4682b4',
tan: 'd2b48c',
teal: '008080',
thistle: 'd8bfd8',
tomato: 'ff6347',
turquoise: '40e0d0',
violet: 'ee82ee',
wheat: 'f5deb3',
white: 'ffffff',
whitesmoke: 'f5f5f5',
yellow: 'ffff00',
yellowgreen: '9acd32',
}

View File

@@ -0,0 +1,73 @@
import { createMermaidDiagram, MermaidDiagramError } from './createMermaidDiagram'
describe('createMermaidDiagram', () => {
it('throws MermaidDiagramError for invalid input', async () => {
const editor = {} as any
await expect(
createMermaidDiagram(editor, 'not a diagram at all', {
blueprintRender: { position: { x: 0, y: 0 }, centerOnPosition: false },
})
).rejects.toThrow(MermaidDiagramError)
await expect(createMermaidDiagram(editor, 'not a diagram at all')).rejects.toMatchObject({
type: 'parse',
})
})
describe('config set inside the diagram', () => {
// The config mermaid lays `source` out with, after our `initialize` and its own directives.
async function effectiveConfig(source: string, mermaidConfig?: Record<string, any>) {
// Runs our `mermaid.initialize` and then fails to parse, leaving mermaid configured.
await expect(
createMermaidDiagram({} as any, 'not a diagram at all', { mermaidConfig })
).rejects.toThrow()
const mermaid = (await import('mermaid')).default
await mermaid.parse(source)
// eslint-disable-next-line @typescript-eslint/no-deprecated
return mermaid.mermaidAPI.getConfig()
}
const directive = `%%{init: {
"securityLevel": "loose",
"themeVariables": { "fontSize": "16px", "primaryColor": "#eef7ff" }
}}%%
stateDiagram-v2
[*] --> Draft`
const frontmatter = `---
config:
securityLevel: loose
themeVariables:
fontSize: 16px
primaryColor: "#eef7ff"
---
stateDiagram-v2
[*] --> Draft`
it.each([
['an init directive', directive],
['frontmatter', frontmatter],
])('cannot shrink the layout font size from %s', async (_, source) => {
const config = await effectiveConfig(source)
// A smaller layout font sizes every box for text narrower than tldraw draws it.
expect(config.themeVariables.fontSize).not.toBe('16px')
// Only the font size is locked; the rest of the diagram's styling still applies.
expect(config.themeVariables.primaryColor).toBe('#eef7ff')
// Mermaid's own secure keys still apply alongside ours.
expect(config.securityLevel).not.toBe('loose')
})
it('keeps secure keys the host passes in', async () => {
const config = await effectiveConfig(
`%%{init: {"theme": "dark"}}%%
stateDiagram-v2
[*] --> Draft`,
{ secure: ['theme'] }
)
expect(config.theme).not.toBe('dark')
})
})
})

View File

@@ -0,0 +1,184 @@
import type { FlowDB } from 'mermaid/dist/diagrams/flowchart/flowDb.d.ts'
import type { FlowEdge, FlowSubGraph, FlowVertex } from 'mermaid/dist/diagrams/flowchart/types.js'
import type { MindmapDB } from 'mermaid/dist/diagrams/mindmap/mindmapDb.d.ts'
import type { SequenceDB } from 'mermaid/dist/diagrams/sequence/sequenceDb.d.ts'
import type { StateDB } from 'mermaid/dist/diagrams/state/stateDb.d.ts'
import { Editor } from 'tldraw'
import { flowchartToBlueprint, parseFlowchartLayout } from './flowchartDiagram'
import { createLabelWidthMeasurer } from './mermaidNodeCreateShape'
import { mindmapToBlueprint, parseMindmapLayout } from './mindmapDiagram'
import { BlueprintRenderingOptions, renderBlueprint } from './renderBlueprint'
import { countSequenceEvents, parseSequenceLayout, sequenceToBlueprint } from './sequenceDiagram'
import { parseStateDiagramLayout, stateToBlueprint } from './stateDiagram'
let nextMermaidId = 0
/** @public */
export class MermaidDiagramError extends Error {
constructor(
public diagramType: string,
public type: 'parse' | 'unsupported'
) {
super(`mermaid diagram error: ${diagramType}`)
this.name = 'MermaidDiagramError'
}
}
// Inflate the font size so Mermaid's layout engine allocates larger nodes,
// compensating for tldraw's hand-drawn font being wider than Mermaid's default.
const FONT_INFLATE = 1.4
const MERMAID_CONFIG = {
startOnLoad: false,
flowchart: { nodeSpacing: 80, rankSpacing: 80, padding: 20 },
state: { nodeSpacing: 80, rankSpacing: 80, padding: 20 },
mindmap: { padding: 20 },
sequence: { actorMargin: 50, noteMargin: 20 },
themeVariables: { fontSize: `${18 * FONT_INFLATE}px` },
}
/** @public */
export interface MermaidDiagramOptions {
mermaidConfig?: Record<string, any>
blueprintRender?: BlueprintRenderingOptions
onUnsupportedDiagram?(svg: string): Promise<void>
}
/**
* Parse mermaid text and create tldraw shapes for supported diagram types.
* Returns the SVG string for supported diagrams, or `null` when the diagram type
* is unsupported (after calling `onUnsupportedDiagram` if provided).
* Throws {@link MermaidDiagramError} if parsing fails.
* @public
*/
export async function createMermaidDiagram(
editor: Editor,
text: string,
options: MermaidDiagramOptions = {}
): Promise<void> {
// load mermaid lazily: it's a large, ESM-only dependency only needed when a
// diagram is actually created. a dynamic import() works from CommonJS (unlike
// a static import, which compiles to require(<esm>) and throws
// ERR_REQUIRE_ESM on Node <20.19, Jest, and ts-node) and avoids pulling
// mermaid in when @tldraw/mermaid is merely imported.
const mermaid = (await import('mermaid')).default
mermaid.initialize({
...MERMAID_CONFIG,
...(options.mermaidConfig ?? {}),
flowchart: { ...MERMAID_CONFIG.flowchart, ...options.mermaidConfig?.flowchart },
state: { ...MERMAID_CONFIG.state, ...options.mermaidConfig?.state },
mindmap: { ...MERMAID_CONFIG.mindmap, ...options.mermaidConfig?.mindmap },
sequence: { ...MERMAID_CONFIG.sequence, ...options.mermaidConfig?.sequence },
themeVariables: { ...MERMAID_CONFIG.themeVariables, ...options.mermaidConfig?.themeVariables },
// A diagram's own `%%{init}%%` or frontmatter config outranks `initialize`, so one that sets
// `fontSize` undoes FONT_INFLATE: every box is measured small while tldraw still draws its
// wider face, and labels break mid-word. Mermaid strips `secure` keys from in-diagram config
// at any depth, and keeps its own defaults alongside the ones listed here.
secure: [...(options.mermaidConfig?.secure ?? []), 'fontSize'],
})
const parsedResult = await mermaid.parse(text, { suppressErrors: true })
if (!parsedResult) {
throw new MermaidDiagramError('not a mermaid diagram', 'parse')
}
const offscreen = document.createElement('div')
Object.assign(offscreen.style, {
position: 'absolute',
left: '-9999px',
top: '-9999px',
overflow: 'hidden',
})
document.body.appendChild(offscreen)
try {
const parsedSvg = (await mermaid.render(`mermaid-${nextMermaidId++}`, text, offscreen)).svg
// Reuse the live SVG that mermaid.render() already mounted into the
// offscreen container. This avoids a second DOM mount and ensures
// getBBox() works for every diagram type (state diagrams in particular
// lack explicit dimension attributes and rely on live layout).
let liveSvg = offscreen.querySelector('svg')
if (!liveSvg) {
offscreen.innerHTML = parsedSvg
liveSvg = offscreen.querySelector('svg')
if (!liveSvg) {
throw new MermaidDiagramError(parsedResult.diagramType, 'parse')
}
}
// eslint-disable-next-line @typescript-eslint/no-deprecated
const diagramResult = await mermaid.mermaidAPI.getDiagramFromText(text)
let blueprint
switch (parsedResult.diagramType) {
case 'flowchart-v2': {
const db = diagramResult.db as FlowDB
const vertices = db.getVertices() as Map<string, FlowVertex>
const edges = db.getEdges() as FlowEdge[]
const subGraphs = db.getSubGraphs() as FlowSubGraph[]
const classes = db.getClasses()
const layout = parseFlowchartLayout(liveSvg)
blueprint = flowchartToBlueprint(layout, vertices, edges, subGraphs, classes)
break
}
case 'sequence': {
const db = diagramResult.db as SequenceDB
const actors = db.getActors()
const actorKeys = db.getActorKeys()
const messages = db.getMessages()
const layout = parseSequenceLayout(liveSvg, actorKeys.length, countSequenceEvents(messages))
blueprint = sequenceToBlueprint(
layout,
actors,
actorKeys,
messages,
db.getCreatedActors(),
db.getDestroyedActors(),
createLabelWidthMeasurer(editor)
)
break
}
case 'state':
case 'stateDiagram': {
const db = diagramResult.db as StateDB
const states = db.getStates()
const relations = db.getRelations()
const classes = db.getClasses()
const layout = parseStateDiagramLayout(liveSvg)
blueprint = stateToBlueprint(layout, states, relations, classes)
break
}
case 'mindmap': {
const db = diagramResult.db as MindmapDB
const tree = db.getMindmap()
if (tree) {
db.assignSections(tree)
const layout = parseMindmapLayout(liveSvg)
blueprint = mindmapToBlueprint(layout, tree, liveSvg)
}
break
}
default:
if (options.onUnsupportedDiagram) {
await options.onUnsupportedDiagram(parsedSvg)
} else {
throw new MermaidDiagramError(parsedResult.diagramType, 'unsupported')
}
break
}
if (blueprint) {
renderBlueprint(editor, blueprint, options.blueprintRender)
}
} catch (e) {
if (e instanceof MermaidDiagramError) throw e
console.error(e)
throw new MermaidDiagramError(parsedResult.diagramType, 'parse')
} finally {
offscreen.remove()
}
}

View File

@@ -0,0 +1,109 @@
import type { TLGeoShapeGeoStyle } from 'tldraw'
import type {
MermaidBlueprintNode,
MermaidBlueprintNodeRenderSpec,
MermaidDiagramKind,
MermaidNodeRenderMapper,
} from './blueprint'
import { MERMAID_MINDMAP_NODE_TYPE } from './mindmapDiagram'
function flowchartKindToGeo(kind: string): TLGeoShapeGeoStyle {
switch (kind) {
case 'diamond':
return 'diamond'
case 'ellipse':
case 'circle':
case 'doublecircle':
case 'stadium':
case 'cylinder':
return 'ellipse'
case 'hexagon':
return 'hexagon'
case 'trapezoid':
case 'inv_trapezoid':
return 'trapezoid'
case 'lean_right':
return 'rhombus'
case 'lean_left':
return 'rhombus-2'
default:
return 'rectangle'
}
}
function stateKindToGeo(kind: string): TLGeoShapeGeoStyle {
switch (kind) {
case 'choice':
return 'diamond'
case 'start':
case 'end':
case 'end_inner':
return 'ellipse'
default:
return 'rectangle'
}
}
function sequenceKindToGeo(kind: string): TLGeoShapeGeoStyle {
switch (kind) {
case 'actor':
return 'ellipse'
case 'database':
return 'oval'
default:
return 'rectangle'
}
}
function mindmapKindToGeo(kind: string): TLGeoShapeGeoStyle {
switch (Number(kind)) {
case MERMAID_MINDMAP_NODE_TYPE.CIRCLE:
return 'ellipse'
case MERMAID_MINDMAP_NODE_TYPE.CLOUD:
return 'cloud'
case MERMAID_MINDMAP_NODE_TYPE.HEXAGON:
return 'hexagon'
case MERMAID_MINDMAP_NODE_TYPE.BANG:
return 'star'
default:
return 'rectangle'
}
}
const KIND_TO_GEO: Record<MermaidDiagramKind, (kind: string) => TLGeoShapeGeoStyle> = {
flowchart: flowchartKindToGeo,
state: stateKindToGeo,
sequence: sequenceKindToGeo,
mindmap: mindmapKindToGeo,
}
/**
* Default built-in mapping from {@link MermaidDiagramKind} + semantic `kind` to a geo {@link MermaidBlueprintNodeRenderSpec}.
* Used when `mapNodeToRenderSpec` is omitted from `renderBlueprint` options, or when the mapper returns `undefined`.
* @public
*/
export function defaultMermaidNodeRenderSpec(
diagramKind: MermaidDiagramKind,
kind: string
): MermaidBlueprintNodeRenderSpec {
return { variant: 'geo', geo: KIND_TO_GEO[diagramKind]?.(kind) ?? 'rectangle' }
}
/**
* Uses the optional mapper when it returns a value; otherwise {@link defaultMermaidNodeRenderSpec}.
* @public
*/
export function resolveMermaidNodeRender(
diagramKind: MermaidDiagramKind,
node: MermaidBlueprintNode,
mapper?: MermaidNodeRenderMapper | undefined
): MermaidBlueprintNodeRenderSpec {
return (
mapper?.({
diagramKind,
nodeId: node.id,
kind: node.kind,
node,
}) ?? defaultMermaidNodeRenderSpec(diagramKind, node.kind)
)
}

View File

@@ -0,0 +1,217 @@
import type {
FlowClass,
FlowEdge,
FlowSubGraph,
FlowVertex,
} from 'mermaid/dist/diagrams/flowchart/types.js'
import { TLArrowShapeArrowheadStyle } from 'tldraw'
import type {
DiagramMermaidBlueprint,
MermaidBlueprintEdge,
MermaidBlueprintNode,
} from './blueprint'
import {
buildClassDefColorMap,
parseCssStyles,
parseNodeInlineColor,
toNodeColorProps,
} from './colors'
import {
buildNodeCentersFromSvg,
claimEdge,
getSelfLoopEdgeLayout,
parseAllEdgePointsFromSvg,
parseClustersFromSvg,
parseDomId,
parseEdgeLabelsFromSvg,
type ParsedDiagramLayout,
parseNodesFromSvg,
scaleLayout,
stripDiagramIdPrefix,
type Vec2,
} from './svgParsing'
import { dropDanglingEdges, getArrowBend, LAYOUT_SCALE, orderTopDown } from './utils'
function mapEdgeTypeToArrowhead(type: string | undefined): TLArrowShapeArrowheadStyle {
if (type?.includes('circle')) return 'dot'
if (type?.includes('cross')) return 'bar'
if (type?.includes('open')) return 'none'
return 'arrow'
}
const FRAME_TOP_PAD = 14
const NODE_ID = /^flowchart-(.+)-\d+$/
function buildHierarchy(subGraphs: FlowSubGraph[]) {
const subGraphIds = new Set(subGraphs.map((subGraph) => subGraph.id))
const nodeToSubGraph = new Map<string, string>()
const subGraphParent = new Map<string, string>()
for (const subGraph of subGraphs) {
for (const nodeId of subGraph.nodes) {
if (subGraphIds.has(nodeId)) {
subGraphParent.set(nodeId, subGraph.id)
} else if (!nodeToSubGraph.has(nodeId)) {
nodeToSubGraph.set(nodeId, subGraph.id)
}
}
}
return { nodeToSubGraph, subGraphParent }
}
/**
* Split mermaid's edge id, `L_<start>_<end>_<n>`, back into the two ids it joins. Node ids can
* contain underscores themselves, so the split is ambiguous: `L_a_b_c_0` joins `a` to `b_c` in a
* diagram with those, and `a_b` to `c` in one with those. A diagram with all four settles it the
* only way left, by which pair the path was actually drawn between. `centers` holds everything an
* edge can end on, subgraphs as well as nodes, since mermaid links those too.
*/
function parseEdgeId(dataId: string, points: Vec2[], centers: Map<string, Vec2>) {
const match = dataId.match(/(?:^|-)L_(.+)_\d+$/)
if (!match) return null
const joined = match[1]
const last = points[points.length - 1]
let best: { start: string; end: string } | undefined
let bestDistance = Infinity
for (let at = joined.indexOf('_'); at > 0; at = joined.indexOf('_', at + 1)) {
const start = joined.slice(0, at)
const end = joined.slice(at + 1)
const startCenter = centers.get(start)
const endCenter = centers.get(end)
if (!startCenter || !endCenter) continue
const distance = last
? Math.hypot(points[0].x - startCenter.x, points[0].y - startCenter.y) +
Math.hypot(last.x - endCenter.x, last.y - endCenter.y)
: 0
if (distance < bestDistance) {
bestDistance = distance
best = { start, end }
}
}
if (best) return best
// An id naming no pair the diagram drew, such as an edge to a node mermaid left out. Splitting at
// the last underscore is right whenever the end id has none, and leaves the edge to be matched by
// proximity when it isn't.
const at = joined.lastIndexOf('_')
return at > 0 ? { start: joined.slice(0, at), end: joined.slice(at + 1) } : null
}
/** Parse flowchart-specific SVG layout data for use by {@link flowchartToBlueprint}. */
export function parseFlowchartLayout(root: Element): ParsedDiagramLayout {
const nodes = parseNodesFromSvg(root, '.node', (domId) => parseDomId(domId, NODE_ID))
const clusters = parseClustersFromSvg(root, '.cluster', stripDiagramIdPrefix)
const centers = buildNodeCentersFromSvg(nodes, clusters)
const edges = parseAllEdgePointsFromSvg(root, (dataId, points) =>
parseEdgeId(dataId, points, centers)
)
const layout = { nodes, clusters, edges, edgeLabels: parseEdgeLabelsFromSvg(root) }
scaleLayout(layout, LAYOUT_SCALE)
return layout
}
/** Convert a parsed Mermaid flowchart into a tldraw blueprint of nodes and edges. */
export function flowchartToBlueprint(
layout: ParsedDiagramLayout,
vertices: Map<string, FlowVertex>,
edges: FlowEdge[],
subGraphs?: FlowSubGraph[],
classDefs?: Map<string, FlowClass>
): DiagramMermaidBlueprint {
const nodeColorMap = buildClassDefColorMap(classDefs ?? new Map(), vertices)
const { nodes: svgNodes, clusters: svgClusters, edges: svgEdges } = layout
const nodeCenters = buildNodeCentersFromSvg(svgNodes, svgClusters)
const allSubGraphs = subGraphs || []
const { nodeToSubGraph, subGraphParent } = buildHierarchy(allSubGraphs)
const nodes: MermaidBlueprintNode[] = []
const blueprintEdges: MermaidBlueprintEdge[] = []
// Frames for subgraphs
for (const subGraph of orderTopDown(
allSubGraphs,
(subGraph) => subGraph.id,
(subGraph) => subGraphParent.get(subGraph.id)
)) {
const cluster = svgClusters.get(subGraph.id)
if (!cluster) continue
nodes.push({
id: subGraph.id,
kind: 'subgraph',
x: cluster.topLeft.x,
y: cluster.topLeft.y - FRAME_TOP_PAD,
w: cluster.width,
h: cluster.height + FRAME_TOP_PAD,
parentId: subGraphParent.get(subGraph.id),
label: subGraph.title || subGraph.id,
fill: 'semi',
color: 'black',
dash: 'draw',
size: 's',
align: 'middle',
verticalAlign: 'start',
})
}
// Node shapes
for (const [id, vertex] of vertices) {
const svgNode = svgNodes.get(id)
if (!svgNode) continue
let { width: w, height: h } = svgNode
if (vertex.type === 'circle' || vertex.type === 'doublecircle') {
w = h = Math.max(w, h)
}
nodes.push({
id,
kind: vertex.type ?? 'rect',
x: svgNode.center.x - w / 2,
y: svgNode.center.y - h / 2,
w,
h,
parentId: nodeToSubGraph.get(id),
label: vertex.text || undefined,
...toNodeColorProps(nodeColorMap.get(id) ?? parseNodeInlineColor(vertex.styles)),
align: 'middle',
verticalAlign: 'middle',
size: 'm',
})
}
// Edges: match DB edges to the paths mermaid drew for them, and take each one's bend
const claimed = new Set<number>()
for (const edge of edges) {
const svgEdge = claimEdge(svgEdges, claimed, {
startId: edge.start,
endId: edge.end,
startCenter: nodeCenters.get(edge.start),
endCenter: nodeCenters.get(edge.end),
})
const svgNode = svgNodes.get(edge.start)
const selfLoop =
edge.start === edge.end && svgEdge && svgNode
? getSelfLoopEdgeLayout(svgEdge, svgNode, layout.edgeLabels)
: undefined
const cssOverrides = parseCssStyles(edge.style)
const arrowheadEnd = mapEdgeTypeToArrowhead(edge.type)
blueprintEdges.push({
startNodeId: edge.start,
endNodeId: edge.end,
label: edge.text,
bend: svgEdge ? getArrowBend(svgEdge) : 0,
...selfLoop,
arrowheadEnd,
arrowheadStart: edge.type?.includes('double_arrow') ? arrowheadEnd : undefined,
dash: cssOverrides.dashOverride ?? (edge.stroke === 'dotted' ? 'dotted' : 'solid'),
size: cssOverrides.sizeOverride ?? (edge.stroke === 'thick' ? 'l' : 'm'),
color: cssOverrides.color,
})
}
return { diagramKind: 'flowchart', nodes, edges: dropDanglingEdges(nodes, blueprintEdges) }
}

View File

@@ -0,0 +1,30 @@
import { registerTldrawLibraryVersion } from '@tldraw/utils'
export type {
DiagramMermaidBlueprint,
MermaidBlueprintEdge,
MermaidBlueprintLineNode,
MermaidBlueprintNode,
MermaidBlueprintNodeRenderSpec,
MermaidDiagramKind,
MermaidNodeRenderMapper,
} from './blueprint'
export { createMermaidDiagram, MermaidDiagramError } from './createMermaidDiagram'
export type { MermaidDiagramOptions } from './createMermaidDiagram'
export {
defaultMermaidNodeRenderSpec,
resolveMermaidNodeRender,
} from './defaultMermaidNodeRenderSpec'
export { MERMAID_MINDMAP_NODE_TYPE } from './mindmapDiagram'
export {
defaultCreateMermaidNodeFromBlueprint,
type MermaidNodeCreateFunctionArgs,
} from './mermaidNodeCreateShape'
export { renderBlueprint } from './renderBlueprint'
export type { BlueprintRenderingOptions } from './renderBlueprint'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import { defaultCreateMermaidNodeFromBlueprint } from './mermaidNodeCreateShape'
describe('defaultCreateMermaidNodeFromBlueprint', () => {
it('creates a geo shape with merged props', () => {
const createShape = vi.fn()
const getShape = vi.fn(() => ({ id: 's1', type: 'geo' }))
const editor = { createShape, getShape } as any
const node = {
id: 'A',
kind: 'rect',
x: 0,
y: 0,
w: 100,
h: 50,
label: 'Hi',
}
defaultCreateMermaidNodeFromBlueprint({
editor,
node,
shapeId: 's1' as any,
x: 10,
y: 20,
diagramKind: 'flowchart',
render: { variant: 'geo', geo: 'diamond' },
})
expect(createShape).toHaveBeenCalledWith(
expect.objectContaining({
id: 's1',
type: 'geo',
x: 10,
y: 20,
props: expect.objectContaining({
geo: 'diamond',
w: 100,
h: 50,
}),
})
)
})
it('creates a custom shape type with merged props', () => {
const createShape = vi.fn()
const getShape = vi.fn(() => ({ id: 's2', type: 'text' }))
const editor = { createShape, getShape } as any
const node = {
id: 'B',
kind: 'rect',
x: 0,
y: 0,
w: 80,
h: 40,
}
defaultCreateMermaidNodeFromBlueprint({
editor,
node,
shapeId: 's2' as any,
x: 0,
y: 0,
diagramKind: 'flowchart',
render: {
variant: 'shape',
type: 'text',
props: { font: 'draw' },
},
})
expect(createShape).toHaveBeenCalledWith(
expect.objectContaining({
id: 's2',
type: 'text',
props: expect.objectContaining({
w: 80,
h: 40,
font: 'draw',
}),
})
)
})
})

View File

@@ -0,0 +1,96 @@
import type { Editor, TLDefaultSizeStyle, TLGeoShape, TLShape, TLShapeId } from 'tldraw'
import {
GeoShapeUtil,
getDisplayValues,
renderHtmlFromRichTextForMeasurement,
toRichText,
} from 'tldraw'
import type {
MermaidBlueprintNode,
MermaidBlueprintNodeRenderSpec,
MermaidDiagramKind,
} from './blueprint'
import { sanitizeDiagramText } from './utils'
/**
* Arguments for {@link defaultCreateMermaidNodeFromBlueprint}. `x` / `y` are in shape space (parent-relative when
* `parentShapeId` is set), matching {@link renderBlueprint}.
* @public
*/
export interface MermaidNodeCreateFunctionArgs {
editor: Editor
node: MermaidBlueprintNode
shapeId: TLShapeId
x: number
y: number
parentShapeId?: TLShapeId
diagramKind: MermaidDiagramKind
/** Materialization spec (from {@link resolveMermaidNodeRender} or your mapper). */
render: MermaidBlueprintNodeRenderSpec
}
/**
* Creates the tldraw shape for one Mermaid blueprint node using `render` and layout-derived props.
* @public
*/
export function defaultCreateMermaidNodeFromBlueprint(
args: MermaidNodeCreateFunctionArgs
): TLShape {
const { editor, node, shapeId, x, y, parentShapeId, render } = args
const baseProps = {
w: node.w,
h: node.h,
fill: node.fill ?? 'none',
color: node.color ?? 'black',
dash: node.dash ?? 'draw',
size: node.size ?? 'm',
...(node.label && { richText: toRichText(sanitizeDiagramText(node.label)) }),
...(node.align && { align: node.align }),
...(node.verticalAlign && { verticalAlign: node.verticalAlign }),
}
const { type, props } =
render.variant === 'geo'
? { type: 'geo', props: { geo: render.geo } }
: { type: render.type, props: render.props }
editor.createShape({
id: shapeId,
type: type as any,
x,
y,
parentId: parentShapeId,
props: { ...baseProps, ...props },
})
return editor.getShape(shapeId)!
}
/** The width a geo shape needs to hold a label without wrapping any of its lines. */
export type MeasureLabelWidth = (label: string, size: TLDefaultSizeStyle) => number
/**
* Measures labels the way {@link defaultCreateMermaidNodeFromBlueprint} draws them: in a geo shape,
* in tldraw's font. Mermaid measures its own font, so a box sized from mermaid's layout alone can be
* too narrow for the same text.
*/
export function createLabelWidthMeasurer(editor: Editor): MeasureLabelWidth {
const util = editor.getShapeUtil<GeoShapeUtil>('geo')
const defaultProps = util.getDefaultProps()
return (label, size) => {
const shape = { type: 'geo', props: { ...defaultProps, size } } as TLGeoShape
const dv = getDisplayValues(util, shape)
const richText = toRichText(sanitizeDiagramText(label))
const { w } = editor.textMeasure.measureHtml(
renderHtmlFromRichTextForMeasurement(editor, richText),
{
fontStyle: 'normal',
fontWeight: 'normal',
padding: '0px',
fontFamily: dv.labelFontFamily,
fontSize: dv.labelFontSize,
lineHeight: dv.labelLineHeight,
maxWidth: null,
}
)
return Math.ceil(w + dv.labelPadding * 2)
}
}

View File

@@ -0,0 +1,112 @@
import type { MindmapNode } from 'mermaid/dist/diagrams/mindmap/mindmapTypes.js'
import type { TLDefaultColorStyle, TLDefaultSizeStyle } from 'tldraw'
import type {
DiagramMermaidBlueprint,
MermaidBlueprintEdge,
MermaidBlueprintNode,
} from './blueprint'
import { parseRgbToTldrawColor } from './colors'
import { parseDomId, parseNodesFromSvg, scaleLayout } from './svgParsing'
import type { ParsedNode } from './svgParsing'
import { dropDanglingEdges, LAYOUT_SCALE } from './utils'
/** Mermaid mindmap node `type` integers (`kind` on blueprint nodes is `String(type)`). @public */
export const MERMAID_MINDMAP_NODE_TYPE = {
DEFAULT: 0,
ROUNDED_RECT: 1,
RECT: 2,
CIRCLE: 3,
CLOUD: 4,
BANG: 5,
HEXAGON: 6,
} as const
function getEdgeSizeForLevel(parentLevel: number): TLDefaultSizeStyle {
if (parentLevel <= 0) return 'l'
if (parentLevel === 1) return 'm'
return 's'
}
/**
* Pre-parsed SVG layout for mindmap diagram converters.
* Contains already-scaled node positions extracted from the SVG.
*/
export interface ParsedMindmapLayout {
nodes: Map<string, ParsedNode>
}
const NODE_ID = /^node_(\d+)$/
const parseNodeId = (domId: string) => parseDomId(domId, NODE_ID)
/** Parse mindmap-specific SVG layout data for use by {@link mindmapToBlueprint}. */
export function parseMindmapLayout(root: Element): ParsedMindmapLayout {
const nodes = parseNodesFromSvg(root, '.node', parseNodeId)
scaleLayout({ nodes, clusters: new Map(), edges: [], edgeLabels: new Map() }, LAYOUT_SCALE)
return { nodes }
}
/** Convert a parsed Mermaid mindmap into a tldraw blueprint of nodes and edges. */
export function mindmapToBlueprint(
layout: ParsedMindmapLayout,
mindmapTree: MindmapNode,
svgRoot: Element
): DiagramMermaidBlueprint {
const nodeColors = new Map<string, TLDefaultColorStyle>()
for (const el of svgRoot.querySelectorAll('.node')) {
const shape =
el.querySelector('rect, circle, ellipse, polygon, path') ??
el.querySelector('.label-container')
if (!shape) continue
const parsed = parseRgbToTldrawColor(getComputedStyle(shape).fill)
if (parsed) nodeColors.set(parseNodeId(el.getAttribute('id') || ''), parsed.color)
}
const nodes: MermaidBlueprintNode[] = []
const edges: MermaidBlueprintEdge[] = []
function visit(node: MindmapNode, parent: MindmapNode | undefined) {
const id = String(node.id)
const svgNode = layout.nodes.get(id)
if (svgNode) {
const color = nodeColors.get(id) ?? 'black'
let { width: w, height: h } = svgNode
if (node.type === MERMAID_MINDMAP_NODE_TYPE.CIRCLE) {
w = h = Math.max(w, h)
}
nodes.push({
id,
kind: String(node.type),
x: svgNode.center.x - w / 2,
y: svgNode.center.y - h / 2,
w,
h,
label: node.descr || undefined,
fill: 'solid',
color,
size: node.isRoot ? 'l' : 'm',
align: 'middle',
verticalAlign: 'middle',
})
// Edge from parent to this node
if (parent) {
edges.push({
startNodeId: String(parent.id),
endNodeId: id,
bend: 0,
arrowheadEnd: 'none',
arrowheadStart: 'none',
size: getEdgeSizeForLevel(parent.level),
color,
})
}
}
for (const child of node.children) visit(child, node)
}
visit(mindmapTree, undefined)
return { diagramKind: 'mindmap', nodes, edges: dropDanglingEdges(nodes, edges) }
}

View File

@@ -0,0 +1,101 @@
import { describe, expect, it, vi } from 'vitest'
import type { DiagramMermaidBlueprint } from './blueprint'
import { renderBlueprint } from './renderBlueprint'
function mockEditor() {
const bounds = (w: number, h: number) => ({
x: 0,
y: 0,
w,
h,
midX: w / 2,
midY: h / 2,
maxX: w,
center: { x: w / 2, y: h / 2 },
})
const editor = {
createShape: vi.fn(),
getShape: vi.fn((id) => ({ id, type: 'geo', x: 0, y: 0 })),
getShapePageBounds: vi.fn(() => bounds(80, 40)),
updateShape: vi.fn(),
run: vi.fn((fn) => fn()),
createBindings: vi.fn(),
groupShapes: vi.fn(),
sendToBack: vi.fn(),
}
return editor as any
}
describe('renderBlueprint', () => {
it('anchors an arrow across the width of its shape, not only down its middle', () => {
const editor = mockEditor()
const blueprint: DiagramMermaidBlueprint = {
diagramKind: 'flowchart',
nodes: [{ id: 'B', kind: 'rect', x: 0, y: 0, w: 80, h: 40 }],
edges: [
{
startNodeId: 'B',
endNodeId: 'B',
bend: -60,
anchorStartX: 0.375,
anchorStartY: 1,
anchorEndX: 0.75,
anchorEndY: 1,
},
],
}
renderBlueprint(editor, blueprint, { position: { x: 0, y: 0 }, centerOnPosition: false })
const [bindings] = editor.createBindings.mock.calls[0]
expect(bindings.map((binding: any) => binding.props.normalizedAnchor)).toEqual([
{ x: 0.375, y: 1 },
{ x: 0.75, y: 1 },
])
})
it('draws an unsized edge at the same size as an unsized node', () => {
const editor = mockEditor()
const blueprint: DiagramMermaidBlueprint = {
diagramKind: 'state',
nodes: [
{ id: 'Idle', kind: 'default', label: 'Idle', x: 0, y: 0, w: 80, h: 40 },
{ id: 'Running', kind: 'default', label: 'Running', x: 0, y: 200, w: 80, h: 40 },
],
edges: [{ startNodeId: 'Idle', endNodeId: 'Running', label: 'start', bend: 0 }],
}
renderBlueprint(editor, blueprint, { position: { x: 0, y: 0 }, centerOnPosition: false })
const sizes = editor.createShape.mock.calls.map(([shape]: any) => [
shape.type,
shape.props.size,
])
expect(sizes).toEqual([
['geo', 'm'],
['geo', 'm'],
['arrow', 'm'],
])
})
it('sends background nodes behind the lines, keeping their own order', () => {
const editor = mockEditor()
const blueprint: DiagramMermaidBlueprint = {
diagramKind: 'sequence',
nodes: [
{ id: 'box', kind: 'sequence_box', x: 0, y: 0, w: 200, h: 200, background: true },
{ id: 'rect', kind: 'sequence_fragment', x: 10, y: 50, w: 180, h: 50, background: true },
{ id: 'actor', kind: 'participant', x: 50, y: 0, w: 100, h: 40 },
],
edges: [],
lines: [{ id: 'lifeline', x: 100, y: 40, endY: 160 }],
}
renderBlueprint(editor, blueprint, { position: { x: 0, y: 0 }, centerOnPosition: false })
// Creation order is z-order, so the line sits beneath every node until the background nodes move.
const created = editor.createShape.mock.calls.map(([shape]: any) => shape)
expect(created.map((shape: any) => shape.type)).toEqual(['line', 'geo', 'geo', 'geo'])
expect(editor.sendToBack).toHaveBeenCalledExactlyOnceWith([created[1].id, created[2].id])
})
})

View File

@@ -0,0 +1,322 @@
import { createShapeId, Editor, IndexKey, TLLineShape, TLShapeId, toRichText, Vec } from 'tldraw'
import type {
DiagramMermaidBlueprint,
MermaidBlueprintEdge,
MermaidBlueprintLineNode,
MermaidBlueprintNode,
MermaidNodeRenderMapper,
} from './blueprint'
import { resolveMermaidNodeRender } from './defaultMermaidNodeRenderSpec'
import { defaultCreateMermaidNodeFromBlueprint } from './mermaidNodeCreateShape'
import { orderTopDown, sanitizeDiagramText } from './utils'
/** @public */
export interface BlueprintRenderingOptions {
centerOnPosition?: boolean
position?: { x: number; y: number }
/**
* Return a custom {@link MermaidBlueprintNodeRenderSpec} per node, or `undefined` for package defaults.
* Called from {@link renderBlueprint} for each node (after layout offsets are known).
*/
mapNodeToRenderSpec?: MermaidNodeRenderMapper
}
const defaultBlueprintRenderingOptions = {
centerOnPosition: true,
}
/** @public */
export function renderBlueprint(
editor: Editor,
blueprint: DiagramMermaidBlueprint,
opts?: BlueprintRenderingOptions
) {
const options = { ...defaultBlueprintRenderingOptions, ...opts }
const { nodes, edges, lines, diagramKind } = blueprint
const mapper = options.mapNodeToRenderSpec
const bounds = computeBlueprintBounds(nodes, lines)
const center =
options.position ??
(editor.user.getIsPasteAtCursorMode()
? editor.inputs.getCurrentPagePoint()
: editor.getViewportPageBounds().center)
const offsetX = options.centerOnPosition
? center.x - (bounds.maxX + bounds.minX) / 2
: center.x - bounds.minX
const offsetY = options.centerOnPosition
? center.y - (bounds.maxY + bounds.minY) / 2
: center.y - bounds.minY
const ordered = orderTopDown(
nodes,
(n) => n.id,
(n) => n.parentId
)
const nodeById = new Map(nodes.map((node) => [node.id, node]))
const shapeIds = new Map<string, TLShapeId>()
// Lines first so nodes render on top (z-order = creation order in tldraw)
for (const line of lines ?? []) {
const lineId = createShapeId()
shapeIds.set(line.id, lineId)
editor.createShape<TLLineShape>({
id: lineId,
type: 'line',
x: offsetX + line.x,
y: offsetY + line.y,
props: {
dash: line.dash ?? 'solid',
size: line.size ?? 's',
color: line.color ?? 'black',
spline: 'line',
points: {
a1: { id: 'a1', index: 'a1' as IndexKey, x: 0, y: 0 },
a2: { id: 'a2', index: 'a2' as IndexKey, x: line.endX ?? 0, y: line.endY },
},
},
})
}
const backgroundIds: TLShapeId[] = []
for (const node of ordered) {
const shapeId = createShapeId()
shapeIds.set(node.id, shapeId)
if (node.background) backgroundIds.push(shapeId)
const parent = node.parentId ? nodeById.get(node.parentId) : undefined
const parentShapeId = node.parentId ? shapeIds.get(node.parentId) : undefined
defaultCreateMermaidNodeFromBlueprint({
editor,
node,
shapeId,
x: parent ? node.x - parent.x : offsetX + node.x,
y: parent ? node.y - parent.y : offsetY + node.y,
parentShapeId,
diagramKind,
render: resolveMermaidNodeRender(diagramKind, node, mapper),
})
}
// One call, so the background nodes keep the blueprint's order among themselves.
if (backgroundIds.length > 0) editor.sendToBack(backgroundIds)
const arrowIds: TLShapeId[] = []
for (const edge of edges) {
const arrowId = createArrowFromEdge(editor, edge, shapeIds)
if (arrowId) arrowIds.push(arrowId)
}
// Create sub-groups and track which shape IDs are consumed by a group
const groupedIds = new Set<TLShapeId>()
const topLevelIds: TLShapeId[] = []
for (const group of blueprint.groups ?? []) {
const members: TLShapeId[] = []
for (const blueprintId of group) {
const memberShapeId = shapeIds.get(blueprintId)
if (memberShapeId) {
members.push(memberShapeId)
groupedIds.add(memberShapeId)
}
}
if (members.length > 0) topLevelIds.push(groupShapes(editor, members) ?? members[0])
}
// Collect ungrouped top-level IDs
for (const item of [...nodes.filter((node) => !node.parentId), ...(lines ?? [])]) {
const itemShapeId = shapeIds.get(item.id)
if (itemShapeId && !groupedIds.has(itemShapeId)) topLevelIds.push(itemShapeId)
}
topLevelIds.push(...arrowIds)
const rootShapeId = groupShapes(editor, topLevelIds)
if (rootShapeId) {
const actualBounds = editor.getShapePageBounds(rootShapeId)
if (actualBounds) {
const desiredX = options.centerOnPosition ? center.x - actualBounds.w / 2 : center.x
const desiredY = options.centerOnPosition ? center.y - actualBounds.h / 2 : center.y
const dx = desiredX - actualBounds.x
const dy = desiredY - actualBounds.y
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
const shape = editor.getShape(rootShapeId)!
editor.updateShape({
id: rootShapeId,
type: shape.type,
x: shape.x + dx,
y: shape.y + dy,
})
}
}
}
}
/**
* Group `ids` and return the group id (or the single id when there is only one).
* Returns undefined when nothing was grouped, e.g. no ids or grouping failed.
*/
function groupShapes(editor: Editor, ids: TLShapeId[]): TLShapeId | undefined {
if (ids.length === 0) return undefined
if (ids.length === 1) return ids[0]
const groupId = createShapeId()
editor.groupShapes(ids, { groupId })
return editor.getShape(groupId) ? groupId : undefined
}
interface ArrowTerminal {
point: { x: number; y: number }
anchor: { x: number; y: number }
isExact: boolean
isPrecise: boolean
}
function createArrowFromEdge(
editor: Editor,
edge: MermaidBlueprintEdge,
shapeIds: Map<string, TLShapeId>
): TLShapeId | undefined {
const startShapeId = shapeIds.get(edge.startNodeId)
const endShapeId = shapeIds.get(edge.endNodeId)
if (!startShapeId || !endShapeId) return undefined
const startBounds = editor.getShapePageBounds(startShapeId)
const endBounds = editor.getShapePageBounds(endShapeId)
if (!startBounds || !endBounds) return undefined
let labelText = edge.label
if (edge.decoration?.type === 'autonumber') {
const num = edge.decoration.value
labelText = labelText ? `${num} ${labelText}` : num
}
const baseProps = {
dash: edge.dash ?? ('solid' as const),
// Match the node default: an arrow's size also sets its label font, so a smaller edge labels
// itself in smaller text than the boxes it joins.
size: edge.size ?? ('m' as const),
arrowheadEnd: edge.arrowheadEnd ?? ('arrow' as const),
...(edge.arrowheadStart && { arrowheadStart: edge.arrowheadStart }),
color: edge.color ?? ('black' as const),
...(labelText && { richText: toRichText(sanitizeDiagramText(labelText)) }),
}
let origin: { x: number; y: number }
let start: ArrowTerminal
let end: ArrowTerminal
let bend = edge.bend
if (
edge.anchorStartX !== undefined ||
edge.anchorStartY !== undefined ||
edge.anchorEndX !== undefined ||
edge.anchorEndY !== undefined
) {
const startAnchor = { x: edge.anchorStartX ?? 0.5, y: edge.anchorStartY ?? 0.5 }
const endAnchor = { x: edge.anchorEndX ?? 0.5, y: edge.anchorEndY ?? 0.5 }
const isExact = edge.isExact ?? true
const isPrecise = edge.isPrecise ?? true
start = {
point: {
x: startBounds.x + startBounds.w * startAnchor.x,
y: startBounds.y + startBounds.h * startAnchor.y,
},
anchor: startAnchor,
isExact,
isPrecise,
}
end = {
point: {
x: endBounds.x + endBounds.w * endAnchor.x,
y: endBounds.y + endBounds.h * endAnchor.y,
},
anchor: endAnchor,
isExact: edge.isExactEnd ?? isExact,
isPrecise: edge.isPreciseEnd ?? isPrecise,
}
origin = Vec.Min(start.point, end.point)
} else if (startShapeId === endShapeId) {
origin = { x: startBounds.x, y: startBounds.y }
start = {
point: { x: startBounds.midX, y: startBounds.y },
anchor: { x: 0.9, y: 0.5 },
isExact: false,
isPrecise: false,
}
end = {
point: { x: startBounds.maxX, y: startBounds.midY },
anchor: { x: 0.85, y: 0.8 },
isExact: false,
isPrecise: false,
}
bend = -80
} else {
start = {
point: startBounds.center,
anchor: { x: 0.5, y: 0.5 },
isExact: false,
isPrecise: false,
}
end = { point: endBounds.center, anchor: { x: 0.5, y: 0.5 }, isExact: false, isPrecise: false }
origin = Vec.Min(start.point, end.point)
}
const arrowId = createShapeId()
editor.run(() => {
editor.createShape({
id: arrowId,
type: 'arrow',
x: origin.x,
y: origin.y,
props: {
...baseProps,
start: { x: start.point.x - origin.x, y: start.point.y - origin.y },
end: { x: end.point.x - origin.x, y: end.point.y - origin.y },
bend,
},
})
editor.createBindings([
makeArrowBinding(arrowId, startShapeId, 'start', start),
makeArrowBinding(arrowId, endShapeId, 'end', end),
])
})
return arrowId
}
function makeArrowBinding(
arrowId: TLShapeId,
targetId: TLShapeId,
terminal: 'start' | 'end',
{ anchor, isExact, isPrecise }: ArrowTerminal
) {
return {
fromId: arrowId,
toId: targetId,
type: 'arrow' as const,
props: { terminal, normalizedAnchor: anchor, isExact, isPrecise },
}
}
function computeBlueprintBounds(
nodes: MermaidBlueprintNode[],
lines?: MermaidBlueprintLineNode[]
): { minX: number; minY: number; maxX: number; maxY: number } {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const node of nodes) {
if (node.parentId) continue
minX = Math.min(minX, node.x)
minY = Math.min(minY, node.y)
maxX = Math.max(maxX, node.x + node.w)
maxY = Math.max(maxY, node.y + node.h)
}
for (const line of lines ?? []) {
minX = Math.min(minX, line.x)
minY = Math.min(minY, line.y)
maxX = Math.max(maxX, line.x)
maxY = Math.max(maxY, line.y + line.endY)
}
return { minX, minY, maxX, maxY }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,391 @@
import type { StateStmt, StyleClass } from 'mermaid/dist/diagrams/state/stateDb.d.ts'
import type {
DiagramMermaidBlueprint,
MermaidBlueprintEdge,
MermaidBlueprintNode,
} from './blueprint'
import { buildClassDefColorMap, type ParsedNodeColors, toNodeColorProps } from './colors'
import {
buildNodeCentersFromSvg,
claimEdge,
getSelfLoopEdgeLayout,
parseAllEdgePointsFromSvg,
parseClustersFromSvg,
parseDomId,
parseEdgeLabelsFromSvg,
type ParsedDiagramLayout,
parseNodesFromSvg,
scaleLayout,
} from './svgParsing'
import { dropDanglingEdges, getArrowBend, LAYOUT_SCALE, orderTopDown } from './utils'
interface DiagramEdge {
id1: string
id2: string
relationTitle?: string
}
interface FlatState {
id: string
type: string
label: string
}
function getEffectiveType(state: StateStmt): string {
if (state.type && state.type !== 'default') return state.type
// Mermaid auto-generates start/end pseudo-state ids ending with "_start"
// or "_end", optionally followed by a disambiguation digit (e.g. "_start2").
if (/_start\d*$/.test(state.id)) return 'start'
if (/_end\d*$/.test(state.id)) return 'end'
return state.type || 'default'
}
const UNLABELED_TYPES = new Set(['start', 'end'])
function getStateLabel(state: StateStmt): string {
if (state.descriptions && state.descriptions.length > 0) {
return state.descriptions.join('\n')
}
if (state.description) return state.description
if (UNLABELED_TYPES.has(getEffectiveType(state))) return ''
return state.id
}
interface FlattenResult {
leafStates: Map<string, FlatState>
compoundLabels: Map<string, string>
parentOf: Map<string, string>
allEdges: DiagramEdge[]
}
function flattenStateHierarchy(
topLevelStates: Map<string, StateStmt>,
topLevelRelations: DiagramEdge[]
): FlattenResult {
const result: FlattenResult = {
leafStates: new Map(),
compoundLabels: new Map(),
parentOf: new Map(),
allEdges: [],
}
function visit(
states: Map<string, StateStmt>,
relations: DiagramEdge[],
parentCompound: string | null
) {
for (const [id, state] of states) {
if (parentCompound) result.parentOf.set(id, parentCompound)
if (!state.doc || state.doc.length === 0) {
result.leafStates.set(id, {
id,
type: getEffectiveType(state),
label: getStateLabel(state),
})
continue
}
result.compoundLabels.set(id, state.description || id)
const childStates = new Map<string, StateStmt>()
const childRelations: DiagramEdge[] = []
for (const stmt of state.doc) {
if (typeof stmt === 'string') {
// Mermaid emits raw strings for stereotyped state declarations
// like `state H <<history>>` — the ID and stereotype are stored
// as separate plain string entries. Look up the state in the
// top-level map so it gets proper parentage.
const topState = topLevelStates.get(stmt)
if (topState && !childStates.has(stmt)) childStates.set(stmt, topState)
} else if (stmt.stmt === 'state' || stmt.stmt === 'default') {
const stateEntry = stmt as StateStmt
childStates.set(stateEntry.id, stateEntry)
} else if (stmt.stmt === 'relation') {
const relation = stmt as unknown as {
state1: StateStmt
state2: StateStmt
description?: string
}
// Relation state refs are shallow objects without `doc`. Only add
// them when the state hasn't been registered yet so we don't
// overwrite a compound-state entry that carries its nested doc.
for (const ref of [relation.state1, relation.state2]) {
if (!childStates.has(ref.id)) childStates.set(ref.id, ref)
}
childRelations.push({
id1: relation.state1.id,
id2: relation.state2.id,
relationTitle: relation.description,
})
}
}
visit(childStates, childRelations, id)
}
result.allEdges.push(...relations)
}
visit(topLevelStates, topLevelRelations, null)
return result
}
const FIXED_NODE_SIZES: Record<string, [number, number]> = {
start: [36, 36],
end: [40, 40],
}
interface Rect {
x: number
y: number
w: number
h: number
}
function stateToNodes(
state: FlatState,
rect: Rect,
parentId: string | undefined,
colors: ParsedNodeColors | undefined
): MermaidBlueprintNode[] {
const { x, y, w, h } = rect
const label = state.label || undefined
const node = (
id: string,
kind: string,
overrides: Partial<Omit<MermaidBlueprintNode, 'id' | 'kind'>>
): MermaidBlueprintNode => ({ id, kind, ...rect, parentId, color: 'black', ...overrides })
switch (state.type) {
case 'note':
return [
node(state.id, 'note', {
label,
fill: 'solid',
color: 'yellow',
size: 's',
align: 'middle',
verticalAlign: 'middle',
}),
]
case 'start':
return [node(state.id, 'start', { fill: 'solid' })]
case 'end': {
const innerSize = w * 0.6
return [
node(state.id, 'end', { fill: 'none' }),
node(`${state.id}__inner`, 'end_inner', {
x: x + (w - innerSize) / 2,
y: y + (h - innerSize) / 2,
w: innerSize,
h: innerSize,
fill: 'solid',
}),
]
}
case 'fork':
case 'join': {
const barW = w * 4
const barH = Math.max(16, barW / 10)
return [
node(state.id, state.type, {
x: x - (barW - w) / 2,
y: y + (h - barH) / 2,
w: barW,
h: barH,
fill: 'solid',
}),
]
}
case 'choice':
return [
node(state.id, 'choice', {
label,
align: 'middle',
verticalAlign: 'middle',
size: 'm',
}),
]
default:
return [
node(state.id, state.type, {
label,
...toNodeColorProps(colors),
align: 'middle',
verticalAlign: 'middle',
size: 'm',
}),
]
}
}
const FRAME_PAD = 24
const FRAME_TOP = 54
const STATE_ID = /^state-(.+)-\d+$/
/** Parse state-diagram SVG layout data for use by {@link stateToBlueprint}. */
export function parseStateDiagramLayout(root: Element): ParsedDiagramLayout {
const parseId = (domId: string) => parseDomId(domId, STATE_ID)
const nodes = parseNodesFromSvg(root, '.node', parseId)
const clusters = parseClustersFromSvg(root, '.statediagram-cluster', parseId)
const edges = parseAllEdgePointsFromSvg(root, (dataId) =>
/(?:^|-)edge\d+$/.test(dataId) ? { start: '', end: '' } : null
)
const layout = { nodes, clusters, edges, edgeLabels: parseEdgeLabelsFromSvg(root) }
scaleLayout(layout, LAYOUT_SCALE)
return layout
}
/** Convert a parsed Mermaid state diagram into a tldraw blueprint of nodes and edges. */
export function stateToBlueprint(
layout: ParsedDiagramLayout,
states: Map<string, StateStmt>,
relations: DiagramEdge[],
classDefs?: Map<string, StyleClass>
): DiagramMermaidBlueprint {
const stateColorMap = buildClassDefColorMap(classDefs ?? new Map(), states)
const { nodes: svgNodes, clusters: svgClusters, edges: svgEdges } = layout
const nodeCenters = buildNodeCentersFromSvg(svgNodes, svgClusters)
const { leafStates, compoundLabels, parentOf, allEdges } = flattenStateHierarchy(
states,
relations
)
// Collect notes attached to states and add them as synthetic leaf nodes + edges.
for (const [id, state] of states) {
const note = (state as StateStmt & { note?: { position?: string; text: string } }).note
if (!note) continue
const noteId = `${id}----note`
leafStates.set(noteId, { id: noteId, type: 'note', label: note.text.trim() })
allEdges.push({ id1: id, id2: noteId, relationTitle: undefined })
}
const nodeLayout = new Map<string, Rect>()
for (const [id, state] of leafStates) {
const svgNode = svgNodes.get(id)
if (!svgNode) continue
const [w, h] = FIXED_NODE_SIZES[state.type] ?? [svgNode.width + 20, svgNode.height + 8]
nodeLayout.set(id, { x: svgNode.center.x - w / 2, y: svgNode.center.y - h / 2, w, h })
}
const compoundIds = [...compoundLabels.keys()]
const compoundsTopDown = orderTopDown(
compoundIds,
(id) => id,
(id) => parentOf.get(id)
)
const frameBounds = new Map<string, Rect>()
// Use SVG cluster bounds as the authoritative frame size. Mermaid's
// layout already accounts for label width, padding, and special nodes
// like <<history>> that are rendered outside the visual cluster.
// Fall back to child-based computation only when no SVG cluster exists.
// Children are visited first (bottom-up) so nested frames are already sized.
for (const compoundId of [...compoundsTopDown].reverse()) {
const cluster = svgClusters.get(compoundId)
if (cluster) {
frameBounds.set(compoundId, {
x: cluster.topLeft.x,
y: cluster.topLeft.y,
w: cluster.width,
h: cluster.height,
})
continue
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const [id, rect] of [...nodeLayout, ...frameBounds]) {
if (parentOf.get(id) !== compoundId) continue
minX = Math.min(minX, rect.x)
minY = Math.min(minY, rect.y)
maxX = Math.max(maxX, rect.x + rect.w)
maxY = Math.max(maxY, rect.y + rect.h)
}
if (!isFinite(minX)) continue
frameBounds.set(compoundId, {
x: minX - FRAME_PAD,
y: minY - FRAME_TOP,
w: maxX - minX + FRAME_PAD * 2,
h: maxY - minY + FRAME_PAD + FRAME_TOP,
})
}
// Un-parent any leaf state whose center falls outside its parent frame.
// Mermaid renders some pseudo-states (e.g. <<history>>) outside the
// visual compound cluster even though they're declared inside it.
for (const [id, rect] of nodeLayout) {
const pid = parentOf.get(id)
const frame = pid ? frameBounds.get(pid) : undefined
if (!frame) continue
const cx = rect.x + rect.w / 2
const cy = rect.y + rect.h / 2
if (cx < frame.x || cx > frame.x + frame.w || cy < frame.y || cy > frame.y + frame.h) {
parentOf.delete(id)
}
}
const nodes: MermaidBlueprintNode[] = []
const blueprintEdges: MermaidBlueprintEdge[] = []
for (const compoundId of compoundsTopDown) {
const bounds = frameBounds.get(compoundId)
if (!bounds) continue
nodes.push({
id: compoundId,
kind: 'compound',
...bounds,
parentId: parentOf.get(compoundId),
label: compoundLabels.get(compoundId) || compoundId,
fill: 'semi',
color: 'black',
dash: 'draw',
size: 's',
align: 'middle',
verticalAlign: 'start',
})
}
for (const [id, state] of leafStates) {
const rect = nodeLayout.get(id)
if (!rect) continue
nodes.push(...stateToNodes(state, rect, parentOf.get(id), stateColorMap.get(id)))
}
const claimed = new Set<number>()
for (const edge of allEdges) {
const svgEdge = claimEdge(svgEdges, claimed, {
startId: edge.id1,
endId: edge.id2,
startCenter: nodeCenters.get(edge.id1),
endCenter: nodeCenters.get(edge.id2),
})
const svgNode = svgNodes.get(edge.id1)
const selfLoop =
edge.id1 === edge.id2 && svgEdge && svgNode
? getSelfLoopEdgeLayout(svgEdge, svgNode, layout.edgeLabels)
: undefined
const isNoteEdge = edge.id2.endsWith('----note') || edge.id1.endsWith('----note')
blueprintEdges.push({
startNodeId: edge.id1,
endNodeId: edge.id2,
label: edge.relationTitle,
bend: svgEdge ? getArrowBend(svgEdge) : 0,
...selfLoop,
...(isNoteEdge && { dash: 'dotted' as const, arrowheadEnd: 'none' as const }),
})
}
return { diagramKind: 'state', nodes, edges: dropDanglingEdges(nodes, blueprintEdges) }
}

View File

@@ -0,0 +1,348 @@
import type { SequenceDB } from 'mermaid/dist/diagrams/sequence/sequenceDb.d.ts'
import { parseFlowchartLayout } from './flowchartDiagram'
import { countSequenceEvents, LINETYPE, parseSequenceLayout } from './sequenceDiagram'
import { parseStateDiagramLayout } from './stateDiagram'
function svgFromString(markup: string): SVGSVGElement {
const container = document.createElement('div')
container.innerHTML = markup
return container.querySelector('svg') as unknown as SVGSVGElement
}
function nodeMarkup(domId: string) {
return `<g class="node" id="${domId}" transform="translate(50,40)"><rect width="80" height="40" /></g>`
}
function edgeMarkup(dataId: string, points: [number, number][]) {
const data = btoa(JSON.stringify(points.map(([x, y]) => ({ x, y }))))
return `<path data-id="${dataId}" id="mermaid-0-${dataId}" data-points="${data}" />`
}
// Regression for the mermaid 11.15 upgrade: every rendered element id gained a
// `<svgId>-` prefix, which broke the node/cluster id parsers and produced empty
// diagrams (and therefore failed exports). The parsers must resolve bare ids
// across mermaid versions.
describe('layout parsing tolerates mermaid >= 11.15 prefixed ids', () => {
it('parses flowchart nodes, clusters, and edges', () => {
const svg = svgFromString(`
<svg id="mermaid-0">
<g class="cluster" id="mermaid-0-Frontend" transform="translate(0,0)">
<rect x="0" y="0" width="200" height="160" />
</g>
${nodeMarkup('mermaid-0-flowchart-A-0')}
${nodeMarkup('mermaid-0-flowchart-B-1')}
${edgeMarkup('L_A_B_0', [
[0, 0],
[100, 0],
])}
</svg>
`)
const layout = parseFlowchartLayout(svg)
expect([...layout.nodes.keys()]).toEqual(['A', 'B'])
expect([...layout.clusters.keys()]).toEqual(['Frontend'])
expect(layout.edges.map((e) => [e.start, e.end])).toEqual([['A', 'B']])
})
it('parses state nodes, clusters, and edges', () => {
const svg = svgFromString(`
<svg id="mermaid-1">
<g class="statediagram-cluster" id="mermaid-1-state-Active-4" transform="translate(0,0)">
<rect x="0" y="0" width="240" height="180" />
</g>
${nodeMarkup('mermaid-1-state-Idle-3')}
${nodeMarkup('mermaid-1-state-Moving-5')}
${edgeMarkup('edge0', [
[0, 0],
[100, 0],
])}
</svg>
`)
const layout = parseStateDiagramLayout(svg)
expect([...layout.nodes.keys()]).toEqual(['Idle', 'Moving'])
expect([...layout.clusters.keys()]).toEqual(['Active'])
expect(layout.edges).toHaveLength(1)
})
it("splits an edge id at the underscore that names two of the diagram's nodes", () => {
// `L_my_node_other_node_0` could start at `my` or `my_node`; only the nodes say which. Getting
// it wrong leaves the edge without usable ids, and parallel edges back on proximity (#10794).
const svg = svgFromString(`
<svg id="mermaid-0">
${nodeMarkup('mermaid-0-flowchart-my_node-0')}
${nodeMarkup('mermaid-0-flowchart-other_node-1')}
${edgeMarkup('L_my_node_other_node_0', [
[0, 0],
[100, 0],
])}
</svg>
`)
const layout = parseFlowchartLayout(svg)
expect(layout.edges.map((e) => [e.start, e.end])).toEqual([['my_node', 'other_node']])
})
it('splits an ambiguous edge id by which pair of nodes the path runs between', () => {
// With all four of these nodes, `L_a_b_c_0` could join `a` to `b_c` or `a_b` to `c`. Picking
// whichever comes first hands the path to the wrong edge, the mix-up this matching exists to
// avoid (#10794).
const positioned = (domId: string, x: number, y: number) =>
`<g class="node" id="${domId}" transform="translate(${x},${y})"><rect width="80" height="40" /></g>`
const svg = svgFromString(`
<svg id="mermaid-0">
${positioned('mermaid-0-flowchart-a-0', 0, 0)}
${positioned('mermaid-0-flowchart-b_c-1', 300, 0)}
${positioned('mermaid-0-flowchart-a_b-2', 0, 200)}
${positioned('mermaid-0-flowchart-c-3', 300, 200)}
${edgeMarkup('L_a_b_c_0', [
[20, 200],
[280, 200],
])}
</svg>
`)
const layout = parseFlowchartLayout(svg)
expect(layout.edges.map((e) => [e.start, e.end])).toEqual([['a_b', 'c']])
})
it('splits an edge id onto a subgraph, which an edge can end on too', () => {
// Mermaid links subgraphs as well as nodes (`L_A_grp_0`), so a split naming one is as real as a
// split naming two nodes. Skipping them hands the path to whichever pair of nodes also splits
// the id, which is the mix-up this matching exists to avoid (#10794).
const positioned = (domId: string, x: number, y: number) =>
`<g class="node" id="${domId}" transform="translate(${x},${y})"><rect width="80" height="40" /></g>`
const svg = svgFromString(`
<svg id="mermaid-0">
<g class="cluster" id="mermaid-0-a_b" transform="translate(0,0)">
<rect x="0" y="180" width="80" height="40" />
</g>
${positioned('mermaid-0-flowchart-a-0', 0, 0)}
${positioned('mermaid-0-flowchart-b_c-1', 300, 0)}
${positioned('mermaid-0-flowchart-c-2', 300, 200)}
${edgeMarkup('L_a_b_c_0', [
[40, 200],
[280, 200],
])}
</svg>
`)
const layout = parseFlowchartLayout(svg)
expect(layout.edges.map((e) => [e.start, e.end])).toEqual([['a_b', 'c']])
})
it('still parses bare ids from older mermaid versions', () => {
const svg = svgFromString(`
<svg>
${nodeMarkup('flowchart-A-0')}
${edgeMarkup('L_A_B_0', [
[0, 0],
[100, 0],
])}
</svg>
`)
const layout = parseFlowchartLayout(svg)
expect([...layout.nodes.keys()]).toEqual(['A'])
expect(layout.edges.map((e) => [e.start, e.end])).toEqual([['A', 'B']])
})
})
describe('edge label parsing', () => {
// Mermaid's markup: the label group is translated to its box's top-left corner, inside an
// `edgeLabel` group translated to the label's centre. Edges without text get an empty group.
function labelMarkup(dataId: string, center: [number, number], size: [number, number]) {
const [w, h] = size
return `<g class="edgeLabel" transform="translate(${center[0]}, ${center[1]})">
<g class="label" data-id="${dataId}" transform="translate(${-w / 2}, ${-h / 2})">
<foreignObject width="${w}" height="${h}"></foreignObject>
</g>
</g>`
}
it("keys each label's box by the data-id it shares with its path", () => {
const svg = svgFromString(`
<svg id="mermaid-0">
<g class="edgeLabels" transform="translate(10, 20)">
${labelMarkup('L_B_B_0', [100, 200], [80, 40])}
<g class="edgeLabel"><g class="label" data-id="L_A_B_0" transform="translate(0, 0)">
<foreignObject width="0" height="0"></foreignObject>
</g></g>
</g>
${nodeMarkup('mermaid-0-flowchart-B-0')}
${edgeMarkup('L_B_B_0', [
[40, 60],
[70, 60],
])}
</svg>
`)
const layout = parseFlowchartLayout(svg)
expect(layout.edges[0].id).toBe('L_B_B_0')
// Scaled with the rest of the layout, so the box stays put relative to its loop.
const scale = layout.edges[0].points[0].x / 40
expect(layout.edgeLabels).toEqual(
new Map([['L_B_B_0', { x: 70 * scale, y: 200 * scale, w: 80 * scale, h: 40 * scale }]])
)
})
})
describe('sequence row parsing', () => {
function participantsMarkup(footerY: number) {
return [0, 200]
.map(
(x) => `<rect class="actor actor-top" x="${x}" y="0" width="150" height="65" />
<rect class="actor actor-bottom" x="${x}" y="${footerY}" width="150" height="65" />`
)
.join('')
}
it('reads where mermaid drew each row and fragment frame, keyed by message index', () => {
const svg = svgFromString(`
<svg>
${participantsMarkup(500)}
<g data-et="note" data-id="i1"><rect class="note" x="80" y="121" width="100" height="135" /></g>
<g data-et="control-structure" data-id="i6">
<line class="loopLine" x1="0" y1="280" x2="350" y2="280" />
<line class="loopLine" x1="350" y1="280" x2="350" y2="470" />
<line class="loopLine" x1="0" y1="470" x2="350" y2="470" />
<line class="loopLine" x1="0" y1="280" x2="0" y2="470" />
<line class="loopLine" x1="0" y1="380" x2="350" y2="380" />
</g>
<text class="messageText" y="80">hello</text>
<line data-et="message" data-id="i0" x1="75" y1="111" x2="275" y2="111" />
<path data-et="message" data-id="i2" d="M 275,239 C 335,229 335,269 275,259" />
</svg>
`)
const { rowYs, fragmentFrames } = parseSequenceLayout(svg, 2, 3)
// The diagram is centred on the origin: 565px tall, so mermaid's y = 0 lands at -282.5.
const toLayoutY = (svgY: number) => svgY - 282.5
expect(rowYs).toEqual(
new Map([
// A quarter of the way up from the line to the top of its label.
[0, toLayoutY(111 - (111 - 80) / 4)],
[1, toLayoutY(121 + 135 / 2)],
// Centred on the 20px loop.
[2, toLayoutY(249)],
])
)
expect(fragmentFrames).toEqual(
new Map([[6, { top: toLayoutY(280), bottom: toLayoutY(470), sectionYs: [toLayoutY(380)] }]])
)
})
it("spreads a short diagram's stretch over the rows between header and footer", () => {
const svg = svgFromString(`
<svg>
${participantsMarkup(300)}
<line data-et="message" data-id="i0" x1="75" y1="182.5" x2="275" y2="182.5" />
</svg>
`)
const { actorLayouts, rowYs } = parseSequenceLayout(svg, 2, 1)
// Halfway between the header and the footer before stretching, and after it too.
const headerBottom = actorLayouts[0].y + 65
expect(rowYs.get(0)).toBeCloseTo((headerBottom + actorLayouts[0].bottomY) / 2)
})
it('measures the diagram past a created or destroyed participant', () => {
// mermaid draws a destroyed participant's bottom box on its destruction row and a created
// participant's top box on its creation row, both mid-diagram. Measuring the header-to-footer
// gap from one of those reports a far shorter diagram than mermaid drew, and the stretch that
// compensates then leaves every surviving lifeline too long.
function layoutFor(topYs: number[], bottomYs: number[]) {
const actors = topYs
.map(
(topY, i) =>
`<rect class="actor actor-top" x="${i * 200}" y="${topY}" width="150" height="65" />
<rect class="actor actor-bottom" x="${i * 200}" y="${bottomYs[i]}" width="150" height="65" />`
)
.join('')
const svg = svgFromString(`
<svg>
${actors}
<line data-et="message" data-id="i0" x1="75" y1="182.5" x2="275" y2="182.5" />
</svg>
`)
return parseSequenceLayout(svg, 3, 1)
}
function lifelineLengths(topYs: number[], bottomYs: number[]) {
return layoutFor(topYs, bottomYs).actorLayouts.map((l) => l.bottomY - (l.y + l.h))
}
// 500 - 65 clears MIN_VERTICAL_GAP, so nothing is stretched and each lifeline runs the
// diagram's full height less the header box and its padding.
const full = 500 - 65 - 10
expect(lifelineLengths([0, 0, 0], [500, 500, 500])).toEqual([full, full, full])
// A participant destroyed on row 150 keeps its own short lifeline; the others are untouched.
expect(lifelineLengths([0, 0, 0], [500, 150, 500])).toEqual([full, 150 - 65 - 10, full])
// A participant created on row 150 starts late, and again the others are untouched.
expect(lifelineLengths([0, 150, 0], [500, 500, 500])).toEqual([full, 500 - 150 - 65 - 10, full])
// 300 - 65 falls short of MIN_VERTICAL_GAP, so this one is stretched. The mid-diagram boxes
// take the same share of that stretch as the row they sit on, which is the message at 182.5.
const destroyed = layoutFor([0, 0, 0], [300, 182.5, 300])
expect(destroyed.actorLayouts[1].bottomY).toBeCloseTo(destroyed.rowYs.get(0)!)
const created = layoutFor([0, 182.5, 0], [300, 300, 300])
expect(created.actorLayouts[1].y).toBeCloseTo(created.rowYs.get(0)!)
})
it("reads every row and frame from the installed mermaid's own rendering", async () => {
// The markup above is built by hand to match mermaid's. If a mermaid upgrade changes it, those
// tests stay green while rows stop being read and every diagram falls back to even spacing.
const svgPrototype = SVGElement.prototype as any
// jsdom lays out no text; mermaid only needs some size for it to render.
svgPrototype.getBBox = function () {
return { x: 0, y: 0, width: (this.textContent ?? '').length * 8, height: 16 }
}
svgPrototype.getComputedTextLength = function () {
return (this.textContent ?? '').length * 8
}
try {
const mermaid = (await import('mermaid')).default
mermaid.initialize({ startOnLoad: false })
const source = `sequenceDiagram
participant A
participant B
A->>B: Hello
loop Every minute
B->>B: Tick
alt Healthy
B-->>A: Fine
else Failing
Note over A,B: Retry<br/>with backoff
A->>B: Again
end
end
B-->>A: Done`
const { svg } = await mermaid.render('sequence-rows', source)
// eslint-disable-next-line @typescript-eslint/no-deprecated
const db = (await mermaid.mermaidAPI.getDiagramFromText(source)).db as SequenceDB
const messages = db.getMessages()
const { rowYs, fragmentFrames } = parseSequenceLayout(
svgFromString(svg),
db.getActorKeys().length,
countSequenceEvents(messages)
)
// Messages, the self-message and the note all get a row: a single missing one sends the
// whole diagram back to even spacing.
const rowTypes: number[] = [LINETYPE.SOLID, LINETYPE.DOTTED, LINETYPE.NOTE]
const eventIndices = messages.flatMap((m, i) => (rowTypes.includes(m.type!) ? [i] : []))
expect([...rowYs.keys()].sort((a, b) => a - b)).toEqual(eventIndices)
const ys = eventIndices.map((i) => rowYs.get(i)!)
expect(ys.every((y, i) => i === 0 || y > ys[i - 1])).toBe(true)
const loop = fragmentFrames.get(messages.findIndex((m) => m.type === LINETYPE.LOOP_END))!
const alt = fragmentFrames.get(messages.findIndex((m) => m.type === LINETYPE.ALT_END))!
expect(fragmentFrames.size).toBe(2)
expect(loop.sectionYs).toEqual([])
expect(alt.sectionYs).toHaveLength(1)
expect(alt.top).toBeGreaterThan(loop.top)
expect(alt.bottom).toBeLessThan(loop.bottom)
} finally {
delete svgPrototype.getBBox
delete svgPrototype.getComputedTextLength
}
})
})

View File

@@ -0,0 +1,392 @@
import { clamp } from 'tldraw'
import { getSelfLoopBend } from './utils'
export interface Vec2 {
x: number
y: number
}
export interface ParsedNode {
id: string
center: Vec2
width: number
height: number
}
export interface ParsedCluster {
id: string
topLeft: Vec2
width: number
height: number
}
export interface ParsedEdge {
/** Mermaid's `data-id` for the path, shared with the edge's label in the SVG. */
id: string
start: string
end: string
points: Vec2[]
}
export interface ParsedEdgeLabel {
x: number
y: number
w: number
h: number
}
/**
* Pre-parsed SVG layout for flowchart and state diagram converters.
* Contains already-scaled node, cluster, and edge data.
*/
export interface ParsedDiagramLayout {
nodes: Map<string, ParsedNode>
clusters: Map<string, ParsedCluster>
edges: ParsedEdge[]
/** Label boxes keyed by the {@link ParsedEdge.id} of the edge they belong to. */
edgeLabels: Map<string, ParsedEdgeLabel>
}
type NodeIdParser = (domId: string) => string
type EdgeIdParser = (dataId: string, points: Vec2[]) => { start: string; end: string } | null
// Mermaid >= 11.15 prefixes every rendered element id with the diagram id
// (`mermaid-0-flowchart-A-0` instead of `flowchart-A-0`); older versions do not.
const DIAGRAM_ID_PREFIX = /^mermaid-\d+-/
export function stripDiagramIdPrefix(domId: string): string {
return domId.replace(DIAGRAM_ID_PREFIX, '')
}
/** Extract the first capture group of `pattern` from a dom id, tolerating the diagram-id prefix. */
export function parseDomId(domId: string, pattern: RegExp): string {
const bareId = stripDiagramIdPrefix(domId)
return bareId.match(pattern)?.[1] ?? bareId
}
function parseTranslate(attr: string | null): Vec2 {
// Matches SVG translate transforms, e.g. transform="translate(123.45, 67.8)".
// Handles scientific notation (1.2e+3). Group 1 = x offset, group 2 = y offset.
const match = attr?.match(/translate\(\s*([\d.e+-]+)[,\s]+([\d.e+-]+)\s*\)/)
if (!match) return { x: 0, y: 0 }
return { x: parseFloat(match[1]), y: parseFloat(match[2]) }
}
export function getAccumulatedTranslate(el: Element): Vec2 {
let x = 0
let y = 0
for (let cur = el.parentElement; cur; cur = cur.parentElement) {
const t = parseTranslate(cur.getAttribute('transform'))
x += t.x
y += t.y
}
return { x, y }
}
function getBBoxSize(el: Element | null): { w: number; h: number } | undefined {
if (!el) return undefined
try {
const bbox = (el as SVGGraphicsElement).getBBox()
if (bbox.width > 0 && bbox.height > 0) return { w: bbox.width, h: bbox.height }
} catch {
// not a live SVG element (e.g. jsdom)
}
return undefined
}
/**
* Extract element dimensions from a live SVG element using getBBox(),
* falling back to attribute parsing for non-browser environments (jsdom).
*/
function getNodeDimensions(groupEl: Element): { w: number; h: number } {
const bbox = getBBoxSize(groupEl.querySelector('.label-container')) ?? getBBoxSize(groupEl)
if (bbox) return bbox
const rect = groupEl.querySelector('rect')
if (rect) {
const w = parseFloat(rect.getAttribute('width') || '0')
const h = parseFloat(rect.getAttribute('height') || '0')
if (w > 0 && h > 0) return { w, h }
}
const poly = groupEl.querySelector('polygon')
if (poly) {
const pts = (poly.getAttribute('points') || '')
.trim()
.split(/\s+/)
.map((pointStr) => pointStr.split(',').map(Number))
let minX = Infinity
let maxX = -Infinity
let minY = Infinity
let maxY = -Infinity
for (const [px, py] of pts) {
minX = Math.min(minX, px)
maxX = Math.max(maxX, px)
minY = Math.min(minY, py)
maxY = Math.max(maxY, py)
}
if (maxX > minX && maxY > minY) return { w: maxX - minX, h: maxY - minY }
}
const circle = groupEl.querySelector('circle')
if (circle) {
const r = parseFloat(circle.getAttribute('r') || '0')
if (r > 0) return { w: r * 2, h: r * 2 }
}
const ellipse = groupEl.querySelector('ellipse')
if (ellipse) {
const w = parseFloat(ellipse.getAttribute('rx') || '0') * 2
const h = parseFloat(ellipse.getAttribute('ry') || '0') * 2
if (w > 0 && h > 0) return { w, h }
}
return { w: 0, h: 0 }
}
export function parseNodesFromSvg(
root: Element,
selector: string,
idParser: NodeIdParser
): Map<string, ParsedNode> {
const out = new Map<string, ParsedNode>()
for (const groupEl of root.querySelectorAll(selector)) {
const id = idParser(groupEl.getAttribute('id') || '')
const self = parseTranslate(groupEl.getAttribute('transform'))
const ancestor = getAccumulatedTranslate(groupEl)
const { w, h } = getNodeDimensions(groupEl)
out.set(id, {
id,
center: { x: ancestor.x + self.x, y: ancestor.y + self.y },
width: w,
height: h,
})
}
return out
}
export function parseClustersFromSvg(
root: Element,
selector: string,
idParser: NodeIdParser
): Map<string, ParsedCluster> {
const out = new Map<string, ParsedCluster>()
for (const groupEl of root.querySelectorAll(selector)) {
const id = idParser(groupEl.getAttribute('id') || '')
const rect = groupEl.querySelector('rect')
if (!rect) continue
const rx = parseFloat(rect.getAttribute('x') || '0')
const ry = parseFloat(rect.getAttribute('y') || '0')
const w = parseFloat(rect.getAttribute('width') || '0')
const h = parseFloat(rect.getAttribute('height') || '0')
const self = parseTranslate(groupEl.getAttribute('transform'))
const ancestor = getAccumulatedTranslate(groupEl)
out.set(id, {
id,
topLeft: { x: ancestor.x + self.x + rx, y: ancestor.y + self.y + ry },
width: w,
height: h,
})
}
return out
}
/**
* Parse every SVG edge path in DOM order (matching mermaid's edge list order).
* Unlike the old per-pair map, this preserves all parallel edges individually.
*/
export function parseAllEdgePointsFromSvg(root: Element, parser: EdgeIdParser): ParsedEdge[] {
const out: ParsedEdge[] = []
for (const path of root.querySelectorAll('path[data-points]')) {
const dataId = path.getAttribute('data-id') || path.getAttribute('id') || ''
const dataPoints = path.getAttribute('data-points')
if (!dataPoints) continue
try {
const points = JSON.parse(atob(dataPoints))
const ancestor = getAccumulatedTranslate(path as Element)
for (const point of points) {
point.x += ancestor.x
point.y += ancestor.y
}
// Parsed after the points, which an id parser may need to place an ambiguous id.
const parsed = parser(dataId, points)
if (!parsed) continue
out.push({ id: dataId, start: parsed.start, end: parsed.end, points })
} catch {
/* ignore malformed data */
}
}
return out
}
/**
* Parse the box mermaid laid each edge label out in, keyed by the `data-id` it shares with its
* path. Edges without text still get an empty label group, which is skipped.
*/
export function parseEdgeLabelsFromSvg(root: Element): Map<string, ParsedEdgeLabel> {
const out = new Map<string, ParsedEdgeLabel>()
for (const label of root.querySelectorAll('.edgeLabel .label[data-id]')) {
const size = getBBoxSize(label) ?? getForeignObjectSize(label)
if (!size) continue
const ancestor = getAccumulatedTranslate(label)
const self = parseTranslate(label.getAttribute('transform'))
out.set(label.getAttribute('data-id')!, {
x: ancestor.x + self.x,
y: ancestor.y + self.y,
w: size.w,
h: size.h,
})
}
return out
}
function getForeignObjectSize(el: Element): { w: number; h: number } | undefined {
const foreignObject = el.querySelector('foreignObject')
const w = parseFloat(foreignObject?.getAttribute('width') || '0')
const h = parseFloat(foreignObject?.getAttribute('height') || '0')
return w > 0 && h > 0 ? { w, h } : undefined
}
/**
* Build a map of node/cluster id → center (for flowchart and state diagram edge matching).
*/
export function buildNodeCentersFromSvg(
nodes: Map<string, ParsedNode>,
clusters: Map<string, ParsedCluster>
): Map<string, Vec2> {
const out = new Map<string, Vec2>()
for (const [id, node] of nodes) {
out.set(id, { x: node.center.x, y: node.center.y })
}
for (const [id, cluster] of clusters) {
out.set(id, {
x: cluster.topLeft.x + cluster.width / 2,
y: cluster.topLeft.y + cluster.height / 2,
})
}
return out
}
/**
* Claim the path mermaid drew for an edge between two nodes, or undefined when nothing matches.
* Each path is claimed at most once.
*
* Mermaid names a flowchart's paths after the two nodes they join and emits them in the order it
* drew them, so the nth edge between a pair of nodes is the nth unclaimed path carrying their ids.
* Paths with no ids to go on, such as a state diagram's `edge<N>`, fall back to whichever ends lie
* nearest the two node centers. That fallback picks the wrong path when several ends sit equally
* close, which is what parallel edges do (#10794).
*/
export function claimEdge(
svgEdges: ParsedEdge[],
claimed: Set<number>,
edge: { startId: string; endId: string; startCenter?: Vec2; endCenter?: Vec2 }
): ParsedEdge | undefined {
const usable = (svgEdge: ParsedEdge, index: number) =>
!claimed.has(index) && svgEdge.points.length >= 2
if (edge.startId && edge.endId) {
const byId = svgEdges.findIndex(
(svgEdge, index) =>
usable(svgEdge, index) && svgEdge.start === edge.startId && svgEdge.end === edge.endId
)
if (byId >= 0) {
claimed.add(byId)
return svgEdges[byId]
}
}
const { startCenter, endCenter } = edge
if (!startCenter || !endCenter) return undefined
let bestIndex = -1
let bestDistance = Infinity
for (let i = 0; i < svgEdges.length; i++) {
if (!usable(svgEdges[i], i)) continue
const points = svgEdges[i].points
const last = points[points.length - 1]
const distance =
Math.hypot(points[0].x - startCenter.x, points[0].y - startCenter.y) +
Math.hypot(last.x - endCenter.x, last.y - endCenter.y)
if (distance < bestDistance) {
bestDistance = distance
bestIndex = i
}
}
if (bestIndex < 0) return undefined
claimed.add(bestIndex)
return svgEdges[bestIndex]
}
/** How far either side of its middle a top or bottom self-loop's ends sit, as a fraction of the edge. */
const SELF_LOOP_EDGE_SPREAD = 0.4
/**
* Where a self-loop leaves and re-enters its node, and how far out it reaches. Mermaid loops out of
* the side facing the next rank (the bottom of a top-down chart, the right of a left-to-right one)
* with the label just beyond; anywhere else, loop and label land on the node.
*/
export function getSelfLoopEdgeLayout(
svgEdge: ParsedEdge,
node: ParsedNode,
edgeLabels: Map<string, ParsedEdgeLabel>
) {
if (svgEdge.points.length < 2 || !(node.width > 0 && node.height > 0)) return undefined
const left = node.center.x - node.width / 2
const top = node.center.y - node.height / 2
const toAnchor = (point: Vec2) => ({
x: clamp((point.x - left) / node.width, 0, 1),
y: clamp((point.y - top) / node.height, 0, 1),
})
const start = toAnchor(svgEdge.points[0])
const end = toAnchor(svgEdge.points[svgEdge.points.length - 1])
// tldraw wraps an arrow's label to the arrow's width less 64px whenever it is wider than tall,
// as a loop on the top or bottom edge always is. Spread its ends across that edge to give the
// label room; the chord stays on the same line, so where the label lands is unchanged.
if (start.y === end.y && (start.y === 0 || start.y === 1)) {
const middle = (start.x + end.x) / 2
const halfSpan = Math.min(SELF_LOOP_EDGE_SPREAD, middle, 1 - middle)
const direction = Math.sign(end.x - start.x) || 1
start.x = middle - halfSpan * direction
end.x = middle + halfSpan * direction
}
const label = edgeLabels.get(svgEdge.id)
const labelCenter = label && { x: label.x + label.w / 2, y: label.y + label.h / 2 }
return {
bend: getSelfLoopBend(svgEdge, labelCenter),
anchorStartX: start.x,
anchorStartY: start.y,
anchorEndX: end.x,
anchorEndY: end.y,
}
}
// ---------------------------------------------------------------------------
// Layout scaling and bounds
// ---------------------------------------------------------------------------
export function scaleLayout(layout: ParsedDiagramLayout, scale: number): void {
const { nodes, clusters, edges, edgeLabels } = layout
for (const node of nodes.values()) {
node.center.x *= scale
node.center.y *= scale
node.width *= scale
node.height *= scale
}
for (const cluster of clusters.values()) {
cluster.topLeft.x *= scale
cluster.topLeft.y *= scale
cluster.width *= scale
cluster.height *= scale
}
for (const edge of edges) {
for (const point of edge.points) {
point.x *= scale
point.y *= scale
}
}
for (const label of edgeLabels.values()) {
label.x *= scale
label.y *= scale
label.w *= scale
label.h *= scale
}
}

View File

@@ -0,0 +1,103 @@
import type { MermaidBlueprintEdge, MermaidBlueprintNode } from './blueprint'
const BEND_SCALE = -1.8
const MAX_ARROW_BEND = 200
/**
* Extrapolate a bend value for tldraw arrows from Mermaid edge path waypoints.
* Uses perpendicular distance from chord to mid-points, scaled and clamped.
*/
/** Signed perpendicular distance from the chord between `points`' two ends to `point`. */
function getChordOffset(points: { x: number; y: number }[], point: { x: number; y: number }) {
const start = points[0]
const end = points[points.length - 1]
const dx = end.x - start.x
const dy = end.y - start.y
const chordLength = Math.sqrt(dx * dx + dy * dy)
if (chordLength === 0) return 0
return ((point.x - start.x) * dy - (point.y - start.y) * dx) / chordLength
}
/** Signed perpendicular distance from an edge's chord to its farthest routing point. */
function getEdgeSagitta(points: { x: number; y: number }[]) {
let maxDistance = 0
for (let i = 1; i < points.length - 1; i++) {
const distance = getChordOffset(points, points[i])
if (Math.abs(distance) > Math.abs(maxDistance)) {
maxDistance = distance
}
}
return maxDistance
}
export function getArrowBend(edgeData: { points: { x: number; y: number }[] }) {
if (edgeData.points.length < 2) return 0
const bend = getEdgeSagitta(edgeData.points) * BEND_SCALE
return Math.max(-MAX_ARROW_BEND, Math.min(MAX_ARROW_BEND, bend))
}
/**
* A self-loop's bend: out to the centre of mermaid's label, and never shallower than mermaid drew
* the loop. tldraw sets an arrow's label on the middle of its arc, so reaching the label's centre is
* what puts the text where mermaid did; a loop at mermaid's own depth centres it on the node's edge.
* The amplification in {@link getArrowBend} is left out, as it suits a line between two shapes.
*/
export function getSelfLoopBend(
edgeData: { points: { x: number; y: number }[] },
labelCenter?: { x: number; y: number }
) {
if (edgeData.points.length < 2) return 0
const depth = getEdgeSagitta(edgeData.points)
const toLabel = labelCenter ? getChordOffset(edgeData.points, labelCenter) : 0
// A label on the far side of the chord would pull the loop into the node.
const reach = Math.sign(toLabel) === Math.sign(depth) && Math.abs(toLabel) > Math.abs(depth)
return (reach ? toLabel : depth) * Math.sign(BEND_SCALE)
}
/** Normalize HTML line breaks to newlines, decode HTML entities, and trim. */
export function sanitizeDiagramText(text: string): string {
if (typeof text !== 'string') return ''
const doc = new DOMParser().parseFromString(text.replace(/<br\s*\/?>/gi, '\n'), 'text/html')
return (doc.body.textContent ?? '').trim()
}
/** Scale factor applied to parsed SVG layout (nodes, clusters, edges). */
export const LAYOUT_SCALE = 1.25
export function dropDanglingEdges(
nodes: MermaidBlueprintNode[],
edges: MermaidBlueprintEdge[]
): MermaidBlueprintEdge[] {
const nodeIds = new Set(nodes.map((n) => n.id))
return edges.filter((e) => nodeIds.has(e.startNodeId) && nodeIds.has(e.endNodeId))
}
/**
* Order items top-down by parent relationship so parents are visited before children.
* Works for subgraphs (FlowSubGraph[]) and compound state IDs (string[]).
*/
export function orderTopDown<T>(
items: T[],
getId: (item: T) => string,
getParentId: (item: T) => string | undefined
): T[] {
const byId = new Map(items.map((item) => [getId(item), item]))
const visited = new Set<string>()
const result: T[] = []
function visit(id: string) {
if (visited.has(id)) return
visited.add(id)
const item = byId.get(id)
if (item) result.push(item)
for (const child of items) {
if (getParentId(child) === id) visit(getId(child))
}
}
for (const item of items) {
const parentId = getParentId(item)
if (!parentId || !byId.has(parentId)) visit(getId(item))
}
return result
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
"references": [{ "path": "../tldraw" }, { "path": "../tlschema" }, { "path": "../utils" }]
}

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
fsModuleCache: true,
pool: 'threads',
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
})