`/``).
+ return content;
+ }
return (
- {
- ref(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- element.dataset.nodeViewContent = "";
- }
- }}
- />
+ {content}
);
},
@@ -319,8 +375,28 @@ export function createReactBlockSpec<
},
)(this.props!) as ReturnType;
} else {
+ const isContainer =
+ !!editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock");
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 (
- {
- refCB(element);
- if (element) {
- element.className = mergeCSSClasses(
- "bn-inline-content",
- element.className,
- );
- }
- }}
- />
+ {content}
);
}, editor);
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..810f3cc319
--- /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 defaultBlocks (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/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts
index 2e49261ec6..c1670e030d 100644
--- a/packages/xl-multi-column/src/blocks/Columns/index.ts
+++ b/packages/xl-multi-column/src/blocks/Columns/index.ts
@@ -1,22 +1,79 @@
+import {
+ createBlockSpec,
+ createBlockSpecFromTiptapNode,
+} 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";
+// 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 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 others. The same can be said for
+// using px instead of percent widths and making them add to the editor width.
+// Using flex-grow on the value handles all the resizing for us, instead of
+// manually having to set `width` on each column.
+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",
+ // Columns only ever live inside a `columnList` (whose content expression
+ // is `column column+`). `topLevel: false` keeps column out of the
+ // generic `blockGroupChild` group so it can't be inserted at the document
+ // root or as a child of any other block.
+ container: { topLevel: false },
},
{
- width: {
- default: 1,
+ render: (block) => {
+ const dom = document.createElement("div");
+ dom.className = "bn-block-column";
+ const width = block.props.width ?? COLUMN_WIDTH_DEFAULT;
+ dom.style.flexGrow = String(width);
+ dom.setAttribute("data-node-type", "column");
+ dom.setAttribute("data-id", block.id);
+ if (width !== COLUMN_WIDTH_DEFAULT) {
+ dom.setAttribute("data-width", String(width));
+ }
+
+ return {
+ dom,
+ contentDOM: dom,
+ update: (newNode: {
+ type: { name: string };
+ attrs: { id?: string; width?: number };
+ }) => {
+ if (newNode.type.name !== "column") {
+ return false;
+ }
+ const newWidth = newNode.attrs.width ?? COLUMN_WIDTH_DEFAULT;
+ dom.style.flexGrow = String(newWidth);
+ if (newWidth !== COLUMN_WIDTH_DEFAULT) {
+ dom.setAttribute("data-width", String(newWidth));
+ } else {
+ dom.removeAttribute("data-width");
+ }
+ if (newNode.attrs.id) {
+ dom.setAttribute("data-id", newNode.attrs.id);
+ } else {
+ dom.removeAttribute("data-id");
+ }
+ return true;
+ },
+ };
},
},
- [MultiColumnDropHandlerExtension()],
-);
+ [MultiColumnDropHandlerExtension(), ColumnResizeExtension()],
+)();
export const ColumnListBlock = createBlockSpecFromTiptapNode(
{
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/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/test/conversions/__snapshots__/multi-column/undefined/external.html b/packages/xl-multi-column/src/test/conversions/__snapshots__/multi-column/undefined/external.html
index 2237513b6b..ec052ff27b 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..ea4d3b437a 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 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 85037877a4..efae7f0509 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 `container` config on `BlockConfig`. Setting `container: { defaultBlocks: ["paragraph"] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested `blockContainer+` children — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.\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- 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",
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==}
|