Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
e9bb1b2
refactor(core): simplify the BlockInfo API into the single vocabulary…
nperez0111 Sep 4, 2026
70fc8f6
refactor(core): consolidate BlockInfo shape and insertion helpers
nperez0111 Sep 8, 2026
db08d7d
feat(container-blocks): unify containers, owned children, and frames
nperez0111 Sep 8, 2026
df78829
test(container-blocks): cover editing, frames, repair, and serialization
nperez0111 Sep 8, 2026
05bf572
docs(container-blocks): document the API and add panel and callout ex…
nperez0111 Sep 8, 2026
c0626cf
refactor(core): simplify container UI and block DOM lookup
nperez0111 Sep 8, 2026
f13b2b7
refactor(container-blocks): simplify rendering, editing, and exports
nperez0111 Sep 8, 2026
afa6358
fix(core): normalize container parsing and export
nperez0111 Sep 8, 2026
56a63d0
fix(tests): streamline Docker build inputs and cache invalidation
nperez0111 Sep 8, 2026
0bce75a
fix(container-blocks): simplify editing and support plain text
nperez0111 Sep 8, 2026
1707571
refactor(container-blocks): simplify implementation and examples
nperez0111 Sep 8, 2026
32fe072
fix(core): complete frame lifecycle and preserve split children
nperez0111 Sep 8, 2026
4f3ade8
fix: address container block review feedback
nperez0111 Sep 8, 2026
c4f0431
fix(core): unwrap a dissolving container in place (#3104)
YousefED Sep 24, 2026
c3ac08a
test(core): add behaviour tests for the built-in toggle blocks
YousefED Sep 29, 2026
cffcf6d
refactor(core): draw the built-in toggles with renderFrame
YousefED Sep 29, 2026
80f635a
feat(core)!: split keyboard behaviour from container structure
YousefED Sep 30, 2026
cd6bd89
feat(exporters): let block mappings place their children
YousefED Sep 30, 2026
4714c87
fix(core): apply block colors to the children of framed blocks
YousefED Sep 30, 2026
3bdd956
test(server-util): update the full HTML snapshot for block color attr…
YousefED Sep 30, 2026
8f0d594
fix: address review findings on keyboard settings and toggles
YousefED Sep 30, 2026
2e1f1ac
Merge branch 'main' into refactor/block-info-api
YousefED Sep 30, 2026
e206b7e
Merge branch 'refactor/block-info-api' into container-blocks/unified
YousefED Sep 30, 2026
9e72dc7
docs(core): note that ignoreFrameChromeMutations needs tests or removal
YousefED Sep 30, 2026
6caa68c
Merge branch 'container-blocks/unified' into container-blocks/editing…
YousefED Sep 30, 2026
8bc7dbb
feat(core): drop a block into a toggle (BLO-956)
YousefED Sep 30, 2026
2159763
fix(core): keep a block's id on Backspace below an alike empty block …
YousefED Sep 30, 2026
0b93f12
fix(core): drop as usual when the drop target is gone
YousefED Sep 30, 2026
bfd0c28
test: update block spec snapshot for meta.dropsIntoChildren
YousefED Sep 30, 2026
cb0315f
fix: one source for the block types that the menus offer (BLO-990, BL…
YousefED Sep 30, 2026
15714ef
refactor(core): rename the keyboard option to experimental_keyboard
YousefED Oct 9, 2026
5a13195
Merge remote-tracking branch 'origin/container-blocks/editing-rules' …
YousefED Oct 9, 2026
b95f09f
Merge remote-tracking branch 'origin/main' into refactor/block-info-api
YousefED Oct 9, 2026
c9a9f26
Merge branch 'container-blocks/unified' into container-blocks/editing…
YousefED Oct 9, 2026
f2a4eb3
Merge branch 'refactor/block-info-api' into container-blocks/unified
YousefED Oct 9, 2026
679148c
Merge branch 'container-blocks/editing-rules' into container-blocks/t…
YousefED Oct 9, 2026
352329e
Fold #3142 and #3143 into #3059: keyboard settings, container flag, t…
YousefED Oct 9, 2026
9778427
Merge main (#3051 squashed) into container-blocks/unified; content un…
YousefED Oct 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# these entries just stop the heavy/irrelevant trees from bloating the context.
**/node_modules
**/dist
**/.next
**/types
**/.vite
**/.vite-plus
Expand Down
201 changes: 201 additions & 0 deletions docs/content/docs/features/custom-schemas/container-blocks.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
---
title: Container Blocks
description: Learn how to create custom blocks that contain other blocks
---

# Container Blocks

You can create custom blocks that contain other blocks, such as panels, callouts, and column layouts. Take a look at the demo below, in which we add a custom panel to a BlockNote editor, as well as a custom [Slash Menu item](/docs/react/components/suggestion-menus#changing-slash-menu-items) to insert it. Each panel can contain paragraphs, headings, lists, or any other blocks in the editor.

<Example name="custom-schema/container-block" />

## Creating a Container Block

Use the `createReactBlockSpec` function to create a container block, just like a [Custom Block](/docs/features/custom-schemas/custom-blocks). For the panel below, we set `content` to `"none"` and `container` to `true`, so the panel's child blocks go inside it:

```tsx
import { createReactBlockSpec } from "@blocknote/react";

export const createPanel = createReactBlockSpec(
{
type: "panel",
propSchema: {},
content: "none",
container: true,
},
{
render: (props) => (
<div className="panel" ref={props.contentRef} />
),
},
);
```

### Block Config

The block config defines the content and child blocks your container can hold:

`content:` Must be `"none"` for a container: its node holds nothing but its child blocks. For a block with its own text and child blocks, see [Combining Content and Child Blocks](#combining-content-and-child-blocks).

`container:` Set to `true` to put the block's child blocks inside it. Without it, a block's child blocks are indented below it. A container accepts any block by default. You can also restrict it to specific container types, as explained in [Restricting Children](#restricting-children).

`propSchema:` Defines the container's props, just like for other custom blocks. Use these to customize its appearance or behavior.

### Block Implementation

`render:` Your React component defines how the block should look. For a container, attach `contentRef` where the child blocks should appear. With `content: "inline"` or `"plain"`, attach it to the block's own editable text. You can add icons, buttons, or other elements around it:

```tsx
render: (props) => (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be renderFrame?

<div className="panel">
<span contentEditable={false}>💡</span>
<div ref={props.contentRef} />
</div>
),
```

You can style the component with CSS, just like any other React component:

```css
.panel {
display: flex;
gap: 12px;
padding: 16px;
border-left: 4px solid #507aff;
border-radius: 6px;
}
```

## Adding Container Blocks to the Editor

Add your container to a [BlockNote schema](/docs/features/custom-schemas#creating-your-own-schema):

```typescript
import { BlockNoteSchema } from "@blocknote/core";
import { createPanel } from "./Panel";

const schema = BlockNoteSchema.create().extend({
blockSpecs: {
panel: createPanel(),
},
});
```

You can then create an editor with this schema, as explained on the [Custom Schemas](/docs/features/custom-schemas) page. Use `children` to set the blocks inside a panel:

```typescript
import { useCreateBlockNote } from "@blocknote/react";

const editor = useCreateBlockNote({
schema,
initialContent: [
{
type: "panel",
children: [
{ type: "heading", content: "Getting started" },
{ type: "paragraph", content: "Follow these steps to get set up." },
{ type: "checkListItem", content: "Create an account" },
],
},
],
});
```

If you create a panel without specifying its children, it starts with an empty paragraph. To let users insert panels themselves, add a [custom Slash Menu item](/docs/react/components/suggestion-menus#changing-slash-menu-items), as shown in the demo.

## Combining Content and Child Blocks

A block can have both its own text and child blocks. Use this for a question followed by hints, a checklist item with detailed instructions, a code sample followed by explanatory blocks, or a callout with a heading. In the demo below, we use the block's text as a callout title:

<Example name="custom-schema/callout-block" />

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's streamline the Alert + Callout examples (we now have an inconsistent mix I think)


Set `content` to `"inline"` for rich text or `"plain"` for unstyled text. Every block with content can have child blocks, so there's nothing to declare for them. Use `render` for the block's own text, `renderFrame` to style that content and its child blocks together, and `keyboard` to keep the child blocks inside the block:

```tsx
import { createReactBlockSpec } from "@blocknote/react";

export const createCallout = createReactBlockSpec(
{
type: "callout",
propSchema: {},
content: "inline",
},
{
experimental_keyboard: {
enter: "into-children",
childrenCanOutdent: false,
emptyChildEnter: "exit-at-end",
},
render: (props) => (
<div className="callout-title" ref={props.contentRef} />
),
renderFrame: (props) => (
<div className="callout">
<div ref={props.contentRef} />
</div>
),
},
);
```

`render:` Attach `contentRef` to the block's own editable text. With `content: "inline"`, users can format it and add links, just like in a paragraph. In this example, we style it as a callout title.

For plain text, set `content: "plain"` and use a `<pre>` element to display line breaks and spacing:

```tsx
render: (props) => <pre ref={props.contentRef} />,
```

The child blocks can still contain rich text, images, and other block types.

`renderFrame:` An optional React component for styling the block and its children together, such as giving the callout a shared border or background. It receives `block`, `editor`, and `contentRef`, just like `render`. Attach `contentRef` where the block's content and children should appear. You can use the block's props to customize the frame, add interactive controls, or return `null` to show the block without a frame.

`experimental_keyboard:` Without it, child blocks behave like any indented blocks. This API is experimental and may change. Here, `enter: "into-children"` makes Enter in the title add a first child block, `childrenCanOutdent: false` keeps Shift-Tab from moving blocks out of the callout, and `emptyChildEnter: "exit-at-end"` makes Enter in an empty last block leave the callout. See [Custom Blocks](/docs/features/custom-schemas/custom-blocks) for all keyboard settings.

For a container with `content: "none"`, like the panel above, add the surrounding styling directly in `render`.

Add `callout: createCallout()` to your schema, then use `content` for the title and `children` for the blocks inside it:

```typescript
{
type: "callout",
content: "Before you start",
children: [
{ type: "paragraph", content: "Make sure you have an account." },
],
}
```

Pressing Enter at the end of the title adds a paragraph inside the callout, and Enter in an empty last paragraph leaves the callout. Moving the callout moves its title and child blocks together.

To add blocks to an existing container, see [Inserting Blocks](/docs/reference/editor/manipulating-content#inserting-blocks).

## Restricting Children

For structured layouts, you can limit a container to specific container types. For example, a column layout should only contain columns, while each column can contain any block.

Use an array of container type names for `children.allow`, and `min` to set the minimum number of children:

```typescript
// Column layout config:
container: true,
children: { allow: ["column"], min: 2 },
```

On the column itself, set `placeable` to `"namedOnly"` so it can only be used inside a container that explicitly allows it:

```typescript
// Column config:
container: true,
placeable: "namedOnly",
```

`children.allow:` Accepts `"blocks"` (the default) or an array of container type names. You cannot list regular block types such as `"paragraph"` individually.

`children.min:` The minimum number of children. Defaults to `1`.

`placeable:` Set to `"namedOnly"` to restrict a container to parents that name it in `children.allow`. Defaults to `"anywhere"`.

These options need `container: true`. Other blocks can always have child blocks of any type, so for them `children` can only be the default, `{ allow: "blocks" }`.

For built-in column blocks, see [Multi-Column Layouts](/docs/foundations/document-structure#column-blocks).
47 changes: 44 additions & 3 deletions docs/content/docs/features/custom-schemas/custom-blocks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ type BlockConfig = {
type: string;
content: "inline" | "plain" | "none";
readonly propSchema: PropSchema;
container?: true; // only with content: "none"
children?: {
allow?: "blocks" | string[];
min?: number;
};
placeable?: "anywhere" | "namedOnly";
};
```

Expand All @@ -72,6 +78,14 @@ type BlockConfig = {
alert, so we set `content` to `"inline"`._
</Callout>

<Callout type="info">
_Every block can have child blocks, indented below it. A block without
content can also hold them inside itself with `container: true`. See
[Container Blocks](/docs/features/custom-schemas/container-blocks)._
</Callout>

`container?:` Puts the block's child blocks inside it. `children?:` Restricts which child blocks a container can hold. `placeable?:` Controls where a container block can be used. See [Container Blocks](/docs/features/custom-schemas/container-blocks) for the supported configurations.

`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
Expand Down Expand Up @@ -133,8 +147,19 @@ type ReactCustomBlockImplementation = {
schema: Schema;
}) => Fragment | undefined;
runsBefore?: string[];
experimental_keyboard?:
| KeyboardSettings
| ((block: Block) => KeyboardSettings);
// KeyboardSettings: {
// enter?: "split" | "into-children" | "line-break";
// shiftEnter?: "line-break" | "same-as-enter";
// splitKeepsType?: boolean;
// resetsTo?: { type: string; props?: Record<string, unknown> };
// emptyEnterResets?: boolean;
// emptyChildEnter?: "outdent" | "exit-at-end" | "stay";
// childrenCanOutdent?: boolean;
// }
meta?: {
hardBreakShortcut?: "shift+enter" | "enter" | "none";
selectable?: boolean;
fileBlockAccept?: string[];
code?: boolean;
Expand Down Expand Up @@ -171,9 +196,25 @@ type ReactCustomBlockImplementation = {

`runsBefore?:` If this block has parsing or extensions that need to be given priority over any other blocks, you can pass their `type`s in an array here.

`meta?:` An object for setting various generic properties of the block.
`experimental_keyboard?:` How the keyboard treats the block and its children. This API is experimental and may change. Give only the settings that differ from the defaults. To make settings depend on the block's props, give a function that gets the block and returns them instead.

- `enter?:` What Enter does in the block's content. `"split"` (default) splits the block, moving the text after the caret into a new block below. `"into-children"` moves it into a new first child instead. `"line-break"` inserts a line break, and makes Shift-Enter do the same. For `content: "plain"` blocks (which can't hold hard break nodes), a line break is a literal newline (`"\n"`).

- `shiftEnter?:` What Shift-Enter does in the block's content: `"line-break"` (default) or `"same-as-enter"`.

- `hardBreakShortcut?:` Defines which keyboard shortcut should be used to insert a hard break into the block's inline content. Defaults to `"shift+enter"`. For `content: "plain"` blocks (which can't hold hard break nodes), the shortcut inserts a literal newline (`"\n"`) instead.
- `splitKeepsType?:` Whether the block created by splitting this one with Enter has the same type, as in lists. Defaults to `false`.

- `resetsTo?:` What the block turns into when it's reset, keeping its content and children. Backspace at the start of the block always resets it. A `type`, and `props` to merge into the block's props. Defaults to `{ type: "paragraph" }`.

- `emptyEnterResets?:` Whether Enter in the empty block resets it too, as when an empty list item turns into a paragraph. Defaults to `false`.

- `emptyChildEnter?:` What Enter does in an empty child of this block. `"outdent"` (default) outdents any empty child. `"exit-at-end"` (default for container blocks) moves an empty last child out to after the block, and adds a new child after any other empty child. `"stay"` always adds a new child after it.

- `childrenCanOutdent?:` Whether the block's children can be outdented out of it with Shift-Tab, or by Enter or Backspace in an empty or nested child. Defaults to `true`, or `false` for container blocks, whose children can never be outdented.

When settings meet, Enter at the start of non-empty content always inserts an empty block above it, and resetting an empty block comes before `enter: "into-children"`.

`meta?:` An object for setting various generic properties of the block.

- `selectable?:` Can be set to false in order to make the block non-selectable, both using the mouse and keyboard. This also helps with being able to select non-editable content within the block. Should only be set to false when `content` is `none` and defaults to true.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,18 +62,20 @@ return (

A few more props customize the states: `errorPreview` for the compact error state shown in place of the preview, `emptySourcePlaceholder` for when the source is empty (a string customizes the default placeholder's text, an element — e.g. the exported `PreviewPlaceholder` with your own icon — replaces it entirely), and `sourcePlaceholder` for the popup input's placeholder. See the `SourceWithPreviewProps` type for the full list.

**3. The spec's `meta`**, opting into the popup:
**3. The spec's `meta`**, opting into the popup, and its `experimental_keyboard`:

```tsx
const createMyBlockSpec = createReactBlockSpec(createMyBlockConfig, {
meta: {
code: true,
// Marks the block as rendering a preview with an editable source popup.
hasPreview: true,
// What Enter does while the popup is open: "enter" inserts a newline
// (multiline sources, like diagrams), "shift+enter" closes the popup
// (single-line sources, like math).
hardBreakShortcut: "enter",
},
// What Enter does while the popup is open: "line-break" inserts a newline
// (multiline sources, like diagrams). Without it, Enter closes the popup
// (single-line sources, like math).
experimental_keyboard: {
enter: "line-break",
},
render: MyBlockPreview,
});
Expand Down
20 changes: 20 additions & 0 deletions docs/content/docs/features/export/typst.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ For a block with inline content, render it the way the default mappings do:
`exporter.transformInlineContent(block.content).join("")` (inline results are
markup strings, so plain concatenation composes them).

### Blocks that place their children

By default, a mapping renders only its block, and the exporter places the
block's children after it, indented. A block whose children are part of it -
a [container block](/docs/features/custom-schemas/container-blocks), or a
callout with a body - uses a `{ withChildren }` mapping instead: the exporter renders
the children first and passes them in as its last argument, and the mapping
decides where they go. Container blocks must use a `{ withChildren }` mapping, and a
container without one is an error rather than a silent omission, since
dropping it would drop its children too.

```typescript
myContainer: {
withChildren: (block, exporter, nestingLevel, numberedListIndex, children) =>
`#rect(width: 100%)[${children.join("\n\n")}]`,
},
```

Separate the children with a blank line, as above, if each should stay its own
block — a single `\n` is only a soft break in Typst markup.

### Math & diagram blocks

Expand Down
6 changes: 4 additions & 2 deletions docs/content/docs/reference/editor/manipulating-content.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,14 +165,16 @@ editor.insertBlocks(
"after",
);

// Insert a paragraph as the last child of an existing block
// Insert a paragraph as the last child of a container block
editor.insertBlocks(
[{ type: "paragraph", content: "Nested paragraph" }],
"existing-block-id",
"container-block-id",
"last-child",
);
```

For [container blocks](/docs/features/custom-schemas/container-blocks), `"first-child"` inserts at the beginning of the container and `"last-child"` inserts at the end. Use `"before"` or `"after"` to insert next to the container instead.
Comment thread
YousefED marked this conversation as resolved.

### Updating Blocks

#### Modifying Existing Blocks
Expand Down
6 changes: 3 additions & 3 deletions examples/01-basic/01-minimal/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export default defineConfig(((conf: { command: string }) => ({
resolve: {
alias:
conf.command === "build" ||
!fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
!fs.existsSync(path.resolve(__dirname, "../../../packages/core/src"))
? {}
: ({
// The repo-wide alias for the shared test-utils directory (private,
Expand All @@ -24,11 +24,11 @@ export default defineConfig(((conf: { command: string }) => ({
// or, keep as is to load live from sources with live reload working
"@blocknote/core": path.resolve(
__dirname,
"../../packages/core/src/",
"../../../packages/core/src/",
),
"@blocknote/react": path.resolve(
__dirname,
"../../packages/react/src/",
"../../../packages/react/src/",
),
} as any),
},
Expand Down
Loading
Loading