-
-
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
Changes from all commits
07d12c1
23702ce
10e2f65
4ebae87
256d237
bc4d8d7
8a53191
17e4cd1
6319314
de86053
f735a36
a0323c9
d8b66e2
91dca94
0724199
f823d7b
91692b1
fe7bef5
990aa3a
cffa1e4
115234a
f6b1fd3
933e937
fced5dd
c0d1ac1
5006ec4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| --- | ||
| 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. Any other `content` is ignored — the node is built from `children` alone. 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure about whether all of this section is relevant for consumers. Feels like we're mixing API explanation and internals a bit |
||
|
|
||
| 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` | `1` | How few children the container may hold. 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` | `"open"` | Whether editing gestures cross the container's edge. 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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this feels like an overly complex explanation (omit or simplify?) |
||
|
|
||
| ## Boundaries | ||
|
|
||
| `boundary` declares whether editing gestures cross a container's edge. On an open edge they 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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should we be able to escape with shift+enter just like in code blocks? |
||
|
|
||
| | Value | Crosses the edge | Use for | | ||
| | --- | --- | --- | | ||
| | `"open"` (default) | The caret and editing gestures. | Containers that are part of the surrounding flow of text, like a callout or the columns of a `columnList`. | | ||
| | `"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. | | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we have other examples than a table cell? Conflating container blocks with tables is confusing as long as tables are not built on top of container blocks
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Zoomed out; I think it might be useful to have examples of a If we can't come up with good examples, we should not expose the API imo. |
||
|
|
||
| A seal binds gestures only. A text selection may span any edge, so a drag out of a sealed container still selects across it. | ||
|
|
||
| ```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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when would "containers" be useful"? |
||
| - `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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. references to |
||
|
|
||
| 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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this seems like implementation explanation / excuse, I don't think it adds anything as long as the API explanation already makes clear that you can only name specific container block types |
||
|
|
||
| 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, "first-child"); | ||
| editor.insertBlocks([{ type: "paragraph" }], calloutId, "last-child"); | ||
| ``` | ||
|
|
||
| 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. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this paragraph is confusing:
then why would you need the option of both first / last child?
not sure what this sentence means |
||
|
|
||
| ## Validation | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is this relevant to consumers, or an implementation detail and consumers should just see a descriptive error message as soon as they configure something wrong? |
||
|
|
||
| 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. `allow: "containers"` with `min: 1` is the same problem, since the container counts as a container itself. | ||
|
|
||
| Documents are checked too. `initialContent` that doesn't fit the schema throws when the editor is created, rather than loading in a broken state. This matters when you change a `children` config on a schema whose documents are already saved somewhere: a stored document that no longer fits, say a `columnList` left with a single column, now fails at load. Migrate those documents before shipping the change. | ||
|
|
||
| ## 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). | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if both work as described in custom-blocks, do we need a separate section for this? |
||
|
|
||
| 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, ODT, email, Typst, and PDF exporters, which throw on a missing one. That mapping receives the container's rendered children as its last argument and decides where they go — the exporters do not append them after the container's own output. Markdown flattens containers, exporting their children in order. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| 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" | ||
| } | ||
| } |
| 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) |
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.
do we throw when content is passed (either to the block instance or a different value than
noneis passed to the schema?)