From 319e9e8cb2ade20d95e510a077c0ef58fc25c39a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 30 Apr 2026 12:57:42 +0200 Subject: [PATCH 1/6] docs: add example --- .../08-container-block/.bnexample.json | 15 +++ .../08-container-block/README.md | 18 +++ .../08-container-block/index.html | 14 +++ .../08-container-block/main.tsx | 11 ++ .../08-container-block/package.json | 32 +++++ .../08-container-block/src/App.tsx | 117 ++++++++++++++++++ .../08-container-block/src/Callout.tsx | 76 ++++++++++++ .../08-container-block/src/styles.css | 93 ++++++++++++++ .../08-container-block/tsconfig.json | 36 ++++++ .../08-container-block/vite.config.ts | 32 +++++ playground/src/examples.gen.tsx | 26 ++++ pnpm-lock.yaml | 65 ++++++++-- 12 files changed, 527 insertions(+), 8 deletions(-) create mode 100644 examples/06-custom-schema/08-container-block/.bnexample.json create mode 100644 examples/06-custom-schema/08-container-block/README.md create mode 100644 examples/06-custom-schema/08-container-block/index.html create mode 100644 examples/06-custom-schema/08-container-block/main.tsx create mode 100644 examples/06-custom-schema/08-container-block/package.json create mode 100644 examples/06-custom-schema/08-container-block/src/App.tsx create mode 100644 examples/06-custom-schema/08-container-block/src/Callout.tsx create mode 100644 examples/06-custom-schema/08-container-block/src/styles.css create mode 100644 examples/06-custom-schema/08-container-block/tsconfig.json create mode 100644 examples/06-custom-schema/08-container-block/vite.config.ts diff --git a/examples/06-custom-schema/08-container-block/.bnexample.json b/examples/06-custom-schema/08-container-block/.bnexample.json new file mode 100644 index 0000000000..3de7330631 --- /dev/null +++ b/examples/06-custom-schema/08-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/08-container-block/README.md b/examples/06-custom-schema/08-container-block/README.md new file mode 100644 index 0000000000..d710e8c2e8 --- /dev/null +++ b/examples/06-custom-schema/08-container-block/README.md @@ -0,0 +1,18 @@ +# 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 `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. + +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. +- 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/08-container-block/index.html b/examples/06-custom-schema/08-container-block/index.html new file mode 100644 index 0000000000..19321f77b5 --- /dev/null +++ b/examples/06-custom-schema/08-container-block/index.html @@ -0,0 +1,14 @@ + + + + + Container Block + + + +
+ + + diff --git a/examples/06-custom-schema/08-container-block/main.tsx b/examples/06-custom-schema/08-container-block/main.tsx new file mode 100644 index 0000000000..677c7f7eed --- /dev/null +++ b/examples/06-custom-schema/08-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/08-container-block/package.json b/examples/06-custom-schema/08-container-block/package.json new file mode 100644 index 0000000000..63a2f24446 --- /dev/null +++ b/examples/06-custom-schema/08-container-block/package.json @@ -0,0 +1,32 @@ +{ + "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": "vite", + "dev": "vite", + "build:prod": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@blocknote/ariakit": "latest", + "@blocknote/core": "latest", + "@blocknote/mantine": "latest", + "@blocknote/react": "latest", + "@blocknote/shadcn": "latest", + "@mantine/core": "^8.3.11", + "@mantine/hooks": "^8.3.11", + "@mantine/utils": "^6.0.22", + "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": "^8.0.8" + } +} \ No newline at end of file diff --git a/examples/06-custom-schema/08-container-block/src/App.tsx b/examples/06-custom-schema/08-container-block/src/App.tsx new file mode 100644 index 0000000000..ae4a322513 --- /dev/null +++ b/examples/06-custom-schema/08-container-block/src/App.tsx @@ -0,0 +1,117 @@ +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 `defaultBlocks` (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 ( +
+
BlockNote Editor:
+
+ { + setBlocks(editor.document); + }} + > + { + const defaultItems = getDefaultReactSlashMenuItems(editor); + const lastBasicBlockIndex = defaultItems.findLastIndex( + (item) => item.group === "Basic blocks", + ); + defaultItems.splice( + lastBasicBlockIndex + 1, + 0, + insertCallout(editor), + ); + return filterSuggestionItems(defaultItems, query); + }} + /> + +
+
Document JSON:
+
+
+          {JSON.stringify(blocks, null, 2)}
+        
+
+
+ ); +} diff --git a/examples/06-custom-schema/08-container-block/src/Callout.tsx b/examples/06-custom-schema/08-container-block/src/Callout.tsx new file mode 100644 index 0000000000..2cad08e682 --- /dev/null +++ b/examples/06-custom-schema/08-container-block/src/Callout.tsx @@ -0,0 +1,76 @@ +import { createReactBlockSpec, NodeViewWrapper } 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 `container` +// config — the block hosts arbitrary child blocks in its body, exposed at +// runtime as `block.children`. +export const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + container: { + min: 1, + defaultBlocks: ["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 }, + }); + }; + + return ( + + +
+ + ); + }, + }, +); diff --git a/examples/06-custom-schema/08-container-block/src/styles.css b/examples/06-custom-schema/08-container-block/src/styles.css new file mode 100644 index 0000000000..c92baabdf1 --- /dev/null +++ b/examples/06-custom-schema/08-container-block/src/styles.css @@ -0,0 +1,93 @@ +.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-body { + flex-grow: 1; + min-width: 0; +} diff --git a/examples/06-custom-schema/08-container-block/tsconfig.json b/examples/06-custom-schema/08-container-block/tsconfig.json new file mode 100644 index 0000000000..dbe3e6f62d --- /dev/null +++ b/examples/06-custom-schema/08-container-block/tsconfig.json @@ -0,0 +1,36 @@ +{ + "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY", + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": false, + "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/" + } + ] +} \ No newline at end of file diff --git a/examples/06-custom-schema/08-container-block/vite.config.ts b/examples/06-custom-schema/08-container-block/vite.config.ts new file mode 100644 index 0000000000..f62ab20bc2 --- /dev/null +++ b/examples/06-custom-schema/08-container-block/vite.config.ts @@ -0,0 +1,32 @@ +// 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"; +// import eslintPlugin from "vite-plugin-eslint"; +// https://vitejs.dev/config/ +export default defineConfig((conf) => ({ + 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), + }, +})); diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index ae117ce392..ebeeffee18 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1414,6 +1414,32 @@ }, "readme": "This example shows how you can configure the editor's default blocks. Specifically, heading blocks are made to only support levels 1-3, and cannot be toggleable.\n\n**Relevant Docs:**\n\n- [Editor Setup](/docs/getting-started/editor-setup)\n- [Default Schema](/docs/foundations/schemas)\n- [Custom Schemas](/docs/features/custom-schemas)" }, + { + "projectSlug": "container-block", + "fullSlug": "custom-schema/container-block", + "pathFromRoot": "examples/06-custom-schema/08-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": "non-editable-block", "fullSlug": "custom-schema/non-editable-block", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 966cf9e887..0517592a12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3395,6 +3395,55 @@ importers: specifier: ^8.0.8 version: 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.8.3) + examples/06-custom-schema/08-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: ^8.3.11 + version: 8.3.18(@mantine/hooks@8.3.18(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: ^8.3.11 + version: 8.3.18(react@19.2.5) + '@mantine/utils': + specifier: ^6.0.22 + version: 6.0.22(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.8.3)) + vite: + specifier: ^8.0.8 + version: 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.8.3) + examples/06-custom-schema/08-non-editable-block: dependencies: '@blocknote/ariakit': @@ -24078,8 +24127,8 @@ snapshots: '@next/eslint-plugin-next': 16.2.2 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -24128,7 +24177,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -24139,7 +24188,7 @@ snapshots: tinyglobby: 0.2.16 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -24153,14 +24202,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -24201,7 +24250,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -24212,7 +24261,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From fe10ae95b92ce5a22652802b625a82352e310598 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 30 Apr 2026 13:11:56 +0200 Subject: [PATCH 2/6] fix: endPosition needs the be before the end of the document --- packages/core/src/extensions/TrailingNode/TrailingNode.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/extensions/TrailingNode/TrailingNode.ts b/packages/core/src/extensions/TrailingNode/TrailingNode.ts index 523c5fef4a..fe879985c1 100644 --- a/packages/core/src/extensions/TrailingNode/TrailingNode.ts +++ b/packages/core/src/extensions/TrailingNode/TrailingNode.ts @@ -22,7 +22,7 @@ export const TrailingNodeExtension = createExtension(() => { appendTransaction: (_, __, state) => { const { doc, tr, schema } = state; const shouldInsertNodeAtEnd = plugin.getState(state); - const endPosition = doc.content.size - 2; + const endPosition = doc.content.size - 1; const type = schema.nodes["blockContainer"]; const contentType = schema.nodes["paragraph"]; if (!shouldInsertNodeAtEnd) { From 1651f5b4c6a6dcd0690550452f42aa941621664d Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 30 Apr 2026 13:14:22 +0200 Subject: [PATCH 3/6] chore: better typing for extension --- .../src/extensions/tiptap-extensions/UniqueID/UniqueID.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index a3ce6f3828..407dff9e05 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -5,7 +5,7 @@ import { getChangedRanges, } from "@tiptap/core"; import { Fragment, Slice } from "prosemirror-model"; -import { Plugin, PluginKey } from "prosemirror-state"; +import { Plugin, PluginKey, Transaction } from "prosemirror-state"; import { uuidv4 } from "lib0/random"; /** @@ -49,7 +49,7 @@ const UniqueID = Extension.create({ addOptions() { return { attributeName: "id", - types: [], + types: [] as string[], setIdAttribute: false, isWithinEditor: undefined as ((element: Element) => boolean) | undefined, generateID: () => { @@ -67,7 +67,7 @@ const UniqueID = Extension.create({ return uuidv4(); }, - filterTransaction: null, + filterTransaction: null as ((tr: Transaction) => boolean) | null, }; }, addGlobalAttributes() { From 88ec90317229cd762924eefd6489c73037302457 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 30 Apr 2026 16:55:17 +0200 Subject: [PATCH 4/6] feat: support nested blocks via "containers" --- .../src/api/nodeConversions/blockToNode.ts | 24 ++- .../managers/ExtensionManager/extensions.ts | 16 +- packages/core/src/schema/blocks/createSpec.ts | 165 +++++++++++++++++- packages/core/src/schema/blocks/internal.ts | 20 +++ packages/core/src/schema/blocks/types.ts | 54 +++++- packages/react/src/index.ts | 5 + .../schema/ReactBlockSpec.container.test.tsx | 72 ++++++++ packages/react/src/schema/ReactBlockSpec.tsx | 157 +++++++++++------ .../ReactBlockSpec.container.test.tsx.snap | 36 ++++ 9 files changed, 490 insertions(+), 59 deletions(-) create mode 100644 packages/react/src/schema/ReactBlockSpec.container.test.tsx create mode 100644 packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 206ff8d9fd..4c727a3191 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -16,10 +16,11 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; +import type { ContainerConfig } from "../../schema/blocks/types.js"; import { getColspan, isPartialTableCell } from "../../util/table.js"; import { UnreachableCaseError } from "../../util/typescript.js"; import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js"; -import { getStyleSchema } from "../pmUtil.js"; +import { getBlockSchema, getStyleSchema } from "../pmUtil.js"; /** * Convert a StyledText inline element to a @@ -367,12 +368,31 @@ export function blockToNode( ); } else if (schema.nodes[block.type].isInGroup("bnBlock")) { // this is a bnBlock node like Column or ColumnList that directly translates to a prosemirror node + let effectiveChildren = children; + + // Seed `defaultBlocks` for container blocks when no children would + // otherwise be present — covers both `block.children === undefined` and + // `block.children === []` (e.g. converting a leaf block whose + // `nodeToBlock` produced empty children into a container). + if (children.length === 0) { + // `container` is normalized to `ContainerConfig | undefined` at spec + // registration time (see addNodeAndExtensionsToSpec). + const containerConfig = getBlockSchema(schema)[block.type] + ?.container as ContainerConfig | undefined; + const defaultBlocks = containerConfig?.defaultBlocks; + if (defaultBlocks && defaultBlocks.length > 0) { + effectiveChildren = defaultBlocks.map((type) => + blockToNode({ type } as PartialBlock, schema, styleSchema), + ); + } + } + return schema.nodes[block.type].createChecked( { id: id, ...block.props, }, - children, + effectiveChildren, ); } else { throw new Error( diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 21d2d86f91..6a94b313a1 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -62,7 +62,21 @@ 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", + // 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]) => { + const group = (spec.implementation.node as Node).config.group; + return ( + typeof group === "string" && + group.split(/\s+/).includes("bnBlock") + ); + }) + .map(([name]) => name), + ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, }), diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 6df3e68aa4..73329c4e2d 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -8,6 +8,7 @@ import { } from "../../editor/BlockNoteExtension.js"; import { PropSchema } from "../propTypes.js"; import { + containerContentExpression, getBlockFromPos, propsToAttributes, wrapInBlockStructure, @@ -18,6 +19,7 @@ import { BlockImplementation, BlockImplementationOrCreator, BlockSpec, + ContainerConfig, LooseBlockSpec, } from "./types.js"; @@ -135,6 +137,132 @@ export function getParseRules< return rules; } +function buildContainerNode< + TName extends string, + TProps extends PropSchema, +>( + blockConfig: BlockConfig, + blockImplementation: BlockImplementation, + containerConfig: ContainerConfig, + // priority is hardcoded inside the node spec below; the param is kept so the + // caller signature mirrors the non-container path but is intentionally unused. + _priority?: number, +) { + return Node.create({ + name: blockConfig.type, + content: containerContentExpression(containerConfig), + group: + containerConfig.topLevel === false + ? "bnBlock childContainer" + : "bnBlock childContainer blockGroupChild", + // All bnBlock-group structural nodes carry these collab annotation marks + // (see Doc, BlockGroup, BlockContainer, Table, Column/ColumnList). + marks: "deletion insertion modification", + selectable: blockImplementation.meta?.selectable ?? true, + isolating: blockImplementation.meta?.isolating ?? true, + defining: true, + // Hardcoded priority 40 (matches the historical Column/ColumnList shape). + // Why hardcoded and ignoring the caller-supplied priority? Because PM's + // `fillBefore` picks the FIRST type in an or-expression / group when + // auto-filling a non-optional node. With `blockGroupChild+` content + // (which includes containers themselves), if a container appeared first + // in the schema's `nodes` map, PM would try to auto-fill empty + // containers with another container and stack-overflow. We need + // `blockContainer` (priority 50, registered later) to come BEFORE + // container blocks in the schema map. Tiptap registers higher-priority + // extensions earlier, so we want our priority to be LOWER than 50. + // The schema layer passes its own per-block priority (~101) but we + // override it here for cycle-safety. + priority: 40, + addAttributes() { + return propsToAttributes(blockConfig.propSchema); + }, + + parseHTML() { + const rules: TagParseRule[] = [ + { + tag: "*", + getAttrs: (element) => { + if (typeof element === "string") { + return false; + } + if (element.getAttribute("data-node-type") === blockConfig.type) { + return {}; + } + return false; + }, + }, + ]; + + if (blockImplementation.parse) { + rules.push({ + tag: "*", + getAttrs(node) { + if (typeof node === "string") { + return false; + } + const props = blockImplementation.parse?.(node); + if (props === undefined) { + return false; + } + return props; + }, + preserveWhitespace: true, + }); + } + + return rules; + }, + + renderHTML({ HTMLAttributes }) { + const div = document.createElement("div"); + div.setAttribute("data-node-type", blockConfig.type); + for (const [attribute, value] of Object.entries(HTMLAttributes)) { + div.setAttribute(attribute, value as any); + } + return { + dom: div, + contentDOM: div, + }; + }, + + addNodeView() { + return (props) => { + const editor = this.options.editor; + // For container blocks the PM node IS the bnBlock (no blockContainer + // wrapper), so the id lives on `props.node.attrs.id` directly. We + // can't use getBlockFromPos here because it walks up to the parent. + const blockIdentifier = (props.node.attrs as Record).id; + if (!blockIdentifier) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute. Make sure it is registered with UniqueID.`, + ); + } + const block = editor.getBlock(blockIdentifier); + if (!block) { + throw new Error( + `Container block with id "${blockIdentifier}" not found.`, + ); + } + const blockContentDOMAttributes = + this.options.domAttributes?.blockContent || {}; + + const nodeView = blockImplementation.render.call( + { blockContentDOMAttributes, props, renderType: "nodeView" }, + block as any, + editor as any, + ) as unknown as NodeView; + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(nodeView, this.editor); + } + + return nodeView; + }; + }, + }); +} + // 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< @@ -147,9 +275,32 @@ export function addNodeAndExtensionsToSpec< extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, ): LooseBlockSpec { + // Normalize the `container: true` shorthand once, here. Downstream code + // sees `ContainerConfig | undefined` only. + const containerConfig: ContainerConfig | undefined = + blockConfig.container === true ? {} : blockConfig.container; + blockConfig = { ...blockConfig, container: containerConfig }; + + if (containerConfig && blockConfig.content !== "none") { + throw new Error( + `Block "${blockConfig.type}" sets \`container\` but its \`content\` is "${blockConfig.content}". Container blocks must declare \`content: "none"\`.`, + ); + } + const node = ((blockImplementation as any).node as Node) || - Node.create({ + (containerConfig + ? buildContainerNode( + blockConfig as unknown as BlockConfig, + blockImplementation as unknown as BlockImplementation< + TName, + TProps, + "none" + >, + containerConfig, + priority, + ) + : Node.create({ name: blockConfig.type, content: (blockConfig.content === "inline" ? "inline*" @@ -226,7 +377,7 @@ export function addNodeAndExtensionsToSpec< return typedNodeView; }; }, - }); + })); if (node.name !== blockConfig.type) { throw new Error( @@ -400,6 +551,12 @@ export function createBlockSpec< return undefined; } + // Container blocks own their outer DOM entirely (the PM node IS + // the bnBlock — no `blockContent` wrapper) — pass through. + if (editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock")) { + return output; + } + return wrapInBlockStructure( output, block.type, @@ -419,6 +576,10 @@ export function createBlockSpec< editor as any, ); + if (editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock")) { + 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 eed8cf9fa3..4e4190a70e 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -10,10 +10,30 @@ import { StyleSchema } from "../styles/types.js"; import { BlockConfig, BlockSchemaWithBlock, + ContainerConfig, LooseBlockSpec, SpecificBlock, } from "./types.js"; +// Builds the ProseMirror content expression for a container block from its +// cardinality config. +export function containerContentExpression(config: ContainerConfig): string { + const min = config.min; + const max = config.max; + + if (max !== undefined) { + const effectiveMin = min ?? 1; + return `blockGroupChild{${effectiveMin},${max}}`; + } + if (min === 0) { + return "blockGroupChild*"; + } + if (min === undefined || min === 1) { + return "blockGroupChild+"; + } + return `blockGroupChild{${min},}`; +} + // Function that uses the 'propSchema' of a blockConfig to create a TipTap // node's `addAttributes` property. // TODO: extract function diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index ba3da8b737..d315907f58 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 { @@ -60,6 +60,34 @@ export interface BlockConfigMeta { isolating?: boolean; } +/** + * Configuration for a block that hosts other blocks as its body (a "container"). + * When set, the block's ProseMirror node is emitted in the `bnBlock` / + * `childContainer` groups with `blockContainer{min,max}` content — the same + * shape that columns use today. Child blocks live on `block.children` at + * runtime (matching the column model). Requires `content: "none"`. + */ +export type ContainerConfig = { + /** Minimum number of child blocks. Defaults to 1. */ + min?: number; + /** Maximum number of child blocks. Defaults to unbounded. */ + max?: number; + /** + * Block types to seed the container with on first insert. Each entry + * produces one empty block of that type. Ignored when the inserted partial + * block already provides explicit `children`. + */ + defaultBlocks?: string[]; + /** + * 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; +}; + /** * BlockConfig contains the "schema" info about a Block type * i.e. what props it supports, what content it supports, etc. @@ -82,8 +110,14 @@ 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 `blockContainer+` + * content; child blocks are exposed on `block.children`. Requires + * `content: "none"`. Pass `true` for defaults or an object to constrain + * cardinality and seed the initial children. + */ + container?: true | ContainerConfig; } /** @@ -189,6 +223,7 @@ export type LooseBlockSpec< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -247,6 +282,7 @@ export type BlockSpecs = { contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -511,6 +547,18 @@ export type BlockImplementation< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + // TODO this may not be the right API for this, but let's just stick with it for now + /** + * 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 today; non-container blocks always + * recreate on attr changes. + */ + update?: (node: PMNode) => boolean | void; }; /** diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 6ed745a789..819bb715cb 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -133,6 +133,11 @@ export * from "./schema/ReactBlockSpec.js"; export * from "./schema/ReactInlineContentSpec.js"; export * from "./schema/ReactStyleSpec.js"; +// TODO figure out the right API for this, but let's just stick with it for now +// Re-exported so container-block authors can return `` from +// their `render` (the framework no longer auto-wraps containers). +export { NodeViewWrapper } from "@tiptap/react"; + export * from "./util/elementOverflow.js"; export * from "./util/mergeRefs.js"; 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..14bb9343fb --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx @@ -0,0 +1,72 @@ +import { + BlockNoteEditor, + BlockNoteSchema, + defaultBlockSpecs, +} from "@blocknote/core"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { createReactBlockSpec } from "./ReactBlockSpec.js"; + +// Same shape as the example callout block (`examples/06-custom-schema/08-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, + container: { min: 1, defaultBlocks: ["paragraph"] }, + }, + { + render: ({ contentRef }) => ( +
+
+
+ ), + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + } as const, +}); + +describe("React updateBlock → container with defaultBlocks (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, + ); +}); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 1ab1b43da8..ec15f0138c 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -33,11 +33,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 +236,31 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { + const isContainer = !!editor.pmSchema.nodes[block.type]?.isInGroup( + "bnBlock", + ); 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) { + return content; + } return ( - { - refCB(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } - }} - context={context} - /> + {content} ); }, editor); @@ -274,12 +286,33 @@ export function createReactBlockSpec< // 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. - const block = getBlockFromPos( - props.getPos, - editor, - props.editor, - blockConfig.type, - ); + const isContainer = props.node.type.isInGroup("bnBlock"); + let block; + if (isContainer) { + // Container blocks are bnBlock-group nodes (no blockContainer + // wrapper), so the id lives on `props.node.attrs.id`. The + // standard getBlockFromPos walks up to a parent, which would + // return the wrong node here. + const id = (props.node.attrs as Record).id; + if (!id) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute.`, + ); + } + block = editor.getBlock(id); + if (!block) { + throw new Error( + `Container block with id "${id}" not found.`, + ); + } + } else { + block = getBlockFromPos( + props.getPos, + editor, + props.editor, + blockConfig.type, + ); + } const ref = useReactNodeView().nodeViewContentRef; @@ -288,6 +321,32 @@ export function createReactBlockSpec< } const BlockContent = blockImplementation.render; + const content = ( + { + ref(element); + if (element) { + if (!isContainer) { + element.className = mergeCSSClasses( + "bn-inline-content", + element.className, + ); + } + element.dataset.nodeViewContent = ""; + } + }} + /> + ); + if (isContainer) { + // 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 content; + } return ( - { - ref(element); - if (element) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - element.dataset.nodeViewContent = ""; - } - }} - /> + {content} ); }, @@ -318,8 +364,29 @@ 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", + }, +] +`; From 5a40d019fcf6a62cc1106e513d7ebd953dadb87a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 30 Apr 2026 16:55:49 +0200 Subject: [PATCH 5/6] feat: migrate columns to use container API --- .../src/blocks/Columns/index.ts | 73 +++++++++++++-- .../ColumnResize/ColumnResizeExtension.ts | 20 ++--- .../xl-multi-column/src/pm-nodes/Column.ts | 88 ------------------- .../multi-column/undefined/external.html | 2 +- .../multi-column/undefined/internal.html | 2 +- 5 files changed, 75 insertions(+), 110 deletions(-) delete mode 100644 packages/xl-multi-column/src/pm-nodes/Column.ts diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index 2e49261ec6..1f55b5532a 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,22 +1,77 @@ +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); + } + 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..46a24abdcb 100644 --- a/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts +++ b/packages/xl-multi-column/src/extensions/ColumnResize/ColumnResizeExtension.ts @@ -1,6 +1,9 @@ -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 +359,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 d527edfd2e..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/Column.ts +++ /dev/null @@ -1,88 +0,0 @@ -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: "deletion insertion modification", - 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 @@ -

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 From ab86f5b4d24812ce87765a8ef3132b9d59930a37 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 30 Apr 2026 17:00:50 +0200 Subject: [PATCH 6/6] test: update snapshots --- tests/nextjs-test-app/package.json | 8 ++++---- .../unit/core/schema/__snapshots__/blocks.json | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/tests/nextjs-test-app/package.json b/tests/nextjs-test-app/package.json index f4f81ae9f7..d0098c1e75 100644 --- a/tests/nextjs-test-app/package.json +++ b/tests/nextjs-test-app/package.json @@ -3,10 +3,10 @@ "private": true, "version": "0.0.0", "dependencies": { - "@blocknote/core": "file:.tarballs/blocknote-core-0.48.1.tgz", - "@blocknote/mantine": "file:.tarballs/blocknote-mantine-0.48.1.tgz", - "@blocknote/react": "file:.tarballs/blocknote-react-0.48.1.tgz", - "@blocknote/server-util": "file:.tarballs/blocknote-server-util-0.48.1.tgz", + "@blocknote/core": "file:.tarballs/blocknote-core-0.49.0.tgz", + "@blocknote/mantine": "file:.tarballs/blocknote-mantine-0.49.0.tgz", + "@blocknote/react": "file:.tarballs/blocknote-react-0.49.0.tgz", + "@blocknote/server-util": "file:.tarballs/blocknote-server-util-0.49.0.tgz", "@mantine/core": "^8.3.11", "@mantine/hooks": "^8.3.11", "next": "^16.0.0", diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index 142a5e7771..22d2a856b6 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -1,6 +1,7 @@ { "audio": { "config": { + "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -39,6 +40,7 @@ }, "bulletListItem": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -75,6 +77,7 @@ }, "checkListItem": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -118,6 +121,7 @@ }, "codeBlock": { "config": { + "container": undefined, "content": "inline", "propSchema": { "language": { @@ -145,6 +149,7 @@ }, "customParagraph": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -175,6 +180,7 @@ }, "divider": { "config": { + "container": undefined, "content": "none", "propSchema": {}, "type": "divider", @@ -194,6 +200,7 @@ }, "file": { "config": { + "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -226,6 +233,7 @@ }, "heading": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -280,6 +288,7 @@ }, "image": { "config": { + "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -331,6 +340,7 @@ }, "numberedListItem": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -371,6 +381,7 @@ }, "pageBreak": { "config": { + "container": undefined, "content": "none", "propSchema": {}, "type": "pageBreak", @@ -385,6 +396,7 @@ }, "paragraph": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -424,6 +436,7 @@ }, "quote": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -450,6 +463,7 @@ }, "simpleCustomParagraph": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -479,6 +493,7 @@ }, "simpleImage": { "config": { + "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -521,6 +536,7 @@ }, "table": { "config": { + "container": undefined, "content": "table", "propSchema": { "textColor": { @@ -541,6 +557,7 @@ }, "toggleListItem": { "config": { + "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -580,6 +597,7 @@ }, "video": { "config": { + "container": undefined, "content": "none", "propSchema": { "backgroundColor": {