From 5a8dd3496e27c2ebbea38d3554bda29c3fd584a0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 6 Aug 2026 08:42:12 +0200 Subject: [PATCH 1/6] feat(core): add container block API for nested blocks Introduce a `container` config on `BlockConfig` so a `content: "none"` block can hold nested `blockContainer+` children (the same shape columns use). `createSpec` builds a bnBlock-group node for container blocks, normalizes the `container: true` shorthand in place (preserving config identity), and `blockToNode` seeds `defaultBlocks` when a container is created empty. --- .../src/api/nodeConversions/blockToNode.ts | 34 +- .../managers/ExtensionManager/extensions.ts | 16 +- packages/core/src/schema/blocks/createSpec.ts | 385 +++++++++++++----- packages/core/src/schema/blocks/internal.ts | 21 +- packages/core/src/schema/blocks/types.ts | 54 ++- .../core/schema/__snapshots__/blocks.json | 18 + 6 files changed, 415 insertions(+), 113 deletions(-) diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 61bc44d68a..57c78af7d7 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -16,10 +16,15 @@ 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, isPlainContentNodeType } from "../pmUtil.js"; +import { + getBlockSchema, + getStyleSchema, + isPlainContentNodeType, +} from "../pmUtil.js"; /** * Convert a StyledText inline element to a @@ -378,6 +383,31 @@ export function blockToNode( groupNode ? [contentNode, groupNode] : contentNode, ); } 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, + ), + ); + } + } + // `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 +415,7 @@ export function blockToNode( 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 2592b25d2a..520690d113 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -59,7 +59,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 5db7ff48eb..605cd4e802 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -13,8 +13,10 @@ import { ExtensionFactoryInstance, } from "../../editor/BlockNoteExtension.js"; import { nonFormattingMarks } from "../markGroups.js"; +import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { + containerContentExpression, getBlockFromNodeView, propsToAttributes, wrapInBlockStructure, @@ -25,6 +27,7 @@ import { BlockImplementation, BlockImplementationOrCreator, BlockSpec, + ContainerConfig, LooseBlockSpec, } from "./types.js"; @@ -167,6 +170,134 @@ export function getParseRules< return rules; } +function buildContainerNode( + 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 allow the block-level suggestion marks + // (see Doc, BlockGroup, BlockContainer, Table), so a whole container can be + // marked inserted/deleted/modified in suggestion mode. Resolved + // conditionally so a plain editor without those marks doesn't reference an + // unknown mark group. + marks() { + return suggestionMarks(this.editor); + }, + 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< @@ -179,119 +310,151 @@ export function addNodeAndExtensionsToSpec< 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); - }, + // Normalize the `container: true` shorthand once, here. Downstream code + // sees `ContainerConfig | undefined` only. + // + // Normalize in place rather than spreading into a fresh object: `init()` + // calls this per schema instance on the same shared `blockSpec.config`, and + // consumers such as `checkMultiColumnBlocksInSchema` compare + // `blockSchema[type]` by reference across schemas. A new object per call + // would break that identity. The mutation only fires for the `true` + // shorthand and is idempotent, so the shared config stays stable. + const containerConfig: ContainerConfig | undefined = + blockConfig.container === true ? {} : blockConfig.container; + if (blockConfig.container !== containerConfig) { + blockConfig.container = containerConfig; + } - parseHTML() { - return getParseRules(blockConfig, blockImplementation); - }, + 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"\`.`, + ); + } - 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, + const node = + ((blockImplementation as any).node as Node) || + (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*" + : 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); }, - 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 || {}; - const nodeView = blockImplementation.render.call( - { - blockContentDOMAttributes, - props, - renderType: "nodeView", - propSchema: blockConfig.propSchema, - }, - block as any, - editor as any, - ); + parseHTML() { + return getParseRules(blockConfig, blockImplementation); + }, - // Cast needed because render returns `dom: HTMLElement | DocumentFragment` - // but tiptap's NodeView expects `dom: HTMLElement` - const typedNodeView = nodeView as unknown as NodeView; + 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, + ); + }, - if (blockImplementation.meta?.selectable === false) { - applyNonSelectableBlockFix(typedNodeView, this.editor); - } + 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 || {}; + + const nodeView = blockImplementation.render.call( + { + blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, + block as any, + editor as any, + ); + + // Cast needed because render returns `dom: HTMLElement | DocumentFragment` + // but tiptap's NodeView expects `dom: HTMLElement` + const typedNodeView = nodeView as unknown as NodeView; + + if (blockImplementation.meta?.selectable === false) { + applyNonSelectableBlockFix(typedNodeView, this.editor); + } - // 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; - }; - }, - }); + // 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 (node.name !== blockConfig.type) { throw new Error( @@ -471,6 +634,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, @@ -490,6 +659,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 cfd17b9d11..c24ff198d5 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,7 +6,26 @@ 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 { ContainerConfig, LooseBlockSpec } 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. diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 00b564ef0b..deaa3fa96a 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 { @@ -61,6 +61,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. @@ -87,8 +115,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; } /** @@ -210,6 +244,7 @@ export type LooseBlockSpec< contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -268,6 +303,7 @@ export type BlockSpecs = { contentDOM?: HTMLElement; ignoreMutation?: (mutation: ViewMutationRecord) => boolean; destroy?: () => void; + update?: (node: PMNode) => boolean | void; }; toExternalHTML?: ( block: any, @@ -553,6 +589,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/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index f0c513b02f..df4364810f 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": "plain", "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": { From 299492938e32482262bd521b2469e17aae828214 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 6 Aug 2026 08:42:21 +0200 Subject: [PATCH 2/6] feat(react): render container blocks in ReactBlockSpec Resolve the block from `props.node.attrs.id` for bnBlock-group container nodes (they have no blockContainer wrapper), and pass through their DOM without the blockContent wrapper. Adds a document-level conversion test. --- packages/react/src/index.ts | 5 + .../schema/ReactBlockSpec.container.test.tsx | 75 +++++++++ packages/react/src/schema/ReactBlockSpec.tsx | 149 ++++++++++++------ .../ReactBlockSpec.container.test.tsx.snap | 36 +++++ 4 files changed, 219 insertions(+), 46 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/react/src/index.ts b/packages/react/src/index.ts index 2259babd84..9fc6838754 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -143,6 +143,11 @@ export * from "./schema/useNodeViewBlock.js"; export * from "./icons.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..3b6789afeb --- /dev/null +++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx @@ -0,0 +1,75 @@ +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, + 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 4bd1649292..1584430044 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,30 @@ 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); @@ -280,7 +291,32 @@ export function createReactBlockSpec< // 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); + const isContainer = props.node.type.isInGroup("bnBlock"); + // `useNodeViewBlock` is a hook, so it must run unconditionally + // (rules of hooks). For container blocks its position-based + // result is discarded in favor of the id-based lookup below. + const nodeViewBlock = useNodeViewBlock(props, initialBlock); + let block; + if (isContainer) { + // Container blocks are bnBlock-group nodes (no blockContainer + // wrapper), so the id lives on `props.node.attrs.id`. The + // standard position-based resolution 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 = nodeViewBlock; + } const ref = useReactNodeView().nodeViewContentRef; @@ -289,6 +325,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} ); }, @@ -319,8 +368,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", + }, +] +`; From 6ee83f9dbb5337702799eee7ea363c7453ee3e06 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 6 Aug 2026 08:42:21 +0200 Subject: [PATCH 3/6] refactor(xl-multi-column): migrate columns to the container API Define `column` via `createBlockSpec` with `container: { topLevel: false }` instead of a hand-written ProseMirror node, and port the column-resize extension to `createExtension`. Removes the now-unused Column.ts pm-node. --- .../src/blocks/Columns/index.ts | 73 +++++++++++++-- .../ColumnResize/ColumnResizeExtension.ts | 16 +--- .../xl-multi-column/src/pm-nodes/Column.ts | 91 ------------------- .../multi-column/undefined/external.html | 2 +- .../multi-column/undefined/internal.html | 2 +- 5 files changed, 71 insertions(+), 113 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..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 @@ -

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 951fa7b0a56e080e6e7d8f563ccbfa8b4f340e77 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 6 Aug 2026 08:42:27 +0200 Subject: [PATCH 4/6] docs(examples): add container-block example A Notion-style Callout block that holds nested blocks via the new `container` config, with a slash-menu insert and a live JSON panel. --- .../09-container-block/.bnexample.json | 15 +++ .../09-container-block/README.md | 18 +++ .../09-container-block/index.html | 14 +++ .../09-container-block/main.tsx | 11 ++ .../09-container-block/package.json | 31 +++++ .../09-container-block/src/App.tsx | 118 ++++++++++++++++++ .../09-container-block/src/Callout.tsx | 71 +++++++++++ .../09-container-block/src/styles.css | 93 ++++++++++++++ .../09-container-block/tsconfig.json | 29 +++++ .../09-container-block/vite-env.d.ts | 1 + .../09-container-block/vite.config.ts | 31 +++++ playground/src/examples.gen.tsx | 27 ++++ pnpm-lock.yaml | 47 +++++++ 13 files changed, 506 insertions(+) create mode 100644 examples/06-custom-schema/09-container-block/.bnexample.json create mode 100644 examples/06-custom-schema/09-container-block/README.md create mode 100644 examples/06-custom-schema/09-container-block/index.html create mode 100644 examples/06-custom-schema/09-container-block/main.tsx create mode 100644 examples/06-custom-schema/09-container-block/package.json create mode 100644 examples/06-custom-schema/09-container-block/src/App.tsx create mode 100644 examples/06-custom-schema/09-container-block/src/Callout.tsx create mode 100644 examples/06-custom-schema/09-container-block/src/styles.css create mode 100644 examples/06-custom-schema/09-container-block/tsconfig.json create mode 100644 examples/06-custom-schema/09-container-block/vite-env.d.ts create mode 100644 examples/06-custom-schema/09-container-block/vite.config.ts 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..d710e8c2e8 --- /dev/null +++ b/examples/06-custom-schema/09-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/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..73fced42ca --- /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 `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/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx new file mode 100644 index 0000000000..b2c077b6c5 --- /dev/null +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -0,0 +1,71 @@ +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/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css new file mode 100644 index 0000000000..c92baabdf1 --- /dev/null +++ b/examples/06-custom-schema/09-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/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/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==} From 97ae720d90b6c5a5837b694e3a63e93cdf91fc3f Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 6 Aug 2026 09:27:19 +0200 Subject: [PATCH 5/6] fix: harden container block implementation - Add aria-label to callout icon toggle button for accessibility - Guard against cyclic defaultBlocks expansion in blockToNode - Add null check for spec.implementation.node in ExtensionManager - Clean up stale data-id in Columns update handler - Apply blockContentDOMAttributes for container blocks in toExternalHTML --- .../09-container-block/src/Callout.tsx | 1 + .../src/api/nodeConversions/blockToNode.ts | 20 ++++++++++++------- .../managers/ExtensionManager/extensions.ts | 6 +++++- packages/react/src/schema/ReactBlockSpec.tsx | 11 +++++++++- .../src/blocks/Columns/index.ts | 2 ++ 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/examples/06-custom-schema/09-container-block/src/Callout.tsx b/examples/06-custom-schema/09-container-block/src/Callout.tsx index b2c077b6c5..5b8ed49885 100644 --- a/examples/06-custom-schema/09-container-block/src/Callout.tsx +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -59,6 +59,7 @@ export const createCallout = createReactBlockSpec( type={"button"} contentEditable={false} onClick={cycleFlavor} + aria-label={`Cycle callout flavor (current: ${flavor.title})`} title={`Click to cycle flavor (current: ${flavor.title})`} > diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 57c78af7d7..b0d1ebab21 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -342,6 +342,7 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), + _seenNodeTypes?: Set, ) { let id = block.id; @@ -398,13 +399,18 @@ export function blockToNode( | undefined; const defaultBlocks = containerConfig?.defaultBlocks; if (defaultBlocks && defaultBlocks.length > 0) { - effectiveChildren = defaultBlocks.map((type) => - blockToNode( - { type } as PartialBlock, - schema, - styleSchema, - ), - ); + const seenNodes = _seenNodeTypes ?? new Set(); + seenNodes.add(block.type); + effectiveChildren = defaultBlocks + .filter((type) => !seenNodes.has(type)) + .map((type) => + blockToNode( + { type } as PartialBlock, + schema, + styleSchema, + seenNodes, + ), + ); } } diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 520690d113..71176e5576 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -66,7 +66,11 @@ export function getDefaultTiptapExtensions( // 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; + const node = spec.implementation.node as Node | undefined; + if (!node?.config) { + return false; + } + const group = node.config.group; return ( typeof group === "string" && group.split(/\s+/).includes("bnBlock") diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 1584430044..82d4913e9a 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -258,7 +258,16 @@ export function createReactBlockSpec< /> ); if (isContainer) { - return content; + return ( + + {content} + + ); } return ( Date: Thu, 6 Aug 2026 09:36:42 +0200 Subject: [PATCH 6/6] fix: resolve container block lookup for suggestion-deleted nodes Fall back to nodeToBlock when editor.getBlock(id) returns null for container blocks. This handles suggestion mode and versioning diff where deleted nodes get disambiguated IDs. Also update the blocks.json snapshot to remove stale "container": undefined entries. --- packages/core/src/schema/blocks/createSpec.ts | 10 ++++------ packages/react/src/schema/ReactBlockSpec.tsx | 10 ++++------ .../unit/core/schema/__snapshots__/blocks.json | 18 ------------------ 3 files changed, 8 insertions(+), 30 deletions(-) diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 605cd4e802..98b08578f1 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 { @@ -273,12 +274,9 @@ function buildContainerNode( `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 block = + editor.getBlock(blockIdentifier) ?? + nodeToBlock(props.node, editor.prosemirrorView.state.doc); const blockContentDOMAttributes = this.options.domAttributes?.blockContent || {}; diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 82d4913e9a..ae6e41c885 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -11,6 +11,7 @@ import { ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, mergeCSSClasses, + nodeToBlock, Props, PropSchema, } from "@blocknote/core"; @@ -317,12 +318,9 @@ export function createReactBlockSpec< `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.`, - ); - } + block = + editor.getBlock(id) ?? + nodeToBlock(props.node, editor.prosemirrorView.state.doc); } else { block = nodeViewBlock; } diff --git a/tests/src/unit/core/schema/__snapshots__/blocks.json b/tests/src/unit/core/schema/__snapshots__/blocks.json index df4364810f..f0c513b02f 100644 --- a/tests/src/unit/core/schema/__snapshots__/blocks.json +++ b/tests/src/unit/core/schema/__snapshots__/blocks.json @@ -1,7 +1,6 @@ { "audio": { "config": { - "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -40,7 +39,6 @@ }, "bulletListItem": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -77,7 +75,6 @@ }, "checkListItem": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -121,7 +118,6 @@ }, "codeBlock": { "config": { - "container": undefined, "content": "plain", "propSchema": { "language": { @@ -149,7 +145,6 @@ }, "customParagraph": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -180,7 +175,6 @@ }, "divider": { "config": { - "container": undefined, "content": "none", "propSchema": {}, "type": "divider", @@ -200,7 +194,6 @@ }, "file": { "config": { - "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -233,7 +226,6 @@ }, "heading": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -288,7 +280,6 @@ }, "image": { "config": { - "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -340,7 +331,6 @@ }, "numberedListItem": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -381,7 +371,6 @@ }, "pageBreak": { "config": { - "container": undefined, "content": "none", "propSchema": {}, "type": "pageBreak", @@ -396,7 +385,6 @@ }, "paragraph": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -436,7 +424,6 @@ }, "quote": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -463,7 +450,6 @@ }, "simpleCustomParagraph": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -493,7 +479,6 @@ }, "simpleImage": { "config": { - "container": undefined, "content": "none", "propSchema": { "backgroundColor": { @@ -536,7 +521,6 @@ }, "table": { "config": { - "container": undefined, "content": "table", "propSchema": { "textColor": { @@ -557,7 +541,6 @@ }, "toggleListItem": { "config": { - "container": undefined, "content": "inline", "propSchema": { "backgroundColor": { @@ -597,7 +580,6 @@ }, "video": { "config": { - "container": undefined, "content": "none", "propSchema": { "backgroundColor": {