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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 32 additions & 6 deletions docs/content/docs/features/blocks/diagrams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Mermaid diagram blocks for BlockNote — rendered as diagrams in th

# Diagrams

The `@blocknote/diagram-block` package adds a **diagram block**: authored as [Mermaid](https://mermaid.js.org/) source in a source popup, rendered as the diagram it describes — flowcharts, sequence diagrams, Gantt charts, and everything else Mermaid supports.
The `@blocknote/diagram-block` package adds a **diagram block**: authored as [Mermaid](https://mermaid.js.org/) source in a source popup and rendered by [Pele](https://pele.run/). Diagram renderers load on demand, supporting flowcharts, sequence diagrams, Gantt charts, and other [supported diagram types](https://pele.run/compatibility).

<Callout type="info">
This block is only available in React (`@blocknote/react`).
Expand Down Expand Up @@ -64,6 +64,31 @@ The example below wires them all up.

<Example name="custom-schema/diagram-block" />

## Renderer compatibility

Stored diagrams and Markdown fences still use Mermaid source, so existing documents need no migration. Pele has its own layout and visual style. Mermaid themes and `themeVariables` do not apply; use [Pele's CSS variables](https://pele.run/theming) to customize previews. Arbitrary HTML labels and external Mermaid plugins such as ZenUML are not supported.

The renderer switch removes the exported Mermaid-specific helpers `initializeMermaid`, `defaultMermaidOptions`, `trimDiagramSVG`, `setSVGFontFamily`, and `withSVGFontFamily`. If you used these helpers directly, replace rendering with `renderDiagram`, or use `renderDiagramToSVG` / `renderDiagramToImage` for export images. Pass the font family to the renderer instead of rewriting the resulting SVG. Global initialization and DOM-based trimming are no longer needed.

## Theming

Diagram previews automatically inherit your editor's theme, including live light/dark changes. No diagram-specific configuration is needed:

- **Mantine:** uses BlockNote's colors and corner radius, including values from a custom `theme` object.
- **Ariakit / React:** uses the shared `--bn-*` theme variables.
- **Shadcn:** uses the host's semantic color variables (`--background`, `--foreground`, `--muted`, `--border`, `--primary`, and `--destructive`), `--radius`, and `--chart-1` through `--chart-5`. These must contain complete CSS colors, as in Shadcn's current themes. Missing variables fall back to BlockNote's palette. Dark mode follows the host's token changes, typically through its `.dark` class.

Chart series use BlockNote's highlight colors, or Shadcn's chart tokens when available. To override individual diagram colors without changing the rest of the editor, set [Pele variables](https://pele.run/theming) on your editor container or a diagram:

```css
.bn-root {
--pele-accent: #6366f1;
--pele-radius: 8px;
}
```

Explicit colors authored in Mermaid `style` / `classDef` rules take precedence over theme defaults. Editor theme variables affect previews only; exported images use the standalone renderer's defaults. Use a custom `renderDiagram` export callback to supply a different export palette.

## Exporting

Diagrams export to every format BlockNote supports. [Markdown](/docs/features/export/markdown) works out of the box — diagrams export as ` ```mermaid ` fenced code blocks, their common Markdown notation.
Expand Down Expand Up @@ -95,9 +120,8 @@ The factory takes one option:
```typescript
createDiagramBlockMapping(options?: {
/**
* Renders the Mermaid source to an image. Defaults to the built-in
* Mermaid renderer, which only works in the browser - see "Exporting
* server-side" below.
* Renders the Mermaid source to an image. Defaults to Pele, producing
* PNG for DOCX, ODT, and email, or vector SVG for Typst / PDF.
*/
renderDiagram?: RenderDiagram;
});
Expand All @@ -109,7 +133,9 @@ The email subpath's factory additionally takes an `imageDelivery` option: some e

### Exporting server-side

Rendering Mermaid source to an image requires a browser, so the built-in renderer only works for client-side exports. When exporting server-side, pass a `renderDiagram` function to `createDiagramBlockMapping` — without one, a server-side export throws:
Typst / PDF exports work on the server out of the box: Pele produces vector SVG without a browser. Labels use the exporter's font family; make those fonts available to the PDF compiler. Exports resolve Pele's default colors so standalone SVG consumers do not need CSS variables; preview-only CSS overrides are not included.

DOCX, ODT, and email use PNG images. Their built-in rasterizer requires browser canvas. For server-side exports to those formats, pass a `renderDiagram` function to `createDiagramBlockMapping`; without one, the export throws:

```typescript
import { createDiagramBlockMapping } from "@blocknote/diagram-block/docx-exporter";
Expand All @@ -125,4 +151,4 @@ const renderDiagram: RenderDiagram = async (source) => {
createDiagramBlockMapping({ renderDiagram });
```

Common choices for the server-side renderer are [`@mermaid-js/mermaid-cli`](https://github.com/mermaid-js/mermaid-cli) (renders in a headless browser) or a [Kroki](https://kroki.io) server. Invalid Mermaid source is an expected failure — return it as `{ error }` rather than throwing, and the export renders the error placeholder for that block instead of failing.
For example, render SVG using Pele's `renderAsync` from `pele/lazy` and rasterize it with a server-side SVG-to-PNG tool. Invalid Mermaid source is an expected failure: return it as `{ error }` rather than throwing, and the export renders the error placeholder for that block instead of failing.
1 change: 0 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,6 @@
"lib0": "1.0.0-rc.36",
"lucide-react": "^0.562.0",
"mathjax-full": "^3.2.2",
"mermaid": "^11.0.0",
"motion": "^12.28.1",
"next": "^16.3.0",
"next-themes": "^0.4.6",
Expand Down
16 changes: 8 additions & 8 deletions packages/diagram-block/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,37 +76,37 @@
"dependencies": {
"@blocknote/core": "workspace:^",
"@blocknote/react": "workspace:^",
"mermaid": "^11.0.0"
"pele": "^1.5.2"
},
"devDependencies": {
"@blocknote/shared": "workspace:^",
"react-icons": "^5.5.0",
"@blocknote/xl-docx-exporter": "workspace:^",
"@blocknote/xl-email-exporter": "workspace:^",
"@blocknote/xl-odt-exporter": "workspace:^",
"@blocknote/xl-typst-compiler": "workspace:^",
"@blocknote/xl-typst-exporter": "workspace:^",
"@react-email/components": "^1.0.12",
"@types/react": "^19.2.3",
"@zip.js/zip.js": "^2.8.8",
"@types/react-dom": "^19.2.3",
"@zip.js/zip.js": "^2.8.8",
"docx": "^9.9.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-icons": "^5.5.0",
"rimraf": "^5.0.10",
"rollup-plugin-webpack-stats": "^0.2.6",
"typescript": "^7.0.2",
"vite-plus": "catalog:",
"@blocknote/xl-typst-exporter": "workspace:^",
"@blocknote/xl-typst-compiler": "workspace:^"
"vite-plus": "catalog:"
},
"peerDependencies": {
"@blocknote/xl-docx-exporter": "workspace:^",
"@blocknote/xl-email-exporter": "workspace:^",
"@blocknote/xl-odt-exporter": "workspace:^",
"@blocknote/xl-typst-exporter": "workspace:^",
"@react-email/components": "^1.0.12",
"docx": "^9.8.0",
"react": "^18.0 || ^19.0 || >= 19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc",
"@blocknote/xl-typst-exporter": "workspace:^"
"react-dom": "^18.0 || ^19.0 || >= 19.0.0-rc"
},
"peerDependenciesMeta": {
"@blocknote/xl-docx-exporter": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,22 +17,13 @@ import { createReactDiagramBlockSpec } from "./createReactDiagramBlockSpec.js";
* @vitest-environment jsdom
*/

// Mermaid needs a real browser to render, so the tests mock it - the specs
// under test cover the block's editing behavior, not Mermaid itself. Sources
// containing "INVALID" fail to parse, for testing the error states.
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
parse: vi.fn(async (source: string) => {
if (source.includes("INVALID")) {
throw new Error("mock parse error");
}
return true;
}),
render: vi.fn(async () => ({
svg: '<svg data-mermaid-mock="true"></svg>',
})),
},
// Cover popup behavior separately from the real Pele renderer's unit tests.
vi.mock("../helpers/renderDiagram.js", () => ({
renderDiagram: vi.fn(async (source: string) =>
source.includes("INVALID")
? { error: "mock parse error" }
: { svg: '<svg data-pele-mock="true"></svg>' },
),
}));

// The diagram block isn't a default block, so register it in a custom schema.
Expand Down Expand Up @@ -64,7 +55,7 @@ describe("Diagram block source popup", () => {
flushSync(() => {
root.render(<BlockNoteViewRaw editor={editor} />);
});
// Let the React node view render (and the mocked Mermaid "render") before
// Let the React node view render (and the mocked diagram renderer) before
// assertions read its DOM.
await flush();

Expand Down Expand Up @@ -117,7 +108,7 @@ describe("Diagram block source popup", () => {
it("renders the (mocked) diagram preview", () => {
expect(
previewRoot().querySelector(
'.bn-preview-container [data-mermaid-mock="true"]',
'.bn-preview-container [data-pele-mock="true"]',
),
).not.toBeNull();
});
Expand Down Expand Up @@ -192,7 +183,7 @@ describe("Diagram block source popup", () => {
/** The mocked rendered diagram, if currently shown as the preview. */
function renderedDiagram(): Element | null {
return previewRoot().querySelector(
'.bn-preview-container [data-mermaid-mock="true"]',
'.bn-preview-container [data-pele-mock="true"]',
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ export const createDiagramBlockConfig = createBlockConfig(
() =>
({
type: "diagram" as const,
// The block is semantically a diagram; Mermaid is its (only, for now)
// rendering engine. An `engine` prop with a "mermaid" default can be
// added later without breaking stored documents.
// Store Mermaid source, independently of the renderer (Pele).
propSchema: {},
content: "plain" as const,
}) as const,
Expand Down
104 changes: 104 additions & 0 deletions packages/diagram-block/src/block/diagramTheme.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, test } from "vite-plus/test";

import "../../../react/src/editor/styles.css";
import "../../../shadcn/src/style.css";
import { renderDiagram } from "../helpers/renderDiagram.js";

// Real SVG/computed CSS is needed: node tests cannot observe inherited theme
// variables or a live color-scheme change. No editor machinery is involved.
const roots: HTMLElement[] = [];

afterEach(() => {
for (const root of roots.splice(0)) {
root.remove();
}
});

async function diagram(
className = "",
source = "graph TD\n A[Start] --> B[End]",
) {
const result = await renderDiagram(source);
if (result.error !== undefined) {
throw new Error(result.error);
}
const root = document.createElement("div");
root.className = `bn-root ${className}`;
root.dataset.colorScheme = "light";
root.innerHTML = result.svg;
roots.push(root);
document.body.append(root);
const svg = root.querySelector("svg")!;
const label = svg.querySelector("text")!;
const node = svg.querySelector(".pele-node rect")!;
return { root, svg, label, node };
}

describe("diagram themes", () => {
test("follows live light/dark changes without replacing the SVG", async () => {
const { root, svg, label, node } = await diagram("bn-mantine");
expect(getComputedStyle(label).fill).toBe("rgb(63, 63, 63)");
expect(getComputedStyle(node).fill).toBe("rgb(239, 239, 239)");

root.dataset.colorScheme = "dark";
expect(getComputedStyle(label).fill).toBe("rgb(207, 207, 207)");
expect(getComputedStyle(node).fill).toBe("rgb(22, 22, 22)");
expect(root.querySelector("svg")).toBe(svg);
});

test("uses custom theme variables and keeps editors isolated", async () => {
const first = await diagram("bn-mantine");
const second = await diagram("bn-ak");
// Mantine's custom theme objects set these variables on the themed root;
// CSS themes can set the same variables without depending on Mantine.
first.root.style.setProperty("--bn-colors-editor-text", "#123456");
first.root.style.setProperty("--bn-colors-hovered-background", "#abcdef");
first.root.style.setProperty("--bn-colors-border", "#345678");
first.root.style.setProperty("--bn-border-radius", "12px");
expect(getComputedStyle(first.label).fill).toBe("rgb(18, 52, 86)");
expect(getComputedStyle(first.node).fill).toBe("rgb(171, 205, 239)");
expect(getComputedStyle(first.node).stroke).toBe("rgb(52, 86, 120)");
expect(
getComputedStyle(first.svg).getPropertyValue("--pele-radius").trim(),
).toBe("12px");
expect(getComputedStyle(second.label).fill).toBe("rgb(63, 63, 63)");
});

test("Shadcn uses its semantic tokens and updates with the host theme", async () => {
const { root, label, node } = await diagram("bn-shadcn");
root.style.setProperty("--foreground", "#112233");
root.style.setProperty("--muted", "#ddeeff");
root.style.setProperty("--border", "#445566");
expect(getComputedStyle(label).fill).toBe("rgb(17, 34, 51)");
expect(getComputedStyle(node).fill).toBe("rgb(221, 238, 255)");
expect(getComputedStyle(node).stroke).toBe("rgb(68, 85, 102)");

root.style.setProperty("--foreground", "#ddeeff");
root.style.setProperty("--muted", "#112233");
expect(getComputedStyle(label).fill).toBe("rgb(221, 238, 255)");
expect(getComputedStyle(node).fill).toBe("rgb(17, 34, 51)");
});

test("chart colors follow BlockNote highlights and Shadcn chart tokens", async () => {
const source = 'pie\n "One" : 3\n "Two" : 2';
const first = await diagram("bn-mantine", source);
const second = await diagram("bn-shadcn", source);
first.root.style.setProperty("--bn-colors-highlights-blue-text", "#123456");
second.root.style.setProperty("--chart-1", "#654321");
expect(getComputedStyle(first.svg.querySelector(".pele-slice")!).fill).toBe(
"rgb(18, 52, 86)",
);
expect(
getComputedStyle(second.svg.querySelector(".pele-slice")!).fill,
).toBe("rgb(101, 67, 33)");
});

test("explicit Pele overrides still win and unconfigured Shadcn falls back", async () => {
const { root, label, node } = await diagram("bn-shadcn");
expect(getComputedStyle(label).fill).toBe("rgb(63, 63, 63)");
root.style.setProperty("--pele-fg", "#654321");
root.style.setProperty("--pele-surface", "#abcdef");
expect(getComputedStyle(label).fill).toBe("rgb(101, 67, 33)");
expect(getComputedStyle(node).fill).toBe("rgb(171, 205, 239)");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { Component, ReactNode } from "react";
import { createRoot, Root } from "react-dom/client";
import { afterEach, beforeEach, expect, test, vi } from "vite-plus/test";

import { renderDiagram } from "../../../helpers/renderDiagram.js";
import { useDiagramSVG } from "./DiagramBlockPreviewWithPopup.js";

vi.mock("../../../helpers/renderDiagram.js", () => ({
renderDiagram: vi.fn(),
}));

type BoundaryState = { status: "healthy" } | { status: "failed" };

class Boundary extends Component<
{ children: ReactNode; onError: (error: unknown) => void },
BoundaryState
> {
state: BoundaryState = { status: "healthy" };

static getDerivedStateFromError(): BoundaryState {
return { status: "failed" };
}

componentDidCatch(error: Error) {
this.props.onError(error);
}

render() {
return this.state.status === "failed" ? (
<span>Unexpected rendering failure</span>
) : (
this.props.children
);
}
}

function Preview({ source }: { source: string }) {
const { svg, error } = useDiagramSVG(source);
return <span>{error ?? svg}</span>;
}

let host: HTMLDivElement;
let root: Root;

beforeEach(() => {
host = document.createElement("div");
document.body.append(host);
// These tests deliberately exercise the boundary, not console reporting.
root = createRoot(host, { onCaughtError() {} });
});

afterEach(() => {
root.unmount();
host.remove();
vi.resetAllMocks();
});

test("unexpected renderer rejections reach the boundary without leaking messages", async () => {
const failure = new Error("private infrastructure details");
vi.mocked(renderDiagram).mockRejectedValue(failure);
const onError = vi.fn();

root.render(
<Boundary onError={onError}>
<Preview source="graph TD; A --> B" />
</Boundary>,
);

await vi.waitFor(() => {
expect(onError).toHaveBeenCalledWith(failure);
expect(host.textContent).toBe("Unexpected rendering failure");
});
expect(host.textContent).not.toContain(failure.message);
});

test("expected source errors remain inline instead of reaching the boundary", async () => {
vi.mocked(renderDiagram).mockResolvedValue({
error: "Invalid diagram source",
});
const onError = vi.fn();

root.render(
<Boundary onError={onError}>
<Preview source="invalid source" />
</Boundary>,
);

await vi.waitFor(() => {
expect(host.textContent).toBe("Invalid diagram source");
});
expect(onError).not.toHaveBeenCalled();
});
Loading
Loading