feat: add container block API for nested blocks - #2697
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
aa70fc1 to
ab86f5b
Compare
📝 WalkthroughWalkthroughThis PR introduces container blocks, a new block type that can host nested child blocks. It adds a complete example demonstrating container block creation, updates core schema and conversion logic to handle container configuration, modifies React rendering to support container-specific handling, and migrates the existing Column block to the new container block infrastructure. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
@blocknote/ariakit
@blocknote/code-block
@blocknote/core
@blocknote/mantine
@blocknote/react
@blocknote/server-util
@blocknote/shadcn
@blocknote/xl-ai
@blocknote/xl-docx-exporter
@blocknote/xl-email-exporter
@blocknote/xl-multi-column
@blocknote/xl-odt-exporter
@blocknote/xl-pdf-exporter
commit: |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/react/src/schema/ReactBlockSpec.tsx (1)
239-277: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftReact container blocks now lose
domAttributes.blockContent.Once
isContaineris true, these paths return the block component directly and never applythis.blockContentDOMAttributes. In the vanilla container path that data is still available via the render call context, but React renderers are plain FCs, so consumers have no way to preserve editor-level classes/data-* attrs on container blocks.Please thread
blockContentDOMAttributesinto the React container renderer contract, or provide a small container-root helper that applies them before returning the outerNodeViewWrapper. Right now any styling/test hooks configured throughdomAttributes.blockContentdisappear specifically on container blocks.Also applies to: 323-359, 367-399
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/react/src/schema/ReactBlockSpec.tsx` around lines 239 - 277, When isContainer is true the code returns the BlockContent directly from the renderToDOMSpec callback and therefore never applies this.blockContentDOMAttributes; update the React container rendering path to accept and apply blockContentDOMAttributes before returning content. Concretely, modify the renderToDOMSpec callback surrounding isContainer/BlockContent so that the container path either (a) wraps the returned BlockContent in a lightweight DOM wrapper that merges this.blockContentDOMAttributes onto the container root, or (b) passes those attributes into BlockContent via a new prop (e.g. blockContentDOMAttributes) and ensure BlockContent consumers apply them to their root; adjust the renderToDOMSpec usage and any BlockContent consumer expectations accordingly (also apply the same change in the other similar blocks around the referenced ranges).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@examples/06-custom-schema/08-container-block/index.html`:
- Line 1: Add the HTML5 doctype declaration before the opening <html> tag in the
file (the top of examples/06-custom-schema/08-container-block/index.html) so the
document starts with <!doctype html> to prevent quirks-mode rendering; simply
insert the doctype line immediately above the existing <html lang="en"> tag.
In `@examples/06-custom-schema/08-container-block/src/Callout.tsx`:
- Around line 62-68: The icon-only toggle button rendered in Callout.tsx (button
with className "callout-icon-button", onClick={cycleFlavor}, title={`Click to
cycle flavor (current: ${flavor.title})`}) lacks an explicit accessible name;
add one by supplying an aria-label (or aria-labelledby) that conveys its purpose
and current state (e.g., include flavor.title) so screen readers get a
consistent name across AT instead of relying solely on title attributes.
In `@examples/06-custom-schema/08-container-block/vite.config.ts`:
- Around line 17-29: The alias paths and the fs.existsSync check are using
two-level up paths ("../../packages/...") which are one directory too shallow
from this file; update the existence check and each path.resolve for the alias
entries (the block that sets "@blocknote/core" and "@blocknote/react") to go up
three levels (e.g., "../../../packages/core/src" and
"../../../packages/react/src") so the fs.existsSync correctly detects the repo
packages and the aliasing works.
In `@packages/core/src/api/nodeConversions/blockToNode.ts`:
- Around line 373-387: The defaultBlocks expansion in blockToNode can recurse
indefinitely for cyclic container defaults; modify blockToNode (or the call path
that maps defaultBlocks) to detect and prevent cycles by tracking a visited/set
or depth for block types (e.g., pass a visitedTypes Set or maxDepth through the
blockToNode call) and skip expanding a default block if its type is already in
the visited set (or depth exceeded); reference blockToNode, getBlockSchema,
containerConfig, defaultBlocks, and effectiveChildren when adding the cycle
guard so seeded defaults do not re-enter types already on the current expansion
stack.
In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts`:
- Around line 70-76: The code assumes every block spec has
spec.implementation.node and reads node.config.group directly; guard that access
by first checking spec.implementation.node exists and has a config (e.g., use an
existence check or optional chaining) before reading config.group so specs
without a node don't throw during setup; update the filter that references
editor.schema.blockSpecs and spec.implementation.node/config.group to skip
entries where node or node.config is missing while still matching the "bnBlock"
group.
In `@packages/xl-multi-column/src/blocks/Columns/index.ts`:
- Around line 51-69: The update handler in the Columns block (the update:
(newNode: ...) function) currently only sets data-id when newNode.attrs.id is
truthy, leaving a stale data-id on the DOM if id is removed; change the logic so
that when newNode.attrs.id is present you set dom.setAttribute("data-id", id)
and when it's absent you call dom.removeAttribute("data-id") — mirror the
existing data-width handling around COLUMN_WIDTH_DEFAULT to ensure the DOM's
data-id always reflects the node's attrs.id.
---
Outside diff comments:
In `@packages/react/src/schema/ReactBlockSpec.tsx`:
- Around line 239-277: When isContainer is true the code returns the
BlockContent directly from the renderToDOMSpec callback and therefore never
applies this.blockContentDOMAttributes; update the React container rendering
path to accept and apply blockContentDOMAttributes before returning content.
Concretely, modify the renderToDOMSpec callback surrounding
isContainer/BlockContent so that the container path either (a) wraps the
returned BlockContent in a lightweight DOM wrapper that merges
this.blockContentDOMAttributes onto the container root, or (b) passes those
attributes into BlockContent via a new prop (e.g. blockContentDOMAttributes) and
ensure BlockContent consumers apply them to their root; adjust the
renderToDOMSpec usage and any BlockContent consumer expectations accordingly
(also apply the same change in the other similar blocks around the referenced
ranges).
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 2e4e27fc-0786-4e9f-ac0d-c1095acb02e5
⛔ Files ignored due to path filters (5)
packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snapis excluded by!**/*.snap,!**/__snapshots__/**packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.htmlis excluded by!**/__snapshots__/**packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.htmlis excluded by!**/__snapshots__/**pnpm-lock.yamlis excluded by!**/pnpm-lock.yamltests/src/unit/core/schema/__snapshots__/blocks.jsonis excluded by!**/__snapshots__/**
📒 Files selected for processing (25)
examples/06-custom-schema/08-container-block/.bnexample.jsonexamples/06-custom-schema/08-container-block/README.mdexamples/06-custom-schema/08-container-block/index.htmlexamples/06-custom-schema/08-container-block/main.tsxexamples/06-custom-schema/08-container-block/package.jsonexamples/06-custom-schema/08-container-block/src/App.tsxexamples/06-custom-schema/08-container-block/src/Callout.tsxexamples/06-custom-schema/08-container-block/src/styles.cssexamples/06-custom-schema/08-container-block/tsconfig.jsonexamples/06-custom-schema/08-container-block/vite.config.tspackages/core/src/api/nodeConversions/blockToNode.tspackages/core/src/editor/managers/ExtensionManager/extensions.tspackages/core/src/extensions/TrailingNode/TrailingNode.tspackages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.tspackages/core/src/schema/blocks/createSpec.tspackages/core/src/schema/blocks/internal.tspackages/core/src/schema/blocks/types.tspackages/react/src/index.tspackages/react/src/schema/ReactBlockSpec.container.test.tsxpackages/react/src/schema/ReactBlockSpec.tsxpackages/xl-multi-column/src/blocks/Columns/index.tspackages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.tspackages/xl-multi-column/src/pm-nodes/Column.tsplayground/src/examples.gen.tsxtests/nextjs-test-app/package.json
💤 Files with no reviewable changes (1)
- packages/xl-multi-column/src/pm-nodes/Column.ts
| @@ -0,0 +1,14 @@ | |||
| <html lang="en"> | |||
There was a problem hiding this comment.
Add HTML5 doctype at the top.
Line [1] starts with <html> directly. Add <!doctype html> before it to avoid quirks-mode rendering differences.
Suggested fix
+<!doctype html>
<html lang="en">📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <html lang="en"> | |
| <!doctype html> | |
| <html lang="en"> |
🧰 Tools
🪛 HTMLHint (1.9.2)
[error] 1-1: Doctype must be declared before any non-comment content.
(doctype-first)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@examples/06-custom-schema/08-container-block/index.html` at line 1, Add the
HTML5 doctype declaration before the opening <html> tag in the file (the top of
examples/06-custom-schema/08-container-block/index.html) so the document starts
with <!doctype html> to prevent quirks-mode rendering; simply insert the doctype
line immediately above the existing <html lang="en"> tag.
| <button | ||
| className={"callout-icon-button"} | ||
| type={"button"} | ||
| contentEditable={false} | ||
| onClick={cycleFlavor} | ||
| title={`Click to cycle flavor (current: ${flavor.title})`} | ||
| > |
There was a problem hiding this comment.
Add an explicit accessible name to the flavor toggle.
This is an icon-only button, so relying on title alone leaves its accessible name inconsistent across assistive tech.
Suggested change
<button
className={"callout-icon-button"}
type={"button"}
contentEditable={false}
onClick={cycleFlavor}
+ aria-label={`Cycle callout flavor (current: ${flavor.title})`}
title={`Click to cycle flavor (current: ${flavor.title})`}
>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <button | |
| className={"callout-icon-button"} | |
| type={"button"} | |
| contentEditable={false} | |
| onClick={cycleFlavor} | |
| title={`Click to cycle flavor (current: ${flavor.title})`} | |
| > | |
| <button | |
| className={"callout-icon-button"} | |
| type={"button"} | |
| contentEditable={false} | |
| onClick={cycleFlavor} | |
| aria-label={`Cycle callout flavor (current: ${flavor.title})`} | |
| title={`Click to cycle flavor (current: ${flavor.title})`} | |
| > |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@examples/06-custom-schema/08-container-block/src/Callout.tsx` around lines 62
- 68, The icon-only toggle button rendered in Callout.tsx (button with className
"callout-icon-button", onClick={cycleFlavor}, title={`Click to cycle flavor
(current: ${flavor.title})`}) lacks an explicit accessible name; add one by
supplying an aria-label (or aria-labelledby) that conveys its purpose and
current state (e.g., include flavor.title) so screen readers get a consistent
name across AT instead of relying solely on title attributes.
| !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) | ||
| ? {} | ||
| : ({ | ||
| // 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/" | ||
| ), |
There was a problem hiding this comment.
Local alias paths look one directory too shallow.
From this folder, Line [17] / Line [24] / Line [28] should go up three levels to reach repo-root packages/*. With the current paths, the existence check likely returns false and aliasing is skipped.
Suggested fix
- !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
@@
- "../../packages/core/src/"
+ "../../../packages/core/src/"
),
"@blocknote/react": path.resolve(
__dirname,
- "../../packages/react/src/"
+ "../../../packages/react/src/"
),
} as any),📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| !fs.existsSync(path.resolve(__dirname, "../../packages/core/src")) | |
| ? {} | |
| : ({ | |
| // 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/" | |
| ), | |
| !fs.existsSync(path.resolve(__dirname, "../../../packages/core/src")) | |
| ? {} | |
| : ({ | |
| // 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/" | |
| ), |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@examples/06-custom-schema/08-container-block/vite.config.ts` around lines 17
- 29, The alias paths and the fs.existsSync check are using two-level up paths
("../../packages/...") which are one directory too shallow from this file;
update the existence check and each path.resolve for the alias entries (the
block that sets "@blocknote/core" and "@blocknote/react") to go up three levels
(e.g., "../../../packages/core/src" and "../../../packages/react/src") so the
fs.existsSync correctly detects the repo packages and the aliasing works.
| // Seed `defaultBlocks` for container blocks when no children would | ||
| // otherwise be present — covers both `block.children === undefined` and | ||
| // `block.children === []` (e.g. converting a leaf block whose | ||
| // `nodeToBlock` produced empty children into a container). | ||
| if (children.length === 0) { | ||
| // `container` is normalized to `ContainerConfig | undefined` at spec | ||
| // registration time (see addNodeAndExtensionsToSpec). | ||
| const containerConfig = getBlockSchema(schema)[block.type] | ||
| ?.container as ContainerConfig | undefined; | ||
| const defaultBlocks = containerConfig?.defaultBlocks; | ||
| if (defaultBlocks && defaultBlocks.length > 0) { | ||
| effectiveChildren = defaultBlocks.map((type) => | ||
| blockToNode({ type } as PartialBlock<any, any, any>, schema, styleSchema), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Guard against recursive defaultBlocks expansion loops.
On Line 384, seeded defaults recurse through blockToNode without cycle protection. A container config like defaultBlocks: ["callout"] (or an indirect cycle) will recurse indefinitely and crash on insert/update.
Suggested fix
-export function blockToNode(
+export function blockToNode(
block: PartialBlock<any, any, any>,
schema: Schema,
styleSchema: StyleSchema = getStyleSchema(schema),
+ defaultSeedPath: string[] = [],
) {
@@
- if (defaultBlocks && defaultBlocks.length > 0) {
- effectiveChildren = defaultBlocks.map((type) =>
- blockToNode({ type } as PartialBlock<any, any, any>, schema, styleSchema),
- );
+ if (defaultBlocks && defaultBlocks.length > 0) {
+ const currentType = block.type;
+ if (currentType && defaultSeedPath.includes(currentType)) {
+ throw new Error(
+ `container defaultBlocks cycle detected: ${[...defaultSeedPath, currentType].join(" -> ")}`,
+ );
+ }
+ const nextPath = currentType
+ ? [...defaultSeedPath, currentType]
+ : defaultSeedPath;
+ effectiveChildren = defaultBlocks.map((type) =>
+ blockToNode(
+ { type } as PartialBlock<any, any, any>,
+ schema,
+ styleSchema,
+ nextPath,
+ ),
+ );
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/src/api/nodeConversions/blockToNode.ts` around lines 373 - 387,
The defaultBlocks expansion in blockToNode can recurse indefinitely for cyclic
container defaults; modify blockToNode (or the call path that maps
defaultBlocks) to detect and prevent cycles by tracking a visited/set or depth
for block types (e.g., pass a visitedTypes Set or maxDepth through the
blockToNode call) and skip expanding a default block if its type is already in
the visited set (or depth exceeded); reference blockToNode, getBlockSchema,
containerConfig, defaultBlocks, and effectiveChildren when adding the cycle
guard so seeded defaults do not re-enter types already on the current expansion
stack.
| ...Object.entries(editor.schema.blockSpecs) | ||
| .filter(([, spec]) => { | ||
| const group = (spec.implementation.node as Node).config.group; | ||
| return ( | ||
| typeof group === "string" && | ||
| group.split(/\s+/).includes("bnBlock") | ||
| ); |
There was a problem hiding this comment.
Guard access to spec.implementation.node before reading config.group.
Line [72] assumes every block spec has a node implementation. For specs without node, this can throw during editor setup and break initialization.
Suggested fix
...Object.entries(editor.schema.blockSpecs)
.filter(([, spec]) => {
- const group = (spec.implementation.node as Node).config.group;
+ if (!("node" in spec.implementation)) {
+ return false;
+ }
+ const group = (spec.implementation.node as Node).config.group;
return (
typeof group === "string" &&
group.split(/\s+/).includes("bnBlock")
);
})📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ...Object.entries(editor.schema.blockSpecs) | |
| .filter(([, spec]) => { | |
| const group = (spec.implementation.node as Node).config.group; | |
| return ( | |
| typeof group === "string" && | |
| group.split(/\s+/).includes("bnBlock") | |
| ); | |
| ...Object.entries(editor.schema.blockSpecs) | |
| .filter(([, spec]) => { | |
| if (!("node" in spec.implementation)) { | |
| return false; | |
| } | |
| const group = (spec.implementation.node as Node).config.group; | |
| return ( | |
| typeof group === "string" && | |
| group.split(/\s+/).includes("bnBlock") | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/core/src/editor/managers/ExtensionManager/extensions.ts` around
lines 70 - 76, The code assumes every block spec has spec.implementation.node
and reads node.config.group directly; guard that access by first checking
spec.implementation.node exists and has a config (e.g., use an existence check
or optional chaining) before reading config.group so specs without a node don't
throw during setup; update the filter that references editor.schema.blockSpecs
and spec.implementation.node/config.group to skip entries where node or
node.config is missing while still matching the "bnBlock" group.
| update: (newNode: { | ||
| type: { name: string }; | ||
| attrs: { id?: string; width?: number }; | ||
| }) => { | ||
| if (newNode.type.name !== "column") { | ||
| return false; | ||
| } | ||
| const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT; | ||
| dom.style.flexGrow = String(newWidth); | ||
| if (newWidth !== COLUMN_WIDTH_DEFAULT) { | ||
| dom.setAttribute("data-width", String(newWidth)); | ||
| } else { | ||
| dom.removeAttribute("data-width"); | ||
| } | ||
| if (newNode.attrs.id) { | ||
| dom.setAttribute("data-id", newNode.attrs.id); | ||
| } | ||
| return true; | ||
| }, |
There was a problem hiding this comment.
Handle data-id removal to avoid stale DOM identity.
At Line 65, data-id is only updated when truthy; if id becomes absent, the old attribute remains. That can desync DOM↔node identity and break lookup paths that rely on data-id (e.g., column resize node resolution).
Proposed fix
if (newNode.attrs.id) {
dom.setAttribute("data-id", newNode.attrs.id);
+ } else {
+ dom.removeAttribute("data-id");
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| update: (newNode: { | |
| type: { name: string }; | |
| attrs: { id?: string; width?: number }; | |
| }) => { | |
| if (newNode.type.name !== "column") { | |
| return false; | |
| } | |
| const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT; | |
| dom.style.flexGrow = String(newWidth); | |
| if (newWidth !== COLUMN_WIDTH_DEFAULT) { | |
| dom.setAttribute("data-width", String(newWidth)); | |
| } else { | |
| dom.removeAttribute("data-width"); | |
| } | |
| if (newNode.attrs.id) { | |
| dom.setAttribute("data-id", newNode.attrs.id); | |
| } | |
| return true; | |
| }, | |
| update: (newNode: { | |
| type: { name: string }; | |
| attrs: { id?: string; width?: number }; | |
| }) => { | |
| if (newNode.type.name !== "column") { | |
| return false; | |
| } | |
| const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT; | |
| dom.style.flexGrow = String(newWidth); | |
| if (newWidth !== COLUMN_WIDTH_DEFAULT) { | |
| dom.setAttribute("data-width", String(newWidth)); | |
| } else { | |
| dom.removeAttribute("data-width"); | |
| } | |
| if (newNode.attrs.id) { | |
| dom.setAttribute("data-id", newNode.attrs.id); | |
| } else { | |
| dom.removeAttribute("data-id"); | |
| } | |
| return true; | |
| }, |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/xl-multi-column/src/blocks/Columns/index.ts` around lines 51 - 69,
The update handler in the Columns block (the update: (newNode: ...) function)
currently only sets data-id when newNode.attrs.id is truthy, leaving a stale
data-id on the DOM if id is removed; change the logic so that when
newNode.attrs.id is present you set dom.setAttribute("data-id", id) and when
it's absent you call dom.removeAttribute("data-id") — mirror the existing
data-width handling around COLUMN_WIDTH_DEFAULT to ensure the DOM's data-id
always reflects the node's attrs.id.
776232a to
78afb09
Compare
78afb09 to
951fa7b
Compare
|
27eb721 to
698457b
Compare
698457b to
ca44851
Compare
Adds first-class *container blocks*: custom blocks whose body is other
blocks, declared with a `children` config on the block spec and exposed
at runtime as `block.children` — the same field used for indented
blocks, now with schema-enforced control over counts, allowed types, and
rendering.
API surface:
- `children: { allow, min, max, default, whenEmptied, boundary }` on
block configs (createBlockSpec / createReactBlockSpec); children mount
into the render's contentRef/contentDOM
- `allow`: "any" | "blocks" | "containers" | [container types]
- `whenEmptied`: "refill" (top up from `default`) or "unwrap"
(dissolve into survivors) when non-empty children drop below `min`
- `boundary`: "open" | "isolated" | "sealed" — declares what
crosses the container's edge (caret, gestures, text selections);
sealed compartments need no hand-written keyboard handlers, and the
block manipulation API always crosses seals intentionally
- `placement: "containerOnly"` restricts a block to containers that
name it (e.g. column inside columnList)
- Containers may combine children with their own `content:
"inline"`/"plain" (toggle title + body), rendered as
data-content-type / data-children-of regions
- insertBlocks gains "start"/"end" placements to insert into a
container's children
- Schema validation catches impossible configs up front (empty allow,
container cycles, invalid defaults, misplaced containerOnly)
Interop: containers round-trip through internal HTML via
data-node-type, support custom `parse`/`parseContent` for foreign
HTML, serialize semantically via toExternalHTML/childrenDOM, and flatten
in Markdown. The multi-column blocks (columnList/column) are reimplemented
on this API, dropping their bespoke node implementations.
Includes the container-block callout example, a container-table example
stress-testing the API (a table built from four container specs using
only public APIs), and docs for the feature.
e6adf59 to
c040291
Compare
Adds first-class container blocks: custom blocks whose body is other blocks — a Notion-style callout wrapping a paragraph and a code block, a toggle with a title and a body, a multi-column layout, or a table. Declared with a
childrenconfig on the block spec and exposed at runtime asblock.children, the same field used for indented blocks, now with schema-enforced control over counts, allowed types, and rendering.The smallest container is one line on any
createBlockSpec/createReactBlockSpecconfig:Child blocks mount into the element the render passes
contentRef(React) /contentDOM(vanilla) to — the same placement mechanism as inline content. The block's JSON shape is unchanged: children live in the standardchildrenarray.API surface
children: { allow, min, max, default, whenEmptied, boundary }on block configs.allow:"any"|"blocks"|"containers"| an array of container block types. Wildcards never includecontainerOnlytypes; the array form is exact.min/max: child counts, compiled into the editor schema.default: insertion template used when a container is created without children, and the seed for refills.whenEmptied:"refill"(top the container back up fromdefault) or"unwrap"(replace the container with its survivors — how an emptied column list dissolves).boundary:"open"|"isolated"|"sealed"— what crosses the container's edge (caret, editing gestures, text selections)."sealed"makes a compartment like a table cell with no hand-written keyboard handlers: Backspace/Delete/Enter/arrows never implicitly move content across the edge, while the block manipulation API always crosses seals as an intentional act.placement: "containerOnly"restricts a block to containers that name it in theirallowarray (e.g.columninsidecolumnList).childrencombines withcontent: "inline"/"plain"(a toggle's title plus its body), rendered as[data-content-type]and[data-children-of]regions placed by the same singlecontentRef.insertBlocksgains"start"/"end"placements to insert into a container's children — addressing containers that currently have no child to point at.allows, unknown or regular block types inallowarrays, invaliddefaults, orphanedcontainerOnlyblocks, and container cycles all fail at schema creation with a message naming the block.meta.draggablelets structural blocks opt out of the side-menu drag handle, falling through to the nearest draggable ancestor.Interop
data-node-type, with non-default props asdata-*attributes.parse/parseContenton the block implementation;runsBeforeorders container parse rules against each other (containers register in a priority band below regular blocks).toExternalHTMLreturning achildrenDOM(how a toggle exports as<details>).Reimplementations & examples
@blocknote/xl-multi-column(columnList/column) is reimplemented on this API, dropping its bespoke node implementations.table,tableRow,tableCell,tableHeader) using only public APIs: sealed cell boundaries,containerOnlyplacement, Tab/Shift-Tab cell navigation viacreateExtensionkeyboard shortcuts, and row/column operations throughinsertBlocks/removeBlocks/updateBlock. Advanced table features (cell merging, column resizing, table handles) are intentionally out of scope; the point is proving the primitive carries the structural weight.Docs
New Container Blocks page covering declaration, the two regions, boundaries, defaults/refilling, restricting children, validation, parsing, and interop;
manipulating-contentreference updated for the new insert placements.