diff --git a/docs/AGENTS.md b/docs/AGENTS.md
new file mode 100644
index 0000000000..643577dfae
--- /dev/null
+++ b/docs/AGENTS.md
@@ -0,0 +1,9 @@
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md
new file mode 100644
index 0000000000..43c994c2d3
--- /dev/null
+++ b/docs/CLAUDE.md
@@ -0,0 +1 @@
+@AGENTS.md
diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx
new file mode 100644
index 0000000000..9658898fdb
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx
@@ -0,0 +1,330 @@
+---
+title: Container Blocks
+description: Learn how to create custom blocks that hold other blocks as their body
+---
+
+# Container Blocks
+
+A *container block* is a custom block that holds other blocks as its body — like a Notion-style callout wrapping a paragraph and a code block, a toggle with a title and a body, or a multi-column layout. BlockNote's built-in multi-column blocks (`columnList` / `column`) are implemented with this same mechanism.
+
+## Declaring a Container Block
+
+Add the `children` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The only required field is `allow`, so the smallest container is:
+
+```typescript
+import { createReactBlockSpec } from "@blocknote/react";
+
+const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {},
+ content: "none",
+ // Makes this a container: its body is other blocks.
+ children: { allow: "any" },
+ },
+ {
+ // Child blocks mount into the element you attach `contentRef` to.
+ render: (props) =>
,
+ },
+);
+```
+
+`children: { allow: "any" }` accepts any block, requires at least one, and can never throw: when a container is created without children, BlockNote fills it with whatever its schema requires.
+
+At runtime the contained blocks live on `block.children` — the same field used for indented (nested) blocks. In fact, every regular block behaves as if it were declared with `children: { allow: "any", min: 0 }`; declaring `children` yourself is how you take control of the counts, the allowed types, and the rendering of that same field:
+
+```json
+{
+ "id": "callout-1",
+ "type": "callout",
+ "props": {},
+ "children": [
+ {
+ "id": "para-1",
+ "type": "paragraph",
+ "content": [{ "type": "text", "text": "Hello", "styles": {} }],
+ "children": []
+ }
+ ]
+}
+```
+
+### Where children render
+
+There is only one placement mechanism, and it is the one you already use for inline content. `contentRef` (React) / `contentDOM` (vanilla) marks **the block's editable region**; what goes in that region depends on the block:
+
+| block | `contentRef` element holds |
+| --- | --- |
+| `content: "inline"`, no `children` | its inline content |
+| `content: "none"` + `children` | its child blocks |
+| `content: "inline"` + `children` | its inline content, then its child blocks |
+| `content: "plain"` + `children` | its plain-text content, then its child blocks |
+
+A `content: "none"` block *without* `children` is the only kind with nothing to place, and it's the only kind that isn't offered a `contentRef` at all.
+
+Container blocks own their entire outer DOM — BlockNote doesn't wrap them in the usual block element. Whatever element your `render` returns *is* the block's element, and BlockNote stamps the attributes it relies on for parsing and UI positioning onto it: `data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute. You write a plain `
` and `data-flavor="info"` lands on it, in the live editor and in serialized HTML alike.
+
+
+ _Because the framework wrappers React puts above your element carry `display:
+ contents`, they contribute no box: your element lays out exactly as if it were
+ the block's root. Selection is mirrored onto it as a `data-selected`
+ attribute, so `[data-selected]` is what you style for the selected state._
+
+
+The demo below puts this together: a callout block that can contain any other blocks. Its title is a regular `` backed by a string prop rather than document content — a pattern covered in [Editable fields that aren't document content](#editable-fields-that-arent-document-content):
+
+
+
+## Containers with their own content
+
+A container can have inline content *of its own* as well as children — a toggle's title with its body beneath it, a card header, a callout whose first line is real rich text rather than a plain ``. Combine `content: "inline"` with `children`, and place both with the same single `contentRef`:
+
+```typescript
+const createToggle = createReactBlockSpec(
+ {
+ type: "toggle",
+ propSchema: {},
+ // The toggle's own title...
+ content: "inline",
+ // ...and its body.
+ children: { allow: "any", min: 0 },
+ },
+ {
+ render: (props) => (
+
+
+
+
+ ),
+ },
+);
+```
+
+This is purely additive: adding `children` to an existing block is one config line and **zero render changes**. The block keeps its `Block` JSON shape — `content` for its own content, `children` for its body — identical to any other nested block.
+
+`content: "plain"` combines with `children` the same way, for a head that is text but not *rich* text — no formatting marks, no inline nodes, like a code block's source. A file group whose header is a literal filename would use it:
+
+```typescript
+{
+ type: "fileGroup",
+ propSchema: {},
+ // The group's filename: plain, unformattable text.
+ content: "plain",
+ // The files it groups.
+ children: { allow: "any" },
+}
+```
+
+### The two regions
+
+Inside the `contentRef` element, BlockNote renders two sibling elements with stable attributes derived from the block type:
+
+- `[data-content-type=""]` — the block's own (inline or plain) content.
+- `[data-children-of=""]` — its child blocks.
+
+You never place these yourself; you style them. The host element between them carries `display: contents`, so a grid on your own root reaches them directly:
+
+```css
+.toggle { display: grid; grid-template-columns: auto 1fr; }
+.toggle-main { display: contents; }
+.chevron { grid-column: 1; grid-row: 1; }
+[data-content-type="toggle"] { grid-column: 2; grid-row: 1; }
+[data-children-of="toggle"] { grid-column: 2; grid-row: 2; }
+```
+
+
+ **Two limits, both imposed by ProseMirror:** reading order is always
+ content-then-children, and your own markup cannot be interleaved between the
+ two regions or wrap only one of them. A grid (or `order`) can reorder them
+ *visually*; the DOM order is fixed.
+
+
+## `children` options
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `allow` | — (required) | What may appear as a child: `"any"`, `"blocks"`, `"containers"`, or an array of container block types. See [Restricting children](#restricting-children). |
+| `min` / `max` | `1` / unbounded | How many children are allowed. Compiled into the editor schema. |
+| `default` | — | Partial blocks to create the container with when it's inserted without an explicit `children` array, and the source of `"refill"` top-ups. Validated against the rest of the config when the schema is created. See [Defaults and refilling](#defaults-and-refilling). |
+| `whenEmptied` | `"refill"` | What happens when fewer non-empty children remain than `min`: `"refill"` tops the container back up from `default`; `"unwrap"` replaces the container with its surviving children, or removes it entirely when none are left. Column lists use `"unwrap"` so emptied columns disappear and a one-column list dissolves. |
+| `boundary` | `"isolated"` | What crosses the container's edge — the caret, selections, or nothing. See [Boundaries](#boundaries). |
+
+`placement` sits next to `children` on the block config rather than inside it, because it's a fact about *this* block rather than about its children:
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `placement` | `"anywhere"` | `"containerOnly"` restricts the block to containers that name it in their `children.allow` array — like a `column`, which only makes sense inside a `columnList` — and requires the block to be a container itself. `"anywhere"` is valid on any block; on a regular block it simply restates the default. |
+
+Purely behavioral options that apply to *every* block kind stay in the block implementation's `meta`:
+
+| Meta option | Default | Description |
+| --- | --- | --- |
+| `draggable` | `true` | Whether the block gets a side menu drag handle. A block that opts out is skipped when looking for a handle, so the handle falls through to the nearest draggable ancestor. |
+
+
+ _`whenEmptied` never destroys typed text: for a container with its own
+ content, neither value does anything while that content is non-empty._
+
+
+## Defaults and refilling
+
+`default` is an insertion template: a container inserted without an explicit `children` array is created with those blocks. Omit it and BlockNote fills the container with empty blocks its schema accepts.
+
+The same template drives `whenEmptied: "refill"`. When a refill container's non-empty children drop below `min` — say `k` remain — BlockNote appends `default[k]` through `default[min - 1]` at the end, falling back to empty blocks where `default` is absent or has no entry for a position. A checklist with `min: 2` and a two-entry `default` that loses its second item gets `default[1]` back, not a bare paragraph.
+
+## Boundaries
+
+`boundary` declares what may cross a container's edge:
+
+| Value | What crosses the edge |
+| --- | --- |
+| `"open"` | Everything: the caret, editing gestures, and text selections — a selection can start inside one child and end outside the container. `columnList` uses this so a selection can span columns. |
+| `"isolated"` (default) | The caret and editing gestures cross, but a text selection cannot span the edge. |
+| `"sealed"` | Nothing crosses implicitly: the caret doesn't wander in via arrow keys or Backspace, and from outside, the container selects and deletes as a single unit. |
+
+For editing gestures, `"open"` and `"isolated"` behave identically — the
+editor moves blocks across the edge implicitly, which is the right feel for
+flow regions. Backspace at the start of a container's first child moves that
+child out; Backspace at the start of the block *after* a container moves it
+inside; Delete mirrors both. And Enter on an empty **last** child moves that
+block out below the container — the "double-Enter escapes" gesture every
+list editor has (an empty block mid-container never ejects; spacing inside a
+block is Shift+Enter's job). Columns and callouts want exactly this. The two
+values differ only in whether a *text selection* can reach across the edge.
+
+### Sealed containers
+
+A compartment — a table cell is the canonical case — wants the opposite:
+whatever happens at its edges, content stays where it is. Declaring
+`boundary: "sealed"` gets that in one line:
+
+```typescript
+// A cell: holds any blocks, but nothing crosses its edge implicitly.
+children: { allow: "any", boundary: "sealed" },
+placement: "containerOnly",
+```
+
+Sealed means every *implicit* move across the edge is off, in both
+directions — without it, each of these would need a hand-written keyboard
+handler:
+
+- Backspace at the start of the first child no longer moves it out; the
+ keystroke does nothing. Deeper in the container, Backspace behaves as
+ usual — merging into the previous sibling, un-indenting nested blocks.
+- Delete at the end of the last child no longer pulls the next block in.
+- Backspace after (or Delete before) a sealed container selects the
+ container instead of merging into it, so a second press deletes it as a
+ whole, deliberately.
+- Arrow keys from outside treat the container as a unit rather than
+ stepping the caret inside; clicking inside still places the caret, and
+ editing within the container is unrestricted.
+- Enter never moves the trailing block out: there is no double-Enter escape
+ from a sealed container.
+
+The setting is deliberately key-agnostic — it declares a fact about the
+boundary, not a keybinding — so any gesture that would implicitly move the
+caret or content across the edge consults it.
+
+One thing seals never bind: the block manipulation API. `insertBlocks` and
+the rest ignore `boundary` entirely — an API call is an intentional
+crossing, so it can always place content inside a sealed container.
+
+## Restricting children
+
+`allow` takes one of four forms:
+
+```typescript
+allow: "any" | "blocks" | "containers" | string[]
+```
+
+- `"any"` — any regular block, plus any container placeable anywhere.
+- `"blocks"` — regular blocks only, no containers.
+- `"containers"` — any anywhere-placeable container, no regular blocks.
+- `string[]` — only the named container block types.
+
+The wildcard forms (`"any"`, `"containers"`) exclude `placement: "containerOnly"` types: a `column` never shows up inside your callout just because the callout accepts "any" block. A containerOnly type appears only where a parent names it in an array.
+
+The array form is exact because container blocks are each their own ProseMirror node type. Every **regular** block — paragraph, heading, code block — is the *same* ProseMirror node internally, so "only headings" is not something the schema can enforce yet. Naming a regular block type in the array is a startup error saying per-type filtering of regular blocks is not yet supported — the array is where that will land later, with no API change.
+
+This is exactly how the multi-column blocks are defined:
+
+```typescript
+// The outer container: only columns, at least two of them;
+// unwraps when it drops to one, and selections span its columns.
+children: {
+ allow: ["column"],
+ min: 2,
+ whenEmptied: "unwrap",
+ boundary: "open",
+}
+
+// The column: holds any blocks, but only lives inside a columnList.
+children: { allow: "any" },
+placement: "containerOnly",
+```
+
+## Inserting into a container
+
+[`editor.insertBlocks`](/docs/reference/editor/manipulating-content#inserting-blocks) takes two nested placements alongside the sibling ones:
+
+```typescript
+// Siblings of the reference block:
+editor.insertBlocks([{ type: "paragraph" }], calloutId, "before");
+editor.insertBlocks([{ type: "paragraph" }], calloutId, "after");
+
+// Nested inside it, as its first or last child:
+editor.insertBlocks([{ type: "paragraph" }], calloutId, "start");
+editor.insertBlocks([{ type: "paragraph" }], calloutId, "end");
+```
+
+The nested placements are what addresses a container with no children to point at — a `min: 0` container that is currently empty has no child block to insert before or after. Whether a block fits is answered by the schema, so it's your `children` config that decides.
+
+## Validation
+
+Configurations are checked when the schema is created, and fail up front with a message naming the block. Beyond unknown block types and impossible `default` children, this catches:
+
+- an `allow` that permits nothing — an empty array, or a wildcard form when no anywhere-placeable container exists;
+- an `allow` array naming an unknown type, or naming a regular block type (per-type filtering of regular blocks is [not yet supported](#restricting-children));
+- `content: "table"` combined with `children`;
+- a `placement: "containerOnly"` block that no container's `allow` array names, or `placement: "containerOnly"` on a regular block;
+- **container cycles** — a container that (transitively) requires a child that requires it back could never be created. An `allow` that permits regular blocks breaks the cycle, since they're always satisfiable.
+
+## Parsing HTML into a container
+
+Containers go through the same parsing path as regular blocks. The default rule matches the marker BlockNote puts on the block's root, `[data-node-type=""]`, which is what makes HTML produced by BlockNote round-trip.
+
+To recognize *foreign* HTML, add `implementation.parse` — it returns the block's props, or `undefined` to decline:
+
+```typescript
+{
+ render: (props) => ,
+ parse: (el) =>
+ el.classList.contains("card")
+ ? { tone: el.getAttribute("data-tone") ?? undefined }
+ : undefined,
+}
+```
+
+With no `parseContent`, ProseMirror parses the element's children with the normal block rules, so `
…
…
` becomes a card with a paragraph and a heading. Supply `parseContent` only if you need to build the body yourself; inline nodes it returns become paragraph children, except a leading inline run in a container that has its own content, which becomes that content.
+
+`runsBefore` orders your parse rule against other blocks'. On a container it may only name **other containers**: container nodes register in a priority band below regular blocks, so a container can never be ordered ahead of one — a container's `tag: "*"` rule is always considered after every regular block's. Naming a regular block there is an error rather than a silent no-op.
+
+
+ **`allow` does not filter what a user pastes.** Pasted HTML is parsed with
+ `blockGroup` as its top node, and ProseMirror's fitting algorithm places
+ content your container's expression rejects *after* the container rather than
+ dropping it. `allow` constrains the document model, not the parser.
+
+
+## Interop behavior
+
+- **HTML**: containers serialize to a `
` with their children nested inside and non-default props as `data-*` attributes, and parse back losslessly. A container with its own content serializes its two regions as `[data-content-type]` and `[data-children-of]` elements.
+- **External HTML** (`blocksToHTMLLossy`, copy to another app) is intentionally semantic and lossy. Override `toExternalHTML` and return a `childrenDOM` to say where children belong in your own markup — this is how toggles export as ``.
+- **Markdown**: containers are flattened — their children are exported in order, and Markdown import never produces containers.
+- **Exporters** (`@blocknote/xl-docx-exporter`, `xl-pdf-exporter`, `xl-odt-exporter`, `xl-email-exporter`): container blocks require an explicit block mapping that places their children; a missing mapping throws a clear error.
+
+## Editable fields that aren't document content
+
+Not every editable field belongs in the document. If a field doesn't need rich text formatting, comments, or multiplayer cursors — a name, a URL, a label — store it as a **string prop** and render a regular `` inside the block, in a `contentEditable={false}` wrapper, committing the value with `editor.updateBlock`. The callout demo on this page does exactly that for its title.
+
+Reach for a container's own `content: "inline"` when the field *is* prose, and for a string prop when it's data.
diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index ff25cf838c..7f919322f4 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -72,6 +72,12 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
+
+ _Any block can also hold **other blocks** as its body by declaring the
+ `children` option — with or without inline content of its own. See [Container
+ Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
`propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior.
```typescript
diff --git a/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx
index 1a9c97c222..8c18feb7eb 100644
--- a/docs/content/docs/reference/editor/manipulating-content.mdx
+++ b/docs/content/docs/reference/editor/manipulating-content.mdx
@@ -141,11 +141,11 @@ editor.forEachBlock((block) => {
insertBlocks(
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
- placement: "before" | "after" = "before"
+ placement: "before" | "after" | "start" | "end" = "before"
): void
```
-Inserts new blocks relative to an existing block.
+Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"start"` and `"end"` nest them inside it, as its first or last children — see [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container).
```typescript
// Insert a paragraph before an existing block
@@ -164,6 +164,13 @@ editor.insertBlocks(
"existing-block-id",
"after",
);
+
+// Insert a paragraph as the last child of a container block
+editor.insertBlocks(
+ [{ type: "paragraph", content: "Nested paragraph" }],
+ "container-block-id",
+ "end",
+);
```
### Updating Blocks
diff --git a/examples/06-custom-schema/09-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json
new file mode 100644
index 0000000000..3de7330631
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/.bnexample.json
@@ -0,0 +1,15 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "nickthesick",
+ "tags": [
+ "Intermediate",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu"
+ ],
+ "dependencies": {
+ "react-icons": "^5.5.0"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md
new file mode 100644
index 0000000000..9e4ee82060
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/README.md
@@ -0,0 +1,22 @@
+# Container Block
+
+In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.
+
+The block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime.
+
+The callout's **title** demonstrates the complementary "string prop slot" pattern: a field that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block's own `content: "inline"` instead.
+
+We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.
+
+**Try it out:**
+
+- Press the "/" key inside the callout's body and add a code block, heading, or list — anything goes.
+- Type a title into the title field — it's stored on `block.props.title`, not as document content.
+- Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`.
+- Insert a new callout via the Slash Menu (search "callout").
+
+**Relevant Docs:**
+
+- [Container Blocks](/docs/features/custom-schemas/container-blocks)
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
+- [Editor Setup](/docs/getting-started/editor-setup)
diff --git a/examples/06-custom-schema/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html
new file mode 100644
index 0000000000..19321f77b5
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Container Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+ ,
+);
diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json
new file mode 100644
index 0000000000..29778f9255
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "@blocknote/example-custom-schema-container-block",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-icons": "^5.5.0"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.0"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx
new file mode 100644
index 0000000000..8945f4a31a
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/App.tsx
@@ -0,0 +1,118 @@
+import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
+import {
+ filterSuggestionItems,
+ insertOrUpdateBlockForSlashMenu,
+} from "@blocknote/core/extensions";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import {
+ SuggestionMenuController,
+ getDefaultReactSlashMenuItems,
+ useCreateBlockNote,
+} from "@blocknote/react";
+import { useEffect, useState } from "react";
+import { RiChatQuoteLine } from "react-icons/ri";
+
+import { createCallout } from "./Callout";
+import "./styles.css";
+
+// Schema with the default blocks plus our custom Callout container block.
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: createCallout(),
+ },
+});
+
+// Slash menu item to insert a Callout. Because Callout is a container block,
+// inserting one with no children causes BlockNote to seed it with the block's
+// configured `children.default` (a single paragraph here).
+const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({
+ title: "Callout",
+ subtext: "Container block that wraps other blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "callout",
+ }),
+ aliases: ["callout", "container", "alert", "note", "tip", "info"],
+ group: "Basic blocks",
+ icon: ,
+});
+
+type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];
+
+export default function App() {
+ const [blocks, setBlocks] = useState([]);
+
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Welcome — this demo shows the new container block kind.",
+ },
+ {
+ type: "callout",
+ props: { flavor: "tip" },
+ children: [
+ {
+ type: "paragraph",
+ content: "Callouts can hold any block as their body.",
+ },
+ {
+ type: "paragraph",
+ content:
+ "Try pressing '/' inside this callout to add a heading or code block.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: "Press '/' anywhere to insert a new Callout.",
+ },
+ {
+ type: "paragraph",
+ },
+ ],
+ });
+
+ useEffect(() => setBlocks(editor.document), [editor]);
+
+ return (
+
+ );
+}
diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx
new file mode 100644
index 0000000000..64db594cfb
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx
@@ -0,0 +1,103 @@
+import { createReactBlockSpec } from "@blocknote/react";
+import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md";
+
+import "./styles.css";
+
+// The flavors of callout the user can switch between.
+export const calloutTypes = [
+ { value: "tip", title: "Tip", icon: MdLightbulb },
+ { value: "info", title: "Info", icon: MdInfo },
+ { value: "warning", title: "Warning", icon: MdWarning },
+ { value: "success", title: "Success", icon: MdCheckCircle },
+] as const;
+
+// The Callout block. Declared with `content: "none"` plus the new
+// `children` config — the block hosts arbitrary child blocks in its body,
+// exposed at runtime as `block.children`.
+//
+// The callout's title demonstrates the complementary "string prop slot"
+// pattern: content that shouldn't be part of the rich-text document (no
+// formatting, comments, or multiplayer cursors needed) can live in a plain
+// string prop, edited through a regular rendered inside the block.
+export const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ title: {
+ default: "",
+ },
+ },
+ content: "none",
+ // `children: { allow: "any" }` is the entire container declaration: any
+ // block is allowed, at least one is required, and BlockNote fills the
+ // callout with an empty paragraph when it's created. `min` / `max` /
+ // `default` / `whenEmptied` / `boundary` tune this.
+ children: { allow: "any" },
+ },
+ {
+ render: (props) => {
+ const flavor =
+ calloutTypes.find((c) => c.value === props.block.props.flavor) ??
+ calloutTypes[0];
+ const Icon = flavor.icon;
+
+ const cycleFlavor = () => {
+ const idx = calloutTypes.findIndex(
+ (c) => c.value === props.block.props.flavor,
+ );
+ const next = calloutTypes[(idx + 1) % calloutTypes.length];
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { flavor: next.value },
+ });
+ };
+
+ const commitTitle = (title: string) => {
+ if (title !== props.block.props.title) {
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { title },
+ });
+ }
+ };
+
+ return (
+
+
+
+ {/* The title lives in a string prop, not in document content —
+ it's edited via a plain input. `contentEditable={false}` keeps
+ ProseMirror from treating typing here as document input. */}
+
+ );
+}
diff --git a/examples/06-custom-schema/12-container-table/src/Table.tsx b/examples/06-custom-schema/12-container-table/src/Table.tsx
new file mode 100644
index 0000000000..052c2cd868
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/src/Table.tsx
@@ -0,0 +1,346 @@
+import {
+ createExtension,
+ type Block,
+ type BlockNoteEditor,
+} from "@blocknote/core";
+import { createReactBlockSpec } from "@blocknote/react";
+
+import "./styles.css";
+
+// A table built entirely out of container blocks — no `prosemirror-tables`,
+// no special `"table"` content type. A table is a container of rows, a row is
+// a container of cells, and a cell is a container of arbitrary blocks:
+//
+// table > tableRow > tableCell / tableHeader > (any blocks)
+//
+// The JSON shape is the same `children` array every other container block
+// uses, and every structural operation (add/remove row or column, toggle the
+// header row) is a plain `insertBlocks` / `removeBlocks` / `updateBlock` call.
+
+type AnyEditor = BlockNoteEditor;
+type AnyBlock = Block;
+
+function isCellType(type: string): boolean {
+ return type === "tableCell" || type === "tableHeader";
+}
+
+// ---------------------------------------------------------------------------
+// Cell navigation (Tab / Shift-Tab)
+// ---------------------------------------------------------------------------
+
+// Finds the cell / row / table the text cursor is currently inside, by
+// walking up the ancestor chain with `editor.getParentBlock`. Returns
+// undefined when the cursor isn't in a table.
+function getCellContext(
+ editor: AnyEditor,
+): { cell: AnyBlock; row: AnyBlock; table: AnyBlock } | undefined {
+ let current: AnyBlock | undefined = editor.getTextCursorPosition().block;
+ while (current && !isCellType(current.type)) {
+ current = editor.getParentBlock(current);
+ }
+ if (!current) {
+ return undefined;
+ }
+
+ const row = editor.getParentBlock(current);
+ if (!row || row.type !== "tableRow") {
+ return undefined;
+ }
+ const table = editor.getParentBlock(row);
+ if (!table || table.type !== "table") {
+ return undefined;
+ }
+
+ return { cell: current, row, table };
+}
+
+// Places the cursor inside a cell. Descends through nested tables so the
+// cursor always lands on a block that can actually hold it.
+function placeCursorInCell(
+ editor: AnyEditor,
+ cell: AnyBlock,
+ placement: "start" | "end",
+) {
+ let target = cell;
+ while (
+ target.children.length > 0 &&
+ (target.type === "table" ||
+ target.type === "tableRow" ||
+ isCellType(target.type))
+ ) {
+ target =
+ placement === "start"
+ ? target.children[0]
+ : target.children[target.children.length - 1];
+ }
+ editor.setTextCursorPosition(target, placement);
+}
+
+function createRow(
+ numColumns: number,
+ cellType: "tableCell" | "tableHeader" = "tableCell",
+) {
+ return {
+ type: "tableRow" as const,
+ children: Array.from({ length: numColumns }, () => ({ type: cellType })),
+ };
+}
+
+// Moves the cursor to the next/previous cell, wrapping across rows. Tab past
+// the last cell grows the table by a row — the classic spreadsheet gesture,
+// implemented as a single `insertBlocks` call.
+function moveToAdjacentCell(editor: AnyEditor, direction: 1 | -1): boolean {
+ const context = getCellContext(editor);
+ if (!context) {
+ // Not in a table: let BlockNote's default Tab (indent) behavior run.
+ return false;
+ }
+ const { cell, row, table } = context;
+
+ const rows = table.children;
+ const rowIndex = rows.findIndex((r) => r.id === row.id);
+ const cellIndex = row.children.findIndex((c) => c.id === cell.id);
+
+ let targetRowIndex = rowIndex;
+ let targetCellIndex = cellIndex + direction;
+ if (targetCellIndex >= row.children.length) {
+ targetRowIndex += 1;
+ targetCellIndex = 0;
+ } else if (targetCellIndex < 0) {
+ targetRowIndex -= 1;
+ targetCellIndex =
+ targetRowIndex >= 0 ? rows[targetRowIndex].children.length - 1 : 0;
+ }
+
+ // Shift-Tab at the very first cell: stay put (but consume the key so the
+ // cell's content isn't un-indented out of the table).
+ if (targetRowIndex < 0) {
+ return true;
+ }
+
+ // Tab at the very last cell: append a new row and move into it.
+ if (targetRowIndex >= rows.length) {
+ editor.insertBlocks(
+ [createRow(row.children.length)],
+ rows[rows.length - 1],
+ "after",
+ );
+ const updatedTable = editor.getBlock(table.id);
+ const newRow = updatedTable?.children[updatedTable.children.length - 1];
+ if (newRow) {
+ placeCursorInCell(editor, newRow.children[0], "start");
+ }
+ return true;
+ }
+
+ placeCursorInCell(
+ editor,
+ rows[targetRowIndex].children[targetCellIndex],
+ direction === 1 ? "start" : "end",
+ );
+ return true;
+}
+
+// Registered on the `table` block spec, so the shortcuts are only added when
+// the block is in the schema. Block-spec extensions run before BlockNote's
+// default keyboard handlers, so Tab reaches us before the default indent.
+const TableKeyboardExtension = createExtension({
+ key: "containerTableKeyboard",
+ keyboardShortcuts: {
+ Tab: ({ editor }) => moveToAdjacentCell(editor, 1),
+ "Shift-Tab": ({ editor }) => moveToAdjacentCell(editor, -1),
+ },
+});
+
+// ---------------------------------------------------------------------------
+// Structural operations — nothing but the public block manipulation API
+// ---------------------------------------------------------------------------
+
+function getTable(editor: AnyEditor, tableId: string): AnyBlock | undefined {
+ const table = editor.getBlock(tableId);
+ return table?.type === "table" ? table : undefined;
+}
+
+export function addRow(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table) {
+ return;
+ }
+ const lastRow = table.children[table.children.length - 1];
+ editor.insertBlocks([createRow(lastRow.children.length)], lastRow, "after");
+}
+
+export function removeRow(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table || table.children.length <= 1) {
+ return;
+ }
+ editor.removeBlocks([table.children[table.children.length - 1]]);
+}
+
+export function addColumn(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table) {
+ return;
+ }
+ editor.transact(() => {
+ for (const row of table.children) {
+ const lastCell = row.children[row.children.length - 1];
+ // Match the row's cell kind, so a header row grows a header cell.
+ editor.insertBlocks([{ type: lastCell.type }], lastCell, "after");
+ }
+ });
+}
+
+export function removeColumn(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table || table.children.some((row) => row.children.length <= 1)) {
+ return;
+ }
+ editor.transact(() => {
+ for (const row of table.children) {
+ editor.removeBlocks([row.children[row.children.length - 1]]);
+ }
+ });
+}
+
+// Flips the first row between header cells and regular cells. Because header
+// cells are a distinct *block type* (not table metadata), this is just
+// `updateBlock` with a new type — children are carried over automatically.
+export function toggleHeaderRow(editor: AnyEditor, tableId: string) {
+ const table = getTable(editor, tableId);
+ if (!table) {
+ return;
+ }
+ const firstRow = table.children[0];
+ const allHeaders = firstRow.children.every((c) => c.type === "tableHeader");
+ const type = allHeaders ? "tableCell" : "tableHeader";
+ editor.transact(() => {
+ for (const cell of firstRow.children) {
+ editor.updateBlock(cell, { type });
+ }
+ });
+}
+
+// ---------------------------------------------------------------------------
+// The four block specs
+// ---------------------------------------------------------------------------
+
+// The table itself: a container that only accepts rows. Inserting one with
+// no explicit children seeds it from `children.default` — a header row plus
+// two body rows, three columns wide.
+export const createTable = createReactBlockSpec(
+ {
+ type: "table",
+ propSchema: {},
+ content: "none",
+ children: {
+ allow: ["tableRow"],
+ default: [
+ createRow(3, "tableHeader"),
+ createRow(3, "tableCell"),
+ createRow(3, "tableCell"),
+ ],
+ },
+ },
+ {
+ render: (props) => {
+ // `props.block` is captured at render time; the control handlers
+ // re-fetch the table by id so they always operate on fresh children.
+ const { editor } = props;
+ const tableId = props.block.id;
+
+ // Keep focus (and the text selection) in the editor when clicking the
+ // controls.
+ const keepFocus = (event: React.MouseEvent) => event.preventDefault();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+ // Recognizes pasted foreign HTML tables.
+ parse: (el) => (el.tagName === "TABLE" ? {} : undefined),
+ },
+ [TableKeyboardExtension],
+);
+
+// A row: only lives inside a table (`placement: "containerOnly"`), only
+// holds cells.
+export const createTableRow = createReactBlockSpec(
+ {
+ type: "tableRow",
+ propSchema: {},
+ content: "none",
+ children: { allow: ["tableCell", "tableHeader"] },
+ placement: "containerOnly",
+ },
+ {
+ // No drag handle of its own — the side menu handle falls through to the
+ // table.
+ meta: { draggable: false },
+ render: (props) => (
+
+ ),
+ parse: (el) => (el.tagName === "TR" ? {} : undefined),
+ },
+);
+
+// A cell: holds any blocks, and is `boundary: "sealed"` — the caret and
+// content never implicitly cross its edge (Backspace at the start of a cell
+// does nothing, Delete at its end doesn't pull the next block in, arrow keys
+// from outside treat the table as a unit). Enter inside a cell just adds
+// another block to the cell.
+export const createTableCell = createReactBlockSpec(
+ {
+ type: "tableCell",
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", boundary: "sealed" },
+ placement: "containerOnly",
+ },
+ {
+ meta: { draggable: false },
+ render: (props) => (
+
+ ),
+ parse: (el) => (el.tagName === "TD" ? {} : undefined),
+ },
+);
+
+// A header cell: identical to a regular cell, but a distinct block type —
+// the structure itself encodes which cells are headers, instead of
+// `headerRows` metadata on the table.
+export const createTableHeader = createReactBlockSpec(
+ {
+ type: "tableHeader",
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", boundary: "sealed" },
+ placement: "containerOnly",
+ },
+ {
+ meta: { draggable: false },
+ render: (props) => (
+
+ ),
+ parse: (el) => (el.tagName === "TH" ? {} : undefined),
+ },
+);
diff --git a/examples/06-custom-schema/12-container-table/src/styles.css b/examples/06-custom-schema/12-container-table/src/styles.css
new file mode 100644
index 0000000000..cfb39149f8
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/src/styles.css
@@ -0,0 +1,100 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.item {
+ border-radius: 0.5rem;
+ flex: 1;
+ overflow: hidden;
+}
+
+.item.bordered {
+ border: 1px solid gray;
+}
+
+.item pre {
+ border-radius: 0.5rem;
+ height: 100%;
+ overflow: auto;
+ padding-block: 1rem;
+ padding-inline: 54px;
+ width: 100%;
+ white-space: pre-wrap;
+}
+
+/* The grid is plain CSS tables on divs. The React node-view wrappers between
+ the regions carry `display: contents`, so the row and cell boxes end up
+ direct children of the table box as far as layout is concerned. */
+.container-table {
+ flex-grow: 1;
+ min-width: 0;
+}
+
+.container-table-rows {
+ display: table;
+ border-collapse: collapse;
+ width: 100%;
+ table-layout: fixed;
+}
+
+.container-table-row {
+ display: table-row;
+}
+
+.container-table-cell {
+ display: table-cell;
+ border: 1px solid #d0d0d0;
+ padding: 4px 8px;
+ vertical-align: top;
+}
+
+.container-table-header {
+ background-color: #f3f4f6;
+ font-weight: 600;
+}
+
+[data-color-scheme="dark"] .container-table-cell {
+ border-color: #4b4b4b;
+}
+
+[data-color-scheme="dark"] .container-table-header {
+ background-color: #2e2e2e;
+}
+
+.container-table-controls {
+ display: flex;
+ gap: 4px;
+ padding-top: 4px;
+ /* Only reveal the controls while working in the table. */
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+
+.container-table:hover .container-table-controls,
+.container-table:focus-within .container-table-controls {
+ opacity: 1;
+}
+
+.container-table-controls button {
+ border: 1px solid #d0d0d0;
+ border-radius: 4px;
+ background: none;
+ color: inherit;
+ font-size: 0.75rem;
+ padding: 2px 8px;
+ cursor: pointer;
+}
+
+.container-table-controls button:hover {
+ background-color: #f3f4f6;
+}
+
+[data-color-scheme="dark"] .container-table-controls button {
+ border-color: #4b4b4b;
+}
+
+[data-color-scheme="dark"] .container-table-controls button:hover {
+ background-color: #2e2e2e;
+}
diff --git a/examples/06-custom-schema/12-container-table/tsconfig.json b/examples/06-custom-schema/12-container-table/tsconfig.json
new file mode 100644
index 0000000000..2aa62c56e6
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/tsconfig.json
@@ -0,0 +1,32 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true,
+ "paths": {
+ "@shared/*": ["../../../shared/*"]
+ }
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/06-custom-schema/12-container-table/vite-env.d.ts b/examples/06-custom-schema/12-container-table/vite-env.d.ts
new file mode 100644
index 0000000000..11f02fe2a0
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/12-container-table/vite.config.ts b/examples/06-custom-schema/12-container-table/vite.config.ts
new file mode 100644
index 0000000000..a96f1f04ff
--- /dev/null
+++ b/examples/06-custom-schema/12-container-table/vite.config.ts
@@ -0,0 +1,35 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ ? {}
+ : ({
+ // The repo-wide alias for the shared test-utils directory (private,
+ // so it only resolves inside the monorepo). Harmless for examples
+ // that don't use it.
+ "@shared": path.resolve(__dirname, "../../../shared/"),
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../packages/core/src/",
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/packages/core/package.json b/packages/core/package.json
index eb2700636d..8b9f1ee69b 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -72,6 +72,11 @@
"import": "./dist/extensions.js",
"require": "./dist/extensions.cjs"
},
+ "./internal": {
+ "types": "./types/src/internal.d.ts",
+ "import": "./dist/internal.js",
+ "require": "./dist/internal.cjs"
+ },
"./yjs": {
"types": "./types/src/yjs/index.d.ts",
"import": "./dist/yjs.js",
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
index b41b268617..6bc57a5dcb 100644
--- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts
@@ -1,4 +1,4 @@
-import { Fragment, Slice } from "prosemirror-model";
+import { Fragment, Node, NodeType, Slice } from "prosemirror-model";
import type { Transaction } from "prosemirror-state";
import { ReplaceStep } from "prosemirror-transform";
import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js";
@@ -8,10 +8,94 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
+import { isContainerBlockNode } from "../../../../schema/blocks/children.js";
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
import { getPmSchema } from "../../../pmUtil.js";
+import {
+ descendToFirstInsertionPos,
+ descendToLastInsertionPos,
+} from "../../containers/containerNav.js";
+
+/**
+ * Where blocks go relative to a reference block. `"before"`/`"after"` make them
+ * siblings of it; `"start"`/`"end"` nest them inside it, as its first or last
+ * children.
+ *
+ * The nested placements are what addresses a container that has no children
+ * to point at — a `min: 0` container that is currently empty has no child
+ * block to insert before or after.
+ */
+export type BlockPlacement = "before" | "after" | "start" | "end";
+
+/**
+ * Resolves a `placement` against a reference block into the document position
+ * a node of `nodeType` should be inserted at, or `null` when the reference
+ * block cannot take it there.
+ *
+ * Both insertion and the move commands ask this same question — "does this
+ * block fit here?" — so they ask it in one place. The answer comes from the
+ * schema's content matches rather than from a hand-written rule, so a
+ * container's `children` config is what decides it.
+ *
+ * `wrapIn` is set when the position only becomes valid once the nodes are
+ * wrapped: a regular block with no children yet has no `blockGroup` for them
+ * to go in, so one is created around them.
+ */
+export function getInsertionPos(
+ doc: Node,
+ reference: { node: Node; posBeforeNode: number },
+ placement: BlockPlacement,
+ nodeType: NodeType,
+): { pos: number; wrapIn?: NodeType } | null {
+ const { node, posBeforeNode } = reference;
+
+ if (placement === "before" || placement === "after") {
+ const pos =
+ placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize;
+ const $pos = doc.resolve(pos);
+
+ return $pos.parent.contentMatchAt($pos.index()).matchType(nodeType)
+ ? { pos }
+ : null;
+ }
+
+ // A container holds its children itself, or — when it has content of its own
+ // — in its generated `__children` node, which the descent helpers step into.
+ // The descent helpers ignore sealed boundaries by default, which is right
+ // here: an explicit `insertBlocks` placement is an intentional crossing.
+ if (isContainerBlockNode(node)) {
+ const pos =
+ placement === "start"
+ ? descendToFirstInsertionPos(node, posBeforeNode, nodeType)
+ : descendToLastInsertionPos(node, posBeforeNode, nodeType);
+
+ return pos === null ? null : { pos };
+ }
+
+ // A regular block keeps its children in a `blockGroup` that only exists once
+ // it has some.
+ const blockGroupType = nodeType.schema.nodes["blockGroup"];
+ if (node.type.name !== "blockContainer" || !blockGroupType) {
+ return null;
+ }
+
+ const blockGroupPos = posBeforeNode + 1 + node.firstChild!.nodeSize;
+
+ if (node.childCount < 2) {
+ return blockGroupType.contentMatch.matchType(nodeType)
+ ? { pos: blockGroupPos, wrapIn: blockGroupType }
+ : null;
+ }
+
+ const pos =
+ placement === "start"
+ ? descendToFirstInsertionPos(node.lastChild!, blockGroupPos, nodeType)
+ : descendToLastInsertionPos(node.lastChild!, blockGroupPos, nodeType);
+
+ return pos === null ? null : { pos };
+}
export function insertBlocks<
BSchema extends BlockSchema,
@@ -21,7 +105,7 @@ export function insertBlocks<
tr: Transaction,
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
- placement: "before" | "after" = "before",
+ placement: BlockPlacement = "before",
): Block[] {
const id =
typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id;
@@ -37,14 +121,30 @@ export function insertBlocks<
throw new Error(`Block with ID ${id} not found`);
}
- let pos = posInfo.posBeforeNode;
- if (placement === "after") {
- pos += posInfo.node.nodeSize;
+ if (nodesToInsert.length === 0) {
+ return [];
}
- tr.step(
- new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)),
+ const target = getInsertionPos(
+ tr.doc,
+ posInfo,
+ placement,
+ nodesToInsert[0].type,
);
+ if (!target) {
+ throw new Error(
+ `Cannot insert a block of type "${blocksToInsert[0].type ?? "paragraph"}" ` +
+ (placement === "before" || placement === "after"
+ ? `${placement} block with ID ${id}: its parent does not accept it.`
+ : `at the ${placement} of block with ID ${id}: the block does not accept it as a child.`),
+ );
+ }
+
+ const fragment = target.wrapIn
+ ? Fragment.from(target.wrapIn.create(null, nodesToInsert))
+ : Fragment.from(nodesToInsert);
+
+ tr.step(new ReplaceStep(target.pos, target.pos, new Slice(fragment, 0, 0)));
// Now that the `PartialBlock`s have been converted to nodes, we can
// re-convert them into full `Block`s.
diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
new file mode 100644
index 0000000000..fc8724adc5
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts
@@ -0,0 +1,244 @@
+// @vitest-environment node
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+
+import { BlockNoteSchema } from "../../../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
+import { createBlockSpec } from "../../../../schema/blocks/createSpec.js";
+
+// These blocks are never rendered — the editor stays headless — so `render`
+// only has to exist for `createBlockSpec` to accept the spec.
+const container = (type: string, config: Record) =>
+ createBlockSpec({ type, propSchema: {}, ...config } as any, {
+ render: () => {
+ throw new Error("not rendered in this suite");
+ },
+ })();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ // The shape `"start"`/`"end"` exist for: a container that may legally hold
+ // nothing has no child block to address, so `"before"`/`"after"` cannot
+ // reach inside it.
+ box: container("box", {
+ content: "none",
+ children: { allow: "any", min: 0 },
+ }),
+ titledBox: container("titledBox", {
+ content: "inline",
+ children: { allow: "any", min: 0 },
+ }),
+ // A container that only accepts other containers, so an insertion has to
+ // descend a level to find a place for a regular block.
+ grid: container("grid", {
+ content: "none",
+ children: { allow: ["cell"], min: 2 },
+ }),
+ cell: container("cell", {
+ content: "none",
+ children: { allow: "any" },
+ placement: "containerOnly",
+ }),
+ // A container that is full once it has one child.
+ single: container("single", {
+ content: "none",
+ children: { allow: "any", max: 1 },
+ }),
+ } as const,
+});
+
+let editor: BlockNoteEditor;
+
+beforeAll(() => {
+ editor = BlockNoteEditor.create({ schema }) as any;
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+});
+
+describe('insertBlocks "start" / "end"', () => {
+ it("inserts into a childless container", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "b-0", type: "box" },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+ expect(editor.getBlock("b-0")!.children).toHaveLength(0);
+
+ editor.insertBlocks(
+ [{ id: "inserted", type: "paragraph", content: "Inserted" }],
+ "b-0",
+ "end",
+ );
+
+ const box = editor.getBlock("b-0")!;
+ expect(box.children.map((child) => child.id)).toEqual(["inserted"]);
+ });
+
+ it('inserts into a childless container with "start"', () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "b-0", type: "box" },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.insertBlocks(
+ [{ id: "inserted", type: "paragraph", content: "Inserted" }],
+ "b-0",
+ "start",
+ );
+
+ expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
+ "inserted",
+ ]);
+ });
+
+ it("prepends and appends around existing children", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "b-0",
+ type: "box",
+ children: [{ id: "existing", type: "paragraph", content: "Existing" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start");
+ editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end");
+
+ expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([
+ "first",
+ "existing",
+ "last",
+ ]);
+ });
+
+ it("inserts into a childless container that has its own content", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "t-0", type: "titledBox", content: "Title" },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+ expect(editor.getBlock("t-0")!.children).toHaveLength(0);
+
+ editor.insertBlocks([{ id: "first", type: "paragraph" }], "t-0", "start");
+ editor.insertBlocks([{ id: "last", type: "paragraph" }], "t-0", "end");
+
+ const toggle = editor.getBlock("t-0")!;
+ // The title is content, not a child — a nested insertion must not land
+ // in it, or before it.
+ expect(toggle.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(toggle.children.map((child) => child.id)).toEqual(["first", "last"]);
+ });
+
+ it("descends into a nested container that accepts the block", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "g-0",
+ type: "grid",
+ children: [
+ { id: "c-0", type: "cell" },
+ { id: "c-1", type: "cell" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // `grid` itself only accepts `cell`s, so both placements have to find the
+ // leading/trailing cell rather than giving up.
+ editor.insertBlocks([{ id: "first", type: "paragraph" }], "g-0", "start");
+ editor.insertBlocks([{ id: "last", type: "paragraph" }], "g-0", "end");
+
+ const grid = editor.getBlock("g-0")!;
+ expect(grid.children[0].children.map((child: any) => child.id)).toContain(
+ "first",
+ );
+ expect(grid.children[1].children.map((child: any) => child.id)).toContain(
+ "last",
+ );
+ });
+
+ it("nests under a regular block that has no children yet", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+
+ editor.insertBlocks(
+ [{ id: "inserted", type: "paragraph", content: "Nested" }],
+ "p-0",
+ "end",
+ );
+
+ expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
+ "inserted",
+ ]);
+ });
+
+ it("nests under a regular block that already has children", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "p-0",
+ type: "paragraph",
+ content: "Paragraph 0",
+ children: [{ id: "existing", type: "paragraph" }],
+ },
+ ]);
+
+ editor.insertBlocks([{ id: "first", type: "paragraph" }], "p-0", "start");
+
+ expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([
+ "first",
+ "existing",
+ ]);
+ });
+
+ it("throws when the container has no room for the block", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "s-0",
+ type: "single",
+ children: [{ id: "only", type: "paragraph" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ expect(() =>
+ editor.insertBlocks([{ type: "paragraph" }], "s-0", "end"),
+ ).toThrow(/does not accept it as a child/);
+ });
+
+ it("throws when a sibling placement isn't allowed either", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "g-0",
+ type: "grid",
+ children: [
+ { id: "c-0", type: "cell" },
+ { id: "c-1", type: "cell" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ // `grid`'s children are `cell`s only, so a paragraph can't become one's
+ // sibling. Previously this surfaced as a raw ProseMirror `ReplaceError`.
+ expect(() =>
+ editor.insertBlocks([{ type: "paragraph" }], "c-0", "after"),
+ ).toThrow(/its parent does not accept it/);
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
index ce1a9455db..0a74cac005 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
@@ -1,6 +1,10 @@
import { Node } from "prosemirror-model";
-import { EditorState } from "prosemirror-state";
+import { EditorState, TextSelection } from "prosemirror-state";
+import {
+ isContentContainerNode,
+ isSealed,
+} from "../../../../schema/blocks/children.js";
import {
BlockInfo,
getBlockInfoFromResolvedPos,
@@ -90,8 +94,20 @@ export const getNextBlockInfo = (doc: Node, beforePos: number) => {
*
* Then the bottom nested block returned is D.
*/
-export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => {
- while (blockInfo.childContainer) {
+export const getBottomNestedBlockInfo = (
+ doc: Node,
+ blockInfo: BlockInfo,
+ // Callers that move content stop the descent at a sealed container, getting
+ // the container itself rather than a block inside it. Caret-only callers
+ // descend through — sealed boundaries govern content, not navigation.
+ opts?: { stopAtSealed?: boolean },
+) => {
+ // A container that allows zero children can have an empty child container,
+ // in which case the block itself is the bottom one.
+ while (blockInfo.childContainer && blockInfo.childContainer.node.childCount) {
+ if (opts?.stopAtSealed && isSealed(blockInfo.childContainer.node)) {
+ break;
+ }
const group = blockInfo.childContainer.node;
const newPos = doc
@@ -105,11 +121,17 @@ export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => {
const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => {
return (
- prevBlockInfo.isBlockContainer &&
+ prevBlockInfo.isWrappedBlock &&
prevBlockInfo.blockContent.node.type.spec.content === "inline*" &&
prevBlockInfo.blockContent.node.childCount > 0 &&
- nextBlockInfo.isBlockContainer &&
- nextBlockInfo.blockContent.node.type.spec.content === "inline*"
+ // A content-bearing container is `isWrappedBlock` with an `inline*`
+ // title, but stitching across its boundary would orphan its required
+ // `__children` node — `mergeIntoContainerContent` is the only supported
+ // merge involving one.
+ !isContentContainerNode(prevBlockInfo.bnBlock.node) &&
+ nextBlockInfo.isWrappedBlock &&
+ nextBlockInfo.blockContent.node.type.spec.content === "inline*" &&
+ !isContentContainerNode(nextBlockInfo.bnBlock.node)
);
};
@@ -120,7 +142,7 @@ const mergeBlocks = (
nextBlockInfo: BlockInfo,
) => {
// Un-nests all children of the next block.
- if (!nextBlockInfo.isBlockContainer) {
+ if (!nextBlockInfo.isWrappedBlock) {
throw new Error(
`Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but next block is not a block container`,
);
@@ -147,13 +169,17 @@ const mergeBlocks = (
// removing the closing tags of the first block and the opening tags of the
// second one to stitch them together.
if (dispatch) {
- if (!prevBlockInfo.isBlockContainer) {
+ if (!prevBlockInfo.isWrappedBlock) {
throw new Error(
`Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but previous block is not a block container`,
);
}
- // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
+ // Merging into or out of container blocks (columnLists, callouts, ...)
+ // is intentionally unsupported — `canMerge` refuses it above. The
+ // container-boundary Backspace/Delete branches in
+ // `KeyboardShortcutsExtension` handle those cases by moving blocks
+ // across the boundary instead of merging their content.
dispatch(
state.tr.delete(
prevBlockInfo.blockContent.afterPos - 1,
@@ -165,6 +191,61 @@ const mergeBlocks = (
return true;
};
+/**
+ * Merges a container's first child into the container's own content — the
+ * Backspace-at-the-start-of-the-first-child case for a container that has a
+ * title of its own. The child's own children stay in the container, taking its
+ * place.
+ *
+ * Deliberately separate from `canMerge`/`mergeBlocks`: a *pure* container has
+ * no content to merge into, so those keep refusing container boundaries
+ * outright and the "move the block out" branch still handles them. Returns
+ * false — falling through to that branch — whenever either side isn't inline
+ * content.
+ */
+export const mergeIntoContainerContent = (
+ state: EditorState,
+ dispatch: ((args?: any) => any) | undefined,
+ containerInfo: BlockInfo,
+ childInfo: BlockInfo,
+) => {
+ if (!containerInfo.isWrappedBlock || !childInfo.isWrappedBlock) {
+ return false;
+ }
+
+ const title = containerInfo.blockContent;
+ const childContent = childInfo.blockContent;
+
+ if (
+ title.node.type.spec.content !== "inline*" ||
+ childContent.node.type.spec.content !== "inline*"
+ ) {
+ return false;
+ }
+
+ if (dispatch) {
+ const tr = state.tr;
+
+ // The title lies before the children, so none of these positions shift the
+ // ones used after them.
+ if (childInfo.childContainer?.node.childCount) {
+ tr.insert(
+ childInfo.bnBlock.afterPos,
+ childInfo.childContainer.node.content,
+ );
+ }
+ tr.delete(childInfo.bnBlock.beforePos, childInfo.bnBlock.afterPos);
+
+ const titleEndPos = title.afterPos - 1;
+ tr.insert(titleEndPos, childContent.node.content);
+ tr.setSelection(TextSelection.create(tr.doc, titleEndPos));
+
+ dispatch(tr);
+ }
+
+ return true;
+};
+
export const mergeBlocksCommand =
(posBetweenBlocks: number) =>
({
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
index 61964a49ee..f034506f44 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts
@@ -18,7 +18,7 @@ const getEditor = setupTestEnv();
function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") {
const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr));
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
throw new Error(
`Selection points to a ${blockInfo.blockNoteType} node, not a blockContainer node`,
);
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
index 71598b7d69..46794bff7f 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
@@ -14,7 +14,8 @@ import {
getNodeId,
} from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
-import { insertBlocks } from "../insertBlocks/insertBlocks.js";
+import { flattenNonInsertableBlocks } from "../../containers/fixContainer.js";
+import { getInsertionPos, insertBlocks } from "../insertBlocks/insertBlocks.js";
import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js";
type BlockSelectionData = (
@@ -131,16 +132,6 @@ function updateBlockSelectionFromData(
tr.setSelection(selection);
}
-// Replaces top-level `column` blocks with their children, as a `column` is not
-// a valid block outside a `columnList`. Other blocks are returned as-is.
-function flattenColumns(
- blocks: Block[],
-): Block[] {
- return blocks.flatMap((block) =>
- block.type === "column" ? block.children : [block],
- );
-}
-
/**
* Removes the given blocks from the editor, then inserts them before/after a
* reference block.
@@ -169,10 +160,12 @@ export function moveBlocks(
//
// When the non-empty block is moved up, the column is seen as empty and
// collapsed in the removal step, so the following insertion fails.
- removeAndInsertBlocks(tr, blocks, [], { fixColumns: false });
+ removeAndInsertBlocks(tr, blocks, [], { fixContainers: false });
insertBlocks(
tr,
- flattenColumns(blocks),
+ // Blocks that can't stand on their own outside their container (e.g. a
+ // `column` outside its `columnList`) are replaced by their children.
+ flattenNonInsertableBlocks(blocks, editor.pmSchema),
referenceBlock,
placement,
);
@@ -207,12 +200,33 @@ export function moveSelectedBlocksAndSelection(
});
}
-// Checks if a block is in a valid place after being moved. This check is
-// primitive at the moment and only returns false if the block's parent is a
-// `columnList` block. This is because regular blocks cannot be direct children
-// of `columnList` blocks.
-function checkPlacementIsValid(parentBlock?: Block): boolean {
- return !parentBlock || parentBlock.type !== "columnList";
+// Checks if a regular block would be in a valid place after being moved
+// before/after `referenceBlock`. A regular block nests under any non-container
+// block (it goes into that block's `blockGroup`), but a container block (e.g. a
+// `columnList`) only accepts what its content expression allows.
+//
+// Deferred to `getInsertionPos` so that "can a block go here?" has exactly one
+// answer, shared with `insertBlocks` — and so that it comes from the schema
+// rather than from a rule restated here.
+function checkPlacementIsValid(
+ editor: BlockNoteEditor,
+ referenceBlock: Block,
+ placement: "before" | "after",
+): boolean {
+ return editor.transact((tr) => {
+ const posInfo = getNodeById(referenceBlock.id, tr.doc);
+ if (!posInfo) {
+ return false;
+ }
+ return (
+ getInsertionPos(
+ tr.doc,
+ posInfo,
+ placement,
+ editor.pmSchema.nodes["blockContainer"],
+ ) !== null
+ );
+ });
}
// Gets the placement for moving a block up. This has 3 cases:
@@ -253,8 +267,8 @@ function getMoveUpPlacement(
return undefined;
}
- const referenceBlockParent = editor.getParentBlock(referenceBlock);
- if (!checkPlacementIsValid(referenceBlockParent)) {
+ if (!checkPlacementIsValid(editor, referenceBlock, placement)) {
+ const referenceBlockParent = editor.getParentBlock(referenceBlock);
return getMoveUpPlacement(
editor,
placement === "after"
@@ -305,8 +319,8 @@ function getMoveDownPlacement(
return undefined;
}
- const referenceBlockParent = editor.getParentBlock(referenceBlock);
- if (!checkPlacementIsValid(referenceBlockParent)) {
+ if (!checkPlacementIsValid(editor, referenceBlock, placement)) {
+ const referenceBlockParent = editor.getParentBlock(referenceBlock);
return getMoveDownPlacement(
editor,
placement === "before"
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
index a0f76fdff0..a0a09d0099 100644
--- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
@@ -19,9 +19,7 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
- (node) =>
- node.childCount > 0 &&
- (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1
);
if (!range) {
return false;
@@ -163,9 +161,7 @@ export function liftItem(
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
- (node) =>
- node.childCount > 0 &&
- (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1
);
if (!range) {
return false;
@@ -195,14 +191,36 @@ export function canNestBlock(editor: BlockNoteEditor) {
return editor.transact((tr) => {
const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
- return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null;
+ // Mirrors `sinkItem`'s precondition: nesting is only possible under a
+ // previous sibling that is itself a `blockContainer`. (A previous sibling
+ // of another type — e.g. a container block — made this return true while
+ // `nestBlock` did nothing.)
+ return (
+ tr.doc.resolve(blockContainer.beforePos).nodeBefore?.type ===
+ editor.pmSchema.nodes["blockContainer"]
+ );
});
}
export function canUnnestBlock(editor: BlockNoteEditor) {
return editor.transact((tr) => {
- const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
+ const { $from, $to } = tr.selection;
+
+ // Mirrors `liftItem`'s preconditions instead of approximating with
+ // depth — a block whose depth > 1 because it sits inside a container
+ // (e.g. a column) is not un-nestable, only a block nested under another
+ // `blockContainer` is.
+ const range = $from.blockRange(
+ $to,
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"),
+ );
+ if (!range) {
+ return false;
+ }
- return tr.doc.resolve(blockContainer.beforePos).depth > 1;
+ return (
+ $from.node(range.depth - 1).type ===
+ editor.pmSchema.nodes["blockContainer"]
+ );
});
}
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
index d9e1e72981..84be8fa9fc 100644
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
@@ -11,7 +11,8 @@ import type {
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getPmSchema } from "../../../pmUtil.js";
-import { fixColumnList } from "./util/fixColumnList.js";
+import { fixContainersById } from "../../containers/fixContainer.js";
+import { getAncestorContainers } from "../../containers/containerNav.js";
export function removeAndInsertBlocks<
BSchema extends BlockSchema,
@@ -22,7 +23,7 @@ export function removeAndInsertBlocks<
blocksToRemove: BlockIdentifier[],
blocksToInsert: PartialBlock[],
options: {
- fixColumns?: boolean;
+ fixContainers?: boolean;
} = {},
): {
insertedBlocks: Block[];
@@ -43,7 +44,10 @@ export function removeAndInsertBlocks<
),
);
const removedBlocks: Block[] = [];
- const columnListPositions = new Set();
+ // Ancestor containers of removed blocks, to repair afterwards. Tracked by
+ // node id (not position) since the removals — and earlier repairs — shift
+ // positions; recorded with their depth so repairs run deepest-first.
+ const containersToFix: { id: string; depth: number }[] = [];
const idOfFirstBlock =
typeof blocksToRemove[0] === "string"
@@ -84,10 +88,10 @@ export function removeAndInsertBlocks<
const $pos = tr.doc.resolve(pos - removedSize);
- if ($pos.node().type.name === "column") {
- columnListPositions.add($pos.before(-1));
- } else if ($pos.node().type.name === "columnList") {
- columnListPositions.add($pos.before());
+ for (const container of getAncestorContainers($pos.doc, $pos.pos)) {
+ if (!containersToFix.some((c) => c.id === container.id)) {
+ containersToFix.push(container);
+ }
}
if (
@@ -119,11 +123,12 @@ export function removeAndInsertBlocks<
);
}
- // Collapses empty columns/columnLists. Callers where the removal isn't a
- // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere
- // and deliberately leaves emptied columns as-is.
- if (options.fixColumns !== false) {
- columnListPositions.forEach((pos) => fixColumnList(tr, pos));
+ // Repairs the containers the removed blocks lived in (e.g. collapses
+ // emptied columns/columnLists), deepest-first. Callers where the removal
+ // isn't a deletion can opt out - e.g. `moveBlocks` re-inserts the blocks
+ // elsewhere and deliberately leaves emptied containers as-is.
+ if (options.fixContainers !== false) {
+ fixContainersById(tr, containersToFix);
}
// Converts the nodes created from `blocksToInsert` into full `Block`s.
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
deleted file mode 100644
index 3097851f47..0000000000
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Slice, type Node } from "prosemirror-model";
-import { type Transaction } from "prosemirror-state";
-import { ReplaceAroundStep } from "prosemirror-transform";
-
-/**
- * Checks if a `column` node is empty, i.e. if it has only a single empty
- * paragraph.
- * @param column The column to check.
- * @returns Whether the column is empty.
- */
-export function isEmptyColumn(column: Node) {
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- const blockContainer = column.firstChild;
- if (!blockContainer) {
- throw new Error("Invalid column: does not have child node.");
- }
-
- const blockContent = blockContainer.firstChild;
- if (!blockContent) {
- throw new Error("Invalid blockContainer: does not have child node.");
- }
-
- return (
- column.childCount === 1 &&
- blockContainer.childCount === 1 &&
- blockContent.type.name === "paragraph" &&
- blockContent.content.content.length === 0
- );
-}
-
-/**
- * Removes all empty `column` nodes in a `columnList`. A `column` node is empty
- * if it has only a single empty block. If, however, removing the `column`s
- * leaves the `columnList` that has fewer than two, ProseMirror will re-add
- * empty columns.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos The position just before the `columnList` node.
- */
-export function removeEmptyColumns(tr: Transaction, columnListPos: number) {
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- for (
- let columnIndex = columnList.childCount - 1;
- columnIndex >= 0;
- columnIndex--
- ) {
- const columnPos = tr.doc
- .resolve($columnListPos.pos + 1)
- .posAtIndex(columnIndex);
- const $columnPos = tr.doc.resolve(columnPos);
- const column = $columnPos.nodeAfter;
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- if (isEmptyColumn(column)) {
- tr.delete(columnPos, columnPos + column.nodeSize);
- }
- }
-}
-
-/**
- * Fixes potential issues in a `columnList` node after a
- * `blockContainer`/`column` node is (re)moved from it:
- *
- * - Removes all empty `column` nodes. A `column` node is empty if it has only
- * a single empty block.
- * - If all but one `column` nodes are empty, replaces the `columnList` with
- * the content of the non-empty `column`.
- * - If all `column` nodes are empty, removes the `columnList` entirely.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos
- * @returns The position just before the `columnList` node.
- */
-export function fixColumnList(tr: Transaction, columnListPos: number) {
- removeEmptyColumns(tr, columnListPos);
-
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- if (columnList.childCount > 2) {
- // Do nothing if the `columnList` has more than two non-empty `column`s. In
- // the case that the `columnList` has exactly two columns, we may need to
- // still remove it, as it's possible that one or both columns are empty.
- // This is because after `removeEmptyColumns` is called, if the
- // `columnList` has fewer than two `column`s, ProseMirror will re-add empty
- // `column`s until there are two total, in order to fit the schema.
- return;
- }
-
- if (columnList.childCount < 2) {
- // Throw an error if the `columnList` has fewer than two columns. After
- // `removeEmptyColumns` is called, if the `columnList` has fewer than two
- // `column`s, ProseMirror will re-add empty `column`s until there are two
- // total, in order to fit the schema. So if there are fewer than two here,
- // either the schema, or ProseMirror's internals, must have changed.
- throw new Error("Invalid columnList: contains fewer than two children.");
- }
-
- const firstColumnBeforePos = columnListPos + 1;
- const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos);
- const firstColumn = $firstColumnBeforePos.nodeAfter;
-
- const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1;
- const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos);
- const lastColumn = $lastColumnAfterPos.nodeBefore;
-
- if (!firstColumn || !lastColumn) {
- throw new Error("Invalid columnList: does not contain children.");
- }
-
- const firstColumnEmpty = isEmptyColumn(firstColumn);
- const lastColumnEmpty = isEmptyColumn(lastColumn);
-
- if (firstColumnEmpty && lastColumnEmpty) {
- // Removes `columnList`
- tr.delete(columnListPos, columnListPos + columnList.nodeSize);
-
- return;
- }
-
- if (firstColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of last `column`.
- lastColumnAfterPos - lastColumn.nodeSize + 1,
- lastColumnAfterPos - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-
- if (lastColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of first `column`.
- firstColumnBeforePos + 1,
- firstColumnBeforePos + firstColumn.nodeSize - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-}
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
index ab02a865f0..9a83857cd1 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts
@@ -35,7 +35,7 @@ function setSelectionWithOffset(
const info = getBlockInfo(posInfo);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("Target block is not a block container");
}
diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
index 1e73471d23..ef74f8e898 100644
--- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts
@@ -36,7 +36,7 @@ export const splitBlockTr = (
const info = getBlockInfo(nearestBlockContainerPos);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
return false;
}
const schema = getPmSchema(tr);
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
index e44e4a6380..c695de98ae 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts
@@ -181,7 +181,7 @@ describe("Test updateBlock", () => {
getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("heading-with-everything is not a block container");
}
@@ -210,7 +210,7 @@ describe("Test updateBlock", () => {
getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("heading-with-everything is not a block container");
}
@@ -240,7 +240,7 @@ describe("Test updateBlock", () => {
getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!,
);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("heading-with-everything is not a block container");
}
@@ -273,7 +273,7 @@ describe("Test updateBlock", () => {
getNodeById("table-0", getEditor().prosemirrorState.doc)!,
);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("table-0 is not a block container");
}
@@ -303,7 +303,7 @@ describe("Test updateBlock", () => {
getNodeById("table-0", getEditor().prosemirrorState.doc)!,
);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("table-0 is not a block container");
}
@@ -940,7 +940,7 @@ describe("Test updateBlock minimal steps", () => {
editor.prosemirrorState.doc,
)!,
);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("paragraph-with-styled-content is not a block container");
}
diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
index 6edfc434d5..5ebc4a619e 100644
--- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts
@@ -2,6 +2,7 @@ import {
Fragment,
type NodeType,
type Node as PMNode,
+ type Schema,
Slice,
} from "prosemirror-model";
import { TextSelection, Transaction } from "prosemirror-state";
@@ -27,7 +28,12 @@ import {
} from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getNodeById } from "../../../nodeUtil.js";
-import { getPmSchema } from "../../../pmUtil.js";
+import { getBlockSchema, getPmSchema } from "../../../pmUtil.js";
+import {
+ getContentContainerNodeTypes,
+ isContainerType,
+ isContentContainerNode,
+} from "../../../../schema/blocks/children.js";
// for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface
export const updateBlockCommand = <
@@ -82,40 +88,75 @@ export function updateBlockTr<
// Adds blockGroup node with child blocks if necessary.
- const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType];
- const newNodeType = pmSchema.nodes[block.type || blockInfo.blockNoteType];
+ const newBlockType = block.type || blockInfo.blockNoteType;
+ const newNodeType = pmSchema.nodes[newBlockType];
const newBnBlockNodeType = newNodeType.isInGroup("bnBlock")
? newNodeType
: pmSchema.nodes["blockContainer"];
- if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) {
- const replaceFromOffset =
- replaceFromPos !== undefined &&
- replaceFromPos > blockInfo.blockContent.beforePos &&
- replaceFromPos < blockInfo.blockContent.afterPos
- ? replaceFromPos - blockInfo.blockContent.beforePos - 1
- : undefined;
-
- const replaceToOffset =
- replaceToPos !== undefined &&
- replaceToPos > blockInfo.blockContent.beforePos &&
- replaceToPos < blockInfo.blockContent.afterPos
- ? replaceToPos - blockInfo.blockContent.beforePos - 1
- : undefined;
+ // The dispatch below is about *content* nodes, not block nodes. A container
+ // with its own content keeps that content in a generated node rather than in
+ // its own, so routing on the block's node type would send an update of its
+ // content to the full-replace arm — where it used to be silently dropped.
+ const isContentContainer = isContentContainerNode(blockInfo.bnBlock.node);
+
+ const replaceFromOffset =
+ blockInfo.blockContent &&
+ replaceFromPos !== undefined &&
+ replaceFromPos > blockInfo.blockContent.beforePos &&
+ replaceFromPos < blockInfo.blockContent.afterPos
+ ? replaceFromPos - blockInfo.blockContent.beforePos - 1
+ : undefined;
+
+ const replaceToOffset =
+ blockInfo.blockContent &&
+ replaceToPos !== undefined &&
+ replaceToPos > blockInfo.blockContent.beforePos &&
+ replaceToPos < blockInfo.blockContent.afterPos
+ ? replaceToPos - blockInfo.blockContent.beforePos - 1
+ : undefined;
+ if (
+ blockInfo.isWrappedBlock &&
+ blockInfo.bnBlock.node.type.name === "blockContainer" &&
+ newNodeType.isInGroup("blockContent")
+ ) {
updateChildren(block, tr, blockInfo);
// The code below determines the new content of the block.
// or "keep" to keep as-is
updateBlockContentNode(
block,
tr,
- oldNodeType,
+ pmSchema.nodes[blockInfo.blockNoteType],
newNodeType,
blockInfo,
replaceFromOffset,
replaceToOffset,
);
- } else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) {
+ } else if (
+ blockInfo.isWrappedBlock &&
+ isContentContainer &&
+ newBlockType === blockInfo.blockNoteType
+ ) {
+ // Same container, so its generated content node stays as it is — only what
+ // that node holds may change.
+ const contentNodeType = blockInfo.blockContent.node.type;
+
+ updateChildren(block, tr, blockInfo);
+ updateBlockContentNode(
+ block,
+ tr,
+ contentNodeType,
+ contentNodeType,
+ blockInfo,
+ replaceFromOffset,
+ replaceToOffset,
+ );
+ } else if (
+ !blockInfo.isWrappedBlock &&
+ newNodeType.isInGroup("bnBlock") &&
+ !getContentContainerNodeTypes(pmSchema, newBlockType)
+ ) {
updateChildren(block, tr, blockInfo);
// old node was a bnBlock type (like column or columnList) and new block as well
// No op, we just update the bnBlock below (at end of function) and have already updated the children
@@ -128,9 +169,21 @@ export function updateBlockTr<
// for this, we do a nodeToBlock on the existing block to get the children.
// it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case
const existingBlock = nodeToBlock(blockInfo.bnBlock.node, tr.doc);
+ const carried = carryOverContent(
+ existingBlock.content,
+ newBlockType,
+ pmSchema,
+ );
+ // If no children are passed in, use the existing block's — but only when
+ // there actually are some. `nodeToBlock` always emits an array, and an
+ // empty one would read as "explicitly childless", suppressing the seeding a
+ // container needs when converting from a childless block.
+ const children = [...carried.children, ...existingBlock.children];
+
const replacementNode = blockToNode(
{
- children: existingBlock.children, // if no children are passed in, use existing children
+ ...(carried.content ? { content: carried.content } : {}),
+ ...(children.length > 0 ? { children } : {}),
...block,
},
pmSchema,
@@ -158,6 +211,41 @@ export function updateBlockTr<
}
}
+function carryOverContent(
+ existingContent: Block["content"],
+ newBlockType: string,
+ pmSchema: Schema,
+): {
+ content?: PartialBlock["content"];
+ children: PartialBlock[];
+} {
+ const nothing = { children: [] };
+
+ if (!existingContent || !Array.isArray(existingContent)) {
+ return nothing;
+ }
+ if (existingContent.length === 0) {
+ return nothing;
+ }
+
+ const targetConfig = getBlockSchema(pmSchema)[newBlockType];
+ if (!targetConfig) {
+ return nothing;
+ }
+
+ if (targetConfig.content === "inline" || targetConfig.content === "plain") {
+ return { content: existingContent, children: [] };
+ }
+
+ if (targetConfig.content === "none" && isContainerType(targetConfig)) {
+ return {
+ children: [{ type: "paragraph", content: existingContent } as any],
+ };
+ }
+
+ return nothing;
+}
+
function updateBlockContentNode<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -521,7 +609,7 @@ function updateChildren<
Fragment.from(childNodes),
);
} else {
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
throw new Error("impossible");
}
// Inserts a new blockGroup containing the child nodes created earlier.
@@ -637,7 +725,7 @@ function restoreCellAnchor(
// 1) Resolve the table node in the current document
let tablePos = -1;
- if (blockInfo.isBlockContainer) {
+ if (blockInfo.isWrappedBlock) {
// Prefer the blockContent position when available (points directly at the PM table node)
tablePos = tr.mapping.map(blockInfo.blockContent.beforePos);
} else {
diff --git a/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap
new file mode 100644
index 0000000000..f8e3f971e1
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap
@@ -0,0 +1,34 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`children repair > unwraps a repair-configured container when only one non-empty child remains 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "cell-b-p",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "trailing",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts
new file mode 100644
index 0000000000..a1439dccfd
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts
@@ -0,0 +1,147 @@
+import type { Node, NodeType } from "prosemirror-model";
+
+import {
+ isContainerBlockNode,
+ isContainerNode,
+ isContentContainerNode,
+ isSealed,
+} from "../../../schema/blocks/children.js";
+
+/**
+ * Seal handling for the navigation helpers below. By default the helpers
+ * ignore seals — the block manipulation API crosses them freely, since an
+ * explicit placement is an intentional crossing. Gesture code (keyboard
+ * merges and moves) opts in with `respectSealed`: a sealed boundary means
+ * content never *implicitly* crosses it.
+ */
+type SealOpts = { respectSealed?: boolean };
+
+export function descendToLastInsertionPos(
+ container: Node,
+ containerBeforePos: number,
+ nodeType: NodeType,
+ opts?: SealOpts,
+): number | null {
+ if (opts?.respectSealed && isSealed(container)) {
+ return null;
+ }
+ const endPos = containerBeforePos + 1 + container.content.size;
+ if (container.contentMatchAt(container.childCount).matchType(nodeType)) {
+ return endPos;
+ }
+ const lastChild = container.lastChild;
+ if (lastChild && isContainerNode(lastChild.type)) {
+ return descendToLastInsertionPos(
+ lastChild,
+ endPos - lastChild.nodeSize,
+ nodeType,
+ opts,
+ );
+ }
+ return null;
+}
+
+// No seal handling: its only callers are API code, which crosses seals by
+// construction.
+export function descendToFirstInsertionPos(
+ container: Node,
+ containerBeforePos: number,
+ nodeType: NodeType,
+): number | null {
+ // A content container's children start after the content node.
+ if (isContentContainerNode(container)) {
+ return descendToFirstInsertionPos(
+ container.lastChild!,
+ containerBeforePos + 1 + container.firstChild!.nodeSize,
+ nodeType,
+ );
+ }
+
+ const startPos = containerBeforePos + 1;
+ if (container.contentMatchAt(0).matchType(nodeType)) {
+ return startPos;
+ }
+ const firstChild = container.firstChild;
+ if (firstChild && isContainerNode(firstChild.type)) {
+ return descendToFirstInsertionPos(firstChild, startPos, nodeType);
+ }
+ return null;
+}
+
+export function getFirstLeafBlock(
+ container: Node,
+ containerBeforePos: number,
+ opts?: SealOpts,
+): { node: Node; beforePos: number } | null {
+ // With `respectSealed`, a sealed container's leaf blocks are not reachable
+ // from outside.
+ if (opts?.respectSealed && isSealed(container)) {
+ return null;
+ }
+ if (isContentContainerNode(container)) {
+ return getFirstLeafBlock(
+ container.lastChild!,
+ containerBeforePos + 1 + container.firstChild!.nodeSize,
+ opts,
+ );
+ }
+
+ const firstChild = container.firstChild;
+ if (!firstChild) {
+ return null;
+ }
+ const firstChildBeforePos = containerBeforePos + 1;
+ if (isContainerNode(firstChild.type)) {
+ return getFirstLeafBlock(firstChild, firstChildBeforePos, opts);
+ }
+ return { node: firstChild, beforePos: firstChildBeforePos };
+}
+
+/**
+ * Climbs out of containers until it reaches a position where `nodeType` fits.
+ * `side` picks which edge of each climbed container to land on: `"before"` for
+ * moves that put a block above the containers it leaves (Backspace move-out),
+ * `"after"` for moves that put it below them (Enter-exit).
+ */
+export function ascendToInsertablePos(
+ doc: Node,
+ pos: number,
+ nodeType: NodeType,
+ opts?: SealOpts,
+ side: "before" | "after" = "before",
+): number | null {
+ for (;;) {
+ const $pos = doc.resolve(pos);
+ const parent = $pos.node();
+ if (parent.contentMatchAt($pos.index()).matchType(nodeType)) {
+ return pos;
+ }
+ // A content-bearing container is climbed out of too: the ascent may sit
+ // right after its `__children` node, where only that node's siblings fit.
+ if ($pos.depth > 0 && isContainerBlockNode(parent)) {
+ // With `respectSealed`, climbing out of a sealed container would move
+ // content across its boundary.
+ if (opts?.respectSealed && isSealed(parent)) {
+ return null;
+ }
+ pos = side === "before" ? $pos.before() : $pos.after();
+ continue;
+ }
+ return null;
+ }
+}
+
+export function getAncestorContainers(
+ doc: Node,
+ pos: number,
+): { id: string; depth: number }[] {
+ const $pos = doc.resolve(pos);
+ const containers: { id: string; depth: number }[] = [];
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ const ancestor = $pos.node(depth);
+ if (isContainerBlockNode(ancestor) && ancestor.attrs.id) {
+ containers.push({ id: ancestor.attrs.id, depth });
+ }
+ }
+ return containers;
+}
diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts
new file mode 100644
index 0000000000..b4633d5ead
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts
@@ -0,0 +1,61 @@
+import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { isContainerType } from "../../../schema/blocks/children.js";
+
+export type ContainerUIInfo = {
+ containerTypes: ReadonlySet;
+ draggableContainerTypes: ReadonlySet;
+ /**
+ * Regular (non-container) block types whose spec sets `meta.draggable:
+ * false`. Container types are tracked separately in
+ * `draggableContainerTypes`, because they're identified in the DOM by
+ * `data-node-type` while regular blocks all share the `blockContainer` node
+ * and are identified by their content's `data-content-type`.
+ */
+ nonDraggableBlockTypes: ReadonlySet;
+ containerSelector: string | null;
+};
+
+function buildSelector(types: ReadonlySet): string | null {
+ if (types.size === 0) {
+ return null;
+ }
+ return [...types].map((type) => `[data-node-type="${type}"]`).join(",");
+}
+
+export function getContainerUIInfo(
+ editor: Pick, "schema">,
+): ContainerUIInfo {
+ const containerTypes = new Set();
+ const draggableContainerTypes = new Set();
+ const nonDraggableBlockTypes = new Set();
+
+ for (const [type, spec] of Object.entries(
+ editor.schema.blockSpecs as Record<
+ string,
+ {
+ config: any;
+ implementation?: { meta?: { draggable?: boolean } };
+ }
+ >,
+ )) {
+ const draggable = spec.implementation?.meta?.draggable !== false;
+
+ if (!isContainerType(spec.config)) {
+ if (!draggable) {
+ nonDraggableBlockTypes.add(type);
+ }
+ continue;
+ }
+ containerTypes.add(type);
+ if (draggable) {
+ draggableContainerTypes.add(type);
+ }
+ }
+
+ return {
+ containerTypes,
+ draggableContainerTypes,
+ nonDraggableBlockTypes,
+ containerSelector: buildSelector(containerTypes),
+ };
+}
diff --git a/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts
new file mode 100644
index 0000000000..4ff79f0b9f
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.browser.test.ts
@@ -0,0 +1,546 @@
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { containerSchema } from "./containers.fixture.js";
+
+// The halves of the container-block story that need a real browser, split off
+// from the (node) `containers.test.ts`:
+//
+// - the keymap, which tiptap can only reach through a mounted view. These used
+// to synthesize a `KeyboardEvent` and hand it to `handleKeyDown` directly,
+// which passes whether or not a real keypress ever gets there. Here the
+// editor is mounted and focused and the keys are pressed for real.
+// - HTML/markdown serialization, which builds and parses real DOM.
+
+const schema = containerSchema;
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+let div: HTMLElement;
+
+beforeAll(() => {
+ div = document.createElement("div");
+ document.body.appendChild(div);
+ editor = BlockNoteEditor.create({ schema });
+ editor.mount(div);
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ div.remove();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ { id: "p-1", type: "paragraph", content: "Paragraph 1" },
+ ]);
+});
+
+/** Puts the caret where the test wants it and presses the key for real. */
+async function pressKey(
+ key: string,
+ at: { block: string; placement: "start" | "end" },
+) {
+ editor.setTextCursorPosition(at.block, at.placement);
+ editor.focus();
+ await userEvent.keyboard(`{${key}}`);
+}
+
+describe("children keyboard handling", () => {
+ it("Enter on an empty last child escapes the container", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "Hello" },
+ { id: "c-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ await pressKey("Enter", { block: "c-p-1", placement: "end" });
+
+ // The empty block has moved out of the callout, becoming its next sibling.
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]);
+ expect(editor.document.map((block) => block.type)).toEqual([
+ "callout",
+ "paragraph",
+ "paragraph",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "c-0",
+ "c-p-1",
+ "trailing",
+ ]);
+ // The caret came with it, so typing continues outside the container.
+ expect(editor.getTextCursorPosition().block.id).toBe("c-p-1");
+ });
+
+ it("Enter escape ascends past levels that can't hold the block", async () => {
+ // A grid holds only cells, so a block escaping the last cell can't stop
+ // at the grid level — it lands below the grid itself.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "g-c-0",
+ children: [{ id: "g-p-0", type: "paragraph", content: "A" }],
+ },
+ {
+ type: "gridCell",
+ id: "g-c-1",
+ children: [
+ { id: "g-p-1", type: "paragraph", content: "B" },
+ { id: "g-p-2", type: "paragraph", content: "" },
+ ],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ await pressKey("Enter", { block: "g-p-2", placement: "end" });
+
+ expect(editor.getBlock("g-c-1")!.children.map((child) => child.id)).toEqual(
+ ["g-p-1"],
+ );
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "g-0",
+ "g-p-2",
+ "trailing",
+ ]);
+ expect(editor.getTextCursorPosition().block.id).toBe("g-p-2");
+ });
+
+ it("Enter on an empty block mid-container stays inside", async () => {
+ // The escape gesture is strictly "at the end of the container": an empty
+ // block with siblings after it never ejects.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "Hello" },
+ { id: "c-p-1", type: "paragraph", content: "" },
+ { id: "c-p-2", type: "paragraph", content: "World" },
+ ],
+ },
+ ]);
+
+ await pressKey("Enter", { block: "c-p-1", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual(["c-0"]);
+ expect(editor.getBlock("c-0")!.children).toHaveLength(4);
+ });
+
+ it("Backspace at the start of a container's first child moves it out", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Backspace", { block: "c-p-0", placement: "start" });
+
+ // The first child has moved out, above the callout, with its text intact.
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-1",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "before",
+ "c-p-0",
+ "c-0",
+ ]);
+ expect(editor.getBlock("c-p-0")!.content).toEqual([
+ { type: "text", text: "First", styles: {} },
+ ]);
+ });
+
+ it("Backspace at the start of a block after a container moves it inside", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Backspace", { block: "after", placement: "start" });
+
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-0",
+ "after",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual(["c-0"]);
+ expect(editor.getBlock("after")!.content).toEqual([
+ { type: "text", text: "After", styles: {} },
+ ]);
+ });
+
+ it("Delete at the end of a block before a container pulls its first child out", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Delete", { block: "before", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "before",
+ "c-p-0",
+ "c-0",
+ ]);
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-1",
+ ]);
+ });
+
+ it("Delete at the end of a container's last child pulls the next block in", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Delete", { block: "c-p-0", placement: "end" });
+
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-0",
+ "after",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual(["c-0"]);
+ });
+});
+
+// The mirror suite for `boundary: "sealed"`: every implicit crossing the open
+// cases above demonstrate must be a no-op on a sealed container, while edits
+// *within* the container keep working.
+describe("sealed boundary keyboard handling", () => {
+ function documentShape() {
+ return editor.document.map((block) => [
+ block.id,
+ block.children.map((child) => child.id),
+ ]);
+ }
+
+ it("Backspace at the start of the first child does not move it out", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [
+ { id: "s-p-0", type: "paragraph", content: "First" },
+ { id: "s-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Backspace", { block: "s-p-0", placement: "start" });
+
+ expect(documentShape()).toEqual(shape);
+ // The keystroke was swallowed, so the caret also stayed put.
+ expect(editor.getTextCursorPosition().block.id).toBe("s-p-0");
+ });
+
+ it("Backspace at the start of the second child still merges within", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [
+ { id: "s-p-0", type: "paragraph", content: "First" },
+ { id: "s-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Backspace", { block: "s-p-1", placement: "start" });
+
+ // Asserted as a change, so the suite can't pass by keystrokes never
+ // arriving: the two children merged into one block.
+ const children = editor.getBlock("s-0")!.children;
+ expect(children).toHaveLength(1);
+ expect(children[0].content).toEqual([
+ { type: "text", text: "FirstSecond", styles: {} },
+ ]);
+ });
+
+ it("Backspace after the container does not move the block inside", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [{ id: "s-p-0", type: "paragraph", content: "Sealed" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Backspace", { block: "after", placement: "start" });
+
+ expect(documentShape()).toEqual(shape);
+ // With no way in, the fallback node-selects the container, so a second
+ // Backspace deletes it explicitly.
+ const selection = editor.transact((tr) => tr.selection);
+ expect("node" in selection && (selection.node as any).type.name).toBe(
+ "sealedBox",
+ );
+ });
+
+ it("Backspace after the container does not replace its trailing empty block", async () => {
+ // The previous case falls through the "descend into the previous
+ // container" branch; this one targets the "previous block is empty"
+ // branch, which descends to the *bottom nested* block — here the empty
+ // paragraph inside the sealed container.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [
+ { id: "s-p-0", type: "paragraph", content: "Sealed" },
+ { id: "s-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Backspace", { block: "after", placement: "start" });
+
+ expect(documentShape()).toEqual(shape);
+ });
+
+ it("Delete before the container does not pull its first child out", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [
+ { id: "s-p-0", type: "paragraph", content: "First" },
+ { id: "s-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Delete", { block: "before", placement: "end" });
+
+ expect(documentShape()).toEqual(shape);
+ });
+
+ it("Delete at the end of the last child does not pull the next block in", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [{ id: "s-p-0", type: "paragraph", content: "Sealed" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Delete", { block: "s-p-0", placement: "end" });
+
+ expect(documentShape()).toEqual(shape);
+ });
+
+ it("Delete at the end of a nested last block does not reach past the boundary", async () => {
+ // The climb in "delete next block at any level" starts from a *nested*
+ // block, where the direct last-child branch doesn't apply — without its
+ // own gate, Delete here would consume "after" into the sealed container.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [
+ {
+ id: "s-p-0",
+ type: "paragraph",
+ content: "Parent",
+ children: [{ id: "s-n-0", type: "paragraph", content: "Nested" }],
+ },
+ ],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Delete", { block: "s-n-0", placement: "end" });
+
+ expect(documentShape()).toEqual(shape);
+ });
+
+ it("Backspace after an isolated container of sealed ones selects it", async () => {
+ // The table shape: the grid itself is not sealed, but everywhere a
+ // descent could land is sealed — so the block can't move in, and the
+ // grid is selected for an explicit second-Backspace delete instead.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedGrid",
+ id: "g-0",
+ children: [
+ {
+ type: "sealedBox",
+ id: "g-c-0",
+ children: [{ id: "g-p-0", type: "paragraph", content: "Cell" }],
+ },
+ ],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Backspace", { block: "after", placement: "start" });
+
+ expect(documentShape()).toEqual(shape);
+ const selection = editor.transact((tr) => tr.selection);
+ expect("node" in selection && (selection.node as any).type.name).toBe(
+ "sealedGrid",
+ );
+ });
+
+ it("Delete before an isolated container of sealed ones does not pull from it", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "sealedGrid",
+ id: "g-0",
+ children: [
+ {
+ type: "sealedBox",
+ id: "g-c-0",
+ children: [{ id: "g-p-0", type: "paragraph", content: "Cell" }],
+ },
+ ],
+ },
+ ]);
+ const shape = documentShape();
+
+ await pressKey("Delete", { block: "before", placement: "end" });
+
+ expect(documentShape()).toEqual(shape);
+ });
+
+ it("Enter on an empty last child stays inside the container", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedBox",
+ id: "s-0",
+ children: [
+ { id: "s-p-0", type: "paragraph", content: "Hello" },
+ { id: "s-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ await pressKey("Enter", { block: "s-p-1", placement: "end" });
+
+ // A new block was created inside — a sealed boundary means Enter never
+ // moves content out, so there is no double-Enter escape here.
+ expect(editor.document.map((block) => block.type)).toEqual([
+ "sealedBox",
+ "paragraph",
+ ]);
+ const children = editor.getBlock("s-0")!.children;
+ expect(children).toHaveLength(3);
+ expect(editor.getTextCursorPosition().block.id).toBe(children[2].id);
+ });
+});
+
+describe("children conversion", () => {
+ it("round-trips a container through full (internal) HTML", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout" as const,
+ id: "c-0",
+ props: { flavor: "warning" as const },
+ children: [
+ { id: "c-p-0", type: "paragraph" as const, content: "In callout" },
+ ],
+ },
+ ]);
+
+ const html = editor.blocksToFullHTML(editor.document);
+ expect(html).toContain('data-node-type="callout"');
+
+ const parsed = editor.tryParseHTMLToBlocks(html);
+ expect(parsed[0].type).toBe("callout");
+ expect((parsed[0].props as any).flavor).toBe("warning");
+ expect(parsed[0].children).toHaveLength(1);
+ expect(parsed[0].children[0].type).toBe("paragraph");
+ });
+
+ it("exports containers to external HTML with type + prop attributes", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ props: { flavor: "warning" },
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ ]);
+
+ const html = editor.blocksToHTMLLossy(editor.document);
+ expect(html).toContain('data-node-type="callout"');
+ expect(html).toContain('data-flavor="warning"');
+ // Container output is not wrapped in a blockContent div.
+ expect(html).not.toContain("bn-block-content");
+ });
+
+ it("flattens containers to their children in markdown export", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "In callout" },
+ { id: "c-p-1", type: "heading", content: "Heading in callout" },
+ ],
+ },
+ ]);
+
+ const markdown = editor.blocksToMarkdownLossy(editor.document);
+ expect(markdown).toContain("In callout");
+ expect(markdown).toContain("# Heading in callout");
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/containers.fixture.ts b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts
new file mode 100644
index 0000000000..dff1c8fa98
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.fixture.ts
@@ -0,0 +1,119 @@
+import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js";
+import { createBlockSpec } from "../../../schema/blocks/createSpec.js";
+
+const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+};
+
+const Callout = createBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ },
+ content: "none",
+ children: {
+ allow: "any",
+ default: [{ type: "paragraph" }],
+ },
+ },
+ { render: renderDiv },
+)();
+
+// A compartment-style container (a table cell): content never implicitly
+// crosses its boundary.
+const SealedBox = createBlockSpec(
+ {
+ type: "sealedBox" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", boundary: "sealed" },
+ },
+ { render: renderDiv },
+)();
+
+// An open container: everything crosses its edge (PM `isolating: false`),
+// like a column list.
+const OpenBox = createBlockSpec(
+ {
+ type: "openBox" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", boundary: "open" },
+ },
+ { render: renderDiv },
+)();
+
+// The table shape: an *isolated* container (the default) that holds only
+// sealed ones, so any descent into it bottoms out at a sealed boundary.
+const SealedGrid = createBlockSpec(
+ {
+ type: "sealedGrid" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: ["sealedBox"] },
+ },
+ { render: renderDiv },
+)();
+
+const Grid = createBlockSpec(
+ {
+ type: "grid" as const,
+ propSchema: {},
+ content: "none",
+ children: {
+ allow: ["gridCell"],
+ min: 2,
+ whenEmptied: "unwrap",
+ },
+ },
+ { render: renderDiv },
+)();
+
+const GridCell = createBlockSpec(
+ {
+ type: "gridCell" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any" },
+ placement: "containerOnly",
+ },
+ { render: renderDiv },
+)();
+
+// A refill (default `whenEmptied`) container whose `default` has content:
+// dropping below `min` tops it back up from the unconsumed tail of `default`.
+const SeededPair = createBlockSpec(
+ {
+ type: "seededPair" as const,
+ propSchema: {},
+ content: "none",
+ children: {
+ allow: "any",
+ min: 2,
+ default: [
+ { type: "paragraph", content: "Seed A" },
+ { type: "paragraph", content: "Seed B" },
+ ],
+ },
+ },
+ { render: renderDiv },
+)();
+
+export const containerSchema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: Callout,
+ sealedBox: SealedBox,
+ openBox: OpenBox,
+ sealedGrid: SealedGrid,
+ grid: Grid,
+ gridCell: GridCell,
+ seededPair: SeededPair,
+ } as const,
+});
diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts
new file mode 100644
index 0000000000..d2ade17d5e
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts
@@ -0,0 +1,411 @@
+// @vitest-environment node
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { containerSchema } from "./containers.fixture.js";
+
+type PartialBlock = (typeof containerSchema)["PartialBlock"];
+
+// Document-model behaviour of container blocks: seeding, schema enforcement,
+// repair and selection. All of it is `Block` JSON in and `Block` JSON out, so
+// the editor stays headless and this suite runs with no DOM at all.
+//
+// The halves that genuinely need one — the keymap (which tiptap can only reach
+// through a mounted view) and HTML/markdown serialization (which builds real
+// DOM) — live in `containers.browser.test.ts`.
+
+const schema = containerSchema;
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+
+beforeAll(() => {
+ editor = BlockNoteEditor.create({ schema });
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ { id: "p-1", type: "paragraph", content: "Paragraph 1" },
+ ]);
+});
+
+describe("children insertion & seeding", () => {
+ it("seeds `default` when inserted without children", () => {
+ editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after");
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ });
+
+ // The minimal container config used to be a landmine: `min` defaults to 1,
+ // nothing seeded it, and the most obvious insert threw a raw ProseMirror
+ // `RangeError: Invalid content for node ...`.
+ it("fills a container that has no `default` rather than throwing", () => {
+ expect(() =>
+ editor.insertBlocks([{ type: "sealedBox", id: "b-0" }], "p-1", "after"),
+ ).not.toThrow();
+
+ const box = editor.getBlock("b-0")!;
+ expect(box.children).toHaveLength(1);
+ expect(box.children[0].type).toBe("paragraph");
+ });
+
+ it("gives auto-filled children real ids", () => {
+ // Auto-filled nodes come straight from the schema with `id: null`, and the
+ // UniqueID plugin never sees them — `insertBlocks` converts back through
+ // `nodeToBlock` before the transaction is dispatched.
+ editor.insertBlocks([{ type: "sealedBox", id: "b-0" }], "p-1", "after");
+
+ const child = editor.getBlock("b-0")!.children[0];
+ expect(child.id).toBeTruthy();
+ expect(editor.getBlock(child.id)).toBeDefined();
+ });
+
+ it("does not re-seed a container round-tripped through the document", () => {
+ editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after");
+ const inserted = editor.getBlock("c-0")!;
+
+ // `nodeToBlock` always emits an array, so a round-trip must not read an
+ // empty one as "unspecified" and seed on top of it.
+ editor.replaceBlocks([inserted], [inserted]);
+
+ expect(editor.getBlock("c-0")!.children).toHaveLength(
+ inserted.children.length,
+ );
+ });
+
+ // `children: []` is a caller asking for a container with no children, which
+ // a `min: 1` container cannot be. It used to be taken at face value, which
+ // built a node below its minimum: `insertBlocks` then threw a raw
+ // `Invalid content for node callout: <>` from its `node.check()`.
+ it("fills an explicitly empty `children` array up to `min`", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [{ type: "callout", id: "c-0", children: [] }],
+ "p-1",
+ "after",
+ ),
+ ).not.toThrow();
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ expect(callout.children[0].id).toBeTruthy();
+ });
+
+ it("does not pad explicit children that already satisfy the config", () => {
+ editor.insertBlocks(
+ [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [{ type: "gridCell" }, { type: "gridCell" }],
+ },
+ ],
+ "p-1",
+ "after",
+ );
+
+ expect(editor.getBlock("g-0")!.children).toHaveLength(2);
+ });
+
+ // A container that unwraps as it empties out is the one case explicit
+ // children are *not* padded: adding a second column to a one-column
+ // columnList would invent content the next repair pass deletes anyway.
+ it("refuses rather than pads a container that unwraps when emptied", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [{ type: "grid", id: "g-1", children: [{ type: "gridCell" }] }],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+
+ // A pure container has no content of its own, but the block it replaces did
+ // — so that content becomes its first child rather than being dropped.
+ it("carries content into the first child when converting via updateBlock", () => {
+ editor.updateBlock("p-1", { type: "callout" });
+
+ const callout = editor.document[1];
+ expect(callout.type).toBe("callout");
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ expect(callout.children[0].content).toEqual([
+ { type: "text", text: "Paragraph 1", styles: {} },
+ ]);
+ });
+
+ it("seeds `default` when converting an empty block via updateBlock", () => {
+ editor.updateBlock("p-1", { content: [] });
+ editor.updateBlock("p-1", { type: "callout" });
+
+ const callout = editor.document[1];
+ expect(callout.type).toBe("callout");
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ expect(callout.children[0].content).toEqual([]);
+ });
+
+ it("accepts arbitrary block children, including nested containers", () => {
+ editor.insertBlocks(
+ [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { type: "heading", content: "In callout" },
+ {
+ type: "callout",
+ id: "c-1",
+ children: [{ type: "paragraph", content: "Nested" }],
+ },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ );
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children.map((child) => child.type)).toEqual([
+ "heading",
+ "callout",
+ ]);
+ expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph");
+ });
+
+ it("rejects non-allowed children for a restricted container", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [
+ {
+ type: "grid",
+ children: [
+ { type: "paragraph", content: "not a cell" },
+ { type: "paragraph", content: "not a cell" },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+
+ it("accepts allowed children for a restricted container", () => {
+ editor.insertBlocks(
+ [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ children: [{ type: "paragraph", content: "Cell A" }],
+ },
+ {
+ type: "gridCell",
+ children: [{ type: "paragraph", content: "Cell B" }],
+ },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ );
+
+ const grid = editor.getBlock("g-0")!;
+ expect(grid.children.map((child) => child.type)).toEqual([
+ "gridCell",
+ "gridCell",
+ ]);
+ });
+
+ it("rejects inserting a containerOnly block at the document root", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [{ type: "gridCell", children: [{ type: "paragraph" }] }],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+
+ // The `allow: "any"` wildcard compiles to the containers placeable
+ // anywhere, so a containerOnly block only fits where a parent names it
+ // explicitly.
+ it("rejects a containerOnly block under a wildcard-allow container", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [
+ {
+ type: "callout",
+ children: [{ type: "gridCell", children: [{ type: "paragraph" }] }],
+ },
+ ],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+});
+
+describe("boundary", () => {
+ it("derives ProseMirror `isolating` from `boundary`", () => {
+ const nodes = editor.pmSchema.nodes;
+ expect(nodes["openBox"].spec.isolating).toBe(false);
+ // "isolated" is the default...
+ expect(nodes["callout"].spec.isolating).toBe(true);
+ // ...and "sealed" also isolates.
+ expect(nodes["sealedBox"].spec.isolating).toBe(true);
+ });
+});
+
+// `initialContent` is the only path that builds a document without validating
+// it: `blockToNode` is deliberately lenient, and `createDocument` builds from
+// JSON. So blocks `insertBlocks` rejects used to load happily, and a container
+// below its `min` stayed below it for the life of the document.
+describe("initialContent enforcement", () => {
+ const createWith = (initialContent: PartialBlock[]) => {
+ return BlockNoteEditor.create({ schema, initialContent });
+ };
+
+ it("fills an explicitly empty `children` array up to `min`", () => {
+ const loaded = createWith([{ type: "callout", id: "c-0", children: [] }]);
+
+ const callout = loaded.getBlock("c-0")!;
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+
+ loaded._tiptapEditor.destroy();
+ });
+
+ it("rejects a container it cannot legally fill", () => {
+ expect(() =>
+ createWith([
+ { type: "grid", id: "g-0", children: [{ type: "gridCell" }] },
+ ]),
+ ).toThrow(/initialContent/);
+ });
+});
+
+describe("children repair", () => {
+ it("keeps a default container when its only child is removed (refilled)", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["c-p-0"]);
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout).toBeDefined();
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ expect(callout.children[0].content).toEqual([]);
+ });
+
+ it("refills below `min` from the unconsumed tail of `default`", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "seededPair",
+ id: "s-0",
+ children: [
+ { id: "s-p-0", type: "paragraph", content: "Kept" },
+ { id: "s-p-1", type: "paragraph", content: "Removed" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["s-p-1"]);
+
+ // One child survives (k = 1), so the top-up seeds `default[1]` — not an
+ // empty paragraph, and not `default[0]`.
+ const pair = editor.getBlock("s-0")!;
+ expect(pair.children).toHaveLength(2);
+ expect(pair.children[0].content).toEqual([
+ { type: "text", text: "Kept", styles: {} },
+ ]);
+ expect(pair.children[1].content).toEqual([
+ { type: "text", text: "Seed B", styles: {} },
+ ]);
+ });
+
+ it("unwraps a repair-configured container when only one non-empty child remains", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "cell-a",
+ children: [{ id: "cell-a-p", type: "paragraph", content: "A" }],
+ },
+ {
+ type: "gridCell",
+ id: "cell-b",
+ children: [{ id: "cell-b-p", type: "paragraph", content: "B" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["cell-a-p"]);
+
+ expect(editor.document).toMatchSnapshot();
+ // The grid has been unwrapped: cell B's content replaced it.
+ expect(editor.getBlock("g-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "cell-b-p",
+ "trailing",
+ ]);
+ });
+});
+
+describe("children selection", () => {
+ it("getSelectionCutBlocks handles selections reaching into a container", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ editor.setSelection("before", "c-p-0");
+
+ // Previously threw "unexpected" for any partial selection touching a
+ // container (breaking comments/AI selection handling).
+ const result = editor.getSelectionCutBlocks();
+ expect(result.blocks.length).toBeGreaterThanOrEqual(1);
+ expect(result.blocks.map((block) => block.id)).toContain("before");
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts
new file mode 100644
index 0000000000..13bbf75b4b
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/contentContainers.browser.test.ts
@@ -0,0 +1,412 @@
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { contentContainerSchema } from "./contentContainers.fixture.js";
+
+// The keymap half of the content-bearing container story, split off from the
+// (node) `contentContainers.test.ts`. Tiptap can only reach `handleKeyDown`
+// through a mounted view, and these used to synthesize a `KeyboardEvent` and
+// call the handler directly — which passes whether or not a real keypress ever
+// gets there. Here the editor is mounted and focused and the keys are pressed
+// for real.
+//
+// Not ported: "Enter at the end of the title creates a new first child". That
+// exact behaviour, caret included, is already covered against a real app in
+// `tests/src/end-to-end/containerblocks/containerblocks.test.tsx`
+// ("Creates a first child on Enter at the end of a container's content").
+
+const schema = contentContainerSchema;
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+let div: HTMLElement;
+
+beforeAll(() => {
+ div = document.createElement("div");
+ document.body.appendChild(div);
+ editor = BlockNoteEditor.create({ schema });
+ editor.mount(div);
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ div.remove();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+});
+
+/** Puts the caret where the test wants it and presses the key for real. */
+async function pressKey(
+ key: string,
+ at: { block: string; placement: "start" | "end"; offset?: number },
+) {
+ editor.setTextCursorPosition(at.block, at.placement);
+ if (at.offset) {
+ editor._tiptapEditor.commands.setTextSelection(
+ editor._tiptapEditor.state.selection.from + at.offset,
+ );
+ }
+ editor.focus();
+ await userEvent.keyboard(`{${key}}`);
+}
+
+describe("content-bearing container: keyboard", () => {
+ it("Backspace at the start of the title unwraps the container", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [
+ { id: "t-p-0", type: "paragraph", content: "First" },
+ { id: "t-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Backspace", { block: "t-0", placement: "start" });
+
+ // The title became a paragraph's content and the children came along as
+ // that paragraph's children — nothing was destroyed.
+ const unwrapped = editor.document[1];
+ expect(unwrapped.type).toBe("paragraph");
+ expect(unwrapped.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(unwrapped.children.map((child) => child.id)).toEqual([
+ "t-p-0",
+ "t-p-1",
+ ]);
+ });
+
+ it("Backspace at the start of the first child merges it into the title", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [
+ { id: "t-p-0", type: "paragraph", content: "First" },
+ { id: "t-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Backspace", { block: "t-p-0", placement: "start" });
+
+ const toggle = editor.getBlock("t-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "TitleFirst", styles: {} },
+ ]);
+ expect(toggle.children.map((child) => child.id)).toEqual(["t-p-1"]);
+ });
+
+ it("Backspace after a sealed container selects it instead of merging into its title", async () => {
+ // A content-bearing container is an ordinary merge target (its title is
+ // `inline*`), so without the boundary the paragraph would merge into it.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedToggle",
+ id: "st-0",
+ content: "Title",
+ children: [{ id: "st-p-0", type: "paragraph", content: "First" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Backspace", { block: "after", placement: "start" });
+
+ const toggle = editor.getBlock("st-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual(["st-0", "after"]);
+ const selection = editor.transact((tr) => tr.selection);
+ expect("node" in selection && (selection.node as any).type.name).toBe(
+ "sealedToggle",
+ );
+ });
+
+ it("Delete before a sealed container selects it instead of merging it in", async () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "sealedToggle",
+ id: "st-0",
+ content: "Title",
+ children: [{ id: "st-p-0", type: "paragraph", content: "First" }],
+ },
+ ]);
+
+ await pressKey("Delete", { block: "before", placement: "end" });
+
+ expect(editor.getBlock("before")!.content).toEqual([
+ { type: "text", text: "Before", styles: {} },
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "before",
+ "st-0",
+ ]);
+ const selection = editor.transact((tr) => tr.selection);
+ expect("node" in selection && (selection.node as any).type.name).toBe(
+ "sealedToggle",
+ );
+ });
+
+ it("Enter on an empty last child of a sealed container stays inside", async () => {
+ // The sealed guard has to resolve the config through the generated
+ // `__children` node name — a plain block-type lookup misses it and lets
+ // Enter escape the sealed boundary.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedToggle",
+ id: "st-0",
+ content: "Title",
+ children: [
+ { id: "st-p-0", type: "paragraph", content: "First" },
+ { id: "st-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ await pressKey("Enter", { block: "st-p-1", placement: "end" });
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "st-0",
+ "trailing",
+ ]);
+ const children = editor.getBlock("st-0")!.children;
+ expect(children).toHaveLength(3);
+ expect(editor.getTextCursorPosition().block.id).toBe(children[2].id);
+ });
+
+ it("Backspace after a container with an empty body moves the block into it", async () => {
+ // An empty-bodied content container is its own bottom nested block, and
+ // merging into it would stitch across its required `__children` node —
+ // so the block moves inside instead, like after any non-sealed container.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "optionalToggle",
+ id: "ot-0",
+ content: "Title",
+ children: [],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Backspace", { block: "after", placement: "start" });
+
+ const toggle = editor.getBlock("ot-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(toggle.children.map((child) => child.id)).toEqual(["after"]);
+ expect(editor.document.map((block) => block.id)).toEqual(["ot-0"]);
+ });
+
+ it("Delete before a container merges its title in and un-nests its children", async () => {
+ // The forward merge no longer stitches across the container boundary;
+ // instead the container is dissolved: title into the previous block,
+ // children out to the top level.
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [{ id: "t-p-0", type: "paragraph", content: "First" }],
+ },
+ ]);
+
+ await pressKey("Delete", { block: "before", placement: "end" });
+
+ expect(editor.getBlock("before")!.content).toEqual([
+ { type: "text", text: "BeforeTitle", styles: {} },
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "before",
+ "t-p-0",
+ ]);
+ });
+
+ it("Backspace still merges a sealed container's first child into its title", async () => {
+ // Within the boundary nothing changes: the title and children are both
+ // inside it.
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "sealedToggle",
+ id: "st-0",
+ content: "Title",
+ children: [
+ { id: "st-p-0", type: "paragraph", content: "First" },
+ { id: "st-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Backspace", { block: "st-p-0", placement: "start" });
+
+ const toggle = editor.getBlock("st-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "TitleFirst", styles: {} },
+ ]);
+ expect(toggle.children.map((child) => child.id)).toEqual(["st-p-1"]);
+ });
+
+ it("Backspace at the start of a pure container's first child still moves it out", async () => {
+ // The control for the two cases above: a container with no title of its own
+ // must keep the old behaviour.
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Backspace", { block: "c-p-0", placement: "start" });
+
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-1",
+ ]);
+ expect(editor.document.map((block) => block.id)[1]).toBe("c-p-0");
+ });
+
+ it("Enter mid-title splits, with the tail becoming the first child", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "TitleTail",
+ children: [{ id: "t-p-0", type: "paragraph", content: "First" }],
+ },
+ ]);
+
+ await pressKey("Enter", { block: "t-0", placement: "start", offset: 5 });
+
+ const toggle = editor.getBlock("t-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(toggle.children[0].content).toEqual([
+ { type: "text", text: "Tail", styles: {} },
+ ]);
+ expect(toggle.children[1].id).toBe("t-p-0");
+ });
+
+ it("Enter on an empty last child still escapes the container", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [
+ { id: "t-p-0", type: "paragraph", content: "First" },
+ { id: "t-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ await pressKey("Enter", { block: "t-p-1", placement: "end" });
+
+ expect(editor.getBlock("t-0")!.children.map((child) => child.id)).toEqual([
+ "t-p-0",
+ ]);
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "t-0",
+ "t-p-1",
+ "trailing",
+ ]);
+ // The container kept its own title through the escape.
+ expect(editor.getBlock("t-0")!.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ });
+
+ it("Delete at the end of the title pulls the first child's content up", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [
+ { id: "t-p-0", type: "paragraph", content: "First" },
+ { id: "t-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+
+ await pressKey("Delete", { block: "t-0", placement: "end" });
+
+ const toggle = editor.getBlock("t-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "TitleFirst", styles: {} },
+ ]);
+ expect(toggle.children.map((child) => child.id)).toEqual(["t-p-1"]);
+ });
+
+ it("Delete at the end of the title of a container that must keep a child", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [{ id: "t-p-0", type: "paragraph", content: "Only" }],
+ },
+ ]);
+
+ await pressKey("Delete", { block: "t-0", placement: "end" });
+
+ const toggle = editor.getBlock("t-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "TitleOnly", styles: {} },
+ ]);
+ // `min: 1` — the schema refills the emptied children node.
+ expect(toggle.children).toHaveLength(1);
+ expect(toggle.children[0].content).toEqual([]);
+ });
+
+ it("Delete in a childless container does not throw", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "optionalToggle",
+ id: "t-0",
+ content: "Title",
+ children: [],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+
+ await pressKey("Delete", { block: "t-0", placement: "end" });
+
+ // Delete at the end of a childless container's title reaches past it to the
+ // next block. What matters is that it doesn't throw; asserted as a real
+ // change so the test can't pass by the keypress never arriving.
+ expect(editor.getBlock("t-0")!.content).toEqual([
+ { type: "text", text: "TitleAfter", styles: {} },
+ ]);
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts
new file mode 100644
index 0000000000..ac7de258e4
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/contentContainers.fixture.ts
@@ -0,0 +1,108 @@
+import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js";
+import { createBlockSpec } from "../../../schema/blocks/createSpec.js";
+
+// The content-bearing container schema shared by `contentContainers.test.ts`
+// (node: document model) and `contentContainers.browser.test.ts` (real browser:
+// keymap), so both halves are describing the same blocks.
+
+const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+};
+
+// The toggle shape: a container with its own inline content (its "title") as
+// well as children. `min` defaults to 1, so it always keeps at least one
+// child.
+const Toggle = createBlockSpec(
+ {
+ type: "toggle" as const,
+ propSchema: { open: { default: true } },
+ content: "inline",
+ children: { allow: "any" },
+ },
+ { render: renderDiv },
+)();
+
+// The same, but allowed to hold no children at all — the `min: 0` shape that
+// has no addressable child to fall back on.
+const OptionalToggle = createBlockSpec(
+ {
+ type: "optionalToggle" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "any", min: 0 },
+ },
+ { render: renderDiv },
+)();
+
+// A content-bearing container that unwraps as it empties out, paired with the
+// pure container below. Repair has to treat these two identically apart from
+// the title.
+const TitledGrid = createBlockSpec(
+ {
+ type: "titledGrid" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "any", min: 2, whenEmptied: "unwrap" },
+ },
+ { render: renderDiv },
+)();
+
+const PureGrid = createBlockSpec(
+ {
+ type: "pureGrid" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", min: 2, whenEmptied: "unwrap" },
+ },
+ { render: renderDiv },
+)();
+
+// The toggle shape with a sealed boundary: its title and children are a
+// compartment that outside content never implicitly merges into or out of.
+const SealedToggle = createBlockSpec(
+ {
+ type: "sealedToggle" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "any", boundary: "sealed" },
+ },
+ { render: renderDiv },
+)();
+
+// A pure container, for the "never regress" half of every pair.
+const Callout = createBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", default: [{ type: "paragraph" }] },
+ },
+ { render: renderDiv },
+)();
+
+// A pure container allowed to hold nothing — the shape that has no child to
+// place a text cursor in.
+const EmptyBox = createBlockSpec(
+ {
+ type: "emptyBox" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any", min: 0 },
+ },
+ { render: renderDiv },
+)();
+
+export const contentContainerSchema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ toggle: Toggle,
+ optionalToggle: OptionalToggle,
+ sealedToggle: SealedToggle,
+ titledGrid: TitledGrid,
+ pureGrid: PureGrid,
+ callout: Callout,
+ emptyBox: EmptyBox,
+ } as const,
+});
diff --git a/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts b/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts
new file mode 100644
index 0000000000..0b423bc10b
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/contentContainers.test.ts
@@ -0,0 +1,358 @@
+// @vitest-environment node
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { contentContainerSchema } from "./contentContainers.fixture.js";
+
+// Document-model behaviour of content-bearing containers (the "toggle" shape:
+// a container with its own inline content as well as children): repair,
+// childless handling, `updateBlock` and selection. All `Block` JSON in and
+// `Block` JSON out, so the editor stays headless and this suite needs no DOM.
+//
+// The keymap half lives in `contentContainers.browser.test.ts`: tiptap can only
+// reach `handleKeyDown` through a mounted view.
+
+const schema = contentContainerSchema;
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+
+beforeAll(() => {
+ editor = BlockNoteEditor.create({ schema });
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+});
+
+describe("content-bearing container: repair", () => {
+ // The whole point of the title is that it holds text the user typed.
+ // Unwrapping the container throws its node — and therefore its title — away,
+ // so repair must refuse rather than silently destroy it.
+ it("does not unwrap a container whose title has content", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "titledGrid",
+ id: "g-0",
+ content: "Kept title",
+ children: [
+ { id: "g-p-0", type: "paragraph", content: "A" },
+ { id: "g-p-1", type: "paragraph", content: "B" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["g-p-0"]);
+
+ const grid = editor.getBlock("g-0");
+ expect(grid).toBeDefined();
+ expect(grid!.content).toEqual([
+ { type: "text", text: "Kept title", styles: {} },
+ ]);
+ // `min: 2`, so ProseMirror refills the removed child rather than letting
+ // the container drop below what its content expression requires.
+ expect(grid!.children).toHaveLength(2);
+ expect(grid!.children.map((child) => child.id)).toContain("g-p-1");
+ });
+
+ it("unwraps a container whose title is empty", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "titledGrid",
+ id: "g-0",
+ content: "",
+ children: [
+ { id: "g-p-0", type: "paragraph", content: "A" },
+ { id: "g-p-1", type: "paragraph", content: "B" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["g-p-0"]);
+
+ expect(editor.getBlock("g-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "g-p-1",
+ "trailing",
+ ]);
+ });
+
+ it("unwraps the equivalent pure container the same way", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "pureGrid",
+ id: "g-0",
+ children: [
+ { id: "g-p-0", type: "paragraph", content: "A" },
+ { id: "g-p-1", type: "paragraph", content: "B" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["g-p-0"]);
+
+ expect(editor.getBlock("g-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "g-p-1",
+ "trailing",
+ ]);
+ });
+
+ it("deletes a titleless container that empties out completely", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "titledGrid",
+ id: "g-0",
+ content: "",
+ children: [
+ { id: "g-p-0", type: "paragraph", content: "A" },
+ { id: "g-p-1", type: "paragraph", content: "B" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["g-p-0", "g-p-1"]);
+
+ expect(editor.getBlock("g-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual(["trailing"]);
+ });
+});
+
+describe("content-bearing container: childless container", () => {
+ it("setTextCursorPosition on a childless pure container does not throw", () => {
+ // A pure container that allows zero children has no child to descend into.
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+ editor.insertBlocks(
+ [{ type: "emptyBox", id: "b-0", children: [] } as any],
+ "p-0",
+ "after",
+ );
+
+ expect(() => editor.setTextCursorPosition("b-0", "start")).not.toThrow();
+ expect(() => editor.setTextCursorPosition("b-0", "end")).not.toThrow();
+ });
+
+ it("setTextCursorPosition on a childless content-bearing container works", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "optionalToggle",
+ id: "t-0",
+ content: "Title",
+ children: [],
+ },
+ ]);
+
+ editor.setTextCursorPosition("t-0", "end");
+ expect(editor.getTextCursorPosition().block.id).toBe("t-0");
+ });
+});
+
+describe("content-bearing container: updateBlock", () => {
+ it("updates the title in place", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [{ id: "t-p-0", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ editor.updateBlock("t-0", { content: "New title" });
+
+ const toggle = editor.getBlock("t-0")!;
+ expect(toggle.content).toEqual([
+ { type: "text", text: "New title", styles: {} },
+ ]);
+ // Children (and their ids) are untouched.
+ expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]);
+ });
+
+ it("updates props without touching content or children", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [{ id: "t-p-0", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ editor.updateBlock("t-0", { props: { open: false } });
+
+ const toggle = editor.getBlock("t-0")!;
+ expect((toggle.props as any).open).toBe(false);
+ expect(toggle.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]);
+ });
+
+ it("carries content and children from a paragraph into a container", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "p-0",
+ type: "paragraph",
+ content: "Title",
+ children: [{ id: "p-c-0", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ editor.updateBlock("p-0", { type: "toggle" });
+
+ // The full-replace path builds a fresh node, so the block is addressed by
+ // position rather than by id here.
+ const toggle = editor.document[0];
+ expect(toggle.type).toBe("toggle");
+ expect(toggle.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(toggle.children.map((child) => child.id)).toEqual(["p-c-0"]);
+ });
+
+ it("carries content and children from a container back to a paragraph", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [{ id: "t-p-0", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ editor.updateBlock("t-0", { type: "paragraph" });
+
+ const paragraph = editor.document[0];
+ expect(paragraph.type).toBe("paragraph");
+ expect(paragraph.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(paragraph.children.map((child) => child.id)).toEqual(["t-p-0"]);
+ });
+
+ it("carries content into a pure container's first child", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Some text" },
+ ]);
+
+ editor.updateBlock("p-0", { type: "callout" });
+
+ const callout = editor.document[0];
+ expect(callout.type).toBe("callout");
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].content).toEqual([
+ { type: "text", text: "Some text", styles: {} },
+ ]);
+ });
+
+ it("drops content that has nowhere to go", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Some text" },
+ ]);
+
+ editor.updateBlock("p-0", { type: "image" });
+
+ expect(editor.document[0].type).toBe("image");
+ });
+
+ it("an explicit `content` in the update wins over the carried one", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Old" },
+ ]);
+
+ editor.updateBlock("p-0", { type: "toggle", content: "New" });
+
+ expect(editor.document[0].content).toEqual([
+ { type: "text", text: "New", styles: {} },
+ ]);
+ });
+
+ it("treats `children: []` as inert, not as a clear", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [{ id: "t-p-0", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ editor.updateBlock("t-0", { children: [], props: { open: false } });
+
+ const toggle = editor.getBlock("t-0")!;
+ expect((toggle.props as any).open).toBe(false);
+ expect(toggle.children.map((child) => child.id)).toEqual(["t-p-0"]);
+ });
+});
+
+describe("content-bearing container: selection", () => {
+ it("getSelectionCutBlocks handles a selection reaching into the container", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [
+ { id: "t-p-0", type: "paragraph", content: "First" },
+ { id: "t-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ editor.setSelection("before", "t-p-0");
+
+ const result = editor.getSelectionCutBlocks();
+ // The container is partially covered, so its included children are
+ // spliced in rather than the container being returned whole.
+ expect(result.blocks.map((block) => block.id)).toEqual(["before", "t-p-0"]);
+ });
+
+ it("getSelectionCutBlocks handles a selection ending inside the title", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "toggle",
+ id: "t-0",
+ content: "Title",
+ children: [
+ { id: "t-p-0", type: "paragraph", content: "First" },
+ { id: "t-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ editor.setSelection("before", "t-0");
+
+ // The selection ends inside the container's own title, before any of its
+ // children — so the generated `__children` node is absent from the slice.
+ // Converting the container must not throw; it comes back as a cut block
+ // (its title, no children) rather than recursing into its content node.
+ const result = editor.getSelectionCutBlocks();
+ expect(result.blocks.map((block) => block.id)).toEqual(["before", "t-0"]);
+ expect(result.blockCutAtEnd).toBe("t-0");
+ const toggle = result.blocks.find((block) => block.id === "t-0")!;
+ expect(toggle.children).toEqual([]);
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts
new file mode 100644
index 0000000000..cd9c86a113
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts
@@ -0,0 +1,338 @@
+import { Fragment, Slice, type Node } from "prosemirror-model";
+import { type Transaction } from "prosemirror-state";
+import { ReplaceAroundStep } from "prosemirror-transform";
+import type { Schema } from "prosemirror-model";
+
+import {
+ BLOCK_GROUP_CHILD_GROUP,
+ blockTypeOfContainerChildrenNode,
+ getChildrenConfig,
+ isContainerNode,
+ isContentContainerNode,
+ resolveChildren,
+} from "../../../schema/blocks/children.js";
+import type { ResolvedChildren } from "../../../schema/blocks/children.js";
+import { seedRefillChildren } from "../../nodeConversions/blockToNode.js";
+import { getNodeById } from "../../nodeUtil.js";
+
+// Defined in `children.ts` (it answers a schema-level question); re-exported
+// here because the public root export (`index.ts`) imports it from this
+// module.
+export { isContainerNode };
+
+export function isEmptyContainerChild(node: Node): boolean {
+ if (node.type.name === "blockContainer") {
+ const blockContent = node.firstChild;
+ return (
+ node.childCount === 1 &&
+ !!blockContent &&
+ blockContent.type.name === "paragraph" &&
+ blockContent.childCount === 0
+ );
+ }
+ if (isContainerNode(node.type)) {
+ return node.childCount === 1 && isEmptyContainerChild(node.firstChild!);
+ }
+ return false;
+}
+
+export function removeEmptyChildren(tr: Transaction, containerPos: number) {
+ const container = tr.doc.resolve(containerPos).nodeAfter;
+ if (!container || !isContainerNode(container.type)) {
+ throw new Error(
+ "Invalid containerPos: does not point to a container node.",
+ );
+ }
+
+ for (
+ let childIndex = container.childCount - 1;
+ childIndex >= 0;
+ childIndex--
+ ) {
+ const childPos = tr.doc.resolve(containerPos + 1).posAtIndex(childIndex);
+ const child = tr.doc.resolve(childPos).nodeAfter;
+ if (!child) {
+ throw new Error("Invalid childPos: does not point to a child node.");
+ }
+
+ if (isEmptyContainerChild(child)) {
+ tr.delete(childPos, childPos + child.nodeSize);
+ }
+ }
+}
+
+function isInsertableChild(node: Node): boolean {
+ return (
+ node.type.name === "blockContainer" ||
+ node.type.isInGroup(BLOCK_GROUP_CHILD_GROUP)
+ );
+}
+
+type ContainerRepairTarget = {
+ blockPos: number;
+ blockNode: Node;
+ childrenPos: number;
+ contentNode: Node | undefined;
+};
+
+function getContainerRepairTarget(
+ doc: Node,
+ containerPos: number,
+): ContainerRepairTarget | undefined {
+ const node = doc.resolve(containerPos).nodeAfter;
+ if (!node) {
+ return undefined;
+ }
+
+ if (isContentContainerNode(node)) {
+ const contentNode = node.firstChild!;
+ return {
+ blockPos: containerPos,
+ blockNode: node,
+ childrenPos: containerPos + 1 + contentNode.nodeSize,
+ contentNode,
+ };
+ }
+
+ if (!isContainerNode(node.type)) {
+ return undefined;
+ }
+
+ // A `__children` node: normalize to the block that owns it.
+ if (blockTypeOfContainerChildrenNode(node.type.name)) {
+ return getContainerRepairTarget(doc, doc.resolve(containerPos).before());
+ }
+
+ return {
+ blockPos: containerPos,
+ blockNode: node,
+ childrenPos: containerPos,
+ contentNode: undefined,
+ };
+}
+
+/**
+ * The (possibly rebuilt) block at the repair target, with where its children
+ * now live and where they start — recomputed after each mutation of `tr`.
+ */
+function refreshRepairTarget(
+ tr: Transaction,
+ target: ContainerRepairTarget,
+): { children: Node; childrenStart: number } | undefined {
+ const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter;
+ if (!refreshedBlock || refreshedBlock.type !== target.blockNode.type) {
+ return undefined;
+ }
+
+ return target.contentNode
+ ? {
+ children: refreshedBlock.lastChild!,
+ childrenStart:
+ target.blockPos + 1 + refreshedBlock.firstChild!.nodeSize + 1,
+ }
+ : { children: refreshedBlock, childrenStart: target.blockPos + 1 };
+}
+
+export function fixContainer(tr: Transaction, containerPos: number) {
+ const target = getContainerRepairTarget(tr.doc, containerPos);
+ if (!target) {
+ throw new Error(
+ "Invalid containerPos: does not point to a container node.",
+ );
+ }
+
+ const blockConfig = target.blockNode.type.spec.blockConfig;
+ const childrenConfig = blockConfig
+ ? getChildrenConfig(blockConfig)
+ : undefined;
+ const config = childrenConfig ? resolveChildren(childrenConfig) : undefined;
+
+ if (!config) {
+ return;
+ }
+
+ // Don't silently destroy non-empty content.
+ if (target.contentNode && target.contentNode.content.size > 0) {
+ return;
+ }
+
+ if (config.whenEmptied === "unwrap") {
+ unwrapContainer(tr, target, config);
+ } else {
+ // `blockConfig` is set whenever `config` is.
+ refillContainer(tr, target, config, blockConfig!.type);
+ }
+}
+
+function unwrapContainer(
+ tr: Transaction,
+ target: ContainerRepairTarget,
+ config: ResolvedChildren,
+) {
+ removeEmptyChildren(tr, target.childrenPos);
+
+ const refreshed = refreshRepairTarget(tr, target);
+ if (!refreshed) {
+ return;
+ }
+ const { children: refreshedChildren, childrenStart } = refreshed;
+
+ const nonEmptyChildren: { child: Node; offset: number }[] = [];
+ refreshedChildren.forEach((child, offset) => {
+ if (!isEmptyContainerChild(child)) {
+ nonEmptyChildren.push({ child, offset });
+ }
+ });
+
+ if (nonEmptyChildren.length >= config.min) {
+ return;
+ }
+
+ const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter!;
+ const blockEnd = target.blockPos + refreshedBlock.nodeSize;
+
+ if (nonEmptyChildren.length === 0) {
+ tr.delete(target.blockPos, blockEnd);
+ return;
+ }
+
+ // Unwrap: replace the container with its remaining non-empty children.
+ if (nonEmptyChildren.length === 1) {
+ const { child, offset } = nonEmptyChildren[0];
+ const childStart = childrenStart + offset;
+
+ const [gapFrom, gapTo] = isInsertableChild(child)
+ ? [childStart, childStart + child.nodeSize]
+ : [childStart + 1, childStart + child.nodeSize - 1];
+
+ tr.step(
+ new ReplaceAroundStep(
+ target.blockPos,
+ blockEnd,
+ gapFrom,
+ gapTo,
+ Slice.empty,
+ 0,
+ false,
+ ),
+ );
+ return;
+ }
+
+ // Several survivors but still below `min`: rebuild replacement content.
+ const replacement: Node[] = [];
+ for (const { child } of nonEmptyChildren) {
+ if (isInsertableChild(child)) {
+ replacement.push(child);
+ } else {
+ child.forEach((grandChild) => replacement.push(grandChild));
+ }
+ }
+ tr.replaceWith(target.blockPos, blockEnd, Fragment.from(replacement));
+}
+
+/**
+ * The `whenEmptied: "refill"` repair: when fewer than `min` non-empty
+ * children remain, drop the emptied ones and top the container back up.
+ * Position `k..min-1` (k = surviving count) is seeded from the container's
+ * `default`, falling back to `fillBefore`-style empty fill when `default` is
+ * absent. Deterministic, appended at the end.
+ *
+ * Rebuilt in a single replace: removing an empty child first would make
+ * ProseMirror's schema fitting instantly pad the container back to `min` with
+ * a fresh empty child, hiding the deficit from the seeding step.
+ */
+function refillContainer(
+ tr: Transaction,
+ target: ContainerRepairTarget,
+ config: ResolvedChildren,
+ blockType: string,
+) {
+ const current = refreshRepairTarget(tr, target);
+ if (!current) {
+ return;
+ }
+ const { children, childrenStart } = current;
+
+ const survivors: Node[] = [];
+ children.forEach((child) => {
+ if (!isEmptyContainerChild(child)) {
+ survivors.push(child);
+ }
+ });
+ // At or above the minimum, empty children are left alone: they may be
+ // intentional.
+ if (survivors.length >= config.min) {
+ return;
+ }
+
+ const seeds = seedRefillChildren(
+ blockType,
+ tr.doc.type.schema,
+ survivors.length,
+ config.min,
+ );
+
+ if (seeds.length === 0) {
+ // No `default` to seed from — empty children are the right fill, and
+ // ProseMirror's schema fitting has usually already padded the container
+ // back to `min` with them. Complete the fill only when it hasn't.
+ const match = children.type.contentMatch.matchFragment(children.content);
+ const fill = match?.fillBefore(Fragment.empty, true);
+ if (fill && fill.size > 0) {
+ tr.insert(childrenStart + children.content.size, fill);
+ }
+ return;
+ }
+
+ // Survivors keep their place; the seeds land at the end, replacing the
+ // emptied (or schema-padded) children.
+ let content = Fragment.from([...survivors, ...seeds]);
+ const match = children.type.contentMatch.matchFragment(content);
+ const fill = match?.fillBefore(Fragment.empty, true);
+ if (fill) {
+ content = content.append(fill);
+ }
+
+ tr.replaceWith(childrenStart, childrenStart + children.content.size, content);
+}
+
+export function fixContainersById(
+ tr: Transaction,
+ containers: { id: string; depth: number }[],
+) {
+ [...containers]
+ .sort((a, b) => b.depth - a.depth)
+ .forEach(({ id }) => {
+ const target = getNodeById(id, tr.doc);
+ if (!target) {
+ return;
+ }
+ fixContainer(tr, target.posBeforeNode);
+ });
+}
+
+export function flattenNonInsertableBlocks<
+ T extends { type?: string; content?: unknown; children?: T[] },
+>(blocks: T[], pmSchema: Schema): T[] {
+ return blocks.flatMap((block) => {
+ const nodeType = block.type ? pmSchema.nodes[block.type] : undefined;
+ if (
+ nodeType &&
+ nodeType.isInGroup("bnBlock") &&
+ !nodeType.isInGroup(BLOCK_GROUP_CHILD_GROUP)
+ ) {
+ const children = flattenNonInsertableBlocks(
+ block.children ?? [],
+ pmSchema,
+ );
+ return Array.isArray(block.content) && block.content.length > 0
+ ? [
+ { type: "paragraph", content: block.content } as unknown as T,
+ ...children,
+ ]
+ : children;
+ }
+ return [block];
+ });
+}
diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts
index d6229a3f0a..466845d94a 100644
--- a/packages/core/src/api/blockManipulation/selections/selection.ts
+++ b/packages/core/src/api/blockManipulation/selections/selection.ts
@@ -169,15 +169,12 @@ export function setSelection(
headBlockInfo.blockNoteType as keyof typeof schema.blockSchema
];
- if (
- !anchorBlockInfo.isBlockContainer ||
- anchorBlockConfig.content === "none"
- ) {
+ if (!anchorBlockInfo.isWrappedBlock || anchorBlockConfig.content === "none") {
throw new Error(
`Attempting to set selection anchor in block without content (id ${startBlockId})`,
);
}
- if (!headBlockInfo.isBlockContainer || headBlockConfig.content === "none") {
+ if (!headBlockInfo.isWrappedBlock || headBlockConfig.content === "none") {
throw new Error(
`Attempting to set selection anchor in block without content (id ${endBlockId})`,
);
diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
index b0b2cc078d..38ad256457 100644
--- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
+++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts
@@ -74,7 +74,7 @@ export function setTextCursorPosition(
const contentType: "none" | "inline" | "table" | "plain" =
schema.blockSchema[info.blockNoteType]!.content;
- if (info.isBlockContainer) {
+ if (info.isWrappedBlock) {
const blockContent = info.blockContent;
if (contentType === "none") {
tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos));
@@ -110,8 +110,15 @@ export function setTextCursorPosition(
} else {
const child =
placement === "start"
- ? info.childContainer.node.firstChild!
- : info.childContainer.node.lastChild!;
+ ? info.childContainer.node.firstChild
+ : info.childContainer.node.lastChild;
+
+ if (!child) {
+ // A container allowed to hold no children has no text to put a cursor
+ // in, so the container itself is selected instead.
+ tr.setSelection(NodeSelection.create(tr.doc, info.bnBlock.beforePos));
+ return;
+ }
setTextCursorPosition(tr, getNodeId(child, tr.doc), placement);
}
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
index e2274140f7..e1e72cf696 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
@@ -6,8 +6,10 @@ import {
BlockImplementation,
BlockSchema,
InlineContentSchema,
+ isContainerType,
StyleSchema,
} from "../../../../schema/index.js";
+import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
inlineContentToNodes,
@@ -270,6 +272,21 @@ function serializeBlock<
}
elementFragment.append(...Array.from(ret.dom.childNodes));
} else {
+ // Asked of the block config rather than of its ProseMirror node — see the
+ // same check in `serializeBlocksInternalHTML`.
+ if (isContainerType(editor.schema.blockSchema[block.type as any])) {
+ // Container blocks own their outer DOM. Make sure the attributes
+ // needed to parse the HTML back (the type marker and non-default
+ // props, in the same `data-*` convention `propsToAttributes` reads)
+ // are present even when the block's render didn't add them.
+ // Author-set attributes win.
+ fillContainerAttributes(
+ ret.dom as HTMLElement,
+ block.type!,
+ props,
+ editor.schema.blockSchema[block.type as any].propSchema,
+ );
+ }
elementFragment.append(ret.dom);
if (nestingLevel > 0) {
(ret.dom as HTMLElement).setAttribute(
@@ -297,15 +314,23 @@ function serializeBlock<
// round trip, we fill their content with a placeholder character that the
// parser strips out again (see `EMPTY_BLOCK_PLACEHOLDER`).
//
- // Only applies to blocks that hold inline content: containers (columns,
- // tables) fill their `contentDOM` with child blocks later on, and code
- // blocks would turn the placeholder into literal content.
+ // Only applies to blocks that hold inline content: pure containers
+ // (columns, tables) fill their `contentDOM` with child blocks later on,
+ // and code blocks would turn the placeholder into literal content.
+ //
+ // A container that has its *own* content needs the placeholder for a
+ // second reason, and its outer node isn't `inlineContent` so it needs its
+ // own check: that node's content is `__content __children`, so
+ // a parser reading a block element first has nothing to satisfy the
+ // content node with and cannot open the children node — every child then
+ // lands *after* the container instead of inside it. A leading text node is
+ // what opens the content node.
const blockNodeType = editor.pmSchema.nodes[block.type as any];
- if (
- blockNodeType?.inlineContent &&
- !blockNodeType.spec.code &&
- ret.contentDOM.childNodes.length === 0
- ) {
+ const blockConfig = editor.schema.blockSchema[block.type as any];
+ const needsPlaceholder = blockNodeType?.inlineContent
+ ? !blockNodeType.spec.code
+ : isContainerType(blockConfig) && blockConfig.content !== "none";
+ if (needsPlaceholder && ret.contentDOM.childNodes.length === 0) {
ret.contentDOM.appendChild(doc.createTextNode(EMPTY_BLOCK_PLACEHOLDER));
}
}
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
index 0f890b77ab..5b533728e8 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
@@ -5,8 +5,10 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
BlockSchema,
InlineContentSchema,
+ isContainerType,
StyleSchema,
} from "../../../../schema/index.js";
+import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
import {
inlineContentToNodes,
@@ -126,6 +128,30 @@ export function serializeInlineContentInternalHTML<
return fragment;
}
+/**
+ * Appends the two region elements a content-bearing container's generated
+ * `__content` / `__children` nodes render, so that internal HTML matches what
+ * the editor puts in the DOM (and what the generated parse rules match).
+ */
+function createContainerRegions(
+ contentDOM: HTMLElement,
+ blockType: string,
+ options?: { document?: Document },
+): { content: HTMLElement; children: HTMLElement } {
+ const doc = options?.document ?? document;
+
+ const content = doc.createElement("div");
+ content.className = "bn-inline-content";
+ content.setAttribute("data-content-type", blockType);
+
+ const children = doc.createElement("div");
+ children.setAttribute("data-children-of", blockType);
+
+ contentDOM.append(content, children);
+
+ return { content, children };
+}
+
function serializeBlock<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -159,7 +185,28 @@ function serializeBlock<
editor as any,
);
- if (ret.contentDOM && block.content) {
+ // Asked of the block config rather than of its ProseMirror node: a container
+ // that has its own content compiles to an outer node holding a separate
+ // children node, so the outer node is not itself a `childContainer` — but
+ // the block is still a container and still owns its outer DOM.
+ const blockConfig = editor.schema.blockSchema[block.type as any];
+ const isContainer = isContainerType(blockConfig);
+
+ // A container with its own content holds two nodes — the generated
+ // `__content` and `__children` — and so renders two region elements inside
+ // its content host. They are not decoration: without them only the *first*
+ // child parses back inside the container. ProseMirror has to invent the
+ // `__children` wrapping while parsing, and `blockContainer`'s `blockOuter`
+ // skip rule re-syncs the parse context to the container afterwards, closing
+ // that invented wrapping again.
+ const regions =
+ isContainer && ret.contentDOM && blockConfig.content !== "none"
+ ? createContainerRegions(ret.contentDOM, block.type!, options)
+ : undefined;
+
+ const contentHost = regions?.content ?? ret.contentDOM;
+
+ if (contentHost && block.content) {
const ic = serializeInlineContentInternalHTML(
editor,
block.content as any, // TODO
@@ -167,12 +214,34 @@ function serializeBlock<
block.type,
options,
);
- ret.contentDOM.appendChild(ic);
+ contentHost.appendChild(ic);
}
- const pmType = editor.pmSchema.nodes[block.type as any];
+ if (isContainer) {
+ // Container blocks own their outer DOM. Internal HTML must round-trip
+ // losslessly, so make sure the attributes the generated parse rules read
+ // (the type marker and non-default props as `data-*`) are present even
+ // when the block's render didn't add them. Author-set attributes win.
+ fillContainerAttributes(
+ ret.dom as HTMLElement,
+ block.type!,
+ props,
+ blockConfig.propSchema,
+ );
- if (pmType.isInGroup("bnBlock")) {
+ // A pure container holds its children directly in its `contentDOM`; one
+ // with its own content puts them in the children region, after the content
+ // region — the reading order the document model itself imposes.
+ const childrenHost = regions?.children ?? ret.contentDOM;
+ // Mark where the children live so the container's round-trip parse rule
+ // can scope itself to this element (`contentElement` in `getParseRules`).
+ // A render is free to put non-content UI text elsewhere in its DOM
+ // (button labels, captions, ...), and without the marker that text would
+ // parse back as document content. Content-bearing containers get the
+ // marker from `createContainerRegions`.
+ if (!regions && ret.contentDOM) {
+ ret.contentDOM.setAttribute("data-children-of", block.type!);
+ }
if (block.children && block.children.length > 0) {
const fragment = serializeBlocks(
editor,
@@ -181,7 +250,7 @@ function serializeBlock<
options,
);
- ret.contentDOM?.append(fragment);
+ childrenHost?.append(fragment);
}
return ret.dom;
}
diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts
index 04ed789c98..4b3a721e6f 100644
--- a/packages/core/src/api/getBlockInfoFromPos.ts
+++ b/packages/core/src/api/getBlockInfoFromPos.ts
@@ -1,6 +1,12 @@
import { Node, ResolvedPos } from "prosemirror-model";
import { EditorState, Transaction } from "prosemirror-state";
+import {
+ CHILD_CONTAINER_GROUP,
+ CONTAINER_CONTENT_GROUP,
+ isContentContainerNode,
+} from "../schema/blocks/children.js";
+
type SingleBlockInfo = {
node: Node;
beforePos: number;
@@ -20,13 +26,16 @@ export type BlockInfo = {
blockNoteType: string;
} & (
| {
- // In case we're not dealing with a BlockContainer, we're dealing with a "wrapper node" (like a Column or ColumnList), so it will always have children
+ // A container block (Column, ColumnList, a custom container): its own node
+ // holds its children directly, and it has no `blockContent` of its own.
/**
- * The Prosemirror node that holds block.children. For non-blockContainer, this node will be the same as bnBlock.
+ * The Prosemirror node that holds block.children. For a container block,
+ * this node is the same as bnBlock.
*/
childContainer: SingleBlockInfo;
- isBlockContainer: false;
+ blockContent?: undefined;
+ isWrappedBlock: false;
}
| {
/**
@@ -38,9 +47,16 @@ export type BlockInfo = {
*/
blockContent: SingleBlockInfo;
/**
- * Whether bnBlock is a blockContainer node
+ * Whether `bnBlock` wraps the block's content in a node of its own —
+ * either a `blockContainer` (an ordinary block wrapped for nesting), or
+ * a container block that has its own content as well as children. Both
+ * have the same shape: a content node, then an optional child container.
+ *
+ * Note this is roughly the *opposite* of "is a container block": a
+ * column has `isWrappedBlock: false`. Sites that need "is this literally
+ * a `blockContainer`" should read `bnBlock.node.type.name`.
*/
- isBlockContainer: true;
+ isWrappedBlock: true;
}
);
@@ -183,48 +199,47 @@ export function getBlockInfoWithManualOffset(
afterPos: bnBlockAfterPos,
};
- if (bnBlockNode.type.name === "blockContainer") {
+ // A container block that has its own content is shaped like a
+ // `blockContainer`: a content node followed by a node holding its children.
+ // Discriminating on that shape rather than on the node's name is what lets
+ // every branch written against `blockContainer` cover it too.
+ const isContentContainer = isContentContainerNode(bnBlockNode);
+
+ if (bnBlockNode.type.name === "blockContainer" || isContentContainer) {
let blockContent: SingleBlockInfo | undefined;
- let blockGroup: SingleBlockInfo | undefined;
+ let childContainer: SingleBlockInfo | undefined;
bnBlockNode.forEach((node, offset) => {
- if (node.type.spec.group === "blockContent") {
- // console.log(beforePos, offset);
- const blockContentNode = node;
- const blockContentBeforePos = bnBlockBeforePos + offset + 1;
- const blockContentAfterPos = blockContentBeforePos + node.nodeSize;
-
- blockContent = {
- node: blockContentNode,
- beforePos: blockContentBeforePos,
- afterPos: blockContentAfterPos,
- };
- } else if (node.type.name === "blockGroup") {
- const blockGroupNode = node;
- const blockGroupBeforePos = bnBlockBeforePos + offset + 1;
- const blockGroupAfterPos = blockGroupBeforePos + node.nodeSize;
+ const beforePos = bnBlockBeforePos + offset + 1;
+ const afterPos = beforePos + node.nodeSize;
- blockGroup = {
- node: blockGroupNode,
- beforePos: blockGroupBeforePos,
- afterPos: blockGroupAfterPos,
- };
+ if (
+ node.type.spec.group === "blockContent" ||
+ node.type.isInGroup(CONTAINER_CONTENT_GROUP)
+ ) {
+ blockContent = { node, beforePos, afterPos };
+ } else if (node.type.isInGroup(CHILD_CONTAINER_GROUP)) {
+ childContainer = { node, beforePos, afterPos };
}
});
if (!blockContent) {
throw new Error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- `blockContainer node does not contain a blockContent node in its children: ${bnBlockNode}`,
+ `${bnBlockNode.type.name} node does not contain a content node in its children: ${bnBlockNode}`,
);
}
return {
- isBlockContainer: true,
+ isWrappedBlock: true,
bnBlock,
blockContent,
- childContainer: blockGroup,
- blockNoteType: blockContent.node.type.name,
+ childContainer,
+ // A `blockContainer` is a generic wrapper, so its type comes from the
+ // content node inside it. A container block *is* its own type.
+ blockNoteType: isContentContainer
+ ? bnBlockNode.type.name
+ : blockContent.node.type.name,
};
} else {
if (!bnBlock.node.type.isInGroup("childContainer")) {
@@ -235,7 +250,7 @@ export function getBlockInfoWithManualOffset(
}
return {
- isBlockContainer: false,
+ isWrappedBlock: false,
bnBlock: bnBlock,
childContainer: bnBlock,
blockNoteType: bnBlock.node.type.name,
diff --git a/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts
index 828894cf1d..2186fefe7d 100644
--- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts
+++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts
@@ -652,8 +652,8 @@ describe("getBlocksChangedByTransaction - ranged optimization", () => {
throw new Error("block not found");
}
const info = getBlockInfo(posInfo);
- if (!info.isBlockContainer) {
- throw new Error("expected a block container");
+ if (!info.isWrappedBlock) {
+ throw new Error("expected a wrapped block");
}
// Adding a mark produces an AddMarkStep, whose StepMap is empty — the case
// getChangedRange has to recover from the step's own from/to.
diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts
index af5c0ba1b7..d2692d0f1d 100644
--- a/packages/core/src/api/nodeConversions/blockToNode.ts
+++ b/packages/core/src/api/nodeConversions/blockToNode.ts
@@ -1,4 +1,11 @@
-import { Attrs, Fragment, Mark, Node, Schema } from "@tiptap/pm/model";
+import {
+ Attrs,
+ Fragment,
+ Mark,
+ Node,
+ NodeType,
+ Schema,
+} from "@tiptap/pm/model";
import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import type {
@@ -16,10 +23,23 @@ import {
isPartialLinkInlineContent,
isStyledTextInlineContent,
} from "../../schema/inlineContent/types.js";
+// `isContainerNode` comes from `children.js` directly (rather than via its
+// `fixContainer.js` re-export) because `fixContainer.js` imports the seeding
+// machinery below — going through it would create an import cycle.
+import {
+ getChildrenConfig,
+ getContentContainerNodeTypes,
+ isContainerNode,
+ resolveChildren,
+} from "../../schema/blocks/children.js";
import { getColspan, isPartialTableCell } from "../../util/table.js";
import { UnreachableCaseError } from "../../util/typescript.js";
import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js";
-import { getStyleSchema, isPlainContentNodeType } from "../pmUtil.js";
+import {
+ getBlockSchema,
+ getStyleSchema,
+ isPlainContentNodeType,
+} from "../pmUtil.js";
/**
* Convert a StyledText inline element to a
@@ -334,6 +354,174 @@ function blockOrInlineContentToContentNode(
return contentNode;
}
+const EMPTY_SEEDING: ReadonlySet = new Set();
+
+function unwrapsWhenEmptied(blockType: string, schema: Schema): boolean {
+ const blockConfig = getBlockSchema(schema)[blockType];
+ const children = blockConfig ? getChildrenConfig(blockConfig) : undefined;
+ return !!children && resolveChildren(children).whenEmptied === "unwrap";
+}
+
+// `createAndFill` produces nodes with `id: null`; patch them before use.
+function withGeneratedIds(node: Node): Node {
+ if (node.isText) {
+ return node;
+ }
+
+ const children: Node[] = [];
+ let childChanged = false;
+ node.forEach((child) => {
+ const next = withGeneratedIds(child);
+ childChanged ||= next !== child;
+ children.push(next);
+ });
+
+ const needsId = node.type.isInGroup("bnBlock") && node.attrs.id === null;
+ if (!needsId && !childChanged) {
+ return node;
+ }
+
+ return node.type.create(
+ needsId ? { ...node.attrs, id: UniqueID.options.generateID() } : node.attrs,
+ childChanged ? Fragment.from(children) : node.content,
+ node.marks,
+ );
+}
+
+function seedDefaultChildren(
+ blockType: string,
+ schema: Schema,
+ styleSchema: StyleSchema,
+ seedingTypes: ReadonlySet,
+): Node[] | undefined {
+ const blockSchemaConfig = getBlockSchema(schema)[blockType];
+ const childrenConfig = blockSchemaConfig
+ ? getChildrenConfig(blockSchemaConfig)
+ : undefined;
+
+ if (!childrenConfig) {
+ return undefined;
+ }
+
+ const defaultChildren = resolveChildren(childrenConfig).default;
+ if (!defaultChildren || defaultChildren.length === 0) {
+ return undefined;
+ }
+
+ if (seedingTypes.has(blockType)) {
+ throw new Error(
+ `Seeding "${blockType}" ends up seeding it again (${[...seedingTypes, blockType].join(" -> ")}). ` +
+ "Give the cyclic default explicit children, or remove the self-reference.",
+ );
+ }
+
+ const nextSeeding = new Set(seedingTypes).add(blockType);
+ return defaultChildren.map((child) =>
+ blockToNode(
+ child as PartialBlock,
+ schema,
+ styleSchema,
+ nextSeeding,
+ ),
+ );
+}
+
+/**
+ * The nodes `whenEmptied: "refill"` appends when a container's non-empty
+ * children drop below `min`: the unconsumed tail of its `default`
+ * (`default[from..min-1]`), each converted exactly like an inserted block.
+ * Empty when the container has no `default`; the caller pads any remainder
+ * with empty fill.
+ */
+export function seedRefillChildren(
+ blockType: string,
+ schema: Schema,
+ from: number,
+ min: number,
+): Node[] {
+ const blockConfig = getBlockSchema(schema)[blockType];
+ const children = blockConfig ? getChildrenConfig(blockConfig) : undefined;
+ const defaultChildren = children
+ ? resolveChildren(children).default
+ : undefined;
+ if (!defaultChildren) {
+ return [];
+ }
+
+ return defaultChildren
+ .slice(from, min)
+ .map((child) => blockToNode(child as PartialBlock, schema));
+}
+
+function partialContentToInlineNodes(
+ block: PartialBlock,
+ contentNodeName: string,
+ schema: Schema,
+ styleSchema: StyleSchema,
+): Node[] {
+ if (block.content === undefined) {
+ return [];
+ }
+ if (typeof block.content === "string" || Array.isArray(block.content)) {
+ return inlineContentToNodes(
+ typeof block.content === "string" ? [block.content] : block.content,
+ schema,
+ contentNodeName,
+ styleSchema,
+ );
+ }
+
+ throw new Error(
+ `Block "${block.type}" cannot have content of type "${block.content.type}".`,
+ );
+}
+
+function createContainerChildrenNode(
+ blockType: string,
+ type: NodeType,
+ schema: Schema,
+ styleSchema: StyleSchema,
+ seedingTypes: ReadonlySet,
+ attrs: Attrs | null = null,
+): Node {
+ const seeded = seedDefaultChildren(
+ blockType,
+ schema,
+ styleSchema,
+ seedingTypes,
+ );
+
+ if (!seeded && unwrapsWhenEmptied(blockType, schema)) {
+ return type.create(attrs);
+ }
+
+ const node = type.createAndFill(attrs, seeded);
+ if (!node) {
+ throw new Error(
+ `Cannot create block "${blockType}": its \`default\` children don't fit its \`children\` config ` +
+ `(it accepts \`${type.spec.content}\`).`,
+ );
+ }
+
+ return node;
+}
+
+// Skips `createAndFill` for unwrap-on-empty containers (fill would be undone
+// by the next repair pass) and for unfittable content (let `node.check()` report it).
+function createExplicitChildrenNode(
+ blockType: string,
+ type: NodeType,
+ schema: Schema,
+ children: Node[],
+ attrs: Attrs | null = null,
+): Node {
+ if (unwrapsWhenEmptied(blockType, schema)) {
+ return type.create(attrs, children);
+ }
+
+ return type.createAndFill(attrs, children) ?? type.create(attrs, children);
+}
+
/**
* Converts a BlockNote block to a Prosemirror node.
*/
@@ -341,6 +529,7 @@ export function blockToNode(
block: PartialBlock,
schema: Schema,
styleSchema: StyleSchema = getStyleSchema(schema),
+ seedingTypes: ReadonlySet = EMPTY_SEEDING,
) {
let id = block.id;
@@ -352,7 +541,7 @@ export function blockToNode(
if (block.children) {
for (const child of block.children) {
- children.push(blockToNode(child, schema, styleSchema));
+ children.push(blockToNode(child, schema, styleSchema, seedingTypes));
}
}
@@ -360,9 +549,11 @@ export function blockToNode(
!block.type || // can happen if block.type is not defined (this should create the default node)
schema.nodes[block.type].isInGroup("blockContent");
- if (isBlockContent) {
- // Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer
+ const contentContainerTypes = block.type
+ ? getContentContainerNodeTypes(schema, block.type)
+ : undefined;
+ if (isBlockContent) {
const contentNode = blockOrInlineContentToContentNode(
block,
schema,
@@ -381,15 +572,53 @@ export function blockToNode(
},
groupNode ? [contentNode, groupNode] : contentNode,
);
- } else if (schema.nodes[block.type].isInGroup("bnBlock")) {
- // `create` (not `createChecked`) so partial container blocks pass through;
- // callers that mutate the doc validate via `node.check()` before inserting.
- return schema.nodes[block.type].create(
- {
- id: id,
- ...block.props,
- },
- children,
+ } else if (contentContainerTypes) {
+ // A container with its own content: the content and the children each get
+ // a node of their own, since a ProseMirror node holds either inline
+ // content or block content but never both.
+ const { contentType, childrenType } = contentContainerTypes;
+
+ const contentNode = contentType.createChecked(
+ null,
+ partialContentToInlineNodes(block, contentType.name, schema, styleSchema),
+ );
+
+ const childrenNode =
+ block.children !== undefined
+ ? createExplicitChildrenNode(block.type, childrenType, schema, children)
+ : createContainerChildrenNode(
+ block.type,
+ childrenType,
+ schema,
+ styleSchema,
+ seedingTypes,
+ );
+
+ return withGeneratedIds(
+ schema.nodes[block.type].create({ id: id, ...block.props }, [
+ contentNode,
+ childrenNode,
+ ]),
+ );
+ } else if (isContainerNode(schema.nodes[block.type])) {
+ const type = schema.nodes[block.type];
+ const attrs = { id: id, ...block.props };
+
+ if (block.children !== undefined) {
+ return withGeneratedIds(
+ createExplicitChildrenNode(block.type, type, schema, children, attrs),
+ );
+ }
+
+ return withGeneratedIds(
+ createContainerChildrenNode(
+ block.type,
+ type,
+ schema,
+ styleSchema,
+ seedingTypes,
+ attrs,
+ ),
);
} else {
throw new Error(
diff --git a/packages/core/src/api/nodeConversions/contentContainers.test.ts b/packages/core/src/api/nodeConversions/contentContainers.test.ts
new file mode 100644
index 0000000000..e071d5ca15
--- /dev/null
+++ b/packages/core/src/api/nodeConversions/contentContainers.test.ts
@@ -0,0 +1,418 @@
+// @vitest-environment node
+import type { Node, Schema } from "@tiptap/pm/model";
+import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { createBlockSpec } from "../../schema/blocks/createSpec.js";
+import {
+ getBottomNestedBlockInfo,
+ getPrevBlockInfo,
+} from "../blockManipulation/commands/mergeBlocks/mergeBlocks.js";
+import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js";
+import { blockToNode } from "./blockToNode.js";
+import { nodeToBlock } from "./nodeToBlock.js";
+
+// A container block with its own inline content: the toggle shape. Its node
+// holds a generated content node and a generated children node, which is what
+// makes its `Block` JSON identical to a nested regular block's.
+// Nothing here is ever rendered — this suite works on nodes and a headless
+// editor's schema — so `render` only has to exist for the spec to be accepted.
+const notRendered = () => {
+ throw new Error("not rendered in this suite");
+};
+
+const Toggle = createBlockSpec(
+ {
+ type: "toggle" as const,
+ propSchema: { open: { default: true } },
+ content: "inline",
+ children: { allow: "any" },
+ },
+ { render: notRendered },
+)();
+
+// The same, but allowed to have no children at all.
+const OptionalToggle = createBlockSpec(
+ {
+ type: "optionalToggle" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "any", min: 0 },
+ },
+ { render: notRendered },
+)();
+
+// A pure container, to pair each content-bearing container against.
+const containerSpec = (
+ type: TName,
+ config: { content: "none" | "inline"; children: any; placement?: any },
+) =>
+ createBlockSpec(
+ {
+ type,
+ propSchema: {},
+ ...config,
+ } as any,
+ { render: notRendered },
+ )();
+
+// Pairs of (pure container, content-bearing container) sharing one `children`
+// config. Their content expressions must match: the same generator runs for
+// both, so every `allow`/`min`/`max` option enforces identically.
+const CHILDREN_CONFIGS = {
+ Default: { allow: "any" },
+ Bounded: { allow: "any", min: 0, max: 3 },
+ Restricted: { allow: ["cell"], min: 2 },
+} as const;
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ toggle: Toggle,
+ optionalToggle: OptionalToggle,
+ cell: containerSpec("cell", {
+ content: "none",
+ children: { allow: "any" },
+ }),
+ pureDefault: containerSpec("pureDefault", {
+ content: "none",
+ children: CHILDREN_CONFIGS.Default,
+ }),
+ contentDefault: containerSpec("contentDefault", {
+ content: "inline",
+ children: CHILDREN_CONFIGS.Default,
+ }),
+ pureBounded: containerSpec("pureBounded", {
+ content: "none",
+ children: CHILDREN_CONFIGS.Bounded,
+ }),
+ contentBounded: containerSpec("contentBounded", {
+ content: "inline",
+ children: CHILDREN_CONFIGS.Bounded,
+ }),
+ pureRestricted: containerSpec("pureRestricted", {
+ content: "none",
+ children: CHILDREN_CONFIGS.Restricted,
+ }),
+ contentRestricted: containerSpec("contentRestricted", {
+ content: "inline",
+ children: CHILDREN_CONFIGS.Restricted,
+ }),
+ } as const,
+});
+
+let editor: BlockNoteEditor;
+let pmSchema: Schema;
+
+beforeAll(() => {
+ editor = BlockNoteEditor.create({ schema }) as any;
+ pmSchema = editor.pmSchema;
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ editor = undefined as any;
+});
+
+// `nodeToBlock(node, doc)` takes the containing document as its second
+// argument, so blocks built in isolation need a minimal valid doc around them.
+const wrapInDoc = (...blocks: Node[]): Node =>
+ pmSchema.nodes["doc"].createChecked(
+ null,
+ pmSchema.nodes["blockGroup"].createChecked(null, blocks),
+ );
+
+describe("content-bearing container: node shape", () => {
+ it("builds a content node and a children node inside the block's node", () => {
+ const node = blockToNode(
+ {
+ id: "t-0",
+ type: "toggle",
+ props: { open: false },
+ content: "Title",
+ children: [{ id: "c-0", type: "paragraph", content: "Child" }],
+ } as any,
+ pmSchema,
+ );
+
+ expect(node.type.name).toBe("toggle");
+ expect(node.type.isInGroup("bnBlock")).toBe(true);
+ expect(node.type.isInGroup("blockGroupChild")).toBe(true);
+ // The block's node is not itself a child container — the generated
+ // children node is.
+ expect(node.type.isInGroup("childContainer")).toBe(false);
+
+ expect(node.childCount).toBe(2);
+
+ const contentNode = node.child(0);
+ expect(contentNode.type.name).toBe("toggle__content");
+ expect(contentNode.type.isInGroup("containerContent")).toBe(true);
+ // Deliberately not `blockContent`: that group is what `blockContainer`
+ // accepts, so a paste could otherwise produce
+ // `blockContainer > toggle__content`.
+ expect(contentNode.type.isInGroup("blockContent")).toBe(false);
+ expect(contentNode.textContent).toBe("Title");
+
+ const childrenNode = node.child(1);
+ expect(childrenNode.type.name).toBe("toggle__children");
+ expect(childrenNode.type.isInGroup("childContainer")).toBe(true);
+ expect(childrenNode.childCount).toBe(1);
+ expect(childrenNode.child(0).type.name).toBe("blockContainer");
+
+ // The node is valid against the schema.
+ expect(() => node.check()).not.toThrow();
+ });
+
+ it("keeps all props (and the id) on the outer node", () => {
+ const node = blockToNode(
+ {
+ id: "t-0",
+ type: "toggle",
+ props: { open: false },
+ content: "Title",
+ } as any,
+ pmSchema,
+ );
+
+ expect(node.attrs.id).toBe("t-0");
+ expect(node.attrs.open).toBe(false);
+ expect("open" in node.child(0).attrs).toBe(false);
+ expect("id" in node.child(0).attrs).toBe(false);
+ });
+
+ it("auto-fills children when none are given", () => {
+ const node = blockToNode(
+ { id: "t-0", type: "toggle", content: "Title" } as any,
+ pmSchema,
+ );
+
+ const childrenNode = node.child(1);
+ expect(childrenNode.childCount).toBe(1);
+ // Auto-filled nodes come from the schema with `id: null`; they must be
+ // given real ids before they're converted back to blocks.
+ expect(childrenNode.child(0).attrs.id).toBeTruthy();
+ });
+});
+
+describe("content-bearing container: Block JSON", () => {
+ it("round-trips identically to a nested regular block", () => {
+ const toggleNode = blockToNode(
+ {
+ id: "b-0",
+ type: "toggle",
+ content: "Title",
+ children: [{ id: "c-0", type: "paragraph", content: "Child" }],
+ } as any,
+ pmSchema,
+ );
+ const paragraphNode = blockToNode(
+ {
+ id: "b-0",
+ type: "paragraph",
+ content: "Title",
+ children: [{ id: "c-0", type: "paragraph", content: "Child" }],
+ } as any,
+ pmSchema,
+ );
+
+ const toggleBlock = nodeToBlock(toggleNode, wrapInDoc(toggleNode));
+ const paragraphBlock = nodeToBlock(paragraphNode, wrapInDoc(paragraphNode));
+
+ expect(Object.keys(toggleBlock)).toEqual([
+ "id",
+ "type",
+ "props",
+ "content",
+ "children",
+ ]);
+ expect(Object.keys(toggleBlock)).toEqual(Object.keys(paragraphBlock));
+
+ // Everything but the block's own type and props is structurally identical
+ // to the nested paragraph's.
+ const { type: _toggleType, props: _toggleProps, ...toggle } = toggleBlock;
+ const {
+ type: _paragraphType,
+ props: _paragraphProps,
+ ...paragraph
+ } = paragraphBlock;
+ expect(toggle).toEqual(paragraph);
+
+ expect(toggleBlock).toEqual({
+ id: "b-0",
+ type: "toggle",
+ props: { open: true },
+ content: [{ type: "text", text: "Title", styles: {} }],
+ children: [
+ {
+ id: "c-0",
+ type: "paragraph",
+ props: (paragraphBlock.children as any[])[0].props,
+ content: [{ type: "text", text: "Child", styles: {} }],
+ children: [],
+ },
+ ],
+ });
+ });
+
+ it("round-trips an empty container with no content", () => {
+ const node = blockToNode(
+ { id: "t-0", type: "optionalToggle", children: [] } as any,
+ pmSchema,
+ );
+ const block = nodeToBlock(node, wrapInDoc(node));
+
+ expect(block.content).toEqual([]);
+ expect(block.children).toEqual([]);
+ });
+});
+
+describe("content-bearing container: BlockInfo", () => {
+ it("is a wrapped block, with the content and children nodes resolved", () => {
+ const node = blockToNode(
+ {
+ id: "t-0",
+ type: "toggle",
+ content: "Title",
+ children: [{ id: "c-0", type: "paragraph", content: "Child" }],
+ } as any,
+ pmSchema,
+ );
+
+ const info = getBlockInfoWithManualOffset(node, 0);
+
+ // Structurally identical to a `blockContainer`, so every keyboard branch
+ // written against one covers this too.
+ expect(info.isWrappedBlock).toBe(true);
+ expect(info.blockContent!.node.type.name).toBe("toggle__content");
+ expect(info.childContainer!.node.type.name).toBe("toggle__children");
+ // The type comes from the outer node — a `blockContainer` is a generic
+ // wrapper, but a container block *is* its own type.
+ expect(info.blockNoteType).toBe("toggle");
+
+ // Positions are those of the nodes themselves.
+ expect(info.bnBlock.beforePos).toBe(0);
+ expect(info.blockContent!.beforePos).toBe(1);
+ expect(info.blockContent!.afterPos).toBe(1 + node.child(0).nodeSize);
+ expect(info.childContainer!.beforePos).toBe(1 + node.child(0).nodeSize);
+ });
+
+ it("still reads a blockContainer's type from its content node", () => {
+ const node = blockToNode(
+ { id: "p-0", type: "paragraph", content: "Hello" } as any,
+ pmSchema,
+ );
+
+ const info = getBlockInfoWithManualOffset(node, 0);
+ expect(info.isWrappedBlock).toBe(true);
+ expect(info.bnBlock.node.type.name).toBe("blockContainer");
+ expect(info.blockNoteType).toBe("paragraph");
+ });
+
+ it("handles a container with zero children", () => {
+ const paragraphNode = blockToNode(
+ { id: "p-0", type: "paragraph", content: "Before" } as any,
+ pmSchema,
+ );
+ const toggleNode = blockToNode(
+ {
+ id: "t-0",
+ type: "optionalToggle",
+ content: "Title",
+ children: [],
+ } as any,
+ pmSchema,
+ );
+ const doc = wrapInDoc(paragraphNode, toggleNode);
+
+ const togglePos = 1 + paragraphNode.nodeSize;
+ const info = getBlockInfoWithManualOffset(toggleNode, togglePos);
+ expect(info.childContainer!.node.childCount).toBe(0);
+
+ // An empty child container has no last child to descend into, so the
+ // block itself is the bottom one.
+ expect(() => getBottomNestedBlockInfo(doc, info)).not.toThrow();
+ expect(getBottomNestedBlockInfo(doc, info).bnBlock.node).toBe(toggleNode);
+
+ expect(() => getPrevBlockInfo(doc, togglePos)).not.toThrow();
+ expect(getPrevBlockInfo(doc, togglePos)!.blockNoteType).toBe("paragraph");
+ });
+});
+
+describe("content-bearing container: in a headless editor", () => {
+ it("inserts and reads back", () => {
+ const headless = BlockNoteEditor.create({ schema }) as any;
+
+ try {
+ headless.replaceBlocks(headless.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+ headless.insertBlocks(
+ [
+ {
+ id: "t-0",
+ type: "toggle",
+ content: "Title",
+ children: [{ id: "c-0", type: "paragraph", content: "Child" }],
+ },
+ ],
+ "p-0",
+ "after",
+ );
+
+ const block = headless.getBlock("t-0")!;
+ expect(block.type).toBe("toggle");
+ expect(block.content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(block.children.map((child: any) => child.id)).toEqual(["c-0"]);
+ // The child is an ordinary block of the document, reachable by id.
+ expect(headless.getBlock("c-0")).toBeDefined();
+ // The rendering half of this — that a container's attributes land on the
+ // author's own root element — is asserted against a real cascade in
+ // `tests/src/end-to-end/containerblocks/containerblocks.test.tsx`
+ // ("Stamps node type and id onto the author's own root element").
+ } finally {
+ headless._tiptapEditor.destroy();
+ }
+ });
+});
+
+describe("content-bearing container: children content expression", () => {
+ it.each(Object.keys(CHILDREN_CONFIGS))(
+ "%s compiles the same as it does for a pure container",
+ (name) => {
+ const pure = pmSchema.nodes[`pure${name}`];
+ const contentBearing = pmSchema.nodes[`content${name}__children`];
+
+ expect(contentBearing).toBeDefined();
+ expect(contentBearing.spec.content).toBe(pure.spec.content);
+ },
+ );
+
+ it("enforces the expression on the children node", () => {
+ // `Restricted` allows only `cell` children, and at least two of them.
+ expect(() =>
+ blockToNode(
+ {
+ type: "contentRestricted",
+ content: "Title",
+ children: [{ type: "paragraph" }],
+ } as any,
+ pmSchema,
+ ).check(),
+ ).toThrow();
+
+ expect(() =>
+ blockToNode(
+ {
+ type: "contentRestricted",
+ content: "Title",
+ children: [{ type: "cell" }, { type: "cell" }],
+ } as any,
+ pmSchema,
+ ).check(),
+ ).not.toThrow();
+ });
+});
diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
index 19f063d8bb..8b99ae669d 100644
--- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
+++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
@@ -1,60 +1,98 @@
-import { Fragment } from "@tiptap/pm/model";
+import { Fragment, Node } from "@tiptap/pm/model";
import {
BlockNoDefaults,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
+import {
+ getChildrenConfig,
+ isContainerNode,
+ isContentContainerNode,
+ isPlaceableAnywhere,
+ resolveChildren,
+} from "../../schema/blocks/children.js";
+import { getBlockSchema } from "../pmUtil.js";
import { nodeToBlock } from "./nodeToBlock.js";
-/**
- * Converts all Blocks within a fragment to BlockNote blocks.
- */
+function getContainerChildren(
+ node: Node,
+): { blockType: string; children: Node } | undefined {
+ if (isContentContainerNode(node)) {
+ return { blockType: node.type.name, children: node.lastChild! };
+ }
+ if (isContainerNode(node.type)) {
+ return { blockType: node.type.name, children: node };
+ }
+ return undefined;
+}
+
+function isSelfContainedContainer(node: Node): boolean {
+ const container = getContainerChildren(node);
+ if (!container) {
+ return false;
+ }
+ const blockConfig =
+ getBlockSchema(node.type.schema)[container.blockType] ?? {};
+ const children = getChildrenConfig(blockConfig);
+ if (!children) {
+ return false;
+ }
+ return (
+ isPlaceableAnywhere(blockConfig) &&
+ container.children.childCount >= resolveChildren(children).min
+ );
+}
+
+function containerContentAsBlock<
+ B extends BlockSchema,
+ I extends InlineContentSchema,
+ S extends StyleSchema,
+>(node: Node, root: Node): BlockNoDefaults | undefined {
+ if (!isContentContainerNode(node) || node.firstChild!.content.size === 0) {
+ return undefined;
+ }
+ const schema = node.type.schema;
+ const paragraph = schema.nodes["paragraph"].create(
+ null,
+ node.firstChild!.content,
+ );
+
+ return nodeToBlock(
+ schema.nodes["blockContainer"].createAndFill(null, paragraph)!,
+ root,
+ );
+}
+
export function fragmentToBlocks<
B extends BlockSchema,
I extends InlineContentSchema,
S extends StyleSchema,
>(fragment: Fragment) {
- // first convert selection to blocknote-style blocks, and then
- // pass these to the exporter
const blocks: BlockNoDefaults[] = [];
+
+ const pushFlattened = (node: Node, root: Node) => {
+ const container = getContainerChildren(node);
+ if (container && !isSelfContainedContainer(node)) {
+ const content = containerContentAsBlock(node, root);
+ if (content) {
+ blocks.push(content);
+ }
+ container.children.forEach((child) => pushFlattened(child, root));
+ return;
+ }
+ blocks.push(nodeToBlock(node, root));
+ };
+
fragment.descendants((node) => {
if (node.type.name === "blockContainer") {
if (node.firstChild?.type.name === "blockGroup") {
- // selection started within a block group
- // in this case the fragment starts with:
- //
- //
- //
- //
- //
- //
- //
- // instead of:
- //
- //
- //
- //
- //
- //
- //
- //
- // so we don't need to serialize this block, just descend into the children of the blockGroup
return true;
}
}
- if (node.type.name === "columnList" && node.childCount === 1) {
- // column lists with a single column should be flattened (not the entire column list has been selected)
- node.firstChild?.forEach((child) => {
- blocks.push(nodeToBlock(child, node));
- });
- return false;
- }
-
if (node.type.isInGroup("bnBlock")) {
- blocks.push(nodeToBlock(node, node));
- // don't descend into children, as they're already included in the block returned by nodeToBlock
+ pushFlattened(node, node);
return false;
}
return true;
diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts
index fead006657..e445c4e9e5 100644
--- a/packages/core/src/api/nodeConversions/nodeToBlock.ts
+++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts
@@ -1,5 +1,7 @@
import { Mark, Node, Slice } from "@tiptap/pm/model";
import type { Block } from "../../blocks/defaultBlocks.js";
+import { isContainerNode } from "../blockManipulation/containers/fixContainer.js";
+import { isContentContainerNode } from "../../schema/blocks/children.js";
import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import type {
BlockSchema,
@@ -430,7 +432,7 @@ export function nodeToBlock<
const props: any = {};
for (const [attr, value] of Object.entries({
...node.attrs,
- ...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}),
+ ...(blockInfo.isWrappedBlock ? blockInfo.blockContent.node.attrs : {}),
})) {
const propSchema = blockSpec.propSchema;
@@ -452,7 +454,7 @@ export function nodeToBlock<
let content: Block["content"];
if (blockConfig.content === "inline") {
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
throw new Error("impossible");
}
content = contentNodeToInlineContent(
@@ -461,7 +463,7 @@ export function nodeToBlock<
styleSchema,
);
} else if (blockConfig.content === "table") {
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
throw new Error("impossible");
}
content = contentNodeToTableContent(
@@ -470,7 +472,7 @@ export function nodeToBlock<
styleSchema,
);
} else if (blockConfig.content === "plain") {
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
throw new Error("impossible");
}
// Plain content is a single unstyled text item; an empty block is an
@@ -533,6 +535,24 @@ export function docToBlocks<
*
*
*/
+/**
+ * The node holding a bnBlock's children when that node holds them directly:
+ * the container itself for a pure container, its generated `__children` node
+ * for a container that also has its own content. `undefined` for a
+ * `blockContainer`, whose children live in an optional `blockGroup`.
+ */
+function getChildrenHolder(node: Node): Node | undefined {
+ if (isContentContainerNode(node)) {
+ // The children live in the generated `__children` node, which is the last
+ // child. When a slice boundary cuts through the container's own `__content`,
+ // the `__children` node is absent from the slice — its last (and only)
+ // child is then the `__content` node, which holds no children of its own.
+ const lastChild = node.lastChild;
+ return lastChild && isContainerNode(lastChild.type) ? lastChild : undefined;
+ }
+ return isContainerNode(node.type) ? node : undefined;
+}
+
export function prosemirrorSliceToSlicedBlocks<
BSchema extends BlockSchema,
I extends InlineContentSchema,
@@ -563,7 +583,9 @@ export function prosemirrorSliceToSlicedBlocks<
blockCutAtStart: string | undefined;
blockCutAtEnd: string | undefined;
} {
- if (node.type.name !== "blockGroup") {
+ // Both `blockGroup` and container nodes (columnList, column, callout,
+ // ...) hold bnBlock children directly, so both can be processed here.
+ if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) {
throw new Error("unexpected");
}
const blocks: Block[] = [];
@@ -571,6 +593,68 @@ export function prosemirrorSliceToSlicedBlocks<
let blockCutAtEnd: string | undefined;
node.forEach((blockContainer, _offset, index) => {
+ const isFirstBlock = index === 0;
+ const isLastBlock = index === node.childCount - 1;
+
+ const childrenHolder = getChildrenHolder(blockContainer);
+ if (childrenHolder) {
+ // A container child. When the slice boundary is open inside it, the
+ // selection covers part of its children — skip the container wrapper
+ // and splice in the included children (mirroring the
+ // nested-blockGroup descent below). When fully enclosed, convert it
+ // wholesale.
+ const openAtStart = isFirstBlock && openStart > 0;
+ const openAtEnd = isLastBlock && openEnd > 0;
+
+ // A container that also has its own content keeps its children one
+ // node deeper, in its generated `__children` node.
+ const depthToChildren = childrenHolder === blockContainer ? 1 : 2;
+
+ if (openAtStart || openAtEnd) {
+ const ret = processNode(
+ childrenHolder,
+ openAtStart ? Math.max(0, openStart - depthToChildren) : 0,
+ openAtEnd ? Math.max(0, openEnd - depthToChildren) : 0,
+ );
+ if (openAtStart) {
+ blockCutAtStart = ret.blockCutAtStart;
+ }
+ if (openAtEnd) {
+ blockCutAtEnd = ret.blockCutAtEnd;
+ }
+ blocks.push(...ret.blocks);
+ return;
+ }
+
+ blocks.push(
+ nodeToBlock(blockContainer, slice.content.firstChild!) as Block<
+ BSchema,
+ I,
+ S
+ >,
+ );
+ return;
+ }
+
+ if (isContentContainerNode(blockContainer)) {
+ // A content-bearing container whose `__children` node is absent from
+ // the slice: the boundary cut through its own `__content`, so it has no
+ // children to splice in. Convert it wholesale (with its cut content),
+ // recording the cut boundary so callers know the block was sliced.
+ const block = nodeToBlock(
+ blockContainer,
+ slice.content.firstChild!,
+ ) as Block;
+ if (isFirstBlock && openStart > 0) {
+ blockCutAtStart = block.id;
+ }
+ if (isLastBlock && openEnd > 0) {
+ blockCutAtEnd = block.id;
+ }
+ blocks.push(block);
+ return;
+ }
+
if (blockContainer.type.name !== "blockContainer") {
throw new Error("unexpected");
}
@@ -583,9 +667,6 @@ export function prosemirrorSliceToSlicedBlocks<
);
}
- const isFirstBlock = index === 0;
- const isLastBlock = index === node.childCount - 1;
-
if (blockContainer.firstChild!.type.name === "blockGroup") {
// this is the parent where a selection starts within one of its children,
// e.g.:
diff --git a/packages/core/src/api/pmUtil.ts b/packages/core/src/api/pmUtil.ts
index 17ed2aa943..79461b75d1 100644
--- a/packages/core/src/api/pmUtil.ts
+++ b/packages/core/src/api/pmUtil.ts
@@ -2,6 +2,7 @@ import type { Node, NodeType, Schema } from "prosemirror-model";
import { Transform } from "prosemirror-transform";
import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js";
import { BlockNoteSchema } from "../blocks/BlockNoteSchema.js";
+import { blockTypeOfContainerContentNode } from "../schema/blocks/children.js";
import type { BlockSchema } from "../schema/blocks/types.js";
import type { InlineContentSchema } from "../schema/inlineContent/types.js";
import type { StyleSchema } from "../schema/styles/types.js";
@@ -67,7 +68,16 @@ export function isPlainContentNodeType(
schema: Schema,
nodeType: NodeType,
): boolean {
- if (getBlockSchema(schema)[nodeType.name]?.content === "plain") {
+ const blockSchema = getBlockSchema(schema);
+ // A content-bearing container's content lives in a generated node, so it
+ // isn't a key in the block schema — resolve it back to the block it belongs
+ // to.
+ const blockType =
+ blockTypeOfContainerContentNode(nodeType.name) ?? nodeType.name;
+
+ if (
+ (blockSchema[nodeType.name] ?? blockSchema[blockType])?.content === "plain"
+ ) {
return true;
}
diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
index 0b33335788..71f3ecaf35 100644
--- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
+++ b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts
@@ -11,7 +11,7 @@ export const handleEnter = (editor: BlockNoteEditor) => {
};
});
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts
index b268598218..5e52c8c76f 100644
--- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts
+++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts
@@ -32,7 +32,7 @@ function calculateListItemIndex(
// Fast path: previous sibling already in cache
const blockInfo = getBlockInfo({ posBeforeNode: pos, node });
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
throw new Error("impossible");
}
const prevBlock = tr.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore;
@@ -80,7 +80,7 @@ function calculateListItemIndex(
posBeforeNode: lastInChain.pos,
node: lastInChain.node,
});
- if (!lastInfo.isBlockContainer) {
+ if (!lastInfo.isWrappedBlock) {
throw new Error("impossible");
}
const predecessorNode = tr.doc.resolve(lastInfo.bnBlock.beforePos).nodeBefore;
diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts
index 12e558a453..578d3aae8b 100644
--- a/packages/core/src/blocks/utils/listItemEnterHandler.ts
+++ b/packages/core/src/blocks/utils/listItemEnterHandler.ts
@@ -14,7 +14,7 @@ export const handleEnter = (
};
});
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index 25b93d03f4..e631edb881 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -7,6 +7,7 @@ import {
} from "@tiptap/core";
import { type Command, type Transaction } from "@tiptap/pm/state";
import { Node, Schema } from "prosemirror-model";
+import type { BlockPlacement } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js";
import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js";
import { blockToNode } from "../api/nodeConversions/blockToNode.js";
import {
@@ -37,6 +38,7 @@ import type {
StyleSchema,
StyleSpecs,
} from "../schema/index.js";
+import { assertContainerSchemaInvariants } from "../schema/blocks/assertSchemaInvariants.js";
import "../style.css";
import { mergeCSSClasses } from "../util/browser.js";
import { EventEmitter } from "../util/EventEmitter.js";
@@ -558,6 +560,13 @@ export class BlockNoteEditor<
tiptapOptions.parseOptions,
);
+ // `blockToNode` is lenient, and `createDocument` builds from JSON without
+ // validating — so without this the one path that never checks its result
+ // is the one that seeds the whole document. A container below its
+ // `children.min` would reach the editor and stay there, where the same
+ // blocks passed to `insertBlocks` would have been rejected.
+ doc.check();
+
this._tiptapEditor = new TiptapEditor({
...tiptapOptions,
content: doc.toJSON(),
@@ -572,6 +581,8 @@ export class BlockNoteEditor<
this.pmSchema.cached.blockNoteEditor = this;
+ assertContainerSchemaInvariants(this.pmSchema);
+
this._tiptapEditor.on("mount", () => {
this.headless = false;
});
@@ -1051,13 +1062,14 @@ export class BlockNoteEditor<
* error if the reference block could not be found.
* @param blocksToInsert An array of partial blocks that should be inserted.
* @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted.
- * @param placement Whether the blocks should be inserted just before, just after, or nested inside the
- * `referenceBlock`.
+ * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next
+ * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children. Throws an error if
+ * the `referenceBlock` (or its parent, for `"before"`/`"after"`) doesn't accept the blocks there.
*/
public insertBlocks(
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
- placement: "before" | "after" = "before",
+ placement: BlockPlacement = "before",
) {
return this._blockManager.insertBlocks(
blocksToInsert,
diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts
index f086444ecc..a33bfcab4b 100644
--- a/packages/core/src/editor/managers/BlockManager.ts
+++ b/packages/core/src/editor/managers/BlockManager.ts
@@ -1,4 +1,7 @@
-import { insertBlocks } from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js";
+import {
+ BlockPlacement,
+ insertBlocks,
+} from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js";
import {
moveBlocksDown,
moveBlocksUp,
@@ -150,13 +153,13 @@ export class BlockManager<
* error if the reference block could not be found.
* @param blocksToInsert An array of partial blocks that should be inserted.
* @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted.
- * @param placement Whether the blocks should be inserted just before, just after, or nested inside the
- * `referenceBlock`.
+ * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next
+ * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children.
*/
public insertBlocks(
blocksToInsert: PartialBlock[],
referenceBlock: BlockIdentifier,
- placement: "before" | "after" = "before",
+ placement: BlockPlacement = "before",
) {
return this.editor.transact((tr) =>
insertBlocks(tr, blocksToInsert, referenceBlock, placement),
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index 853cca2493..83977b943d 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -39,6 +39,7 @@ import {
UniqueID,
} from "../../../extensions/tiptap-extensions/index.js";
import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js";
+import { isContainerType } from "../../../schema/blocks/children.js";
import type {
BlockNoteEditor,
BlockNoteEditorOptions,
@@ -62,7 +63,16 @@ export function getDefaultTiptapExtensions(
UniqueID.configure({
// everything from bnBlock group (nodes that represent a BlockNote block should have an id)
- types: ["blockContainer", "columnList", "column"],
+ types: [
+ "blockContainer",
+ // Container block specs whose PM node is itself in the `bnBlock` group
+ // (column, columnList, callout, etc.) — i.e. the bnBlock node IS the
+ // block, so the id lives on its attrs rather than on a wrapping
+ // blockContainer.
+ ...Object.entries(editor.schema.blockSpecs)
+ .filter(([, spec]) => isContainerType((spec as any).config))
+ .map(([type]) => type),
+ ],
setIdAttribute: options.setIdAttribute,
isWithinEditor: editor.isWithinEditor,
}),
@@ -130,6 +140,16 @@ export function getDefaultTiptapExtensions(
}),
]
: []),
+ // Nodes the block's node depends on but which aren't blocks
+ // themselves (a content-bearing container's content & children nodes).
+ ...("extraNodes" in blockSpec.implementation
+ ? (blockSpec.implementation.extraNodes as Node[]).map((node) =>
+ node.configure({
+ editor: editor,
+ domAttributes: options.domAttributes,
+ }),
+ )
+ : []),
];
}),
createCopyToClipboardExtension(editor),
diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts
index 5cf6e74c1c..71167e8f5a 100644
--- a/packages/core/src/editor/managers/ExtensionManager/index.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/index.ts
@@ -563,7 +563,7 @@ export class ExtensionManager {
const blockInfo = getBlockInfoFromSelection(tr);
if (
- !blockInfo.isBlockContainer ||
+ !blockInfo.isWrappedBlock ||
this.editor.schema.blockSchema[blockInfo.blockNoteType]
?.content !== "inline"
) {
diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts
index 4f0515df95..033df48484 100644
--- a/packages/core/src/editor/transformPasted.ts
+++ b/packages/core/src/editor/transformPasted.ts
@@ -118,7 +118,11 @@ export function transformPasted(slice: Slice, view: EditorView) {
return retyped;
}
- if (isInTableCell(view)) {
+ // `tableParagraph` only exists in schemas with the default table blocks. A
+ // schema with a custom table implementation (e.g. container-block cells,
+ // which hold real blocks and need no inline conversion) skips this branch.
+ const tableParagraph = view.state.schema.nodes.tableParagraph;
+ if (tableParagraph && isInTableCell(view)) {
let hasTableContent = false;
f.descendants((node) => {
if (node.type.isInGroup("tableContent")) {
@@ -128,7 +132,7 @@ export function transformPasted(slice: Slice, view: EditorView) {
if (
!hasTableContent &&
// is the content valid for a table paragraph?
- !view.state.schema.nodes.tableParagraph.validContent(f)
+ !tableParagraph.validContent(f)
) {
// if not, convert the content to inline content
return new Slice(
@@ -213,9 +217,7 @@ function retypeLeadingParagraphForEmptyTarget(
}
const blockInfo = getBlockInfoFromSelection(view.state);
- const target = blockInfo.isBlockContainer
- ? blockInfo.blockContent.node
- : null;
+ const target = blockInfo.isWrappedBlock ? blockInfo.blockContent.node : null;
if (
!target ||
target.type.name === "paragraph" ||
@@ -275,7 +277,7 @@ function shouldApplyFix(fragment: Fragment, view: EditorView) {
// for both paste and drop events. Drop events can potentially cause
// issues as they don't always happen at the current selection.
const blockInfo = getBlockInfoFromSelection(view.state);
- if (blockInfo.isBlockContainer) {
+ if (blockInfo.isWrappedBlock) {
const selectedBlockHasTableContent =
blockInfo.blockContent.node.type.spec.content === "tableRow+";
diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts
index 9c7a2650fd..fabb450c84 100644
--- a/packages/core/src/exporter/Exporter.ts
+++ b/packages/core/src/exporter/Exporter.ts
@@ -11,6 +11,7 @@ import {
StyledText,
Styles,
} from "../schema/index.js";
+import { isContainerType } from "../schema/blocks/children.js";
import type {
BlockMapping,
@@ -60,15 +61,35 @@ export abstract class Exporter<
RS,
TS,
> {
+ // Stored with erased generics: a generically-typed property would change
+ // the class's variance in B/I/S and break mapping inference at subclass
+ // construction sites (the schema param was previously inference-only).
+ private readonly blockNoteSchema: BlockNoteSchema;
+
public constructor(
- _schema: BlockNoteSchema, // only used for type inference
+ schema: BlockNoteSchema,
protected readonly mappings: {
blockMapping: BlockMapping;
inlineContentMapping: InlineContentMapping;
styleMapping: StyleMapping;
},
public readonly options: ExporterOptions,
- ) {}
+ ) {
+ this.blockNoteSchema = schema;
+ }
+
+ /**
+ * Whether a block type is a container block (declares `children`, e.g.
+ * `columnList`, `column`, or a custom callout). Container mappings own the
+ * placement of their children — exporters must not append the children
+ * after the container's own output.
+ */
+ public isContainerBlock(blockType: string): boolean {
+ const spec = (this.blockNoteSchema.blockSpecs as Record)[
+ blockType
+ ];
+ return !!spec && isContainerType(spec.config);
+ }
/**
* The strings this exporter renders into the produced document - the
@@ -129,7 +150,9 @@ export abstract class Exporter<
const mapping = this.mappings.blockMapping[block.type];
if (!mapping) {
throw new Error(
- `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`,
+ this.isContainerBlock(block.type)
+ ? `No mapping found for container block type "${block.type}" — container blocks require an explicit block mapping that places their children.`
+ : `Exporter is missing a block mapping for block type "${block.type}". If this block comes from a separate package, spread that package's exporter mappings into your blockMapping.`,
);
}
return mapping(block, this, nestingLevel, numberedListIndex, children);
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts
index fddd2712e9..dabf5f4d2b 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.ts
@@ -20,8 +20,16 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
+import {
+ ContainerUIInfo,
+ getContainerUIInfo,
+} from "../../api/blockManipulation/containers/containerUI.js";
import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
import { dragStart, unsetDragImage } from "./dragging.js";
+import {
+ getContainerChildAtCursor,
+ hasHorizontalContainerAncestor,
+} from "./sideMenuContainerGeometry.js";
export type SideMenuState<
BSchema extends BlockSchema,
@@ -37,7 +45,8 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250;
function getBlockFromCoords(
view: EditorView,
coords: { left: number; top: number },
- adjustForColumns = true,
+ containerUIInfo: ContainerUIInfo,
+ adjustForHorizontalContainers = true,
) {
const elements = view.root.elementsFromPoint(coords.left, coords.top);
@@ -46,21 +55,28 @@ function getBlockFromCoords(
// probably a ui overlay like formatting toolbar etc
continue;
}
- if (adjustForColumns) {
- const column = element.closest("[data-node-type=columnList]");
- if (column) {
- return getBlockFromCoords(
- view,
- {
- // TODO can we do better than this?
- left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself
- top: coords.top,
- },
- false,
- );
- }
+ if (
+ adjustForHorizontalContainers &&
+ containerUIInfo.containerSelector &&
+ // Inside a container with side-by-side children (e.g. a columnList),
+ // the x position must be offset — the hovered coordinates land in the
+ // side menu's own gutter, which belongs to a different child. The
+ // horizontal container can be any ancestor (the element may sit inside
+ // a vertical child of it, like a block inside a column).
+ hasHorizontalContainerAncestor(element, containerUIInfo)
+ ) {
+ return getBlockFromCoords(
+ view,
+ {
+ // TODO can we do better than this?
+ left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself
+ top: coords.top,
+ },
+ containerUIInfo,
+ false,
+ );
}
- return getDraggableBlockFromElement(element, view);
+ return getDraggableBlockFromElement(element, view, containerUIInfo);
}
return undefined;
}
@@ -71,6 +87,7 @@ function getBlockFromMousePos(
y: number;
},
view: EditorView,
+ containerUIInfo: ContainerUIInfo,
): { node: HTMLElement; id: string } | undefined {
// Editor itself may have padding or other styling which affects
// size/position, so we get the boundingRect of the first child (i.e. the
@@ -94,7 +111,7 @@ function getBlockFromMousePos(
top: mousePos.y,
};
- const referenceBlock = getBlockFromCoords(view, coords);
+ const referenceBlock = getBlockFromCoords(view, coords, containerUIInfo);
if (!referenceBlock) {
// could not find the reference block
@@ -109,15 +126,26 @@ function getBlockFromMousePos(
* ```
* Hovering at position x (left edge of BlockB) would return BlockA.
* Instead, we check at position y (right edge of BlockA) to correctly identify BlockB.
+ * `elementsFromPoint` returns the deepest element at a point, so this single
+ * probe descends through any depth of regular nesting.
+ *
+ * When the reference block is a (draggable) container block, the probe is
+ * aimed at the direct child under the cursor instead of the container
+ * itself — the container's own padding can exceed the probe inset, which
+ * would keep resolving the container even though the cursor is aligned with
+ * one of its children (making the child's menu jump away as the cursor
+ * moves towards it).
*/
- const referenceBlocksBoundingBox =
- referenceBlock.node.getBoundingClientRect();
+ const probeTarget =
+ getContainerChildAtCursor(referenceBlock.node, mousePos, containerUIInfo) ??
+ referenceBlock.node;
return getBlockFromCoords(
view,
{
- left: referenceBlocksBoundingBox.right - 10,
+ left: probeTarget.getBoundingClientRect().right - 10,
top: mousePos.y,
},
+ containerUIInfo,
false,
);
}
@@ -214,7 +242,12 @@ export class SideMenuView<
return;
}
- const block = getBlockFromMousePos(this.mousePos, this.pmView);
+ const containerUIInfo = getContainerUIInfo(this.editor);
+ const block = getBlockFromMousePos(
+ this.mousePos,
+ this.pmView,
+ containerUIInfo,
+ );
// Closes the menu if the mouse cursor is beyond the editor vertically.
if (!block || !this.editor.isEditable) {
@@ -240,7 +273,14 @@ export class SideMenuView<
// Shows or updates elements.
if (this.editor.isEditable) {
const blockContentBoundingBox = block.node.getBoundingClientRect();
- const column = block.node.closest("[data-node-type=column]");
+ // The closest container ancestor (a column, callout, ...) — excluding
+ // the hovered block itself, which may be a draggable container. Blocks
+ // inside a container anchor the side menu to the container's block
+ // area rather than the editor's left edge, which would put the menu
+ // over unrelated content (or off-screen inside columns).
+ const container = containerUIInfo.containerSelector
+ ? block.node.parentElement?.closest(containerUIInfo.containerSelector)
+ : undefined;
const sideMenuBlock = this.editor.getBlock(
this.hoveredBlock!.getAttribute("data-id")!,
);
@@ -255,12 +295,16 @@ export class SideMenuView<
this.state = {
show: true,
referencePos: new DOMRect(
- column
- ? // We take the first child as column elements have some default
- // padding. This is a little weird since this child element will
- // be the first block, but since it's always non-nested and we
- // only take the x coordinate, it's ok.
- column.firstElementChild!.getBoundingClientRect().x
+ container
+ ? // We anchor to the container's first block element (rather
+ // than the container itself, which may have padding or its own
+ // chrome around the block area). This is a little weird since
+ // this element is the first block, but since it's always
+ // non-nested and we only take the x coordinate, it's ok.
+ (
+ container.querySelector('[data-node-type="blockOuter"]') ??
+ container.firstElementChild!
+ ).getBoundingClientRect().x
: (
this.pmView.dom.firstChild as HTMLElement
).getBoundingClientRect().x,
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts
new file mode 100644
index 0000000000..5bb52a71c9
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.browser.test.ts
@@ -0,0 +1,267 @@
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js";
+import {
+ getContainerChildAtCursor,
+ getDirectChildBlocks,
+ hasHorizontalContainerAncestor,
+ isHorizontalContainer,
+} from "./sideMenuContainerGeometry.js";
+
+// The DOM half of the side-menu container geometry: the `querySelectorAll` /
+// `closest` walks that find a container's direct child blocks, and the
+// live-layout claim the module exists for — that a container whose children
+// *happen* to sit side-by-side is recognised as horizontal without declaring
+// anything.
+//
+// Everything here is attached to the real document and laid out by the real
+// engine; nothing stubs `getBoundingClientRect`. The counterpart node suite
+// (`sideMenuContainerGeometry.test.ts`) covers the arithmetic these adapters
+// feed. A column list inside a real editor is covered end-to-end by
+// `tests/src/end-to-end/multicolumn/multicolumn.test.tsx`.
+
+let mounted: HTMLElement[] = [];
+
+afterEach(() => {
+ mounted.forEach((el) => el.remove());
+ mounted = [];
+});
+
+/** Attaches a tree to the document so the browser actually lays it out. */
+function mount(el: T): T {
+ document.body.appendChild(el);
+ mounted.push(el);
+ return el;
+}
+
+function el(nodeType: string): HTMLElement {
+ const node = document.createElement("div");
+ node.setAttribute("data-node-type", nodeType);
+ return node;
+}
+
+/** The `blockOuter > blockContainer` chrome BlockNote renders around every
+ * regular block, with real text in it so it has a real height. */
+function regularChild(text = "block"): {
+ outer: HTMLElement;
+ blockContainer: HTMLElement;
+} {
+ const outer = el("blockOuter");
+ const blockContainer = el("blockContainer");
+ blockContainer.textContent = text;
+ outer.append(blockContainer);
+ return { outer, blockContainer };
+}
+
+function uiInfo(containerTypes: string[]): ContainerUIInfo {
+ const set = new Set(containerTypes);
+ return {
+ containerTypes: set,
+ draggableContainerTypes: set,
+ nonDraggableBlockTypes: new Set(),
+ containerSelector: containerTypes.length
+ ? containerTypes.map((t) => `[data-node-type="${t}"]`).join(",")
+ : null,
+ };
+}
+
+/**
+ * A column list laid out the way the real one is: a flex row of two columns,
+ * each holding one block. Nothing declares "horizontal" — the browser puts the
+ * columns side by side and the module has to notice.
+ */
+function buildColumnList() {
+ const info = uiInfo(["columnList", "column"]);
+
+ const columnList = el("columnList");
+ columnList.style.display = "flex";
+ columnList.style.width = "400px";
+
+ const columnA = el("column");
+ const columnB = el("column");
+ for (const column of [columnA, columnB]) {
+ column.style.flex = "1";
+ }
+
+ const childA = regularChild("A");
+ const childB = regularChild("B");
+ columnA.append(childA.outer);
+ columnB.append(childB.outer);
+ columnList.append(columnA, columnB);
+ mount(columnList);
+
+ return { info, columnList, columnA, columnB, childA, childB };
+}
+
+/** A callout: an ordinary block-flow container, so its children stack. */
+function buildVerticalContainer() {
+ const info = uiInfo(["callout"]);
+
+ const callout = el("callout");
+ callout.style.width = "400px";
+ const first = regularChild("first");
+ const second = regularChild("second");
+ callout.append(first.outer, second.outer);
+ mount(callout);
+
+ return { info, callout, first, second };
+}
+
+describe("getDirectChildBlocks", () => {
+ it("returns direct child blocks, skipping nested grandchildren", () => {
+ const { info, columnList, columnA, columnB } = buildColumnList();
+
+ // The blocks inside each column must not come back as the list's own
+ // children — the `closest` check is what stops the walk one level down.
+ expect(getDirectChildBlocks(columnList, info)).toEqual([columnA, columnB]);
+ });
+
+ it("sees through blockOuter wrappers to the blockContainer child", () => {
+ const { info, columnA, childA } = buildColumnList();
+
+ // The column's own direct child is the wrapped blockContainer, not the
+ // blockOuter chrome (which isn't a block in the selector's sense).
+ expect(getDirectChildBlocks(columnA, info)).toEqual([
+ childA.blockContainer,
+ ]);
+ });
+
+ it("returns nothing for a container with no block children", () => {
+ const info = uiInfo(["callout"]);
+ const empty = mount(el("callout"));
+
+ expect(getDirectChildBlocks(empty, info)).toEqual([]);
+ });
+});
+
+describe("isHorizontalContainer", () => {
+ it("recognises a real flex row as horizontal", () => {
+ const { info, columnList, columnA, columnB } = buildColumnList();
+
+ // The claim the module exists for, asserted against real layout: nothing
+ // declares the column list horizontal, and no rect is stubbed.
+ expect(isHorizontalContainer(columnList, info)).toBe(true);
+
+ // Stated as geometry too, so a failure says whether the layout or the
+ // detection is what broke.
+ const a = columnA.getBoundingClientRect();
+ const b = columnB.getBoundingClientRect();
+ expect(a.width).toBeGreaterThan(0);
+ expect(b.left).toBeGreaterThanOrEqual(a.right - 1);
+ expect(a.top).toBe(b.top);
+ });
+
+ it("is false for a container whose children stack", () => {
+ const { info, callout, first, second } = buildVerticalContainer();
+
+ expect(isHorizontalContainer(callout, info)).toBe(false);
+
+ const a = first.blockContainer.getBoundingClientRect();
+ const b = second.blockContainer.getBoundingClientRect();
+ expect(a.height).toBeGreaterThan(0);
+ expect(b.top).toBeGreaterThanOrEqual(a.bottom);
+ });
+
+ it("is false for a column holding a single block", () => {
+ const { info, columnA } = buildColumnList();
+
+ expect(isHorizontalContainer(columnA, info)).toBe(false);
+ });
+});
+
+describe("hasHorizontalContainerAncestor", () => {
+ it("is true for a block nested inside a column of a column list", () => {
+ const { info, childA } = buildColumnList();
+
+ // The block sits inside a (vertical) column, whose parent column list is
+ // the horizontal one — the walk must climb past the column.
+ expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe(
+ true,
+ );
+ });
+
+ it("is false for a block inside a purely vertical container", () => {
+ const { info, first } = buildVerticalContainer();
+
+ expect(hasHorizontalContainerAncestor(first.blockContainer, info)).toBe(
+ false,
+ );
+ });
+
+ it("is false when there is no container ancestor", () => {
+ const info = uiInfo(["columnList", "column"]);
+ const loose = regularChild();
+ mount(loose.outer);
+
+ expect(hasHorizontalContainerAncestor(loose.blockContainer, info)).toBe(
+ false,
+ );
+ });
+
+ it("is false when the schema declares no containers", () => {
+ const { childA } = buildColumnList();
+
+ expect(
+ hasHorizontalContainerAncestor(childA.blockContainer, uiInfo([])),
+ ).toBe(false);
+ });
+});
+
+describe("getContainerChildAtCursor", () => {
+ it("returns undefined for a non-container element", () => {
+ const { info, childA } = buildColumnList();
+
+ expect(
+ getContainerChildAtCursor(childA.blockContainer, { x: 10, y: 10 }, info),
+ ).toBeUndefined();
+ });
+
+ it("resolves the hovered column of a real row", () => {
+ const { info, columnList, columnA, columnB } = buildColumnList();
+ const b = columnB.getBoundingClientRect();
+
+ expect(
+ getContainerChildAtCursor(
+ columnList,
+ { x: b.left + b.width / 2, y: b.top + b.height / 2 },
+ info,
+ ),
+ ).toBe(columnB);
+
+ const a = columnA.getBoundingClientRect();
+ expect(
+ getContainerChildAtCursor(
+ columnList,
+ { x: a.left + a.width / 2, y: a.top + a.height / 2 },
+ info,
+ ),
+ ).toBe(columnA);
+ });
+
+ it("falls back to the block on that row when x is in the gutter", () => {
+ const { info, callout, first } = buildVerticalContainer();
+ const rect = first.blockContainer.getBoundingClientRect();
+
+ // The cursor's y is in the first block's band but its x is left of the
+ // container entirely — where the side menu renders.
+ expect(
+ getContainerChildAtCursor(
+ callout,
+ { x: rect.left - 20, y: rect.top + rect.height / 2 },
+ info,
+ ),
+ ).toBe(first.blockContainer);
+ });
+
+ it("returns undefined when the cursor is below all children", () => {
+ const { info, callout } = buildVerticalContainer();
+
+ expect(
+ getContainerChildAtCursor(
+ callout,
+ { x: 10, y: callout.getBoundingClientRect().bottom + 500 },
+ info,
+ ),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts
new file mode 100644
index 0000000000..9cbdfcdbd8
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts
@@ -0,0 +1,112 @@
+// @vitest-environment node
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ rectIndexAtCursor,
+ rectsAreSideBySide,
+ type BlockRect,
+} from "./sideMenuContainerGeometry.js";
+
+// The arithmetic half of the side-menu container geometry: pure functions over
+// rects, so there is nothing to stub and no DOM to build. These used to be
+// tested through the DOM adapters with `getBoundingClientRect` monkey-patched
+// onto detached elements — which faked the one input the module exists to read.
+// The adapters (and the claim that a real column list really does lay its
+// children out side-by-side) are covered against real layout in
+// `sideMenuContainerGeometry.browser.test.ts`.
+
+const rect = (
+ top: number,
+ bottom: number,
+ left: number,
+ right: number,
+): BlockRect => ({ top, bottom, left, right });
+
+// Two columns of a column list: same vertical band, adjacent horizontally.
+const SIDE_BY_SIDE = [rect(0, 100, 0, 100), rect(0, 100, 100, 200)];
+// Two blocks of a callout: same horizontal band, stacked vertically with a gap
+// between them (the abutting, gap-free case is its own test below).
+const STACKED = [rect(0, 40, 0, 200), rect(50, 90, 0, 200)];
+
+describe("rectsAreSideBySide", () => {
+ it("is true when two rects overlap vertically", () => {
+ expect(rectsAreSideBySide(SIDE_BY_SIDE)).toBe(true);
+ });
+
+ it("is false when rects are stacked", () => {
+ expect(rectsAreSideBySide(STACKED)).toBe(false);
+ });
+
+ it("is false for a single rect", () => {
+ expect(rectsAreSideBySide([rect(0, 100, 0, 100)])).toBe(false);
+ });
+
+ it("is false for no rects at all", () => {
+ expect(rectsAreSideBySide([])).toBe(false);
+ });
+
+ it("treats abutting (non-overlapping) rects as stacked", () => {
+ // The second rect's top exactly meets the first's bottom — a stack with no
+ // gap must not be misread as a row.
+ expect(
+ rectsAreSideBySide([rect(0, 40, 0, 200), rect(40, 80, 0, 200)]),
+ ).toBe(false);
+ });
+
+ it("finds an overlapping pair that isn't the first two", () => {
+ // The loop is over every pair, not just neighbours: a column list whose
+ // first two children happen to be stacked is still a row.
+ expect(
+ rectsAreSideBySide([
+ rect(0, 40, 0, 100),
+ rect(40, 80, 0, 100),
+ rect(40, 80, 100, 200),
+ ]),
+ ).toBe(true);
+ });
+
+ it("counts even a one-pixel vertical overlap", () => {
+ expect(
+ rectsAreSideBySide([rect(0, 41, 0, 100), rect(40, 80, 0, 100)]),
+ ).toBe(true);
+ });
+});
+
+describe("rectIndexAtCursor", () => {
+ it("returns the rect whose x range contains the cursor (side-by-side)", () => {
+ // Both rects share the y range, so only x distinguishes them. x=150 lands
+ // in the second: the vertical-only fallback recorded for the first must not
+ // win over an x match found later in the list. This is what makes hovering
+ // the second column of a row resolve to it rather than to its neighbour.
+ expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 150, y: 50 })).toBe(1);
+ });
+
+ it("prefers the x match over the first vertical match", () => {
+ // The mirror of the above: x=10 is within the first rect.
+ expect(rectIndexAtCursor(SIDE_BY_SIDE, { x: 10, y: 50 })).toBe(0);
+ });
+
+ it("falls back to the first vertical match when x is in the gutter", () => {
+ // The cursor's y is in the first block's band but its x is left of it (the
+ // side-menu gutter). The first vertical match wins.
+ expect(rectIndexAtCursor(STACKED, { x: -20, y: 20 })).toBe(0);
+ });
+
+ it("returns undefined when the cursor is below every rect", () => {
+ expect(rectIndexAtCursor(STACKED, { x: 10, y: 999 })).toBeUndefined();
+ });
+
+ it("returns undefined when the cursor is above every rect", () => {
+ expect(rectIndexAtCursor(STACKED, { x: 10, y: -999 })).toBeUndefined();
+ });
+
+ it("returns undefined for no rects at all", () => {
+ expect(rectIndexAtCursor([], { x: 10, y: 10 })).toBeUndefined();
+ });
+
+ it("includes the rect edges", () => {
+ const single = [rect(0, 40, 0, 200)];
+ expect(rectIndexAtCursor(single, { x: 0, y: 0 })).toBe(0);
+ expect(rectIndexAtCursor(single, { x: 200, y: 40 })).toBe(0);
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts
new file mode 100644
index 0000000000..87f13f9c13
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts
@@ -0,0 +1,107 @@
+import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js";
+
+function containerChildSelector(containerUIInfo: ContainerUIInfo): string {
+ return containerUIInfo.containerSelector
+ ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}`
+ : `[data-node-type="blockContainer"]`;
+}
+
+export function getDirectChildBlocks(
+ container: Element,
+ containerUIInfo: ContainerUIInfo,
+): Element[] {
+ const childSelector = containerChildSelector(containerUIInfo);
+
+ const children: Element[] = [];
+ for (const child of container.querySelectorAll(childSelector)) {
+ if (child.parentElement?.closest(childSelector) === container) {
+ children.push(child);
+ }
+ }
+ return children;
+}
+
+export type BlockRect = {
+ top: number;
+ bottom: number;
+ left: number;
+ right: number;
+};
+
+export function rectsAreSideBySide(rects: BlockRect[]): boolean {
+ for (let i = 0; i < rects.length; i++) {
+ for (let j = i + 1; j < rects.length; j++) {
+ if (rects[i].top < rects[j].bottom && rects[j].top < rects[i].bottom) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+// X-match wins over y-only match (disambiguates side-by-side children).
+export function rectIndexAtCursor(
+ rects: BlockRect[],
+ mousePos: { x: number; y: number },
+): number | undefined {
+ let verticalMatch: number | undefined = undefined;
+ for (let i = 0; i < rects.length; i++) {
+ const rect = rects[i];
+ if (mousePos.y < rect.top || mousePos.y > rect.bottom) {
+ continue;
+ }
+ if (mousePos.x >= rect.left && mousePos.x <= rect.right) {
+ return i;
+ }
+ verticalMatch = verticalMatch ?? i;
+ }
+ return verticalMatch;
+}
+
+export function isHorizontalContainer(
+ container: Element,
+ containerUIInfo: ContainerUIInfo,
+): boolean {
+ return rectsAreSideBySide(
+ getDirectChildBlocks(container, containerUIInfo).map((child) =>
+ child.getBoundingClientRect(),
+ ),
+ );
+}
+
+export function hasHorizontalContainerAncestor(
+ element: Element,
+ containerUIInfo: ContainerUIInfo,
+): boolean {
+ if (!containerUIInfo.containerSelector) {
+ return false;
+ }
+ let container = element.closest(containerUIInfo.containerSelector);
+ while (container) {
+ if (isHorizontalContainer(container, containerUIInfo)) {
+ return true;
+ }
+ container =
+ container.parentElement?.closest(containerUIInfo.containerSelector) ??
+ null;
+ }
+ return false;
+}
+
+export function getContainerChildAtCursor(
+ element: Element,
+ mousePos: { x: number; y: number },
+ containerUIInfo: ContainerUIInfo,
+): Element | undefined {
+ const nodeType = element.getAttribute("data-node-type");
+ if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) {
+ return undefined;
+ }
+
+ const children = getDirectChildBlocks(element, containerUIInfo);
+ const index = rectIndexAtCursor(
+ children.map((child) => child.getBoundingClientRect()),
+ mousePos,
+ );
+ return index === undefined ? undefined : children[index];
+}
diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts b/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts
new file mode 100644
index 0000000000..97c775267d
--- /dev/null
+++ b/packages/core/src/extensions/getDraggableBlockFromElement.browser.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { getDraggableBlockFromElement } from "./getDraggableBlockFromElement.js";
+
+// These are pure DOM walks (`closest`/`querySelector` over the block chrome),
+// so we build detached trees rather than booting an editor. Only `view.dom` is
+// read, as the stop condition for the upward walk. No layout is involved, but
+// the unit under test *is* the DOM API surface, so it runs against a real
+// browser engine rather than jsdom's re-implementation of it.
+
+/** Builds the `blockOuter > blockContainer > blockContent` chrome BlockNote
+ * renders around every regular block. */
+function regularBlock(
+ id: string,
+ contentType: string,
+): { outer: HTMLElement; blockContainer: HTMLElement; content: HTMLElement } {
+ const outer = document.createElement("div");
+ outer.setAttribute("data-node-type", "blockOuter");
+
+ const blockContainer = document.createElement("div");
+ blockContainer.setAttribute("data-node-type", "blockContainer");
+ blockContainer.setAttribute("data-id", id);
+
+ const content = document.createElement("div");
+ content.setAttribute("data-content-type", contentType);
+
+ blockContainer.append(content);
+ outer.append(blockContainer);
+ return { outer, blockContainer, content };
+}
+
+/** Nests `child` under `parent` in a `blockGroup`, as list nesting does. */
+function nest(parent: HTMLElement, child: HTMLElement) {
+ const group = document.createElement("div");
+ group.setAttribute("data-node-type", "blockGroup");
+ group.append(child);
+ parent.append(group);
+}
+
+function viewWith(root: HTMLElement) {
+ const dom = document.createElement("div");
+ dom.append(root);
+ return { dom };
+}
+
+describe("getDraggableBlockFromElement", () => {
+ it("returns the block container for a regular block", () => {
+ const { outer, blockContainer, content } = regularBlock("a", "paragraph");
+
+ expect(getDraggableBlockFromElement(content, viewWith(outer))).toEqual({
+ node: blockContainer,
+ id: "a",
+ });
+ });
+
+ it("skips a block whose type opts out of dragging", () => {
+ const { outer, content } = regularBlock("a", "lockedBlock");
+
+ expect(
+ getDraggableBlockFromElement(content, viewWith(outer), {
+ nonDraggableBlockTypes: new Set(["lockedBlock"]),
+ }),
+ ).toBeUndefined();
+ });
+
+ it("falls through to the nearest draggable ancestor", () => {
+ const parent = regularBlock("parent", "paragraph");
+ const child = regularBlock("child", "lockedBlock");
+ nest(parent.blockContainer, child.outer);
+
+ // Dragging from inside the locked child should hand back the parent's
+ // handle rather than no handle at all.
+ expect(
+ getDraggableBlockFromElement(child.content, viewWith(parent.outer), {
+ nonDraggableBlockTypes: new Set(["lockedBlock"]),
+ }),
+ ).toEqual({ node: parent.blockContainer, id: "parent" });
+ });
+
+ it("reads the block's own content type, not a nested block's", () => {
+ const parent = regularBlock("parent", "lockedBlock");
+ const child = regularBlock("child", "paragraph");
+ nest(parent.blockContainer, child.outer);
+
+ // `parent`'s own content element precedes the nested `blockGroup`, so the
+ // first `[data-content-type]` match inside it must be "lockedBlock".
+ expect(
+ getDraggableBlockFromElement(parent.content, viewWith(parent.outer), {
+ nonDraggableBlockTypes: new Set(["lockedBlock"]),
+ }),
+ ).toBeUndefined();
+ });
+
+ it("returns a container block only when its type is draggable", () => {
+ const column = document.createElement("div");
+ column.setAttribute("data-node-type", "column");
+ column.setAttribute("data-id", "col");
+
+ expect(
+ getDraggableBlockFromElement(column, viewWith(column), {
+ draggableContainerTypes: new Set(["columnList"]),
+ }),
+ ).toBeUndefined();
+
+ expect(
+ getDraggableBlockFromElement(column, viewWith(column), {
+ draggableContainerTypes: new Set(["column"]),
+ }),
+ ).toEqual({ node: column, id: "col" });
+ });
+});
diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts
index abc6bd2906..188f3d6503 100644
--- a/packages/core/src/extensions/getDraggableBlockFromElement.ts
+++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts
@@ -1,18 +1,59 @@
import { EditorView } from "prosemirror-view";
+const EMPTY_SET: ReadonlySet = new Set();
+
+/**
+ * Walks up from `element` to the closest element that can host a side-menu
+ * drag handle. Both sets are derived from each spec's `meta.draggable` (see
+ * `getContainerUIInfo`); a block that opts out is skipped, so the handle falls
+ * through to the nearest draggable ancestor rather than disappearing.
+ */
export function getDraggableBlockFromElement(
element: Element,
- view: EditorView,
+ // Only `dom` is read — the stop condition for the upward walk.
+ view: Pick,
+ types: {
+ draggableContainerTypes?: ReadonlySet;
+ nonDraggableBlockTypes?: ReadonlySet;
+ } = {},
) {
+ const draggableContainerTypes = types.draggableContainerTypes ?? EMPTY_SET;
+ const nonDraggableBlockTypes = types.nonDraggableBlockTypes ?? EMPTY_SET;
+
+ const isDraggable = (el: Element) => {
+ const nodeType = el.getAttribute?.("data-node-type");
+
+ if (nodeType === "blockContainer") {
+ if (nonDraggableBlockTypes.size === 0) {
+ return true;
+ }
+ // Every regular block shares the `blockContainer` node, so its actual
+ // block type only shows up on its content element. That element comes
+ // before any nested `blockGroup`, so the first match in document order
+ // is this block's own content rather than a descendant's.
+ const contentType = el
+ .querySelector("[data-content-type]")
+ ?.getAttribute("data-content-type");
+
+ return !contentType || !nonDraggableBlockTypes.has(contentType);
+ }
+
+ return (
+ nodeType !== null &&
+ nodeType !== undefined &&
+ draggableContainerTypes.has(nodeType)
+ );
+ };
+
while (
element &&
element.parentElement &&
element.parentElement !== view.dom &&
- element.getAttribute?.("data-node-type") !== "blockContainer"
+ !isDraggable(element)
) {
element = element.parentElement;
}
- if (element.getAttribute?.("data-node-type") !== "blockContainer") {
+ if (!isDraggable(element)) {
return undefined;
}
return { node: element as HTMLElement, id: element.getAttribute("data-id")! };
diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
index 4d1758094a..1e532cdddc 100644
--- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
@@ -1,6 +1,6 @@
import { Extension } from "@tiptap/core";
import { Fragment, Node } from "prosemirror-model";
-import { TextSelection } from "prosemirror-state";
+import { NodeSelection, TextSelection } from "prosemirror-state";
import {
getBottomNestedBlockInfo,
@@ -8,13 +8,27 @@ import {
getParentBlockInfo,
getPrevBlockInfo,
mergeBlocksCommand,
+ mergeIntoContainerContent,
} from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js";
import {
liftItem,
nestBlock,
unnestBlock,
} from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js";
-import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js";
+import {
+ fixContainersById,
+ isContainerNode,
+} from "../../../api/blockManipulation/containers/fixContainer.js";
+import {
+ ascendToInsertablePos,
+ descendToLastInsertionPos,
+ getAncestorContainers,
+ getFirstLeafBlock,
+} from "../../../api/blockManipulation/containers/containerNav.js";
+import {
+ isContentContainerNode,
+ isSealed,
+} from "../../../schema/blocks/children.js";
import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js";
import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import {
@@ -45,7 +59,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -69,7 +83,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state, tr }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { blockContent } = blockInfo;
@@ -87,12 +101,47 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // If the previous sibling is a sealed container, selects it instead
+ // of merging into it: merging into a content-bearing container's
+ // title would cross the sealed boundary. Selection lets a second
+ // Backspace delete the container explicitly.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.isWrappedBlock) {
+ return false;
+ }
+
+ const selectionAtBlockStart =
+ state.selection.from === blockInfo.blockContent.beforePos + 1;
+ if (!selectionAtBlockStart || !state.selection.empty) {
+ return false;
+ }
+
+ const prevBlockInfo = getPrevBlockInfo(
+ state.doc,
+ blockInfo.bnBlock.beforePos,
+ );
+ if (!prevBlockInfo || !isSealed(prevBlockInfo.bnBlock.node)) {
+ return false;
+ }
+
+ if (
+ dispatch &&
+ NodeSelection.isSelectable(prevBlockInfo.bnBlock.node)
+ ) {
+ tr.setSelection(
+ NodeSelection.create(tr.doc, prevBlockInfo.bnBlock.beforePos),
+ ).scrollIntoView();
+ }
+ return true;
+ }),
// Merges block with the previous one if it isn't indented, and the selection is at the start of the
// block. The target block for merging must contain inline content.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
@@ -106,7 +155,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
// return early here.
if (
!prevBlockInfo ||
- !prevBlockInfo.isBlockContainer ||
+ !prevBlockInfo.isWrappedBlock ||
prevBlockInfo.blockContent.node.type.spec.content !== "inline*"
) {
return false;
@@ -127,12 +176,14 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the previous block is a columnList, moves the current block to
- // the end of the last column in it.
+ // If the previous block is a container (e.g. a columnList or a
+ // callout), moves the current block to its deepest trailing insertion
+ // slot — descending through nested containers, e.g. to the end of the
+ // last column.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -146,21 +197,62 @@ export const KeyboardShortcutsExtension = Extension.create<{
state.doc,
blockInfo.bnBlock.beforePos,
);
- if (!prevBlockInfo || prevBlockInfo.isBlockContainer) {
+ // A content-bearing container is `isWrappedBlock` but still a
+ // container to descend into — its non-empty-body merges are
+ // handled by the merge branch above; this catches the rest
+ // (e.g. an empty body, which refuses to merge).
+ if (
+ !prevBlockInfo ||
+ (prevBlockInfo.isWrappedBlock &&
+ !isContentContainerNode(prevBlockInfo.bnBlock.node))
+ ) {
return false;
}
- if (dispatch) {
- const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1;
- const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1);
+ const insertionPos = descendToLastInsertionPos(
+ prevBlockInfo.bnBlock.node,
+ prevBlockInfo.bnBlock.beforePos,
+ state.schema.nodes["blockContainer"],
+ { respectSealed: true },
+ );
+ if (insertionPos === null) {
+ // When only a sealed boundary blocked the descent, the
+ // container can't be entered — so it's selected instead, and a
+ // second Backspace deletes it explicitly. A container with
+ // nowhere a `blockContainer` can land falls through as before.
+ // (The probe descends without `respectSealed`, i.e. through
+ // seals.)
+ const blockedBySeal =
+ descendToLastInsertionPos(
+ prevBlockInfo.bnBlock.node,
+ prevBlockInfo.bnBlock.beforePos,
+ state.schema.nodes["blockContainer"],
+ ) !== null;
+ if (
+ blockedBySeal &&
+ NodeSelection.isSelectable(prevBlockInfo.bnBlock.node)
+ ) {
+ if (dispatch) {
+ tr.setSelection(
+ NodeSelection.create(
+ tr.doc,
+ prevBlockInfo.bnBlock.beforePos,
+ ),
+ ).scrollIntoView();
+ }
+ return true;
+ }
+ return false;
+ }
+ if (dispatch) {
tr.delete(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
);
- tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node);
+ tr.insert(insertionPos, blockInfo.bnBlock.node);
tr.setSelection(
- TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)),
+ TextSelection.near(tr.doc.resolve(insertionPos + 1)),
);
return true;
@@ -168,13 +260,55 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the block is the first in a column, moves it to the end of the
- // previous column. If there is no previous column, moves it above the
- // columnList.
+ // If the block is the first child of a container that has its own
+ // content, merges it into that content — the mirror of the Delete
+ // case. A *pure* container has nothing to merge into, so it falls
+ // through to the "move it out" branch below, as before.
+ () =>
+ commands.command(({ state, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.isWrappedBlock) {
+ return false;
+ }
+
+ const selectionAtBlockStart =
+ state.selection.from === blockInfo.blockContent.beforePos + 1;
+ if (!selectionAtBlockStart || !state.selection.empty) {
+ return false;
+ }
+
+ // Only the container's first child.
+ if (state.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore) {
+ return false;
+ }
+
+ const parentInfo = getParentBlockInfo(
+ state.doc,
+ blockInfo.bnBlock.beforePos,
+ );
+ if (
+ !parentInfo ||
+ !isContentContainerNode(parentInfo.bnBlock.node)
+ ) {
+ return false;
+ }
+
+ return mergeIntoContainerContent(
+ state,
+ dispatch,
+ parentInfo,
+ blockInfo,
+ );
+ }),
+ // If the block is the first in a container (e.g. a column or a
+ // callout), moves it out: to the end of the previous sibling
+ // container if there is one (e.g. the previous column), otherwise to
+ // just before the closest enclosing boundary that accepts it (e.g.
+ // above the columnList / callout).
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -192,32 +326,63 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const parentBlock = $pos.node();
- if (parentBlock.type.name !== "column") {
+ if (!isContainerNode(parentBlock.type)) {
return false;
}
- const $blockPos = tr.doc.resolve(blockInfo.bnBlock.beforePos);
- const $columnPos = tr.doc.resolve($blockPos.before());
- const columnListPos = $columnPos.before();
+ // A sealed container swallows Backspace at its first block:
+ // moving the block out would cross the boundary.
+ if (isSealed(parentBlock)) {
+ return true;
+ }
+
+ const blockContainerType = state.schema.nodes["blockContainer"];
+ const containerBeforePos = $pos.before();
+ const $containerPos = tr.doc.resolve(containerBeforePos);
+
+ // A previous sibling inside an enclosing container (e.g. the
+ // previous column) is a target to descend into. A sibling at a
+ // regular block position is not — there the block moves out to
+ // before the container instead.
+ const prevSibling =
+ isContainerNode($containerPos.node().type) &&
+ $containerPos.nodeBefore &&
+ isContainerNode($containerPos.nodeBefore.type)
+ ? $containerPos.nodeBefore
+ : null;
+
+ const insertionPos = prevSibling
+ ? descendToLastInsertionPos(
+ prevSibling,
+ containerBeforePos - prevSibling.nodeSize,
+ blockContainerType,
+ { respectSealed: true },
+ )
+ : ascendToInsertablePos(
+ tr.doc,
+ containerBeforePos,
+ blockContainerType,
+ { respectSealed: true },
+ );
+ if (insertionPos === null) {
+ return false;
+ }
if (dispatch) {
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ blockInfo.bnBlock.beforePos,
+ );
+
tr.delete(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
);
- fixColumnList(tr, columnListPos);
-
- if ($columnPos.pos === columnListPos + 1) {
- tr.insert(columnListPos, blockInfo.bnBlock.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve(columnListPos)),
- );
- } else {
- tr.insert($columnPos.pos - 1, blockInfo.bnBlock.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve($columnPos.pos)),
- );
- }
+ tr.insert(insertionPos, blockInfo.bnBlock.node);
+ fixContainersById(tr, containersToFix);
+ tr.setSelection(
+ TextSelection.near(tr.doc.resolve(insertionPos + 1)),
+ );
}
return true;
@@ -227,7 +392,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -247,12 +412,12 @@ export const KeyboardShortcutsExtension = Extension.create<{
state.doc,
prevBlockInfo,
);
- if (!bottomNestedPrevBlockInfo.isBlockContainer) {
+ if (!bottomNestedPrevBlockInfo.isWrappedBlock) {
return false;
}
if (
!bottomNestedPrevBlockInfo ||
- !bottomNestedPrevBlockInfo.isBlockContainer
+ !bottomNestedPrevBlockInfo.isWrappedBlock
) {
return false;
}
@@ -313,7 +478,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -327,12 +492,21 @@ export const KeyboardShortcutsExtension = Extension.create<{
);
if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) {
+ // The sealed-aware descent stops at a sealed container instead
+ // of finding an (empty) block inside it, so the current block
+ // is never cut in across the boundary.
const bottomBlock = getBottomNestedBlockInfo(
state.doc,
prevBlockInfo,
+ { stopAtSealed: true },
);
- if (!bottomBlock.isBlockContainer) {
+ if (!bottomBlock.isWrappedBlock) {
+ return false;
+ }
+ // A sealed content container also stops the descent; deleting
+ // it here would take its children with it.
+ if (isSealed(bottomBlock.bnBlock.node)) {
return false;
}
@@ -375,11 +549,17 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer || !blockInfo.childContainer) {
+ if (!blockInfo.isWrappedBlock || !blockInfo.childContainer) {
return false;
}
const { blockContent, childContainer } = blockInfo;
+ // A container allowed to hold no children still has a child
+ // container node, but no first child to pull anything out of.
+ if (childContainer.node.childCount === 0) {
+ return false;
+ }
+
const selectionAtBlockEnd =
state.selection.from === blockContent.afterPos - 1;
const selectionEmpty = state.selection.empty;
@@ -387,7 +567,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
const firstChildBlockInfo = getBlockInfoFromResolvedPos(
state.doc.resolve(childContainer.beforePos + 1),
);
- if (!firstChildBlockInfo.isBlockContainer) {
+ if (!firstChildBlockInfo.isWrappedBlock) {
return false;
}
@@ -408,8 +588,12 @@ export const KeyboardShortcutsExtension = Extension.create<{
Fragment.empty,
)
.deleteRange(
- // Deletes whole child container if there's only one child.
- childContainer.node.childCount === 1
+ // Deletes whole child container if there's only one child
+ // — but a container with its own content always has one
+ // (its children node is part of its content expression),
+ // so there only the child itself goes.
+ childContainer.node.childCount === 1 &&
+ !isContentContainerNode(blockInfo.bnBlock.node)
? {
from: childContainer.beforePos,
to: childContainer.afterPos,
@@ -434,13 +618,47 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // If the next sibling is a sealed container, selects it instead of
+ // merging it in — the Delete mirror of the sealed-previous-sibling
+ // Backspace case.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.isWrappedBlock) {
+ return false;
+ }
+
+ const selectionAtBlockEnd =
+ state.selection.from === blockInfo.blockContent.afterPos - 1;
+ if (!selectionAtBlockEnd || !state.selection.empty) {
+ return false;
+ }
+
+ const nextBlockInfo = getNextBlockInfo(
+ state.doc,
+ blockInfo.bnBlock.beforePos,
+ );
+ if (!nextBlockInfo || !isSealed(nextBlockInfo.bnBlock.node)) {
+ return false;
+ }
+
+ if (
+ dispatch &&
+ NodeSelection.isSelectable(nextBlockInfo.bnBlock.node)
+ ) {
+ tr.setSelection(
+ NodeSelection.create(tr.doc, nextBlockInfo.bnBlock.beforePos),
+ ).scrollIntoView();
+ }
+ return true;
+ }),
// Merges block with the next one (at the same nesting level or lower),
// if one exists, the block has no children, and the selection is at the
// end of the block.
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
@@ -449,7 +667,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
state.doc,
blockInfo.bnBlock.beforePos,
);
- if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) {
+ if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) {
return false;
}
@@ -468,12 +686,12 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the next block is a columnList, moves the first block from its
- // first column to after the current block.
+ // If the next block is a container (e.g. a columnList or a callout),
+ // moves its first leaf block out, to after the current block.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -487,22 +705,33 @@ export const KeyboardShortcutsExtension = Extension.create<{
state.doc,
blockInfo.bnBlock.beforePos,
);
- if (!nextBlockInfo || nextBlockInfo.isBlockContainer) {
+ if (!nextBlockInfo || nextBlockInfo.isWrappedBlock) {
+ return false;
+ }
+
+ const firstLeaf = getFirstLeafBlock(
+ nextBlockInfo.bnBlock.node,
+ nextBlockInfo.bnBlock.beforePos,
+ { respectSealed: true },
+ );
+ if (!firstLeaf) {
return false;
}
if (dispatch) {
- const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1;
- const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1);
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ firstLeaf.beforePos,
+ );
tr.delete(
- $blockBeforePos.pos,
- $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize,
+ firstLeaf.beforePos,
+ firstLeaf.beforePos + firstLeaf.node.nodeSize,
);
- fixColumnList(tr, nextBlockInfo.bnBlock.beforePos);
- tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!);
+ tr.insert(blockInfo.bnBlock.afterPos, firstLeaf.node);
+ fixContainersById(tr, containersToFix);
tr.setSelection(
- TextSelection.near(tr.doc.resolve($blockBeforePos.pos)),
+ TextSelection.near(tr.doc.resolve(firstLeaf.beforePos)),
);
return true;
@@ -510,13 +739,14 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the block is the last in a column, moves it to the start of the
- // next column. If there is no next column, moves it below the
- // columnList.
+ // If the block is the last in a container (e.g. a column or a
+ // callout), moves the next block — the first leaf of the next sibling
+ // container, or the block following the enclosing containers — to
+ // after it.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -534,36 +764,56 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const parentBlock = $pos.node();
- if (parentBlock.type.name !== "column") {
+ if (!isContainerNode(parentBlock.type)) {
+ return false;
+ }
+
+ // Climbs out of the containers the block is the last child of,
+ // to the first position with a following node.
+ let $boundary = $pos;
+ while (
+ $boundary.nodeAfter === null &&
+ $boundary.depth > 0 &&
+ isContainerNode($boundary.node().type)
+ ) {
+ // Pulling a block in from past a sealed boundary would cross
+ // it, so the keystroke is swallowed instead.
+ if (isSealed($boundary.node())) {
+ return true;
+ }
+ $boundary = tr.doc.resolve($boundary.after());
+ }
+
+ const nextNode = $boundary.nodeAfter;
+ if (!nextNode) {
return false;
}
- const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos);
- const $columnEndPos = tr.doc.resolve($blockEndPos.after());
- const columnListEndPos = $columnEndPos.after();
+ // The block to pull in: the next node itself or — when it's a
+ // container — its first leaf block.
+ const target = isContainerNode(nextNode.type)
+ ? getFirstLeafBlock(nextNode, $boundary.pos, {
+ respectSealed: true,
+ })
+ : { node: nextNode, beforePos: $boundary.pos };
+ if (!target) {
+ return false;
+ }
if (dispatch) {
- // Position before first block in next column, or first block
- // after columnList if there is no next column.
- const nextBlockBeforePos =
- $columnEndPos.pos === columnListEndPos - 1
- ? columnListEndPos
- : $columnEndPos.pos + 1;
- const nextBlockInfo = getBlockInfoFromResolvedPos(
- tr.doc.resolve(nextBlockBeforePos),
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ target.beforePos,
);
tr.delete(
- nextBlockInfo.bnBlock.beforePos,
- nextBlockInfo.bnBlock.afterPos,
+ target.beforePos,
+ target.beforePos + target.node.nodeSize,
);
- fixColumnList(
- tr,
- columnListEndPos - $columnEndPos.node().nodeSize,
- );
- tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node);
+ tr.insert(blockInfo.bnBlock.afterPos, target.node);
+ fixContainersById(tr, containersToFix);
tr.setSelection(
- TextSelection.near(tr.doc.resolve(nextBlockBeforePos)),
+ TextSelection.near(tr.doc.resolve(target.beforePos)),
);
}
@@ -577,7 +827,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { blockContent } = blockInfo;
@@ -597,7 +847,12 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const parentBlockInfo = getParentBlockInfo(doc, beforePos);
- if (!parentBlockInfo) {
+ if (
+ !parentBlockInfo ||
+ // Never climbs past a sealed boundary — a block found
+ // there would be pulled in across it.
+ isSealed(parentBlockInfo.bnBlock.node)
+ ) {
return undefined;
}
@@ -611,7 +866,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
state.doc,
blockInfo.bnBlock.beforePos,
);
- if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) {
+ if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) {
return false;
}
@@ -653,7 +908,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -666,7 +921,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
state.doc,
blockInfo.bnBlock.beforePos,
);
- if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) {
+ if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) {
return false;
}
@@ -715,7 +970,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
commands.command(({ state }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
@@ -730,7 +985,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
if (!nextBlockInfo) {
return false;
}
- if (!nextBlockInfo.isBlockContainer) {
+ if (!nextBlockInfo.isWrappedBlock) {
return false;
}
@@ -770,7 +1025,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state, tr }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
@@ -859,12 +1114,143 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // Enter inside the content of a container that has children of its own
+ // (a toggle's title): everything after the cursor becomes a new first
+ // child, and the cursor moves into it. At the end of the title that's
+ // a new empty first child. Without this, the generic split below would
+ // try to split the container itself.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (
+ !blockInfo.isWrappedBlock ||
+ !blockInfo.childContainer ||
+ !isContentContainerNode(blockInfo.bnBlock.node)
+ ) {
+ return false;
+ }
+ const { blockContent, childContainer } = blockInfo;
+
+ const titleEndPos = blockContent.afterPos - 1;
+ if (
+ state.selection.from < blockContent.beforePos + 1 ||
+ state.selection.to > titleEndPos
+ ) {
+ return false;
+ }
+
+ if (dispatch) {
+ // The tail of the title — empty when the cursor is at its end.
+ const tail = blockContent.node.content.cut(
+ state.selection.to - blockContent.beforePos - 1,
+ );
+ const newChild = state.schema.nodes[
+ "blockContainer"
+ ].createAndFill(
+ undefined,
+ state.schema.nodes["paragraph"].create(undefined, tail),
+ )!;
+
+ // Removes the tail (and anything selected) from the title, then
+ // prepends it to the container's children.
+ tr.delete(state.selection.from, titleEndPos);
+ const insertionPos = tr.mapping.map(childContainer.beforePos + 1);
+ tr.insert(insertionPos, newChild);
+ tr.setSelection(
+ TextSelection.near(tr.doc.resolve(insertionPos + 1)),
+ );
+ tr.scrollIntoView();
+ }
+
+ return true;
+ }),
+ // If the block is empty and the last child of a non-sealed container,
+ // moves the block out — the "double-Enter escapes" gesture. The
+ // block lands at the nearest enclosing position that accepts it
+ // (e.g. out of a column it skips the columnList, which holds only
+ // columns, and lands below it). Without this, Enter only ever
+ // creates new blocks *within* the container, so a trailing container
+ // could trap the cursor. Spacing inside a container is Shift+Enter's
+ // job, which keeps Enter unambiguous here.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.isWrappedBlock) {
+ return false;
+ }
+
+ const selectionEmpty =
+ state.selection.anchor === state.selection.head;
+ const blockEmpty = blockInfo.blockContent.node.childCount === 0;
+ if (!selectionEmpty || !blockEmpty) {
+ return false;
+ }
+
+ const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos);
+ const parentBlock = $pos.node();
+ if (!isContainerNode(parentBlock.type)) {
+ return false;
+ }
+
+ // Only fires on the container's last child.
+ if (tr.doc.resolve(blockInfo.bnBlock.afterPos).nodeAfter !== null) {
+ return false;
+ }
+
+ // A sealed boundary means Enter never moves content out.
+ if (isSealed(parentBlock)) {
+ return false;
+ }
+
+ const containerAfterPos = ascendToInsertablePos(
+ tr.doc,
+ $pos.after(),
+ state.schema.nodes["blockContainer"],
+ { respectSealed: true },
+ "after",
+ );
+ if (containerAfterPos === null) {
+ return false;
+ }
+
+ if (dispatch) {
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ blockInfo.bnBlock.beforePos,
+ );
+
+ tr.delete(
+ blockInfo.bnBlock.beforePos,
+ blockInfo.bnBlock.afterPos,
+ );
+ // The insertion position, mapped through the deletion (and any
+ // schema-driven refill it triggered).
+ const insertionPos = tr.mapping.map(containerAfterPos);
+ tr.insert(insertionPos, blockInfo.bnBlock.node);
+ const stepsBeforeFix = tr.steps.length;
+ fixContainersById(tr, containersToFix);
+ // The exited container lies *before* the inserted block, so a
+ // repair that rewrites it (an emptied column unwrapping its
+ // list, say) shifts the block — map the position through the
+ // repair's steps before placing the caret.
+ tr.setSelection(
+ TextSelection.near(
+ tr.doc.resolve(
+ tr.mapping.slice(stepsBeforeFix).map(insertionPos) + 1,
+ ),
+ ),
+ );
+ tr.scrollIntoView();
+ }
+
+ return true;
+ }),
// Creates a new block and moves the selection to it if the current one is empty, while the selection is also
// empty & at the start of the block.
() =>
commands.command(({ state, dispatch, tr }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { bnBlock: blockContainer, blockContent } = blockInfo;
@@ -920,7 +1306,7 @@ export const KeyboardShortcutsExtension = Extension.create<{
() =>
commands.command(({ state, chain }) => {
const blockInfo = getBlockInfoFromSelection(state);
- if (!blockInfo.isBlockContainer) {
+ if (!blockInfo.isWrappedBlock) {
return false;
}
const { blockContent } = blockInfo;
diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
index 7ab30b78aa..c6c57a72c9 100644
--- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
+++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts
@@ -67,9 +67,12 @@ const UniqueID = Extension.create({
setIdAttribute: false,
isWithinEditor: undefined as ((element: Element) => boolean) | undefined,
generateID: () => {
- // Use mock ID if tests are running.
- if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) {
- const testOptions = (window as any).__TEST_OPTIONS;
+ // Use mock ID if tests are running. Resolved off `globalThis` rather
+ // than a bare `window` so that tests running in the plain `node`
+ // environment (no `window`) still get deterministic IDs.
+ const testHost: any = (globalThis as any).window ?? globalThis;
+ if (testHost.__TEST_OPTIONS) {
+ const testOptions = testHost.__TEST_OPTIONS;
if (testOptions.mockID === undefined) {
testOptions.mockID = 0;
} else {
diff --git a/packages/core/src/fonts/inter.css b/packages/core/src/fonts/inter.css
index 57337cdd50..6e152551bf 100644
--- a/packages/core/src/fonts/inter.css
+++ b/packages/core/src/fonts/inter.css
@@ -9,7 +9,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-100.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-100.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-200 - latin */
@font-face {
@@ -20,7 +20,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-200.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-200.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-300 - latin */
@font-face {
@@ -31,7 +31,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-300.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-300.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-regular - latin */
@font-face {
@@ -42,7 +42,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-regular.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-regular.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-500 - latin */
@font-face {
@@ -53,7 +53,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-500.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-500.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-600 - latin */
@font-face {
@@ -64,7 +64,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-600.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-600.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-700 - latin */
@font-face {
@@ -75,7 +75,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-700.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-700.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-800 - latin */
@font-face {
@@ -86,7 +86,7 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-800.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-800.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
/* inter-900 - latin */
@font-face {
@@ -97,5 +97,5 @@
local(""),
url("./inter-v12-latin/inter-v12-latin-900.woff2") format("woff2"),
/* Chrome 26+, Opera 23+, Firefox 39+ */
- url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
+ url("./inter-v12-latin/inter-v12-latin-900.woff") format("woff"); /* Chrome 6+, Firefox 3.6+, IE 9+, Safari 5.1+ */
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index b4f220e1e2..f8f89f007a 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,11 @@
export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js";
export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js";
-export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js";
+// The rest of the container machinery — repair, navigation, UI info, the node
+// groups and the generated node names — is on `@blocknote/core/internal`.
+// `isContainerNode` stays here: it answers a schema-level question ("is this
+// node type a container?") that integrations legitimately ask. It is defined
+// in `children.ts` and re-exported via `fixContainer.ts`.
+export { isContainerNode } from "./api/blockManipulation/containers/fixContainer.js";
export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js";
export * from "./api/exporters/html/externalHTMLExporter.js";
export * from "./api/exporters/html/internalHTMLSerializer.js";
diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts
new file mode 100644
index 0000000000..ec9825fc69
--- /dev/null
+++ b/packages/core/src/internal.ts
@@ -0,0 +1,74 @@
+/**
+ * `@blocknote/core/internal`
+ *
+ * BlockNote's own machinery, exposed so the packages built on top of core
+ * (`@blocknote/react`, `@blocknote/xl-multi-column`, …) and BlockNote's tests
+ * can reach it — not part of the public API. Anything here may change in any
+ * release, without a major version bump or a deprecation.
+ *
+ * The public counterparts stay on the root entrypoint: `isContainerType`,
+ * `isContainerNode`, and the `children` config types (`ChildrenConfig`,
+ * `ChildrenAllow`).
+ */
+
+// How a `children` config compiles to a ProseMirror content expression, and the
+// node groups and generated node names that fall out of it.
+export {
+ ANY_CONTAINER_GROUP,
+ BLOCK_GROUP_CHILD_GROUP,
+ CHILD_CONTAINER_GROUP,
+ CONTAINER_CONTENT_GROUP,
+ CONTAINER_NODE_PRIORITY,
+ blockTypeOfContainerChildrenNode,
+ blockTypeOfContainerContentNode,
+ childrenContentExpression,
+ containerChildrenNodeName,
+ containerContentNodeName,
+ containerNodePriority,
+ getChildrenConfig,
+ getContentContainerNodeTypes,
+ isContainerBlockNode,
+ isContentContainerNode,
+ isPlaceableAnywhere,
+ resolveChildren,
+} from "./schema/blocks/children.js";
+
+// Validation of `children` configs, run when a schema is built.
+export {
+ validateChildrenConfigs,
+ validateContainerRunsBefore,
+} from "./schema/blocks/validateChildren.js";
+
+export { assertContainerSchemaInvariants } from "./schema/blocks/assertSchemaInvariants.js";
+
+// The attributes a container block's root element carries, and the three ways
+// they get there (node view, HTML serialization, framework render).
+export {
+ applyContainerAttributes,
+ fillContainerAttributes,
+ getContainerAttributes,
+} from "./schema/blocks/containerAttributes.js";
+
+// Repairing a container after its children changed.
+export {
+ fixContainer,
+ fixContainersById,
+ flattenNonInsertableBlocks,
+ isEmptyContainerChild,
+ removeEmptyChildren,
+} from "./api/blockManipulation/containers/fixContainer.js";
+
+// Position-based navigation through arbitrarily nested containers.
+export {
+ ascendToInsertablePos,
+ descendToFirstInsertionPos,
+ descendToLastInsertionPos,
+ getAncestorContainers,
+ getFirstLeafBlock,
+} from "./api/blockManipulation/containers/containerNav.js";
+
+// What the side menu and drag handle need to know about a schema's containers.
+export {
+ getContainerUIInfo,
+ type ContainerUIInfo,
+} from "./api/blockManipulation/containers/containerUI.js";
diff --git a/packages/core/src/schema/blocks/assertSchemaInvariants.ts b/packages/core/src/schema/blocks/assertSchemaInvariants.ts
new file mode 100644
index 0000000000..baeb5a6e13
--- /dev/null
+++ b/packages/core/src/schema/blocks/assertSchemaInvariants.ts
@@ -0,0 +1,101 @@
+import { Fragment, type Schema } from "prosemirror-model";
+
+import {
+ ANY_CONTAINER_GROUP,
+ getChildrenConfig,
+ isContainerNode,
+ isPlaceableAnywhere,
+} from "./children.js";
+
+/**
+ * Checks the structural properties the rest of the container machinery
+ * assumes, once, when the ProseMirror schema is built.
+ *
+ * Each used to be guaranteed only by a chain of implicit reasoning spread
+ * across several files. Asserting them here turns a class of "works until it
+ * silently doesn't" bugs into a startup error naming the cause.
+ */
+export function assertContainerSchemaInvariants(pmSchema: Schema) {
+ assertBlockGroupFillsWithBlockContainer(pmSchema);
+ assertContainersAreFillable(pmSchema);
+ assertAnyContainerGroupMatchesConfigs(pmSchema);
+}
+
+/**
+ * `blockGroup` must auto-fill with `blockContainer` rather than with some
+ * container block type.
+ *
+ * Today this holds because container nodes register below `blockContainer`'s
+ * priority, which drives TipTap's registration order, which drives the order
+ * ProseMirror resolves a group into types, which drives what `fillBefore`
+ * picks. That's four implicit links, and Yjs document initialization depends
+ * on the result (see `FixUpSchema`, which reads the first auto-filled child
+ * expecting it to be the id-carrying `blockContainer`).
+ */
+function assertBlockGroupFillsWithBlockContainer(pmSchema: Schema) {
+ const defaultType = pmSchema.nodes["blockGroup"]?.contentMatch.defaultType;
+
+ if (defaultType?.name !== "blockContainer") {
+ throw new Error(
+ `BlockNote schema invariant broken: \`blockGroup\` auto-fills with "${defaultType?.name}" instead of "blockContainer". ` +
+ "Container block nodes must register at a lower priority than `blockContainer` (see CONTAINER_NODE_PRIORITY). " +
+ "Yjs document initialization depends on this (see FixUpSchema).",
+ );
+ }
+}
+
+/**
+ * The `anyContainer` group must contain exactly the container blocks
+ * placeable anywhere — it's what the `allow` container wildcards (`"any"`,
+ * `"containers"`) compile to. Generated nodes always get this right; a
+ * hand-written container node that forgets the group would silently drop out
+ * of every wildcard `allow`, so the mismatch is surfaced here instead.
+ */
+function assertAnyContainerGroupMatchesConfigs(pmSchema: Schema) {
+ for (const type of Object.values(pmSchema.nodes)) {
+ const blockConfig = type.spec.blockConfig;
+ // Only a block's own node — generated `__content`/`__children` nodes
+ // carry their owning block's config under a different node name.
+ if (!blockConfig || blockConfig.type !== type.name) {
+ continue;
+ }
+
+ const shouldBeInGroup =
+ getChildrenConfig(blockConfig) !== undefined &&
+ isPlaceableAnywhere(blockConfig);
+ if (shouldBeInGroup !== type.isInGroup(ANY_CONTAINER_GROUP)) {
+ throw new Error(
+ shouldBeInGroup
+ ? `BlockNote schema invariant broken: container block "${type.name}" is placeable anywhere but its node is not in the "${ANY_CONTAINER_GROUP}" group, ` +
+ `so wildcard \`allow\` containers would not accept it. A hand-written container node must include the group itself.`
+ : `BlockNote schema invariant broken: node "${type.name}" is in the "${ANY_CONTAINER_GROUP}" group but its block config does not make it a container placeable anywhere.`,
+ );
+ }
+ }
+}
+
+/**
+ * Every container must be creatable empty, or inserting one throws a raw
+ * ProseMirror error at the call site instead of here.
+ *
+ * This is the empirical version of "is this `children` config buildable" — it
+ * asks ProseMirror rather than trying to re-derive the answer from the config,
+ * so it catches combinations no hand-written check would think to cover.
+ * `whenEmptied: "refill"`'s empty-fill fallback leans on the same
+ * `fillBefore`, so this also guarantees that a refill repair can always
+ * complete.
+ */
+function assertContainersAreFillable(pmSchema: Schema) {
+ for (const type of Object.values(pmSchema.nodes)) {
+ if (!isContainerNode(type)) {
+ continue;
+ }
+
+ if (!type.contentMatch.fillBefore(Fragment.empty, true)) {
+ throw new Error(
+ `Container block "${type.name}" can never be created empty: its \`children\` config compiles to \`${type.spec.content}\`, ` +
+ "which ProseMirror cannot auto-fill. Lower the minimum child count, or allow regular blocks.",
+ );
+ }
+ }
+}
diff --git a/packages/core/src/schema/blocks/children.test.ts b/packages/core/src/schema/blocks/children.test.ts
new file mode 100644
index 0000000000..d9f84a8250
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.test.ts
@@ -0,0 +1,99 @@
+// @vitest-environment node
+import { describe, expect, it } from "vite-plus/test";
+
+import { childrenContentExpression, resolveChildren } from "./children.js";
+import type { ChildrenConfig } from "./types.js";
+
+// The content expression is the whole enforcement story — if this table is
+// right, `allow`/`min`/`max` are enforced by ProseMirror itself.
+const CASES: [string, ChildrenConfig, string][] = [
+ [
+ "any block, at least one (the minimal config)",
+ { allow: "any" },
+ "blockGroupChild+",
+ ],
+ ["any block, possibly none", { allow: "any", min: 0 }, "blockGroupChild*"],
+ [
+ "any block, exactly one",
+ { allow: "any", min: 1, max: 1 },
+ "blockGroupChild",
+ ],
+ ["any block, two or more", { allow: "any", min: 2 }, "blockGroupChild{2,}"],
+ [
+ "any block, two to four",
+ { allow: "any", min: 2, max: 4 },
+ "blockGroupChild{2,4}",
+ ],
+ [
+ "any block, at most one",
+ { allow: "any", min: 0, max: 1 },
+ "blockGroupChild?",
+ ],
+ [
+ "any block, exactly three",
+ { allow: "any", min: 3, max: 3 },
+ "blockGroupChild{3}",
+ ],
+ ["regular blocks only", { allow: "blocks" }, "blockContainer+"],
+ ["one container type only", { allow: ["column"], min: 2 }, "column{2,}"],
+ [
+ "several container types",
+ { allow: ["column", "card"] },
+ "(column | card)+",
+ ],
+ [
+ "any container but no regular blocks",
+ { allow: "containers" },
+ "anyContainer+",
+ ],
+];
+
+describe("childrenContentExpression", () => {
+ it.each(CASES)("%s", (_name, config, expected) => {
+ expect(childrenContentExpression(config)).toBe(expected);
+ });
+});
+
+describe("resolveChildren", () => {
+ // The four `allow` forms and what they desugar to. The compiled expressions
+ // above are a direct function of this table.
+ it.each([
+ ["any", { blocks: true, containers: true }],
+ ["blocks", { blocks: true, containers: [] }],
+ ["containers", { blocks: false, containers: true }],
+ [["column"], { blocks: false, containers: ["column"] }],
+ ] as const)("resolves allow %j", (allow, expected) => {
+ expect(resolveChildren({ allow })).toMatchObject(expected);
+ });
+
+ it("defaults `min` to 1 and `max` to unbounded", () => {
+ const resolved = resolveChildren({ allow: "any" });
+ expect(resolved.min).toBe(1);
+ expect(resolved.max).toBeUndefined();
+ });
+
+ it("returns the same object for the same config", () => {
+ // Downstream code resolves the same config object on every node build and
+ // repair pass, and must never mutate the user's object.
+ const config: ChildrenConfig = { allow: "any", min: 1 };
+ expect(resolveChildren(config)).toBe(resolveChildren(config));
+ expect(config).toEqual({ allow: "any", min: 1 });
+ });
+
+ it("defaults `whenEmptied` to refill", () => {
+ expect(resolveChildren({ allow: "any" }).whenEmptied).toBe("refill");
+ expect(
+ resolveChildren({ allow: "any", whenEmptied: "unwrap" }).whenEmptied,
+ ).toBe("unwrap");
+ });
+
+ it("defaults the boundary to isolated", () => {
+ expect(resolveChildren({ allow: "any" }).boundary).toBe("isolated");
+ expect(resolveChildren({ allow: "any", boundary: "open" }).boundary).toBe(
+ "open",
+ );
+ expect(resolveChildren({ allow: "any", boundary: "sealed" }).boundary).toBe(
+ "sealed",
+ );
+ });
+});
diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts
new file mode 100644
index 0000000000..1980965857
--- /dev/null
+++ b/packages/core/src/schema/blocks/children.ts
@@ -0,0 +1,244 @@
+import type { Node, NodeType, Schema } from "prosemirror-model";
+
+import type {
+ BlockConfig,
+ ChildrenAllow,
+ ChildrenConfig,
+ PartialBlockNoDefaults,
+} from "./types.js";
+
+/** A {@link ChildrenConfig} with every default filled in. */
+export type ResolvedChildren = {
+ blocks: boolean;
+ /** `true` for any container type; a (possibly empty) list otherwise. */
+ containers: true | readonly string[];
+ /** What `whenEmptied` compares against. */
+ min: number;
+ max: number | undefined;
+ default: readonly PartialBlockNoDefaults[] | undefined;
+ whenEmptied: "refill" | "unwrap";
+ boundary: "open" | "isolated" | "sealed";
+};
+
+export const CHILD_CONTAINER_GROUP = "childContainer";
+
+export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild";
+
+// Joined by every container block placeable anywhere (`placement` other than
+// `"containerOnly"`). It's what the `allow` container wildcards (`"any"`,
+// `"containers"`) compile to: a containerOnly type only ever lives where a
+// container names it explicitly, so it stays out of the group.
+export const ANY_CONTAINER_GROUP = "anyContainer";
+
+// Not `blockContent` — that's a legal child of `blockContainer`, so a paste
+// could produce an unrepresentable `blockContainer > toggle__content`.
+export const CONTAINER_CONTENT_GROUP = "containerContent";
+
+// `__` because PM's expression parser only accepts word characters in node names.
+const CONTENT_NODE_SUFFIX = "__content";
+const CHILDREN_NODE_SUFFIX = "__children";
+
+export function containerContentNodeName(blockType: string): string {
+ return `${blockType}${CONTENT_NODE_SUFFIX}`;
+}
+
+export function containerChildrenNodeName(blockType: string): string {
+ return `${blockType}${CHILDREN_NODE_SUFFIX}`;
+}
+
+export function blockTypeOfContainerContentNode(
+ nodeName: string,
+): string | undefined {
+ return nodeName.endsWith(CONTENT_NODE_SUFFIX)
+ ? nodeName.slice(0, -CONTENT_NODE_SUFFIX.length)
+ : undefined;
+}
+
+export function blockTypeOfContainerChildrenNode(
+ nodeName: string,
+): string | undefined {
+ return nodeName.endsWith(CHILDREN_NODE_SUFFIX)
+ ? nodeName.slice(0, -CHILDREN_NODE_SUFFIX.length)
+ : undefined;
+}
+
+// Whether `type` is a node that holds child blocks directly: a pure container
+// block's own node, or a generated `__children` node. (`blockGroup` is in the
+// group too but is regular-block nesting machinery, not a container.)
+export function isContainerNode(type: NodeType): boolean {
+ return type.isInGroup(CHILD_CONTAINER_GROUP) && type.name !== "blockGroup";
+}
+
+// Whether `node` is a container that has its own content (children live in
+// a generated `__children` node). Not the same as `isContainerNode`.
+export function isContentContainerNode(node: Node): boolean {
+ return !!node.firstChild?.type.isInGroup(CONTAINER_CONTENT_GROUP);
+}
+
+/**
+ * Whether `node` is a container block's node in either shape — a pure
+ * container (children held directly) or a content-bearing container (children
+ * held in a generated `__children` node). The disjunction every nav/repair
+ * call site needs.
+ */
+export function isContainerBlockNode(node: Node): boolean {
+ return isContainerNode(node.type) || isContentContainerNode(node);
+}
+
+export function getContentContainerNodeTypes(
+ schema: Schema,
+ blockType: string,
+): { contentType: NodeType; childrenType: NodeType } | undefined {
+ const contentType = schema.nodes[containerContentNodeName(blockType)];
+ const childrenType = schema.nodes[containerChildrenNodeName(blockType)];
+
+ return contentType && childrenType
+ ? { contentType, childrenType }
+ : undefined;
+}
+
+// Below `blockContainer`'s priority (50) so PM's `fillBefore` picks
+// `blockContainer` first, avoiding recursion through nested containers.
+export const CONTAINER_NODE_PRIORITY = 40;
+
+const CONTAINER_PRIORITY_BAND = { min: 30, max: 49 };
+const DEFAULT_SPEC_PRIORITY = 101;
+
+// Maps `sortByDependencies` priority into the container band (30–49).
+// Preserves relative order but keeps all containers below regular blocks.
+export function containerNodePriority(priority: number | undefined): number {
+ if (priority === undefined) {
+ return CONTAINER_NODE_PRIORITY;
+ }
+
+ const steps = Math.round((priority - DEFAULT_SPEC_PRIORITY) / 10);
+
+ return Math.min(
+ CONTAINER_PRIORITY_BAND.max,
+ Math.max(CONTAINER_PRIORITY_BAND.min, CONTAINER_NODE_PRIORITY + steps),
+ );
+}
+
+export function getChildrenConfig(config: {
+ children?: ChildrenConfig;
+}): ChildrenConfig | undefined {
+ return config.children;
+}
+
+export function isContainerType(config: {
+ children?: ChildrenConfig;
+}): boolean {
+ return config.children !== undefined;
+}
+
+export function isPlaceableAnywhere(config: {
+ placement?: BlockConfig["placement"];
+}): boolean {
+ return config.placement !== "containerOnly";
+}
+
+const resolvedCache = new WeakMap();
+
+export function resolveChildren(children: ChildrenConfig): ResolvedChildren {
+ const cached = resolvedCache.get(children);
+ if (cached) {
+ return cached;
+ }
+
+ const resolved: ResolvedChildren = {
+ ...resolveAllow(children.allow),
+ min: children.min ?? 1,
+ max: children.max,
+ default: children.default,
+ whenEmptied: children.whenEmptied ?? "refill",
+ boundary: children.boundary ?? "isolated",
+ };
+
+ resolvedCache.set(children, resolved);
+ return resolved;
+}
+
+function resolveAllow(
+ allow: ChildrenAllow,
+): Pick {
+ if (allow === "any") {
+ return { blocks: true, containers: true };
+ }
+ if (allow === "blocks") {
+ return { blocks: true, containers: [] };
+ }
+ if (allow === "containers") {
+ return { blocks: false, containers: true };
+ }
+ return { blocks: false, containers: allow };
+}
+
+/**
+ * Whether `node` belongs to a container with a `"sealed"` boundary — one whose
+ * edge content may never implicitly cross (a table cell rather than a column).
+ * Reads the block config off the node's spec, so it works on a container
+ * block's own node and on its generated `__children` node alike.
+ */
+export function isSealed(node: Node): boolean {
+ const children = getChildrenConfig(node.type.spec.blockConfig ?? {});
+ return (
+ children !== undefined && resolveChildren(children).boundary === "sealed"
+ );
+}
+
+export function childrenContentExpression(children: ChildrenConfig): string {
+ const resolved = resolveChildren(children);
+ return allowTerm(resolved) + quantifier(resolved.min, resolved.max);
+}
+
+function allowTerm(resolved: ResolvedChildren): string {
+ // "Anything" is already a group, so use it rather than spelling out a union
+ // that would need rebuilding whenever the schema gains a container type.
+ if (resolved.blocks && resolved.containers === true) {
+ return BLOCK_GROUP_CHILD_GROUP;
+ }
+
+ const terms: string[] = [];
+ // `blockContainer` FIRST: PM's `fillBefore` picks the first matching type in
+ // a union, and filling with `blockContainer` (rather than another container)
+ // keeps auto-fill from recursing through nested containers.
+ if (resolved.blocks) {
+ terms.push("blockContainer");
+ }
+ // The wildcard is the `anyContainer` group, not `childContainer` — that
+ // group also contains `blockGroup`, which is not a block.
+ if (resolved.containers === true) {
+ terms.push(ANY_CONTAINER_GROUP);
+ } else {
+ terms.push(...resolved.containers);
+ }
+
+ if (terms.length === 0) {
+ // Validation rejects this first; this is a bug-guard, not a user-facing
+ // error path.
+ throw new Error(
+ "Container `allow` permits nothing. This is a bug in BlockNote.",
+ );
+ }
+
+ return terms.length === 1 ? terms[0] : `(${terms.join(" | ")})`;
+}
+
+function quantifier(min: number, max: number | undefined): string {
+ if (max === undefined) {
+ if (min === 0) {
+ return "*";
+ }
+ if (min === 1) {
+ return "+";
+ }
+ return `{${min},}`;
+ }
+ if (min === max) {
+ return max === 1 ? "" : `{${min}}`;
+ }
+ if (min === 0 && max === 1) {
+ return "?";
+ }
+ return `{${min},${max}}`;
+}
diff --git a/packages/core/src/schema/blocks/containerAttributes.ts b/packages/core/src/schema/blocks/containerAttributes.ts
new file mode 100644
index 0000000000..e0657b6e88
--- /dev/null
+++ b/packages/core/src/schema/blocks/containerAttributes.ts
@@ -0,0 +1,75 @@
+import { camelToDataKebab } from "../../util/string.js";
+import { PropSchema, Props } from "../propTypes.js";
+
+export function getContainerAttributes(
+ blockType: string,
+ blockProps: Partial>,
+ propSchema: PSchema,
+ id: string | undefined,
+): Record {
+ const attributes: Record = { "data-node-type": blockType };
+
+ for (const [prop, value] of Object.entries(blockProps)) {
+ if (value === undefined || value === propSchema[prop]?.default) {
+ continue;
+ }
+ attributes[camelToDataKebab(prop)] = `${value}`;
+ }
+
+ if (id) {
+ attributes["data-id"] = id;
+ }
+
+ return attributes;
+}
+
+export function applyContainerAttributes(
+ dom: HTMLElement | DocumentFragment | undefined | null,
+ blockType: string,
+ blockProps: Partial>,
+ propSchema: PSchema,
+ id: string | undefined,
+) {
+ const element = dom as HTMLElement | undefined;
+ if (!element || typeof element.setAttribute !== "function") {
+ return;
+ }
+
+ const attributes = getContainerAttributes(
+ blockType,
+ blockProps,
+ propSchema,
+ id,
+ );
+
+ for (const prop of Object.keys(blockProps)) {
+ const attr = camelToDataKebab(prop);
+ if (!(attr in attributes)) {
+ element.removeAttribute(attr);
+ }
+ }
+ for (const [attr, value] of Object.entries(attributes)) {
+ element.setAttribute(attr, value);
+ }
+}
+
+// Like `applyContainerAttributes` but won't overwrite existing attributes.
+export function fillContainerAttributes(
+ dom: HTMLElement,
+ blockType: string,
+ blockProps: Partial>,
+ propSchema: PSchema,
+) {
+ const attributes = getContainerAttributes(
+ blockType,
+ blockProps,
+ propSchema,
+ undefined,
+ );
+
+ for (const [attr, value] of Object.entries(attributes)) {
+ if (!dom.hasAttribute(attr)) {
+ dom.setAttribute(attr, value);
+ }
+ }
+}
diff --git a/packages/core/src/schema/blocks/containerParse.browser.test.ts b/packages/core/src/schema/blocks/containerParse.browser.test.ts
new file mode 100644
index 0000000000..4aa95ef2f3
--- /dev/null
+++ b/packages/core/src/schema/blocks/containerParse.browser.test.ts
@@ -0,0 +1,400 @@
+import { Fragment } from "prosemirror-model";
+import { AllSelection } from "prosemirror-state";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+
+import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
+import { createBlockSpec } from "./createSpec.js";
+
+// Every test here goes through `tryParseHTMLToBlocks`, which parses real HTML
+// into a real DOM (`document.implementation.createHTMLDocument` in
+// `api/parsers/html/util/nestedLists.ts`) before ProseMirror's parser ever runs
+// — and the clipboard test additionally needs a mounted view for
+// `view.serializeForClipboard`. Parsing HTML *is* the capability under test, so
+// this whole suite runs against a real browser engine rather than jsdom's.
+
+const renderDiv = () => {
+ const dom = document.createElement("div");
+ return { dom, contentDOM: dom };
+};
+
+// A pure container that recognizes its own external HTML. Before containers
+// went through `getParseRules`, `parse` was silently dropped for them and this
+// produced nothing at all.
+const Card = createBlockSpec(
+ {
+ type: "card" as const,
+ propSchema: { tone: { default: "neutral" } },
+ content: "none",
+ children: { allow: "any" },
+ },
+ {
+ render: renderDiv,
+ parse: (el) =>
+ el.classList.contains("card")
+ ? { tone: el.getAttribute("data-tone") ?? undefined }
+ : undefined,
+ },
+)();
+
+// The same, but taking over the parsing of its own body.
+const Quote = createBlockSpec(
+ {
+ type: "quote" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any" },
+ },
+ {
+ render: renderDiv,
+ parse: (el) => (el.tagName === "BLOCKQUOTE" ? {} : undefined),
+ // Returns inline nodes — the natural thing to build from an element — and
+ // relies on `toContainerChildren` to place them.
+ parseContent: ({ el, schema }) =>
+ Fragment.from(schema.text(el.textContent?.trim() || "empty")),
+ },
+)();
+
+// A container with its own content, to exercise the two generated nodes
+// through the clipboard.
+const Toggle = createBlockSpec(
+ {
+ type: "toggle" as const,
+ propSchema: { open: { default: true } },
+ content: "inline",
+ children: { allow: "any" },
+ },
+ { render: renderDiv },
+)();
+
+// A content-bearing container whose `parseContent` returns a leading run of
+// inline nodes followed by a block — the shape that has to split across the
+// two generated nodes.
+const Section = createBlockSpec(
+ {
+ type: "section" as const,
+ propSchema: {},
+ content: "inline",
+ children: { allow: "any" },
+ },
+ {
+ render: renderDiv,
+ parse: (el) => (el.tagName === "SECTION" ? {} : undefined),
+ parseContent: ({ el, schema }) =>
+ Fragment.fromArray([
+ schema.text(el.getAttribute("data-title") || "untitled"),
+ schema.nodes["paragraph"].create(
+ null,
+ schema.text(el.textContent?.trim() || "empty"),
+ ),
+ ]),
+ },
+)();
+
+// A pure container whose render puts non-content UI text next to the children
+// host — the table-with-controls shape. That text must never round-trip into
+// document content.
+const Widget = createBlockSpec(
+ {
+ type: "widget" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any" },
+ },
+ {
+ render: () => {
+ const dom = document.createElement("div");
+ const contentDOM = document.createElement("div");
+ const controls = document.createElement("div");
+ controls.contentEditable = "false";
+ controls.textContent = "UI LABEL";
+ dom.append(contentDOM, controls);
+ return { dom, contentDOM };
+ },
+ },
+)();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ card: Card,
+ quote: Quote,
+ toggle: Toggle,
+ section: Section,
+ widget: Widget,
+ } as const,
+});
+
+let editor: BlockNoteEditor;
+const div = document.createElement("div");
+
+beforeAll(() => {
+ document.body.appendChild(div);
+ editor = BlockNoteEditor.create({ schema }) as any;
+ editor.mount(div);
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ div.remove();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ ]);
+});
+
+describe("container `parse`", () => {
+ it("parses an external element into a container, children intact", () => {
+ const blocks = editor.tryParseHTMLToBlocks(
+ '
First
Second
',
+ );
+
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0].type).toBe("card");
+ expect(blocks[0].props.tone).toBe("warning");
+ // No `getContent` is supplied, so ProseMirror parses the children with the
+ // normal block rules and `findWrapping` adds the `blockContainer`s.
+ expect(blocks[0].children.map((child: any) => child.type)).toEqual([
+ "paragraph",
+ "heading",
+ ]);
+ expect(blocks[0].children[0].content).toEqual([
+ { type: "text", text: "First", styles: {} },
+ ]);
+ });
+
+ it("places inline nodes returned by `parseContent` into a child block", () => {
+ const blocks = editor.tryParseHTMLToBlocks(
+ "
Quoted text
",
+ );
+
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0].type).toBe("quote");
+ expect(blocks[0].children.map((child: any) => child.type)).toEqual([
+ "paragraph",
+ ]);
+ expect(blocks[0].children[0].content).toEqual([
+ { type: "text", text: "Quoted text", styles: {} },
+ ]);
+ });
+
+ it("splits `parseContent` across a content-bearing container's two regions", () => {
+ const blocks = editor.tryParseHTMLToBlocks(
+ 'Body',
+ );
+
+ expect(blocks).toHaveLength(1);
+ expect(blocks[0].type).toBe("section");
+ // The leading inline run is the block's own content; the block that
+ // follows it is a child.
+ expect(blocks[0].content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(blocks[0].children.map((child: any) => child.type)).toEqual([
+ "paragraph",
+ ]);
+ expect(blocks[0].children[0].content).toEqual([
+ { type: "text", text: "Body", styles: {} },
+ ]);
+ });
+});
+
+describe("container HTML round-trip", () => {
+ const toggleBlocks = [
+ {
+ id: "t-0",
+ type: "toggle" as const,
+ props: { open: false },
+ content: "Title",
+ children: [
+ { id: "t-p-0", type: "paragraph" as const, content: "Body" },
+ { id: "t-p-1", type: "heading" as const, content: "Sub" },
+ ],
+ },
+ ];
+
+ const expectRoundTripped = (parsed: any[]) => {
+ expect(parsed).toHaveLength(1);
+ expect(parsed[0].type).toBe("toggle");
+ expect(parsed[0].props.open).toBe(false);
+ expect(parsed[0].content).toEqual([
+ { type: "text", text: "Title", styles: {} },
+ ]);
+ expect(
+ parsed[0].children.map((child: any) => [
+ child.type,
+ child.content?.[0]?.text,
+ ]),
+ ).toEqual([
+ ["paragraph", "Body"],
+ ["heading", "Sub"],
+ ]);
+ };
+
+ it("round-trips a content-bearing container through full HTML", () => {
+ editor.replaceBlocks(editor.document, toggleBlocks);
+
+ const html = editor.blocksToFullHTML(editor.document);
+ expect(html).toContain('data-node-type="toggle"');
+
+ expectRoundTripped(editor.tryParseHTMLToBlocks(html));
+ });
+
+ it("round-trips a content-bearing container through the clipboard", () => {
+ editor.replaceBlocks(editor.document, toggleBlocks);
+
+ // What a copy actually puts on the clipboard: ProseMirror's own
+ // serialization, which renders the generated content & children nodes.
+ const view = editor._tiptapEditor.view;
+ view.dispatch(view.state.tr.setSelection(new AllSelection(view.state.doc)));
+ const clipboardHTML = view.serializeForClipboard(
+ view.state.selection.content(),
+ ).dom.innerHTML;
+
+ expect(clipboardHTML).toContain('data-content-type="toggle"');
+ expect(clipboardHTML).toContain('data-children-of="toggle"');
+
+ expectRoundTripped(editor.tryParseHTMLToBlocks(clipboardHTML));
+ });
+
+ it("round-trips a content-bearing container through external HTML", () => {
+ editor.replaceBlocks(editor.document, toggleBlocks);
+
+ const html = editor.blocksToHTMLLossy(editor.document);
+ expect(html).toContain('data-node-type="toggle"');
+
+ expectRoundTripped(editor.tryParseHTMLToBlocks(html));
+ });
+
+ // Two children, because the one-child case passes either way. External HTML
+ // has no marker element for the container's own content, so an empty title
+ // leaves the parser reading a block element first — with nothing to satisfy
+ // the content node, it can't open the children node and every child used to
+ // land *after* the container.
+ it("round-trips an empty-titled container's children through external HTML", () => {
+ editor.replaceBlocks(editor.document, [
+ { ...toggleBlocks[0], content: undefined },
+ ]);
+
+ const html = editor.blocksToHTMLLossy(editor.document);
+ const parsed = editor.tryParseHTMLToBlocks(html);
+
+ expect(parsed).toHaveLength(1);
+ expect(parsed[0].type).toBe("toggle");
+ expect(
+ (parsed[0] as any).children.map((child: any) => [
+ child.type,
+ child.content?.[0]?.text,
+ ]),
+ ).toEqual([
+ ["paragraph", "Body"],
+ ["heading", "Sub"],
+ ]);
+ });
+
+ // Regression: internal HTML renders the block's full DOM, so a render with
+ // non-content UI text (control buttons, labels — the container-table shape)
+ // used to leak that text into the document as extra blocks on re-parse.
+ // The serializer marks the children host with `data-children-of` and the
+ // round-trip rule scopes itself to it.
+ it("excludes a render's non-content UI text from a pure container's round-trip", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "w-0",
+ type: "widget" as const,
+ children: [
+ { id: "w-p-0", type: "paragraph" as const, content: "Inside" },
+ ],
+ },
+ ]);
+
+ const html = editor.blocksToFullHTML(editor.document);
+ expect(html).toContain('data-children-of="widget"');
+ expect(html).toContain("UI LABEL");
+
+ const parsed = editor.tryParseHTMLToBlocks(html);
+ expect(parsed).toHaveLength(1);
+ expect(parsed[0].type).toBe("widget");
+ expect(
+ (parsed[0] as any).children.map((child: any) => [
+ child.type,
+ child.content?.[0]?.text,
+ ]),
+ ).toEqual([["paragraph", "Inside"]]);
+ expect(JSON.stringify(parsed)).not.toContain("UI LABEL");
+ });
+});
+
+describe("container `runsBefore`", () => {
+ const ambiguous = (type: string) =>
+ createBlockSpec(
+ {
+ type,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any" },
+ } as any,
+ {
+ render: renderDiv,
+ parse: (el: HTMLElement) =>
+ el.classList.contains("shared") ? {} : undefined,
+ },
+ );
+
+ const makeEditor = (betaRunsBefore?: string[]) => {
+ const alpha = ambiguous("alpha")();
+ const beta = ambiguous("beta")();
+ if (betaRunsBefore) {
+ (beta.implementation as any).runsBefore = betaRunsBefore;
+ }
+
+ return BlockNoteEditor.create({
+ schema: BlockNoteSchema.create().extend({
+ blockSpecs: { ...defaultBlockSpecs, alpha, beta } as any,
+ }),
+ }) as BlockNoteEditor;
+ };
+
+ it("leaves the declaration order alone by default", () => {
+ const other = makeEditor();
+ try {
+ expect(
+ other.tryParseHTMLToBlocks('
x
')[0]
+ .type,
+ ).toBe("alpha");
+ } finally {
+ other._tiptapEditor.destroy();
+ }
+ });
+
+ it("orders a container's parse rules before another container's", () => {
+ const other = makeEditor(["alpha"]);
+ try {
+ expect(
+ other.tryParseHTMLToBlocks('
x
')[0]
+ .type,
+ ).toBe("beta");
+ } finally {
+ other._tiptapEditor.destroy();
+ }
+ });
+
+ it("rejects a `runsBefore` naming a regular block", () => {
+ // Container nodes all register below `blockContainer`, so this ordering is
+ // not something the schema could ever produce.
+ expect(() => makeEditor(["paragraph"])).toThrow(
+ /can never be ordered before a regular block/,
+ );
+ });
+});
diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts
index b1e54d640a..6b0d677445 100644
--- a/packages/core/src/schema/blocks/createSpec.ts
+++ b/packages/core/src/schema/blocks/createSpec.ts
@@ -1,11 +1,13 @@
-import { Editor, Node } from "@tiptap/core";
+import { Editor, Node, NodeViewRendererProps } from "@tiptap/core";
import {
DOMParser,
Fragment,
Node as PMNode,
+ Schema as PMSchema,
TagParseRule,
} from "@tiptap/pm/model";
import { NodeView } from "@tiptap/pm/view";
+import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js";
import {
Extension,
@@ -13,8 +15,24 @@ import {
} from "../../editor/BlockNoteExtension.js";
import { nonFormattingMarks } from "../markGroups.js";
import { ignoreNonContentMutations } from "../nodeViewMutations.js";
+import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js";
import { PropSchema } from "../propTypes.js";
import {
+ ANY_CONTAINER_GROUP,
+ BLOCK_GROUP_CHILD_GROUP,
+ CHILD_CONTAINER_GROUP,
+ CONTAINER_CONTENT_GROUP,
+ childrenContentExpression,
+ containerChildrenNodeName,
+ containerContentNodeName,
+ containerNodePriority,
+ getChildrenConfig,
+ isPlaceableAnywhere,
+ resolveChildren,
+} from "./children.js";
+import { applyContainerAttributes } from "./containerAttributes.js";
+import {
+ applyDOMAttributes,
getBlockFromNodeView,
propsToAttributes,
wrapInBlockStructure,
@@ -45,9 +63,78 @@ export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) {
};
}
-// Function that uses the 'parse' function of a blockConfig to create a
-// TipTap node's `parseHTML` property. This is only used for parsing content
-// from the clipboard.
+// Wraps inline runs from `parseContent` into paragraphs so they fit a
+// container's block content expression. A leading inline run in a
+// content-bearing container stays inline (it's the block's own content).
+function toContainerChildren(
+ fragment: Fragment,
+ schema: PMSchema,
+ hasOwnContent: boolean,
+): Fragment {
+ const out: PMNode[] = [];
+ let inlineRun: PMNode[] = [];
+ let seenBlock = false;
+
+ const flush = () => {
+ if (inlineRun.length === 0) {
+ return;
+ }
+ out.push(
+ ...(hasOwnContent && !seenBlock
+ ? inlineRun
+ : [schema.nodes["paragraph"].create(null, inlineRun)]),
+ );
+ inlineRun = [];
+ };
+
+ fragment.forEach((child) => {
+ if (child.isInline) {
+ inlineRun.push(child);
+ return;
+ }
+ flush();
+ seenBlock = true;
+ out.push(child);
+ });
+ flush();
+
+ return Fragment.fromArray(out);
+}
+
+// Finds the element holding a serialized container block's content, marked
+// `data-children-of` by the internal HTML serializer. For a pure container
+// that element holds the children and is the content element; for a container
+// with its own content it's the generated children *region*, whose parent
+// hosts both regions and is the content element. Returns undefined when no
+// marker belonging to *this* block (rather than a same-typed nested
+// container) is present.
+function findContainerContentElement(
+ el: HTMLElement,
+ config: { type: string; content: string },
+): HTMLElement | undefined {
+ const selector = `[data-children-of="${config.type}"]`;
+
+ const resolve = (host: HTMLElement) =>
+ config.content === "none" ? host : (host.parentElement ?? undefined);
+
+ // The block's root may itself be the children host (a render that passes
+ // its own root to `contentRef`) — `querySelectorAll` only sees descendants.
+ if (el.matches(selector)) {
+ return resolve(el);
+ }
+
+ for (const host of el.querySelectorAll(selector)) {
+ // Skip hosts of same-typed *nested* containers: this block's own host is
+ // the one with no other container root between it and `el`.
+ if (host.parentElement?.closest("[data-node-type]") === el) {
+ return resolve(host);
+ }
+ }
+
+ return undefined;
+}
+
+// Creates `parseHTML` rules for clipboard parsing.
export function getParseRules<
TName extends string,
TProps extends PropSchema,
@@ -55,12 +142,28 @@ export function getParseRules<
>(
config: BlockConfig,
implementation: BlockImplementation,
+ kind: "regular" | "container" = "regular",
) {
+ const isContainer = kind === "container";
+
const rules: TagParseRule[] = [
- {
- tag: "[data-content-type=" + config.type + "]",
- contentElement: ".bn-inline-content",
- },
+ isContainer
+ ? {
+ tag: `[data-node-type=${config.type}]`,
+ // Scope the round-trip parse to the block's content region, so text
+ // the render puts elsewhere in its DOM (button labels, captions,
+ // ...) doesn't parse back as document content. The internal HTML
+ // serializer marks the region with `data-children-of`; HTML without
+ // the marker (older or hand-written) falls back to the whole
+ // element, the previous behavior.
+ contentElement: (el) =>
+ findContainerContentElement(el as HTMLElement, config) ??
+ (el as HTMLElement),
+ }
+ : {
+ tag: "[data-content-type=" + config.type + "]",
+ contentElement: ".bn-inline-content",
+ },
];
if (implementation.parse) {
@@ -81,10 +184,25 @@ export function getParseRules<
},
// Because we do the parsing ourselves, we want to preserve whitespace for content we've parsed
preserveWhitespace: true,
- getContent:
- config.content === "inline" ||
- config.content === "none" ||
- config.content === "plain"
+ getContent: isContainer
+ ? implementation.parseContent
+ ? (node, schema) =>
+ toContainerChildren(
+ implementation.parseContent!({
+ el: node as HTMLElement,
+ schema,
+ }) ??
+ DOMParser.fromSchema(schema).parse(node as HTMLElement, {
+ topNode: schema.nodes["blockGroup"].create(),
+ preserveWhitespace: true,
+ }).content,
+ schema,
+ config.content !== "none",
+ )
+ : undefined
+ : config.content === "inline" ||
+ config.content === "none" ||
+ config.content === "plain"
? (node, schema) => {
if (implementation.parseContent) {
const result = implementation.parseContent({
@@ -167,147 +285,465 @@ export function getParseRules<
return rules;
}
-// A function to create custom block for API consumers
-// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
-export function addNodeAndExtensionsToSpec<
+function buildContainerNode(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ priority?: number,
+) {
+ const children = getChildrenConfig(blockConfig)!;
+
+ const groups = ["bnBlock", CHILD_CONTAINER_GROUP];
+ if (isPlaceableAnywhere(blockConfig)) {
+ groups.push(BLOCK_GROUP_CHILD_GROUP, ANY_CONTAINER_GROUP);
+ }
+
+ return Node.create({
+ name: blockConfig.type,
+ content: childrenContentExpression(children),
+ group: groups.join(" "),
+ marks() {
+ return suggestionMarks(this.editor);
+ },
+ selectable: blockImplementation.meta?.selectable ?? true,
+ // Derived from `boundary`: an "open" container lets everything cross its
+ // edge; "isolated" and "sealed" both map to PM `isolating: true`.
+ isolating: resolveChildren(children).boundary !== "open",
+ defining: true,
+ priority: containerNodePriority(priority),
+ addAttributes() {
+ return propsToAttributes(blockConfig.propSchema);
+ },
+
+ parseHTML() {
+ return getParseRules(blockConfig, blockImplementation, "container");
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", blockConfig.type);
+ for (const [attribute, value] of Object.entries(HTMLAttributes)) {
+ dom.setAttribute(attribute, value as string);
+ }
+ return { dom, contentDOM: dom };
+ },
+
+ addNodeView() {
+ return (props) =>
+ containerNodeView(blockConfig, blockImplementation, props, {
+ editor: this.options.editor,
+ tiptapEditor: this.editor,
+ blockContentDOMAttributes:
+ this.options.domAttributes?.blockContent || {},
+ });
+ },
+ });
+}
+
+function containerRootDOM(output: {
+ dom: HTMLElement | DocumentFragment;
+ rootDOM?: HTMLElement | null;
+}): HTMLElement | DocumentFragment | null | undefined {
+ return output.rootDOM === undefined ? output.dom : output.rootDOM;
+}
+
+function containerNodeView<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "none" | "plain",
+>(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ props: NodeViewRendererProps,
+ context: {
+ editor: unknown;
+ tiptapEditor: Editor;
+ blockContentDOMAttributes: Record;
+ },
+): NodeView {
+ const block = nodeToBlock(props.node, props.view.state.doc);
+
+ const nodeView = blockImplementation.render.call(
+ {
+ blockContentDOMAttributes: context.blockContentDOMAttributes,
+ props,
+ renderType: "nodeView",
+ propSchema: blockConfig.propSchema,
+ },
+ block as any,
+ context.editor as any,
+ );
+
+ const rootDOM = () => containerRootDOM(nodeView);
+
+ applyContainerAttributes(
+ rootDOM(),
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+
+ const typedNodeView = nodeView as unknown as NodeView;
+
+ // Mark the children host in the live DOM, mirroring what the internal HTML
+ // serializer emits, so the container's round-trip parse rule can scope
+ // itself to it (`contentElement` in `getParseRules`) when ProseMirror
+ // re-reads editor DOM. Content-bearing containers get the marker from their
+ // generated `__children` node's own DOM instead.
+ if (blockConfig.content === "none" && typedNodeView.contentDOM) {
+ (typedNodeView.contentDOM as HTMLElement).setAttribute(
+ "data-children-of",
+ blockConfig.type,
+ );
+ }
+
+ if (blockImplementation.meta?.selectable === false) {
+ applyNonSelectableBlockFix(typedNodeView, context.tiptapEditor);
+ }
+
+ ignoreNonContentMutations(typedNodeView);
+
+ const update = typedNodeView.update?.bind(typedNodeView);
+ if (update) {
+ typedNodeView.update = (node, decorations, innerDecorations) => {
+ if (node.type.name !== blockConfig.type) {
+ return false;
+ }
+ if (update(node, decorations, innerDecorations) === false) {
+ return false;
+ }
+ applyContainerAttributes(
+ rootDOM(),
+ blockConfig.type,
+ nodeToBlock(node, props.view.state.doc).props as any,
+ blockConfig.propSchema,
+ node.attrs.id,
+ );
+ return true;
+ };
+ }
+
+ return typedNodeView;
+}
+
+function buildContentContainerNode<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "plain",
+>(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ priority?: number,
+): { node: Node; extraNodes: Node[] } {
+ const children = getChildrenConfig(blockConfig)!;
+
+ const contentName = containerContentNodeName(blockConfig.type);
+ const childrenName = containerChildrenNodeName(blockConfig.type);
+ const nodePriority = containerNodePriority(priority);
+
+ const groups = ["bnBlock"];
+ if (isPlaceableAnywhere(blockConfig)) {
+ groups.push(BLOCK_GROUP_CHILD_GROUP, ANY_CONTAINER_GROUP);
+ }
+
+ const node = Node.create({
+ name: blockConfig.type,
+ content: `${contentName} ${childrenName}`,
+ group: groups.join(" "),
+ marks() {
+ return suggestionMarks(this.editor);
+ },
+ selectable: blockImplementation.meta?.selectable ?? true,
+ // Derived from `boundary`: an "open" container lets everything cross its
+ // edge; "isolated" and "sealed" both map to PM `isolating: true`.
+ isolating: resolveChildren(children).boundary !== "open",
+ defining: true,
+ priority: nodePriority,
+ addAttributes() {
+ return propsToAttributes(blockConfig.propSchema);
+ },
+
+ parseHTML() {
+ return getParseRules(blockConfig, blockImplementation, "container");
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", blockConfig.type);
+ for (const [attribute, value] of Object.entries(HTMLAttributes)) {
+ dom.setAttribute(attribute, value as string);
+ }
+ return { dom, contentDOM: dom };
+ },
+
+ addNodeView() {
+ return (props) =>
+ containerNodeView(blockConfig, blockImplementation, props, {
+ editor: this.options.editor,
+ tiptapEditor: this.editor,
+ blockContentDOMAttributes:
+ this.options.domAttributes?.blockContent || {},
+ });
+ },
+ });
+
+ const contentNode = Node.create({
+ name: contentName,
+ group: CONTAINER_CONTENT_GROUP,
+ content: blockConfig.content === "plain" ? "text*" : "inline*",
+ marks() {
+ return blockConfig.content === "plain"
+ ? nonFormattingMarks(this.editor)
+ : undefined;
+ },
+ code: blockImplementation.meta?.code ?? false,
+ defining: true,
+ priority: nodePriority,
+
+ parseHTML() {
+ return [{ tag: `[data-content-type=${blockConfig.type}]` }];
+ },
+
+ renderHTML() {
+ const dom = document.createElement("div");
+ dom.className = "bn-inline-content";
+ dom.setAttribute("data-content-type", blockConfig.type);
+ return { dom, contentDOM: dom };
+ },
+ });
+
+ const childrenNode = Node.create({
+ name: childrenName,
+ group: CHILD_CONTAINER_GROUP,
+ content: childrenContentExpression(children),
+ marks() {
+ return suggestionMarks(this.editor);
+ },
+ priority: nodePriority,
+
+ parseHTML() {
+ return [{ tag: `[data-children-of=${blockConfig.type}]` }];
+ },
+
+ renderHTML() {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-children-of", blockConfig.type);
+ return { dom, contentDOM: dom };
+ },
+ });
+
+ return { node, extraNodes: [contentNode, childrenNode] };
+}
+
+function buildRegularNode<
TName extends string,
TProps extends PropSchema,
TContent extends "inline" | "none" | "table" | "plain",
>(
blockConfig: BlockConfig,
blockImplementation: BlockImplementation,
- extensions?: (ExtensionFactoryInstance | Extension)[],
priority?: number,
-): LooseBlockSpec {
- const node =
- ((blockImplementation as any).node as Node) ||
- Node.create({
- name: blockConfig.type,
- content: (blockConfig.content === "inline"
- ? "inline*"
- : blockConfig.content === "plain"
- ? "text*"
- : blockConfig.content === "none"
- ? ""
- : blockConfig.content) as TContent extends "inline"
- ? "inline*"
- : TContent extends "plain"
- ? "text*"
- : "",
- // "plain" blocks hold unstyled text, so they disallow formatting marks.
- // They still allow the non-formatting marks (comments and
- // suggestions/diffs) — those annotate content without changing it and are
- // ignored by the block model. `nonFormattingMarks` resolves the group only
- // when at least one such mark is registered, so a plain block in an editor
- // without any of them doesn't reference an empty (unknown) mark group.
- marks() {
- return blockConfig.content === "plain"
- ? nonFormattingMarks(this.editor)
- : undefined;
- },
- group: "blockContent",
- selectable: blockImplementation.meta?.selectable ?? true,
- isolating: blockImplementation.meta?.isolating ?? true,
- code: blockImplementation.meta?.code ?? false,
- defining: blockImplementation.meta?.defining ?? true,
- priority,
- addAttributes() {
- return propsToAttributes(blockConfig.propSchema);
- },
+) {
+ return Node.create({
+ name: blockConfig.type,
+ content: (blockConfig.content === "inline"
+ ? "inline*"
+ : blockConfig.content === "plain"
+ ? "text*"
+ : blockConfig.content === "none"
+ ? ""
+ : blockConfig.content) as TContent extends "inline"
+ ? "inline*"
+ : TContent extends "plain"
+ ? "text*"
+ : "",
+ // "plain" blocks hold unstyled text, so they disallow formatting marks.
+ // They still allow the non-formatting marks (comments and
+ // suggestions/diffs) — those annotate content without changing it and are
+ // ignored by the block model. `nonFormattingMarks` resolves the group only
+ // when at least one such mark is registered, so a plain block in an editor
+ // without any of them doesn't reference an empty (unknown) mark group.
+ marks() {
+ return blockConfig.content === "plain"
+ ? nonFormattingMarks(this.editor)
+ : undefined;
+ },
+ group: "blockContent",
+ selectable: blockImplementation.meta?.selectable ?? true,
+ isolating: blockImplementation.meta?.isolating ?? true,
+ code: blockImplementation.meta?.code ?? false,
+ defining: blockImplementation.meta?.defining ?? true,
+ priority,
+ addAttributes() {
+ return propsToAttributes(blockConfig.propSchema);
+ },
- parseHTML() {
- return getParseRules(blockConfig, blockImplementation);
- },
+ parseHTML() {
+ return getParseRules(blockConfig, blockImplementation);
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ // renderHTML is used for copy/pasting content from the editor back into
+ // the editor, so we need to make sure the `blockContent` element is
+ // structured correctly as this is what's used for parsing blocks. We
+ // just render a placeholder div inside as the `blockContent` element
+ // already has all the information needed for proper parsing.
+ const div = document.createElement("div");
+ return wrapInBlockStructure(
+ {
+ dom: div,
+ contentDOM:
+ blockConfig.content === "inline" || blockConfig.content === "plain"
+ ? div
+ : undefined,
+ },
+ blockConfig.type,
+ {},
+ blockConfig.propSchema,
+ blockImplementation.meta?.fileBlockAccept !== undefined,
+ HTMLAttributes,
+ );
+ },
- renderHTML({ HTMLAttributes }) {
- // renderHTML is used for copy/pasting content from the editor back into
- // the editor, so we need to make sure the `blockContent` element is
- // structured correctly as this is what's used for parsing blocks. We
- // just render a placeholder div inside as the `blockContent` element
- // already has all the information needed for proper parsing.
- const div = document.createElement("div");
- return wrapInBlockStructure(
+ addNodeView() {
+ return (props) => {
+ // Gets the BlockNote editor instance
+ const editor = this.options.editor;
+ // Gets the block. Resolving this can't rely on `getPos()` alone —
+ // node views are constructed part-way through ProseMirror's
+ // reconciliation, where positions don't always line up with
+ // `view.state.doc` yet (see `getBlockFromNodeView`).
+ const block = getBlockFromNodeView(
+ props.getPos,
+ props.node,
+ props.view.state.doc,
+ );
+ // Gets the custom HTML attributes for `blockContent` nodes
+ const blockContentDOMAttributes =
+ this.options.domAttributes?.blockContent || {};
+
+ const nodeView = blockImplementation.render.call(
{
- dom: div,
- contentDOM:
- blockConfig.content === "inline" ||
- blockConfig.content === "plain"
- ? div
- : undefined,
+ blockContentDOMAttributes,
+ props,
+ renderType: "nodeView",
+ propSchema: blockConfig.propSchema,
},
- blockConfig.type,
- {},
- blockConfig.propSchema,
- blockImplementation.meta?.fileBlockAccept !== undefined,
- HTMLAttributes,
+ block as any,
+ editor as any,
);
- },
- addNodeView() {
- return (props) => {
- // Gets the BlockNote editor instance
- const editor = this.options.editor;
- // Gets the block. Resolving this can't rely on `getPos()` alone —
- // node views are constructed part-way through ProseMirror's
- // reconciliation, where positions don't always line up with
- // `view.state.doc` yet (see `getBlockFromNodeView`).
- const block = getBlockFromNodeView(
- props.getPos,
- props.node,
- props.view.state.doc,
- );
- // Gets the custom HTML attributes for `blockContent` nodes
- const blockContentDOMAttributes =
- this.options.domAttributes?.blockContent || {};
+ // Cast needed because render returns `dom: HTMLElement | DocumentFragment`
+ // but tiptap's NodeView expects `dom: HTMLElement`
+ const typedNodeView = nodeView as unknown as NodeView;
- const nodeView = blockImplementation.render.call(
- {
- blockContentDOMAttributes,
- props,
- renderType: "nodeView",
- propSchema: blockConfig.propSchema,
- },
- block as any,
- editor as any,
- );
+ if (blockImplementation.meta?.selectable === false) {
+ applyNonSelectableBlockFix(typedNodeView, this.editor);
+ }
- // Cast needed because render returns `dom: HTMLElement | DocumentFragment`
- // but tiptap's NodeView expects `dom: HTMLElement`
- const typedNodeView = nodeView as unknown as NodeView;
+ // Ignores DOM mutations that don't affect the block's content, so
+ // that browser extensions which rewrite the DOM (e.g. Dark Reader)
+ // can't trigger an infinite re-render loop that freezes the tab.
+ ignoreNonContentMutations(typedNodeView);
+
+ // See explanation for why `update` is not implemented for NodeViews
+ // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464
+ // TODO: in a future version, we might want to implement updates so that
+ // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220)
+ return typedNodeView;
+ };
+ },
+ });
+}
- if (blockImplementation.meta?.selectable === false) {
- applyNonSelectableBlockFix(typedNodeView, this.editor);
+// A function to create custom block for API consumers
+// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
+export function addNodeAndExtensionsToSpec<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "none" | "table" | "plain",
+>(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ extensions?: (ExtensionFactoryInstance | Extension)[],
+ priority?: number,
+): LooseBlockSpec {
+ // A `children` + `content: "table"` combination is rejected by
+ // `validateChildrenConfigs` when the schema is built.
+ const childrenConfig = getChildrenConfig(blockConfig);
+
+ const isContainer = childrenConfig !== undefined;
+
+ // A container with its own content is built from three nodes (see
+ // `buildContentContainerNode`); every other kind of block is a single node.
+ const built: { node: Node; extraNodes?: Node[] } = (
+ blockImplementation as any
+ ).node
+ ? { node: (blockImplementation as any).node as Node }
+ : childrenConfig && blockConfig.content !== "none"
+ ? buildContentContainerNode(
+ blockConfig as unknown as BlockConfig<
+ TName,
+ TProps,
+ "inline" | "plain"
+ >,
+ blockImplementation as unknown as BlockImplementation<
+ TName,
+ TProps,
+ "inline" | "plain"
+ >,
+ priority,
+ )
+ : childrenConfig
+ ? {
+ node: buildContainerNode(
+ blockConfig as unknown as BlockConfig,
+ blockImplementation as unknown as BlockImplementation<
+ TName,
+ TProps,
+ "none"
+ >,
+ priority,
+ ),
}
+ : {
+ node: buildRegularNode(blockConfig, blockImplementation, priority),
+ };
- // Ignores DOM mutations that don't affect the block's content, so
- // that browser extensions which rewrite the DOM (e.g. Dark Reader)
- // can't trigger an infinite re-render loop that freezes the tab.
- ignoreNonContentMutations(typedNodeView);
-
- // See explanation for why `update` is not implemented for NodeViews
- // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464
- // https://github.com/TypeCellOS/BlockNote/issues/220
- return typedNodeView;
- };
- },
- });
+ const { node: builtNode, extraNodes } = built;
- if (node.name !== blockConfig.type) {
+ if (builtNode.name !== blockConfig.type) {
throw new Error(
"Node name does not match block type. This is a bug in BlockNote.",
);
}
+ // The block's config rides on its nodes' PM specs (`NodeSpec.blockConfig`),
+ // so code holding a bare `Node` can consult it without an editor or schema
+ // reference. Generated `__content`/`__children` nodes carry their owning
+ // block's config. (`extendNodeSchema` hooks run for every node in the
+ // schema, hence the name gate.)
+ const specNodeNames = new Set([
+ builtNode.name,
+ ...(extraNodes?.map((extraNode) => extraNode.name) ?? []),
+ ]);
+ const node = builtNode.extend({
+ extendNodeSchema(extension) {
+ return specNodeNames.has(extension.name) ? { blockConfig } : {};
+ },
+ });
+
return {
config: blockConfig,
implementation: {
...blockImplementation,
node,
+ ...(extraNodes ? { extraNodes } : {}),
render(block, editor) {
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
- return blockImplementation.render.call(
+ const output = blockImplementation.render.call(
{
blockContentDOMAttributes,
props: undefined,
@@ -317,6 +753,18 @@ export function addNodeAndExtensionsToSpec<
block as any,
editor as any,
);
+
+ if (isContainer) {
+ applyContainerAttributes(
+ containerRootDOM(output),
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+ }
+
+ return output;
},
// TODO: this should not have wrapInBlockStructure and generally be a lot simpler
// post-processing in externalHTMLExporter should not be necessary
@@ -324,7 +772,7 @@ export function addNodeAndExtensionsToSpec<
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
- return (
+ const output =
blockImplementation.toExternalHTML?.call(
{ blockContentDOMAttributes, propSchema: blockConfig.propSchema },
block as any,
@@ -340,8 +788,19 @@ export function addNodeAndExtensionsToSpec<
},
block as any,
editor as any,
- )
- );
+ );
+
+ if (output && isContainer) {
+ applyContainerAttributes(
+ containerRootDOM(output),
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+ }
+
+ return output;
},
},
extensions,
@@ -452,6 +911,8 @@ export function createBlockSpec<
: extensionsOrCreator
: undefined;
+ const isContainer = getChildrenConfig(blockConfig) !== undefined;
+
return {
config: blockConfig,
implementation: {
@@ -470,6 +931,11 @@ export function createBlockSpec<
return undefined;
}
+ if (isContainer) {
+ applyDOMAttributes(output.dom, this.blockContentDOMAttributes);
+ return output;
+ }
+
return wrapInBlockStructure(
output,
block.type,
@@ -489,6 +955,11 @@ export function createBlockSpec<
editor as any,
);
+ if (isContainer) {
+ applyDOMAttributes(output.dom, this.blockContentDOMAttributes);
+ return output;
+ }
+
const nodeView = wrapInBlockStructure(
output,
block.type,
diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts
index cfd17b9d11..551963f417 100644
--- a/packages/core/src/schema/blocks/internal.ts
+++ b/packages/core/src/schema/blocks/internal.ts
@@ -6,7 +6,7 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j
import { mergeCSSClasses } from "../../util/browser.js";
import { camelToDataKebab } from "../../util/string.js";
import { PropSchema, Props } from "../propTypes.js";
-import { LooseBlockSpec } from "./types.js";
+import { BlockConfig, ChildrenConfig, LooseBlockSpec } from "./types.js";
// Function that uses the 'propSchema' of a blockConfig to create a TipTap
// node's `addAttributes` property.
@@ -157,6 +157,26 @@ export function getBlockFromNodeView(
}
}
+/**
+ * Applies custom `blockContent` DOM attributes to an element, merging (rather
+ * than overwriting) its class list.
+ */
+export function applyDOMAttributes(
+ dom: HTMLElement | DocumentFragment,
+ domAttributes: Record | undefined,
+) {
+ if (!domAttributes || !(dom instanceof HTMLElement)) {
+ return;
+ }
+ for (const [attr, value] of Object.entries(domAttributes)) {
+ if (attr === "class") {
+ dom.className = mergeCSSClasses(dom.className, value);
+ } else {
+ dom.setAttribute(attr, value);
+ }
+ }
+}
+
// Function that wraps the `dom` element returned from 'blockConfig.render' in a
// `blockContent` div, which contains the block type and props as HTML
// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds
@@ -232,6 +252,12 @@ export function createBlockSpecFromTiptapNode<
node: Node;
type: string;
content: "inline" | "table" | "none" | "plain";
+ // Declares the block's container semantics (child counts/repair etc.) even
+ // though the node itself is hand-written — the node's own content
+ // expression stays authoritative for the PM schema, while BlockNote-level
+ // behavior (repair, seeding, validation) reads this config.
+ children?: ChildrenConfig;
+ placement?: BlockConfig["placement"];
},
P extends PropSchema,
>(
@@ -244,6 +270,10 @@ export function createBlockSpecFromTiptapNode<
type: config.type as T["type"],
content: config.content,
propSchema,
+ ...(config.children !== undefined ? { children: config.children } : {}),
+ ...(config.placement !== undefined
+ ? { placement: config.placement }
+ : {}),
},
implementation: {
node: config.node,
diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts
index 8d7e203e61..c8cb67bc81 100644
--- a/packages/core/src/schema/blocks/types.ts
+++ b/packages/core/src/schema/blocks/types.ts
@@ -1,11 +1,7 @@
/** Define the main block types **/
// import { Extension, Node } from "@tiptap/core";
import type { Node, NodeViewRendererProps } from "@tiptap/core";
-import type {
- Fragment,
- Node as ProsemirrorNode,
- Schema,
-} from "prosemirror-model";
+import type { Fragment, Node as PMNode, Schema } from "prosemirror-model";
import type { ViewMutationRecord } from "prosemirror-view";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type {
@@ -67,6 +63,16 @@ export interface BlockConfigMeta<
*/
isolating?: boolean;
+ /**
+ * Whether this block type gets a side menu drag handle (and can be dragged
+ * by it). Applies to any block type, not just container blocks — e.g. a
+ * "locked" block can opt out of dragging entirely. A block that opts out is
+ * skipped when looking for a drag handle, so the handle falls through to the
+ * nearest draggable ancestor.
+ * @default true
+ */
+ draggable?: boolean;
+
/**
* Enables syntax highlighting of the contents of the block with the result of this callback
*/
@@ -80,6 +86,98 @@ export interface BlockConfigMeta<
hasPreview?: boolean;
}
+/**
+ * What may appear as a child of a container block.
+ *
+ * - `"any"`: any regular block, or any container block placeable anywhere.
+ * - `"blocks"`: regular (non-container) blocks only. This cannot be narrowed
+ * to specific block types: every regular block is the *same* ProseMirror
+ * node (`blockContainer`), so paragraphs, headings and code blocks are
+ * indistinguishable at the node level.
+ * - `"containers"`: any container block placeable anywhere, no regular blocks.
+ * - `readonly string[]`: only these types — enforced exactly by the schema.
+ * Today the array may only name *container* block types (naming a regular
+ * block type is a startup error); it is the reserved place where per-type
+ * regular-block filtering lands later, with no API change.
+ *
+ * The wildcards (`"any"`, `"containers"`) never include
+ * `placement: "containerOnly"` types — those appear only where a parent names
+ * them explicitly in an array.
+ */
+export type ChildrenAllow = "any" | "blocks" | "containers" | readonly string[];
+
+/**
+ * Marks a block as a *container*: a block whose body is other blocks, exposed
+ * as `block.children` at runtime.
+ *
+ * The config describes one uniform body — semantically a single implicit
+ * slot, which is what leaves room for ordered multi-slot bodies (a `sequence`
+ * of slots) to arrive later as a sibling form.
+ */
+export type ChildrenConfig = {
+ /** What may appear as a child. See {@link ChildrenAllow}. */
+ allow: ChildrenAllow;
+ /** @default 1 */
+ min?: number;
+ /** @default unbounded */
+ max?: number;
+ /**
+ * Children to create the container with when it is inserted without an
+ * explicit `children` array. When omitted, BlockNote fills the container
+ * with whatever its content expression requires (usually one empty
+ * paragraph), so a container can never be created in an invalid state.
+ *
+ * Also the seed that `whenEmptied: "refill"` tops up from, when children
+ * drop below `min`.
+ */
+ default?: readonly PartialBlockNoDefaults[];
+ /**
+ * What happens as children are emptied out (Backspace merges the last child
+ * away, `removeBlocks` deletes children, ...) and fewer than `min` non-empty
+ * children remain:
+ *
+ * - `"refill"` (the default): drop the emptied children and top the
+ * container back up to `min`, seeding the missing positions from the
+ * unconsumed tail of `default` (falling back to empty blocks when
+ * `default` is absent or too short).
+ * - `"unwrap"`: drop the emptied children and replace the container with its
+ * survivors, or remove it entirely when none remain. Column lists use this
+ * so emptied columns disappear and a one-column list unwraps.
+ *
+ * Coupled to the child count, so it lives here rather than in `meta`:
+ * ProseMirror's schema fitting always pads a container back up to its
+ * minimum with empty children, so "effectively below the minimum" can only
+ * be detected by discounting those.
+ * @default "refill"
+ */
+ whenEmptied?: "refill" | "unwrap";
+ /**
+ * What may cross the container's edge.
+ *
+ * - `"open"`: everything crosses the edge — the caret, editing gestures
+ * and text selections (ProseMirror `isolating: false`). Right for flow
+ * regions like column lists, where a selection may span columns.
+ * - `"isolated"` (the default): the caret and editing gestures cross
+ * exactly as with `"open"`; only a text selection cannot span the edge
+ * (`isolating: true`).
+ * - `"sealed"`: atomic to gestures, like a table cell — the caret doesn't
+ * enter via arrows/Backspace, and the block selects as a unit
+ * (`isolating: true`). Key-agnostic, so compartments need no hand-written
+ * keyboard handlers.
+ *
+ * Seals bind editing gestures only: the block manipulation API
+ * (`insertBlocks` etc.) ignores them.
+ * @default "isolated"
+ */
+ boundary?: "open" | "isolated" | "sealed";
+};
+
+// `ResolvedChildren` — the fully-defaulted, desugared shape a `ChildrenConfig`
+// compiles to — is internal machinery, not part of the consumer-facing config
+// surface, so it lives in `./children.ts` (which is not re-exported wholesale)
+// rather than here, where `export *` would leak it onto `@blocknote/core`'s
+// public types.
+
/**
* BlockConfig contains the "schema" info about a Block type
* i.e. what props it supports, what content it supports, etc.
@@ -106,8 +204,44 @@ export interface BlockConfig<
* The content that the block supports
*/
content: C;
- // TODO: how do you represent things that have nested content?
- // e.g. tables, alerts (with title & content)
+ /**
+ * Makes this a *container* block: a block whose body is other blocks,
+ * exposed on `block.children`. The block's `render` places them via
+ * `contentRef` (React) / `contentDOM` (vanilla), the same way it would place
+ * inline content.
+ *
+ * Can be combined with `content: "inline"` / `"plain"`, in which case the
+ * block has its own content *and* children, and both are placed in that one
+ * editable region. Only `content: "table"` is incompatible.
+ *
+ * `children: { allow: "any" }` is the minimal container.
+ */
+ children?: ChildrenConfig;
+ /**
+ * Where this block may be placed.
+ *
+ * - `"anywhere"` (default): anywhere a regular block goes — the document
+ * root, or nested under any other block.
+ * - `"containerOnly"`: only inside a container that names this type in its
+ * `children.allow` array (e.g. a `column` inside a `columnList`).
+ *
+ * Only meaningful for container blocks; regular blocks are always placeable
+ * anywhere.
+ */
+ placement?: "anywhere" | "containerOnly";
+}
+
+declare module "prosemirror-model" {
+ interface NodeSpec {
+ /**
+ * The config of the BlockNote block this node was built from, so code
+ * holding a bare `Node` can read block-level facts (children config,
+ * placement, ...) without an editor or schema reference. Set on every
+ * node built from a block spec; a container's generated
+ * `__content`/`__children` nodes carry their owning block's config.
+ */
+ blockConfig?: BlockConfig;
+ }
}
/**
@@ -227,9 +361,11 @@ export type LooseBlockSpec<
) => {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
+ /** See {@link BlockImplementation.render}'s `rootDOM`. */
+ rootDOM?: HTMLElement | null;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
- update?: (node: ProsemirrorNode) => boolean;
destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
toExternalHTML?: (
block: any,
@@ -246,6 +382,12 @@ export type LooseBlockSpec<
| undefined;
node: Node;
+ /**
+ * Nodes the block's own node needs in the schema but which aren't blocks
+ * themselves — the generated content & children nodes of a container block
+ * that has its own content. Registered alongside `node`.
+ */
+ extraNodes?: Node[];
};
extensions?: (Extension | ExtensionFactoryInstance)[];
};
@@ -286,9 +428,11 @@ export type BlockSpecs = {
) => {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
+ /** See {@link BlockImplementation.render}'s `rootDOM`. */
+ rootDOM?: HTMLElement | null;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
- update?: (node: ProsemirrorNode) => boolean;
destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
toExternalHTML?: (
block: any,
@@ -590,19 +734,31 @@ export type BlockImplementation<
) => {
dom: HTMLElement | DocumentFragment;
contentDOM?: HTMLElement;
+ /**
+ * The block author's own root element, when it isn't `dom` itself. React
+ * renders a node view through wrapper elements of its own, so the element
+ * ProseMirror is handed is not the one the author wrote — this points at
+ * the latter, and is what container attributes (`data-node-type`,
+ * `data-id`, prop `data-*`) are stamped onto.
+ * @default dom
+ */
+ rootDOM?: HTMLElement | null;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
+ destroy?: () => void;
/**
- * Called by ProseMirror when this block's node is updated (e.g. its content
- * or props change). Return `true` to handle the update in place - keeping
- * the existing DOM - or `false` to have the node view recreated via
- * `render`. When omitted, ProseMirror keeps the node view and reconciles its
- * `contentDOM` in place as long as the node type stays the same.
+ * Optional NodeView update hook. Called when the underlying ProseMirror
+ * node's attributes change (or its decorations change). Return `false` to
+ * tell ProseMirror to destroy and recreate the NodeView (i.e. re-run
+ * `render` from scratch). Return `true` (or `undefined`) when you have
+ * patched `dom` in-place and PM should keep the existing view.
*
- * Useful for blocks whose `render` builds custom DOM that needs to stay in
- * sync with the node (e.g. a code block rendering a preview of its content).
+ * Only honored for container blocks (blocks with `children`), where
+ * recreating the node view would remount every child block — e.g. column
+ * resizing patches widths in place through this hook. Non-container
+ * blocks always recreate on attr changes (see
+ * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464).
*/
- update?: (node: ProsemirrorNode) => boolean;
- destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
/**
diff --git a/packages/core/src/schema/blocks/validateChildren.test.ts b/packages/core/src/schema/blocks/validateChildren.test.ts
new file mode 100644
index 0000000000..5768cc466a
--- /dev/null
+++ b/packages/core/src/schema/blocks/validateChildren.test.ts
@@ -0,0 +1,288 @@
+// @vitest-environment node
+import { describe, expect, it } from "vite-plus/test";
+
+import type { ChildrenConfig } from "./types.js";
+import { validateChildrenConfigs } from "./validateChildren.js";
+
+type ContainerFixture = {
+ children: ChildrenConfig;
+ placement?: "anywhere" | "containerOnly";
+};
+
+function configsWith(containers: Record) {
+ return {
+ paragraph: { type: "paragraph", content: "inline" as const },
+ heading: { type: "heading", content: "inline" as const },
+ ...Object.fromEntries(
+ Object.entries(containers).map(([type, { children, placement }]) => [
+ type,
+ { type, content: "none" as const, children, placement },
+ ]),
+ ),
+ };
+}
+
+const validate = (containers: Record) => () =>
+ validateChildrenConfigs(configsWith(containers));
+
+describe("validateChildrenConfigs", () => {
+ it("accepts the minimal container config", () => {
+ expect(validate({ callout: { children: { allow: "any" } } })).not.toThrow();
+ });
+
+ it("accepts the columnList shape (restricted children, min 2)", () => {
+ expect(
+ validate({
+ grid: {
+ children: { allow: ["gridCell"], min: 2 },
+ },
+ gridCell: {
+ children: { allow: "any" },
+ placement: "containerOnly",
+ },
+ }),
+ ).not.toThrow();
+ });
+
+ // `allow` carries the whole meaning of a `children` config, so a config
+ // without it is rejected rather than silently defaulted (JS consumers don't
+ // get the type error TS consumers do).
+ it("rejects a config without `allow`", () => {
+ expect(
+ validate({ callout: { children: {} as unknown as ChildrenConfig } }),
+ ).toThrow(/`allow` is required/);
+ });
+
+ it("rejects an unknown `allow` form", () => {
+ expect(
+ validate({
+ callout: {
+ children: { allow: "everything" } as unknown as ChildrenConfig,
+ },
+ }),
+ ).toThrow(/`allow` must be/);
+ });
+
+ it("rejects unknown types in an allow array", () => {
+ expect(validate({ grid: { children: { allow: ["nope"] } } })).toThrow(
+ /nope/,
+ );
+ });
+
+ // The bug this design exists to fix: naming a regular block used to compile
+ // to "any regular block" and validate as if it had restricted something.
+ // Naming a regular type is a hard error until per-type regular-block
+ // filtering is actually supported.
+ it("rejects a regular block type in an allow array", () => {
+ expect(validate({ grid: { children: { allow: ["heading"] } } })).toThrow(
+ /not yet supported/,
+ );
+ });
+
+ it("rejects an allow that permits nothing", () => {
+ expect(validate({ grid: { children: { allow: [] } } })).toThrow(
+ /permits nothing/,
+ );
+ });
+
+ it("rejects the containers wildcard when the schema has no other containers", () => {
+ expect(validate({ grid: { children: { allow: "containers" } } })).toThrow(
+ /no other container block types/,
+ );
+ });
+
+ it("rejects negative or non-integer minimums", () => {
+ expect(
+ validate({ callout: { children: { allow: "any", min: -1 } } }),
+ ).toThrow(/non-negative integer/);
+ });
+
+ it("rejects a maximum smaller than the minimum", () => {
+ expect(
+ validate({ callout: { children: { allow: "any", min: 3, max: 2 } } }),
+ ).toThrow(/greater than or equal/);
+ });
+
+ it("rejects a containerOnly block in a wildcard `default`", () => {
+ // The wildcards compile to the containers placeable anywhere, so a
+ // containerOnly block only fits where it is named explicitly — a default
+ // relying on the wildcard would build an unsatisfiable node.
+ expect(
+ validate({
+ box: { children: { allow: "any", default: [{ type: "cell" }] } },
+ cell: { children: { allow: "any" }, placement: "containerOnly" },
+ }),
+ ).toThrow(/not permitted/);
+ });
+
+ it("rejects a containerOnly block even when a wildcard container exists", () => {
+ // The wildcard never accepts containerOnly blocks, so it must not mask
+ // the reachability check.
+ expect(
+ validate({
+ box: { children: { allow: "any" } },
+ cell: { children: { allow: "any" }, placement: "containerOnly" },
+ }),
+ ).toThrow(/could never be inserted/);
+ });
+
+ it("rejects the containers wildcard when every other container is containerOnly", () => {
+ expect(
+ validate({
+ box: { children: { allow: "containers" } },
+ cell: {
+ children: { allow: "any" },
+ placement: "containerOnly",
+ },
+ }),
+ ).toThrow(/placeable anywhere/);
+ });
+
+ it("rejects an unknown boundary value", () => {
+ expect(
+ validate({
+ cell: {
+ children: {
+ allow: "any",
+ boundary: "shut",
+ } as unknown as ChildrenConfig,
+ },
+ }),
+ ).toThrow(/`boundary` must be "open", "isolated" or "sealed"/);
+ });
+
+ it("rejects `default` violating the child count", () => {
+ expect(
+ validate({
+ callout: {
+ children: { allow: "any", min: 2, default: [{ type: "paragraph" }] },
+ },
+ }),
+ ).toThrow(/fewer than the 2 required/);
+ });
+
+ it("rejects `default` containing a block that isn't permitted", () => {
+ expect(
+ validate({
+ grid: {
+ children: {
+ allow: ["gridCell"],
+ min: 2,
+ default: [{ type: "paragraph" }, { type: "paragraph" }],
+ },
+ },
+ gridCell: {
+ children: { allow: "any" },
+ placement: "containerOnly",
+ },
+ }),
+ ).toThrow(/not permitted/);
+ });
+
+ it("rejects placement on a block that isn't a container", () => {
+ expect(() =>
+ validateChildrenConfigs({
+ paragraph: {
+ type: "paragraph",
+ content: "inline",
+ placement: "containerOnly",
+ },
+ }),
+ ).toThrow(/only applies to container blocks/);
+ });
+
+ it("allows the default placement to be restated on a non-container", () => {
+ expect(() =>
+ validateChildrenConfigs({
+ paragraph: {
+ type: "paragraph",
+ content: "inline",
+ placement: "anywhere",
+ },
+ }),
+ ).not.toThrow();
+ });
+
+ it("rejects a containerOnly block no container accepts", () => {
+ expect(
+ validate({
+ grid: {
+ children: { allow: ["gridCell"], min: 2 },
+ },
+ gridCell: {
+ children: { allow: "blocks" },
+ placement: "containerOnly",
+ },
+ orphan: {
+ children: { allow: "blocks" },
+ placement: "containerOnly",
+ },
+ }),
+ ).toThrow(/could never be inserted/);
+ });
+
+ // A container may have its own content: it becomes a node holding a content
+ // node and a children node.
+ it("accepts `children` on a block with inline content", () => {
+ expect(() =>
+ validateChildrenConfigs({
+ toggle: {
+ type: "toggle",
+ content: "inline",
+ children: { allow: "any" },
+ },
+ }),
+ ).not.toThrow();
+ });
+
+ it("rejects `children` on a table block", () => {
+ expect(() =>
+ validateChildrenConfigs({
+ bad: { type: "bad", content: "table", children: { allow: "any" } },
+ }),
+ ).toThrow(/cannot be combined with `content: "table"`/);
+ });
+
+ // The content & children nodes are generated from the block type, so a block
+ // type that happens to have one of those names would clash with them.
+ it("rejects a block type that collides with a generated node name", () => {
+ expect(() =>
+ validateChildrenConfigs({
+ toggle: {
+ type: "toggle",
+ content: "inline",
+ children: { allow: "any" },
+ },
+ toggle__content: { type: "toggle__content", content: "inline" },
+ }),
+ ).toThrow(/collides with the block type of the same name/);
+ });
+
+ // `fillBefore` recurses across node types, so a cycle blows the stack rather
+ // than returning null — it has to be caught before the schema is built.
+ it("rejects a container cycle", () => {
+ expect(
+ validate({
+ card: { children: { allow: ["cardBody"] } },
+ cardBody: {
+ children: { allow: ["card"] },
+ placement: "containerOnly",
+ },
+ }),
+ ).toThrow(/requires it back/);
+ });
+
+ it("accepts a mutual reference when one side allows regular blocks", () => {
+ // A container that accepts regular blocks can always be filled with a
+ // paragraph, so it breaks the cycle.
+ expect(
+ validate({
+ card: { children: { allow: ["cardBody"] } },
+ cardBody: {
+ children: { allow: "any" },
+ placement: "containerOnly",
+ },
+ }),
+ ).not.toThrow();
+ });
+});
diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts
new file mode 100644
index 0000000000..c8705de522
--- /dev/null
+++ b/packages/core/src/schema/blocks/validateChildren.ts
@@ -0,0 +1,408 @@
+import {
+ containerChildrenNodeName,
+ containerContentNodeName,
+ getChildrenConfig,
+ isContainerType,
+ isPlaceableAnywhere,
+ resolveChildren,
+} from "./children.js";
+import type { ResolvedChildren } from "./children.js";
+import type { BlockConfig, ChildrenConfig } from "./types.js";
+
+type ValidatableConfig = Pick & {
+ children?: ChildrenConfig;
+ placement?: BlockConfig["placement"];
+};
+
+/**
+ * Validates the `children` config of every block in a schema, so that
+ * misconfigurations surface as a clear error at schema-creation time instead
+ * of as an opaque ProseMirror one (or a stack overflow) much later.
+ *
+ * @param blockConfigs The configs of every block in the schema, keyed by type.
+ */
+export function validateChildrenConfigs(
+ blockConfigs: Record,
+) {
+ const isContainerBlockType = (blockType: string) =>
+ !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]);
+ const acceptCtx = {
+ isContainerBlockType,
+ isPlaceableAnywhereType: (blockType: string) =>
+ !!blockConfigs[blockType] && isPlaceableAnywhere(blockConfigs[blockType]),
+ };
+
+ for (const [type, config] of Object.entries(blockConfigs)) {
+ const children = getChildrenConfig(config);
+
+ if (!children) {
+ // `placement: "anywhere"` is the documented default for every block, so
+ // writing it on a regular block is a harmless restatement. Only
+ // `"containerOnly"` is meaningless without `children`.
+ if (config.placement === "containerOnly") {
+ fail(
+ type,
+ '`placement: "containerOnly"` only applies to container blocks, but this block does not declare `children`. Regular blocks can always be placed anywhere.',
+ );
+ }
+ continue;
+ }
+
+ validateOne(type, config, children, blockConfigs, acceptCtx);
+ }
+
+ validateContainerOnlyIsReachable(blockConfigs);
+ validateNoCycles(blockConfigs, isContainerBlockType);
+}
+
+function fail(type: string, message: string): never {
+ throw new Error(
+ `Invalid \`children\` config for block "${type}": ${message}`,
+ );
+}
+
+type AllowAcceptContext = {
+ isContainerBlockType: (blockType: string) => boolean;
+ isPlaceableAnywhereType: (blockType: string) => boolean;
+};
+
+function validateOne(
+ type: string,
+ config: ValidatableConfig,
+ children: ChildrenConfig,
+ blockConfigs: Record,
+ acceptCtx: AllowAcceptContext,
+) {
+ // A container may have its own content: it then becomes a node holding a
+ // content node and a children node. A table can't — its content is already
+ // a node tree of its own, with nowhere to put the children node.
+ if (config.content === "table") {
+ fail(
+ type,
+ '`children` cannot be combined with `content: "table"`. A table block\'s content is already a structure of its own.',
+ );
+ }
+
+ if (config.content !== "none") {
+ // The content & children nodes are generated from the block type, so a
+ // block type that happens to have the generated name would silently
+ // overwrite one of them.
+ for (const generated of [
+ containerContentNodeName(type),
+ containerChildrenNodeName(type),
+ ]) {
+ if (generated in blockConfigs) {
+ fail(
+ type,
+ `it has its own content as well as \`children\`, so it generates a node named "${generated}" — which collides with the block type of the same name. Rename one of the two.`,
+ );
+ }
+ }
+ }
+
+ // Mirror the type-level contract for JS consumers: `allow` is required, and
+ // takes exactly the four forms. Widened to `unknown` because the type
+ // narrowing would otherwise leave `never` for the message.
+ const allow: unknown = children.allow;
+ if (allow === undefined) {
+ fail(
+ type,
+ '`allow` is required. Use `children: { allow: "any" }` for a container that accepts any block.',
+ );
+ }
+ if (
+ !Array.isArray(allow) &&
+ allow !== "any" &&
+ allow !== "blocks" &&
+ allow !== "containers"
+ ) {
+ fail(
+ type,
+ `\`allow\` must be "any", "blocks", "containers" or an array of container block types, but is ${JSON.stringify(allow)}.`,
+ );
+ }
+
+ const boundary: string | undefined = children.boundary;
+ if (
+ boundary !== undefined &&
+ boundary !== "open" &&
+ boundary !== "isolated" &&
+ boundary !== "sealed"
+ ) {
+ fail(
+ type,
+ `\`boundary\` must be "open", "isolated" or "sealed", but is "${boundary}".`,
+ );
+ }
+
+ const resolved = resolveChildren(children);
+
+ if (!Number.isInteger(resolved.min) || resolved.min < 0) {
+ fail(
+ type,
+ `minimum child count must be a non-negative integer, but is ${resolved.min}.`,
+ );
+ }
+ if (resolved.max !== undefined) {
+ if (!Number.isInteger(resolved.max) || resolved.max < 1) {
+ fail(
+ type,
+ `maximum child count must be a positive integer, but is ${resolved.max}.`,
+ );
+ }
+ if (resolved.max < resolved.min) {
+ fail(
+ type,
+ `maximum child count (${resolved.max}) must be greater than or equal to the minimum (${resolved.min}).`,
+ );
+ }
+ }
+
+ validateAllow(type, resolved, blockConfigs, acceptCtx);
+ validateDefault(type, resolved, blockConfigs, acceptCtx);
+}
+
+function validateAllow(
+ type: string,
+ resolved: ResolvedChildren,
+ blockConfigs: Record,
+ { isContainerBlockType, isPlaceableAnywhereType }: AllowAcceptContext,
+) {
+ if (resolved.containers !== true) {
+ for (const allowed of resolved.containers) {
+ if (!(allowed in blockConfigs)) {
+ fail(
+ type,
+ `\`allow\` contains "${allowed}", which is not a block type in this schema.`,
+ );
+ }
+ // An `allow` array is exact by construction: each named type is its own
+ // ProseMirror node. Every *regular* block, by contrast, is the same node
+ // (`blockContainer`), so naming one here would promise a restriction the
+ // schema cannot keep.
+ if (!isContainerBlockType(allowed)) {
+ fail(
+ type,
+ `\`allow\` contains "${allowed}", which is a regular block, not a container block. ` +
+ "Restricting which regular block types a container accepts is not yet supported — every regular block is the same ProseMirror node. " +
+ 'Use `allow: "blocks"` to accept all regular blocks, or name only container block types.',
+ );
+ }
+ }
+ }
+
+ if (
+ !resolved.blocks &&
+ resolved.containers !== true &&
+ resolved.containers.length === 0
+ ) {
+ fail(
+ type,
+ "`allow` permits nothing. A container must accept at least one block or container type; drop `children` entirely for a block that holds none.",
+ );
+ }
+
+ if (!resolved.blocks && resolved.containers === true) {
+ // The wildcard compiles to the containers placeable anywhere, so only
+ // those make the container fillable — `containerOnly` blocks never join
+ // it.
+ const hasContainer = Object.keys(blockConfigs).some(
+ (blockType) =>
+ isContainerBlockType(blockType) &&
+ blockType !== type &&
+ isPlaceableAnywhereType(blockType),
+ );
+ if (!hasContainer) {
+ fail(
+ type,
+ "`allow` permits only container blocks, but this schema has no other container block types placeable anywhere. " +
+ 'The `"containers"` wildcard never includes `placement: "containerOnly"` blocks — name those explicitly in an `allow` array.',
+ );
+ }
+ }
+}
+
+function validateDefault(
+ type: string,
+ resolved: ResolvedChildren,
+ blockConfigs: Record,
+ acceptCtx: AllowAcceptContext,
+) {
+ const { default: defaultChildren, min, max } = resolved;
+ if (!defaultChildren) {
+ return;
+ }
+
+ if (defaultChildren.length < min) {
+ fail(
+ type,
+ `\`default\` has ${defaultChildren.length} block(s), fewer than the ${min} required.`,
+ );
+ }
+ if (max !== undefined && defaultChildren.length > max) {
+ fail(
+ type,
+ `\`default\` has ${defaultChildren.length} block(s), more than the ${max} allowed.`,
+ );
+ }
+
+ for (const child of defaultChildren) {
+ const childType = child.type ?? "paragraph";
+ if (!(childType in blockConfigs)) {
+ fail(
+ type,
+ `\`default\` contains a block of type "${childType}", which is not a block type in this schema.`,
+ );
+ }
+
+ if (!allowAccepts(resolved, childType, acceptCtx)) {
+ fail(
+ type,
+ `\`default\` contains a block of type "${childType}", which is not permitted.`,
+ );
+ }
+ }
+}
+
+/**
+ * Whether a container's `allow` accepts a block type. Honest by construction:
+ * the schema's only lever for regular blocks is "is `blockContainer` in the
+ * expression or not", so that is exactly what this asks. Likewise the
+ * container wildcards mirror what they compile to — the containers placeable
+ * anywhere — so a `placement: "containerOnly"` block is only accepted where it
+ * is named explicitly.
+ */
+function allowAccepts(
+ resolved: ResolvedChildren,
+ blockType: string,
+ ctx: AllowAcceptContext,
+): boolean {
+ if (ctx.isContainerBlockType(blockType)) {
+ return resolved.containers === true
+ ? ctx.isPlaceableAnywhereType(blockType)
+ : resolved.containers.includes(blockType);
+ }
+ return resolved.blocks;
+}
+
+/**
+ * Container nodes register in a priority band strictly below `blockContainer`
+ * (see `containerNodePriority`), which is below every regular block. So a
+ * container's `runsBefore` can only order it against other containers — naming
+ * a regular block there promises an ordering the schema cannot produce.
+ *
+ * @param blockConfigs The configs of every block in the schema, keyed by type.
+ * @param runsBefore The `runsBefore` each block's implementation declares.
+ */
+export function validateContainerRunsBefore(
+ blockConfigs: Record,
+ runsBefore: Record,
+) {
+ for (const [type, config] of Object.entries(blockConfigs)) {
+ if (!isContainerType(config)) {
+ continue;
+ }
+
+ for (const other of runsBefore[type] ?? []) {
+ // "default" is `sortByDependencies`' reference point rather than a block
+ // type, and a type that isn't in the schema is somebody else's error.
+ if (other === "default" || !(other in blockConfigs)) {
+ continue;
+ }
+ if (!isContainerType(blockConfigs[other])) {
+ throw new Error(
+ `Invalid \`runsBefore\` for container block "${type}": it names "${other}", which is a regular block, not a container block. ` +
+ "Container block nodes always register below regular ones, so a container can never be ordered before a regular block. " +
+ "`runsBefore` on a container can only name other container blocks.",
+ );
+ }
+ }
+ }
+}
+
+/**
+ * A `placement: "containerOnly"` block that no container accepts could never
+ * be inserted anywhere, which is always a mistake rather than a choice.
+ *
+ * Only explicit `allow` arrays count: the container wildcards compile to the
+ * containers placeable anywhere, so they never accept a `containerOnly`
+ * block. Deliberately conservative otherwise — proving that the block is
+ * reachable from a block placeable at the root is full graph reachability, and
+ * this check exists to catch typos, not to police schema topology.
+ */
+function validateContainerOnlyIsReachable(
+ blockConfigs: Record,
+) {
+ const accepted = new Set();
+ for (const config of Object.values(blockConfigs)) {
+ const children = getChildrenConfig(config);
+ if (!children) {
+ continue;
+ }
+ const { containers } = resolveChildren(children);
+ if (containers === true) {
+ continue;
+ }
+ for (const allowed of containers) {
+ accepted.add(allowed);
+ }
+ }
+
+ for (const [type, config] of Object.entries(blockConfigs)) {
+ if (!isPlaceableAnywhere(config) && !accepted.has(type)) {
+ fail(
+ type,
+ `it declares \`placement: "containerOnly"\`, but no container's \`children.allow\` array includes it, so it could never be inserted.`,
+ );
+ }
+ }
+}
+
+/**
+ * A container that requires a child which in turn requires it back can never
+ * be created: ProseMirror's `fillBefore` recurses across node types and blows
+ * the stack rather than returning `null`. So this has to be caught statically,
+ * before the schema is built.
+ */
+function validateNoCycles(
+ blockConfigs: Record,
+ isContainerBlockType: (blockType: string) => boolean,
+) {
+ // A container that allows regular blocks can always be filled with a plain
+ // paragraph, so it never forces recursion — only container-only lists do.
+ const requiredContainers = (type: string): string[] => {
+ const children = getChildrenConfig(blockConfigs[type]);
+ if (!children) {
+ return [];
+ }
+ const resolved = resolveChildren(children);
+ return resolved.min >= 1 && !resolved.blocks && resolved.containers !== true
+ ? resolved.containers.filter(isContainerBlockType)
+ : [];
+ };
+
+ const state = new Map();
+
+ const visit = (type: string, path: string[]) => {
+ const seen = state.get(type);
+ if (seen === "done") {
+ return;
+ }
+ if (seen === "visiting") {
+ fail(
+ type,
+ `it requires a child that requires it back (${[...path, type].join(" -> ")}), so it could never be created. Allow regular blocks in one of the containers to break the cycle.`,
+ );
+ }
+
+ state.set(type, "visiting");
+ for (const next of requiredContainers(type)) {
+ visit(next, [...path, type]);
+ }
+ state.set(type, "done");
+ };
+
+ for (const type of Object.keys(blockConfigs)) {
+ visit(type, []);
+ }
+}
diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts
index 2f1e703007..674a0bdb96 100644
--- a/packages/core/src/schema/index.ts
+++ b/packages/core/src/schema/index.ts
@@ -1,3 +1,10 @@
+// `children.js` and `validateChildren.js` are deliberately *not* re-exported
+// wholesale: almost everything in them is machinery for compiling a `children`
+// config into a ProseMirror content expression, which lives on
+// `@blocknote/core/internal` (see `src/internal.ts`). Only the question a block
+// author asks — "is this a container?" — belongs here; the config types come
+// from `./blocks/types.js` below.
+export { isContainerType } from "./blocks/children.js";
export * from "./blocks/createSpec.js";
export * from "./blocks/internal.js";
export * from "./blocks/types.js";
diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts
index a7a04e93dc..b69ba53fbf 100644
--- a/packages/core/src/schema/schema.ts
+++ b/packages/core/src/schema/schema.ts
@@ -16,6 +16,10 @@ import {
getInlineContentSchemaFromSpecs,
getStyleSchemaFromSpecs,
} from "./index.js";
+import {
+ validateChildrenConfigs,
+ validateContainerRunsBefore,
+} from "./blocks/validateChildren.js";
function removeUndefined | undefined>(obj: T): T {
if (!obj) {
@@ -91,6 +95,26 @@ export class CustomBlockNoteSchema<
})),
);
+ // Validation runs before the nodes are built, so misconfigurations
+ // surface as clear errors rather than as opaque ProseMirror ones.
+ const blockConfigs = Object.fromEntries(
+ Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [
+ key,
+ blockSpec.config,
+ ]),
+ );
+
+ validateChildrenConfigs(blockConfigs);
+ validateContainerRunsBefore(
+ blockConfigs,
+ Object.fromEntries(
+ Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [
+ key,
+ blockSpec.implementation?.runsBefore,
+ ]),
+ ),
+ );
+
const blockSpecs = Object.fromEntries(
Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => {
return [
diff --git a/packages/core/src/y/extensions/AttributionExtension.test.ts b/packages/core/src/y/extensions/AttributionExtension.test.ts
index df0267f093..f752b48182 100644
--- a/packages/core/src/y/extensions/AttributionExtension.test.ts
+++ b/packages/core/src/y/extensions/AttributionExtension.test.ts
@@ -17,15 +17,14 @@ const editors: BlockNoteEditor[] = [];
// No Yjs/collaboration needed — the extension's load plugin only cares that a
// transaction adds a `y-attributed-*` mark, which we do directly below.
function createEditor() {
- const resolveUsers = vi.fn(
- async (ids: string[]): Promise =>
- ids.map((id) => ({
- id,
- username: `name-${id}`,
- avatarUrl: "",
- color: "#123456",
- colorLight: "#abcdef",
- })),
+ const resolveUsers = vi.fn(async (ids: string[]): Promise =>
+ ids.map((id) => ({
+ id,
+ username: `name-${id}`,
+ avatarUrl: "",
+ color: "#123456",
+ colorLight: "#abcdef",
+ })),
);
const editor = BlockNoteEditor.create({
diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts
index 37fb1fd4e9..7dc3f4253d 100644
--- a/packages/core/src/yjs/extensions/FixUpSchema.ts
+++ b/packages/core/src/yjs/extensions/FixUpSchema.ts
@@ -25,7 +25,15 @@ export const FixUpSchemaExtension = createExtension(({ editor }) => {
// create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state)
const jsonNode = JSON.parse(JSON.stringify(ret.toJSON()));
- jsonNode.content[0].content[0].attrs.id = "initialBlockId";
+ // The first fill of the doc's blockGroup is guaranteed to be a
+ // `blockContainer` (container block nodes register at lower priority
+ // precisely so auto-fill picks `blockContainer` first), but guard on
+ // the node actually carrying an id attr in case a custom schema
+ // changes that.
+ const firstBlock = jsonNode.content?.[0]?.content?.[0];
+ if (firstBlock?.attrs && "id" in firstBlock.attrs) {
+ firstBlock.attrs.id = "initialBlockId";
+ }
cache = Node.fromJSON(schema, jsonNode);
return cache;
diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts
index 2763b9723c..4f743d69fe 100644
--- a/packages/core/vite.config.ts
+++ b/packages/core/vite.config.ts
@@ -35,6 +35,7 @@ export default defineConfig({
blocks: path.resolve(__dirname, "src/blocks/index.ts"),
locales: path.resolve(__dirname, "src/i18n/index.ts"),
extensions: path.resolve(__dirname, "src/extensions/index.ts"),
+ internal: path.resolve(__dirname, "src/internal.ts"),
yjs: path.resolve(__dirname, "src/yjs/index.ts"),
y: path.resolve(__dirname, "src/y/index.ts"),
},
diff --git a/packages/core/vitestSetup.ts b/packages/core/vitestSetup.ts
index bf9678c8f8..23642824a6 100644
--- a/packages/core/vitestSetup.ts
+++ b/packages/core/vitestSetup.ts
@@ -1,11 +1,18 @@
import { afterEach, beforeEach } from "vite-plus/test";
+// This setup file also runs for test files that opt into the plain `node`
+// environment (`@vitest-environment node`), where there is no `window` at all.
+// `__TEST_OPTIONS` (which drives deterministic block IDs) therefore hangs off
+// `window` when there is one and off `globalThis` otherwise — the same
+// resolution `UniqueID`'s `generateID` uses.
+const testHost: any = (globalThis as any).window ?? globalThis;
+
beforeEach(() => {
- (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
+ testHost.__TEST_OPTIONS = {};
});
afterEach(() => {
- delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
+ delete testHost.__TEST_OPTIONS;
});
// Mock ClipboardEvent
diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx
index 2bf0e4fa57..57f340afb2 100644
--- a/packages/react/src/components/Popovers/BlockPopover.tsx
+++ b/packages/react/src/components/Popovers/BlockPopover.tsx
@@ -1,4 +1,4 @@
-import { getNodeById } from "@blocknote/core";
+import { getNodeById, isContainerNode } from "@blocknote/core";
import { ReactNode, useMemo } from "react";
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
@@ -29,6 +29,28 @@ export const BlockPopover = (
return undefined;
}
+ // For container blocks the PM node IS the block, so a position
+ // inside it resolves to its contentDOM — the child-blocks area —
+ // which would anchor the popover to the first child's rows instead
+ // of the block's own element.
+ if (isContainerNode(nodePosInfo.node.type)) {
+ const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode);
+ // Frameworks like React wrap the node view in a `display: contents`
+ // element that has no box of its own (a zero-size bounding rect), so
+ // anchoring to it would place the popover at (0, 0). The block's
+ // actual box is the author's root element inside it, which core
+ // stamps with `data-node-type`; vanilla containers render that boxed
+ // element directly as the node view's DOM.
+ if (dom instanceof Element) {
+ const boxed = dom.matches("[data-node-type]")
+ ? dom
+ : dom.querySelector("[data-node-type]");
+ if (boxed) {
+ return { element: boxed };
+ }
+ }
+ }
+
const { node } = editor.prosemirrorView.domAtPos(
nodePosInfo.posBeforeNode + 1,
);
diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css
index 507f2cd46f..c3a39005a5 100644
--- a/packages/react/src/editor/styles.css
+++ b/packages/react/src/editor/styles.css
@@ -111,6 +111,13 @@
width: 100%;
}
+/* Container blocks own their outer DOM: the block's root element is the one
+ its `render` returned, so the wrapper React needs around it must not be a
+ box of its own. */
+.bn-react-node-view-renderer.bn-container-node-view {
+ display: contents;
+}
+
/* Indent line styling */
.bn-block-group
.bn-block:not(:has(.bn-toggle-wrapper))
diff --git a/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx
new file mode 100644
index 0000000000..e2e8ddc236
--- /dev/null
+++ b/packages/react/src/schema/ReactBlockSpec.container.browser.test.tsx
@@ -0,0 +1,168 @@
+import { BlockNoteEditor, BlockNoteSchema } from "@blocknote/core";
+import { flushSync } from "react-dom";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteViewRaw } from "../editor/BlockNoteView.js";
+import { createReactBlockSpec } from "./ReactBlockSpec.js";
+
+/**
+ * The DOM output of React container blocks, in a real browser.
+ *
+ * Both halves need a DOM that actually exists: the external-HTML path renders
+ * the block through a temporary `createRoot` (see `@util/ReactRenderUtil`), and
+ * a React node view only runs at all once `contentComponent` is set, which
+ * happens when `BlockNoteViewRaw` mounts the editor. Runs in the tests
+ * package's browser suite; the document-level assertions live next door in
+ * `ReactBlockSpec.container.test.tsx` (node).
+ *
+ * Layout facts (`display: contents` hosts, box geometry) and the content +
+ * children region ordering are covered by
+ * `tests/src/end-to-end/containerblocks/containerblocks.test.tsx`.
+ */
+
+// A pure container: its `contentRef` element holds its child blocks.
+const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: { flavor: { default: "tip" } },
+ content: "none",
+ children: { allow: "any", default: [{ type: "paragraph" }] },
+ },
+ {
+ render: (props) => (
+
+
+
+ ),
+ },
+);
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: { callout: createCallout() },
+});
+
+describe("React container block external HTML", () => {
+ it("does not wrap containers in a blockContent div", () => {
+ // Headless: the React `toExternalHTML` path renders through a temporary
+ // React root, which is why this still needs a real document.
+ const editor = BlockNoteEditor.create({ schema });
+
+ const html = editor.blocksToHTMLLossy([
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }],
+ },
+ ] as any);
+
+ // Container blocks own their outer DOM entirely — regression test for the
+ // React `toExternalHTML` path wrapping them in a spurious
+ // `bn-block-content` div (core's `createBlockSpec` passes them through).
+ expect(html).not.toContain('data-content-type="callout"');
+ expect(html).toContain('data-node-type="callout"');
+ expect(html).toContain("Hello");
+ });
+
+ it("puts container attributes on the block's own root element", () => {
+ // The serialized root is the element the block's `render` returned — no
+ // wrapper of React's in between — so `.callout[data-flavor]` CSS matches
+ // the same element here as it does in the live editor (below).
+ const editor = BlockNoteEditor.create({ schema });
+
+ const html = editor.blocksToHTMLLossy([
+ { type: "callout", id: "c-0", children: [{ type: "paragraph" }] },
+ ] as any);
+
+ expect(html).toContain('class="callout"');
+ expect(html).toContain('data-node-type="callout"');
+ expect(html).not.toContain("data-node-view-wrapper");
+ });
+});
+
+let root: Root | undefined;
+let div: HTMLDivElement | undefined;
+let editor: BlockNoteEditor | undefined;
+
+afterEach(() => {
+ root?.unmount();
+ root = undefined;
+ if (div) {
+ document.body.removeChild(div);
+ div = undefined;
+ }
+ editor?._tiptapEditor.destroy();
+ editor = undefined;
+});
+
+/** Lets TipTap's deferred node-view render and React's commit run. */
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+async function mountEditor(initialContent: any[]) {
+ div = document.createElement("div");
+ document.body.appendChild(div);
+
+ editor = BlockNoteEditor.create({
+ schema,
+ trailingBlock: false,
+ initialContent,
+ }) as BlockNoteEditor;
+
+ root = createRoot(div);
+ flushSync(() => {
+ root!.render();
+ });
+ // TipTap only renders a node view synchronously when this is set; BlockNote
+ // mounts the editor itself and never does, so the first batch of node views
+ // takes the deferred path (see `tests/src/unit/react/staleNodeViewPos.test.tsx`).
+ (editor as any)._tiptapEditor.isEditorContentInitialized = true;
+ await tick();
+
+ return { editor: editor!, div: div! };
+}
+
+describe("React container block node view", () => {
+ it("stamps only non-default props onto the block's own root, and keeps them in sync", async () => {
+ const mounted = await mountEditor([
+ { id: "c-0", type: "callout", children: [{ type: "paragraph" }] },
+ ]);
+
+ const calloutRoot = mounted.div.querySelector(".callout")!;
+ // The author's element, not `div.react-renderer` or the node view wrapper —
+ // exactly the class the author wrote, and nothing else.
+ expect(calloutRoot.className).toBe("callout");
+ expect(calloutRoot.getAttribute("data-id")).toBe("c-0");
+ // `flavor` is at its default, so no attribute is written for it.
+ expect(calloutRoot.hasAttribute("data-flavor")).toBe(false);
+
+ mounted.editor.updateBlock("c-0", { props: { flavor: "warning" } } as any);
+ await tick();
+
+ // Re-queried: a prop change must land on whatever element is now the
+ // block's root, so `.callout[data-flavor="warning"]` selects in the live
+ // editor exactly as it does in the serialized HTML above.
+ expect(
+ mounted.div
+ .querySelector(".callout")!
+ .getAttribute("data-flavor"),
+ ).toBe("warning");
+ });
+
+ it("mounts a pure container's children inside its `contentRef` element", async () => {
+ const mounted = await mountEditor([
+ {
+ id: "c-0",
+ type: "callout",
+ children: [{ id: "c-child", type: "paragraph", content: "Child" }],
+ },
+ ]);
+
+ const body = mounted.div.querySelector(".callout-body")!;
+ // A container with no content of its own puts its children where the
+ // author placed `contentRef` — not somewhere else in the node view. The
+ // child's own block element is a descendant, so this is structure, not
+ // just text that happened to bubble up.
+ expect(body.querySelector('[data-id="c-child"]')).not.toBeNull();
+ expect(body.textContent).toBe("Child");
+ });
+});
diff --git a/packages/react/src/schema/ReactBlockSpec.container.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.test.tsx
new file mode 100644
index 0000000000..456c9806c9
--- /dev/null
+++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx
@@ -0,0 +1,179 @@
+/**
+ * @vitest-environment node
+ *
+ * Document-level tests for React container blocks. Every assertion here reads
+ * `editor.document`, so there is nothing to render and no DOM to need — the
+ * node environment keeps that honest. The DOM output (serialized HTML and the
+ * live node view) is covered by `ReactBlockSpec.container.browser.test.tsx`.
+ */
+import {
+ BlockNoteEditor,
+ BlockNoteSchema,
+ defaultBlockSpecs,
+} from "@blocknote/core";
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+
+import { createReactBlockSpec } from "./ReactBlockSpec.js";
+
+// Same shape as the example callout block (`examples/06-custom-schema/09-container-block`).
+const Callout = createReactBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {},
+ content: "none" as const,
+ children: { allow: "any", default: [{ type: "paragraph" }] },
+ },
+ {
+ render: ({ contentRef }) => (
+
+
+
+ ),
+ },
+)();
+
+// The additivity claim: adding `children` to an existing block is one config
+// line and *zero* render changes. Both blocks below share this render, which is
+// the shape every inline-content React block already has — `contentRef` on a
+// plain div. `Alert` is `examples/06-custom-schema/01-alert-block` reduced to
+// its structure; `AlertWithBody` is the same block with `children` added.
+const renderAlert = ({ contentRef }: { contentRef: (el: any) => void }) => (
+
+
+
+
+);
+
+const Alert = createReactBlockSpec(
+ {
+ type: "alert" as const,
+ propSchema: { flavor: { default: "warning" } },
+ content: "inline" as const,
+ },
+ { render: renderAlert },
+)();
+
+const AlertWithBody = createReactBlockSpec(
+ {
+ type: "alertWithBody" as const,
+ propSchema: { flavor: { default: "warning" } },
+ content: "inline" as const,
+ children: { allow: "any" },
+ },
+ { render: renderAlert },
+)();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: Callout,
+ alert: Alert,
+ alertWithBody: AlertWithBody,
+ } as const,
+});
+
+const defaultParagraphProps = {
+ backgroundColor: "default",
+ textAlignment: "left",
+ textColor: "default",
+};
+
+/**
+ * The document without block ids. Converting a block generates fresh ids, and
+ * core's deterministic test-id hook (`UniqueID`'s `generateID`) only kicks in
+ * when a `window` exists — under node it falls back to real UUIDs. Ids that
+ * matter are asserted individually.
+ */
+const withoutIds = (blocks: any[]): any[] =>
+ blocks.map(({ id: _id, children, ...rest }) => ({
+ ...rest,
+ children: withoutIds(children),
+ }));
+
+describe("React updateBlock → container with `default` (document-level)", () => {
+ const editor = BlockNoteEditor.create({ schema });
+
+ beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "" },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+ });
+
+ it("converts an empty paragraph to a callout via editor.updateBlock", () => {
+ editor.updateBlock("p-0", { type: "callout" });
+
+ expect(withoutIds(editor.document)).toEqual([
+ {
+ type: "callout",
+ props: {},
+ // `content: "none"`, so the block has no inline content of its own —
+ // and the `children.default` seeded it exactly one empty paragraph.
+ content: undefined,
+ children: [
+ {
+ type: "paragraph",
+ props: defaultParagraphProps,
+ content: [],
+ children: [],
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ props: defaultParagraphProps,
+ content: [],
+ children: [],
+ },
+ ]);
+ // The block that wasn't converted keeps its identity.
+ expect(editor.document[1].id).toBe("trailing");
+ }, 5000);
+});
+
+// The plan's headline claim: `children` is additive. Adding it to a block gives
+// that block a body without touching its `render` — the block's `contentRef`
+// element goes from holding just its inline content to holding its inline
+// content followed by its child blocks.
+describe("adding `children` to an existing block", () => {
+ const editor = BlockNoteEditor.create({ schema });
+
+ it("keeps the block without `children` unchanged", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "a-0", type: "alert", content: "Heads up" },
+ ] as any);
+
+ const block = editor.getBlock("a-0")!;
+ expect(block.content).toEqual([
+ { type: "text", text: "Heads up", styles: {} },
+ ]);
+ expect(block.children).toEqual([]);
+ }, 5000);
+
+ it("gains a body that accepts child blocks, with the same render", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ id: "b-0",
+ type: "alertWithBody",
+ content: "Heads up",
+ children: [{ id: "b-child", type: "paragraph", content: "Details" }],
+ },
+ ] as any);
+
+ const block = editor.getBlock("b-0")!;
+ expect(block.content).toEqual([
+ { type: "text", text: "Heads up", styles: {} },
+ ]);
+ expect(block.children.map((child) => child.id)).toEqual(["b-child"]);
+ // The child is an ordinary block of the document, reachable by id.
+ expect(editor.getBlock("b-child")).toBeDefined();
+ }, 5000);
+
+ it("seeds a body when inserted without children", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "c-0", type: "alertWithBody", content: "Heads up" },
+ ] as any);
+
+ expect(editor.getBlock("c-0")!.children).toHaveLength(1);
+ }, 5000);
+});
diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx
index 5311d4e37d..57e4f9d949 100644
--- a/packages/react/src/schema/ReactBlockSpec.tsx
+++ b/packages/react/src/schema/ReactBlockSpec.tsx
@@ -1,3 +1,4 @@
+import { applyContainerAttributes } from "@blocknote/core/internal";
import {
BlockConfig,
BlockConfigOrCreator,
@@ -6,11 +7,14 @@ import {
BlockNoteEditor,
BlockSpec,
camelToDataKebab,
+ ChildrenConfig,
CustomBlockImplementation,
Extension,
ExtensionFactoryInstance,
ExtractBlockConfigFromConfigOrCreator,
+ isContainerType,
mergeCSSClasses,
+ nodeToBlock,
Props,
PropSchema,
} from "@blocknote/core";
@@ -20,12 +24,29 @@ import {
ReactNodeViewRenderer,
useReactNodeView,
} from "@tiptap/react";
-import { FC, ReactNode } from "react";
+import { CSSProperties, FC, ReactNode, useLayoutEffect } from "react";
import { renderToDOMSpec } from "./@util/ReactRenderUtil.js";
import { useNodeViewBlock } from "./useNodeViewBlock.js";
// this file is mostly analogoues to `customBlocks.ts`, but for React blocks
+// A container block's root element is the block's own element, so every
+// wrapper React puts above it has to contribute no box of its own. Module
+// scope so the style object is referentially stable across renders.
+const DISPLAY_CONTENTS: CSSProperties = { display: "contents" };
+
+/**
+ * Whether the block has an editable region for its `render` to place: its
+ * inline content, its child blocks, or — for a container that also has its own
+ * content — both. Only a `content: "none"` block without `children` has
+ * nothing to place, and so is the only kind that doesn't get a `contentRef`.
+ */
+type HasEditableRegion = Config extends { children: ChildrenConfig }
+ ? true
+ : Config extends { content: "none" }
+ ? false
+ : true;
+
export type ReactCustomBlockRenderProps<
B extends BlockConfigOrCreator,
Config extends ExtractBlockConfigFromConfigOrCreator =
@@ -33,11 +54,16 @@ export type ReactCustomBlockRenderProps<
> = {
block: BlockNoDefaults, any, any>;
editor: BlockNoteEditor, any, any>;
-} & (Config["content"] extends "inline" | "plain"
- ? {
- contentRef: (node: HTMLElement | null) => void;
- }
- : object);
+} & (Config["content"] extends "table"
+ ? object
+ : HasEditableRegion extends true
+ ? {
+ // Points to where the block's editable region mounts: its inline
+ // content, its child blocks, or — for a container that has its own
+ // content — its content followed by its children.
+ contentRef: (node: HTMLElement | null) => void;
+ }
+ : object);
// extend BlockConfig but use a React render function
export type ReactCustomBlockImplementation<
@@ -131,20 +157,20 @@ export function createReactBlockSpec<
const TName extends string,
const TProps extends PropSchema,
const TContent extends "inline" | "none" | "plain",
+ // Inferred from the config object itself rather than widened to
+ // `BlockConfig<...>`, so `children` survives into the render props and
+ // `contentRef` is offered exactly when the block has an editable region.
+ const BlockConf extends BlockConfig,
const TOptions extends Record | undefined = undefined,
>(
- blockConfigOrCreator: BlockConfig,
+ blockConfigOrCreator: BlockConf,
blockImplementationOrCreator:
- | ReactCustomBlockImplementation>
+ | ReactCustomBlockImplementation
| (TOptions extends undefined
- ? () => ReactCustomBlockImplementation<
- BlockConfig
- >
+ ? () => ReactCustomBlockImplementation
: (
options: Partial,
- ) => ReactCustomBlockImplementation<
- BlockConfig
- >),
+ ) => ReactCustomBlockImplementation),
extensionsOrCreator?:
| (ExtensionFactoryInstance | Extension)[]
| (TOptions extends undefined
@@ -152,7 +178,13 @@ export function createReactBlockSpec<
: (
options: Partial,
) => (ExtensionFactoryInstance | Extension)[]),
-): (options?: Partial) => BlockSpec;
+): (
+ options?: Partial,
+) => BlockSpec<
+ BlockConf["type"],
+ BlockConf["propSchema"],
+ BlockConf["content"]
+>;
export function createReactBlockSpec<
const TName extends string,
const TProps extends PropSchema,
@@ -230,10 +262,33 @@ export function createReactBlockSpec<
implementation: {
...blockImplementation,
toExternalHTML(block, editor, context) {
- const BlockContent =
- blockImplementation.toExternalHTML || blockImplementation.render;
+ const isContainer = isContainerType(blockConfig);
+ const BlockContent = (blockImplementation.toExternalHTML ||
+ blockImplementation.render) as FC;
const output = renderToDOMSpec((refCB) => {
- return (
+ const content = (
+ {
+ refCB(element);
+ if (element && !isContainer) {
+ element.className = mergeCSSClasses(
+ "bn-inline-content",
+ element.className,
+ );
+ }
+ }}
+ context={context}
+ />
+ );
+ // A container block's render output *is* the block's root element.
+ // No wrapper of any kind, so the attributes core stamps
+ // afterwards land on the author's own element — the same element
+ // they land on in the live editor.
+ return isContainer ? (
+ content
+ ) : (
- {
- refCB(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- }
- }}
- context={context}
- />
+ {content}
);
}, editor);
@@ -268,78 +310,210 @@ export function createReactBlockSpec<
// constructed (itself guarded, via `getBlockFromNodeView`). Seeds
// the fallback below so there is always something to render.
const initialBlock = block;
+ // Container-ness is fixed per spec, so the node-view component
+ // can be chosen once — each variant is straight-line code using
+ // only the hooks and wrappers it needs.
+ const isContainer = isContainerType(blockConfig);
+ const BlockContent = blockImplementation.render as FC;
+ const blockContentDOMAttributes = this.blockContentDOMAttributes;
- return ReactNodeViewRenderer(
- (props: NodeViewProps) => {
- // Vanilla JS node views are recreated on each update. However,
- // using `ReactNodeViewRenderer` makes it so the node view is
- // only created once, so the block we get in the node view will
- // be outdated. Therefore, we have to get the block in the
- // `ReactNodeViewRenderer` instead. That position can be stale,
- // so resolving it is guarded (see `useNodeViewBlock`).
- const block = useNodeViewBlock(props, initialBlock);
+ // Set by the container node view's `NodeViewWrapper` below. The
+ // author's own root element is that wrapper's first element child;
+ // it's read lazily because React may not have committed yet when
+ // this node view is handed to core, and because the author's
+ // component is free to swap its root element on a re-render.
+ const wrapper: { current: HTMLElement | null } = { current: null };
+ const authorRootDOM = () =>
+ (wrapper.current?.firstElementChild as HTMLElement | null) ??
+ null;
- const ref = useReactNodeView().nodeViewContentRef;
+ // Vanilla JS node views are recreated on each update. However,
+ // using `ReactNodeViewRenderer` makes it so the node view is only
+ // created once, so the block we get in the node view will be
+ // outdated. Therefore, both variants have to (re-)resolve the
+ // block inside the `ReactNodeViewRenderer` component.
- if (!ref) {
- throw new Error("nodeViewContentRef is not set");
+ const ContainerNodeView = (props: NodeViewProps) => {
+ // Container blocks are bnBlock nodes (no `blockContainer`
+ // wrapper), so the id lives on the node's own attrs and the
+ // block resolves by id. Position-based resolution
+ // (`useNodeViewBlock`) would walk up to a *parent* bnBlock —
+ // the wrong block here — and ids are also immune to the stale
+ // positions it has to guard against.
+ const id = (props.node.attrs as Record).id;
+ if (!id) {
+ throw new Error(
+ `Container block "${blockConfig.type}" is missing an id attribute.`,
+ );
+ }
+ // The id lookup misses when the node was just removed from the
+ // document (e.g. a suggestion-mode deletion still rendering);
+ // fall back to converting the node the view was handed.
+ const block =
+ editor.getBlock(id) ??
+ nodeToBlock(props.node, props.view.state.doc);
+
+ const ref = useReactNodeView().nodeViewContentRef;
+ if (!ref) {
+ throw new Error("nodeViewContentRef is not set");
+ }
+
+ const selected = props.selected;
+
+ // Stamped imperatively rather than spread as JSX props: the root
+ // element belongs to the block's author, so there is nothing to
+ // spread onto. Runs after every render, since both the block's
+ // props and the author's root element can change.
+ useLayoutEffect(() => {
+ const root = authorRootDOM();
+ if (!root) {
+ return;
}
- const BlockContent = blockImplementation.render;
- return (
-
- {
- ref(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
+ applyContainerAttributes(
+ root,
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+
+ // ProseMirror marks the outermost element with
+ // `ProseMirror-selectednode`, and that element carries
+ // `display: contents` for containers — which suppresses any
+ // outline drawn on it. So the state is mirrored onto the
+ // author's root, which is the block's actual box.
+ if (selected) {
+ root.setAttribute("data-selected", "");
+ } else {
+ root.removeAttribute("data-selected");
+ }
+ });
+
+ return (
+
+ {
+ ref(element);
+ if (element) {
+ element.dataset.nodeViewContent = "";
+ // Mark the children host of a pure container so the
+ // round-trip parse rule can scope itself to it (see
+ // `getParseRules`); a content-bearing container's
+ // regions carry their own markers.
+ if (blockConfig.content === "none") {
+ element.setAttribute(
+ "data-children-of",
+ blockConfig.type,
);
- element.dataset.nodeViewContent = "";
}
- }}
- />
-
- );
- },
- {
- className: "bn-react-node-view-renderer",
- },
- )(this.props!) as ReturnType;
- } else {
- const BlockContent = blockImplementation.render;
- const output = renderToDOMSpec((refCB) => {
+ }
+ }}
+ />
+
+ );
+ };
+
+ const RegularNodeView = (props: NodeViewProps) => {
+ // The node view's position can be stale mid-render, so
+ // resolving it is guarded (see `useNodeViewBlock`).
+ const block = useNodeViewBlock(props, initialBlock);
+
+ const ref = useReactNodeView().nodeViewContentRef;
+ if (!ref) {
+ throw new Error("nodeViewContentRef is not set");
+ }
+
return (
{
- refCB(element);
+ contentRef={(element: HTMLElement | null) => {
+ ref(element);
if (element) {
element.className = mergeCSSClasses(
"bn-inline-content",
element.className,
);
+ element.dataset.nodeViewContent = "";
}
}}
/>
);
+ };
+
+ const nodeView = ReactNodeViewRenderer(
+ isContainer ? ContainerNodeView : RegularNodeView,
+ {
+ // The container class is separate because it *removes* the
+ // box the regular class relies on (see `Block.css`).
+ className: isContainer
+ ? "bn-react-node-view-renderer bn-container-node-view"
+ : "bn-react-node-view-renderer",
+ },
+ )(this.props!) as ReturnType;
+
+ if (isContainer) {
+ // TipTap appends its content host into whichever element the
+ // block passed `contentRef` to. `display: contents` keeps that
+ // host from contributing a box, so the block's editable region
+ // lays out exactly where the author put the ref — and, for a
+ // container that has its own content, the content and children
+ // regions sit there as siblings.
+ if (nodeView.contentDOM) {
+ nodeView.contentDOM.style.display = "contents";
+ }
+ // Where core stamps the container attributes: the author's own
+ // element, not React's outermost wrapper (`dom`).
+ Object.defineProperty(nodeView, "rootDOM", {
+ get: authorRootDOM,
+ });
+ }
+
+ return nodeView;
+ } else {
+ const isContainer = isContainerType(blockConfig);
+ const BlockContent = blockImplementation.render as FC;
+ const output = renderToDOMSpec((refCB) => {
+ const content = (
+ {
+ refCB(element);
+ if (element && !isContainer) {
+ element.className = mergeCSSClasses(
+ "bn-inline-content",
+ element.className,
+ );
+ }
+ }}
+ />
+ );
+ // See `toExternalHTML` above: a container block owns its outer
+ // DOM, so its render output is the block's root element.
+ return isContainer ? (
+ content
+ ) : (
+
+ {content}
+
+ );
}, editor);
return output;
}
diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts
index 02393a2fd0..74d2e84037 100644
--- a/packages/react/src/schema/useNodeViewBlock.ts
+++ b/packages/react/src/schema/useNodeViewBlock.ts
@@ -42,6 +42,17 @@ export function useNodeViewBlock(
const lastBlockRef = useRef(initialBlock);
const doc = props.view.state.doc;
+ // Position-based resolution finds the nearest bnBlock *parent* of the
+ // position — correct for blockContent node views, but wrong-by-construction
+ // for container blocks, whose node IS the bnBlock: it would return an
+ // ancestor block. Guarded loudly so a container node view can't silently
+ // render the wrong block.
+ if (props.node.type.isInGroup("bnBlock")) {
+ throw new Error(
+ `useNodeViewBlock cannot resolve container block "${props.node.type.name}": position-based resolution returns the nearest bnBlock parent, which is the wrong block when the node view's node is the block itself. Resolve container blocks by id instead, e.g. editor.getBlock(props.node.attrs.id).`,
+ );
+ }
+
try {
// Deliberate render-phase write: a monotonic "last good value" cache, so a
// repeated render (e.g. StrictMode's double invoke) recomputes the same
diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts
index 2a835469db..ee43b8792d 100644
--- a/packages/react/vite.config.ts
+++ b/packages/react/vite.config.ts
@@ -1,7 +1,7 @@
import react from "@vitejs/plugin-react";
import * as path from "path";
import { webpackStats } from "rollup-plugin-webpack-stats";
-import { defineConfig, type UserConfig } from "vite-plus";
+import { configDefaults, defineConfig, type UserConfig } from "vite-plus";
import pkg from "./package.json";
// import eslintPlugin from "vite-plugin-eslint";
@@ -24,6 +24,9 @@ export default defineConfig(
test: {
environment: "jsdom",
setupFiles: ["./vitestSetup.ts"],
+ // `.browser.test` files need a real browser; the tests package's
+ // browser suite runs them.
+ exclude: [...configDefaults.exclude, "**/*.browser.test.*"],
},
plugins: [react(), webpackStats()],
// used so that vitest resolves the core package from the sources instead of the built version
diff --git a/packages/react/vitestSetup.ts b/packages/react/vitestSetup.ts
index beafe25357..1c3619a84d 100644
--- a/packages/react/vitestSetup.ts
+++ b/packages/react/vitestSetup.ts
@@ -1,10 +1,21 @@
import { afterEach, beforeEach } from "vite-plus/test";
+// This setup file also runs for test files that opt into the plain `node`
+// environment (`@vitest-environment node`), where there is no `window` at all —
+// everything below is a DOM mock, so it is a no-op there.
+const hasWindow = typeof window !== "undefined";
+
beforeEach(() => {
+ if (!hasWindow) {
+ return;
+ }
(window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {};
});
afterEach(() => {
+ if (!hasWindow) {
+ return;
+ }
delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS;
});
@@ -32,28 +43,30 @@ class DragEventMock extends Event {
},
};
}
-Object.defineProperty(window, "matchMedia", {
- writable: true,
- value: (query: string) => ({
- matches: false,
- media: query,
- onchange: null,
- addListener: () => {
- //
- }, // Deprecated
- removeListener: () => {
- //
- }, // Deprecated
- addEventListener: () => {
- //
- },
- removeEventListener: () => {
- //
- },
- dispatchEvent: () => {
- //
- },
- }),
-});
+if (hasWindow) {
+ Object.defineProperty(window, "matchMedia", {
+ writable: true,
+ value: (query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: () => {
+ //
+ }, // Deprecated
+ removeListener: () => {
+ //
+ }, // Deprecated
+ addEventListener: () => {
+ //
+ },
+ removeEventListener: () => {
+ //
+ },
+ dispatchEvent: () => {
+ //
+ },
+ }),
+ });
+}
(global as any).DragEvent = DragEventMock;
diff --git a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts
index fec31293a5..34d60aa6bf 100644
--- a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts
+++ b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts
@@ -80,7 +80,7 @@ function createCollabEditor(text: string) {
function selectWholeFirstBlock(editor: BlockNoteEditor) {
const id = editor.document[0].id;
const info = getBlockInfo(getNodeById(id, editor.prosemirrorState.doc)!);
- if (!info.isBlockContainer) {
+ if (!info.isWrappedBlock) {
throw new Error("not a block container");
}
const from = info.blockContent.beforePos + 1;
diff --git a/packages/xl-ai/src/prosemirror/agent.test.ts b/packages/xl-ai/src/prosemirror/agent.test.ts
index 44d87c8108..d2a7d9178b 100644
--- a/packages/xl-ai/src/prosemirror/agent.test.ts
+++ b/packages/xl-ai/src/prosemirror/agent.test.ts
@@ -39,7 +39,7 @@ describe.skip("getStepsAsAgent", () => {
// Get the position of the content in the paragraph
const blockPos = getNodeById("1", doc)!;
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
@@ -72,7 +72,7 @@ describe.skip("getStepsAsAgent", () => {
// Get the position of the content in the paragraph
const blockPos = getNodeById("1", doc)!;
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
@@ -98,7 +98,7 @@ describe.skip("getStepsAsAgent", () => {
// Get the position of the content in the paragraph
const blockPos = getNodeById("1", doc)!;
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
@@ -128,7 +128,7 @@ describe.skip("getStepsAsAgent", () => {
// Get the position of the content in the paragraph
const blockPos = getNodeById("1", doc)!;
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
@@ -157,7 +157,7 @@ describe.skip("getStepsAsAgent", () => {
// Get the position of the content in the paragraph
const blockPos = getNodeById("1", doc)!;
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
diff --git a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts
index 21454b7b79..edd8a3b1bb 100644
--- a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts
+++ b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts
@@ -21,7 +21,7 @@ function getExampleEditorWithSuggestions() {
const blockPos = getNodeById("1", editor.prosemirrorState.doc)!;
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
@@ -56,7 +56,7 @@ it("should be able to apply changes to a clean doc (use invertMap)", async () =>
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
@@ -85,7 +85,7 @@ it("should be able to apply changes to a clean doc (use rebaseTr)", async () =>
const block = getBlockInfo(blockPos);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a container");
}
diff --git a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts
index 6262f505cb..8bbcb29315 100644
--- a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts
+++ b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts
@@ -47,7 +47,7 @@ export const combinedOperationsTestCases: DocumentOperationTestCase[] = [
const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!;
const block = getBlockInfo(posInfo);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a block container");
}
return {
diff --git a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts
index 2261863430..3d4d25f152 100644
--- a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts
+++ b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts
@@ -41,7 +41,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [
getTestSelection: (editor: BlockNoteEditor) => {
const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!;
const block = getBlockInfo(posInfo);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a block container");
}
return {
@@ -68,7 +68,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [
getTestSelection: (editor: BlockNoteEditor) => {
const posInfo = getNodeById("ref1", editor.prosemirrorState.doc)!;
const block = getBlockInfo(posInfo);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a block container");
}
// 'ello, world! Dow are yo'
@@ -737,7 +737,7 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [
getTestSelection(editor) {
const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!;
const block = getBlockInfo(posInfo);
- if (!block.isBlockContainer) {
+ if (!block.isWrappedBlock) {
throw new Error("Block is not a block container");
}
return {
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
index 16e45a304f..7141fefdfd 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
@@ -1,5 +1,6 @@
import {
BlockNoteSchema,
+ createBlockSpec,
defaultBlockSpecs,
createPageBreakBlockSpec,
PartialBlock,
@@ -415,6 +416,82 @@ describe("exporter", () => {
);
});
+describe("custom container blocks", () => {
+ const Box = createBlockSpec(
+ {
+ type: "box" as const,
+ propSchema: {},
+ content: "none",
+ children: { allow: "any" },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "box");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ },
+ )();
+
+ const boxSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ box: Box,
+ },
+ });
+
+ const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [
+ {
+ type: "box",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+ ] as any);
+
+ it("passes children to a custom container mapping", async () => {
+ const exporter = new DOCXExporter(
+ boxSchema,
+ {
+ ...docxDefaultSchemaMappings,
+ blockMapping: {
+ ...docxDefaultSchemaMappings.blockMapping,
+ box: (
+ _block: any,
+ _exporter: any,
+ _nesting: any,
+ _index: any,
+ children: any,
+ ) =>
+ new Paragraph({
+ children: [new TextRun(`BOX(${children?.length ?? 0})`)],
+ }),
+ },
+ } as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ const transformed = await exporter.transformBlocks(boxDocument as any);
+ expect(transformed).toHaveLength(1);
+ const xml = JSON.stringify(transformed[0]);
+ expect(xml).toContain("BOX(2)");
+ });
+
+ it("throws a clear error for an unmapped container block", async () => {
+ const exporter = new DOCXExporter(
+ boxSchema,
+ docxDefaultSchemaMappings as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow(
+ /container block type "box"/,
+ );
+ });
+});
+
function prettify(sourceXml: string) {
let ret = xmlFormat(sourceXml);
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts
index f987ad4a7d..5caf6b5606 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts
@@ -116,7 +116,7 @@ export class DOCXExporter<
for (const b of blocks) {
let children = await this.transformBlocks(b.children, nestingLevel + 1);
- if (!["columnList", "column"].includes(b.type)) {
+ if (!this.isContainerBlock(b.type)) {
children = children.map((c, _i) => {
// NOTE: nested tables not supported (we can't insert the new Tab before a table)
if (
@@ -139,7 +139,7 @@ export class DOCXExporter<
0 /*unused*/,
children,
); // TODO: any
- if (["columnList", "column"].includes(b.type)) {
+ if (this.isContainerBlock(b.type)) {
ret.push(self as Table);
} else if (Array.isArray(self)) {
ret.push(...self, ...children);
diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
index 5f4eecf3c5..df9fafdf61 100644
--- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
+++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
@@ -246,6 +246,24 @@ export class ReactEmailExporter<
i = nextIndex;
continue;
}
+ if (this.isContainerBlock(b.type)) {
+ // Container blocks (columnList, column, custom containers): the
+ // mapping owns the placement of the children, so they are passed in
+ // and not rendered as an indented sibling list.
+ const containerChildren = await this.transformBlocks(
+ b.children,
+ nestingLevel + 1,
+ );
+ const containerSelf = (await this.mapBlock(
+ b as any,
+ nestingLevel,
+ 0,
+ containerChildren as any,
+ )) as any;
+ ret.push({containerSelf});
+ i++;
+ continue;
+ }
// Non-list blocks
const children = await this.transformBlocks(b.children, nestingLevel + 1);
const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any;
diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts
index 2e49261ec6..ad80a46860 100644
--- a/packages/xl-multi-column/src/blocks/Columns/index.ts
+++ b/packages/xl-multi-column/src/blocks/Columns/index.ts
@@ -1,28 +1,82 @@
+import { createBlockSpec } from "@blocknote/core";
+
+import { ColumnResizeExtension } from "../../extensions/ColumnResize/ColumnResizeExtension.js";
import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js";
-import { Column } from "../../pm-nodes/Column.js";
-import { ColumnList } from "../../pm-nodes/ColumnList.js";
-import { createBlockSpecFromTiptapNode } from "@blocknote/core";
+const COLUMN_WIDTH_DEFAULT = 1;
-export const ColumnBlock = createBlockSpecFromTiptapNode(
+export const ColumnBlock = createBlockSpec(
{
- node: Column,
- type: "column",
+ type: "column" as const,
+ propSchema: {
+ width: {
+ default: COLUMN_WIDTH_DEFAULT,
+ },
+ },
content: "none",
+ children: { allow: "any" },
+ placement: "containerOnly",
},
{
- width: {
- default: 1,
+ meta: {
+ draggable: false,
+ },
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.className = "bn-block-column";
+ dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT);
+
+ return {
+ dom,
+ contentDOM: dom,
+ update: (newNode: {
+ type: { name: string };
+ attrs: { width?: number };
+ }) => {
+ if (newNode.type.name !== "column") {
+ return false;
+ }
+ dom.style.flexGrow = String(
+ newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT,
+ );
+ return true;
+ },
+ };
},
},
- [MultiColumnDropHandlerExtension()],
-);
+ [MultiColumnDropHandlerExtension(), ColumnResizeExtension()],
+)();
-export const ColumnListBlock = createBlockSpecFromTiptapNode(
+export const ColumnListBlock = createBlockSpec(
{
- node: ColumnList,
- type: "columnList",
+ type: "columnList" as const,
+ propSchema: {},
content: "none",
+ children: {
+ allow: ["column"],
+ min: 2,
+ whenEmptied: "unwrap",
+ // Everything crosses the column list's edge — e.g. a text selection
+ // dragged across columns.
+ boundary: "open",
+ },
+ },
+ {
+ meta: {
+ draggable: false,
+ },
+ render: () => {
+ const dom = document.createElement("div");
+ dom.className = "bn-block-column-list";
+ dom.style.display = "flex";
+
+ return {
+ dom,
+ contentDOM: dom,
+ update: (newNode: { type: { name: string } }) => {
+ return newNode.type.name === "columnList";
+ },
+ };
+ },
},
- {},
-);
+)();
diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
index 5713466a6d..1d2da18690 100644
--- a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
+++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
@@ -1,6 +1,5 @@
-import { BlockNoteEditor, getNodeById } from "@blocknote/core";
+import { BlockNoteEditor, createExtension, getNodeById } from "@blocknote/core";
import { SideMenuExtension } from "@blocknote/core/extensions";
-import { Extension } from "@tiptap/core";
import { Node } from "prosemirror-model";
import { Plugin, PluginKey, PluginView } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
@@ -41,13 +40,71 @@ type ColumnResizeState = {
columnList: ColumnData;
};
-type ColumnState =
+// Exported for tests only - not part of the package's public API.
+export type ColumnState =
| ColumnDefaultState
| ColumnHoverState
| ColumnHoverColumnListState
| ColumnResizeState;
-const columnResizePluginKey = new PluginKey("ColumnResizePlugin");
+// Exported for tests only - not part of the package's public API.
+export const columnResizePluginKey = new PluginKey(
+ "ColumnResizePlugin",
+);
+
+// Re-resolves stored column data against a (possibly changed) doc, since the
+// stored node and position may be stale. Returns undefined if the node no
+// longer exists in the doc.
+function refreshColumnData(
+ data: T,
+ doc: Node,
+): T | undefined {
+ const nodeAndPos = getNodeById(data.id, doc);
+ if (!nodeAndPos) {
+ return undefined;
+ }
+
+ return { ...data, ...nodeAndPos };
+}
+
+// Re-resolves all column data stored in the plugin state against a (possibly
+// changed) doc. Falls back to the default state if any of the referenced
+// nodes no longer exist - e.g. when a backspace removes a hovered column, or
+// unwraps the column list entirely - so decorations are never built from
+// positions that are invalid in the new doc.
+function refreshColumnState(state: ColumnState, doc: Node): ColumnState {
+ switch (state.type) {
+ case "default":
+ return state;
+ case "hover-column-list": {
+ const columnList = refreshColumnData(state.columnList, doc);
+
+ return columnList ? { ...state, columnList } : { type: "default" };
+ }
+ case "hover-column": {
+ const columnList = refreshColumnData(state.columnList, doc);
+ const leftColumn = refreshColumnData(state.leftColumn, doc);
+ const rightColumn = refreshColumnData(state.rightColumn, doc);
+
+ if (!columnList || !leftColumn || !rightColumn) {
+ return { type: "default" };
+ }
+
+ return { ...state, columnList, leftColumn, rightColumn };
+ }
+ case "resize": {
+ const columnList = refreshColumnData(state.columnList, doc);
+ const leftColumn = refreshColumnData(state.leftColumn, doc);
+ const rightColumn = refreshColumnData(state.rightColumn, doc);
+
+ if (!columnList || !leftColumn || !rightColumn) {
+ return { type: "default" };
+ }
+
+ return { ...state, columnList, leftColumn, rightColumn };
+ }
+ }
+}
class ColumnResizePluginView implements PluginView {
editor: BlockNoteEditor;
@@ -428,22 +485,26 @@ const createColumnResizePlugin = (editor: BlockNoteEditor) =>
state: {
init: () => ({ type: "default" }) as ColumnState,
apply: (tr, oldPluginState) => {
- const newPluginState = tr.getMeta(columnResizePluginKey) as
+ const metaPluginState = tr.getMeta(columnResizePluginKey) as
| ColumnState
| undefined;
- return newPluginState === undefined ? oldPluginState : newPluginState;
+ const pluginState =
+ metaPluginState === undefined ? oldPluginState : metaPluginState;
+
+ // The stored column nodes & positions were resolved against an older
+ // doc, so when the doc changes they must be re-resolved against the
+ // new one - a backspace may have removed a hovered column or
+ // unwrapped the column list entirely.
+ return tr.docChanged
+ ? refreshColumnState(pluginState, tr.doc)
+ : pluginState;
},
},
view: (view) => new ColumnResizePluginView(editor, view),
});
-export const createColumnResizeExtension = (
- editor: BlockNoteEditor,
-) =>
- Extension.create({
- name: "columnResize",
- addProseMirrorPlugins() {
- return [createColumnResizePlugin(editor)];
- },
- });
+export const ColumnResizeExtension = createExtension(({ editor }) => ({
+ key: "columnResize",
+ prosemirrorPlugins: [createColumnResizePlugin(editor)],
+}));
diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts
index 77d93b7f4a..8f05068da3 100644
--- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts
+++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts
@@ -1,4 +1,8 @@
-import { type DropCursorHooks, getNearestBlockPos } from "@blocknote/core";
+import {
+ type DropCursorHooks,
+ getNearestBlockPos,
+ isContainerNode,
+} from "@blocknote/core";
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
@@ -31,10 +35,16 @@ export function detectEdgePosition(
const blockPos = getNearestBlockPos(state.doc, eventPos.pos);
- // If we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it
- // Why? Because we want to insert a new column in the columnList, instead of a new columnList inside of the column
+ // If we're at a block inside a column of a columnList, we want to compare
+ // the mouse position to the column, not the block inside it.
+ // Why? Because we want to insert a new sibling column in the columnList
+ // instead of a new container inside the column.
let resolved = state.doc.resolve(blockPos.posBeforeNode);
- if (resolved.parent.type.name === "column") {
+ if (
+ isContainerNode(resolved.parent.type) &&
+ resolved.depth > 0 &&
+ state.doc.resolve(resolved.before()).parent.type.name === "columnList"
+ ) {
resolved = state.doc.resolve(resolved.before());
}
diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
index a762f78d96..4d8b020ddd 100644
--- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
+++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
@@ -4,6 +4,7 @@ import {
createExtension,
fragmentToBlocks,
getBlockInfo,
+ isContainerNode,
nodeToBlock,
} from "@blocknote/core";
import { Plugin } from "prosemirror-state";
@@ -42,7 +43,14 @@ export function createMultiColumnHandleDropPlugin(
}
const draggedBlockIds = new Set(draggedBlocks.map((block) => block.id));
- if (blockInfo.blockNoteType === "column") {
+ // Whether the edge target is a `columnList` (after `detectEdgePosition`
+ // hoisted blocks inside a column to the column itself, the target's
+ // parent is the columnList).
+ const $target = view.state.doc.resolve(blockInfo.bnBlock.beforePos);
+ const targetInHorizontalContainer =
+ $target.node().type.name === "columnList";
+
+ if (targetInHorizontalContainer) {
// The user is dropping the target column's entire contents on the
// column's own edge - the new column would just replace the
// emptied target in the same position, so do nothing. This also
@@ -57,16 +65,22 @@ export function createMultiColumnHandleDropPlugin(
return true;
}
- // Insert new column in existing columnList
- const parentBlock = view.state.doc
- .resolve(blockInfo.bnBlock.beforePos)
- .node();
+ // Insert a new sibling child in the existing horizontal container
+ // (e.g. a new column in the columnList).
+ const parentBlock = $target.node();
const columnList = nodeToBlock(
parentBlock,
view.state.doc,
);
+ // Whether the horizontal container's children are typed child
+ // containers (like `column`) that wrap the actual blocks, or plain
+ // blocks spliced in directly.
+ const targetIsChildContainer = isContainerNode(
+ blockInfo.bnBlock.node.type,
+ );
+
// Normalize column widths to average of 1
// In a `columnList`, we expect that the average width of each column
// is 1. However, there are cases in which this stops being true. For
@@ -74,24 +88,31 @@ export function createMultiColumnHandleDropPlugin(
// the average width to go down. This isn't really an issue until the
// user tries to add a new column, which will, in this case, be wider
// than expected. Therefore, we normalize the column widths to an
- // average of 1 here to avoid this issue.
- let sumColumnWidthPercent = 0;
- columnList.children.forEach((column) => {
- sumColumnWidthPercent += column.props.width as number;
- });
- const avgColumnWidthPercent =
- sumColumnWidthPercent / columnList.children.length;
-
- // If the average column width is not 1, normalize it. We're dealing
- // with floats so we need a small margin to account for precision
- // errors.
- if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) {
- const scalingFactor = 1 / avgColumnWidthPercent;
-
+ // average of 1 here to avoid this issue. (Only applies to child
+ // containers with a numeric `width` prop, i.e. columns.)
+ if (
+ columnList.children.every(
+ (column) => typeof column.props.width === "number",
+ )
+ ) {
+ let sumColumnWidthPercent = 0;
columnList.children.forEach((column) => {
- column.props.width =
- (column.props.width as number) * scalingFactor;
+ sumColumnWidthPercent += column.props.width as number;
});
+ const avgColumnWidthPercent =
+ sumColumnWidthPercent / columnList.children.length;
+
+ // If the average column width is not 1, normalize it. We're
+ // dealing with floats so we need a small margin to account for
+ // precision errors.
+ if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) {
+ const scalingFactor = 1 / avgColumnWidthPercent;
+
+ columnList.children.forEach((column) => {
+ column.props.width =
+ (column.props.width as number) * scalingFactor;
+ });
+ }
}
const targetColumnId = blockInfo.bnBlock.node.attrs.id;
@@ -103,20 +124,26 @@ export function createMultiColumnHandleDropPlugin(
const remainingColumns = columnList.children
// If any of the dragged blocks are in one of the columns, remove
// them.
- .map((column) => ({
- ...column,
- children: column.children.filter((block) => {
- if (!draggedBlockIds.has(block.id)) {
- return true;
- }
-
- blocksAlreadyInColumnList.add(block.id);
- return false;
- }),
- }))
+ .map((column) =>
+ targetIsChildContainer
+ ? {
+ ...column,
+ children: column.children.filter((block) => {
+ if (!draggedBlockIds.has(block.id)) {
+ return true;
+ }
+
+ blocksAlreadyInColumnList.add(block.id);
+ return false;
+ }),
+ }
+ : column,
+ )
// Remove empty columns (can happen when dragged blocks are
// removed).
- .filter((column) => column.children.length > 0);
+ .filter(
+ (column) => !targetIsChildContainer || column.children.length > 0,
+ );
// The insertion index is computed on the remaining columns, as
// removing an emptied column before the drop target shifts the
@@ -134,15 +161,22 @@ export function createMultiColumnHandleDropPlugin(
const insertionIndex =
edgePos.position === "left" ? targetIndex : targetIndex + 1;
- // Insert the dragged blocks as a new column in the correct
- // position.
- const newChildren = remainingColumns.toSpliced(insertionIndex, 0, {
- type: "column",
- children: draggedBlocks,
- props: {},
- content: undefined,
- id: UniqueID.options.generateID(),
- });
+ // Insert the dragged blocks in the correct position, wrapped in a
+ // new child container (e.g. a new `column`) when the container's
+ // children are typed containers.
+ const newChildren = remainingColumns.toSpliced(
+ insertionIndex,
+ 0,
+ targetIsChildContainer
+ ? {
+ type: blockInfo.blockNoteType,
+ children: draggedBlocks,
+ props: {},
+ content: undefined,
+ id: UniqueID.options.generateID(),
+ }
+ : draggedBlocks[0],
+ );
const blocksToRemove = draggedBlocks.filter(
(block) =>
diff --git a/packages/xl-multi-column/src/pm-nodes/Column.ts b/packages/xl-multi-column/src/pm-nodes/Column.ts
deleted file mode 100644
index dccf60c74b..0000000000
--- a/packages/xl-multi-column/src/pm-nodes/Column.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { suggestionMarks } from "@blocknote/core";
-import { Node } from "@tiptap/core";
-
-import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js";
-
-export const Column = Node.create({
- name: "column",
- group: "bnBlock childContainer",
- // A block always contains content, and optionally a blockGroup which contains nested blocks
- content: "blockContainer+",
- priority: 40,
- defining: true,
- marks() {
- return suggestionMarks(this.editor);
- },
- addAttributes() {
- return {
- width: {
- // Why does each column have a default width of 1, i.e. 100%? Because
- // when creating a new column, we want to make sure that existing
- // column widths are preserved, while the new one also has a sensible
- // width. If we'd set it so all column widths must add up to 100%
- // instead, then each time a new column is created, we'd have to assign
- // it a width depending on the total number of columns and also adjust
- // the widths of the other columns. The same can be said for using px
- // instead of percent widths and making them add to the editor width. So
- // using this method is both simpler and computationally cheaper. This
- // is possible because we can set the `flex-grow` property to the width
- // value, which handles all the resizing for us, instead of manually
- // having to set the `width` property of each column.
- default: 1,
- parseHTML: (element) => {
- const attr = element.getAttribute("data-width");
- if (attr === null) {
- return null;
- }
-
- const parsed = parseFloat(attr);
- if (isFinite(parsed)) {
- return parsed;
- }
-
- return null;
- },
- renderHTML: (attributes) => {
- return {
- "data-width": (attributes.width as number).toString(),
- style: `flex-grow: ${attributes.width as number};`,
- };
- },
- },
- };
- },
-
- parseHTML() {
- return [
- {
- tag: "div",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- if (element.getAttribute("data-node-type") === this.name) {
- return {};
- }
-
- return false;
- },
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- const column = document.createElement("div");
- column.className = "bn-block-column";
- column.setAttribute("data-node-type", this.name);
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- column.setAttribute(attribute, value as any); // TODO as any
- }
-
- return {
- dom: column,
- contentDOM: column,
- };
- },
-
- addExtensions() {
- return [createColumnResizeExtension(this.options.editor)];
- },
-});
diff --git a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts
deleted file mode 100644
index eeb06f4d4e..0000000000
--- a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { suggestionMarks } from "@blocknote/core";
-import { Node } from "@tiptap/core";
-
-export const ColumnList = Node.create({
- name: "columnList",
- group: "childContainer bnBlock blockGroupChild",
- // A block always contains content, and optionally a blockGroup which contains nested blocks
- content: "column column+", // min two columns
- priority: 40, // should be below blockContainer
- defining: true,
- marks() {
- return suggestionMarks(this.editor);
- },
- parseHTML() {
- return [
- {
- tag: "div",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- if (element.getAttribute("data-node-type") === this.name) {
- return {};
- }
-
- return false;
- },
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- const columnList = document.createElement("div");
- columnList.className = "bn-block-column-list";
- columnList.setAttribute("data-node-type", this.name);
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- columnList.setAttribute(attribute, value as any); // TODO as any
- }
- columnList.style.display = "flex";
-
- return {
- dom: columnList,
- contentDOM: columnList,
- };
- },
-});
diff --git a/packages/xl-multi-column/src/test/commands/enter.test.ts b/packages/xl-multi-column/src/test/commands/enter.test.ts
new file mode 100644
index 0000000000..f22a6d3451
--- /dev/null
+++ b/packages/xl-multi-column/src/test/commands/enter.test.ts
@@ -0,0 +1,216 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { BlockNoteEditor } from "@blocknote/core";
+
+import { setupTestEnv } from "../setupTestEnv.js";
+
+const getEditor = setupTestEnv();
+
+function pressEnter(editor: BlockNoteEditor) {
+ const view = editor._tiptapEditor.view;
+ const event = new KeyboardEvent("keydown", {
+ key: "Enter",
+ code: "Enter",
+ keyCode: 13,
+ bubbles: true,
+ });
+ view.someProp("handleKeyDown", (f: any) => f(view, event));
+}
+
+// Columns have no special Enter config: like any non-sealed container, an empty
+// *last* block escapes on Enter ("double-Enter escapes"). A column list only
+// holds columns, so the escaping block can't stop at the column-list level —
+// it lands below the whole list.
+describe("Enter exit from columns", () => {
+ it("moves an empty last block of the last column below the column list", () => {
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "cl-0",
+ children: [
+ {
+ type: "column",
+ id: "col-1",
+ children: [{ id: "col1-para", type: "paragraph", content: "col1" }],
+ },
+ {
+ type: "column",
+ id: "col-2",
+ children: [
+ { id: "col2-para", type: "paragraph", content: "col2" },
+ { id: "col2-empty", type: "paragraph", content: "" },
+ ],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "trailing" },
+ ]);
+
+ editor.setTextCursorPosition("col2-empty", "end");
+ pressEnter(editor);
+
+ expect(editor.getBlock("col-2")!.children.map((child) => child.id)).toEqual(
+ ["col2-para"],
+ );
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "cl-0",
+ "col2-empty",
+ "trailing",
+ ]);
+ // The caret came along, so typing continues below the column list.
+ expect(editor.getTextCursorPosition().block.id).toBe("col2-empty");
+ });
+
+ it("moves an empty last block of a non-last column below the column list too", () => {
+ // Blocks can't sit between columns, so the escape from any column lands
+ // below the whole list.
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "cl-0",
+ children: [
+ {
+ type: "column",
+ id: "col-1",
+ children: [
+ { id: "col1-para", type: "paragraph", content: "col1" },
+ { id: "col1-empty", type: "paragraph", content: "" },
+ ],
+ },
+ {
+ type: "column",
+ id: "col-2",
+ children: [{ id: "col2-para", type: "paragraph", content: "col2" }],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("col1-empty", "end");
+ pressEnter(editor);
+
+ expect(editor.getBlock("col-1")!.children.map((child) => child.id)).toEqual(
+ ["col1-para"],
+ );
+ expect(editor.getBlock("col-2")!.children.map((child) => child.id)).toEqual(
+ ["col2-para"],
+ );
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "cl-0",
+ "col1-empty",
+ ]);
+ });
+
+ it("keeps an empty block mid-column inside on Enter", () => {
+ // The escape gesture is strictly "at the end of the column": an empty
+ // block with a sibling after it never ejects.
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "cl-0",
+ children: [
+ {
+ type: "column",
+ id: "col-1",
+ children: [{ id: "col1-para", type: "paragraph", content: "col1" }],
+ },
+ {
+ type: "column",
+ id: "col-2",
+ children: [
+ { id: "col2-empty", type: "paragraph", content: "" },
+ { id: "col2-para", type: "paragraph", content: "col2" },
+ ],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("col2-empty", "end");
+ pressEnter(editor);
+
+ expect(editor.document.map((block) => block.id)).toEqual(["cl-0"]);
+ expect(editor.getBlock("col-2")!.children).toHaveLength(3);
+ expect(
+ editor.getBlock("col-2")!.children.map((child) => child.content),
+ ).toEqual([[], [], [{ type: "text", text: "col2", styles: {} }]]);
+ });
+
+ it("escaping a column's only block dissolves it and unwraps the list", () => {
+ // The exit empties the column, so the column list's `whenEmptied: "unwrap"`
+ // repair kicks in: the emptied column disappears, and the one-column
+ // list unwraps to the surviving column's blocks.
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "cl-0",
+ children: [
+ {
+ type: "column",
+ id: "col-1",
+ children: [{ id: "col1-para", type: "paragraph", content: "col1" }],
+ },
+ {
+ type: "column",
+ id: "col-2",
+ children: [{ id: "col2-empty", type: "paragraph", content: "" }],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("col2-empty", "end");
+ pressEnter(editor);
+
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "col1-para",
+ "col2-empty",
+ ]);
+ });
+
+ it("typing then double-Enter escapes in two presses", () => {
+ // The end-to-end gesture: the first Enter creates the empty trailing
+ // block inside the column, the second moves it out.
+ const editor = getEditor();
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "columnList",
+ id: "cl-0",
+ children: [
+ {
+ type: "column",
+ id: "col-1",
+ children: [{ id: "col1-para", type: "paragraph", content: "col1" }],
+ },
+ {
+ type: "column",
+ id: "col-2",
+ children: [{ id: "col2-para", type: "paragraph", content: "col2" }],
+ },
+ ],
+ },
+ ]);
+
+ editor.setTextCursorPosition("col2-para", "end");
+ pressEnter(editor);
+
+ // First press: a new empty block inside the column.
+ expect(editor.document.map((block) => block.id)).toEqual(["cl-0"]);
+ const children = editor.getBlock("col-2")!.children;
+ expect(children).toHaveLength(2);
+ const created = children[1].id;
+ expect(editor.getTextCursorPosition().block.id).toBe(created);
+
+ pressEnter(editor);
+
+ // Second press: that block moves below the column list.
+ expect(editor.getBlock("col-2")!.children.map((child) => child.id)).toEqual(
+ ["col2-para"],
+ );
+ expect(editor.document.map((block) => block.id)).toEqual(["cl-0", created]);
+ });
+});
diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
similarity index 95%
rename from packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap
rename to packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
index 87b5f2e588..a5d8ddf91f 100644
--- a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap
+++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`Test fixColumnList > First of two columns empty 1`] = `
+exports[`Test fixContainer > First of two columns empty 1`] = `
{
"content": [
{
@@ -35,7 +35,7 @@ exports[`Test fixColumnList > First of two columns empty 1`] = `
}
`;
-exports[`Test fixColumnList > Last of two columns empty 1`] = `
+exports[`Test fixContainer > Last of two columns empty 1`] = `
{
"content": [
{
@@ -70,7 +70,7 @@ exports[`Test fixColumnList > Last of two columns empty 1`] = `
}
`;
-exports[`Test fixColumnList > Two empty columns 1`] = `
+exports[`Test fixContainer > Two empty columns 1`] = `
{
"content": [
{
@@ -99,7 +99,7 @@ exports[`Test fixColumnList > Two empty columns 1`] = `
}
`;
-exports[`Test removeEmptyColumns > First of two columns empty 1`] = `
+exports[`Test removeEmptyChildren > First of two columns empty 1`] = `
{
"content": [
{
@@ -176,7 +176,7 @@ exports[`Test removeEmptyColumns > First of two columns empty 1`] = `
}
`;
-exports[`Test removeEmptyColumns > Last of two columns empty 1`] = `
+exports[`Test removeEmptyChildren > Last of two columns empty 1`] = `
{
"content": [
{
@@ -253,7 +253,7 @@ exports[`Test removeEmptyColumns > Last of two columns empty 1`] = `
}
`;
-exports[`Test removeEmptyColumns > Start and end columns empty 1`] = `
+exports[`Test removeEmptyChildren > Start and end columns empty 1`] = `
{
"content": [
{
@@ -336,7 +336,7 @@ exports[`Test removeEmptyColumns > Start and end columns empty 1`] = `
}
`;
-exports[`Test removeEmptyColumns > Two empty columns 1`] = `
+exports[`Test removeEmptyChildren > Two empty columns 1`] = `
{
"content": [
{
diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
similarity index 91%
rename from packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts
rename to packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
index b5bd190c6d..d41cc00f72 100644
--- a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts
+++ b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
@@ -2,14 +2,14 @@ import { describe, expect, it } from "vite-plus/test";
import { setupTestEnv } from "../../setupTestEnv.js";
import {
- fixColumnList,
- isEmptyColumn,
- removeEmptyColumns,
-} from "@blocknote/core";
+ fixContainer,
+ isEmptyContainerChild,
+ removeEmptyChildren,
+} from "@blocknote/core/internal";
const getEditor = setupTestEnv();
-describe("Test isEmptyColumn", () => {
+describe("Test isEmptyContainerChild", () => {
it("Empty blocks", () => {
const schema = getEditor()._tiptapEditor.schema;
@@ -19,7 +19,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeTruthy();
+ expect(isEmptyContainerChild(column)).toBeTruthy();
});
it("Multiple blocks", () => {
@@ -34,7 +34,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
it("Block with children", () => {
@@ -51,7 +51,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
it("Block with text", () => {
@@ -65,7 +65,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
it("Non-text block", () => {
@@ -77,11 +77,11 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
});
-describe("Test removeEmptyColumns", () => {
+describe("Test removeEmptyChildren", () => {
it("Start and end columns empty", () => {
const editor = getEditor();
const schema = editor._tiptapEditor.schema;
@@ -116,7 +116,7 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -143,7 +143,7 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -170,7 +170,7 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -195,13 +195,13 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
});
-describe("Test fixColumnList", () => {
+describe("Test fixContainer", () => {
it("First of two columns empty", () => {
const editor = getEditor();
const schema = editor._tiptapEditor.schema;
@@ -224,7 +224,7 @@ describe("Test fixColumnList", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
+ fixContainer(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -251,7 +251,7 @@ describe("Test fixColumnList", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
+ fixContainer(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -276,7 +276,7 @@ describe("Test fixColumnList", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
+ fixContainer(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
index 2237513b6b..72b0f2d7ab 100644
--- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
@@ -1 +1 @@
-
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
+
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
index 5876b3bd03..0d6612056e 100644
--- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
@@ -1 +1 @@
-
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
+
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
diff --git a/packages/xl-multi-column/src/test/extensions/columnResize.test.ts b/packages/xl-multi-column/src/test/extensions/columnResize.test.ts
new file mode 100644
index 0000000000..964a61de1a
--- /dev/null
+++ b/packages/xl-multi-column/src/test/extensions/columnResize.test.ts
@@ -0,0 +1,101 @@
+import { getNodeById } from "@blocknote/core";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ ColumnState,
+ columnResizePluginKey,
+} from "../../extensions/ColumnResize/ColumnResizeExtension.js";
+import { setupTestEnv } from "../setupTestEnv.js";
+
+const getEditor = setupTestEnv();
+
+// Puts the column resize plugin into the state it would be in when the user
+// hovers the boundary between the two columns of "column-list-0" in the test
+// document, as the plugin's mouse handlers would.
+function hoverColumnBoundary() {
+ const editor = getEditor();
+ const view = editor._tiptapEditor.view;
+
+ const columnList = getNodeById("column-list-0", view.state.doc);
+ const leftColumn = getNodeById("column-0", view.state.doc);
+ const rightColumn = getNodeById("column-1", view.state.doc);
+
+ if (!columnList || !leftColumn || !rightColumn) {
+ throw new Error("Test document is missing expected columns");
+ }
+
+ const hoverState: ColumnState = {
+ type: "hover-column",
+ columnList: {
+ element: document.createElement("div"),
+ id: "column-list-0",
+ ...columnList,
+ },
+ leftColumn: {
+ element: document.createElement("div"),
+ id: "column-0",
+ ...leftColumn,
+ },
+ rightColumn: {
+ element: document.createElement("div"),
+ id: "column-1",
+ ...rightColumn,
+ },
+ };
+
+ view.dispatch(view.state.tr.setMeta(columnResizePluginKey, hoverState));
+}
+
+describe("Column resize plugin state after doc changes", () => {
+ it("falls back to default when a hovered column's removal unwraps the column list", () => {
+ const editor = getEditor();
+
+ hoverColumnBoundary();
+
+ // Removing one of the two columns brings the column list below its
+ // minimum of 2 children, so it gets unwrapped entirely. This used to
+ // throw a RangeError from the plugin's decorations, as they were built
+ // from positions resolved against the old, larger doc.
+ editor.removeBlocks(["column-1"]);
+
+ expect(
+ columnResizePluginKey.getState(editor._tiptapEditor.view.state),
+ ).toEqual({ type: "default" });
+ // The surviving column's two paragraphs are unwrapped to the top level.
+ expect(editor.document.map((block) => block.type)).toEqual([
+ "paragraph",
+ "paragraph",
+ "paragraph",
+ "paragraph",
+ "paragraph",
+ ]);
+ });
+
+ it("falls back to default when the whole doc is replaced", () => {
+ const editor = getEditor();
+
+ hoverColumnBoundary();
+
+ // Mimics select-all + backspace clearing the document while columns are
+ // hovered.
+ editor.replaceBlocks(editor.document, [{ type: "paragraph" }]);
+
+ expect(
+ columnResizePluginKey.getState(editor._tiptapEditor.view.state),
+ ).toEqual({ type: "default" });
+ expect(editor.document).toHaveLength(1);
+ });
+
+ it("keeps the hover state when an unrelated block changes", () => {
+ const editor = getEditor();
+
+ hoverColumnBoundary();
+
+ editor.updateBlock("paragraph-1", { content: "Updated Paragraph 1" });
+
+ const pluginState = columnResizePluginKey.getState(
+ editor._tiptapEditor.view.state,
+ );
+ expect(pluginState?.type).toBe("hover-column");
+ });
+});
diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx
index 7c17cad0ad..ee7bde9e60 100644
--- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx
+++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx
@@ -142,7 +142,7 @@ export class ODTExporter<
numberedListIndex = 0;
}
- if (["columnList", "column"].includes(block.type)) {
+ if (this.isContainerBlock(block.type)) {
const children = await this.transformBlocks(block.children, 0);
const content = await this.mapBlock(
block as any,
diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx
index f91ec93a86..1063ea5daa 100644
--- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx
+++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx
@@ -176,7 +176,7 @@ export class PDFExporter<
children,
); // TODO: any
- if (["pageBreak", "columnList", "column"].includes(b.type)) {
+ if (b.type === "pageBreak" || this.isContainerBlock(b.type)) {
ret.push(self);
continue;
}
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 155a460786..0aa179f48f 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1457,6 +1457,33 @@ export const examples = {
readme:
"In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)",
},
+ {
+ projectSlug: "container-block",
+ fullSlug: "custom-schema/container-block",
+ pathFromRoot: "examples/06-custom-schema/09-container-block",
+ config: {
+ playground: true,
+ docs: true,
+ author: "nickthesick",
+ tags: [
+ "Intermediate",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu",
+ ],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ } as any,
+ },
+ title: "Container Block",
+ group: {
+ pathFromRoot: "examples/06-custom-schema",
+ slug: "custom-schema",
+ },
+ readme:
+ 'In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.\n\nThe block declares the `children` config on `BlockConfig`. `children: { min: 1, default: [{ type: "paragraph" }] }` makes it a container: its child blocks mount into the element the render passes `contentRef` to, and live on `block.children` at runtime.\n\nThe callout\'s **title** demonstrates the complementary "string prop slot" pattern: a field that doesn\'t need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. A field that _is_ prose belongs in the block\'s own `content: "inline"` instead.\n\nWe also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the callout\'s body and add a code block, heading, or list — anything goes.\n- Type a title into the title field — it\'s stored on `block.props.title`, not as document content.\n- Watch the JSON panel on the right update as you edit; the callout\'s children appear in `block.children`.\n- Insert a new callout via the Slash Menu (search "callout").\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ },
{
projectSlug: "math-block",
fullSlug: "custom-schema/math-block",
@@ -1536,6 +1563,33 @@ export const examples = {
readme:
'In this example, we build custom blocks on the source-with-preview pattern — the same building blocks behind BlockNote\'s math and diagram blocks. A custom "CSV table" block renders its comma-separated source as a table, and a custom "color" inline content renders a CSS color as a swatch. Both show the rendered preview in place, while the source is edited in a popup.\n\n**Try it out:** Click the table or a color chip to edit its source!\n\n**Relevant Docs:**\n\n- [Source with Preview Blocks](/docs/features/custom-schemas/source-with-preview)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Custom Inline Content](/docs/features/custom-schemas/custom-inline-content)',
},
+ {
+ projectSlug: "container-table",
+ fullSlug: "custom-schema/container-table",
+ pathFromRoot: "examples/06-custom-schema/12-container-table",
+ config: {
+ playground: true,
+ docs: true,
+ author: "nickthesick",
+ tags: [
+ "Advanced",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu",
+ ],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ } as any,
+ },
+ title: "Table Built From Container Blocks",
+ group: {
+ pathFromRoot: "examples/06-custom-schema",
+ slug: "custom-schema",
+ },
+ readme:
+ 'In this example, we rebuild BlockNote\'s table as four **container blocks** — `table`, `tableRow`, `tableCell`, and `tableHeader` — with no `prosemirror-tables` and no special `"table"` content type. A table is a container of rows, a row is a container of cells, and a cell is a container of arbitrary blocks, so cells can hold lists, headings, images, or even nested tables, and the JSON shape is the same `children` array every other block uses.\n\nCells declare `boundary: "sealed"`, which is all it takes to make them behave like compartments: Backspace, Delete, and arrow keys never implicitly move content or the caret across a cell\'s edge, and Enter adds another block *inside* the cell. Header cells are a distinct block type rather than table metadata, so toggling the header row is just `updateBlock` with a new type. All structural operations — add/remove row or column, Tab-to-next-cell, Tab-past-the-end to grow the table — are plain calls to the public block manipulation API (`insertBlocks`, `removeBlocks`, `updateBlock`, `getParentBlock`, `setTextCursorPosition`).\n\n**Try it out:**\n\n- Press Tab / Shift-Tab to move between cells — Tab in the last cell adds a new row.\n- Press Enter inside a cell to stack more blocks in it, or "/" to add a list or heading.\n- Hover the table to reveal the row/column controls, and watch the JSON panel update.\n\n**Relevant Docs:**\n\n- [Container Blocks](/docs/features/custom-schemas/container-blocks)\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Manipulating Blocks](/docs/reference/editor/manipulating-content)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ },
{
projectSlug: "draggable-inline-content",
fullSlug: "custom-schema/draggable-inline-content",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6decb347ed..d18218456e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3459,6 +3459,52 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/06-custom-schema/09-container-block:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+
examples/06-custom-schema/09-math-block:
dependencies:
'@blocknote/ariakit':
@@ -3609,6 +3655,52 @@ importers:
specifier: ^8.0.0
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+ examples/06-custom-schema/12-container-table:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite:
+ specifier: ^8.0.0
+ version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0)
+
examples/06-custom-schema/draggable-inline-content:
dependencies:
'@blocknote/ariakit':
diff --git a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx
index 77cd1cd21d..063ebb68cd 100644
--- a/tests/src/end-to-end/multicolumn/multicolumn.test.tsx
+++ b/tests/src/end-to-end/multicolumn/multicolumn.test.tsx
@@ -11,6 +11,7 @@ import {
import {
compareDocToSnapshot,
focusOnEditor,
+ sleep,
waitForSelector,
} from "../../utils/editor.js";
import {
@@ -134,3 +135,59 @@ describe("Check Multi-Column Behaviour", () => {
await compareDocToSnapshot("deleteEndOfColumnList");
});
});
+
+// Which block the side menu attaches to is resolved from live layout
+// (`elementsFromPoint` / `posAtCoords`). The pieces below that are covered
+// closer to the code: the arithmetic in
+// `packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts`
+// (node, plain rects) and the DOM/layout adapters in the `.browser.test.ts`
+// beside it. This is the whole path, through a real column list.
+//
+// Each column carries 25px of its own left padding and the side menu renders
+// into it, so the coordinates the lookup is handed belong to one column while
+// horizontally overlapping the column before it. `SideMenu.ts` compensates by
+// re-probing 50px further right once `isHorizontalContainer` recognises the
+// column list; drop that (or let the detection fail — a `display: contents`
+// element reports a zero rect, which is exactly what would silently defeat it)
+// and the menu attaches to a block in the *previous* column instead. Only the
+// padding is affected: hovering a block's own text resolves correctly either
+// way, which is why this can't be tested by hovering a block.
+describe("Check side menu placement inside a column list", () => {
+ /** Vertical centre of a rect — what the menu lines itself up with. */
+ const centerY = (rect: DOMRect) => rect.y + rect.height / 2;
+
+ test("Check drag handle resolves the block on the hovered row of a column", async () => {
+ await focusOnEditor();
+
+ // The last column is the only one holding several blocks, so it's the only
+ // place a wrongly resolved block is distinguishable by its row.
+ const target = page.getByText("Block 2").element();
+ const columnRect = getRect(target.closest(".bn-block-column")!);
+
+ await mouseSequence([
+ {
+ type: "move",
+ x: columnRect.x + 5,
+ y: centerY(getRect(target)),
+ steps: 5,
+ },
+ ]);
+ await waitForSelector(DRAG_HANDLE_SELECTOR);
+ await sleep(150);
+ const handleRect = getRect(DRAG_HANDLE_SELECTOR);
+
+ expect(handleRect.x).toBeLessThan(getRect(target).x);
+
+ // The handle lines up with the hovered block's row rather than any other
+ // block's — a stronger claim than a pixel tolerance would be, since every
+ // candidate is only a line-height away, and it's what distinguishes this
+ // column's blocks from the neighbouring column's.
+ const distance = (rect: DOMRect) =>
+ Math.abs(centerY(handleRect) - centerY(rect));
+ for (const other of ["Block 1", "Block 3", "So is this heading!"]) {
+ expect(distance(getRect(target))).toBeLessThan(
+ distance(getRect(page.getByText(other).element())),
+ );
+ }
+ });
+});
diff --git a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx
index f3e12560d9..2c768cff6c 100644
--- a/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx
+++ b/tests/src/end-to-end/y-prosemirror/fixtures/suggestionFixture.tsx
@@ -348,8 +348,8 @@ function opToXml(op: DeltaInsertOp): string {
// concurrent merge of two marks), which would otherwise make these
// snapshots flaky. Sorted ascending => the alphabetically-first mark
// ends up innermost (e.g. `world`).
- for (const [name, value] of Object.entries(op.format ?? {}).sort(([a], [b]) =>
- a < b ? -1 : a > b ? 1 : 0,
+ for (const [name, value] of Object.entries(op.format ?? {}).sort(
+ ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
)) {
if (value !== null && typeof value === "object") {
// Object value: trivial empty `{}` renders as a bare tag, richer
diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx
index 71101c7fa7..286fed335b 100644
--- a/tests/src/unit/react/useNodeViewBlock.test.tsx
+++ b/tests/src/unit/react/useNodeViewBlock.test.tsx
@@ -27,8 +27,15 @@ const createReproBlock = createReactBlockSpec(
{ render: (props) => },
);
+// A container block, whose node view's node IS the bnBlock — resolved by id
+// instead of by position.
+const createBoxBlock = createReactBlockSpec(
+ { type: "box", propSchema: {}, content: "none", children: { allow: "any" } },
+ { render: (props) => },
+);
+
const schema = BlockNoteSchema.create().extend({
- blockSpecs: { repro: createReproBlock() },
+ blockSpecs: { repro: createReproBlock(), box: createBoxBlock() },
});
let editor: BlockNoteEditor;
@@ -43,6 +50,7 @@ beforeEach(() => {
{ type: "paragraph", content: "first" },
{ type: "repro", content: "target block" },
{ type: "paragraph", content: "last" },
+ { type: "box", children: [{ type: "paragraph", content: "inside" }] },
],
}) as BlockNoteEditor;
@@ -78,11 +86,14 @@ function renderHook(
return resolved;
}
-// Only the two fields `useNodeViewBlock` reads. Built structurally so `tests`
-// doesn't need a dependency on `@tiptap/react` just for its prop types.
-function makeProps(getPos: () => number | undefined) {
+// Only the fields `useNodeViewBlock` reads. Built structurally so `tests`
+// doesn't need a dependency on `@tiptap/react` just for its prop types. The
+// `node` defaults to a regular (non-container) block's node shape; container
+// tests pass the real PM node instead.
+function makeProps(getPos: () => number | undefined, node?: unknown) {
return {
getPos,
+ node: node ?? { type: { isInGroup: () => false } },
view: { state: { doc: editor.prosemirrorState.doc } },
} as unknown as Parameters[0];
}
@@ -170,4 +181,34 @@ describe("useNodeViewBlock", () => {
expect(resolved.id).toBe(target.id);
expect(resolved).not.toBe(seed);
});
+
+ it("rejects container blocks loudly instead of resolving the wrong block", () => {
+ const box = editor.document[3];
+ const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!;
+ const props = makeProps(() => undefined, node);
+
+ let captured: unknown;
+
+ function Probe() {
+ useNodeViewBlock(props, box);
+ return null;
+ }
+
+ root = createRoot(div, {
+ // React 19 reports uncaught render errors here instead of rethrowing
+ // out of `flushSync`.
+ onUncaughtError: (error: unknown) => {
+ captured = error;
+ },
+ });
+ try {
+ flushSync(() => {
+ root!.render();
+ });
+ } catch (error) {
+ captured = error;
+ }
+
+ expect(String(captured)).toMatch(/cannot resolve container block "box"/);
+ });
});