Skip to content

feat(core): container blocks, compartments and frames (second pass on #3014) - #3050

Draft
YousefED wants to merge 2 commits into
mainfrom
container-blocks/minimal
Draft

feat(core): container blocks, compartments and frames (second pass on #3014)#3050
YousefED wants to merge 2 commits into
mainfrom
container-blocks/minimal

Conversation

@YousefED

@YousefED YousefED commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Draft, opened next to #3014 rather than against it. Everything here either keeps Nick's design from #2997 / #3014 or narrows it. It is meant to be read as a second pass over that work, not a replacement for it.

What #3014 got right, and what this keeps

The container-block API in #2997 / #3014 is the foundation this builds on, kept close to verbatim:

  • children: { allow, min } on the block config, compiled straight into the node's content expression, so the schema enforces the shape instead of runtime checks.
  • placement: "anywhere" | "containerOnly", so a column can only exist inside a columnList.
  • Containers declared through the ordinary createBlockSpec — no bespoke ProseMirror nodes for authors to write.
  • The multi-column migration onto that API, deleting Column.ts and ColumnList.ts.
  • A repair pass that keeps a container valid as its children are removed.

Those decisions are unchanged and most of the doc comments came across as written. The two additions below are what this PR is actually for.

What's new: a callout can have a title

In #3014 a block declaring children has its content expression replaced by those children, so a container's node holds blocks instead of content. That makes a rich text title impossible — the shape behind the long-standing toggle-block reports (#2020, #2378).

This PR splits the one config knob into two independent ones:

Knob What it decides
children Whose the children are. Declared → the block owns them as a body. Not declared → ordinary nesting.
renderFrame Who draws the box. Author markup wrapping the block's content and children together, with a slot they render into.

Neither implies the other, which is what makes the three shapes below fall out of one API.

How columns, callouts, alerts and toggles relate

Block Declares Node shape Children are Shift-Tab in the body
Column list / column content: "none" + children its own node holds the children its body stays inside
Callout / alert with a title content: "inline" + children + renderFrame ordinary block + blockGroup its body stays inside
Callout / alert without a title content: "none" + children + a render that draws the box its own node holds the children its body stays inside
Toggle renderFrame only ordinary block + blockGroup ordinary nesting moves the block out

Two things worth pulling out of that table:

An alert without a title is just a container. It is the same shape as a column list — a box that holds blocks — so it needs no new machinery at all. Its own render draws the box; renderFrame is for the case where content and children must sit inside the same author markup.

A toggle is deliberately not a compartment. In Notion, Shift-Tab moves a block out of a toggle but keeps it inside a callout, because a toggle's children are nesting while a callout's are a body. Declaring renderFrame without children gives exactly that: the chevron and the box, with untouched nesting behaviour. Two tests in compartments.test.ts pin this down.

That covers both toggle shapes in #2988. A toggle list is a block type that is always a toggle, so it declares renderFrame outright. A toggle heading is a heading either way, and is only a toggle when props.isToggleable — so renderFrame may return undefined to decline, and a block whose type frames itself rebuilds its node view when its props change, the way an unframed block already does. The per-user collapse state itself is orthogonal to all of this and stays where #2988 put it: outside the document, in an extension. What this PR removes from that picture is the need for a wrapper block.

What compartments change about editing

A body belongs to its block, so gestures treat title and body as one unit:

  • Enter in the title starts the body, and whatever follows the cursor becomes its first block. The body the callout already had stays on the callout — this is the Enter Key Breaks Children in Custom Toggleable Blocks #2020 / Toggle block content is moved to next toggle block #2378 bug, which was that splitting a block handed its children to whatever the split created.
  • Enter on an empty last body block leaves the compartment, the way a second Enter gets you out of a list. Shared with containers, so it works for columns too.
  • Backspace at the start of the first body block merges into the title.
  • Backspace at the start of the block after a callout moves that block into the body, whole.
  • Shift-Tab stops at the body's edge instead of lifting the block out of it.

The guard for all of this is one predicate, isCompartment, which is true for a container block and for a block with a body. compartmentBody erases the shape difference between the two, so the column code and the callout code are the same code. What stays container-specific is only the part that moves between sibling children — the previous or next column — which a compartment has none of.

What was dropped from #3014

Each of these was cut because nothing in columns, callouts or toggles needed it, not because it was wrong:

#3014 Here
children.default — seed children for a new container dropped; ProseMirror's own fill covers it
whenEmptied: "refill" | "unwrap" dropped; a container that can live anywhere dissolves when emptied, a containerOnly one is padded
boundary: "open" | "sealed" dropped
allow: "any" | "blocks" | "containers" | string[] "any" | string[]
rootDOM on the render output not needed — a container's own element carries its attributes
a second entrypoint none
a block cannot have both content and children compartments

The result, source only, excluding tests and snapshots:

#3014 this
packages/core/src 50 files, +4,080 / −1,472 20 files, +1,194 / −298
all of packages/ 74 files, +4,805 / −1,899 25 files, +1,339 / −476

Tests

All green: core 798, shared matrix 913, multi-column 82, react 4.

New coverage: containers.test.ts (schema and node shape), compartments.test.ts (the keyboard behaviour above, plus the toggle case), renderFrame.test.ts, reactContainer.test.tsx (a container written with createReactBlockSpec — renders, holds children, round-trips through HTML), reactFrame.test.tsx, and fixContainer.test.ts (renamed from fixColumnLists.test.ts).

There is a runnable example at examples/06-custom-schema/13-callout-block.

Breaking changes

  • fixColumnList, removeEmptyColumns and isEmptyColumn are replaced by fixContainer, removeEmptyChildren, isEmptyContainerChild, containerAncestorIds and fixContainersById.
  • A column no longer serializes data-width when its width is the default, matching how every other block omits default props.

Known gaps

  • A frame is built as DOM, not JSX. A React block spec can declare renderFrame — the callout example does — but the frame itself is plain DOM, so a frame with its own React UI isn't expressible yet.
  • Dropping into a fully empty compartment or container is the Empty toggle list doesn't allow drag and drop #2109 equivalent and is not handled.
  • Separately, and pre-existing on main (reproduced at 63c2389 with plain nesting and no custom schema): Delete at the end of the last nested block silently deletes the following block when it can't merge as text — a table, say. Callouts inherit it; containers don't, because they intercept that key first. Worth a fix in the generic branch, not here.

🤖 Generated with Claude Code

Builds on the container-block API from #2997 / #3014: `children` on the
block config, compiled into the node's content expression, columns and
column lists declared through the ordinary `createBlockSpec`, and a
repair pass that keeps containers valid as children are removed.

Adds two things on top of that:

- compartments — a block that keeps content of its own *and* declares
  `children`, so a callout can have a rich text title above a body of
  blocks. Editing gestures treat title and body as one unit.
- `renderFrame` — markup wrapping a block's content and children
  together, so the author draws the box. Independent of `children`: a
  toggle frames itself but keeps ordinary nesting.

Narrows the rest of the API: no `default` seeds, no `whenEmptied`, no
`boundary`, no `rootDOM`, and `allow` is `"any"` or a list of container
type names.

BREAKING CHANGE: `fixColumnList`, `removeEmptyColumns` and
`isEmptyColumn` are replaced by `fixContainer`, `removeEmptyChildren`,
`isEmptyContainerChild`, `containerAncestorIds` and `fixContainersById`.
Columns no longer serialize `data-width` when the width is the default.
@vercel

vercel Bot commented Sep 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
blocknote Error Error Sep 4, 2026 12:48pm UTC
blocknote-website Error Error Sep 4, 2026 12:48pm UTC

Request Review

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://TypeCellOS.github.io/BlockNote/pr-preview/pr-3050/

Built to branch gh-pages at 2026-09-04 14:13 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

A toggle heading is a heading either way - it is only a toggle when
`props.isToggleable` says so - so `renderFrame` may now decline by
returning `undefined`.

Whether a block is framed is decided when the frame is built, so a block
whose type frames itself now rebuilds its node view when its props
change, the way an unframed block already does. `frame.update` still
handles content changes, which ProseMirror would not rebuild for.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant