Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9c28482
feat: Typst-powered accessible PDF export (PDF/UA-1) (#2992)
YousefED Aug 31, 2026
aad80d6
feat(pdf): own Typst wasm compiler (@blocknote/xl-typst-compiler), re…
YousefED Aug 31, 2026
225a9f1
fix(exporters): editor-parity rendering fixes + shared-document groun…
YousefED Aug 31, 2026
f0d80ae
refactor(exporters)!: drop the react-pdf math/diagram mappings (#3022)
YousefED Aug 31, 2026
ab4015c
fix(core): stop schema.extend() mutating the shared default specs (#3…
YousefED Sep 1, 2026
63c2389
fix(mantine): restore compact AI menu items (#3027)
YousefED Sep 1, 2026
f69f5c9
Always pass `editorPortalElement` to `portalRoot`
matthewlipski Sep 4, 2026
85b9029
refactor(react): one vocabulary and simpler plumbing for portal elements
YousefED Sep 4, 2026
473642d
refactor(ui): keep the portalElement prop's plain name in the UI libr…
YousefED Sep 4, 2026
9e7dfa5
refactor(core)!: drop the portal option from editor.mount
YousefED Sep 5, 2026
bbb5ddf
small fixes
YousefED Sep 5, 2026
ceb93bd
refactor: require portalRoot and never fall back to the body
YousefED Sep 5, 2026
3eb01f7
Merge branch 'portal-root-always-pass' into portals-cleanup-v2
YousefED Sep 5, 2026
c63532a
fix: pass the required portal element in the SettingsSelect examples
YousefED Sep 5, 2026
ea7aef9
Merge branch 'portals-context' into portals-cleanup-v2
YousefED Sep 5, 2026
17eca72
test(mantine): pin the render profile of BlockNoteView around portals
YousefED Sep 5, 2026
e3dde99
Merge branch 'main' into mobile-toolbar-demo
YousefED Sep 5, 2026
355ddc4
Merge branch 'mobile-toolbar-demo' into portals-cleanup-v2
YousefED Sep 5, 2026
7c50b00
fix(mantine,ariakit): style portalled menus by their own class
YousefED Sep 5, 2026
4150a35
feat(react): render a floating component's menus inside its wrapper
YousefED Sep 5, 2026
4dbe297
fix(mantine): drop the toolbar focus trap
YousefED Sep 5, 2026
4d3a67c
test(mantine): let commits settle before measuring the render profile
YousefED Sep 5, 2026
9cfade6
fix(react): portal floating UI next to the editor element by default
YousefED Sep 5, 2026
ef72729
docs(react): drop the null portal target from the doc comments
YousefED Sep 6, 2026
3351b2e
chore(docs): allow only the private 172.16/12 range for dev origins
YousefED Sep 6, 2026
27f48e8
Merge branch 'mobile-toolbar-demo' into portals-cleanup-v2
YousefED Sep 6, 2026
05d2e3b
feat(react): warn about the viewport meta tag from the view, in devel…
YousefED Sep 6, 2026
aaef894
docs(examples): show the mobile toolbar example in the playground
YousefED Sep 6, 2026
bd0537a
fix(react): a nested BlockNoteView resolves its own portal element
YousefED Sep 6, 2026
36e4a47
docs(react): state the preventFocusOnOpen contract
YousefED Sep 6, 2026
d94a980
test: link toolbar create and edit flows per skin
YousefED Sep 6, 2026
261d5f6
Merge branch 'mobile-toolbar-demo' into portals-cleanup-v2
YousefED Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 8 additions & 0 deletions .claude/skills/docs-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ The documentation site lives in `docs/content/docs` (fumadocs + Next.js). These
- **Live examples cap the setup they demonstrate.** Place an `<Example>` embed after the prose has introduced everything the example's code uses — an embed whose `App.tsx` shows APIs the page never mentions teaches by confusion. Introduced means named, given a purpose, and linked — the example itself (and the linked component pages) can carry the full wiring; expanding every integration inline pushes the example too far down the page. Not at the top (a demo without context motivates but doesn't teach) and not at the bottom (readers rarely reach it); at the end of the "getting it working" narrative, before advanced/optional topics. If an example uses more API than the page should cover, simplify the example rather than the rule.

- **Signature snippets tell the truth for one API.** A snippet formatted as a type signature documents exactly that export — never fold variant differences ("only for the X subpath…") into a doc-comment inside it. Show the signature that is true everywhere, and describe variant-specific options as prose in the variant's own section.
- **Docs follow the dependency direction.** When a package builds on another, the base layer's page owns the shared mechanisms (mappings, formats, behaviors) and never points "up" to a consumer page for its own concepts; consumer pages link down. Consumer pages still document their own API surfaces (option lists, signatures) explicitly rather than delegating them, per the signature rule below. Name sections for every layer they serve ("Typst / PDF", not just "PDF") so the base layer isn't erased.
- **Confine a cross-cutting concept to one designated section per page.** A page's headline concept (a conformance standard, an offline guarantee) gets one owning section; the intro may link to it once, and other sections mention it only where the reader must act on it there. Sprinkled re-mentions read as emphasis when written but age as duplication.
- **The hero snippet is the happy path only.** The first usage snippet shows the shortest end-to-end flow and nothing else; auxiliary mechanisms (asset maps, secondary outputs, tuning knobs) move to their own short sections even when genuinely relevant. Same for prose caveats aimed at a niche audience (a live-preview memory note): put them where that audience looks, not in the getting-started flow.
- **Caveats must be actionable.** Only note a limitation if the reader can do something with it (install a package, avoid a pattern, pass an option). Speculative hedges ("rare X may behave differently") and defensive implementation details (what a function guards against internally) erode trust without helping anyone act — cut them.

## Prose style

- Avoid em-dash-heavy prose; prefer commas, colons, semicolons, periods, or parentheses, choosing per sentence rather than substituting mechanically. An em-dash is fine occasionally; several per section reads as filler.
- Before listing something as a _requirement_, verify the reader can actually fail it. A "requirement" the implementation always satisfies automatically (e.g. auto-derived alt text) is at most a quality tip, phrased as one.

## Verifying content

- **Verify snippets against the actual package exports, not memory or existing docs.** APIs drift; grep the package source for every symbol a snippet imports (`export function X` / `export const X`) and check option names and shapes. Content copied forward without this check stays wrong after refactors.
Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/testing-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ End-to-end tests run in vitest browser mode (chromium, firefox and webkit) insid
bash tests/docker-run.sh -e CI=1 -- --run [filters]
```

A specific test file may be targeted by appending (part of) its name as a filter. A single browser may be targeted with `--project "e2e (chromium)"`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass).
**Never run the browser suite natively** (`vp test -c vite.config.browser.ts` outside the container) — not even "just one file". Two failure modes, both silent: the screenshot matcher _seeds_ a new `-darwin`/`-win32` baseline for every screenshot test that has none (passing without comparing anything, and littering the tree with hundreds of unvetted PNGs — these are gitignored as a backstop, and only `-linux` baselines are tracked), and several suites genuinely behave differently outside Linux (e.g. caret placement is font-metric-dependent, so platform-shared JSON snapshots mismatch). If Docker isn't running, notify the user to launch it instead of falling back to a native run.

Screenshot baselines can be regenerated with the `-u` argument, which must come **after** the filters (`--run <filters> -u`): written as `--run -u <filter>`, the filter is parsed as the flag's value and the **whole** suite runs in update mode, silently rewriting unrelated baselines. Note that `-u` only rewrites baselines whose comparison **fails** — a small intended change (e.g. a short text edit) that fits inside the suite's 2% pixel tolerance leaves the baseline stale while the test passes. To force a fresh capture, delete the baseline file first. Baselines are per-browser (`<name>-<browser>-linux.png`); after regenerating, always inspect the images before committing them.
A specific test file may be targeted by appending (part of) its name as a filter. A single browser may be targeted with `--project "e2e (chromium)"`. Individual tests in a file may be disabled using `skip`, i.e. `test.skip("Test name", ...)` (remember to revert this once all tests pass).

If Docker isn't running, notify the user to launch it.
Screenshot baselines can be regenerated with `--update=true` (as `e2e:updateSnaps` does). Always attach the value: vitest declares the flag as `-u, --update [type]` (it also accepts `new`/`all`/`none`), so a bare `-u` before a filter swallows the filter as its value and silently runs the **whole** suite in update mode. `--update=true` cannot, so filters stay filters in any position. Note that `-u` only rewrites baselines whose comparison **fails** — a small intended change (e.g. a short text edit) that fits inside the suite's 2% pixel tolerance leaves the baseline stale while the test passes. To force a fresh capture, delete the baseline file first. Baselines are per-browser (`<name>-<browser>-linux.png`); after regenerating, always inspect the images before committing them.

When testing a visual change, prefer writing screenshots to verify that the change is working as expected.

Expand Down
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,9 @@
.git
**/test-results
**/blob-report
# The Typst compiler's Rust build tree (GBs) and wasm output: never image
# content - docker-run.sh bind-mounts pkg/ at run time.
**/rust/target
packages/xl-typst-compiler/pkg
**/playwright-report
tests/.vitest-attachments
71 changes: 69 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ jobs:
- name: Install Dependencies
run: vp install

# `vp run -r build` includes @blocknote/xl-typst-compiler, whose build
# task compiles its Rust wasm when missing or stale (rustup is
# preinstalled on the runners; the pinned toolchain + wasm32 target
# auto-provision from rust/rust-toolchain.toml).
- name: Build packages
run: vp run -r build

Expand All @@ -52,18 +56,72 @@ jobs:

- name: Soft release
id: soft-release
run: vp dlx pkg-pr-new publish './packages/*' # TODO disabled only for AI branch--compact
# xl-typst-compiler is excluded: its wasm makes the package ~25MB,
# which pkg.pr.new rejects (413; multipart uploads are whitelisted -
# https://github.com/stackblitz-labs/pkg.pr.new/blob/main/.whitelist).
# Preview installs of the PDF exporter therefore lack the compiler
# until the repo is whitelisted or the wasm ships separately.
# TODO disabled only for AI branch--compact
run: |
packages=()
for dir in ./packages/*/; do
if [[ "$dir" != *"/xl-typst-compiler/"* ]]; then
packages+=("${dir%/}")
fi
done
vp dlx pkg-pr-new publish "${packages[@]}"

build-typst-compiler:
# The one package the e2e suite consumes through build outputs instead of
# src/ aliases: @blocknote/xl-typst-compiler (its /pkg and /wasm subpaths
# are wasm build artifacts - see tests/vite.config.browser.ts). Built once
# on a bare runner - the Playwright container has no C toolchain for the
# Rust build - and shared with the shards as an artifact.
name: Build Typst compiler
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 100
persist-credentials: false

- uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0
with:
node-version-file: ".node-version"
cache: true

- name: Install dependencies
run: vp install

# Compiles the Rust wasm when missing/stale (rustup is bootstrapped by
# the build task itself) plus the TS wrapper's dist/types.
- name: Build Typst compiler package
run: vp run --filter @blocknote/xl-typst-compiler build

- name: Upload compiler build outputs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: typst-compiler-build
path: |
packages/xl-typst-compiler/pkg
packages/xl-typst-compiler/dist
packages/xl-typst-compiler/types
include-hidden-files: true

e2e:
# Vitest Browser Mode runs in the Playwright Linux container — the same
# environment as the local Docker run — so behaviour matches local dev.
# The suite resolves every `@blocknote/*` import to its `src/` via the
# aliases in `tests/vite.config.browser.ts` (vite transpiles the package
# sources on the fly), so the packages do NOT need to be built to `dist`
# first — `vp install` + the checked-out sources are enough.
# first — `vp install` + the checked-out sources are enough (the one
# exception, xl-typst-compiler, arrives prebuilt from the
# build-typst-compiler job).
name: "E2E - ${{ matrix.browser }} (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})"
runs-on: ubuntu-latest
timeout-minutes: 30
needs: build-typst-compiler
container:
image: mcr.microsoft.com/playwright:v1.60.0-noble
strategy:
Expand All @@ -86,6 +144,12 @@ jobs:
- name: Install dependencies
run: vp install

- name: Download Typst compiler build outputs
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: typst-compiler-build
path: packages/xl-typst-compiler

# No preview server: Vitest Browser Mode serves the tests + mounted example
# apps itself. `--browser` selects this matrix job's browser and
# `--shard=<index>/<total>` splits that browser's test files across two
Expand All @@ -110,6 +174,9 @@ jobs:
with:
name: e2e-attachments-${{ matrix.browser }}-${{ matrix.shardIndex }}
path: tests/.vitest-attachments/
# The attachments dir is a dot-directory; without this the action
# silently uploads nothing ("No files were found").
include-hidden-files: true
retention-days: 7

merge-reports:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ jobs:
- name: Prebuild
run: cp README.md packages/core/README.md && cp README.md packages/react/README.md

# `vp run -r build` includes @blocknote/xl-typst-compiler, whose build
# task compiles its Rust wasm (published inside the package as pkg/);
# rustup auto-provisions the pinned toolchain from
# rust/rust-toolchain.toml.
- name: Build packages
run: vp run -r build

Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ All commands below are listed under `package.json` in the project root. See `vit
- `vp run preview`: Previews the build on port 3000.
- `vp run test`: Runs unit tests. Append with `-u` to update snapshots. Append with a file name to target only that file.
- To run individual unit tests, use `vp run test <file>`. For example, `vp run test packages/core/src/extensions/Versioning/inMemoryVersioning.test.ts`.
- `vp run e2e`: Runs end-to-end tests. Append with a file name to target only that file.
- `vp run e2e`: Runs end-to-end tests (always in Docker - NEVER run the browser suite natively; it seeds bogus per-platform snapshots). Append with a file name to target only that file.
- `vp run e2e:updateSnaps`: Runs end-to-end tests & updates snapshots. Append with a file name to target only that file.
- `vp help`: Prints a list of all available commands.

Expand All @@ -40,3 +40,4 @@ When writing a new feature, bug fix, or other modification, it may not be immedi
# Additional Notes

- Do not create git commits, unless asked for directly, and do not add Co-Authored-By lines to commits.
- **The exporters mirror the editor's look, and that parity is guarded by review, not types.** The exporter packages (`xl-typst-exporter`/`xl-pdf-exporter`, `xl-docx-exporter`, `xl-odt-exporter`, `xl-email-exporter`) hardcode editor-derived styling constants (heading scale, spacing, list markers, code-block chrome, ...), each annotated with the `packages/core/src/editor/Block.css` rule it mirrors — keep those comments when touching either side. When changing visual rules in `Block.css` (or adding a block type), regenerate the exporter visual baselines and review them against the editor ground truth: the static-equality baseline (`tests/src/end-to-end/static`) renders the _same shared test document_ as the typst PDF baselines (`tests/src/end-to-end/exporters`), so fidelity drift shows up as a side-by-side diff in the same PR.
4 changes: 4 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ next-env.d.ts
/content/examples/*/*
/components/example/generated/
sqlite.db

# Auto-generated by Next.js postinstall (agent rules); not repo content.
/AGENTS.md
/CLAUDE.md
28 changes: 23 additions & 5 deletions docs/app/demo/_components/DemoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ import {
} from "@blocknote/xl-odt-exporter";
import {
PDFExporter,
pdfDefaultSchemaMappings,
typstDefaultSchemaMappings,
} from "@blocknote/xl-pdf-exporter";
import { pdf } from "@react-pdf/renderer";
// Bundle the Typst compiler wasm (resolved to a local asset in
// next.config.ts) instead of loading it from a CDN.
import compilerWasmUrl from "@blocknote/xl-typst-compiler/wasm?url";
import { DefaultChatTransport } from "ai";
import { useTheme } from "next-themes";
import { useEffect, useMemo, useState } from "react";
Expand Down Expand Up @@ -345,9 +347,25 @@ function DemoEditorInner({
let filename = `blocknote-export.${format}`;

if (format === "pdf") {
const exporter = new PDFExporter(editor.schema, pdfDefaultSchemaMappings);
const pdfDocs = await exporter.toReactPDFDocument(editor.document);
blob = await pdf(pdfDocs).toBlob();
// Tagged PDF/UA-1 via the Typst-based exporter; the default fonts
// (matching the editor) load lazily from the package.
const exporter = new PDFExporter(
editor.schema,
typstDefaultSchemaMappings,
{ wasm: compilerWasmUrl },
);
const result = await exporter.toPDF(editor.document, {
title: "BlockNote demo document",
lang: "en",
});
if (result.error) {
throw new Error(
`PDF export failed: ${result.compileErrors
.map((d) => d.message)
.join("; ")}`,
);
}
blob = result.blob;
} else if (format === "docx") {
const exporter = new DOCXExporter(
editor.schema,
Expand Down
11 changes: 11 additions & 0 deletions docs/components/typstCompilerWasmUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// The Typst compiler wasm for the docs build (see next.config.ts): the
// pdf-ua example bundles it via Vite's `?url`; under the docs site the
// import is aliased here. `new URL(..., import.meta.url)` is Turbopack's
// static-asset reference: the ~25MB wasm is emitted as a hashed static
// file and served by the site itself - no CDN involved, and the version
// can't skew from the installed package because it IS the installed file.
const url = new URL(
"../node_modules/@blocknote/xl-typst-compiler/pkg/blocknote_typst_wasm_bg.wasm",
import.meta.url,
).href;
export default url;
5 changes: 3 additions & 2 deletions docs/content/docs/features/blocks/diagrams.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,16 @@ The example below wires them all up.

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.

The [PDF](/docs/features/export/pdf), [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters embed the diagram as an image via their mappings — they live as subpaths of this package, and each exports a `createDiagramBlockMapping` factory to spread into the exporter's default mappings. The [DOCX exporter](/docs/features/export/docx) shown here; the [PDF](/docs/features/export/pdf), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters work the same way with their respective subpaths:
The [PDF](/docs/features/export/pdf) (and [Typst](/docs/features/export/typst)), [DOCX](/docs/features/export/docx), [ODT](/docs/features/export/odt), and [email](/docs/features/export/email) exporters embed the diagram as an image via their mappings — they live as subpaths of this package, and each exports a `createDiagramBlockMapping` factory to spread into the exporter's default mappings. The [DOCX exporter](/docs/features/export/docx) shown here; the others work the same way with their respective subpaths:

```typescript
import {
DOCXExporter,
docxDefaultSchemaMappings,
} from "@blocknote/xl-docx-exporter";
import { createDiagramBlockMapping } from "@blocknote/diagram-block/docx-exporter";
// ...or "@blocknote/diagram-block/pdf-exporter",
// ...or "@blocknote/diagram-block/typst-exporter" (PDF + Typst - embeds
// vector SVG with labels as real text),
// "@blocknote/diagram-block/odt-exporter",
// "@blocknote/diagram-block/email-exporter"

Expand Down
36 changes: 14 additions & 22 deletions docs/content/docs/features/blocks/math.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -119,36 +119,28 @@ import {
// Spread into the ODTExporter's mappings exactly as for DOCX above.
```

### PDF
### Typst / PDF

With the [PDF exporter](/docs/features/export/pdf), math blocks export as vector formulas — no rasterization, so they also work server-side out of the box. Inline math is rasterized to images that flow with the text:
With the [Typst exporter](/docs/features/export/typst) and the
[PDF exporter](/docs/features/export/pdf) built on it (they share mappings),
math blocks and inline math export as **native Typst equations**: real,
selectable text, no rasterization, so they also work server-side out of the
box:

```typescript
import {
createInlineMathMapping,
inlineMathMapping,
mathBlockMapping,
} from "@blocknote/math-block/pdf-exporter";

// Spread into the PDFExporter's mappings as for DOCX above - note that
// inline math is a factory here: `math: createInlineMathMapping()`.
```

The inline math factory takes one option:
} from "@blocknote/math-block/typst-exporter";

```typescript
createInlineMathMapping(options?: {
/**
* Rasterizes the formula SVG to an image. Defaults to the built-in
* canvas rasterizer, which only works in the browser - when exporting
* server-side, pass one backed by e.g. `@resvg/resvg-js` or `sharp`;
* without it, a server-side export throws. The `RasterizeSVG` type is
* exported from the same subpath.
*/
rasterize?: RasterizeSVG;
});
// Spread into the PDFExporter's mappings as for DOCX above:
// `mathBlock: mathBlockMapping` and `math: inlineMathMapping`.
```

Math blocks require the `@react-pdf/math` package (a peer dependency of the PDF mapping).
The LaTeX is converted to Typst's math notation. Most KaTeX commands are
covered; a valid formula using an uncovered command fails the export loudly
(rather than silently exporting something else), while invalid LaTeX renders
the error placeholder as in other formats.

### Email

Expand Down
Loading
Loading