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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 65 additions & 8 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,60 @@ 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`, `--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.

Diagrams reuse BlockNote's existing palette rather than introducing a separate diagram theme. Node fills match the editor background (white in the default light theme, dark gray in the default dark theme), with a secondary background for subgraphs:

| Diagram colors | BlockNote defaults |
| --- | --- |
| Background, text, and connecting lines | Editor background and text |
| Secondary text | Side-menu color |
| Node fills | Editor background |
| Subgraph fills | Hovered background |
| Outlines | Editor text (dark in light mode, light in dark mode) |
| Accent and critical/error colors | Blue and red text highlights |
| Eight chart series | Blue, orange, green, red, purple, pink, yellow, and brown text highlights |

Outlines use the text color rather than the editor's subtle UI border color so shapes remain distinguishable from their background. Shadcn replaces these with its semantic tokens where available; its five chart tokens replace the first five series colors, while series six through eight retain the shared defaults.

You can customize every Pele color without changing BlockNote's editor palette or adding new BlockNote theme properties. Set `--pele-*` variables on `.bn-root` to include portalled previews, or on an individual diagram's `.pele` SVG. For example:

```css
.bn-root {
--pele-accent: #6366f1;
--pele-surface: #eef2ff;
--pele-border: #6366f1;
--pele-series-1: #4338ca;
--pele-series-2: #b45309;
--pele-series-3: #047857;
--pele-radius: 8px;
}

.bn-root[data-color-scheme="dark"] {
--pele-accent: #a5b4fc;
--pele-surface: #312e81;
--pele-border: #a5b4fc;
}
```

Use the selector that controls your application's dark theme (for example, `.dark .bn-root` with Shadcn). UI borders and highlight colors are not necessarily a high-contrast diagram or categorical chart palette: check outlines, labels, and series in both themes. Pele repeats series colors after the eighth category, and some shapes use `--pele-bg` for text on a series fill, so choose contrasting colors.

See [Pele's theming reference](https://pele.run/theming) for all variables, including `--pele-series-1` through `--pele-series-8`, contrasting text on source-defined fills (`--pele-fg-on-light` / `--pele-fg-on-dark`), and element-level styling. Font overrides must match the font used to measure labels; changing only `--pele-font` can make labels overflow their nodes.

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 +149,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 ODT and email, or vector SVG for DOCX and Typst / PDF.
*/
renderDiagram?: RenderDiagram;
});
Expand All @@ -109,11 +162,15 @@ 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:
DOCX and Typst / PDF exports work on the server out of the box: Pele produces vector SVG without a browser. Typst / PDF 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 diagrams require a reader that supports embedded SVG. Readers without SVG support display a blank placeholder; a rasterized compatibility copy is not generated. To support those readers, supply a custom `renderDiagram` callback that returns PNG or another supported raster format.

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";
import type { RenderDiagram } from "@blocknote/diagram-block/docx-exporter";
import { createDiagramBlockMapping } from "@blocknote/diagram-block/odt-exporter";
import type { RenderDiagram } from "@blocknote/diagram-block/odt-exporter";

const renderDiagram: RenderDiagram = async (source) => {
// Render the Mermaid source to an image with your renderer of choice.
Expand All @@ -125,4 +182,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 @@ -92,37 +92,37 @@
"dependencies": {
"@blocknote/core": "workspace:^",
"@blocknote/react": "workspace:^",
"mermaid": "^11.0.0"
"pele": "^1.12.0"
},
"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
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Component, ReactNode } from "react";
import { BlockNoteEditor } from "@blocknote/core";
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, editor);
return <span>{error ?? svg}</span>;
}

let host: HTMLDivElement;
let root: Root;
let editor: BlockNoteEditor;

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

afterEach(() => {
root.unmount();
editor.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