-
-
Notifications
You must be signed in to change notification settings - Fork 767
feat(core): container blocks — core API, multi-column migration, docs & examples #3014
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
ff19497
feat(core): add container block API for nested blocks
nperez0111 24ec0c8
fix(core): handle content-bearing containers in navigation and slicing
nperez0111 f7f3623
fix(core): address container-block review findings
nperez0111 9bd0dc9
refactor(core): dedupe container helpers for net-smaller diff
nperez0111 31ab641
fix(core): resolve an element root for fragment-rendered containers
nperez0111 81d5b84
feat(xl-multi-column): migrate columns onto the container block API
nperez0111 f8602bc
fix(xl-multi-column): handle dragged direct children in edge drops
nperez0111 72903e9
docs: add container block docs and examples
nperez0111 7ec114f
refactor(core): remove content-container support (content + children)
nperez0111 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
202 changes: 202 additions & 0 deletions
202
docs/content/docs/features/custom-schemas/container-blocks.mdx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| --- | ||
| 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, or a multi-column layout. | ||
|
|
||
| ## 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) => <div className="callout" ref={props.contentRef} />, | ||
| }, | ||
| ); | ||
| ``` | ||
|
|
||
| `children: { allow: "any" }` accepts any block, requires at least one, and never throws. When a container is created without children, BlockNote fills it with whatever its schema requires. | ||
|
|
||
| A container block always declares `content: "none"`: its body is its children. Combining `children` with any other `content` is a schema-creation error. For an editable title or caption, use a string prop rendered as an `<input>`, as the demo below does. | ||
|
|
||
| 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 | | ||
|
|
||
| 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 `<div className="callout">` and `data-flavor="info"` lands on it, in the live editor and in serialized HTML alike. | ||
|
|
||
| <Callout type="info"> | ||
| _The framework wrappers React puts above your element carry `display: | ||
| contents`, so they contribute no box and 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._ | ||
| </Callout> | ||
|
|
||
| The demo below puts this together: a callout block that can contain any other blocks. Its title is a regular `<input>` backed by a string prop rather than document content — the pattern to reach for whenever a container needs an editable heading, caption, or label of its own: | ||
|
|
||
| <Example name="custom-schema/container-block" /> | ||
|
|
||
| ## `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` | none | 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`. It also 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. | | ||
|
|
||
| <Callout type="warn"> | ||
| _`whenEmptied` never destroys typed text: only empty children are dropped._ | ||
| </Callout> | ||
|
|
||
| ## 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. On an open or isolated edge, editing gestures move blocks across it: Backspace at the start of the first child moves that child out, and Enter on an empty last child escapes below the container. A sealed edge blocks all of that, so the container behaves as a single unit. | ||
|
|
||
| | Value | Crosses the edge | Use for | | ||
| | --- | --- | --- | | ||
| | `"open"` | Caret, editing gestures, and text selections. A selection can span children and reach outside the container. | Flow regions where a selection should cross child boundaries, like the columns of a `columnList`. | | ||
| | `"isolated"` (default) | Caret and editing gestures, but not a text selection. | Most containers, like a callout. | | ||
| | `"sealed"` | Nothing implicitly. The caret won't wander in, and from outside the container selects and deletes as one unit. | Compartments that should stay put, like a table cell. | | ||
|
|
||
| ```typescript | ||
| // A cell: holds any blocks, but nothing crosses its edge implicitly. | ||
| children: { allow: "any", boundary: "sealed" }, | ||
| placement: "containerOnly", | ||
| ``` | ||
|
|
||
| The block manipulation API ignores `boundary` entirely. An `insertBlocks` 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 each container block type is distinct in the schema, while every regular block (paragraph, heading, code block) shares one underlying type. So "only headings" is not something the schema can enforce yet. Naming a regular block type in the array is a startup error; per-type filtering of regular blocks is not yet supported, and the array is where it 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)); | ||
| - `children` combined with any `content` other than `"none"`; | ||
| - 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 parse like any other custom block. The default rule matches `[data-node-type="<type>"]` so BlockNote's own HTML round-trips, and `implementation.parse` recognizes foreign HTML. Both work exactly as described for [custom blocks](/docs/features/custom-schemas/custom-blocks). | ||
|
|
||
| What's specific to a container is its body. By default BlockNote parses the element's children with the normal block rules, so `<div class="card"><p>…</p><h1>…</h1></div>` becomes a card with a paragraph and a heading. Supply `parseContent` only when you need to build the body yourself. | ||
|
|
||
| <Callout type="warn"> | ||
| _`allow` does not filter what a user pastes. Content your container rejects is | ||
| placed after the container rather than dropped. `allow` constrains the | ||
| document model, not the parser._ | ||
| </Callout> | ||
|
|
||
| ## Interop behavior | ||
|
|
||
| Containers serialize to a `<div data-node-type="...">` with their children nested inside, and round-trip losslessly. For lossy targets you place the children yourself: return a `childrenDOM` from `toExternalHTML` (this is how toggles export as `<details>`), and give container blocks an explicit mapping in the DOCX, PDF, ODT, and email exporters, which throw on a missing one. Markdown flattens containers, exporting their children in order. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
15 changes: 15 additions & 0 deletions
15
examples/06-custom-schema/09-container-block/.bnexample.json
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 wrapping a paragraph followed by a code block. | ||
|
|
||
| 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 `<input>` 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. | ||
| - 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Container Block</title> | ||
| <script> | ||
| <!-- AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY --> | ||
| </script> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="./main.tsx"></script> | ||
| </body> | ||
| </html> | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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( | ||
| <React.StrictMode> | ||
| <App /> | ||
| </React.StrictMode>, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Restore the doctype in the shared example template.
This document has no
<!doctype html>. Browsers can use quirks mode and render the example CSS differently. Add the doctype inpackages/dev-scripts/examples/template-react/index.html.template.tsx, then regenerate this file. Based on learnings: example HTML is generated frompackages/dev-scripts/examples/template-react/index.html.template.tsx; do not edit this artifact directly.🧰 Tools
🪛 HTMLHint (1.9.2)
[error] 1-1: Doctype must be declared before any non-comment content.
(doctype-first)
🤖 Prompt for AI Agents
Sources: Learnings, Linters/SAST tools