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
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:
1
packages/mermaid/LICENSE.md
Normal file
1
packages/mermaid/LICENSE.md
Normal 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
214
packages/mermaid/README.md
Normal 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).
|
||||
4
packages/mermaid/api-extractor.json
Normal file
4
packages/mermaid/api-extractor.json
Normal 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"
|
||||
}
|
||||
225
packages/mermaid/api-report.api.md
Normal file
225
packages/mermaid/api-report.api.md
Normal 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)
|
||||
|
||||
```
|
||||
67
packages/mermaid/package.json
Normal file
67
packages/mermaid/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
132
packages/mermaid/src/blueprint.ts
Normal file
132
packages/mermaid/src/blueprint.ts
Normal 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
|
||||
}
|
||||
392
packages/mermaid/src/colors.ts
Normal file
392
packages/mermaid/src/colors.ts
Normal 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',
|
||||
}
|
||||
73
packages/mermaid/src/createMermaidDiagram.test.ts
Normal file
73
packages/mermaid/src/createMermaidDiagram.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
184
packages/mermaid/src/createMermaidDiagram.ts
Normal file
184
packages/mermaid/src/createMermaidDiagram.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
109
packages/mermaid/src/defaultMermaidNodeRenderSpec.ts
Normal file
109
packages/mermaid/src/defaultMermaidNodeRenderSpec.ts
Normal 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)
|
||||
)
|
||||
}
|
||||
217
packages/mermaid/src/flowchartDiagram.ts
Normal file
217
packages/mermaid/src/flowchartDiagram.ts
Normal 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) }
|
||||
}
|
||||
30
packages/mermaid/src/index.ts
Normal file
30
packages/mermaid/src/index.ts
Normal 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
|
||||
)
|
||||
1896
packages/mermaid/src/mermaidDiagrams.test.ts
Normal file
1896
packages/mermaid/src/mermaidDiagrams.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
79
packages/mermaid/src/mermaidNodeCreateShape.test.ts
Normal file
79
packages/mermaid/src/mermaidNodeCreateShape.test.ts
Normal 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',
|
||||
}),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
96
packages/mermaid/src/mermaidNodeCreateShape.ts
Normal file
96
packages/mermaid/src/mermaidNodeCreateShape.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
112
packages/mermaid/src/mindmapDiagram.ts
Normal file
112
packages/mermaid/src/mindmapDiagram.ts
Normal 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) }
|
||||
}
|
||||
101
packages/mermaid/src/renderBlueprint.test.ts
Normal file
101
packages/mermaid/src/renderBlueprint.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
322
packages/mermaid/src/renderBlueprint.ts
Normal file
322
packages/mermaid/src/renderBlueprint.ts
Normal 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 }
|
||||
}
|
||||
1129
packages/mermaid/src/sequenceDiagram.ts
Normal file
1129
packages/mermaid/src/sequenceDiagram.ts
Normal file
File diff suppressed because it is too large
Load Diff
391
packages/mermaid/src/stateDiagram.ts
Normal file
391
packages/mermaid/src/stateDiagram.ts
Normal 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) }
|
||||
}
|
||||
348
packages/mermaid/src/svgParsing.test.ts
Normal file
348
packages/mermaid/src/svgParsing.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
392
packages/mermaid/src/svgParsing.ts
Normal file
392
packages/mermaid/src/svgParsing.ts
Normal 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
|
||||
}
|
||||
}
|
||||
103
packages/mermaid/src/utils.ts
Normal file
103
packages/mermaid/src/utils.ts
Normal 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
|
||||
}
|
||||
7
packages/mermaid/tsconfig.json
Normal file
7
packages/mermaid/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
11
packages/mermaid/vitest.config.ts
Normal file
11
packages/mermaid/vitest.config.ts
Normal 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}'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user