diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx
new file mode 100644
index 0000000000..02b2c44124
--- /dev/null
+++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx
@@ -0,0 +1,119 @@
+---
+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. BlockNote's built-in multi-column blocks (`columnList` / `column`) are implemented with this same mechanism.
+
+Take a look at the demo below, in which we add a custom callout block that can contain any other blocks:
+
+
+
+## Declaring a Container Block
+
+Add the `childBlocks` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The block must declare `content: "none"` — its body is made of child blocks, not inline content:
+
+```typescript
+const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ },
+ // The callout has no inline content of its own; it defers to its children.
+ content: "none",
+ // Marks the block as a container of other blocks.
+ childBlocks: {
+ // At least one child block is required.
+ min: 1,
+ // Seeded when the block is inserted without explicit children.
+ defaultChildren: [{ type: "paragraph" }],
+ },
+ },
+ {
+ render: (props) => (
+
+ {/* Child blocks are rendered into the element you attach contentRef to. */}
+
+
+ ),
+ },
+);
+```
+
+At runtime, the contained blocks live on `block.children` — the same field used for indented (nested) blocks:
+
+```json
+{
+ "id": "callout-1",
+ "type": "callout",
+ "props": { "flavor": "tip" },
+ "content": undefined,
+ "children": [
+ {
+ "id": "para-1",
+ "type": "paragraph",
+ "content": [{ "type": "text", "text": "Hello", "styles": {} }],
+ "children": []
+ }
+ ]
+}
+```
+
+### `ChildBlocksWrapper` (React)
+
+Container blocks own their entire outer DOM — BlockNote doesn't wrap them in the usual block element. Your `render` should return a `ChildBlocksWrapper` (exported from `@blocknote/react`) as the root element: it automatically applies the attributes BlockNote relies on for HTML parsing and UI positioning (`data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute). Any other props (`className`, event handlers) are passed through.
+
+For vanilla JS blocks (`createBlockSpec`), return a DOM element with `contentDOM` pointing to where children mount. BlockNote fills in the missing `data-*` attributes when serializing to HTML, but it's good practice to set `data-node-type` and `data-id` yourself so UI features (side menu positioning, drag & drop) work on the live editor DOM.
+
+## `childBlocks` options
+
+| Option | Default | Description |
+| --- | --- | --- |
+| `allowedBlocks` | any block | Block types allowed as direct children. Container types are enforced exactly by the schema; regular block types collapse to "any regular block" (they all share one node type internally). |
+| `min` / `max` | `1` / unbounded | How many children are allowed. Enforced by the editor schema. |
+| `defaultChildren` | — | Partial blocks seeded when the container is inserted without children. Validated against `allowedBlocks`/`min`/`max` when the schema is created. |
+| `topLevel` | `true` | Whether the block can appear anywhere a regular block goes. Set `false` for blocks that only make sense inside a specific parent (like a `column` inside a `columnList`). |
+| `collapseWhenEmptied` | `false` | Structural cleanup after children are removed: drops emptied children, and unwraps the container (replacing it with its remaining children, or removing it when none are left) once fewer than `min` non-empty children remain. Column lists set this to `true`. |
+
+Behavioral options live in the block implementation's `meta` instead, since they don't affect the document schema:
+
+| Meta option | Default | Description |
+| --- | --- | --- |
+| `exitOnEnter` | `true` | Pressing Enter on an empty last child moves it out of the container, list-style. Disable to keep the cursor inside (columns do this). |
+| `draggable` | `true` | Whether the container itself gets a side menu drag handle. |
+
+### Restricting children: a columnList-style pair
+
+`allowedBlocks` + `topLevel: false` let you build tightly-coupled structures. This is exactly how the multi-column blocks are defined:
+
+```typescript
+// The outer container: only accepts columns, at least two of them.
+childBlocks: {
+ allowedBlocks: ["column"],
+ min: 2,
+ collapseWhenEmptied: true,
+}
+
+// The column: holds any blocks, but can only live inside a columnList.
+childBlocks: { topLevel: false }
+```
+
+The same pattern works for table-like structures (a "grid" of "cells"), FAQ lists, and so on. Configurations are validated when the schema is created — unknown `allowedBlocks` entries, impossible `defaultChildren`, and container cycles that could never be auto-filled all fail up front with a clear error.
+
+## Editable fields that aren't document content
+
+A container can only have one "hole" for child blocks and no inline content of its own. If your block needs an extra editable field — like the callout's title — store it as a **string prop** and render a regular `` inside the block (in a `contentEditable={false}` wrapper), committing the value with `editor.updateBlock`. See the demo above for a full implementation.
+
+This is the right tool when the field doesn't need rich text formatting, comments, or multiplayer cursors — it's plain data on the block, not document content.
+
+## Interop behavior
+
+- **HTML**: containers serialize to a `
` with their children nested inside and non-default props as `data-*` attributes, and parse back losslessly.
+- **Markdown**: containers are flattened — their children are exported in order, and Markdown import never produces containers.
+- **Exporters** (`@blocknote/xl-docx-exporter`, `xl-pdf-exporter`, `xl-odt-exporter`, `xl-email-exporter`): container blocks require an explicit block mapping that places their children; a missing mapping throws a clear error.
diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
index 60bacafe68..a8ae04da4e 100644
--- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx
+++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx
@@ -68,6 +68,12 @@ type BlockConfig = {
we set `content` to `"inline"`._
+
+ _Blocks with `content: "none"` can instead hold **other blocks** as their
+ body by declaring the `childBlocks` option — see [Container
+ Blocks](/docs/features/custom-schemas/container-blocks)._
+
+
`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
diff --git a/examples/06-custom-schema/09-container-block/.bnexample.json b/examples/06-custom-schema/09-container-block/.bnexample.json
new file mode 100644
index 0000000000..3de7330631
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/.bnexample.json
@@ -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"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md
new file mode 100644
index 0000000000..4a285c69b8
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/README.md
@@ -0,0 +1,21 @@
+# Container Block
+
+In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.
+
+The block uses the new `childBlocks` config on `BlockConfig`. Setting `childBlocks: { defaultChildren: [{ type: "paragraph" }] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested block children directly — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.
+
+The callout also has an editable **title**, demonstrating the complementary "string prop slot" pattern: content that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`.
+
+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 — anything goes.
+- 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:**
+
+- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)
+- [Editor Setup](/docs/getting-started/editor-setup)
diff --git a/examples/06-custom-schema/09-container-block/index.html b/examples/06-custom-schema/09-container-block/index.html
new file mode 100644
index 0000000000..19321f77b5
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Container Block
+
+
+
+
+
+
+
diff --git a/examples/06-custom-schema/09-container-block/main.tsx b/examples/06-custom-schema/09-container-block/main.tsx
new file mode 100644
index 0000000000..1260513388
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/main.tsx
@@ -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(
+
+
+ ,
+);
diff --git a/examples/06-custom-schema/09-container-block/package.json b/examples/06-custom-schema/09-container-block/package.json
new file mode 100644
index 0000000000..d92c915975
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/package.json
@@ -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": "vp dev",
+ "dev": "vp dev",
+ "build:prod": "tsc && vp build",
+ "preview": "vp 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-plus": "^0.1.24"
+ }
+}
diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx
new file mode 100644
index 0000000000..3f3255bbba
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/App.tsx
@@ -0,0 +1,118 @@
+import { BlockNoteSchema, defaultBlockSpecs } from "@blocknote/core";
+import {
+ filterSuggestionItems,
+ insertOrUpdateBlockForSlashMenu,
+} from "@blocknote/core/extensions";
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import {
+ SuggestionMenuController,
+ getDefaultReactSlashMenuItems,
+ useCreateBlockNote,
+} from "@blocknote/react";
+import { useEffect, useState } from "react";
+import { RiChatQuoteLine } from "react-icons/ri";
+
+import { createCallout } from "./Callout";
+import "./styles.css";
+
+// Schema with the default blocks plus our custom Callout container block.
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: createCallout(),
+ },
+});
+
+// Slash menu item to insert a Callout. Because Callout is a container block,
+// inserting one with no children causes BlockNote to seed it with the block's
+// configured `defaultChildren` (a single paragraph here).
+const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({
+ title: "Callout",
+ subtext: "Container block that wraps other blocks",
+ onItemClick: () =>
+ insertOrUpdateBlockForSlashMenu(editor, {
+ type: "callout",
+ }),
+ aliases: ["callout", "container", "alert", "note", "tip", "info"],
+ group: "Basic blocks",
+ icon: ,
+});
+
+type AppBlock = (typeof schema.BlockNoteEditor)["document"][number];
+
+export default function App() {
+ const [blocks, setBlocks] = useState([]);
+
+ const editor = useCreateBlockNote({
+ schema,
+ initialContent: [
+ {
+ type: "paragraph",
+ content: "Welcome — this demo shows the new container block kind.",
+ },
+ {
+ type: "callout",
+ props: { flavor: "tip" },
+ children: [
+ {
+ type: "paragraph",
+ content: "Callouts can hold any block as their body.",
+ },
+ {
+ type: "paragraph",
+ content:
+ "Try pressing '/' inside this callout to add a heading or code block.",
+ },
+ ],
+ },
+ {
+ type: "paragraph",
+ content: "Press '/' anywhere to insert a new Callout.",
+ },
+ {
+ type: "paragraph",
+ },
+ ],
+ });
+
+ useEffect(() => setBlocks(editor.document), [editor]);
+
+ return (
+
+ );
+}
diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx
new file mode 100644
index 0000000000..1c64a69af6
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx
@@ -0,0 +1,102 @@
+import { ChildBlocksWrapper, createReactBlockSpec } from "@blocknote/react";
+import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md";
+
+import "./styles.css";
+
+// The flavors of callout the user can switch between.
+export const calloutTypes = [
+ { value: "tip", title: "Tip", icon: MdLightbulb },
+ { value: "info", title: "Info", icon: MdInfo },
+ { value: "warning", title: "Warning", icon: MdWarning },
+ { value: "success", title: "Success", icon: MdCheckCircle },
+] as const;
+
+// The Callout block. Declared with `content: "none"` plus the new
+// `childBlocks` config — the block hosts arbitrary child blocks in its body,
+// exposed at runtime as `block.children`.
+//
+// The callout's title demonstrates the complementary "string prop slot"
+// pattern: content that shouldn't be part of the rich-text document (no
+// formatting, comments, or multiplayer cursors needed) can live in a plain
+// string prop, edited through a regular rendered inside the block.
+export const createCallout = createReactBlockSpec(
+ {
+ type: "callout",
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ title: {
+ default: "",
+ },
+ },
+ content: "none",
+ childBlocks: {
+ min: 1,
+ defaultChildren: [{ type: "paragraph" }],
+ },
+ },
+ {
+ render: (props) => {
+ const flavor =
+ calloutTypes.find((c) => c.value === props.block.props.flavor) ??
+ calloutTypes[0];
+ const Icon = flavor.icon;
+
+ const cycleFlavor = () => {
+ const idx = calloutTypes.findIndex(
+ (c) => c.value === props.block.props.flavor,
+ );
+ const next = calloutTypes[(idx + 1) % calloutTypes.length];
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { flavor: next.value },
+ });
+ };
+
+ const commitTitle = (title: string) => {
+ if (title !== props.block.props.title) {
+ props.editor.updateBlock(props.block, {
+ type: "callout",
+ props: { title },
+ });
+ }
+ };
+
+ return (
+
+
+
+ {/* The title lives in a string prop, not in document content —
+ it's edited via a plain input. `contentEditable={false}` keeps
+ ProseMirror from treating typing here as document input. */}
+
+
+ );
+ },
+ },
+);
diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css
new file mode 100644
index 0000000000..19dce1d861
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/src/styles.css
@@ -0,0 +1,118 @@
+.wrapper {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+}
+
+.item {
+ border-radius: 0.5rem;
+ flex: 1;
+ overflow: hidden;
+}
+
+.item.bordered {
+ border: 1px solid gray;
+}
+
+.item pre {
+ border-radius: 0.5rem;
+ height: 100%;
+ overflow: auto;
+ padding-block: 1rem;
+ padding-inline: 54px;
+ width: 100%;
+ white-space: pre-wrap;
+}
+
+.callout {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ flex-grow: 1;
+ border-radius: 6px;
+ padding: 12px 16px;
+ border-left: 4px solid var(--callout-accent, #888);
+ background-color: var(--callout-bg, #f3f4f6);
+}
+
+.callout[data-flavor="tip"] {
+ --callout-accent: #d97706;
+ --callout-bg: #fff7ed;
+}
+
+.callout[data-flavor="info"] {
+ --callout-accent: #507aff;
+ --callout-bg: #e6ebff;
+}
+
+.callout[data-flavor="warning"] {
+ --callout-accent: #b91c1c;
+ --callout-bg: #fef2f2;
+}
+
+.callout[data-flavor="success"] {
+ --callout-accent: #16a34a;
+ --callout-bg: #ecfdf5;
+}
+
+[data-color-scheme="dark"] .callout[data-flavor="tip"] {
+ --callout-bg: #432e0e;
+}
+
+[data-color-scheme="dark"] .callout[data-flavor="info"] {
+ --callout-bg: #1e2a5c;
+}
+
+[data-color-scheme="dark"] .callout[data-flavor="warning"] {
+ --callout-bg: #4a1212;
+}
+
+[data-color-scheme="dark"] .callout[data-flavor="success"] {
+ --callout-bg: #0d3b21;
+}
+
+.callout-icon-button {
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 4px;
+ color: var(--callout-accent, #888);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 2px;
+}
+
+.callout-icon-button:hover {
+ opacity: 0.75;
+}
+
+.callout-main {
+ flex-grow: 1;
+ min-width: 0;
+}
+
+.callout-title-wrapper {
+ margin-bottom: 4px;
+}
+
+.callout-title-input {
+ width: 100%;
+ border: none;
+ background: none;
+ outline: none;
+ font-weight: 600;
+ font-size: 1rem;
+ color: inherit;
+ padding: 0;
+}
+
+.callout-title-input::placeholder {
+ color: var(--callout-accent, #888);
+ opacity: 0.5;
+}
+
+.callout-body {
+ flex-grow: 1;
+ min-width: 0;
+}
diff --git a/examples/06-custom-schema/09-container-block/tsconfig.json b/examples/06-custom-schema/09-container-block/tsconfig.json
new file mode 100644
index 0000000000..93fa81bee8
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": ["DOM", "DOM.Iterable", "ESNext"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true
+ },
+ "include": ["."],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
diff --git a/examples/06-custom-schema/09-container-block/vite-env.d.ts b/examples/06-custom-schema/09-container-block/vite-env.d.ts
new file mode 100644
index 0000000000..bc2d8a36f3
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/06-custom-schema/09-container-block/vite.config.ts b/examples/06-custom-schema/09-container-block/vite.config.ts
new file mode 100644
index 0000000000..0133a6da9e
--- /dev/null
+++ b/examples/06-custom-schema/09-container-block/vite.config.ts
@@ -0,0 +1,31 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite-plus";
+// https://vitejs.dev/config/
+export default defineConfig(((conf: { command: string }) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !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/",
+ ),
+ } as any),
+ },
+})) as Parameters[0]);
diff --git a/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json
index 1893df45b9..ca1451aaa8 100644
--- a/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json
+++ b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json
@@ -5,6 +5,6 @@
"tags": ["Extension"],
"pro": true,
"dependencies": {
- "@tiptap/core": "^3.13.0"
+ "@tiptap/core": "^3.29.2"
}
}
diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
index ce1a9455db..012f322ff7 100644
--- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts
@@ -153,7 +153,11 @@ const mergeBlocks = (
);
}
- // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v.
+ // Merging into or out of container blocks (columnLists, callouts, ...)
+ // is intentionally unsupported — `canMerge` refuses it above. The
+ // container-boundary Backspace/Delete branches in
+ // `KeyboardShortcutsExtension` handle those cases by moving blocks
+ // across the boundary instead of merging their content.
dispatch(
state.tr.delete(
prevBlockInfo.blockContent.afterPos - 1,
diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
index 71598b7d69..3e92da9127 100644
--- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts
@@ -14,6 +14,10 @@ import {
getNodeId,
} from "../../../getBlockInfoFromPos.js";
import { getNodeById } from "../../../nodeUtil.js";
+import {
+ flattenNonInsertableBlocks,
+ isContainerNode,
+} from "../../containers/fixContainer.js";
import { insertBlocks } from "../insertBlocks/insertBlocks.js";
import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js";
@@ -131,16 +135,6 @@ function updateBlockSelectionFromData(
tr.setSelection(selection);
}
-// Replaces top-level `column` blocks with their children, as a `column` is not
-// a valid block outside a `columnList`. Other blocks are returned as-is.
-function flattenColumns(
- blocks: Block[],
-): Block[] {
- return blocks.flatMap((block) =>
- block.type === "column" ? block.children : [block],
- );
-}
-
/**
* Removes the given blocks from the editor, then inserts them before/after a
* reference block.
@@ -169,10 +163,12 @@ export function moveBlocks(
//
// When the non-empty block is moved up, the column is seen as empty and
// collapsed in the removal step, so the following insertion fails.
- removeAndInsertBlocks(tr, blocks, [], { fixColumns: false });
+ removeAndInsertBlocks(tr, blocks, [], { fixContainers: false });
insertBlocks(
tr,
- flattenColumns(blocks),
+ // Blocks that can't stand on their own outside their container (e.g. a
+ // `column` outside its `columnList`) are replaced by their children.
+ flattenNonInsertableBlocks(blocks, editor.pmSchema),
referenceBlock,
placement,
);
@@ -207,12 +203,27 @@ export function moveSelectedBlocksAndSelection(
});
}
-// Checks if a block is in a valid place after being moved. This check is
-// primitive at the moment and only returns false if the block's parent is a
-// `columnList` block. This is because regular blocks cannot be direct children
-// of `columnList` blocks.
-function checkPlacementIsValid(parentBlock?: Block): boolean {
- return !parentBlock || parentBlock.type !== "columnList";
+// Checks if a regular block is in a valid place after being moved, i.e.
+// whether its would-be parent accepts a regular block as a direct child.
+// Regular blocks nest under any non-container block (they go into its
+// `blockGroup`), but a container block (e.g. a `columnList`) only accepts
+// what its content expression allows.
+function checkPlacementIsValid(
+ editor: BlockNoteEditor,
+ parentBlock?: Block,
+): boolean {
+ if (!parentBlock) {
+ return true;
+ }
+ const parentNodeType = editor.pmSchema.nodes[parentBlock.type];
+ if (!parentNodeType || !isContainerNode(parentNodeType)) {
+ return true;
+ }
+ return (
+ parentNodeType.contentMatch.matchType(
+ editor.pmSchema.nodes["blockContainer"],
+ ) !== null
+ );
}
// Gets the placement for moving a block up. This has 3 cases:
@@ -254,7 +265,7 @@ function getMoveUpPlacement(
}
const referenceBlockParent = editor.getParentBlock(referenceBlock);
- if (!checkPlacementIsValid(referenceBlockParent)) {
+ if (!checkPlacementIsValid(editor, referenceBlockParent)) {
return getMoveUpPlacement(
editor,
placement === "after"
@@ -306,7 +317,7 @@ function getMoveDownPlacement(
}
const referenceBlockParent = editor.getParentBlock(referenceBlock);
- if (!checkPlacementIsValid(referenceBlockParent)) {
+ if (!checkPlacementIsValid(editor, referenceBlockParent)) {
return getMoveDownPlacement(
editor,
placement === "before"
diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
index a0f76fdff0..a0a09d0099 100644
--- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
+++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts
@@ -19,9 +19,7 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) {
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
- (node) =>
- node.childCount > 0 &&
- (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1
);
if (!range) {
return false;
@@ -163,9 +161,7 @@ export function liftItem(
const { $from, $to } = tr.selection;
const range = $from.blockRange(
$to,
- (node) =>
- node.childCount > 0 &&
- (node.type.name === "blockGroup" || node.type.name === "column"), // change 1
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1
);
if (!range) {
return false;
@@ -195,14 +191,36 @@ export function canNestBlock(editor: BlockNoteEditor) {
return editor.transact((tr) => {
const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
- return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null;
+ // Mirrors `sinkItem`'s precondition: nesting is only possible under a
+ // previous sibling that is itself a `blockContainer`. (A previous sibling
+ // of another type — e.g. a container block — made this return true while
+ // `nestBlock` did nothing.)
+ return (
+ tr.doc.resolve(blockContainer.beforePos).nodeBefore?.type ===
+ editor.pmSchema.nodes["blockContainer"]
+ );
});
}
export function canUnnestBlock(editor: BlockNoteEditor) {
return editor.transact((tr) => {
- const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr);
+ const { $from, $to } = tr.selection;
+
+ // Mirrors `liftItem`'s preconditions instead of approximating with
+ // depth — a block whose depth > 1 because it sits inside a container
+ // (e.g. a column) is not un-nestable, only a block nested under another
+ // `blockContainer` is.
+ const range = $from.blockRange(
+ $to,
+ (node) => node.childCount > 0 && node.type.isInGroup("childContainer"),
+ );
+ if (!range) {
+ return false;
+ }
- return tr.doc.resolve(blockContainer.beforePos).depth > 1;
+ return (
+ $from.node(range.depth - 1).type ===
+ editor.pmSchema.nodes["blockContainer"]
+ );
});
}
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
index d9e1e72981..84be8fa9fc 100644
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
+++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts
@@ -11,7 +11,8 @@ import type {
import { blockToNode } from "../../../nodeConversions/blockToNode.js";
import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js";
import { getPmSchema } from "../../../pmUtil.js";
-import { fixColumnList } from "./util/fixColumnList.js";
+import { fixContainersById } from "../../containers/fixContainer.js";
+import { getAncestorContainers } from "../../containers/containerNav.js";
export function removeAndInsertBlocks<
BSchema extends BlockSchema,
@@ -22,7 +23,7 @@ export function removeAndInsertBlocks<
blocksToRemove: BlockIdentifier[],
blocksToInsert: PartialBlock[],
options: {
- fixColumns?: boolean;
+ fixContainers?: boolean;
} = {},
): {
insertedBlocks: Block[];
@@ -43,7 +44,10 @@ export function removeAndInsertBlocks<
),
);
const removedBlocks: Block[] = [];
- const columnListPositions = new Set();
+ // Ancestor containers of removed blocks, to repair afterwards. Tracked by
+ // node id (not position) since the removals — and earlier repairs — shift
+ // positions; recorded with their depth so repairs run deepest-first.
+ const containersToFix: { id: string; depth: number }[] = [];
const idOfFirstBlock =
typeof blocksToRemove[0] === "string"
@@ -84,10 +88,10 @@ export function removeAndInsertBlocks<
const $pos = tr.doc.resolve(pos - removedSize);
- if ($pos.node().type.name === "column") {
- columnListPositions.add($pos.before(-1));
- } else if ($pos.node().type.name === "columnList") {
- columnListPositions.add($pos.before());
+ for (const container of getAncestorContainers($pos.doc, $pos.pos)) {
+ if (!containersToFix.some((c) => c.id === container.id)) {
+ containersToFix.push(container);
+ }
}
if (
@@ -119,11 +123,12 @@ export function removeAndInsertBlocks<
);
}
- // Collapses empty columns/columnLists. Callers where the removal isn't a
- // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere
- // and deliberately leaves emptied columns as-is.
- if (options.fixColumns !== false) {
- columnListPositions.forEach((pos) => fixColumnList(tr, pos));
+ // Repairs the containers the removed blocks lived in (e.g. collapses
+ // emptied columns/columnLists), deepest-first. Callers where the removal
+ // isn't a deletion can opt out - e.g. `moveBlocks` re-inserts the blocks
+ // elsewhere and deliberately leaves emptied containers as-is.
+ if (options.fixContainers !== false) {
+ fixContainersById(tr, containersToFix);
}
// Converts the nodes created from `blocksToInsert` into full `Block`s.
diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
deleted file mode 100644
index 3097851f47..0000000000
--- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { Slice, type Node } from "prosemirror-model";
-import { type Transaction } from "prosemirror-state";
-import { ReplaceAroundStep } from "prosemirror-transform";
-
-/**
- * Checks if a `column` node is empty, i.e. if it has only a single empty
- * paragraph.
- * @param column The column to check.
- * @returns Whether the column is empty.
- */
-export function isEmptyColumn(column: Node) {
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- const blockContainer = column.firstChild;
- if (!blockContainer) {
- throw new Error("Invalid column: does not have child node.");
- }
-
- const blockContent = blockContainer.firstChild;
- if (!blockContent) {
- throw new Error("Invalid blockContainer: does not have child node.");
- }
-
- return (
- column.childCount === 1 &&
- blockContainer.childCount === 1 &&
- blockContent.type.name === "paragraph" &&
- blockContent.content.content.length === 0
- );
-}
-
-/**
- * Removes all empty `column` nodes in a `columnList`. A `column` node is empty
- * if it has only a single empty block. If, however, removing the `column`s
- * leaves the `columnList` that has fewer than two, ProseMirror will re-add
- * empty columns.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos The position just before the `columnList` node.
- */
-export function removeEmptyColumns(tr: Transaction, columnListPos: number) {
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- for (
- let columnIndex = columnList.childCount - 1;
- columnIndex >= 0;
- columnIndex--
- ) {
- const columnPos = tr.doc
- .resolve($columnListPos.pos + 1)
- .posAtIndex(columnIndex);
- const $columnPos = tr.doc.resolve(columnPos);
- const column = $columnPos.nodeAfter;
- if (!column || column.type.name !== "column") {
- throw new Error("Invalid columnPos: does not point to column node.");
- }
-
- if (isEmptyColumn(column)) {
- tr.delete(columnPos, columnPos + column.nodeSize);
- }
- }
-}
-
-/**
- * Fixes potential issues in a `columnList` node after a
- * `blockContainer`/`column` node is (re)moved from it:
- *
- * - Removes all empty `column` nodes. A `column` node is empty if it has only
- * a single empty block.
- * - If all but one `column` nodes are empty, replaces the `columnList` with
- * the content of the non-empty `column`.
- * - If all `column` nodes are empty, removes the `columnList` entirely.
- * @param tr The `Transaction` to add the changes to.
- * @param columnListPos
- * @returns The position just before the `columnList` node.
- */
-export function fixColumnList(tr: Transaction, columnListPos: number) {
- removeEmptyColumns(tr, columnListPos);
-
- const $columnListPos = tr.doc.resolve(columnListPos);
- const columnList = $columnListPos.nodeAfter;
- if (!columnList || columnList.type.name !== "columnList") {
- throw new Error(
- "Invalid columnListPos: does not point to columnList node.",
- );
- }
-
- if (columnList.childCount > 2) {
- // Do nothing if the `columnList` has more than two non-empty `column`s. In
- // the case that the `columnList` has exactly two columns, we may need to
- // still remove it, as it's possible that one or both columns are empty.
- // This is because after `removeEmptyColumns` is called, if the
- // `columnList` has fewer than two `column`s, ProseMirror will re-add empty
- // `column`s until there are two total, in order to fit the schema.
- return;
- }
-
- if (columnList.childCount < 2) {
- // Throw an error if the `columnList` has fewer than two columns. After
- // `removeEmptyColumns` is called, if the `columnList` has fewer than two
- // `column`s, ProseMirror will re-add empty `column`s until there are two
- // total, in order to fit the schema. So if there are fewer than two here,
- // either the schema, or ProseMirror's internals, must have changed.
- throw new Error("Invalid columnList: contains fewer than two children.");
- }
-
- const firstColumnBeforePos = columnListPos + 1;
- const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos);
- const firstColumn = $firstColumnBeforePos.nodeAfter;
-
- const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1;
- const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos);
- const lastColumn = $lastColumnAfterPos.nodeBefore;
-
- if (!firstColumn || !lastColumn) {
- throw new Error("Invalid columnList: does not contain children.");
- }
-
- const firstColumnEmpty = isEmptyColumn(firstColumn);
- const lastColumnEmpty = isEmptyColumn(lastColumn);
-
- if (firstColumnEmpty && lastColumnEmpty) {
- // Removes `columnList`
- tr.delete(columnListPos, columnListPos + columnList.nodeSize);
-
- return;
- }
-
- if (firstColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of last `column`.
- lastColumnAfterPos - lastColumn.nodeSize + 1,
- lastColumnAfterPos - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-
- if (lastColumnEmpty) {
- tr.step(
- new ReplaceAroundStep(
- // Replaces `columnList`.
- columnListPos,
- columnListPos + columnList.nodeSize,
- // Replaces with content of first `column`.
- firstColumnBeforePos + 1,
- firstColumnBeforePos + firstColumn.nodeSize - 1,
- // Doesn't append anything.
- Slice.empty,
- 0,
- false,
- ),
- );
-
- return;
- }
-}
diff --git a/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap
new file mode 100644
index 0000000000..29dc87ff63
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap
@@ -0,0 +1,318 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`childBlocks keyboard handling > Backspace at the start of a block after a container moves it inside 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "In callout",
+ "type": "text",
+ },
+ ],
+ "id": "c-p-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "After",
+ "type": "text",
+ },
+ ],
+ "id": "after",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "c-0",
+ "props": {
+ "flavor": "tip",
+ },
+ "type": "callout",
+ },
+]
+`;
+
+exports[`childBlocks keyboard handling > Backspace at the start of a container's first child moves it out 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Before",
+ "type": "text",
+ },
+ ],
+ "id": "before",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "First",
+ "type": "text",
+ },
+ ],
+ "id": "c-p-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Second",
+ "type": "text",
+ },
+ ],
+ "id": "c-p-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "c-0",
+ "props": {
+ "flavor": "tip",
+ },
+ "type": "callout",
+ },
+]
+`;
+
+exports[`childBlocks keyboard handling > Delete at the end of a block before a container pulls its first child out 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Before",
+ "type": "text",
+ },
+ ],
+ "id": "before",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "First",
+ "type": "text",
+ },
+ ],
+ "id": "c-p-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Second",
+ "type": "text",
+ },
+ ],
+ "id": "c-p-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "c-0",
+ "props": {
+ "flavor": "tip",
+ },
+ "type": "callout",
+ },
+]
+`;
+
+exports[`childBlocks keyboard handling > Delete at the end of a container's last child pulls the next block in 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "In callout",
+ "type": "text",
+ },
+ ],
+ "id": "c-p-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "After",
+ "type": "text",
+ },
+ ],
+ "id": "after",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "c-0",
+ "props": {
+ "flavor": "tip",
+ },
+ "type": "callout",
+ },
+]
+`;
+
+exports[`childBlocks keyboard handling > Enter on an empty last child escapes the container 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "Hello",
+ "type": "text",
+ },
+ ],
+ "id": "c-p-0",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "c-0",
+ "props": {
+ "flavor": "tip",
+ },
+ "type": "callout",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "c-p-1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "trailing",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
+
+exports[`childBlocks repair > unwraps a repair-configured container when only one non-empty child remains 1`] = `
+[
+ {
+ "children": [],
+ "content": [
+ {
+ "styles": {},
+ "text": "B",
+ "type": "text",
+ },
+ ],
+ "id": "cell-b-p",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "trailing",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts
new file mode 100644
index 0000000000..4513bcfe23
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts
@@ -0,0 +1,134 @@
+import type { Node, NodeType } from "prosemirror-model";
+
+import { isContainerNode } from "./fixContainer.js";
+
+/**
+ * Position-based helpers for navigating container blocks. These generalize
+ * what the keyboard handlers used to hard-code for the exact
+ * `columnList > column > blockContainer` shape: they recurse through
+ * arbitrarily nested containers and consult the schema's content matches
+ * instead of assuming two levels.
+ */
+
+/**
+ * Finds the deepest position inside `container` where a node of `nodeType`
+ * can be appended at the end, descending through trailing nested containers
+ * (e.g. into the last `column` of a `columnList`, which itself doesn't accept
+ * `blockContainer` children). Returns null if no level accepts the node.
+ * @param container The container node.
+ * @param containerBeforePos The position just before `container`.
+ * @param nodeType The node type to find an insertion position for.
+ */
+export function descendToLastInsertionPos(
+ container: Node,
+ containerBeforePos: number,
+ nodeType: NodeType,
+): number | null {
+ const endPos = containerBeforePos + 1 + container.content.size;
+ if (container.contentMatchAt(container.childCount).matchType(nodeType)) {
+ return endPos;
+ }
+ const lastChild = container.lastChild;
+ if (lastChild && isContainerNode(lastChild.type)) {
+ return descendToLastInsertionPos(
+ lastChild,
+ endPos - lastChild.nodeSize,
+ nodeType,
+ );
+ }
+ return null;
+}
+
+/**
+ * Mirror of `descendToLastInsertionPos`: the deepest position inside
+ * `container` where a node of `nodeType` can be prepended at the start.
+ */
+export function descendToFirstInsertionPos(
+ container: Node,
+ containerBeforePos: number,
+ nodeType: NodeType,
+): number | null {
+ const startPos = containerBeforePos + 1;
+ if (container.contentMatchAt(0).matchType(nodeType)) {
+ return startPos;
+ }
+ const firstChild = container.firstChild;
+ if (firstChild && isContainerNode(firstChild.type)) {
+ return descendToFirstInsertionPos(firstChild, startPos, nodeType);
+ }
+ return null;
+}
+
+/**
+ * Descends through leading nested containers to the first non-container child
+ * (e.g. the first `blockContainer` inside the first `column` of a
+ * `columnList`). Returns null for a container whose leading chain has no
+ * such child.
+ * @param container The container node.
+ * @param containerBeforePos The position just before `container`.
+ */
+export function getFirstLeafBlock(
+ container: Node,
+ containerBeforePos: number,
+): { node: Node; beforePos: number } | null {
+ const firstChild = container.firstChild;
+ if (!firstChild) {
+ return null;
+ }
+ const firstChildBeforePos = containerBeforePos + 1;
+ if (isContainerNode(firstChild.type)) {
+ return getFirstLeafBlock(firstChild, firstChildBeforePos);
+ }
+ return { node: firstChild, beforePos: firstChildBeforePos };
+}
+
+/**
+ * Climbs upward from a position until one is found where a node of
+ * `nodeType` may be inserted, moving to just before each enclosing container
+ * in turn (e.g. from before a first `column` — where only `column` nodes are
+ * allowed — to before the enclosing `columnList`). Returns null when an
+ * enclosing non-container parent still doesn't accept the node.
+ * @param doc The document to resolve positions in.
+ * @param pos The position to start climbing from.
+ * @param nodeType The node type to find an insertion position for.
+ */
+export function ascendToInsertablePos(
+ doc: Node,
+ pos: number,
+ nodeType: NodeType,
+): number | null {
+ for (;;) {
+ const $pos = doc.resolve(pos);
+ const parent = $pos.node();
+ if (parent.contentMatchAt($pos.index()).matchType(nodeType)) {
+ return pos;
+ }
+ if (isContainerNode(parent.type) && $pos.depth > 0) {
+ pos = $pos.before();
+ continue;
+ }
+ return null;
+ }
+}
+
+/**
+ * Collects the chain of container-node ancestors at a position (deepest
+ * first) as `{ id, depth }` entries. Containers are re-located by id when
+ * repairing, since repairs shift positions.
+ * @param doc The document to resolve the position in.
+ * @param pos A position inside the containers of interest.
+ */
+export function getAncestorContainers(
+ doc: Node,
+ pos: number,
+): { id: string; depth: number }[] {
+ const $pos = doc.resolve(pos);
+ const containers: { id: string; depth: number }[] = [];
+ for (let depth = $pos.depth; depth > 0; depth--) {
+ const ancestor = $pos.node(depth);
+ if (isContainerNode(ancestor.type) && ancestor.attrs.id) {
+ containers.push({ id: ancestor.attrs.id, depth });
+ }
+ }
+ return containers;
+}
diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts
new file mode 100644
index 0000000000..713be21404
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts
@@ -0,0 +1,46 @@
+import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { isContainerType } from "../../../schema/blocks/childBlocks.js";
+
+export type ContainerUIInfo = {
+ containerTypes: ReadonlySet;
+ draggableContainerTypes: ReadonlySet;
+ containerSelector: string | null;
+};
+
+function buildSelector(types: ReadonlySet): string | null {
+ if (types.size === 0) {
+ return null;
+ }
+ return [...types].map((type) => `[data-node-type="${type}"]`).join(",");
+}
+
+export function getContainerUIInfo(
+ editor: Pick, "schema">,
+): ContainerUIInfo {
+ const containerTypes = new Set();
+ const draggableContainerTypes = new Set();
+
+ for (const [type, spec] of Object.entries(
+ editor.schema.blockSpecs as Record<
+ string,
+ {
+ config: any;
+ implementation?: { meta?: { draggable?: boolean } };
+ }
+ >,
+ )) {
+ if (!isContainerType(spec.config)) {
+ continue;
+ }
+ containerTypes.add(type);
+ if (spec.implementation?.meta?.draggable !== false) {
+ draggableContainerTypes.add(type);
+ }
+ }
+
+ return {
+ containerTypes,
+ draggableContainerTypes,
+ containerSelector: buildSelector(containerTypes),
+ };
+}
diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts
new file mode 100644
index 0000000000..fd2ebdc3ca
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts
@@ -0,0 +1,532 @@
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+
+import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js";
+import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js";
+import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js";
+import { createBlockSpec } from "../../../schema/blocks/createSpec.js";
+
+// A vanilla (non-React) container block accepting any children, with
+// defaultChildren seeding — the callout from the container-block example.
+const Callout = createBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {
+ flavor: {
+ default: "tip",
+ values: ["tip", "info", "warning", "success"],
+ },
+ },
+ content: "none",
+ childBlocks: {
+ min: 1,
+ defaultChildren: [{ type: "paragraph" }],
+ },
+ },
+ {
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.className = "callout";
+ dom.setAttribute("data-node-type", "callout");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ },
+)();
+
+// A container that never exits on Enter (like columns).
+const LockedBox = createBlockSpec(
+ {
+ type: "lockedBox" as const,
+ propSchema: {},
+ content: "none",
+ childBlocks: { min: 1 },
+ },
+ {
+ meta: {
+ exitOnEnter: false,
+ },
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "lockedBox");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ },
+)();
+
+// A columnList-like pair: a horizontal container restricted to `gridCell`
+// children (min 2) with column-style repair, and a non-top-level cell.
+// Proves the "custom table-like structure" story is buildable.
+const Grid = createBlockSpec(
+ {
+ type: "grid" as const,
+ propSchema: {},
+ content: "none",
+ childBlocks: {
+ allowedBlocks: ["gridCell"],
+ min: 2,
+ collapseWhenEmptied: true,
+ },
+ },
+ {
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "grid");
+ dom.setAttribute("data-id", block.id);
+ dom.style.display = "flex";
+ return { dom, contentDOM: dom };
+ },
+ },
+)();
+
+const GridCell = createBlockSpec(
+ {
+ type: "gridCell" as const,
+ propSchema: {},
+ content: "none",
+ childBlocks: { topLevel: false },
+ },
+ {
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "gridCell");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ },
+)();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: Callout,
+ lockedBox: LockedBox,
+ grid: Grid,
+ gridCell: GridCell,
+ } as const,
+});
+
+let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+>;
+const div = document.createElement("div");
+
+beforeAll(() => {
+ document.body.appendChild(div);
+ editor = BlockNoteEditor.create({ schema });
+ editor.mount(div);
+});
+
+afterAll(() => {
+ editor._tiptapEditor.destroy();
+ div.remove();
+ editor = undefined as any;
+});
+
+beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "Paragraph 0" },
+ { id: "p-1", type: "paragraph", content: "Paragraph 1" },
+ ]);
+});
+
+function pressKey(key: string, keyCode: number) {
+ const view = editor._tiptapEditor.view;
+ const event = new KeyboardEvent("keydown", {
+ key,
+ code: key,
+ keyCode,
+ bubbles: true,
+ });
+ view.someProp("handleKeyDown", (f: any) => f(view, event));
+}
+
+describe("childBlocks insertion & seeding", () => {
+ it("seeds defaultChildren when inserted without children", () => {
+ editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after");
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ });
+
+ it("seeds defaultChildren when converting a block via updateBlock", () => {
+ editor.updateBlock("p-1", { type: "callout" });
+
+ const callout = editor.document[1];
+ expect(callout.type).toBe("callout");
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ });
+
+ it("accepts arbitrary block children, including nested containers", () => {
+ editor.insertBlocks(
+ [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { type: "heading", content: "In callout" },
+ {
+ type: "callout",
+ id: "c-1",
+ children: [{ type: "paragraph", content: "Nested" }],
+ },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ );
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children.map((child) => child.type)).toEqual([
+ "heading",
+ "callout",
+ ]);
+ expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph");
+ });
+
+ it("rejects non-allowed children for a restricted container", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [
+ {
+ type: "grid",
+ children: [
+ { type: "paragraph", content: "not a cell" },
+ { type: "paragraph", content: "not a cell" },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+
+ it("accepts allowed children for a restricted container", () => {
+ editor.insertBlocks(
+ [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ children: [{ type: "paragraph", content: "Cell A" }],
+ },
+ {
+ type: "gridCell",
+ children: [{ type: "paragraph", content: "Cell B" }],
+ },
+ ],
+ },
+ ],
+ "p-1",
+ "after",
+ );
+
+ const grid = editor.getBlock("g-0")!;
+ expect(grid.children.map((child) => child.type)).toEqual([
+ "gridCell",
+ "gridCell",
+ ]);
+ });
+
+ it("rejects inserting a topLevel: false container at the document root", () => {
+ expect(() =>
+ editor.insertBlocks(
+ [{ type: "gridCell", children: [{ type: "paragraph" }] }],
+ "p-1",
+ "after",
+ ),
+ ).toThrow();
+ });
+});
+
+describe("childBlocks keyboard handling", () => {
+ it("Enter on an empty last child escapes the container", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "Hello" },
+ { id: "c-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+ editor.setTextCursorPosition("c-p-1", "end");
+
+ pressKey("Enter", 13);
+
+ expect(editor.document).toMatchSnapshot();
+ // The empty block has moved out of the callout.
+ const callout = editor.getBlock("c-0")!;
+ expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]);
+ expect(editor.document.map((block) => block.type)).toEqual([
+ "callout",
+ "paragraph",
+ "paragraph",
+ ]);
+ });
+
+ it("Enter does not escape a container with meta.exitOnEnter: false", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "lockedBox",
+ id: "l-0",
+ children: [
+ { id: "l-p-0", type: "paragraph", content: "Hello" },
+ { id: "l-p-1", type: "paragraph", content: "" },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+ editor.setTextCursorPosition("l-p-1", "end");
+
+ pressKey("Enter", 13);
+
+ // Still exactly one top-level lockedBox followed by the trailing
+ // paragraph; the new block was created inside the container.
+ expect(editor.document.map((block) => block.type)).toEqual([
+ "lockedBox",
+ "paragraph",
+ ]);
+ expect(editor.getBlock("l-0")!.children.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it("Backspace at the start of a container's first child moves it out", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ editor.setTextCursorPosition("c-p-0", "start");
+
+ pressKey("Backspace", 8);
+
+ expect(editor.document).toMatchSnapshot();
+ // The first child has moved out, above the callout.
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-1",
+ ]);
+ expect(editor.document.map((block) => block.id)[1]).toBe("c-p-0");
+ });
+
+ it("Backspace at the start of a block after a container moves it inside", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ editor.setTextCursorPosition("after", "start");
+
+ pressKey("Backspace", 8);
+
+ expect(editor.document).toMatchSnapshot();
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-0",
+ "after",
+ ]);
+ });
+
+ it("Delete at the end of a block before a container pulls its first child out", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ editor.setTextCursorPosition("before", "end");
+
+ pressKey("Delete", 46);
+
+ expect(editor.document).toMatchSnapshot();
+ expect(editor.document.map((block) => block.id).slice(0, 2)).toEqual([
+ "before",
+ "c-p-0",
+ ]);
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-1",
+ ]);
+ });
+
+ it("Delete at the end of a container's last child pulls the next block in", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ { id: "after", type: "paragraph", content: "After" },
+ ]);
+ editor.setTextCursorPosition("c-p-0", "end");
+
+ pressKey("Delete", 46);
+
+ expect(editor.document).toMatchSnapshot();
+ expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([
+ "c-p-0",
+ "after",
+ ]);
+ });
+});
+
+describe("childBlocks repair", () => {
+ it("keeps a default container when its only child is removed (refilled)", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["c-p-0"]);
+
+ const callout = editor.getBlock("c-0")!;
+ expect(callout).toBeDefined();
+ expect(callout.children).toHaveLength(1);
+ expect(callout.children[0].type).toBe("paragraph");
+ expect(callout.children[0].content).toEqual([]);
+ });
+
+ it("unwraps a repair-configured container when only one non-empty child remains", () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "grid",
+ id: "g-0",
+ children: [
+ {
+ type: "gridCell",
+ id: "cell-a",
+ children: [{ id: "cell-a-p", type: "paragraph", content: "A" }],
+ },
+ {
+ type: "gridCell",
+ id: "cell-b",
+ children: [{ id: "cell-b-p", type: "paragraph", content: "B" }],
+ },
+ ],
+ },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+
+ editor.removeBlocks(["cell-a-p"]);
+
+ expect(editor.document).toMatchSnapshot();
+ // The grid has been unwrapped: cell B's content replaced it.
+ expect(editor.getBlock("g-0")).toBeUndefined();
+ expect(editor.document.map((block) => block.id)).toEqual([
+ "cell-b-p",
+ "trailing",
+ ]);
+ });
+});
+
+describe("childBlocks selection & conversion", () => {
+ it("getSelectionCutBlocks handles selections reaching into a container", () => {
+ editor.replaceBlocks(editor.document, [
+ { id: "before", type: "paragraph", content: "Before" },
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "First" },
+ { id: "c-p-1", type: "paragraph", content: "Second" },
+ ],
+ },
+ ]);
+ editor.setSelection("before", "c-p-0");
+
+ // Previously threw "unexpected" for any partial selection touching a
+ // container (breaking comments/AI selection handling).
+ const result = editor.getSelectionCutBlocks();
+ expect(result.blocks.length).toBeGreaterThanOrEqual(1);
+ expect(result.blocks.map((block) => block.id)).toContain("before");
+ });
+
+ it("round-trips a container through full (internal) HTML", async () => {
+ const blocks = [
+ {
+ type: "callout" as const,
+ id: "c-0",
+ props: { flavor: "warning" as const },
+ children: [
+ { id: "c-p-0", type: "paragraph" as const, content: "In callout" },
+ ],
+ },
+ ];
+ editor.replaceBlocks(editor.document, blocks);
+
+ const html = editor.blocksToFullHTML(editor.document);
+ expect(html).toContain('data-node-type="callout"');
+
+ const parsed = editor.tryParseHTMLToBlocks(html);
+ expect(parsed[0].type).toBe("callout");
+ expect((parsed[0].props as any).flavor).toBe("warning");
+ expect(parsed[0].children).toHaveLength(1);
+ expect(parsed[0].children[0].type).toBe("paragraph");
+ });
+
+ it("exports containers to external HTML with type + prop attributes", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ props: { flavor: "warning" },
+ children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }],
+ },
+ ]);
+
+ const html = editor.blocksToHTMLLossy(editor.document);
+ expect(html).toContain('data-node-type="callout"');
+ expect(html).toContain('data-flavor="warning"');
+ // Container output is not wrapped in a blockContent div.
+ expect(html).not.toContain("bn-block-content");
+ });
+
+ it("flattens containers to their children in markdown export", async () => {
+ editor.replaceBlocks(editor.document, [
+ {
+ type: "callout",
+ id: "c-0",
+ children: [
+ { id: "c-p-0", type: "paragraph", content: "In callout" },
+ { id: "c-p-1", type: "heading", content: "Heading in callout" },
+ ],
+ },
+ ]);
+
+ const markdown = editor.blocksToMarkdownLossy(editor.document);
+ expect(markdown).toContain("In callout");
+ expect(markdown).toContain("# Heading in callout");
+ });
+});
diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts
new file mode 100644
index 0000000000..2a1e273de9
--- /dev/null
+++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts
@@ -0,0 +1,194 @@
+import { Fragment, Slice, type Node, type NodeType } from "prosemirror-model";
+import { type Transaction } from "prosemirror-state";
+import { ReplaceAroundStep } from "prosemirror-transform";
+import type { Schema } from "prosemirror-model";
+
+import {
+ getChildBlocksConfig,
+ getMinChildren,
+} from "../../../schema/blocks/childBlocks.js";
+import { getNodeById } from "../../nodeUtil.js";
+import { getBlockSchema, getPmSchema } from "../../pmUtil.js";
+
+export function isContainerNode(type: NodeType): boolean {
+ return type.isInGroup("childContainer") && type.name !== "blockGroup";
+}
+
+/**
+ * A container child is "empty" when it contributes no meaningful content:
+ * a blockContainer with just an empty paragraph, or a nested container
+ * whose only child is itself empty (recursively).
+ *
+ * Multi-child nodes are never empty — collapsing user-built structure would
+ * be destructive.
+ */
+export function isEmptyContainerChild(node: Node): boolean {
+ if (node.type.name === "blockContainer") {
+ const blockContent = node.firstChild;
+ return (
+ node.childCount === 1 &&
+ !!blockContent &&
+ blockContent.type.name === "paragraph" &&
+ blockContent.childCount === 0
+ );
+ }
+ if (isContainerNode(node.type)) {
+ return node.childCount === 1 && isEmptyContainerChild(node.firstChild!);
+ }
+ return false;
+}
+
+export function removeEmptyChildren(tr: Transaction, containerPos: number) {
+ const container = tr.doc.resolve(containerPos).nodeAfter;
+ if (!container || !isContainerNode(container.type)) {
+ throw new Error(
+ "Invalid containerPos: does not point to a container node.",
+ );
+ }
+
+ for (
+ let childIndex = container.childCount - 1;
+ childIndex >= 0;
+ childIndex--
+ ) {
+ const childPos = tr.doc.resolve(containerPos + 1).posAtIndex(childIndex);
+ const child = tr.doc.resolve(childPos).nodeAfter;
+ if (!child) {
+ throw new Error("Invalid childPos: does not point to a child node.");
+ }
+
+ if (isEmptyContainerChild(child)) {
+ tr.delete(childPos, childPos + child.nodeSize);
+ }
+ }
+}
+
+function isInsertableChild(node: Node): boolean {
+ return (
+ node.type.name === "blockContainer" ||
+ node.type.isInGroup("blockGroupChild")
+ );
+}
+
+/**
+ * Repairs a container after children were removed, per `collapseWhenEmptied`:
+ * drops empty children, then unwraps the container if fewer than `min`
+ * non-empty children remain.
+ */
+export function fixContainer(tr: Transaction, containerPos: number) {
+ const container = tr.doc.resolve(containerPos).nodeAfter;
+ if (!container || !isContainerNode(container.type)) {
+ throw new Error(
+ "Invalid containerPos: does not point to a container node.",
+ );
+ }
+
+ const blockConfig = getBlockSchema(getPmSchema(tr))[container.type.name];
+ const config = blockConfig ? getChildBlocksConfig(blockConfig) : undefined;
+
+ if (!config?.collapseWhenEmptied) {
+ return;
+ }
+
+ removeEmptyChildren(tr, containerPos);
+
+ const refreshed = tr.doc.resolve(containerPos).nodeAfter;
+ if (!refreshed || refreshed.type !== container.type) {
+ return;
+ }
+
+ const min = getMinChildren(config);
+
+ const nonEmptyChildren: { child: Node; offset: number }[] = [];
+ refreshed.forEach((child, offset) => {
+ if (!isEmptyContainerChild(child)) {
+ nonEmptyChildren.push({ child, offset });
+ }
+ });
+
+ if (nonEmptyChildren.length >= min) {
+ return;
+ }
+
+ if (nonEmptyChildren.length === 0) {
+ tr.delete(containerPos, containerPos + refreshed.nodeSize);
+ return;
+ }
+
+ // Unwrap: replace the container with its remaining non-empty children.
+ if (nonEmptyChildren.length === 1) {
+ const { child, offset } = nonEmptyChildren[0];
+ const childStart = containerPos + 1 + offset;
+
+ const [gapFrom, gapTo] = isInsertableChild(child)
+ ? [childStart, childStart + child.nodeSize]
+ : [childStart + 1, childStart + child.nodeSize - 1];
+
+ tr.step(
+ new ReplaceAroundStep(
+ containerPos,
+ containerPos + refreshed.nodeSize,
+ gapFrom,
+ gapTo,
+ Slice.empty,
+ 0,
+ false,
+ ),
+ );
+ return;
+ }
+
+ // Several survivors but still below `min`: rebuild replacement content.
+ const replacement: Node[] = [];
+ for (const { child } of nonEmptyChildren) {
+ if (isInsertableChild(child)) {
+ replacement.push(child);
+ } else {
+ child.forEach((grandChild) => replacement.push(grandChild));
+ }
+ }
+ tr.replaceWith(
+ containerPos,
+ containerPos + refreshed.nodeSize,
+ Fragment.from(replacement),
+ );
+}
+
+/**
+ * Runs `fixContainer` on containers by id, deepest first.
+ * Re-locates each container before repair since positions shift.
+ */
+export function fixContainersById(
+ tr: Transaction,
+ containers: { id: string; depth: number }[],
+) {
+ [...containers]
+ .sort((a, b) => b.depth - a.depth)
+ .forEach(({ id }) => {
+ const target = getNodeById(id, tr.doc);
+ if (!target) {
+ return;
+ }
+ fixContainer(tr, target.posBeforeNode);
+ });
+}
+
+/**
+ * Flattens blocks that can't be inserted at the top level (e.g. `column`)
+ * into their children, recursively.
+ */
+export function flattenNonInsertableBlocks<
+ T extends { type?: string; children?: T[] },
+>(blocks: T[], pmSchema: Schema): T[] {
+ return blocks.flatMap((block) => {
+ const nodeType = block.type ? pmSchema.nodes[block.type] : undefined;
+ if (
+ nodeType &&
+ nodeType.isInGroup("bnBlock") &&
+ !nodeType.isInGroup("blockGroupChild")
+ ) {
+ return flattenNonInsertableBlocks(block.children ?? [], pmSchema);
+ }
+ return [block];
+ });
+}
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
index e2274140f7..d7f0bdbf2c 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts
@@ -5,10 +5,12 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
BlockImplementation,
BlockSchema,
+ fillContainerAttributes,
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
+import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js";
import {
inlineContentToNodes,
tableContentToNodes,
@@ -270,6 +272,20 @@ function serializeBlock<
}
elementFragment.append(...Array.from(ret.dom.childNodes));
} else {
+ const blockNodeType = editor.pmSchema.nodes[block.type as any];
+ if (blockNodeType && isContainerNode(blockNodeType)) {
+ // Container blocks own their outer DOM. Make sure the attributes
+ // needed to parse the HTML back (the type marker and non-default
+ // props, in the same `data-*` convention `propsToAttributes` reads)
+ // are present even when the block's render didn't add them.
+ // Author-set attributes win.
+ fillContainerAttributes(
+ ret.dom as HTMLElement,
+ block.type!,
+ props,
+ editor.schema.blockSchema[block.type as any].propSchema,
+ );
+ }
elementFragment.append(ret.dom);
if (nestingLevel > 0) {
(ret.dom as HTMLElement).setAttribute(
diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
index 0f890b77ab..ba98b8206d 100644
--- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
+++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts
@@ -4,10 +4,12 @@ import { PartialBlock } from "../../../../blocks/defaultBlocks.js";
import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js";
import {
BlockSchema,
+ fillContainerAttributes,
InlineContentSchema,
StyleSchema,
} from "../../../../schema/index.js";
import { UnreachableCaseError } from "../../../../util/typescript.js";
+import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js";
import {
inlineContentToNodes,
tableContentToNodes,
@@ -172,7 +174,18 @@ function serializeBlock<
const pmType = editor.pmSchema.nodes[block.type as any];
- if (pmType.isInGroup("bnBlock")) {
+ if (isContainerNode(pmType)) {
+ // Container blocks own their outer DOM. Internal HTML must round-trip
+ // losslessly, so make sure the attributes the generated parse rules read
+ // (the type marker and non-default props as `data-*`) are present even
+ // when the block's render didn't add them. Author-set attributes win.
+ fillContainerAttributes(
+ ret.dom as HTMLElement,
+ block.type!,
+ props,
+ editor.schema.blockSchema[block.type as any].propSchema,
+ );
+
if (block.children && block.children.length > 0) {
const fragment = serializeBlocks(
editor,
diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts
index 61bc44d68a..9ea73a1594 100644
--- a/packages/core/src/api/nodeConversions/blockToNode.ts
+++ b/packages/core/src/api/nodeConversions/blockToNode.ts
@@ -16,10 +16,19 @@ import {
isPartialLinkInlineContent,
isStyledTextInlineContent,
} from "../../schema/inlineContent/types.js";
+import {
+ getChildBlocksConfig,
+ getMinChildren,
+} from "../../schema/blocks/childBlocks.js";
+import { isContainerNode } from "../blockManipulation/containers/fixContainer.js";
import { getColspan, isPartialTableCell } from "../../util/table.js";
import { UnreachableCaseError } from "../../util/typescript.js";
import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js";
-import { getStyleSchema, isPlainContentNodeType } from "../pmUtil.js";
+import {
+ getBlockSchema,
+ getStyleSchema,
+ isPlainContentNodeType,
+} from "../pmUtil.js";
/**
* Convert a StyledText inline element to a
@@ -330,6 +339,57 @@ function blockOrInlineContentToContentNode(
return contentNode;
}
+const EMPTY_SEEDING: ReadonlySet = new Set();
+
+/**
+ * Produces child nodes from a container's `defaultChildren` config, guarding
+ * against self-referential cycles. Returns `undefined` when seeding is not
+ * needed (no defaults, `min: 0`, or the caller already supplied children).
+ */
+function seedDefaultChildren(
+ blockType: string,
+ schema: Schema,
+ styleSchema: StyleSchema,
+ seedingTypes: ReadonlySet,
+): Node[] | undefined {
+ const blockSchemaConfig = getBlockSchema(schema)[blockType];
+ const childBlocksConfig = blockSchemaConfig
+ ? getChildBlocksConfig(blockSchemaConfig)
+ : undefined;
+
+ if (!childBlocksConfig) {
+ return undefined;
+ }
+
+ const defaultChildren = childBlocksConfig.defaultChildren;
+ if (!defaultChildren || defaultChildren.length === 0) {
+ return undefined;
+ }
+
+ // A `min: 0` container is allowed to be empty — don't re-seed it on
+ // round-trip (`nodeToBlock` emits `children: []` for childless containers).
+ if (getMinChildren(childBlocksConfig) === 0) {
+ return undefined;
+ }
+
+ if (seedingTypes.has(blockType)) {
+ throw new Error(
+ `Seeding "${blockType}" ends up seeding it again (${[...seedingTypes, blockType].join(" -> ")}). ` +
+ "Give the cyclic default explicit children, or remove the self-reference.",
+ );
+ }
+
+ const nextSeeding = new Set(seedingTypes).add(blockType);
+ return defaultChildren.map((child) =>
+ blockToNode(
+ child as PartialBlock,
+ schema,
+ styleSchema,
+ nextSeeding,
+ ),
+ );
+}
+
/**
* Converts a BlockNote block to a Prosemirror node.
*/
@@ -337,6 +397,7 @@ export function blockToNode(
block: PartialBlock,
schema: Schema,
styleSchema: StyleSchema = getStyleSchema(schema),
+ seedingTypes: ReadonlySet = EMPTY_SEEDING,
) {
let id = block.id;
@@ -348,7 +409,7 @@ export function blockToNode(
if (block.children) {
for (const child of block.children) {
- children.push(blockToNode(child, schema, styleSchema));
+ children.push(blockToNode(child, schema, styleSchema, seedingTypes));
}
}
@@ -357,8 +418,6 @@ export function blockToNode(
schema.nodes[block.type].isInGroup("blockContent");
if (isBlockContent) {
- // Blocks with a type that matches "blockContent" group always need to be wrapped in a blockContainer
-
const contentNode = blockOrInlineContentToContentNode(
block,
schema,
@@ -377,7 +436,12 @@ export function blockToNode(
},
groupNode ? [contentNode, groupNode] : contentNode,
);
- } else if (schema.nodes[block.type].isInGroup("bnBlock")) {
+ } else if (isContainerNode(schema.nodes[block.type])) {
+ const effectiveChildren =
+ children.length === 0
+ ? seedDefaultChildren(block.type, schema, styleSchema, seedingTypes)
+ : undefined;
+
// `create` (not `createChecked`) so partial container blocks pass through;
// callers that mutate the doc validate via `node.check()` before inserting.
return schema.nodes[block.type].create(
@@ -385,7 +449,7 @@ export function blockToNode(
id: id,
...block.props,
},
- children,
+ effectiveChildren ?? children,
);
} else {
throw new Error(
diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
index 19f063d8bb..40085fe417 100644
--- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
+++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts
@@ -1,12 +1,41 @@
-import { Fragment } from "@tiptap/pm/model";
+import { Fragment, Node } from "@tiptap/pm/model";
import {
BlockNoDefaults,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
+import {
+ getChildBlocksConfig,
+ getMinChildren,
+ isTopLevelContainer,
+} from "../../schema/blocks/childBlocks.js";
+import { isContainerNode } from "../blockManipulation/containers/fixContainer.js";
+import { getBlockSchema } from "../pmUtil.js";
import { nodeToBlock } from "./nodeToBlock.js";
+/**
+ * Whether a container node is "self-contained" — it has enough children to
+ * stand on its own and is allowed at the top level. Containers that aren't
+ * (e.g. a single selected column of a columnList) are flattened into their
+ * children.
+ */
+function isSelfContainedContainer(node: Node): boolean {
+ if (!isContainerNode(node.type)) {
+ return false;
+ }
+ const childBlocks = getChildBlocksConfig(
+ getBlockSchema(node.type.schema)[node.type.name] ?? {},
+ );
+ if (!childBlocks) {
+ return false;
+ }
+ return (
+ isTopLevelContainer(childBlocks) &&
+ node.childCount >= getMinChildren(childBlocks)
+ );
+}
+
/**
* Converts all Blocks within a fragment to BlockNote blocks.
*/
@@ -15,46 +44,28 @@ export function fragmentToBlocks<
I extends InlineContentSchema,
S extends StyleSchema,
>(fragment: Fragment) {
- // first convert selection to blocknote-style blocks, and then
- // pass these to the exporter
const blocks: BlockNoDefaults[] = [];
+
+ const pushFlattened = (node: Node, root: Node) => {
+ if (isContainerNode(node.type) && !isSelfContainedContainer(node)) {
+ node.forEach((child) => pushFlattened(child, root));
+ return;
+ }
+ blocks.push(nodeToBlock(node, root));
+ };
+
fragment.descendants((node) => {
if (node.type.name === "blockContainer") {
if (node.firstChild?.type.name === "blockGroup") {
- // selection started within a block group
- // in this case the fragment starts with:
- //
- //
- //
- //
- //
- //
- //
- // instead of:
- //
- //
- //
- //
- //
- //
- //
- //
- // so we don't need to serialize this block, just descend into the children of the blockGroup
+ // Selection started within a block group — the fragment wraps the
+ // children in a blockContainer > blockGroup without a blockContent,
+ // so we descend into the blockGroup's children instead.
return true;
}
}
- if (node.type.name === "columnList" && node.childCount === 1) {
- // column lists with a single column should be flattened (not the entire column list has been selected)
- node.firstChild?.forEach((child) => {
- blocks.push(nodeToBlock(child, node));
- });
- return false;
- }
-
if (node.type.isInGroup("bnBlock")) {
- blocks.push(nodeToBlock(node, node));
- // don't descend into children, as they're already included in the block returned by nodeToBlock
+ pushFlattened(node, node);
return false;
}
return true;
diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts
index 6d3b7e23b2..67878115ce 100644
--- a/packages/core/src/api/nodeConversions/nodeToBlock.ts
+++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts
@@ -1,5 +1,6 @@
import { Mark, Node, Slice } from "@tiptap/pm/model";
import type { Block } from "../../blocks/defaultBlocks.js";
+import { isContainerNode } from "../blockManipulation/containers/fixContainer.js";
import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js";
import type {
BlockSchema,
@@ -560,7 +561,9 @@ export function prosemirrorSliceToSlicedBlocks<
blockCutAtStart: string | undefined;
blockCutAtEnd: string | undefined;
} {
- if (node.type.name !== "blockGroup") {
+ // Both `blockGroup` and container nodes (columnList, column, callout,
+ // ...) hold bnBlock children directly, so both can be processed here.
+ if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) {
throw new Error("unexpected");
}
const blocks: Block[] = [];
@@ -568,6 +571,44 @@ export function prosemirrorSliceToSlicedBlocks<
let blockCutAtEnd: string | undefined;
node.forEach((blockContainer, _offset, index) => {
+ const isFirstBlock = index === 0;
+ const isLastBlock = index === node.childCount - 1;
+
+ if (isContainerNode(blockContainer.type)) {
+ // A container child. When the slice boundary is open inside it, the
+ // selection covers part of its children — skip the container wrapper
+ // and splice in the included children (mirroring the
+ // nested-blockGroup descent below). When fully enclosed, convert it
+ // wholesale.
+ const openAtStart = isFirstBlock && openStart > 0;
+ const openAtEnd = isLastBlock && openEnd > 0;
+
+ if (openAtStart || openAtEnd) {
+ const ret = processNode(
+ blockContainer,
+ openAtStart ? Math.max(0, openStart - 1) : 0,
+ openAtEnd ? Math.max(0, openEnd - 1) : 0,
+ );
+ if (openAtStart) {
+ blockCutAtStart = ret.blockCutAtStart;
+ }
+ if (openAtEnd) {
+ blockCutAtEnd = ret.blockCutAtEnd;
+ }
+ blocks.push(...ret.blocks);
+ return;
+ }
+
+ blocks.push(
+ nodeToBlock(blockContainer, slice.content.firstChild!) as Block<
+ BSchema,
+ I,
+ S
+ >,
+ );
+ return;
+ }
+
if (blockContainer.type.name !== "blockContainer") {
throw new Error("unexpected");
}
@@ -580,9 +621,6 @@ export function prosemirrorSliceToSlicedBlocks<
);
}
- const isFirstBlock = index === 0;
- const isLastBlock = index === node.childCount - 1;
-
if (blockContainer.firstChild!.type.name === "blockGroup") {
// this is the parent where a selection starts within one of its children,
// e.g.:
diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
index 2592b25d2a..d50627c455 100644
--- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts
+++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts
@@ -36,6 +36,7 @@ import {
UniqueID,
} from "../../../extensions/tiptap-extensions/index.js";
import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js";
+import { isContainerType } from "../../../schema/blocks/childBlocks.js";
import type {
BlockNoteEditor,
BlockNoteEditorOptions,
@@ -59,7 +60,16 @@ export function getDefaultTiptapExtensions(
UniqueID.configure({
// everything from bnBlock group (nodes that represent a BlockNote block should have an id)
- types: ["blockContainer", "columnList", "column"],
+ types: [
+ "blockContainer",
+ // Container block specs whose PM node is itself in the `bnBlock` group
+ // (column, columnList, callout, etc.) — i.e. the bnBlock node IS the
+ // block, so the id lives on its attrs rather than on a wrapping
+ // blockContainer.
+ ...Object.entries(editor.schema.blockSpecs)
+ .filter(([, spec]) => isContainerType((spec as any).config))
+ .map(([type]) => type),
+ ],
setIdAttribute: options.setIdAttribute,
isWithinEditor: editor.isWithinEditor,
}),
diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts
index f42e89e6f4..2f58d32080 100644
--- a/packages/core/src/exporter/Exporter.ts
+++ b/packages/core/src/exporter/Exporter.ts
@@ -9,6 +9,7 @@ import {
StyledText,
Styles,
} from "../schema/index.js";
+import { isContainerType } from "../schema/blocks/childBlocks.js";
import type {
BlockMapping,
@@ -44,15 +45,35 @@ export abstract class Exporter<
RS,
TS,
> {
+ // Stored with erased generics: a generically-typed property would change
+ // the class's variance in B/I/S and break mapping inference at subclass
+ // construction sites (the schema param was previously inference-only).
+ private readonly blockNoteSchema: BlockNoteSchema;
+
public constructor(
- _schema: BlockNoteSchema, // only used for type inference
+ schema: BlockNoteSchema,
protected readonly mappings: {
blockMapping: BlockMapping;
inlineContentMapping: InlineContentMapping;
styleMapping: StyleMapping;
},
public readonly options: ExporterOptions,
- ) {}
+ ) {
+ this.blockNoteSchema = schema;
+ }
+
+ /**
+ * Whether a block type is a container block (declares `childBlocks`, e.g.
+ * `columnList`, `column`, or a custom callout). Container mappings own the
+ * placement of their children — exporters must not append the children
+ * after the container's own output.
+ */
+ public isContainerBlock(blockType: string): boolean {
+ const spec = (this.blockNoteSchema.blockSpecs as Record)[
+ blockType
+ ];
+ return !!spec && isContainerType(spec.config);
+ }
public async resolveFile(url: string) {
if (!this.options?.resolveFileUrl) {
@@ -92,12 +113,17 @@ export abstract class Exporter<
numberedListIndex: number,
children?: Array>,
) {
- return this.mappings.blockMapping[block.type](
- block,
- this,
- nestingLevel,
- numberedListIndex,
- children,
- );
+ const mapping = this.mappings.blockMapping[block.type];
+ if (!mapping) {
+ // Without this, a missing mapping surfaces as an opaque "is not a
+ // function" TypeError. Container blocks are called out explicitly: they
+ // have no sensible generic representation, so a mapping is required.
+ throw new Error(
+ this.isContainerBlock(block.type)
+ ? `No mapping found for container block type "${block.type}" — container blocks require an explicit block mapping that places their children.`
+ : `No mapping found for block type "${block.type}".`,
+ );
+ }
+ return mapping(block, this, nestingLevel, numberedListIndex, children);
}
}
diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts
index d53a8444cc..c6673dd7df 100644
--- a/packages/core/src/extensions/SideMenu/SideMenu.ts
+++ b/packages/core/src/extensions/SideMenu/SideMenu.ts
@@ -20,8 +20,16 @@ import {
InlineContentSchema,
StyleSchema,
} from "../../schema/index.js";
+import {
+ ContainerUIInfo,
+ getContainerUIInfo,
+} from "../../api/blockManipulation/containers/containerUI.js";
import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js";
import { dragStart, unsetDragImage } from "./dragging.js";
+import {
+ getContainerChildAtCursor,
+ hasHorizontalContainerAncestor,
+} from "./sideMenuContainerGeometry.js";
export type SideMenuState<
BSchema extends BlockSchema,
@@ -37,7 +45,8 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250;
function getBlockFromCoords(
view: EditorView,
coords: { left: number; top: number },
- adjustForColumns = true,
+ containerUIInfo: ContainerUIInfo,
+ adjustForHorizontalContainers = true,
) {
const elements = view.root.elementsFromPoint(coords.left, coords.top);
@@ -46,21 +55,32 @@ function getBlockFromCoords(
// probably a ui overlay like formatting toolbar etc
continue;
}
- if (adjustForColumns) {
- const column = element.closest("[data-node-type=columnList]");
- if (column) {
- return getBlockFromCoords(
- view,
- {
- // TODO can we do better than this?
- left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself
- top: coords.top,
- },
- false,
- );
- }
+ if (
+ adjustForHorizontalContainers &&
+ containerUIInfo.containerSelector &&
+ // Inside a container with side-by-side children (e.g. a columnList),
+ // the x position must be offset — the hovered coordinates land in the
+ // side menu's own gutter, which belongs to a different child. The
+ // horizontal container can be any ancestor (the element may sit inside
+ // a vertical child of it, like a block inside a column).
+ hasHorizontalContainerAncestor(element, containerUIInfo)
+ ) {
+ return getBlockFromCoords(
+ view,
+ {
+ // TODO can we do better than this?
+ left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself
+ top: coords.top,
+ },
+ containerUIInfo,
+ false,
+ );
}
- return getDraggableBlockFromElement(element, view);
+ return getDraggableBlockFromElement(
+ element,
+ view,
+ containerUIInfo.draggableContainerTypes,
+ );
}
return undefined;
}
@@ -71,6 +91,7 @@ function getBlockFromMousePos(
y: number;
},
view: EditorView,
+ containerUIInfo: ContainerUIInfo,
): { node: HTMLElement; id: string } | undefined {
// Editor itself may have padding or other styling which affects
// size/position, so we get the boundingRect of the first child (i.e. the
@@ -94,7 +115,7 @@ function getBlockFromMousePos(
top: mousePos.y,
};
- const referenceBlock = getBlockFromCoords(view, coords);
+ const referenceBlock = getBlockFromCoords(view, coords, containerUIInfo);
if (!referenceBlock) {
// could not find the reference block
@@ -109,15 +130,26 @@ function getBlockFromMousePos(
* ```
* Hovering at position x (left edge of BlockB) would return BlockA.
* Instead, we check at position y (right edge of BlockA) to correctly identify BlockB.
+ * `elementsFromPoint` returns the deepest element at a point, so this single
+ * probe descends through any depth of regular nesting.
+ *
+ * When the reference block is a (draggable) container block, the probe is
+ * aimed at the direct child under the cursor instead of the container
+ * itself — the container's own padding can exceed the probe inset, which
+ * would keep resolving the container even though the cursor is aligned with
+ * one of its children (making the child's menu jump away as the cursor
+ * moves towards it).
*/
- const referenceBlocksBoundingBox =
- referenceBlock.node.getBoundingClientRect();
+ const probeTarget =
+ getContainerChildAtCursor(referenceBlock.node, mousePos, containerUIInfo) ??
+ referenceBlock.node;
return getBlockFromCoords(
view,
{
- left: referenceBlocksBoundingBox.right - 10,
+ left: probeTarget.getBoundingClientRect().right - 10,
top: mousePos.y,
},
+ containerUIInfo,
false,
);
}
@@ -214,7 +246,11 @@ export class SideMenuView<
return;
}
- const block = getBlockFromMousePos(this.mousePos, this.pmView);
+ const block = getBlockFromMousePos(
+ this.mousePos,
+ this.pmView,
+ getContainerUIInfo(this.editor),
+ );
// Closes the menu if the mouse cursor is beyond the editor vertically.
if (!block || !this.editor.isEditable) {
@@ -240,7 +276,15 @@ export class SideMenuView<
// Shows or updates elements.
if (this.editor.isEditable) {
const blockContentBoundingBox = block.node.getBoundingClientRect();
- const column = block.node.closest("[data-node-type=column]");
+ // The closest container ancestor (a column, callout, ...) — excluding
+ // the hovered block itself, which may be a draggable container. Blocks
+ // inside a container anchor the side menu to the container's block
+ // area rather than the editor's left edge, which would put the menu
+ // over unrelated content (or off-screen inside columns).
+ const containerUIInfo = getContainerUIInfo(this.editor);
+ const container = containerUIInfo.containerSelector
+ ? block.node.parentElement?.closest(containerUIInfo.containerSelector)
+ : undefined;
const sideMenuBlock = this.editor.getBlock(
this.hoveredBlock!.getAttribute("data-id")!,
);
@@ -255,12 +299,16 @@ export class SideMenuView<
this.state = {
show: true,
referencePos: new DOMRect(
- column
- ? // We take the first child as column elements have some default
- // padding. This is a little weird since this child element will
- // be the first block, but since it's always non-nested and we
- // only take the x coordinate, it's ok.
- column.firstElementChild!.getBoundingClientRect().x
+ container
+ ? // We anchor to the container's first block element (rather
+ // than the container itself, which may have padding or its own
+ // chrome around the block area). This is a little weird since
+ // this element is the first block, but since it's always
+ // non-nested and we only take the x coordinate, it's ok.
+ (
+ container.querySelector('[data-node-type="blockOuter"]') ??
+ container.firstElementChild!
+ ).getBoundingClientRect().x
: (
this.pmView.dom.firstChild as HTMLElement
).getBoundingClientRect().x,
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts
new file mode 100644
index 0000000000..d5da72e4b2
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js";
+import {
+ getContainerChildAtCursor,
+ getDirectChildBlocks,
+ hasHorizontalContainerAncestor,
+ isHorizontalContainer,
+} from "./sideMenuContainerGeometry.js";
+
+// jsdom does no layout, so `getBoundingClientRect` returns all-zero rects.
+// These helpers are pure geometry over the DOM, so we build detached trees and
+// stub each element's rect to simulate a laid-out editor.
+
+type Rect = { top: number; bottom: number; left: number; right: number };
+
+function setRect(el: Element, rect: Rect) {
+ const full = {
+ ...rect,
+ x: rect.left,
+ y: rect.top,
+ width: rect.right - rect.left,
+ height: rect.bottom - rect.top,
+ };
+ (el as HTMLElement).getBoundingClientRect = () =>
+ ({ ...full, toJSON: () => full }) as DOMRect;
+}
+
+/** Creates a `[data-node-type]` element with an optional stubbed rect. */
+function el(nodeType: string, rect?: Rect): HTMLElement {
+ const node = document.createElement("div");
+ node.setAttribute("data-node-type", nodeType);
+ if (rect) {
+ setRect(node, rect);
+ }
+ return node;
+}
+
+/** Wraps a block content node in the `blockOuter > blockContainer` chrome that
+ * BlockNote renders around every regular block, returning the outer wrapper. */
+function regularChild(rect?: Rect): {
+ outer: HTMLElement;
+ blockContainer: HTMLElement;
+} {
+ const outer = el("blockOuter");
+ const blockContainer = el("blockContainer", rect);
+ outer.append(blockContainer);
+ return { outer, blockContainer };
+}
+
+function uiInfo(containerTypes: string[]): ContainerUIInfo {
+ const set = new Set(containerTypes);
+ return {
+ containerTypes: set,
+ draggableContainerTypes: set,
+ containerSelector: containerTypes.length
+ ? containerTypes.map((t) => `[data-node-type="${t}"]`).join(",")
+ : null,
+ };
+}
+
+/**
+ * Builds a columnList with two columns laid out side-by-side, each holding a
+ * single regular block. Returns the pieces so tests can probe them.
+ */
+function buildColumnList() {
+ const info = uiInfo(["columnList", "column"]);
+
+ const columnList = el("columnList", {
+ top: 0,
+ bottom: 100,
+ left: 0,
+ right: 200,
+ });
+ const columnA = el("column", { top: 0, bottom: 100, left: 0, right: 100 });
+ const columnB = el("column", { top: 0, bottom: 100, left: 100, right: 200 });
+
+ const childA = regularChild({ top: 0, bottom: 40, left: 0, right: 100 });
+ const childB = regularChild({ top: 0, bottom: 40, left: 100, right: 200 });
+ columnA.append(childA.outer);
+ columnB.append(childB.outer);
+ columnList.append(columnA, columnB);
+
+ return { info, columnList, columnA, columnB, childA, childB };
+}
+
+/**
+ * Builds a vertical container (callout-like) holding two stacked regular
+ * blocks.
+ */
+function buildVerticalContainer() {
+ const info = uiInfo(["callout"]);
+
+ const callout = el("callout", { top: 0, bottom: 80, left: 0, right: 200 });
+ const first = regularChild({ top: 0, bottom: 40, left: 0, right: 200 });
+ const second = regularChild({ top: 40, bottom: 80, left: 0, right: 200 });
+ callout.append(first.outer, second.outer);
+
+ return { info, callout, first, second };
+}
+
+describe("getDirectChildBlocks", () => {
+ it("returns direct child blocks, skipping nested grandchildren", () => {
+ const { info, columnList, columnA, columnB } = buildColumnList();
+
+ const children = getDirectChildBlocks(columnList, info);
+
+ expect(children).toEqual([columnA, columnB]);
+ });
+
+ it("sees through blockOuter wrappers to the blockContainer child", () => {
+ const { info, columnA, childA } = buildColumnList();
+
+ // The column's own direct child is the wrapped blockContainer, not the
+ // blockOuter chrome.
+ expect(getDirectChildBlocks(columnA, info)).toEqual([
+ childA.blockContainer,
+ ]);
+ });
+
+ it("returns nothing for a container with no block children", () => {
+ const info = uiInfo(["callout"]);
+ const empty = el("callout");
+
+ expect(getDirectChildBlocks(empty, info)).toEqual([]);
+ });
+});
+
+describe("isHorizontalContainer", () => {
+ it("is true when direct children overlap vertically (side-by-side)", () => {
+ const { info, columnList } = buildColumnList();
+
+ expect(isHorizontalContainer(columnList, info)).toBe(true);
+ });
+
+ it("is false when direct children are stacked", () => {
+ const { info, callout } = buildVerticalContainer();
+
+ expect(isHorizontalContainer(callout, info)).toBe(false);
+ });
+
+ it("is false for a single child", () => {
+ const { info, columnA } = buildColumnList();
+
+ expect(isHorizontalContainer(columnA, info)).toBe(false);
+ });
+
+ it("treats abutting (non-overlapping) children as vertical", () => {
+ // Second child's top exactly meets the first child's bottom — a stack
+ // with no gap must not be misread as horizontal.
+ const info = uiInfo(["callout"]);
+ const callout = el("callout");
+ const a = regularChild({ top: 0, bottom: 40, left: 0, right: 200 });
+ const b = regularChild({ top: 40, bottom: 80, left: 0, right: 200 });
+ callout.append(a.outer, b.outer);
+
+ expect(isHorizontalContainer(callout, info)).toBe(false);
+ });
+});
+
+describe("hasHorizontalContainerAncestor", () => {
+ it("is true for a block nested inside a column of a columnList", () => {
+ const { info, childA } = buildColumnList();
+
+ // The block sits inside a (vertical) column, whose parent columnList is
+ // the horizontal one — the walk must climb past the column.
+ expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe(
+ true,
+ );
+ });
+
+ it("is false for a block inside a purely vertical container", () => {
+ const { info, first } = buildVerticalContainer();
+
+ expect(hasHorizontalContainerAncestor(first.blockContainer, info)).toBe(
+ false,
+ );
+ });
+
+ it("is false when there is no container ancestor", () => {
+ const info = uiInfo(["columnList", "column"]);
+ const loose = regularChild();
+
+ expect(hasHorizontalContainerAncestor(loose.blockContainer, info)).toBe(
+ false,
+ );
+ });
+
+ it("is false when the schema declares no containers", () => {
+ const { childA } = buildColumnList();
+ const info = uiInfo([]);
+
+ expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe(
+ false,
+ );
+ });
+});
+
+describe("getContainerChildAtCursor", () => {
+ it("returns undefined for a non-container element", () => {
+ const { info, childA } = buildColumnList();
+
+ expect(
+ getContainerChildAtCursor(childA.blockContainer, { x: 10, y: 10 }, info),
+ ).toBeUndefined();
+ });
+
+ it("returns the child whose x range contains the cursor (horizontal)", () => {
+ const { info, columnList, columnB } = buildColumnList();
+
+ // x=150 lands in the second column's horizontal range.
+ expect(getContainerChildAtCursor(columnList, { x: 150, y: 50 }, info)).toBe(
+ columnB,
+ );
+ });
+
+ it("prefers the x match over the first vertical match", () => {
+ const { info, columnList, columnA } = buildColumnList();
+
+ // x=10 is within column A; both columns share the y range.
+ expect(getContainerChildAtCursor(columnList, { x: 10, y: 50 }, info)).toBe(
+ columnA,
+ );
+ });
+
+ it("falls back to the first vertical match when x is in the gutter", () => {
+ const { info, callout, first } = buildVerticalContainer();
+
+ // A vertical container: the cursor y is in the first child's band but x
+ // is left of it (the side-menu gutter). The first vertical match wins.
+ expect(getContainerChildAtCursor(callout, { x: -20, y: 20 }, info)).toBe(
+ first.blockContainer,
+ );
+ });
+
+ it("returns undefined when the cursor is below all children", () => {
+ const { info, callout } = buildVerticalContainer();
+
+ expect(
+ getContainerChildAtCursor(callout, { x: 10, y: 999 }, info),
+ ).toBeUndefined();
+ });
+});
diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts
new file mode 100644
index 0000000000..8ff8a69278
--- /dev/null
+++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts
@@ -0,0 +1,121 @@
+import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js";
+
+/**
+ * DOM hit-testing helpers the side menu uses to resolve which block a cursor
+ * is over inside container blocks. They read live layout geometry
+ * (`getBoundingClientRect`) rather than any declared layout flag, so container
+ * blocks don't have to describe their own layout — a container whose children
+ * happen to sit side-by-side (like a column list) is detected as such.
+ */
+
+/**
+ * The selector matching any block that can be a direct child of a container:
+ * a regular block (`blockContainer`) or a nested container element.
+ */
+function containerChildSelector(containerUIInfo: ContainerUIInfo): string {
+ return containerUIInfo.containerSelector
+ ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}`
+ : `[data-node-type="blockContainer"]`;
+}
+
+/**
+ * The direct child block elements of a container element (in the block
+ * sense): the closest block element above each match must be the container
+ * itself. Matches `blockContainer`s (regular children) and nested container
+ * elements.
+ */
+export function getDirectChildBlocks(
+ container: Element,
+ containerUIInfo: ContainerUIInfo,
+): Element[] {
+ const childSelector = containerChildSelector(containerUIInfo);
+
+ const children: Element[] = [];
+ for (const child of container.querySelectorAll(childSelector)) {
+ if (child.parentElement?.closest(childSelector) === container) {
+ children.push(child);
+ }
+ }
+ return children;
+}
+
+/**
+ * Whether a container element lays its direct child blocks out side-by-side
+ * (like a column list), detected from geometry rather than a declared flag:
+ * two direct children that overlap along the vertical axis can only be
+ * sitting next to each other horizontally.
+ */
+export function isHorizontalContainer(
+ container: Element,
+ containerUIInfo: ContainerUIInfo,
+): boolean {
+ const rects = getDirectChildBlocks(container, containerUIInfo).map((child) =>
+ child.getBoundingClientRect(),
+ );
+ for (let i = 0; i < rects.length; i++) {
+ for (let j = i + 1; j < rects.length; j++) {
+ if (rects[i].top < rects[j].bottom && rects[j].top < rects[i].bottom) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+/**
+ * Whether `element` sits inside a horizontal container at any depth (e.g. a
+ * block inside a column of a columnList — the columnList, not the column, is
+ * the horizontal one). Walks up the container ancestor chain.
+ */
+export function hasHorizontalContainerAncestor(
+ element: Element,
+ containerUIInfo: ContainerUIInfo,
+): boolean {
+ if (!containerUIInfo.containerSelector) {
+ return false;
+ }
+ let container = element.closest(containerUIInfo.containerSelector);
+ while (container) {
+ if (isHorizontalContainer(container, containerUIInfo)) {
+ return true;
+ }
+ container =
+ container.parentElement?.closest(containerUIInfo.containerSelector) ??
+ null;
+ }
+ return false;
+}
+
+/**
+ * If `element` is a container block's element, finds its direct child block
+ * whose vertical range contains the cursor. Hovering a container's own
+ * chrome (padding, a title bar, the side-menu gutter) next to a child
+ * should attach the side menu to that child — mirroring how hovering a
+ * parent block's gutter next to a nested block attaches to the nested
+ * block. The container's own menu stays reachable on rows occupied only by
+ * its chrome. When children sit side-by-side, a child containing the
+ * cursor's x position wins over the first vertical match.
+ */
+export function getContainerChildAtCursor(
+ element: Element,
+ mousePos: { x: number; y: number },
+ containerUIInfo: ContainerUIInfo,
+): Element | undefined {
+ const nodeType = element.getAttribute("data-node-type");
+ if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) {
+ return undefined;
+ }
+
+ let verticalMatch: Element | undefined = undefined;
+ for (const child of getDirectChildBlocks(element, containerUIInfo)) {
+ const rect = child.getBoundingClientRect();
+ if (mousePos.y < rect.top || mousePos.y > rect.bottom) {
+ continue;
+ }
+ if (mousePos.x >= rect.left && mousePos.x <= rect.right) {
+ return child;
+ }
+ verticalMatch = verticalMatch ?? child;
+ }
+ return verticalMatch;
+}
diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts
index abc6bd2906..4b4223ca82 100644
--- a/packages/core/src/extensions/getDraggableBlockFromElement.ts
+++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts
@@ -1,18 +1,37 @@
import { EditorView } from "prosemirror-view";
+const EMPTY_SET: ReadonlySet = new Set();
+
+/**
+ * Walks up from `element` to the closest element that can host a side-menu
+ * drag handle: a regular block (`blockContainer`) or a container block whose
+ * type is in `draggableContainerTypes` (derived from each spec's
+ * `meta.draggable`).
+ */
export function getDraggableBlockFromElement(
element: Element,
view: EditorView,
+ draggableContainerTypes: ReadonlySet = EMPTY_SET,
) {
+ const isDraggable = (el: Element) => {
+ const nodeType = el.getAttribute?.("data-node-type");
+ return (
+ nodeType === "blockContainer" ||
+ (nodeType !== null &&
+ nodeType !== undefined &&
+ draggableContainerTypes.has(nodeType))
+ );
+ };
+
while (
element &&
element.parentElement &&
element.parentElement !== view.dom &&
- element.getAttribute?.("data-node-type") !== "blockContainer"
+ !isDraggable(element)
) {
element = element.parentElement;
}
- if (element.getAttribute?.("data-node-type") !== "blockContainer") {
+ if (!isDraggable(element)) {
return undefined;
}
return { node: element as HTMLElement, id: element.getAttribute("data-id")! };
diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
index b3a0b62550..d29d3aa939 100644
--- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
+++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts
@@ -14,7 +14,16 @@ import {
nestBlock,
unnestBlock,
} from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js";
-import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js";
+import {
+ fixContainersById,
+ isContainerNode,
+} from "../../../api/blockManipulation/containers/fixContainer.js";
+import {
+ ascendToInsertablePos,
+ descendToLastInsertionPos,
+ getAncestorContainers,
+ getFirstLeafBlock,
+} from "../../../api/blockManipulation/containers/containerNav.js";
import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js";
import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js";
import {
@@ -127,8 +136,10 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the previous block is a columnList, moves the current block to
- // the end of the last column in it.
+ // If the previous block is a container (e.g. a columnList or a
+ // callout), moves the current block to its deepest trailing insertion
+ // slot — descending through nested containers, e.g. to the end of the
+ // last column.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
@@ -150,17 +161,23 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}
- if (dispatch) {
- const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1;
- const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1);
+ const insertionPos = descendToLastInsertionPos(
+ prevBlockInfo.bnBlock.node,
+ prevBlockInfo.bnBlock.beforePos,
+ state.schema.nodes["blockContainer"],
+ );
+ if (insertionPos === null) {
+ return false;
+ }
+ if (dispatch) {
tr.delete(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
);
- tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node);
+ tr.insert(insertionPos, blockInfo.bnBlock.node);
tr.setSelection(
- TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)),
+ TextSelection.near(tr.doc.resolve(insertionPos + 1)),
);
return true;
@@ -168,9 +185,11 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the block is the first in a column, moves it to the end of the
- // previous column. If there is no previous column, moves it above the
- // columnList.
+ // If the block is the first in a container (e.g. a column or a
+ // callout), moves it out: to the end of the previous sibling
+ // container if there is one (e.g. the previous column), otherwise to
+ // just before the closest enclosing boundary that accepts it (e.g.
+ // above the columnList / callout).
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
@@ -192,32 +211,55 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const parentBlock = $pos.node();
- if (parentBlock.type.name !== "column") {
+ if (!isContainerNode(parentBlock.type)) {
return false;
}
- const $blockPos = tr.doc.resolve(blockInfo.bnBlock.beforePos);
- const $columnPos = tr.doc.resolve($blockPos.before());
- const columnListPos = $columnPos.before();
+ const blockContainerType = state.schema.nodes["blockContainer"];
+ const containerBeforePos = $pos.before();
+ const $containerPos = tr.doc.resolve(containerBeforePos);
+
+ // A previous sibling inside an enclosing container (e.g. the
+ // previous column) is a target to descend into. A sibling at a
+ // regular block position is not — there the block moves out to
+ // before the container instead.
+ const prevSibling =
+ isContainerNode($containerPos.node().type) &&
+ $containerPos.nodeBefore &&
+ isContainerNode($containerPos.nodeBefore.type)
+ ? $containerPos.nodeBefore
+ : null;
+
+ const insertionPos = prevSibling
+ ? descendToLastInsertionPos(
+ prevSibling,
+ containerBeforePos - prevSibling.nodeSize,
+ blockContainerType,
+ )
+ : ascendToInsertablePos(
+ tr.doc,
+ containerBeforePos,
+ blockContainerType,
+ );
+ if (insertionPos === null) {
+ return false;
+ }
if (dispatch) {
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ blockInfo.bnBlock.beforePos,
+ );
+
tr.delete(
blockInfo.bnBlock.beforePos,
blockInfo.bnBlock.afterPos,
);
- fixColumnList(tr, columnListPos);
-
- if ($columnPos.pos === columnListPos + 1) {
- tr.insert(columnListPos, blockInfo.bnBlock.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve(columnListPos)),
- );
- } else {
- tr.insert($columnPos.pos - 1, blockInfo.bnBlock.node);
- tr.setSelection(
- TextSelection.near(tr.doc.resolve($columnPos.pos)),
- );
- }
+ tr.insert(insertionPos, blockInfo.bnBlock.node);
+ fixContainersById(tr, containersToFix);
+ tr.setSelection(
+ TextSelection.near(tr.doc.resolve(insertionPos + 1)),
+ );
}
return true;
@@ -468,8 +510,8 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the next block is a columnList, moves the first block from its
- // first column to after the current block.
+ // If the next block is a container (e.g. a columnList or a callout),
+ // moves its first leaf block out, to after the current block.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
@@ -491,18 +533,28 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}
+ const firstLeaf = getFirstLeafBlock(
+ nextBlockInfo.bnBlock.node,
+ nextBlockInfo.bnBlock.beforePos,
+ );
+ if (!firstLeaf) {
+ return false;
+ }
+
if (dispatch) {
- const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1;
- const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1);
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ firstLeaf.beforePos,
+ );
tr.delete(
- $blockBeforePos.pos,
- $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize,
+ firstLeaf.beforePos,
+ firstLeaf.beforePos + firstLeaf.node.nodeSize,
);
- fixColumnList(tr, nextBlockInfo.bnBlock.beforePos);
- tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!);
+ tr.insert(blockInfo.bnBlock.afterPos, firstLeaf.node);
+ fixContainersById(tr, containersToFix);
tr.setSelection(
- TextSelection.near(tr.doc.resolve($blockBeforePos.pos)),
+ TextSelection.near(tr.doc.resolve(firstLeaf.beforePos)),
);
return true;
@@ -510,9 +562,10 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
- // If the block is the last in a column, moves it to the start of the
- // next column. If there is no next column, moves it below the
- // columnList.
+ // If the block is the last in a container (e.g. a column or a
+ // callout), moves the next block — the first leaf of the next sibling
+ // container, or the block following the enclosing containers — to
+ // after it.
() =>
commands.command(({ state, tr, dispatch }) => {
const blockInfo = getBlockInfoFromSelection(state);
@@ -534,36 +587,49 @@ export const KeyboardShortcutsExtension = Extension.create<{
}
const parentBlock = $pos.node();
- if (parentBlock.type.name !== "column") {
+ if (!isContainerNode(parentBlock.type)) {
+ return false;
+ }
+
+ // Climbs out of the containers the block is the last child of,
+ // to the first position with a following node.
+ let $boundary = $pos;
+ while (
+ $boundary.nodeAfter === null &&
+ $boundary.depth > 0 &&
+ isContainerNode($boundary.node().type)
+ ) {
+ $boundary = tr.doc.resolve($boundary.after());
+ }
+
+ const nextNode = $boundary.nodeAfter;
+ if (!nextNode) {
return false;
}
- const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos);
- const $columnEndPos = tr.doc.resolve($blockEndPos.after());
- const columnListEndPos = $columnEndPos.after();
+ // The block to pull in: the next node itself or — when it's a
+ // container — its first leaf block.
+ const target = isContainerNode(nextNode.type)
+ ? getFirstLeafBlock(nextNode, $boundary.pos)
+ : { node: nextNode, beforePos: $boundary.pos };
+ if (!target) {
+ return false;
+ }
if (dispatch) {
- // Position before first block in next column, or first block
- // after columnList if there is no next column.
- const nextBlockBeforePos =
- $columnEndPos.pos === columnListEndPos - 1
- ? columnListEndPos
- : $columnEndPos.pos + 1;
- const nextBlockInfo = getBlockInfoFromResolvedPos(
- tr.doc.resolve(nextBlockBeforePos),
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ target.beforePos,
);
tr.delete(
- nextBlockInfo.bnBlock.beforePos,
- nextBlockInfo.bnBlock.afterPos,
- );
- fixColumnList(
- tr,
- columnListEndPos - $columnEndPos.node().nodeSize,
+ target.beforePos,
+ target.beforePos + target.node.nodeSize,
);
- tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node);
+ tr.insert(blockInfo.bnBlock.afterPos, target.node);
+ fixContainersById(tr, containersToFix);
tr.setSelection(
- TextSelection.near(tr.doc.resolve(nextBlockBeforePos)),
+ TextSelection.near(tr.doc.resolve(target.beforePos)),
);
}
@@ -839,6 +905,68 @@ export const KeyboardShortcutsExtension = Extension.create<{
return false;
}),
+ // If the block is empty and the last child of a container with
+ // `exitOnEnter` behavior (the default for containers), moves the
+ // block out to after the container — one level per press, list-style.
+ // Without this, Enter only ever creates new blocks *within* the
+ // container, so a trailing container could trap the cursor.
+ () =>
+ commands.command(({ state, tr, dispatch }) => {
+ const blockInfo = getBlockInfoFromSelection(state);
+ if (!blockInfo.isBlockContainer) {
+ return false;
+ }
+
+ const selectionEmpty =
+ state.selection.anchor === state.selection.head;
+ const blockEmpty = blockInfo.blockContent.node.childCount === 0;
+ if (!selectionEmpty || !blockEmpty) {
+ return false;
+ }
+
+ const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos);
+ const parentBlock = $pos.node();
+ if (!isContainerNode(parentBlock.type)) {
+ return false;
+ }
+
+ // Only fires on the container's last child.
+ if (tr.doc.resolve(blockInfo.bnBlock.afterPos).nodeAfter !== null) {
+ return false;
+ }
+
+ const exitOnEnter =
+ this.options.editor.schema.blockSpecs[parentBlock.type.name]
+ ?.implementation?.meta?.exitOnEnter ?? true;
+ if (!exitOnEnter) {
+ return false;
+ }
+
+ const containerAfterPos = $pos.after();
+
+ if (dispatch) {
+ const containersToFix = getAncestorContainers(
+ tr.doc,
+ blockInfo.bnBlock.beforePos,
+ );
+
+ tr.delete(
+ blockInfo.bnBlock.beforePos,
+ blockInfo.bnBlock.afterPos,
+ );
+ // The container's after-position, mapped through the deletion
+ // (and any schema-driven refill it triggered).
+ const insertionPos = tr.mapping.map(containerAfterPos);
+ tr.insert(insertionPos, blockInfo.bnBlock.node);
+ fixContainersById(tr, containersToFix);
+ tr.setSelection(
+ TextSelection.near(tr.doc.resolve(insertionPos + 1)),
+ );
+ tr.scrollIntoView();
+ }
+
+ return true;
+ }),
// Creates a new block and moves the selection to it if the current one is empty, while the selection is also
// empty & at the start of the block.
() =>
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 981ba21e48..bd7edb94f0 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,6 +1,8 @@
export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js";
export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js";
-export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js";
+export * from "./api/blockManipulation/containers/fixContainer.js";
+export * from "./api/blockManipulation/containers/containerNav.js";
+export * from "./api/blockManipulation/containers/containerUI.js";
export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js";
export * from "./api/exporters/html/externalHTMLExporter.js";
export * from "./api/exporters/html/internalHTMLSerializer.js";
diff --git a/packages/core/src/pm-nodes/README.md b/packages/core/src/pm-nodes/README.md
index be57ead212..d0f7d408f7 100644
--- a/packages/core/src/pm-nodes/README.md
+++ b/packages/core/src/pm-nodes/README.md
@@ -16,7 +16,7 @@ In the BlockNote API, recall that blocks look like this:
}
```
-`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a `column` or `columnList`.
+`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a container block (a block declaring `childBlocks`, such as a `column`, `columnList`, or a custom callout).
`content` is the block's Inline Content. Inline content doesn't have any `id`, it's "loose" content within the node.
@@ -61,41 +61,62 @@ group: "blockContent",
Blocks that are part of the `blockContent` group define the appearance / behaviour of the main element of the block (i.e.: headings, paragraphs, list items, etc.).
These are only used for "regular" blocks that are represented as `blockContainer` nodes.
-## Multi-column
+## Container blocks
-The `multi-column` package makes it possible to order blocks side by side in
-columns. It adds the `columnList` and `column` nodes to the schema.
+A block config can declare `childBlocks`, marking the block as a _container
+block_: a block that holds other blocks directly as its body. Container
+blocks emit their own ProseMirror node (built by `createSpec`'s
+`buildContainerNode`) with this shape:
+
+```typescript
+name: blockConfig.type,
+group: "bnBlock childContainer blockGroupChild", // blockGroupChild is dropped for `topLevel: false`
+// From `childBlocks.allowedBlocks`/`min`/`max`; defaults to `blockGroupChild+`.
+// Allowed regular blocks collapse to one `blockContainer` term (ordered first,
+// so ProseMirror auto-fill picks it); allowed container types appear verbatim.
+content: "blockGroupChild{min,max}",
+priority: 40, // below blockContainer (50), so `blockGroupChild` auto-fill never recurses into containers
+```
+
+Unlike regular blocks, a container block's PM node **is** the `bnBlock` — there
+is no `blockContainer` wrapper and no `blockGroup` around the children; child
+blocks sit directly inside the container node. The children are exposed as
+`block.children` in the BlockNote API.
+
+The `xl-multi-column` package's blocks are the canonical containers:
### ColumnList
```typescript
+// childBlocks: { allowedBlocks: ["column"], min: 2, collapseWhenEmptied: true }
name: "columnList",
-group: "childContainer bnBlock blockGroupChild",
-// A block always contains content, and optionally a blockGroup which contains nested blocks
-content: "column column+", // min two columns
+group: "bnBlock childContainer blockGroupChild",
+content: "column{2,}", // min two columns
```
-The column list contains 2 or more columns.
+The column list contains 2 or more columns. Its `collapseWhenEmptied` config
+makes `fixContainer` drop emptied columns and unwrap the list when fewer than
+two non-empty columns remain.
### Column
```typescript
+// childBlocks: { topLevel: false }
name: "column",
-group: "bnBlock childContainer",
-// A block always contains content, and optionally a blockGroup which contains nested blocks
-content: "blockContainer+",
+group: "bnBlock childContainer", // not blockGroupChild: only valid inside a columnList
+content: "blockGroupChild+",
```
-The column contains 1 or more block containers.
+The column contains 1 or more blocks.
# Groups
We use Prosemirror "groups" to help organize this schema. Here is a list of the different groups:
- `blockContent`: described above (contain the content for blocks that are represented as `BlockContainer` nodes)
-- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and `columnList`
-- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`, but for columns, both `columnList` and `column` are considered to be `childContainer` nodes.
-- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. Both `blockContainer`, `column` and `columnList` are part of this group.
+- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and top-level container blocks (e.g. `columnList`)
+- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`; every container block node (`columnList`, `column`, custom containers) is also a `childContainer`.
+- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. `blockContainer` and every container block node are part of this group.
_Note that the last two groups, `bnBlock` and `childContainer`, are not used anywhere in the schema. They are however helpful while programming. For example, we can check whether a node is a `bnBlock`, and then we know it corresponds to a BlockNote Block. Or, we can check whether a node is a `childContainer`, and then we know it's a container of a BlockNote Block's `children`. See `getBlockInfoFromPos` for an example of how this is used._
diff --git a/packages/core/src/schema/blocks/childBlocks.ts b/packages/core/src/schema/blocks/childBlocks.ts
new file mode 100644
index 0000000000..4f3b9a82e3
--- /dev/null
+++ b/packages/core/src/schema/blocks/childBlocks.ts
@@ -0,0 +1,95 @@
+import type { ChildBlocksConfig } from "./types.js";
+
+export const CHILD_CONTAINER_GROUP = "childContainer";
+
+export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild";
+
+// Below `blockContainer`'s priority (50): PM's `fillBefore` picks the first
+// matching type in a group, and `blockContainer` must win so auto-fill doesn't
+// recurse through nested containers.
+export const CONTAINER_NODE_PRIORITY = 40;
+
+// Shared frozen instance so `childBlocks: true` configs always compare equal.
+const EMPTY_CHILD_BLOCKS_CONFIG: ChildBlocksConfig = Object.freeze({});
+
+// Normalizes the `childBlocks: true` shorthand at read time. Downstream code
+// must use this instead of reading `config.childBlocks` directly — the user's
+// config object is never mutated, so `blockSchema[type]` identity checks
+// (e.g. `checkMultiColumnBlocksInSchema`) stay valid across schema instances.
+export function getChildBlocksConfig(config: {
+ childBlocks?: true | ChildBlocksConfig;
+}): ChildBlocksConfig | undefined {
+ if (!config.childBlocks) {
+ return undefined;
+ }
+ return config.childBlocks === true
+ ? EMPTY_CHILD_BLOCKS_CONFIG
+ : config.childBlocks;
+}
+
+export function isContainerType(config: {
+ childBlocks?: true | ChildBlocksConfig;
+}): boolean {
+ return getChildBlocksConfig(config) !== undefined;
+}
+
+export function getMinChildren(childBlocks: ChildBlocksConfig): number {
+ return childBlocks.min ?? 1;
+}
+
+export function isTopLevelContainer(childBlocks: ChildBlocksConfig): boolean {
+ return childBlocks.topLevel !== false;
+}
+
+// Builds the ProseMirror content expression for a container block from its
+// `childBlocks` config.
+//
+// `allowedBlocks` entries are BlockNote block types, but container children
+// are PM *nodes*: container-type blocks are their own node type, while every
+// regular block lives inside a `blockContainer` node. So container entries
+// are kept verbatim and regular entries collapse to a single `blockContainer`
+// term. `blockContainer` is deliberately ordered FIRST in the union — PM's
+// `fillBefore` picks the first matching type when auto-filling a non-optional
+// node, and filling with `blockContainer` (rather than another container)
+// keeps auto-fill from recursing through nested containers.
+export function childBlocksContentExpression(
+ childBlocks: ChildBlocksConfig,
+ isContainerBlockType: (blockType: string) => boolean,
+): string {
+ const min = getMinChildren(childBlocks);
+ const { max, allowedBlocks } = childBlocks;
+
+ let term: string;
+ if (!allowedBlocks) {
+ term = BLOCK_GROUP_CHILD_GROUP;
+ } else {
+ const terms: string[] = [];
+ if (allowedBlocks.some((blockType) => !isContainerBlockType(blockType))) {
+ terms.push("blockContainer");
+ }
+ terms.push(...allowedBlocks.filter(isContainerBlockType));
+
+ if (terms.length === 0) {
+ throw new Error(
+ "`childBlocks.allowedBlocks` must not be empty. Omit it to allow any block.",
+ );
+ }
+
+ term = terms.length === 1 ? terms[0] : `(${terms.join(" | ")})`;
+ }
+
+ return `${term}${childCountQuantifier(min, max)}`;
+}
+
+function childCountQuantifier(min: number, max: number | undefined): string {
+ if (max === undefined) {
+ if (min === 0) {
+ return "*";
+ }
+ if (min === 1) {
+ return "+";
+ }
+ return `{${min},}`;
+ }
+ return `{${min},${max}}`;
+}
diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts
index 5db7ff48eb..b5ba0c685c 100644
--- a/packages/core/src/schema/blocks/createSpec.ts
+++ b/packages/core/src/schema/blocks/createSpec.ts
@@ -6,6 +6,7 @@ import {
TagParseRule,
} from "@tiptap/pm/model";
import { NodeView } from "@tiptap/pm/view";
+import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js";
import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js";
import { ignoreNonContentMutations } from "../nodeViewMutations.js";
import {
@@ -13,8 +14,17 @@ import {
ExtensionFactoryInstance,
} from "../../editor/BlockNoteExtension.js";
import { nonFormattingMarks } from "../markGroups.js";
+import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js";
import { PropSchema } from "../propTypes.js";
import {
+ CONTAINER_NODE_PRIORITY,
+ childBlocksContentExpression,
+ getChildBlocksConfig,
+ isTopLevelContainer,
+} from "./childBlocks.js";
+import {
+ applyContainerAttributes,
+ applyDOMAttributes,
getBlockFromNodeView,
propsToAttributes,
wrapInBlockStructure,
@@ -28,6 +38,23 @@ import {
LooseBlockSpec,
} from "./types.js";
+/**
+ * Full-schema context that node construction needs but a single block spec
+ * can't know on its own.
+ */
+export type BlockSchemaContext = {
+ isContainerBlockType: (blockType: string) => boolean;
+};
+
+const NO_SCHEMA_CONTEXT: BlockSchemaContext = {
+ isContainerBlockType: (blockType) => {
+ throw new Error(
+ `Cannot resolve whether "${blockType}" is a container block without full schema context. ` +
+ "Blocks that use `childBlocks.allowedBlocks` must be registered through `BlockNoteSchema.create`.",
+ );
+ },
+};
+
// Function that causes events within non-selectable blocks to be handled by the
// browser instead of the editor.
export function applyNonSelectableBlockFix(nodeView: NodeView, editor: Editor) {
@@ -167,131 +194,275 @@ export function getParseRules<
return rules;
}
-// A function to create custom block for API consumers
-// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
-export function addNodeAndExtensionsToSpec<
+function buildContainerNode(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ schemaContext: BlockSchemaContext,
+) {
+ const childBlocks = getChildBlocksConfig(blockConfig)!;
+
+ const groups = ["bnBlock", "childContainer"];
+ if (isTopLevelContainer(childBlocks)) {
+ groups.push("blockGroupChild");
+ }
+
+ return Node.create({
+ name: blockConfig.type,
+ content: childBlocksContentExpression(
+ childBlocks,
+ schemaContext.isContainerBlockType,
+ ),
+ group: groups.join(" "),
+ marks() {
+ return suggestionMarks(this.editor);
+ },
+ selectable: blockImplementation.meta?.selectable ?? true,
+ isolating: blockImplementation.meta?.isolating ?? true,
+ defining: true,
+ priority: CONTAINER_NODE_PRIORITY,
+ addAttributes() {
+ return propsToAttributes(blockConfig.propSchema);
+ },
+
+ parseHTML() {
+ return [
+ {
+ tag: `[data-node-type=${blockConfig.type}]`,
+ },
+ ];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", blockConfig.type);
+ for (const [attribute, value] of Object.entries(HTMLAttributes)) {
+ dom.setAttribute(attribute, value as string);
+ }
+ return { dom, contentDOM: dom };
+ },
+
+ addNodeView() {
+ return (props) => {
+ const editor = this.options.editor;
+
+ // A container's node *is* the block — convert directly rather than
+ // walking up to a parent (which would find the enclosing block).
+ const block = nodeToBlock(props.node, props.view.state.doc);
+
+ const nodeView = blockImplementation.render.call(
+ {
+ blockContentDOMAttributes:
+ this.options.domAttributes?.blockContent || {},
+ props,
+ renderType: "nodeView",
+ propSchema: blockConfig.propSchema,
+ },
+ block as any,
+ editor as any,
+ );
+
+ applyContainerAttributes(
+ nodeView.dom,
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+
+ const typedNodeView = nodeView as unknown as NodeView;
+
+ if (blockImplementation.meta?.selectable === false) {
+ applyNonSelectableBlockFix(typedNodeView, this.editor);
+ }
+
+ ignoreNonContentMutations(typedNodeView);
+
+ // Unlike regular blocks, containers honor an `update` hook:
+ // recreating the node view would remount every child block, so a
+ // container patches its own DOM in place instead.
+ const update = typedNodeView.update?.bind(typedNodeView);
+ if (update) {
+ typedNodeView.update = (node, decorations, innerDecorations) => {
+ if (node.type.name !== blockConfig.type) {
+ return false;
+ }
+ if (update(node, decorations, innerDecorations) === false) {
+ return false;
+ }
+ applyContainerAttributes(
+ typedNodeView.dom,
+ blockConfig.type,
+ nodeToBlock(node, props.view.state.doc).props as any,
+ blockConfig.propSchema,
+ node.attrs.id,
+ );
+ return true;
+ };
+ }
+
+ return typedNodeView;
+ };
+ },
+ });
+}
+
+function buildRegularNode<
TName extends string,
TProps extends PropSchema,
TContent extends "inline" | "none" | "table" | "plain",
>(
blockConfig: BlockConfig,
blockImplementation: BlockImplementation,
- extensions?: (ExtensionFactoryInstance | Extension)[],
priority?: number,
-): LooseBlockSpec {
- const node =
- ((blockImplementation as any).node as Node) ||
- Node.create({
- name: blockConfig.type,
- content: (blockConfig.content === "inline"
- ? "inline*"
- : blockConfig.content === "plain"
- ? "text*"
- : blockConfig.content === "none"
- ? ""
- : blockConfig.content) as TContent extends "inline"
- ? "inline*"
- : TContent extends "plain"
- ? "text*"
- : "",
- // "plain" blocks hold unstyled text, so they disallow formatting marks.
- // They still allow the non-formatting marks (comments and
- // suggestions/diffs) — those annotate content without changing it and are
- // ignored by the block model. `nonFormattingMarks` resolves the group only
- // when at least one such mark is registered, so a plain block in an editor
- // without any of them doesn't reference an empty (unknown) mark group.
- marks() {
- return blockConfig.content === "plain"
- ? nonFormattingMarks(this.editor)
- : undefined;
- },
- group: "blockContent",
- selectable: blockImplementation.meta?.selectable ?? true,
- isolating: blockImplementation.meta?.isolating ?? true,
- code: blockImplementation.meta?.code ?? false,
- defining: blockImplementation.meta?.defining ?? true,
- priority,
- addAttributes() {
- return propsToAttributes(blockConfig.propSchema);
- },
+) {
+ return Node.create({
+ name: blockConfig.type,
+ content: (blockConfig.content === "inline"
+ ? "inline*"
+ : blockConfig.content === "plain"
+ ? "text*"
+ : blockConfig.content === "none"
+ ? ""
+ : blockConfig.content) as TContent extends "inline"
+ ? "inline*"
+ : TContent extends "plain"
+ ? "text*"
+ : "",
+ // "plain" blocks hold unstyled text, so they disallow formatting marks.
+ // They still allow the non-formatting marks (comments and
+ // suggestions/diffs) — those annotate content without changing it and are
+ // ignored by the block model. `nonFormattingMarks` resolves the group only
+ // when at least one such mark is registered, so a plain block in an editor
+ // without any of them doesn't reference an empty (unknown) mark group.
+ marks() {
+ return blockConfig.content === "plain"
+ ? nonFormattingMarks(this.editor)
+ : undefined;
+ },
+ group: "blockContent",
+ selectable: blockImplementation.meta?.selectable ?? true,
+ isolating: blockImplementation.meta?.isolating ?? true,
+ code: blockImplementation.meta?.code ?? false,
+ defining: blockImplementation.meta?.defining ?? true,
+ priority,
+ addAttributes() {
+ return propsToAttributes(blockConfig.propSchema);
+ },
- parseHTML() {
- return getParseRules(blockConfig, blockImplementation);
- },
+ parseHTML() {
+ return getParseRules(blockConfig, blockImplementation);
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ // renderHTML is used for copy/pasting content from the editor back into
+ // the editor, so we need to make sure the `blockContent` element is
+ // structured correctly as this is what's used for parsing blocks. We
+ // just render a placeholder div inside as the `blockContent` element
+ // already has all the information needed for proper parsing.
+ const div = document.createElement("div");
+ return wrapInBlockStructure(
+ {
+ dom: div,
+ contentDOM:
+ blockConfig.content === "inline" || blockConfig.content === "plain"
+ ? div
+ : undefined,
+ },
+ blockConfig.type,
+ {},
+ blockConfig.propSchema,
+ blockImplementation.meta?.fileBlockAccept !== undefined,
+ HTMLAttributes,
+ );
+ },
+
+ addNodeView() {
+ return (props) => {
+ // Gets the BlockNote editor instance
+ const editor = this.options.editor;
+ // Gets the block. Resolving this can't rely on `getPos()` alone —
+ // node views are constructed part-way through ProseMirror's
+ // reconciliation, where positions don't always line up with
+ // `view.state.doc` yet (see `getBlockFromNodeView`).
+ const block = getBlockFromNodeView(
+ props.getPos,
+ props.node,
+ props.view.state.doc,
+ );
+ // Gets the custom HTML attributes for `blockContent` nodes
+ const blockContentDOMAttributes =
+ this.options.domAttributes?.blockContent || {};
- renderHTML({ HTMLAttributes }) {
- // renderHTML is used for copy/pasting content from the editor back into
- // the editor, so we need to make sure the `blockContent` element is
- // structured correctly as this is what's used for parsing blocks. We
- // just render a placeholder div inside as the `blockContent` element
- // already has all the information needed for proper parsing.
- const div = document.createElement("div");
- return wrapInBlockStructure(
+ const nodeView = blockImplementation.render.call(
{
- dom: div,
- contentDOM:
- blockConfig.content === "inline" ||
- blockConfig.content === "plain"
- ? div
- : undefined,
+ blockContentDOMAttributes,
+ props,
+ renderType: "nodeView",
+ propSchema: blockConfig.propSchema,
},
- blockConfig.type,
- {},
- blockConfig.propSchema,
- blockImplementation.meta?.fileBlockAccept !== undefined,
- HTMLAttributes,
+ block as any,
+ editor as any,
);
- },
- addNodeView() {
- return (props) => {
- // Gets the BlockNote editor instance
- const editor = this.options.editor;
- // Gets the block. Resolving this can't rely on `getPos()` alone —
- // node views are constructed part-way through ProseMirror's
- // reconciliation, where positions don't always line up with
- // `view.state.doc` yet (see `getBlockFromNodeView`).
- const block = getBlockFromNodeView(
- props.getPos,
- props.node,
- props.view.state.doc,
- );
- // Gets the custom HTML attributes for `blockContent` nodes
- const blockContentDOMAttributes =
- this.options.domAttributes?.blockContent || {};
+ // Cast needed because render returns `dom: HTMLElement | DocumentFragment`
+ // but tiptap's NodeView expects `dom: HTMLElement`
+ const typedNodeView = nodeView as unknown as NodeView;
- const nodeView = blockImplementation.render.call(
- {
- blockContentDOMAttributes,
- props,
- renderType: "nodeView",
- propSchema: blockConfig.propSchema,
- },
- block as any,
- editor as any,
- );
+ if (blockImplementation.meta?.selectable === false) {
+ applyNonSelectableBlockFix(typedNodeView, this.editor);
+ }
- // Cast needed because render returns `dom: HTMLElement | DocumentFragment`
- // but tiptap's NodeView expects `dom: HTMLElement`
- const typedNodeView = nodeView as unknown as NodeView;
+ // Ignores DOM mutations that don't affect the block's content, so
+ // that browser extensions which rewrite the DOM (e.g. Dark Reader)
+ // can't trigger an infinite re-render loop that freezes the tab.
+ ignoreNonContentMutations(typedNodeView);
+
+ // See explanation for why `update` is not implemented for NodeViews
+ // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464
+ // TODO: in a future version, we might want to implement updates so that
+ // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220)
+ return typedNodeView;
+ };
+ },
+ });
+}
- if (blockImplementation.meta?.selectable === false) {
- applyNonSelectableBlockFix(typedNodeView, this.editor);
- }
+// A function to create custom block for API consumers
+// we want to hide the tiptap node from API consumers and provide a simpler API surface instead
+export function addNodeAndExtensionsToSpec<
+ TName extends string,
+ TProps extends PropSchema,
+ TContent extends "inline" | "none" | "table" | "plain",
+>(
+ blockConfig: BlockConfig,
+ blockImplementation: BlockImplementation,
+ extensions?: (ExtensionFactoryInstance | Extension)[],
+ priority?: number,
+ schemaContext: BlockSchemaContext = NO_SCHEMA_CONTEXT,
+): LooseBlockSpec {
+ const childBlocksConfig = getChildBlocksConfig(blockConfig);
- // Ignores DOM mutations that don't affect the block's content, so
- // that browser extensions which rewrite the DOM (e.g. Dark Reader)
- // can't trigger an infinite re-render loop that freezes the tab.
- ignoreNonContentMutations(typedNodeView);
-
- // See explanation for why `update` is not implemented for NodeViews
- // https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464
- // TODO: in a future version, we might want to implement updates so that
- // vanilla blocks don't always re-render entirely (https://github.com/TypeCellOS/BlockNote/issues/220)
- return typedNodeView;
- };
- },
- });
+ if (childBlocksConfig && blockConfig.content !== "none") {
+ throw new Error(
+ `Block "${blockConfig.type}" sets \`childBlocks\` but its \`content\` is "${blockConfig.content}". Container blocks must declare \`content: "none"\`.`,
+ );
+ }
+
+ const isContainer = childBlocksConfig !== undefined;
+
+ const node =
+ ((blockImplementation as any).node as Node) ||
+ (childBlocksConfig
+ ? buildContainerNode(
+ blockConfig as unknown as BlockConfig,
+ blockImplementation as unknown as BlockImplementation<
+ TName,
+ TProps,
+ "none"
+ >,
+ schemaContext,
+ )
+ : buildRegularNode(blockConfig, blockImplementation, priority));
if (node.name !== blockConfig.type) {
throw new Error(
@@ -308,7 +479,7 @@ export function addNodeAndExtensionsToSpec<
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
- return blockImplementation.render.call(
+ const output = blockImplementation.render.call(
{
blockContentDOMAttributes,
props: undefined,
@@ -318,6 +489,18 @@ export function addNodeAndExtensionsToSpec<
block as any,
editor as any,
);
+
+ if (isContainer) {
+ applyContainerAttributes(
+ output.dom,
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+ }
+
+ return output;
},
// TODO: this should not have wrapInBlockStructure and generally be a lot simpler
// post-processing in externalHTMLExporter should not be necessary
@@ -325,7 +508,7 @@ export function addNodeAndExtensionsToSpec<
const blockContentDOMAttributes =
node.options.domAttributes?.blockContent || {};
- return (
+ const output =
blockImplementation.toExternalHTML?.call(
{ blockContentDOMAttributes, propSchema: blockConfig.propSchema },
block as any,
@@ -341,8 +524,19 @@ export function addNodeAndExtensionsToSpec<
},
block as any,
editor as any,
- )
- );
+ );
+
+ if (output && isContainer) {
+ applyContainerAttributes(
+ output.dom,
+ blockConfig.type,
+ block.props as any,
+ blockConfig.propSchema,
+ block.id,
+ );
+ }
+
+ return output;
},
},
extensions,
@@ -453,6 +647,8 @@ export function createBlockSpec<
: extensionsOrCreator
: undefined;
+ const isContainer = getChildBlocksConfig(blockConfig) !== undefined;
+
return {
config: blockConfig,
implementation: {
@@ -471,6 +667,11 @@ export function createBlockSpec<
return undefined;
}
+ if (isContainer) {
+ applyDOMAttributes(output.dom, this.blockContentDOMAttributes);
+ return output;
+ }
+
return wrapInBlockStructure(
output,
block.type,
@@ -490,6 +691,11 @@ export function createBlockSpec<
editor as any,
);
+ if (isContainer) {
+ applyDOMAttributes(output.dom, this.blockContentDOMAttributes);
+ return output;
+ }
+
const nodeView = wrapInBlockStructure(
output,
block.type,
diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts
index cfd17b9d11..1b7dc4afda 100644
--- a/packages/core/src/schema/blocks/internal.ts
+++ b/packages/core/src/schema/blocks/internal.ts
@@ -6,7 +6,19 @@ import type { ExtensionFactoryInstance } from "../../editor/BlockNoteExtension.j
import { mergeCSSClasses } from "../../util/browser.js";
import { camelToDataKebab } from "../../util/string.js";
import { PropSchema, Props } from "../propTypes.js";
-import { LooseBlockSpec } from "./types.js";
+import { ChildBlocksConfig, LooseBlockSpec } from "./types.js";
+
+// Re-export child-block helpers so existing callers of `internal.js` keep working.
+export {
+ getChildBlocksConfig,
+ isContainerType,
+ childBlocksContentExpression,
+ getMinChildren,
+ isTopLevelContainer,
+ CHILD_CONTAINER_GROUP,
+ BLOCK_GROUP_CHILD_GROUP,
+ CONTAINER_NODE_PRIORITY,
+} from "./childBlocks.js";
// Function that uses the 'propSchema' of a blockConfig to create a TipTap
// node's `addAttributes` property.
@@ -157,6 +169,105 @@ export function getBlockFromNodeView(
}
}
+/**
+ * Applies custom `blockContent` DOM attributes to an element, merging (rather
+ * than overwriting) its class list.
+ */
+export function applyDOMAttributes(
+ dom: HTMLElement | DocumentFragment,
+ domAttributes: Record | undefined,
+) {
+ if (!domAttributes || !(dom instanceof HTMLElement)) {
+ return;
+ }
+ for (const [attr, value] of Object.entries(domAttributes)) {
+ if (attr === "class") {
+ dom.className = mergeCSSClasses(dom.className, value);
+ } else {
+ dom.setAttribute(attr, value);
+ }
+ }
+}
+
+// Writes the `data-node-type` marker and each non-default prop (as a
+// kebab-cased `data-*` attribute) a container block's root element needs to
+// round-trip through the generated parse rules. Two modes:
+// - `authoritative`: the caller owns the element (the node view), so it
+// overwrites and clears defaulted attrs.
+// - otherwise: the block's own render owns the element (HTML serialization),
+// so existing attributes are left untouched — author-set attributes win.
+// `data-id` is handled by `applyContainerAttributes` alone.
+function writeContainerPropAttributes(
+ element: HTMLElement,
+ blockType: string,
+ blockProps: Partial>,
+ propSchema: PSchema,
+ authoritative: boolean,
+) {
+ if (authoritative || !element.hasAttribute("data-node-type")) {
+ element.setAttribute("data-node-type", blockType);
+ }
+ for (const [prop, value] of Object.entries(blockProps)) {
+ const attr = camelToDataKebab(prop);
+ const isDefault =
+ value === propSchema[prop]?.default || value === undefined;
+ if (authoritative) {
+ if (isDefault) {
+ element.removeAttribute(attr);
+ } else {
+ element.setAttribute(attr, `${value}`);
+ }
+ } else if (!isDefault && !element.hasAttribute(attr)) {
+ element.setAttribute(attr, `${value}`);
+ }
+ }
+}
+
+/**
+ * Applies the attributes BlockNote relies on to a container block's root
+ * element: `data-node-type`, `data-id`, and each non-default prop as a
+ * kebab-cased `data-*` attribute. Called from `buildContainerNode`'s node
+ * view (both initial render and `update`) so block renders don't have to
+ * stamp these themselves. Overwrites existing attributes.
+ */
+export function applyContainerAttributes(
+ dom: HTMLElement | DocumentFragment | undefined,
+ blockType: string,
+ blockProps: Partial>,
+ propSchema: PSchema,
+ id: string | undefined,
+) {
+ const element = dom as HTMLElement | undefined;
+ if (!element || typeof element.setAttribute !== "function") {
+ return;
+ }
+ writeContainerPropAttributes(
+ element,
+ blockType,
+ blockProps,
+ propSchema,
+ true,
+ );
+ if (id) {
+ element.setAttribute("data-id", id);
+ }
+}
+
+/**
+ * Fills in the `data-node-type` marker and non-default prop `data-*`
+ * attributes a container block's serialized root needs to parse back, without
+ * clobbering any the block's own render already set (author-set attributes
+ * win). Used by internal/external HTML serialization.
+ */
+export function fillContainerAttributes(
+ dom: HTMLElement,
+ blockType: string,
+ blockProps: Partial>,
+ propSchema: PSchema,
+) {
+ writeContainerPropAttributes(dom, blockType, blockProps, propSchema, false);
+}
+
// Function that wraps the `dom` element returned from 'blockConfig.render' in a
// `blockContent` div, which contains the block type and props as HTML
// attributes. If `blockConfig.render` also returns a `contentDOM`, it also adds
@@ -232,6 +343,11 @@ export function createBlockSpecFromTiptapNode<
node: Node;
type: string;
content: "inline" | "table" | "none" | "plain";
+ // Declares the block's container semantics (min/max/repair etc.) even
+ // though the node itself is hand-written — the node's own content
+ // expression stays authoritative for the PM schema, while BlockNote-level
+ // behavior (repair, seeding, validation) reads this config.
+ childBlocks?: true | ChildBlocksConfig;
},
P extends PropSchema,
>(
@@ -244,6 +360,9 @@ export function createBlockSpecFromTiptapNode<
type: config.type as T["type"],
content: config.content,
propSchema,
+ ...(config.childBlocks !== undefined
+ ? { childBlocks: config.childBlocks }
+ : {}),
},
implementation: {
node: config.node,
diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts
index 00b564ef0b..75c5ac4532 100644
--- a/packages/core/src/schema/blocks/types.ts
+++ b/packages/core/src/schema/blocks/types.ts
@@ -1,7 +1,7 @@
/** Define the main block types **/
// import { Extension, Node } from "@tiptap/core";
import type { Node, NodeViewRendererProps } from "@tiptap/core";
-import type { Fragment, Schema } from "prosemirror-model";
+import type { Fragment, Node as PMNode, Schema } from "prosemirror-model";
import type { ViewMutationRecord } from "prosemirror-view";
import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js";
import type {
@@ -59,8 +59,83 @@ export interface BlockConfigMeta {
* Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.isolating} block
*/
isolating?: boolean;
+
+ /**
+ * Whether this block type gets a side menu drag handle (and can be dragged
+ * by it). Applies to any block type, not just container blocks — e.g. a
+ * "locked" block can opt out of dragging entirely.
+ * @default true
+ */
+ draggable?: boolean;
+
+ /**
+ * Only applies to container blocks (blocks with `childBlocks`): whether
+ * pressing Enter on an empty block that is the last child of the container
+ * moves that block out of (after) the container, list-style. Without this,
+ * a container as the last block in the document can trap the cursor, as
+ * Enter only ever creates new blocks *within* the container.
+ * @default true
+ */
+ exitOnEnter?: boolean;
}
+/**
+ * Configuration for a block that hosts other blocks as its body (a
+ * "container" block). When set, the block's ProseMirror node is emitted in
+ * the `bnBlock` / `childContainer` groups with block-node content (by
+ * default `blockGroupChild{min,max}`) — the same shape that columns use.
+ * Child blocks live on `block.children` at runtime (matching the column
+ * model). Requires `content: "none"`.
+ */
+export type ChildBlocksConfig = {
+ /**
+ * Block types allowed as direct children. Container-block entries (types
+ * that themselves declare `childBlocks`) are enforced exactly by the
+ * ProseMirror schema. Regular block entries collapse to "any regular
+ * block" at the node level — every regular block is wrapped in the same
+ * `blockContainer` node, so the schema cannot distinguish between them.
+ * Defaults to any block (`blockGroupChild`).
+ */
+ allowedBlocks?: string[];
+ /** Minimum number of child blocks. Defaults to 1. */
+ min?: number;
+ /** Maximum number of child blocks. Defaults to unbounded. */
+ max?: number;
+ /**
+ * Children to seed the container with on first insert, as partial blocks
+ * (so props and nested children are expressible). Ignored when the
+ * inserted partial block already provides explicit `children`. Validated
+ * against `allowedBlocks`/`min`/`max` when the schema is created.
+ */
+ defaultChildren?: PartialBlockNoDefaults[];
+ /**
+ * Whether the block can be inserted at any position where a regular block
+ * goes — i.e. directly inside a `blockGroup` (the document root, or as a
+ * child of any other block). Defaults to `true`. Set to `false` for blocks
+ * that should only appear inside a specific schema-restricted parent (e.g.
+ * a `column` only ever lives inside a `columnList`).
+ */
+ topLevel?: boolean;
+ /**
+ * When set, `fixContainer` collapses the container as its children empty
+ * out (after Backspace merges a child out, `replaceBlocks` deletes
+ * children, etc.): it drops emptied children (a child holding nothing but a
+ * single empty paragraph, possibly through nested containers) and, if that
+ * leaves fewer than `min` non-empty children, unwraps the container —
+ * replacing it with its remaining non-empty children (non-top-level
+ * container children are flattened into *their* children), or removing it
+ * entirely when none remain. Column lists use this so emptied columns
+ * disappear and a one-column list unwraps.
+ *
+ * Coupled to `min`, so it lives here rather than in `meta`: without it
+ * repair is a no-op, because ProseMirror's schema fitting always pads a
+ * container back up to `min` with empty children, so "effectively below
+ * min" can only be detected by discounting those empty children.
+ * @default false
+ */
+ collapseWhenEmptied?: boolean;
+};
+
/**
* BlockConfig contains the "schema" info about a Block type
* i.e. what props it supports, what content it supports, etc.
@@ -87,8 +162,16 @@ export interface BlockConfig<
* The content that the block supports
*/
content: C;
- // TODO: how do you represent things that have nested content?
- // e.g. tables, alerts (with title & content)
+ /**
+ * Marks this block as a container of other blocks. The block's PM node is
+ * emitted in the `bnBlock` / `childContainer` groups with block-node
+ * content (regular blocks wrapped in `blockContainer` nodes, plus
+ * container-type blocks); child blocks are exposed on `block.children`.
+ * Requires `content: "none"`. Pass `true` for defaults or an object to
+ * constrain which/how many children are allowed and seed the initial
+ * children.
+ */
+ childBlocks?: true | ChildBlocksConfig;
}
/**
@@ -210,6 +293,7 @@ export type LooseBlockSpec<
contentDOM?: HTMLElement;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
toExternalHTML?: (
block: any,
@@ -268,6 +352,7 @@ export type BlockSpecs = {
contentDOM?: HTMLElement;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
destroy?: () => void;
+ update?: (node: PMNode) => boolean | void;
};
toExternalHTML?: (
block: any,
@@ -553,6 +638,20 @@ export type BlockImplementation<
contentDOM?: HTMLElement;
ignoreMutation?: (mutation: ViewMutationRecord) => boolean;
destroy?: () => void;
+ /**
+ * Optional NodeView update hook. Called when the underlying ProseMirror
+ * node's attributes change (or its decorations change). Return `false` to
+ * tell ProseMirror to destroy and recreate the NodeView (i.e. re-run
+ * `render` from scratch). Return `true` (or `undefined`) when you have
+ * patched `dom` in-place and PM should keep the existing view.
+ *
+ * Only honored for container blocks (blocks with `childBlocks`), where
+ * recreating the node view would remount every child block — e.g. column
+ * resizing patches widths in place through this hook. Non-container
+ * blocks always recreate on attr changes (see
+ * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464).
+ */
+ update?: (node: PMNode) => boolean | void;
};
/**
diff --git a/packages/core/src/schema/blocks/validateChildBlocks.test.ts b/packages/core/src/schema/blocks/validateChildBlocks.test.ts
new file mode 100644
index 0000000000..4635164630
--- /dev/null
+++ b/packages/core/src/schema/blocks/validateChildBlocks.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { validateChildBlocksConfigs } from "./validateChildBlocks.js";
+
+function configsWith(containers: Record) {
+ return {
+ paragraph: { type: "paragraph", content: "inline" as const },
+ ...Object.fromEntries(
+ Object.entries(containers).map(([type, { childBlocks }]) => [
+ type,
+ { type, content: "none" as const, childBlocks },
+ ]),
+ ),
+ };
+}
+
+describe("validateChildBlocksConfigs", () => {
+ it("accepts a plain container config", () => {
+ const configs = configsWith({ callout: { childBlocks: { min: 1 } } });
+ expect(() => validateChildBlocksConfigs(configs)).not.toThrow();
+ });
+
+ it("accepts the columnList shape (restricted children, min 2)", () => {
+ const configs = configsWith({
+ grid: { childBlocks: { allowedBlocks: ["gridCell"], min: 2 } },
+ gridCell: { childBlocks: { topLevel: false } },
+ });
+ expect(() => validateChildBlocksConfigs(configs)).not.toThrow();
+ });
+
+ it("rejects unknown allowedBlocks entries", () => {
+ const configs = configsWith({
+ grid: { childBlocks: { allowedBlocks: ["doesNotExist"] } },
+ });
+ expect(() => validateChildBlocksConfigs(configs)).toThrow(/doesNotExist/);
+ });
+
+ it("rejects negative or non-integer min", () => {
+ const configs = configsWith({ callout: { childBlocks: { min: -1 } } });
+ expect(() => validateChildBlocksConfigs(configs)).toThrow(/min/);
+ });
+
+ it("rejects max smaller than min", () => {
+ const configs = configsWith({
+ callout: { childBlocks: { min: 3, max: 2 } },
+ });
+ expect(() => validateChildBlocksConfigs(configs)).toThrow(/max/);
+ });
+
+ it("rejects defaultChildren violating min/max", () => {
+ const configs = configsWith({
+ callout: {
+ childBlocks: {
+ min: 2,
+ defaultChildren: [{ type: "paragraph" }],
+ },
+ },
+ });
+ expect(() => validateChildBlocksConfigs(configs)).toThrow(
+ /defaultChildren/,
+ );
+ });
+
+ it("rejects defaultChildren of unknown types", () => {
+ const configs = configsWith({
+ callout: {
+ childBlocks: { defaultChildren: [{ type: "doesNotExist" }] },
+ },
+ });
+ expect(() => validateChildBlocksConfigs(configs)).toThrow(/doesNotExist/);
+ });
+
+ it("rejects defaultChildren not allowed by allowedBlocks", () => {
+ const configs = configsWith({
+ grid: {
+ childBlocks: {
+ allowedBlocks: ["gridCell"],
+ min: 1,
+ defaultChildren: [{ type: "paragraph" }],
+ },
+ },
+ gridCell: { childBlocks: { topLevel: false } },
+ });
+ expect(() => validateChildBlocksConfigs(configs)).toThrow(
+ /not permitted by/,
+ );
+ });
+
+ it("rejects content that is not 'none'", () => {
+ const configs = {
+ paragraph: { type: "paragraph", content: "inline" },
+ bad: { type: "bad", content: "inline", childBlocks: true },
+ };
+ expect(() => validateChildBlocksConfigs(configs as any)).toThrow(
+ /content: "none"/,
+ );
+ });
+
+ it("rejects empty allowedBlocks", () => {
+ const configs = configsWith({
+ callout: { childBlocks: { allowedBlocks: [] } },
+ });
+ expect(() => validateChildBlocksConfigs(configs)).toThrow(
+ /must not be empty/,
+ );
+ });
+});
diff --git a/packages/core/src/schema/blocks/validateChildBlocks.ts b/packages/core/src/schema/blocks/validateChildBlocks.ts
new file mode 100644
index 0000000000..0d39ce4e26
--- /dev/null
+++ b/packages/core/src/schema/blocks/validateChildBlocks.ts
@@ -0,0 +1,134 @@
+import {
+ getChildBlocksConfig,
+ getMinChildren,
+ isContainerType,
+} from "./childBlocks.js";
+import type { BlockConfig, ChildBlocksConfig } from "./types.js";
+
+/**
+ * Validates the `childBlocks` config of every block in a schema, so that
+ * misconfigurations surface as a clear error at schema-creation time instead of
+ * as an opaque ProseMirror one (or a stack overflow) much later.
+ *
+ * @param blockConfigs The configs of every block in the schema, keyed by type.
+ */
+export function validateChildBlocksConfigs(
+ blockConfigs: Record<
+ string,
+ Pick & {
+ childBlocks?: true | ChildBlocksConfig;
+ }
+ >,
+) {
+ const isContainerBlockType = (blockType: string) =>
+ !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]);
+
+ for (const [type, config] of Object.entries(blockConfigs)) {
+ const childBlocks = getChildBlocksConfig(config);
+ if (!childBlocks) {
+ continue;
+ }
+
+ validateOne(
+ type,
+ config.content,
+ childBlocks,
+ blockConfigs,
+ isContainerBlockType,
+ );
+ }
+}
+
+function validateOne(
+ type: string,
+ content: string,
+ childBlocks: ChildBlocksConfig,
+ blockConfigs: Record,
+ isContainerBlockType: (blockType: string) => boolean,
+) {
+ const fail = (message: string): never => {
+ throw new Error(
+ `Invalid \`childBlocks\` config for block "${type}": ${message}`,
+ );
+ };
+
+ if (content !== "none") {
+ fail(
+ `\`childBlocks\` requires \`content: "none"\`, but content is "${content}". A container block holds blocks, not inline content.`,
+ );
+ }
+
+ const min = getMinChildren(childBlocks);
+ const { max, allowedBlocks, defaultChildren } = childBlocks;
+
+ if (!Number.isInteger(min) || min < 0) {
+ fail(`\`min\` must be a non-negative integer, but is ${min}.`);
+ }
+
+ if (max !== undefined) {
+ if (!Number.isInteger(max) || max < 1) {
+ fail(`\`max\` must be a positive integer, but is ${max}.`);
+ }
+ if (max < min) {
+ fail(
+ `\`max\` (${max}) must be greater than or equal to \`min\` (${min}).`,
+ );
+ }
+ }
+
+ if (allowedBlocks) {
+ if (allowedBlocks.length === 0) {
+ fail("`allowedBlocks` must not be empty. Omit it to allow any block.");
+ }
+ for (const allowed of allowedBlocks) {
+ if (!(allowed in blockConfigs)) {
+ fail(
+ `\`allowedBlocks\` contains "${allowed}", which is not a block type in this schema.`,
+ );
+ }
+ }
+ }
+
+ if (defaultChildren) {
+ if (defaultChildren.length < min) {
+ fail(
+ `\`defaultChildren\` has ${defaultChildren.length} block(s), fewer than \`min\` (${min}).`,
+ );
+ }
+ if (max !== undefined && defaultChildren.length > max) {
+ fail(
+ `\`defaultChildren\` has ${defaultChildren.length} block(s), more than \`max\` (${max}).`,
+ );
+ }
+ for (const child of defaultChildren) {
+ const childType = child.type ?? "paragraph";
+ if (!(childType in blockConfigs)) {
+ fail(
+ `\`defaultChildren\` contains a block of type "${childType}", which is not a block type in this schema.`,
+ );
+ }
+ if (
+ allowedBlocks &&
+ !isAllowed(childType, allowedBlocks, isContainerBlockType)
+ ) {
+ fail(
+ `\`defaultChildren\` contains a block of type "${childType}", which is not permitted by \`allowedBlocks\`.`,
+ );
+ }
+ }
+ }
+}
+
+function isAllowed(
+ blockType: string,
+ allowedBlocks: string[],
+ isContainerBlockType: (blockType: string) => boolean,
+): boolean {
+ if (allowedBlocks.includes(blockType)) {
+ return true;
+ }
+ return (
+ !isContainerBlockType(blockType) &&
+ allowedBlocks.some((allowed) => !isContainerBlockType(allowed))
+ );
+}
diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts
index 2f1e703007..c86b9206b4 100644
--- a/packages/core/src/schema/index.ts
+++ b/packages/core/src/schema/index.ts
@@ -1,6 +1,8 @@
+export * from "./blocks/childBlocks.js";
export * from "./blocks/createSpec.js";
export * from "./blocks/internal.js";
export * from "./blocks/types.js";
+export * from "./blocks/validateChildBlocks.js";
export * from "./inlineContent/createSpec.js";
export * from "./inlineContent/internal.js";
export * from "./inlineContent/types.js";
diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts
index a7a04e93dc..5418ac93dd 100644
--- a/packages/core/src/schema/schema.ts
+++ b/packages/core/src/schema/schema.ts
@@ -16,6 +16,9 @@ import {
getInlineContentSchemaFromSpecs,
getStyleSchemaFromSpecs,
} from "./index.js";
+import { isContainerType } from "./blocks/childBlocks.js";
+import type { BlockSchemaContext } from "./blocks/createSpec.js";
+import { validateChildBlocksConfigs } from "./blocks/validateChildBlocks.js";
function removeUndefined | undefined>(obj: T): T {
if (!obj) {
@@ -91,6 +94,25 @@ export class CustomBlockNoteSchema<
})),
);
+ // Container-ness is needed to build *other* blocks' nodes (a container's
+ // `allowedBlocks` maps block types to node terms, and only container types
+ // are their own node type), so it's resolved across the whole schema up
+ // front. Validation runs first so misconfigurations surface as clear errors
+ // rather than as opaque ProseMirror ones.
+ const blockConfigs = Object.fromEntries(
+ Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => [
+ key,
+ blockSpec.config,
+ ]),
+ );
+
+ validateChildBlocksConfigs(blockConfigs);
+
+ const schemaContext: BlockSchemaContext = {
+ isContainerBlockType: (blockType) =>
+ !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]),
+ };
+
const blockSpecs = Object.fromEntries(
Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => {
return [
@@ -100,6 +122,7 @@ export class CustomBlockNoteSchema<
blockSpec.implementation,
blockSpec.extensions,
getPriority(key),
+ schemaContext,
),
];
}),
diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts
index 37fb1fd4e9..7dc3f4253d 100644
--- a/packages/core/src/yjs/extensions/FixUpSchema.ts
+++ b/packages/core/src/yjs/extensions/FixUpSchema.ts
@@ -25,7 +25,15 @@ export const FixUpSchemaExtension = createExtension(({ editor }) => {
// create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state)
const jsonNode = JSON.parse(JSON.stringify(ret.toJSON()));
- jsonNode.content[0].content[0].attrs.id = "initialBlockId";
+ // The first fill of the doc's blockGroup is guaranteed to be a
+ // `blockContainer` (container block nodes register at lower priority
+ // precisely so auto-fill picks `blockContainer` first), but guard on
+ // the node actually carrying an id attr in case a custom schema
+ // changes that.
+ const firstBlock = jsonNode.content?.[0]?.content?.[0];
+ if (firstBlock?.attrs && "id" in firstBlock.attrs) {
+ firstBlock.attrs.id = "initialBlockId";
+ }
cache = Node.fromJSON(schema, jsonNode);
return cache;
diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx
index 2bf0e4fa57..eed8e0ef93 100644
--- a/packages/react/src/components/Popovers/BlockPopover.tsx
+++ b/packages/react/src/components/Popovers/BlockPopover.tsx
@@ -1,4 +1,4 @@
-import { getNodeById } from "@blocknote/core";
+import { getNodeById, isContainerNode } from "@blocknote/core";
import { ReactNode, useMemo } from "react";
import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
@@ -29,6 +29,17 @@ export const BlockPopover = (
return undefined;
}
+ // For container blocks the PM node IS the block, so a position
+ // inside it resolves to its contentDOM — the child-blocks area —
+ // which would anchor the popover to the first child's rows instead
+ // of the block's own element.
+ if (isContainerNode(nodePosInfo.node.type)) {
+ const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode);
+ if (dom instanceof Element) {
+ return { element: dom };
+ }
+ }
+
const { node } = editor.prosemirrorView.domAtPos(
nodePosInfo.posBeforeNode + 1,
);
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 2259babd84..586d7437b7 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -137,6 +137,7 @@ export * from "./hooks/useExtension.js";
export * from "./hooks/useEditorState.js";
export * from "./schema/ReactBlockSpec.js";
+export * from "./schema/ChildBlocksWrapper.js";
export * from "./schema/ReactInlineContentSpec.js";
export * from "./schema/ReactStyleSpec.js";
export * from "./schema/useNodeViewBlock.js";
diff --git a/packages/react/src/schema/ChildBlocksWrapper.tsx b/packages/react/src/schema/ChildBlocksWrapper.tsx
new file mode 100644
index 0000000000..fe8cfa4902
--- /dev/null
+++ b/packages/react/src/schema/ChildBlocksWrapper.tsx
@@ -0,0 +1,31 @@
+import { NodeViewWrapper } from "@tiptap/react";
+import { ComponentPropsWithoutRef, forwardRef, ReactNode } from "react";
+
+export type ChildBlocksWrapperProps = ComponentPropsWithoutRef<"div"> & {
+ children: ReactNode;
+};
+
+/**
+ * The root element a container block's `render` should return.
+ *
+ * A container block owns its outer DOM: BlockNote adds no `blockContent`
+ * wrapper around it, and its content target holds child blocks rather than
+ * inline content. This component wraps Tiptap's `NodeViewWrapper` (so
+ * ProseMirror recognizes the node view's element) and spreads everything
+ * else through.
+ *
+ * The attributes BlockNote relies on (`data-node-type`, `data-id`, and each
+ * non-default prop as a `data-*` attribute) are applied by
+ * `applyContainerAttributes` in `@blocknote/core` — a container render
+ * doesn't set them itself.
+ */
+export const ChildBlocksWrapper = forwardRef<
+ HTMLDivElement,
+ ChildBlocksWrapperProps
+>(({ children, ...rest }, ref) => (
+
+ {children}
+
+));
+
+ChildBlocksWrapper.displayName = "ChildBlocksWrapper";
diff --git a/packages/react/src/schema/ReactBlockSpec.container.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.test.tsx
new file mode 100644
index 0000000000..01680dd527
--- /dev/null
+++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx
@@ -0,0 +1,95 @@
+import {
+ BlockNoteEditor,
+ BlockNoteSchema,
+ defaultBlockSpecs,
+} from "@blocknote/core";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+} from "vite-plus/test";
+
+import { createReactBlockSpec } from "./ReactBlockSpec.js";
+
+// Same shape as the example callout block (`examples/06-custom-schema/09-container-block`).
+// This test exists to confirm the document-level transformation succeeds — it
+// does NOT mount BlockNoteView, so React rendering of the nodeView itself is
+// not exercised here.
+const Callout = createReactBlockSpec(
+ {
+ type: "callout" as const,
+ propSchema: {},
+ content: "none" as const,
+ childBlocks: { min: 1, defaultChildren: [{ type: "paragraph" }] },
+ },
+ {
+ render: ({ contentRef }) => (
+
+
+
+ ),
+ },
+)();
+
+const schema = BlockNoteSchema.create().extend({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ callout: Callout,
+ } as const,
+});
+
+describe("React updateBlock → container with defaultChildren (document-level)", () => {
+ let editor: BlockNoteEditor<
+ typeof schema.blockSchema,
+ typeof schema.inlineContentSchema,
+ typeof schema.styleSchema
+ >;
+ const div = document.createElement("div");
+
+ beforeAll(() => {
+ document.body.appendChild(div);
+ editor = BlockNoteEditor.create({ schema });
+ editor.mount(div);
+ });
+
+ afterAll(() => {
+ editor._tiptapEditor.destroy();
+ div.remove();
+ editor = undefined as any;
+ });
+
+ beforeEach(() => {
+ editor.replaceBlocks(editor.document, [
+ { id: "p-0", type: "paragraph", content: "" },
+ { id: "trailing", type: "paragraph", content: "" },
+ ]);
+ });
+
+ it("converts an empty paragraph to a callout via editor.updateBlock", () => {
+ editor.updateBlock("p-0", { type: "callout" });
+ expect(editor.document).toMatchSnapshot();
+ }, 5000);
+
+ it("does not wrap containers in a blockContent div in external HTML", async () => {
+ // A separate, unmounted (headless) editor: the React external-HTML path
+ // renders through a temporary root in headless mode.
+ const headlessEditor = BlockNoteEditor.create({ schema });
+
+ const html = headlessEditor.blocksToHTMLLossy([
+ {
+ type: "callout",
+ id: "c-0",
+ children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }],
+ },
+ ] as any);
+ // Container blocks own their outer DOM entirely — regression test for
+ // the React `toExternalHTML` path wrapping them in a spurious
+ // `bn-block-content` div (core's `createBlockSpec` passes them through).
+ expect(html).not.toContain('data-content-type="callout"');
+ expect(html).toContain('data-node-type="callout"');
+ expect(html).toContain("Hello");
+ }, 5000);
+});
diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx
index 4bd1649292..db996a601b 100644
--- a/packages/react/src/schema/ReactBlockSpec.tsx
+++ b/packages/react/src/schema/ReactBlockSpec.tsx
@@ -10,7 +10,9 @@ import {
Extension,
ExtensionFactoryInstance,
ExtractBlockConfigFromConfigOrCreator,
+ isContainerType,
mergeCSSClasses,
+ nodeToBlock,
Props,
PropSchema,
} from "@blocknote/core";
@@ -33,11 +35,14 @@ export type ReactCustomBlockRenderProps<
> = {
block: BlockNoDefaults, any, any>;
editor: BlockNoteEditor, any, any>;
-} & (Config["content"] extends "inline"
- ? {
+} & (Config["content"] extends "table"
+ ? object
+ : {
+ // For inline-content blocks, points to where the inline text mounts.
+ // For container blocks, points to where child blocks mount. For other
+ // `content: "none"` blocks, this can be ignored.
contentRef: (node: HTMLElement | null) => void;
- }
- : object);
+ });
// extend BlockConfig but use a React render function
export type ReactCustomBlockImplementation<
@@ -233,9 +238,33 @@ export function createReactBlockSpec<
implementation: {
...blockImplementation,
toExternalHTML(block, editor, context) {
+ const isContainer = isContainerType(blockConfig);
const BlockContent =
blockImplementation.toExternalHTML || blockImplementation.render;
const output = renderToDOMSpec((refCB) => {
+ const content = (
+ {
+ refCB(element);
+ if (element && !isContainer) {
+ element.className = mergeCSSClasses(
+ "bn-inline-content",
+ element.className,
+ );
+ }
+ }}
+ context={context}
+ />
+ );
+ if (isContainer) {
+ // Container blocks own their outer DOM entirely (the PM node IS
+ // the bnBlock — no `blockContent` wrapper), matching the core
+ // `createBlockSpec` pass-through and the node-view/dom render
+ // paths below.
+ return content;
+ }
return (
- {
- refCB(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- }
- }}
- context={context}
- />
+ {content}
);
}, editor);
@@ -271,78 +287,137 @@ export function createReactBlockSpec<
// constructed (itself guarded, via `getBlockFromNodeView`). Seeds
// the fallback below so there is always something to render.
const initialBlock = block;
+ // Container-ness is fixed per spec, so the node-view component
+ // can be chosen once — each variant is straight-line code using
+ // only the hooks and wrappers it needs.
+ const isContainer = isContainerType(blockConfig);
+ const BlockContent = blockImplementation.render;
+ const blockContentDOMAttributes = this.blockContentDOMAttributes;
- return ReactNodeViewRenderer(
- (props: NodeViewProps) => {
- // Vanilla JS node views are recreated on each update. However,
- // using `ReactNodeViewRenderer` makes it so the node view is
- // only created once, so the block we get in the node view will
- // be outdated. Therefore, we have to get the block in the
- // `ReactNodeViewRenderer` instead. That position can be stale,
- // so resolving it is guarded (see `useNodeViewBlock`).
- const block = useNodeViewBlock(props, initialBlock);
+ // Vanilla JS node views are recreated on each update. However,
+ // using `ReactNodeViewRenderer` makes it so the node view is only
+ // created once, so the block we get in the node view will be
+ // outdated. Therefore, both variants have to (re-)resolve the
+ // block inside the `ReactNodeViewRenderer` component.
- const ref = useReactNodeView().nodeViewContentRef;
+ const ContainerNodeView = (props: NodeViewProps) => {
+ // Container blocks are bnBlock nodes (no `blockContainer`
+ // wrapper), so the id lives on the node's own attrs and the
+ // block resolves by id. Position-based resolution
+ // (`useNodeViewBlock`) would walk up to a *parent* bnBlock —
+ // the wrong block here — and ids are also immune to the stale
+ // positions it has to guard against.
+ const id = (props.node.attrs as Record).id;
+ if (!id) {
+ throw new Error(
+ `Container block "${blockConfig.type}" is missing an id attribute.`,
+ );
+ }
+ // The id lookup misses when the node was just removed from the
+ // document (e.g. a suggestion-mode deletion still rendering);
+ // fall back to converting the node the view was handed.
+ const block =
+ editor.getBlock(id) ??
+ nodeToBlock(props.node, props.view.state.doc);
- if (!ref) {
- throw new Error("nodeViewContentRef is not set");
- }
+ const ref = useReactNodeView().nodeViewContentRef;
+ if (!ref) {
+ throw new Error("nodeViewContentRef is not set");
+ }
+
+ // Container blocks own their entire DOM: the user's render is
+ // responsible for returning a `` (with any
+ // `data-*` attrs they want exposed). The framework doesn't
+ // insert any wrapping element — letting authors build tag-pure
+ // structures (e.g. `
`/`
`/`
`).
+ return (
+ {
+ ref(element);
+ if (element) {
+ element.dataset.nodeViewContent = "";
+ }
+ }}
+ />
+ );
+ };
+
+ const RegularNodeView = (props: NodeViewProps) => {
+ // The node view's position can be stale mid-render, so
+ // resolving it is guarded (see `useNodeViewBlock`).
+ const block = useNodeViewBlock(props, initialBlock);
+
+ const ref = useReactNodeView().nodeViewContentRef;
+ if (!ref) {
+ throw new Error("nodeViewContentRef is not set");
+ }
- const BlockContent = blockImplementation.render;
- return (
-
- {
- ref(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- element.dataset.nodeViewContent = "";
- }
- }}
- />
-
- );
- },
- {
- className: "bn-react-node-view-renderer",
- },
- )(this.props!) as ReturnType;
- } else {
- const BlockContent = blockImplementation.render;
- const output = renderToDOMSpec((refCB) => {
return (
{
- refCB(element);
+ ref(element);
if (element) {
element.className = mergeCSSClasses(
"bn-inline-content",
element.className,
);
+ element.dataset.nodeViewContent = "";
}
}}
/>
);
+ };
+
+ return ReactNodeViewRenderer(
+ isContainer ? ContainerNodeView : RegularNodeView,
+ {
+ className: "bn-react-node-view-renderer",
+ },
+ )(this.props!) as ReturnType;
+ } else {
+ const isContainer = isContainerType(blockConfig);
+ const BlockContent = blockImplementation.render;
+ const output = renderToDOMSpec((refCB) => {
+ const content = (
+ {
+ refCB(element);
+ if (element && !isContainer) {
+ element.className = mergeCSSClasses(
+ "bn-inline-content",
+ element.className,
+ );
+ }
+ }}
+ />
+ );
+ if (isContainer) {
+ return content;
+ }
+ return (
+
+ {content}
+
+ );
}, editor);
return output;
}
diff --git a/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap b/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap
new file mode 100644
index 0000000000..2a70aa4d12
--- /dev/null
+++ b/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap
@@ -0,0 +1,36 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`React updateBlock → container with defaultChildren (document-level) > converts an empty paragraph to a callout via editor.updateBlock 1`] = `
+[
+ {
+ "children": [
+ {
+ "children": [],
+ "content": [],
+ "id": "1",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+ ],
+ "content": undefined,
+ "id": "0",
+ "props": {},
+ "type": "callout",
+ },
+ {
+ "children": [],
+ "content": [],
+ "id": "trailing",
+ "props": {
+ "backgroundColor": "default",
+ "textAlignment": "left",
+ "textColor": "default",
+ },
+ "type": "paragraph",
+ },
+]
+`;
diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts
index 02393a2fd0..74d2e84037 100644
--- a/packages/react/src/schema/useNodeViewBlock.ts
+++ b/packages/react/src/schema/useNodeViewBlock.ts
@@ -42,6 +42,17 @@ export function useNodeViewBlock(
const lastBlockRef = useRef(initialBlock);
const doc = props.view.state.doc;
+ // Position-based resolution finds the nearest bnBlock *parent* of the
+ // position — correct for blockContent node views, but wrong-by-construction
+ // for container blocks, whose node IS the bnBlock: it would return an
+ // ancestor block. Guarded loudly so a container node view can't silently
+ // render the wrong block.
+ if (props.node.type.isInGroup("bnBlock")) {
+ throw new Error(
+ `useNodeViewBlock cannot resolve container block "${props.node.type.name}": position-based resolution returns the nearest bnBlock parent, which is the wrong block when the node view's node is the block itself. Resolve container blocks by id instead, e.g. editor.getBlock(props.node.attrs.id).`,
+ );
+ }
+
try {
// Deliberate render-phase write: a monotonic "last good value" cache, so a
// repeated render (e.g. StrictMode's double invoke) recomputes the same
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
index a24340a7ab..9dfe893326 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts
@@ -1,5 +1,6 @@
import {
BlockNoteSchema,
+ createBlockSpec,
defaultBlockSpecs,
createPageBreakBlockSpec,
} from "@blocknote/core";
@@ -270,6 +271,82 @@ describe("exporter", () => {
);
});
+describe("custom container blocks", () => {
+ const Box = createBlockSpec(
+ {
+ type: "box" as const,
+ propSchema: {},
+ content: "none",
+ childBlocks: { min: 1 },
+ },
+ {
+ render: (block: any) => {
+ const dom = document.createElement("div");
+ dom.setAttribute("data-node-type", "box");
+ dom.setAttribute("data-id", block.id);
+ return { dom, contentDOM: dom };
+ },
+ },
+ )();
+
+ const boxSchema = BlockNoteSchema.create({
+ blockSpecs: {
+ ...defaultBlockSpecs,
+ box: Box,
+ },
+ });
+
+ const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [
+ {
+ type: "box",
+ children: [
+ { type: "paragraph", content: "First" },
+ { type: "paragraph", content: "Second" },
+ ],
+ },
+ ] as any);
+
+ it("passes children to a custom container mapping", async () => {
+ const exporter = new DOCXExporter(
+ boxSchema,
+ {
+ ...docxDefaultSchemaMappings,
+ blockMapping: {
+ ...docxDefaultSchemaMappings.blockMapping,
+ box: (
+ _block: any,
+ _exporter: any,
+ _nesting: any,
+ _index: any,
+ children: any,
+ ) =>
+ new Paragraph({
+ children: [new TextRun(`BOX(${children?.length ?? 0})`)],
+ }),
+ },
+ } as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ const transformed = await exporter.transformBlocks(boxDocument as any);
+ expect(transformed).toHaveLength(1);
+ const xml = JSON.stringify(transformed[0]);
+ expect(xml).toContain("BOX(2)");
+ });
+
+ it("throws a clear error for an unmapped container block", async () => {
+ const exporter = new DOCXExporter(
+ boxSchema,
+ docxDefaultSchemaMappings as any,
+ { resolveFileUrl: testResolveFileUrl },
+ );
+
+ await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow(
+ /container block type "box"/,
+ );
+ });
+});
+
function prettify(sourceXml: string) {
let ret = xmlFormat(sourceXml);
diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts
index 6fce968a3e..bf4a991a44 100644
--- a/packages/xl-docx-exporter/src/docx/docxExporter.ts
+++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts
@@ -115,7 +115,7 @@ export class DOCXExporter<
for (const b of blocks) {
let children = await this.transformBlocks(b.children, nestingLevel + 1);
- if (!["columnList", "column"].includes(b.type)) {
+ if (!this.isContainerBlock(b.type)) {
children = children.map((c, _i) => {
// NOTE: nested tables not supported (we can't insert the new Tab before a table)
if (
@@ -138,7 +138,7 @@ export class DOCXExporter<
0 /*unused*/,
children,
); // TODO: any
- if (["columnList", "column"].includes(b.type)) {
+ if (this.isContainerBlock(b.type)) {
ret.push(self as Table);
} else if (Array.isArray(self)) {
ret.push(...self, ...children);
diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
index 5f4eecf3c5..df9fafdf61 100644
--- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
+++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx
@@ -246,6 +246,24 @@ export class ReactEmailExporter<
i = nextIndex;
continue;
}
+ if (this.isContainerBlock(b.type)) {
+ // Container blocks (columnList, column, custom containers): the
+ // mapping owns the placement of the children, so they are passed in
+ // and not rendered as an indented sibling list.
+ const containerChildren = await this.transformBlocks(
+ b.children,
+ nestingLevel + 1,
+ );
+ const containerSelf = (await this.mapBlock(
+ b as any,
+ nestingLevel,
+ 0,
+ containerChildren as any,
+ )) as any;
+ ret.push({containerSelf});
+ i++;
+ continue;
+ }
// Non-list blocks
const children = await this.transformBlocks(b.children, nestingLevel + 1);
const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any;
diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts
index 2e49261ec6..3b5f7fa6c1 100644
--- a/packages/xl-multi-column/src/blocks/Columns/index.ts
+++ b/packages/xl-multi-column/src/blocks/Columns/index.ts
@@ -1,28 +1,81 @@
+import { createBlockSpec } from "@blocknote/core";
+
+import { ColumnResizeExtension } from "../../extensions/ColumnResize/ColumnResizeExtension.js";
import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js";
-import { Column } from "../../pm-nodes/Column.js";
-import { ColumnList } from "../../pm-nodes/ColumnList.js";
-import { createBlockSpecFromTiptapNode } from "@blocknote/core";
+const COLUMN_WIDTH_DEFAULT = 1;
-export const ColumnBlock = createBlockSpecFromTiptapNode(
+export const ColumnBlock = createBlockSpec(
{
- node: Column,
- type: "column",
+ type: "column" as const,
+ propSchema: {
+ width: {
+ default: COLUMN_WIDTH_DEFAULT,
+ },
+ },
content: "none",
+ childBlocks: { topLevel: false },
},
{
- width: {
- default: 1,
+ meta: {
+ exitOnEnter: false,
+ draggable: false,
+ },
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.className = "bn-block-column";
+ dom.style.flexGrow = String(block.props.width ?? COLUMN_WIDTH_DEFAULT);
+
+ return {
+ dom,
+ contentDOM: dom,
+ update: (newNode: {
+ type: { name: string };
+ attrs: { width?: number };
+ }) => {
+ if (newNode.type.name !== "column") {
+ return false;
+ }
+ dom.style.flexGrow = String(
+ newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT,
+ );
+ return true;
+ },
+ };
},
},
- [MultiColumnDropHandlerExtension()],
-);
+ [MultiColumnDropHandlerExtension(), ColumnResizeExtension()],
+)();
-export const ColumnListBlock = createBlockSpecFromTiptapNode(
+export const ColumnListBlock = createBlockSpec(
{
- node: ColumnList,
- type: "columnList",
+ type: "columnList" as const,
+ propSchema: {},
content: "none",
+ childBlocks: {
+ allowedBlocks: ["column"],
+ min: 2,
+ collapseWhenEmptied: true,
+ },
+ },
+ {
+ meta: {
+ isolating: false,
+ draggable: false,
+ exitOnEnter: false,
+ },
+ render: () => {
+ const dom = document.createElement("div");
+ dom.className = "bn-block-column-list";
+ dom.style.display = "flex";
+
+ return {
+ dom,
+ contentDOM: dom,
+ update: (newNode: { type: { name: string } }) => {
+ return newNode.type.name === "columnList";
+ },
+ };
+ },
},
- {},
-);
+)();
diff --git a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
index 50d95c1292..9ffe5de2a0 100644
--- a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
+++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts
@@ -1,6 +1,5 @@
-import { BlockNoteEditor, getNodeById } from "@blocknote/core";
+import { BlockNoteEditor, createExtension, getNodeById } from "@blocknote/core";
import { SideMenuExtension } from "@blocknote/core/extensions";
-import { Extension } from "@tiptap/core";
import { Node } from "prosemirror-model";
import { Plugin, PluginKey, PluginView } from "prosemirror-state";
import { Decoration, DecorationSet, EditorView } from "prosemirror-view";
@@ -356,12 +355,7 @@ const createColumnResizePlugin = (editor: BlockNoteEditor) =>
view: (view) => new ColumnResizePluginView(editor, view),
});
-export const createColumnResizeExtension = (
- editor: BlockNoteEditor,
-) =>
- Extension.create({
- name: "columnResize",
- addProseMirrorPlugins() {
- return [createColumnResizePlugin(editor)];
- },
- });
+export const ColumnResizeExtension = createExtension(({ editor }) => ({
+ key: "columnResize",
+ prosemirrorPlugins: [createColumnResizePlugin(editor)],
+}));
diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts
index 61defa7886..e5db7a8a9e 100644
--- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts
+++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts
@@ -1,4 +1,8 @@
-import { type DropCursorHooks, getNearestBlockPos } from "@blocknote/core";
+import {
+ type DropCursorHooks,
+ getNearestBlockPos,
+ isContainerNode,
+} from "@blocknote/core";
import type { EditorState } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
@@ -31,10 +35,16 @@ export function detectEdgePosition(
const blockPos = getNearestBlockPos(state.doc, eventPos.pos);
- // If we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it
- // Why? Because we want to insert a new column in the columnList, instead of a new columnList inside of the column
+ // If we're at a block inside a column of a columnList, we want to compare
+ // the mouse position to the column, not the block inside it.
+ // Why? Because we want to insert a new sibling column in the columnList
+ // instead of a new container inside the column.
let resolved = state.doc.resolve(blockPos.posBeforeNode);
- if (resolved.parent.type.name === "column") {
+ if (
+ isContainerNode(resolved.parent.type) &&
+ resolved.depth > 0 &&
+ state.doc.resolve(resolved.before()).parent.type.name === "columnList"
+ ) {
resolved = state.doc.resolve(resolved.before());
}
diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
index 7c8e0b312e..8796cbb051 100644
--- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
+++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts
@@ -3,6 +3,7 @@ import {
UniqueID,
createExtension,
getBlockInfo,
+ isContainerNode,
nodeToBlock,
} from "@blocknote/core";
import { Plugin } from "prosemirror-state";
@@ -41,17 +42,30 @@ export function createMultiColumnHandleDropPlugin(
view.state.doc,
);
- if (blockInfo.blockNoteType === "column") {
- // Insert new column in existing columnList
- const parentBlock = view.state.doc
- .resolve(blockInfo.bnBlock.beforePos)
- .node();
+ // Whether the edge target is a `columnList` (after `detectEdgePosition`
+ // hoisted blocks inside a column to the column itself, the target's
+ // parent is the columnList).
+ const $target = view.state.doc.resolve(blockInfo.bnBlock.beforePos);
+ const targetInHorizontalContainer =
+ $target.node().type.name === "columnList";
+
+ if (targetInHorizontalContainer) {
+ // Insert a new sibling child in the existing horizontal container
+ // (e.g. a new column in the columnList).
+ const parentBlock = $target.node();
const columnList = nodeToBlock(
parentBlock,
view.state.doc,
);
+ // Whether the horizontal container's children are typed child
+ // containers (like `column`) that wrap the actual blocks, or plain
+ // blocks spliced in directly.
+ const targetIsChildContainer = isContainerNode(
+ blockInfo.bnBlock.node.type,
+ );
+
// Normalize column widths to average of 1
// In a `columnList`, we expect that the average width of each column
// is 1. However, there are cases in which this stops being true. For
@@ -59,24 +73,31 @@ export function createMultiColumnHandleDropPlugin(
// the average width to go down. This isn't really an issue until the
// user tries to add a new column, which will, in this case, be wider
// than expected. Therefore, we normalize the column widths to an
- // average of 1 here to avoid this issue.
- let sumColumnWidthPercent = 0;
- columnList.children.forEach((column) => {
- sumColumnWidthPercent += column.props.width as number;
- });
- const avgColumnWidthPercent =
- sumColumnWidthPercent / columnList.children.length;
-
- // If the average column width is not 1, normalize it. We're dealing
- // with floats so we need a small margin to account for precision
- // errors.
- if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) {
- const scalingFactor = 1 / avgColumnWidthPercent;
-
+ // average of 1 here to avoid this issue. (Only applies to child
+ // containers with a numeric `width` prop, i.e. columns.)
+ if (
+ columnList.children.every(
+ (column) => typeof column.props.width === "number",
+ )
+ ) {
+ let sumColumnWidthPercent = 0;
columnList.children.forEach((column) => {
- column.props.width =
- (column.props.width as number) * scalingFactor;
+ sumColumnWidthPercent += column.props.width as number;
});
+ const avgColumnWidthPercent =
+ sumColumnWidthPercent / columnList.children.length;
+
+ // If the average column width is not 1, normalize it. We're
+ // dealing with floats so we need a small margin to account for
+ // precision errors.
+ if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) {
+ const scalingFactor = 1 / avgColumnWidthPercent;
+
+ columnList.children.forEach((column) => {
+ column.props.width =
+ (column.props.width as number) * scalingFactor;
+ });
+ }
}
const index = columnList.children.findIndex(
@@ -85,22 +106,36 @@ export function createMultiColumnHandleDropPlugin(
const newChildren = columnList.children
// If the dragged block is in one of the columns, remove it.
- .map((column) => ({
- ...column,
- children: column.children.filter(
- (block) => block.id !== draggedBlock.id,
- ),
- }))
+ .map((column) =>
+ targetIsChildContainer
+ ? {
+ ...column,
+ children: column.children.filter(
+ (block) => block.id !== draggedBlock.id,
+ ),
+ }
+ : column,
+ )
// Remove empty columns (can happen when dragged block is removed).
- .filter((column) => column.children.length > 0)
- // Insert the dragged block in the correct position.
- .toSpliced(edgePos.position === "left" ? index : index + 1, 0, {
- type: "column",
- children: [draggedBlock],
- props: {},
- content: undefined,
- id: UniqueID.options.generateID(),
- });
+ .filter(
+ (column) => !targetIsChildContainer || column.children.length > 0,
+ )
+ // Insert the dragged block in the correct position, wrapped in a
+ // new child container (e.g. a new `column`) when the container's
+ // children are typed containers.
+ .toSpliced(
+ edgePos.position === "left" ? index : index + 1,
+ 0,
+ targetIsChildContainer
+ ? {
+ type: blockInfo.blockNoteType,
+ children: [draggedBlock],
+ props: {},
+ content: undefined,
+ id: UniqueID.options.generateID(),
+ }
+ : draggedBlock,
+ );
if (editor.getBlock(draggedBlock.id)) {
editor.removeBlocks([draggedBlock]);
diff --git a/packages/xl-multi-column/src/pm-nodes/Column.ts b/packages/xl-multi-column/src/pm-nodes/Column.ts
deleted file mode 100644
index dccf60c74b..0000000000
--- a/packages/xl-multi-column/src/pm-nodes/Column.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { suggestionMarks } from "@blocknote/core";
-import { Node } from "@tiptap/core";
-
-import { createColumnResizeExtension } from "../extensions/ColumnResize/ColumnResizeExtension.js";
-
-export const Column = Node.create({
- name: "column",
- group: "bnBlock childContainer",
- // A block always contains content, and optionally a blockGroup which contains nested blocks
- content: "blockContainer+",
- priority: 40,
- defining: true,
- marks() {
- return suggestionMarks(this.editor);
- },
- addAttributes() {
- return {
- width: {
- // Why does each column have a default width of 1, i.e. 100%? Because
- // when creating a new column, we want to make sure that existing
- // column widths are preserved, while the new one also has a sensible
- // width. If we'd set it so all column widths must add up to 100%
- // instead, then each time a new column is created, we'd have to assign
- // it a width depending on the total number of columns and also adjust
- // the widths of the other columns. The same can be said for using px
- // instead of percent widths and making them add to the editor width. So
- // using this method is both simpler and computationally cheaper. This
- // is possible because we can set the `flex-grow` property to the width
- // value, which handles all the resizing for us, instead of manually
- // having to set the `width` property of each column.
- default: 1,
- parseHTML: (element) => {
- const attr = element.getAttribute("data-width");
- if (attr === null) {
- return null;
- }
-
- const parsed = parseFloat(attr);
- if (isFinite(parsed)) {
- return parsed;
- }
-
- return null;
- },
- renderHTML: (attributes) => {
- return {
- "data-width": (attributes.width as number).toString(),
- style: `flex-grow: ${attributes.width as number};`,
- };
- },
- },
- };
- },
-
- parseHTML() {
- return [
- {
- tag: "div",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- if (element.getAttribute("data-node-type") === this.name) {
- return {};
- }
-
- return false;
- },
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- const column = document.createElement("div");
- column.className = "bn-block-column";
- column.setAttribute("data-node-type", this.name);
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- column.setAttribute(attribute, value as any); // TODO as any
- }
-
- return {
- dom: column,
- contentDOM: column,
- };
- },
-
- addExtensions() {
- return [createColumnResizeExtension(this.options.editor)];
- },
-});
diff --git a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts
deleted file mode 100644
index eeb06f4d4e..0000000000
--- a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { suggestionMarks } from "@blocknote/core";
-import { Node } from "@tiptap/core";
-
-export const ColumnList = Node.create({
- name: "columnList",
- group: "childContainer bnBlock blockGroupChild",
- // A block always contains content, and optionally a blockGroup which contains nested blocks
- content: "column column+", // min two columns
- priority: 40, // should be below blockContainer
- defining: true,
- marks() {
- return suggestionMarks(this.editor);
- },
- parseHTML() {
- return [
- {
- tag: "div",
- getAttrs: (element) => {
- if (typeof element === "string") {
- return false;
- }
-
- if (element.getAttribute("data-node-type") === this.name) {
- return {};
- }
-
- return false;
- },
- },
- ];
- },
-
- renderHTML({ HTMLAttributes }) {
- const columnList = document.createElement("div");
- columnList.className = "bn-block-column-list";
- columnList.setAttribute("data-node-type", this.name);
- for (const [attribute, value] of Object.entries(HTMLAttributes)) {
- columnList.setAttribute(attribute, value as any); // TODO as any
- }
- columnList.style.display = "flex";
-
- return {
- dom: columnList,
- contentDOM: columnList,
- };
- },
-});
diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
similarity index 95%
rename from packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap
rename to packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
index 87b5f2e588..a5d8ddf91f 100644
--- a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap
+++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`Test fixColumnList > First of two columns empty 1`] = `
+exports[`Test fixContainer > First of two columns empty 1`] = `
{
"content": [
{
@@ -35,7 +35,7 @@ exports[`Test fixColumnList > First of two columns empty 1`] = `
}
`;
-exports[`Test fixColumnList > Last of two columns empty 1`] = `
+exports[`Test fixContainer > Last of two columns empty 1`] = `
{
"content": [
{
@@ -70,7 +70,7 @@ exports[`Test fixColumnList > Last of two columns empty 1`] = `
}
`;
-exports[`Test fixColumnList > Two empty columns 1`] = `
+exports[`Test fixContainer > Two empty columns 1`] = `
{
"content": [
{
@@ -99,7 +99,7 @@ exports[`Test fixColumnList > Two empty columns 1`] = `
}
`;
-exports[`Test removeEmptyColumns > First of two columns empty 1`] = `
+exports[`Test removeEmptyChildren > First of two columns empty 1`] = `
{
"content": [
{
@@ -176,7 +176,7 @@ exports[`Test removeEmptyColumns > First of two columns empty 1`] = `
}
`;
-exports[`Test removeEmptyColumns > Last of two columns empty 1`] = `
+exports[`Test removeEmptyChildren > Last of two columns empty 1`] = `
{
"content": [
{
@@ -253,7 +253,7 @@ exports[`Test removeEmptyColumns > Last of two columns empty 1`] = `
}
`;
-exports[`Test removeEmptyColumns > Start and end columns empty 1`] = `
+exports[`Test removeEmptyChildren > Start and end columns empty 1`] = `
{
"content": [
{
@@ -336,7 +336,7 @@ exports[`Test removeEmptyColumns > Start and end columns empty 1`] = `
}
`;
-exports[`Test removeEmptyColumns > Two empty columns 1`] = `
+exports[`Test removeEmptyChildren > Two empty columns 1`] = `
{
"content": [
{
diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
similarity index 91%
rename from packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts
rename to packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
index b5bd190c6d..7a49e97907 100644
--- a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts
+++ b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts
@@ -2,14 +2,14 @@ import { describe, expect, it } from "vite-plus/test";
import { setupTestEnv } from "../../setupTestEnv.js";
import {
- fixColumnList,
- isEmptyColumn,
- removeEmptyColumns,
+ fixContainer,
+ isEmptyContainerChild,
+ removeEmptyChildren,
} from "@blocknote/core";
const getEditor = setupTestEnv();
-describe("Test isEmptyColumn", () => {
+describe("Test isEmptyContainerChild", () => {
it("Empty blocks", () => {
const schema = getEditor()._tiptapEditor.schema;
@@ -19,7 +19,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeTruthy();
+ expect(isEmptyContainerChild(column)).toBeTruthy();
});
it("Multiple blocks", () => {
@@ -34,7 +34,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
it("Block with children", () => {
@@ -51,7 +51,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
it("Block with text", () => {
@@ -65,7 +65,7 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
it("Non-text block", () => {
@@ -77,11 +77,11 @@ describe("Test isEmptyColumn", () => {
]),
]);
- expect(isEmptyColumn(column)).toBeFalsy();
+ expect(isEmptyContainerChild(column)).toBeFalsy();
});
});
-describe("Test removeEmptyColumns", () => {
+describe("Test removeEmptyChildren", () => {
it("Start and end columns empty", () => {
const editor = getEditor();
const schema = editor._tiptapEditor.schema;
@@ -116,7 +116,7 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -143,7 +143,7 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -170,7 +170,7 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -195,13 +195,13 @@ describe("Test removeEmptyColumns", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- removeEmptyColumns(tr, 1);
+ removeEmptyChildren(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
});
-describe("Test fixColumnList", () => {
+describe("Test fixContainer", () => {
it("First of two columns empty", () => {
const editor = getEditor();
const schema = editor._tiptapEditor.schema;
@@ -224,7 +224,7 @@ describe("Test fixColumnList", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
+ fixContainer(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -251,7 +251,7 @@ describe("Test fixColumnList", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
+ fixContainer(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
@@ -276,7 +276,7 @@ describe("Test fixColumnList", () => {
const tr = editor.prosemirrorState.tr;
tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList);
- fixColumnList(tr, 1);
+ fixContainer(tr, 1);
expect(tr.doc).toMatchSnapshot();
});
diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
index 2237513b6b..72b0f2d7ab 100644
--- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
@@ -1 +1 @@
-
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
+
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
diff --git a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
index 5876b3bd03..083e86c6ad 100644
--- a/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
+++ b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/internal.html
@@ -1 +1 @@
-
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
+
Column Paragraph 0
Column Paragraph 1
Column Paragraph 2
Column Paragraph 3
\ No newline at end of file
diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx
index de9567c59e..c2ea317bff 100644
--- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx
+++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx
@@ -126,7 +126,7 @@ export class ODTExporter<
numberedListIndex = 0;
}
- if (["columnList", "column"].includes(block.type)) {
+ if (this.isContainerBlock(block.type)) {
const children = await this.transformBlocks(block.children, 0);
const content = await this.mapBlock(
block as any,
diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx
index 6d8962eaaf..591e5db33e 100644
--- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx
+++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx
@@ -156,7 +156,7 @@ export class PDFExporter<
children,
); // TODO: any
- if (["pageBreak", "columnList", "column"].includes(b.type)) {
+ if (b.type === "pageBreak" || this.isContainerBlock(b.type)) {
ret.push(self);
continue;
}
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 85037877a4..798b380806 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -1445,6 +1445,33 @@ export const examples = {
readme:
"In this example, we create a custom block which renders a simple HTML paragraph with placeholder text. The block has no editable content.\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)",
},
+ {
+ projectSlug: "container-block",
+ fullSlug: "custom-schema/container-block",
+ pathFromRoot: "examples/06-custom-schema/09-container-block",
+ config: {
+ playground: true,
+ docs: true,
+ author: "nickthesick",
+ tags: [
+ "Intermediate",
+ "Blocks",
+ "Custom Schemas",
+ "Suggestion Menus",
+ "Slash Menu",
+ ],
+ dependencies: {
+ "react-icons": "^5.5.0",
+ } as any,
+ },
+ title: "Container Block",
+ group: {
+ pathFromRoot: "examples/06-custom-schema",
+ slug: "custom-schema",
+ },
+ readme:
+ 'In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.\n\nThe block uses the new `childBlocks` config on `BlockConfig`. Setting `childBlocks: { defaultChildren: [{ type: "paragraph" }] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested block children directly — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.\n\nThe callout also has an editable **title**, demonstrating the complementary "string prop slot" pattern: content that doesn\'t need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`.\n\nWe 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.\n\n**Try it out:**\n\n- Press the "/" key inside the callout\'s body and add a code block, heading, or list — anything goes.\n- Type a title into the title field — it\'s stored on `block.props.title`, not as document content.\n- Watch the JSON panel on the right update as you edit; the callout\'s children appear in `block.children`.\n- Insert a new callout via the Slash Menu (search "callout").\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)',
+ },
{
projectSlug: "draggable-inline-content",
fullSlug: "custom-schema/draggable-inline-content",
@@ -1853,7 +1880,7 @@ export const examples = {
tags: ["Extension"],
pro: true,
dependencies: {
- "@tiptap/core": "^3.13.0",
+ "@tiptap/core": "^3.29.2",
} as any,
},
title: "TipTap extension (arrow InputRule)",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c4afc0c985..9e77e5daeb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3393,6 +3393,52 @@ importers:
specifier: ^0.1.24
version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)
+ examples/06-custom-schema/09-container-block:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ react-icons:
+ specifier: ^5.5.0
+ version: 5.6.0(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))
+ vite-plus:
+ specifier: ^0.1.24
+ version: 0.1.24(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.1.0))(terser@5.46.2)(tsx@4.21.0)(typescript@5.9.3)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.9.0))(yaml@2.9.0)
+
examples/06-custom-schema/draggable-inline-content:
dependencies:
'@blocknote/ariakit':
@@ -11568,6 +11614,7 @@ packages:
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+ deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx
index 71101c7fa7..eb99225aa5 100644
--- a/tests/src/unit/react/useNodeViewBlock.test.tsx
+++ b/tests/src/unit/react/useNodeViewBlock.test.tsx
@@ -27,8 +27,15 @@ const createReproBlock = createReactBlockSpec(
{ render: (props) => },
);
+// A container block, whose node view's node IS the bnBlock — resolved by id
+// instead of by position.
+const createBoxBlock = createReactBlockSpec(
+ { type: "box", propSchema: {}, content: "none", childBlocks: true },
+ { render: (props) => },
+);
+
const schema = BlockNoteSchema.create().extend({
- blockSpecs: { repro: createReproBlock() },
+ blockSpecs: { repro: createReproBlock(), box: createBoxBlock() },
});
let editor: BlockNoteEditor;
@@ -43,6 +50,7 @@ beforeEach(() => {
{ type: "paragraph", content: "first" },
{ type: "repro", content: "target block" },
{ type: "paragraph", content: "last" },
+ { type: "box", children: [{ type: "paragraph", content: "inside" }] },
],
}) as BlockNoteEditor;
@@ -78,11 +86,14 @@ function renderHook(
return resolved;
}
-// Only the two fields `useNodeViewBlock` reads. Built structurally so `tests`
-// doesn't need a dependency on `@tiptap/react` just for its prop types.
-function makeProps(getPos: () => number | undefined) {
+// Only the fields `useNodeViewBlock` reads. Built structurally so `tests`
+// doesn't need a dependency on `@tiptap/react` just for its prop types. The
+// `node` defaults to a regular (non-container) block's node shape; container
+// tests pass the real PM node instead.
+function makeProps(getPos: () => number | undefined, node?: unknown) {
return {
getPos,
+ node: node ?? { type: { isInGroup: () => false } },
view: { state: { doc: editor.prosemirrorState.doc } },
} as unknown as Parameters[0];
}
@@ -170,4 +181,34 @@ describe("useNodeViewBlock", () => {
expect(resolved.id).toBe(target.id);
expect(resolved).not.toBe(seed);
});
+
+ it("rejects container blocks loudly instead of resolving the wrong block", () => {
+ const box = editor.document[3];
+ const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!;
+ const props = makeProps(() => undefined, node);
+
+ let captured: unknown;
+
+ function Probe() {
+ useNodeViewBlock(props, box);
+ return null;
+ }
+
+ root = createRoot(div, {
+ // React 19 reports uncaught render errors here instead of rethrowing
+ // out of `flushSync`.
+ onUncaughtError: (error: unknown) => {
+ captured = error;
+ },
+ });
+ try {
+ flushSync(() => {
+ root!.render();
+ });
+ } catch (error) {
+ captured = error;
+ }
+
+ expect(String(captured)).toMatch(/cannot resolve container block "box"/);
+ });
});