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/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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": { From a0fa0c332a693c72683bec144895ff6ec7c376ec Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 11 Aug 2026 12:44:15 +0200 Subject: [PATCH 7/8] refactor(core): unify and simplify container-block APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates the container-block support introduced on this branch and trims its scope: - Predicates: collapse the ~6 ad-hoc "is this a container?" variants to two named ones — isContainerNode(type) for a live NodeType and isContainerType(blockSpecs, type) for the spec set — plus the getChildBlocksConfig accessor. Route schema, containerUI, extensions and Exporter through isContainerType (also fixing the exporter's incomplete check) and the bnBlock dispatch sites through isContainerNode. - validateChildBlocks: keep only the cheap min/max/allowedBlocks/ defaultChildren correctness guards; drop the dev-only warnings and the fill-cycle detector (a custom-schema foot-gun, out of scope). - defaultChildren: only seed to satisfy a positive min (no more re-seeding an empty min:0 container on round-trip), and guard the seeding recursion against a self-referential config instead of overflowing the stack. - SideMenu: collapse the container hit-testing probe to a single pass. --- .../custom-schemas/container-blocks.mdx | 120 ++++ .../features/custom-schemas/custom-blocks.mdx | 6 + .../09-container-block/README.md | 5 +- .../09-container-block/src/App.tsx | 4 +- .../09-container-block/src/Callout.tsx | 61 +- .../09-container-block/src/styles.css | 25 + .../.bnexample.json | 2 +- .../commands/mergeBlocks/mergeBlocks.ts | 6 +- .../commands/moveBlocks/moveBlocks.ts | 51 +- .../commands/nestBlock/nestBlock.ts | 36 +- .../commands/replaceBlocks/replaceBlocks.ts | 49 +- .../replaceBlocks/util/fixColumnList.ts | 173 ------ .../__snapshots__/containers.test.ts.snap | 318 +++++++++++ .../containers/containerNav.ts | 134 +++++ .../containers/containerUI.ts | 92 +++ .../containers/containers.test.ts | 538 ++++++++++++++++++ .../containers/fixContainer.ts | 247 ++++++++ .../html/util/serializeBlocksExternalHTML.ts | 25 + .../html/util/serializeBlocksInternalHTML.ts | 23 +- .../src/api/nodeConversions/blockToNode.ts | 77 ++- .../api/nodeConversions/fragmentToBlocks.ts | 41 +- .../src/api/nodeConversions/nodeToBlock.ts | 46 +- .../managers/ExtensionManager/extensions.ts | 24 +- packages/core/src/exporter/Exporter.ts | 41 +- .../core/src/extensions/SideMenu/SideMenu.ts | 123 +++- .../getDraggableBlockFromElement.ts | 23 +- .../KeyboardShortcutsExtension.ts | 248 ++++++-- packages/core/src/index.ts | 4 +- packages/core/src/pm-nodes/README.md | 51 +- packages/core/src/schema/blocks/createSpec.ts | 63 +- packages/core/src/schema/blocks/internal.ts | 91 ++- packages/core/src/schema/blocks/types.ts | 109 +++- .../schema/blocks/validateChildBlocks.test.ts | 107 ++++ .../src/schema/blocks/validateChildBlocks.ts | 92 +++ packages/core/src/schema/index.ts | 1 + packages/core/src/schema/schema.ts | 14 + .../core/src/yjs/extensions/FixUpSchema.ts | 10 +- .../src/components/Popovers/BlockPopover.tsx | 13 +- packages/react/src/index.ts | 6 +- .../react/src/schema/ChildBlocksWrapper.tsx | 57 ++ .../schema/ReactBlockSpec.container.test.tsx | 24 +- packages/react/src/schema/ReactBlockSpec.tsx | 173 +++--- .../ReactBlockSpec.container.test.tsx.snap | 2 +- packages/react/src/schema/useNodeViewBlock.ts | 11 + .../src/docx/docxExporter.test.ts | 77 +++ .../xl-docx-exporter/src/docx/docxExporter.ts | 4 +- .../src/react-email/reactEmailExporter.tsx | 18 + .../src/blocks/Columns/index.ts | 79 ++- .../DropCursor/multiColumnDropCursor.ts | 26 +- .../DropCursor/multiColumnHandleDropPlugin.ts | 113 ++-- .../src/pm-nodes/ColumnList.ts | 47 -- ...test.ts.snap => fixContainer.test.ts.snap} | 14 +- ...lumnLists.test.ts => fixContainer.test.ts} | 36 +- .../xl-odt-exporter/src/odt/odtExporter.tsx | 2 +- .../xl-pdf-exporter/src/pdf/pdfExporter.tsx | 2 +- playground/src/examples.gen.tsx | 4 +- .../src/unit/react/useNodeViewBlock.test.tsx | 49 +- 57 files changed, 3168 insertions(+), 669 deletions(-) create mode 100644 docs/content/docs/features/custom-schemas/container-blocks.mdx delete mode 100644 packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts create mode 100644 packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap create mode 100644 packages/core/src/api/blockManipulation/containers/containerNav.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containerUI.ts create mode 100644 packages/core/src/api/blockManipulation/containers/containers.test.ts create mode 100644 packages/core/src/api/blockManipulation/containers/fixContainer.ts create mode 100644 packages/core/src/schema/blocks/validateChildBlocks.test.ts create mode 100644 packages/core/src/schema/blocks/validateChildBlocks.ts create mode 100644 packages/react/src/schema/ChildBlocksWrapper.tsx delete mode 100644 packages/xl-multi-column/src/pm-nodes/ColumnList.ts rename packages/xl-multi-column/src/test/commands/util/__snapshots__/{fixColumnLists.test.ts.snap => fixContainer.test.ts.snap} (95%) rename packages/xl-multi-column/src/test/commands/util/{fixColumnLists.test.ts => fixContainer.test.ts} (91%) diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx new file mode 100644 index 0000000000..2fa4208d65 --- /dev/null +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -0,0 +1,120 @@ +--- +title: Container Blocks +description: Learn how to create custom blocks that hold other blocks as their body +--- + +# Container Blocks + +A *container block* is a custom block that holds other blocks as its body — like a Notion-style callout wrapping a paragraph and a code block, or a multi-column layout. BlockNote's built-in multi-column blocks (`columnList` / `column`) are implemented with this same mechanism. + +Take a look at the demo below, in which we add a custom callout block that can contain any other blocks: + + + +## Declaring a Container Block + +Add the `childBlocks` option to your block config (created with [`createBlockSpec` or `createReactBlockSpec`](/docs/features/custom-schemas/custom-blocks)). The block must declare `content: "none"` — its body is made of child blocks, not inline content: + +```typescript +const createCallout = createReactBlockSpec( + { + type: "callout", + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + // The callout has no inline content of its own; it defers to its children. + content: "none", + // Marks the block as a container of other blocks. + childBlocks: { + // At least one child block is required. + min: 1, + // Seeded when the block is inserted without explicit children. + defaultChildren: [{ type: "paragraph" }], + }, + }, + { + render: (props) => ( + + {/* Child blocks are rendered into the element you attach contentRef to. */} +
+ + ), + }, +); +``` + +At runtime, the contained blocks live on `block.children` — the same field used for indented (nested) blocks: + +```json +{ + "id": "callout-1", + "type": "callout", + "props": { "flavor": "tip" }, + "content": undefined, + "children": [ + { + "id": "para-1", + "type": "paragraph", + "content": [{ "type": "text", "text": "Hello", "styles": {} }], + "children": [] + } + ] +} +``` + +### `ChildBlocksWrapper` (React) + +Container blocks own their entire outer DOM — BlockNote doesn't wrap them in the usual block element. Your `render` should return a `ChildBlocksWrapper` (exported from `@blocknote/react`) as the root element: it automatically applies the attributes BlockNote relies on for HTML parsing and UI positioning (`data-node-type`, `data-id`, and each non-default prop as a `data-*` attribute). Any other props (`className`, event handlers) are passed through. + +For vanilla JS blocks (`createBlockSpec`), return a DOM element with `contentDOM` pointing to where children mount. BlockNote fills in the missing `data-*` attributes when serializing to HTML, but it's good practice to set `data-node-type` and `data-id` yourself so UI features (side menu positioning, drag & drop) work on the live editor DOM. + +## `childBlocks` options + +| Option | Default | Description | +| --- | --- | --- | +| `allowedBlocks` | any block | Block types allowed as direct children. Container types are enforced exactly by the schema; regular block types collapse to "any regular block" (they all share one node type internally). | +| `min` / `max` | `1` / unbounded | How many children are allowed. Enforced by the editor schema. | +| `defaultChildren` | — | Partial blocks seeded when the container is inserted without children. Validated against `allowedBlocks`/`min`/`max` when the schema is created. | +| `topLevel` | `true` | Whether the block can appear anywhere a regular block goes. Set `false` for blocks that only make sense inside a specific parent (like a `column` inside a `columnList`). | +| `repair` | — | Structural cleanup after children are removed: `removeEmptyChildren` drops emptied children, and `belowMin` (`"unwrap"` / `"remove"` / `"fill"`) decides what happens when fewer than `min` non-empty children remain. Column lists use `{ removeEmptyChildren: true, belowMin: "unwrap" }`. | + +Behavioral options live in the block implementation's `meta` instead, since they don't affect the document schema: + +| Meta option | Default | Description | +| --- | --- | --- | +| `exitOnEnter` | `true` | Pressing Enter on an empty last child moves it out of the container, list-style. Disable to keep the cursor inside (columns do this). | +| `childLayout` | `"vertical"` | Set `"horizontal"` for side-by-side children (like columns) — drives side menu positioning and edge-drop behavior. | +| `draggable` | `true` | Whether the container itself gets a side menu drag handle. | + +### Restricting children: a columnList-style pair + +`allowedBlocks` + `topLevel: false` let you build tightly-coupled structures. This is exactly how the multi-column blocks are defined: + +```typescript +// The outer container: only accepts columns, at least two of them. +childBlocks: { + allowedBlocks: ["column"], + min: 2, + repair: { removeEmptyChildren: true, belowMin: "unwrap" }, +} + +// The column: holds any blocks, but can only live inside a columnList. +childBlocks: { topLevel: false } +``` + +The same pattern works for table-like structures (a "grid" of "cells"), FAQ lists, and so on. Configurations are validated when the schema is created — unknown `allowedBlocks` entries, impossible `defaultChildren`, and container cycles that could never be auto-filled all fail up front with a clear error. + +## Editable fields that aren't document content + +A container can only have one "hole" for child blocks and no inline content of its own. If your block needs an extra editable field — like the callout's title — store it as a **string prop** and render a regular `` inside the block (in a `contentEditable={false}` wrapper), committing the value with `editor.updateBlock`. See the demo above for a full implementation. + +This is the right tool when the field doesn't need rich text formatting, comments, or multiplayer cursors — it's plain data on the block, not document content. + +## Interop behavior + +- **HTML**: containers serialize to a `
` with their children nested inside and non-default props as `data-*` attributes, and parse back losslessly. +- **Markdown**: containers are flattened — their children are exported in order, and Markdown import never produces containers. +- **Exporters** (`@blocknote/xl-docx-exporter`, `xl-pdf-exporter`, `xl-odt-exporter`, `xl-email-exporter`): container blocks require an explicit block mapping that places their children; a missing mapping throws a clear error. diff --git a/docs/content/docs/features/custom-schemas/custom-blocks.mdx b/docs/content/docs/features/custom-schemas/custom-blocks.mdx index 60bacafe68..a8ae04da4e 100644 --- a/docs/content/docs/features/custom-schemas/custom-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/custom-blocks.mdx @@ -68,6 +68,12 @@ type BlockConfig = { we set `content` to `"inline"`._ + + _Blocks with `content: "none"` can instead hold **other blocks** as their + body by declaring the `childBlocks` option — see [Container + Blocks](/docs/features/custom-schemas/container-blocks)._ + + `propSchema:` The `PropSchema` specifies the props that the block supports. Block props (properties) are data stored with your Block in the document, and can be used to customize its appearance or behavior. ```typescript diff --git a/examples/06-custom-schema/09-container-block/README.md b/examples/06-custom-schema/09-container-block/README.md index d710e8c2e8..4a285c69b8 100644 --- a/examples/06-custom-schema/09-container-block/README.md +++ b/examples/06-custom-schema/09-container-block/README.md @@ -2,13 +2,16 @@ 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. +The block uses the new `childBlocks` config on `BlockConfig`. Setting `childBlocks: { defaultChildren: [{ type: "paragraph" }] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested block children directly — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime. + +The callout also has an editable **title**, demonstrating the complementary "string prop slot" pattern: content that doesn't need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`. We also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks. **Try it out:** - Press the "/" key inside the callout's body and add a code block, heading, or list — anything goes. +- Type a title into the title field — it's stored on `block.props.title`, not as document content. - Watch the JSON panel on the right update as you edit; the callout's children appear in `block.children`. - Insert a new callout via the Slash Menu (search "callout"). diff --git a/examples/06-custom-schema/09-container-block/src/App.tsx b/examples/06-custom-schema/09-container-block/src/App.tsx index 73fced42ca..3f3255bbba 100644 --- a/examples/06-custom-schema/09-container-block/src/App.tsx +++ b/examples/06-custom-schema/09-container-block/src/App.tsx @@ -27,7 +27,7 @@ const schema = BlockNoteSchema.create().extend({ // 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). +// configured `defaultChildren` (a single paragraph here). const insertCallout = (editor: typeof schema.BlockNoteEditor) => ({ title: "Callout", subtext: "Container block that wraps other blocks", @@ -50,7 +50,7 @@ export default function App() { initialContent: [ { type: "paragraph", - content: "Welcome — this demo shows the new `container` block kind.", + content: "Welcome — this demo shows the new container block kind.", }, { type: "callout", 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 5b8ed49885..35eea18d72 100644 --- a/examples/06-custom-schema/09-container-block/src/Callout.tsx +++ b/examples/06-custom-schema/09-container-block/src/Callout.tsx @@ -1,4 +1,4 @@ -import { createReactBlockSpec, NodeViewWrapper } from "@blocknote/react"; +import { ChildBlocksWrapper, createReactBlockSpec } from "@blocknote/react"; import { MdCheckCircle, MdInfo, MdLightbulb, MdWarning } from "react-icons/md"; import "./styles.css"; @@ -11,9 +11,14 @@ export const calloutTypes = [ { 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`. +// The Callout block. Declared with `content: "none"` plus the new +// `childBlocks` config — the block hosts arbitrary child blocks in its body, +// exposed at runtime as `block.children`. +// +// The callout's title demonstrates the complementary "string prop slot" +// pattern: content that shouldn't be part of the rich-text document (no +// formatting, comments, or multiplayer cursors needed) can live in a plain +// string prop, edited through a regular rendered inside the block. export const createCallout = createReactBlockSpec( { type: "callout", @@ -22,11 +27,14 @@ export const createCallout = createReactBlockSpec( default: "tip", values: ["tip", "info", "warning", "success"], }, + title: { + default: "", + }, }, content: "none", - container: { + childBlocks: { min: 1, - defaultBlocks: ["paragraph"], + defaultChildren: [{ type: "paragraph" }], }, }, { @@ -47,12 +55,23 @@ export const createCallout = createReactBlockSpec( }); }; + const commitTitle = (title: string) => { + if (title !== props.block.props.title) { + props.editor.updateBlock(props.block, { + type: "callout", + props: { title }, + }); + } + }; + return ( - -
- +
+ {/* The title lives in a string prop, not in document content — + it's edited via a plain input. `contentEditable={false}` keeps + ProseMirror from treating typing here as document input. */} +
+ commitTitle(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.currentTarget.blur(); + } + }} + /> +
+
+
+ ); }, }, diff --git a/examples/06-custom-schema/09-container-block/src/styles.css b/examples/06-custom-schema/09-container-block/src/styles.css index c92baabdf1..19dce1d861 100644 --- a/examples/06-custom-schema/09-container-block/src/styles.css +++ b/examples/06-custom-schema/09-container-block/src/styles.css @@ -87,6 +87,31 @@ opacity: 0.75; } +.callout-main { + flex-grow: 1; + min-width: 0; +} + +.callout-title-wrapper { + margin-bottom: 4px; +} + +.callout-title-input { + width: 100%; + border: none; + background: none; + outline: none; + font-weight: 600; + font-size: 1rem; + color: inherit; + padding: 0; +} + +.callout-title-input::placeholder { + color: var(--callout-accent, #888); + opacity: 0.5; +} + .callout-body { flex-grow: 1; min-width: 0; diff --git a/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json index 1893df45b9..ca1451aaa8 100644 --- a/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json +++ b/examples/08-extensions/01-tiptap-arrow-conversion/.bnexample.json @@ -5,6 +5,6 @@ "tags": ["Extension"], "pro": true, "dependencies": { - "@tiptap/core": "^3.13.0" + "@tiptap/core": "^3.29.2" } } diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index ce1a9455db..012f322ff7 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -153,7 +153,11 @@ const mergeBlocks = ( ); } - // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v. + // Merging into or out of container blocks (columnLists, callouts, ...) + // is intentionally unsupported — `canMerge` refuses it above. The + // container-boundary Backspace/Delete branches in + // `KeyboardShortcutsExtension` handle those cases by moving blocks + // across the boundary instead of merging their content. dispatch( state.tr.delete( prevBlockInfo.blockContent.afterPos - 1, diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 71598b7d69..3e92da9127 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -14,6 +14,10 @@ import { getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; +import { + flattenNonInsertableBlocks, + isContainerNode, +} from "../../containers/fixContainer.js"; import { insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; @@ -131,16 +135,6 @@ function updateBlockSelectionFromData( tr.setSelection(selection); } -// Replaces top-level `column` blocks with their children, as a `column` is not -// a valid block outside a `columnList`. Other blocks are returned as-is. -function flattenColumns( - blocks: Block[], -): Block[] { - return blocks.flatMap((block) => - block.type === "column" ? block.children : [block], - ); -} - /** * Removes the given blocks from the editor, then inserts them before/after a * reference block. @@ -169,10 +163,12 @@ export function moveBlocks( // // When the non-empty block is moved up, the column is seen as empty and // collapsed in the removal step, so the following insertion fails. - removeAndInsertBlocks(tr, blocks, [], { fixColumns: false }); + removeAndInsertBlocks(tr, blocks, [], { fixContainers: false }); insertBlocks( tr, - flattenColumns(blocks), + // Blocks that can't stand on their own outside their container (e.g. a + // `column` outside its `columnList`) are replaced by their children. + flattenNonInsertableBlocks(blocks, editor.pmSchema), referenceBlock, placement, ); @@ -207,12 +203,27 @@ export function moveSelectedBlocksAndSelection( }); } -// Checks if a block is in a valid place after being moved. This check is -// primitive at the moment and only returns false if the block's parent is a -// `columnList` block. This is because regular blocks cannot be direct children -// of `columnList` blocks. -function checkPlacementIsValid(parentBlock?: Block): boolean { - return !parentBlock || parentBlock.type !== "columnList"; +// Checks if a regular block is in a valid place after being moved, i.e. +// whether its would-be parent accepts a regular block as a direct child. +// Regular blocks nest under any non-container block (they go into its +// `blockGroup`), but a container block (e.g. a `columnList`) only accepts +// what its content expression allows. +function checkPlacementIsValid( + editor: BlockNoteEditor, + parentBlock?: Block, +): boolean { + if (!parentBlock) { + return true; + } + const parentNodeType = editor.pmSchema.nodes[parentBlock.type]; + if (!parentNodeType || !isContainerNode(parentNodeType)) { + return true; + } + return ( + parentNodeType.contentMatch.matchType( + editor.pmSchema.nodes["blockContainer"], + ) !== null + ); } // Gets the placement for moving a block up. This has 3 cases: @@ -254,7 +265,7 @@ function getMoveUpPlacement( } const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlockParent)) { return getMoveUpPlacement( editor, placement === "after" @@ -306,7 +317,7 @@ function getMoveDownPlacement( } const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlockParent)) { return getMoveDownPlacement( editor, placement === "before" diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index a0f76fdff0..a0a09d0099 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -19,9 +19,7 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -163,9 +161,7 @@ export function liftItem( const { $from, $to } = tr.selection; const range = $from.blockRange( $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), // change 1 ); if (!range) { return false; @@ -195,14 +191,36 @@ export function canNestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); - return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null; + // Mirrors `sinkItem`'s precondition: nesting is only possible under a + // previous sibling that is itself a `blockContainer`. (A previous sibling + // of another type — e.g. a container block — made this return true while + // `nestBlock` did nothing.) + return ( + tr.doc.resolve(blockContainer.beforePos).nodeBefore?.type === + editor.pmSchema.nodes["blockContainer"] + ); }); } export function canUnnestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); + const { $from, $to } = tr.selection; + + // Mirrors `liftItem`'s preconditions instead of approximating with + // depth — a block whose depth > 1 because it sits inside a container + // (e.g. a column) is not un-nestable, only a block nested under another + // `blockContainer` is. + const range = $from.blockRange( + $to, + (node) => node.childCount > 0 && node.type.isInGroup("childContainer"), + ); + if (!range) { + return false; + } - return tr.doc.resolve(blockContainer.beforePos).depth > 1; + return ( + $from.node(range.depth - 1).type === + editor.pmSchema.nodes["blockContainer"] + ); }); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index d9e1e72981..02b6161a2d 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -8,10 +8,14 @@ import type { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { getNodeById } from "../../../nodeUtil.js"; import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getPmSchema } from "../../../pmUtil.js"; -import { fixColumnList } from "./util/fixColumnList.js"; +import { + fixContainer, + isContainerNode, +} from "../../containers/fixContainer.js"; export function removeAndInsertBlocks< BSchema extends BlockSchema, @@ -22,7 +26,7 @@ export function removeAndInsertBlocks< blocksToRemove: BlockIdentifier[], blocksToInsert: PartialBlock[], options: { - fixColumns?: boolean; + fixContainers?: boolean; } = {}, ): { insertedBlocks: Block[]; @@ -43,7 +47,10 @@ export function removeAndInsertBlocks< ), ); const removedBlocks: Block[] = []; - const columnListPositions = new Set(); + // Ancestor containers of removed blocks, to repair afterwards. Tracked by + // node id (not position) since the removals — and earlier repairs — shift + // positions; recorded with their depth so repairs run deepest-first. + const containersToFix: { id: string; depth: number }[] = []; const idOfFirstBlock = typeof blocksToRemove[0] === "string" @@ -84,10 +91,15 @@ export function removeAndInsertBlocks< const $pos = tr.doc.resolve(pos - removedSize); - if ($pos.node().type.name === "column") { - columnListPositions.add($pos.before(-1)); - } else if ($pos.node().type.name === "columnList") { - columnListPositions.add($pos.before()); + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if ( + isContainerNode(ancestor.type) && + ancestor.attrs.id && + !containersToFix.some((c) => c.id === ancestor.attrs.id) + ) { + containersToFix.push({ id: ancestor.attrs.id, depth }); + } } if ( @@ -119,11 +131,24 @@ export function removeAndInsertBlocks< ); } - // Collapses empty columns/columnLists. Callers where the removal isn't a - // deletion can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere - // and deliberately leaves emptied columns as-is. - if (options.fixColumns !== false) { - columnListPositions.forEach((pos) => fixColumnList(tr, pos)); + // Repairs the containers the removed blocks lived in (e.g. collapses + // emptied columns/columnLists). Callers where the removal isn't a deletion + // can opt out - e.g. `moveBlocks` re-inserts the blocks elsewhere and + // deliberately leaves emptied containers as-is. Runs deepest-first, + // re-locating each container by id, so a repair that removes or unwraps a + // nested container is simply skipped at the ancestor level if the ancestor + // was affected (and ancestors are re-checked in their own pass). + if (options.fixContainers !== false) { + [...containersToFix] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (!target) { + // Already removed by a deeper repair. + return; + } + fixContainer(tr, target.posBeforeNode); + }); } // Converts the nodes created from `blocksToInsert` into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts deleted file mode 100644 index 3097851f47..0000000000 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/util/fixColumnList.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Slice, type Node } from "prosemirror-model"; -import { type Transaction } from "prosemirror-state"; -import { ReplaceAroundStep } from "prosemirror-transform"; - -/** - * Checks if a `column` node is empty, i.e. if it has only a single empty - * paragraph. - * @param column The column to check. - * @returns Whether the column is empty. - */ -export function isEmptyColumn(column: Node) { - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - const blockContainer = column.firstChild; - if (!blockContainer) { - throw new Error("Invalid column: does not have child node."); - } - - const blockContent = blockContainer.firstChild; - if (!blockContent) { - throw new Error("Invalid blockContainer: does not have child node."); - } - - return ( - column.childCount === 1 && - blockContainer.childCount === 1 && - blockContent.type.name === "paragraph" && - blockContent.content.content.length === 0 - ); -} - -/** - * Removes all empty `column` nodes in a `columnList`. A `column` node is empty - * if it has only a single empty block. If, however, removing the `column`s - * leaves the `columnList` that has fewer than two, ProseMirror will re-add - * empty columns. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos The position just before the `columnList` node. - */ -export function removeEmptyColumns(tr: Transaction, columnListPos: number) { - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - for ( - let columnIndex = columnList.childCount - 1; - columnIndex >= 0; - columnIndex-- - ) { - const columnPos = tr.doc - .resolve($columnListPos.pos + 1) - .posAtIndex(columnIndex); - const $columnPos = tr.doc.resolve(columnPos); - const column = $columnPos.nodeAfter; - if (!column || column.type.name !== "column") { - throw new Error("Invalid columnPos: does not point to column node."); - } - - if (isEmptyColumn(column)) { - tr.delete(columnPos, columnPos + column.nodeSize); - } - } -} - -/** - * Fixes potential issues in a `columnList` node after a - * `blockContainer`/`column` node is (re)moved from it: - * - * - Removes all empty `column` nodes. A `column` node is empty if it has only - * a single empty block. - * - If all but one `column` nodes are empty, replaces the `columnList` with - * the content of the non-empty `column`. - * - If all `column` nodes are empty, removes the `columnList` entirely. - * @param tr The `Transaction` to add the changes to. - * @param columnListPos - * @returns The position just before the `columnList` node. - */ -export function fixColumnList(tr: Transaction, columnListPos: number) { - removeEmptyColumns(tr, columnListPos); - - const $columnListPos = tr.doc.resolve(columnListPos); - const columnList = $columnListPos.nodeAfter; - if (!columnList || columnList.type.name !== "columnList") { - throw new Error( - "Invalid columnListPos: does not point to columnList node.", - ); - } - - if (columnList.childCount > 2) { - // Do nothing if the `columnList` has more than two non-empty `column`s. In - // the case that the `columnList` has exactly two columns, we may need to - // still remove it, as it's possible that one or both columns are empty. - // This is because after `removeEmptyColumns` is called, if the - // `columnList` has fewer than two `column`s, ProseMirror will re-add empty - // `column`s until there are two total, in order to fit the schema. - return; - } - - if (columnList.childCount < 2) { - // Throw an error if the `columnList` has fewer than two columns. After - // `removeEmptyColumns` is called, if the `columnList` has fewer than two - // `column`s, ProseMirror will re-add empty `column`s until there are two - // total, in order to fit the schema. So if there are fewer than two here, - // either the schema, or ProseMirror's internals, must have changed. - throw new Error("Invalid columnList: contains fewer than two children."); - } - - const firstColumnBeforePos = columnListPos + 1; - const $firstColumnBeforePos = tr.doc.resolve(firstColumnBeforePos); - const firstColumn = $firstColumnBeforePos.nodeAfter; - - const lastColumnAfterPos = columnListPos + columnList.nodeSize - 1; - const $lastColumnAfterPos = tr.doc.resolve(lastColumnAfterPos); - const lastColumn = $lastColumnAfterPos.nodeBefore; - - if (!firstColumn || !lastColumn) { - throw new Error("Invalid columnList: does not contain children."); - } - - const firstColumnEmpty = isEmptyColumn(firstColumn); - const lastColumnEmpty = isEmptyColumn(lastColumn); - - if (firstColumnEmpty && lastColumnEmpty) { - // Removes `columnList` - tr.delete(columnListPos, columnListPos + columnList.nodeSize); - - return; - } - - if (firstColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of last `column`. - lastColumnAfterPos - lastColumn.nodeSize + 1, - lastColumnAfterPos - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } - - if (lastColumnEmpty) { - tr.step( - new ReplaceAroundStep( - // Replaces `columnList`. - columnListPos, - columnListPos + columnList.nodeSize, - // Replaces with content of first `column`. - firstColumnBeforePos + 1, - firstColumnBeforePos + firstColumn.nodeSize - 1, - // Doesn't append anything. - Slice.empty, - 0, - false, - ), - ); - - return; - } -} diff --git a/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap new file mode 100644 index 0000000000..29dc87ff63 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/__snapshots__/containers.test.ts.snap @@ -0,0 +1,318 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`childBlocks keyboard handling > Backspace at the start of a block after a container moves it inside 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "In callout", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "After", + "type": "text", + }, + ], + "id": "after", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Backspace at the start of a container's first child moves it out 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Before", + "type": "text", + }, + ], + "id": "before", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "First", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Second", + "type": "text", + }, + ], + "id": "c-p-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Delete at the end of a block before a container pulls its first child out 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Before", + "type": "text", + }, + ], + "id": "before", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "First", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Second", + "type": "text", + }, + ], + "id": "c-p-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Delete at the end of a container's last child pulls the next block in 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "In callout", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "After", + "type": "text", + }, + ], + "id": "after", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, +] +`; + +exports[`childBlocks keyboard handling > Enter on an empty last child escapes the container 1`] = ` +[ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Hello", + "type": "text", + }, + ], + "id": "c-p-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": undefined, + "id": "c-0", + "props": { + "flavor": "tip", + }, + "type": "callout", + }, + { + "children": [], + "content": [], + "id": "c-p-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "trailing", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; + +exports[`childBlocks repair > unwraps a repair-configured container when only one non-empty child remains 1`] = ` +[ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "cell-b-p", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [], + "id": "trailing", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, +] +`; diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts new file mode 100644 index 0000000000..4513bcfe23 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -0,0 +1,134 @@ +import type { Node, NodeType } from "prosemirror-model"; + +import { isContainerNode } from "./fixContainer.js"; + +/** + * Position-based helpers for navigating container blocks. These generalize + * what the keyboard handlers used to hard-code for the exact + * `columnList > column > blockContainer` shape: they recurse through + * arbitrarily nested containers and consult the schema's content matches + * instead of assuming two levels. + */ + +/** + * Finds the deepest position inside `container` where a node of `nodeType` + * can be appended at the end, descending through trailing nested containers + * (e.g. into the last `column` of a `columnList`, which itself doesn't accept + * `blockContainer` children). Returns null if no level accepts the node. + * @param container The container node. + * @param containerBeforePos The position just before `container`. + * @param nodeType The node type to find an insertion position for. + */ +export function descendToLastInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + const endPos = containerBeforePos + 1 + container.content.size; + if (container.contentMatchAt(container.childCount).matchType(nodeType)) { + return endPos; + } + const lastChild = container.lastChild; + if (lastChild && isContainerNode(lastChild.type)) { + return descendToLastInsertionPos( + lastChild, + endPos - lastChild.nodeSize, + nodeType, + ); + } + return null; +} + +/** + * Mirror of `descendToLastInsertionPos`: the deepest position inside + * `container` where a node of `nodeType` can be prepended at the start. + */ +export function descendToFirstInsertionPos( + container: Node, + containerBeforePos: number, + nodeType: NodeType, +): number | null { + const startPos = containerBeforePos + 1; + if (container.contentMatchAt(0).matchType(nodeType)) { + return startPos; + } + const firstChild = container.firstChild; + if (firstChild && isContainerNode(firstChild.type)) { + return descendToFirstInsertionPos(firstChild, startPos, nodeType); + } + return null; +} + +/** + * Descends through leading nested containers to the first non-container child + * (e.g. the first `blockContainer` inside the first `column` of a + * `columnList`). Returns null for a container whose leading chain has no + * such child. + * @param container The container node. + * @param containerBeforePos The position just before `container`. + */ +export function getFirstLeafBlock( + container: Node, + containerBeforePos: number, +): { node: Node; beforePos: number } | null { + const firstChild = container.firstChild; + if (!firstChild) { + return null; + } + const firstChildBeforePos = containerBeforePos + 1; + if (isContainerNode(firstChild.type)) { + return getFirstLeafBlock(firstChild, firstChildBeforePos); + } + return { node: firstChild, beforePos: firstChildBeforePos }; +} + +/** + * Climbs upward from a position until one is found where a node of + * `nodeType` may be inserted, moving to just before each enclosing container + * in turn (e.g. from before a first `column` — where only `column` nodes are + * allowed — to before the enclosing `columnList`). Returns null when an + * enclosing non-container parent still doesn't accept the node. + * @param doc The document to resolve positions in. + * @param pos The position to start climbing from. + * @param nodeType The node type to find an insertion position for. + */ +export function ascendToInsertablePos( + doc: Node, + pos: number, + nodeType: NodeType, +): number | null { + for (;;) { + const $pos = doc.resolve(pos); + const parent = $pos.node(); + if (parent.contentMatchAt($pos.index()).matchType(nodeType)) { + return pos; + } + if (isContainerNode(parent.type) && $pos.depth > 0) { + pos = $pos.before(); + continue; + } + return null; + } +} + +/** + * Collects the chain of container-node ancestors at a position (deepest + * first) as `{ id, depth }` entries. Containers are re-located by id when + * repairing, since repairs shift positions. + * @param doc The document to resolve the position in. + * @param pos A position inside the containers of interest. + */ +export function getAncestorContainers( + doc: Node, + pos: number, +): { id: string; depth: number }[] { + const $pos = doc.resolve(pos); + const containers: { id: string; depth: number }[] = []; + for (let depth = $pos.depth; depth > 0; depth--) { + const ancestor = $pos.node(depth); + if (isContainerNode(ancestor.type) && ancestor.attrs.id) { + containers.push({ id: ancestor.attrs.id, depth }); + } + } + return containers; +} diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts new file mode 100644 index 0000000000..772f8b34d8 --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts @@ -0,0 +1,92 @@ +import { isContainerType } from "../../../schema/blocks/internal.js"; + +/** + * Schema-derived info about the container block types in an editor, consumed + * by UI code (side menu positioning, drag & drop). Container DOM always + * carries `data-node-type`, so container elements can be matched with CSS + * selectors built from the type names. + */ +export type ContainerUIInfo = { + /** All container block types (blocks holding child blocks directly). */ + containerTypes: ReadonlySet; + /** Container types whose children are laid out side-by-side. */ + horizontalContainerTypes: ReadonlySet; + /** Container types that get their own side-menu drag handle. */ + draggableContainerTypes: ReadonlySet; + /** Selector matching any container element, or null if there are none. */ + containerSelector: string | null; + /** Selector matching horizontal container elements, or null if none. */ + horizontalContainerSelector: string | null; +}; + +// Minimal structural view of the editor, to avoid depending on the full +// BlockNoteEditor type here. `blockSpecs` is a schema-specific mapped type +// on the editor, so it is accepted loosely and read defensively below. +type EditorWithSchema = { + schema: { + blockSpecs: any; + }; +}; + +const cache = new WeakMap(); + +function buildSelector(types: ReadonlySet): string | null { + if (types.size === 0) { + return null; + } + return [...types].map((type) => `[data-node-type="${type}"]`).join(","); +} + +/** + * Returns (and caches per editor) the container-type info derived from the + * editor's block schema. + */ +export function getContainerUIInfo(editor: EditorWithSchema): ContainerUIInfo { + const cached = cache.get(editor); + if (cached) { + return cached; + } + + const containerTypes = new Set(); + const horizontalContainerTypes = new Set(); + const draggableContainerTypes = new Set(); + + for (const [type, spec] of Object.entries( + editor.schema.blockSpecs as Record< + string, + { + config: any; + implementation?: { + meta?: { + childLayout?: "vertical" | "horizontal"; + draggable?: boolean; + }; + node?: { config?: { group?: string } }; + }; + } + >, + )) { + if (!isContainerType(editor.schema.blockSpecs, type)) { + continue; + } + + containerTypes.add(type); + const meta = spec.implementation?.meta; + if (meta?.childLayout === "horizontal") { + horizontalContainerTypes.add(type); + } + if (meta?.draggable !== false) { + draggableContainerTypes.add(type); + } + } + + const info: ContainerUIInfo = { + containerTypes, + horizontalContainerTypes, + draggableContainerTypes, + containerSelector: buildSelector(containerTypes), + horizontalContainerSelector: buildSelector(horizontalContainerTypes), + }; + cache.set(editor, info); + return info; +} diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts new file mode 100644 index 0000000000..0abf0ba2eb --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -0,0 +1,538 @@ +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteSchema } from "../../../blocks/BlockNoteSchema.js"; +import { defaultBlockSpecs } from "../../../blocks/defaultBlocks.js"; +import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { createBlockSpec } from "../../../schema/blocks/createSpec.js"; + +// A vanilla (non-React) container block accepting any children, with +// defaultChildren seeding — the callout from the container-block example. +const Callout = createBlockSpec( + { + type: "callout" as const, + propSchema: { + flavor: { + default: "tip", + values: ["tip", "info", "warning", "success"], + }, + }, + content: "none", + childBlocks: { + min: 1, + defaultChildren: [{ type: "paragraph" }], + }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.className = "callout"; + dom.setAttribute("data-node-type", "callout"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +// A container that never exits on Enter (like columns). +const LockedBox = createBlockSpec( + { + type: "lockedBox" as const, + propSchema: {}, + content: "none", + childBlocks: { min: 1 }, + }, + { + meta: { + exitOnEnter: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "lockedBox"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +// A columnList-like pair: a horizontal container restricted to `gridCell` +// children (min 2) with column-style repair, and a non-top-level cell. +// Proves the "custom table-like structure" story is buildable. +const Grid = createBlockSpec( + { + type: "grid" as const, + propSchema: {}, + content: "none", + childBlocks: { + allowedBlocks: ["gridCell"], + min: 2, + repair: { + removeEmptyChildren: true, + belowMin: "unwrap", + }, + }, + }, + { + meta: { + childLayout: "horizontal", + }, + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "grid"); + dom.setAttribute("data-id", block.id); + dom.style.display = "flex"; + return { dom, contentDOM: dom }; + }, + }, +)(); + +const GridCell = createBlockSpec( + { + type: "gridCell" as const, + propSchema: {}, + content: "none", + childBlocks: { topLevel: false }, + }, + { + render: (block) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "gridCell"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, +)(); + +const schema = BlockNoteSchema.create().extend({ + blockSpecs: { + ...defaultBlockSpecs, + callout: Callout, + lockedBox: LockedBox, + grid: Grid, + gridCell: GridCell, + } as const, +}); + +let editor: BlockNoteEditor< + typeof schema.blockSchema, + typeof schema.inlineContentSchema, + typeof schema.styleSchema +>; +const div = document.createElement("div"); + +beforeAll(() => { + document.body.appendChild(div); + editor = BlockNoteEditor.create({ schema }); + editor.mount(div); +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + div.remove(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + { id: "p-1", type: "paragraph", content: "Paragraph 1" }, + ]); +}); + +function pressKey(key: string, keyCode: number) { + const view = editor._tiptapEditor.view; + const event = new KeyboardEvent("keydown", { + key, + code: key, + keyCode, + bubbles: true, + }); + view.someProp("handleKeyDown", (f: any) => f(view, event)); +} + +describe("childBlocks insertion & seeding", () => { + it("seeds defaultChildren when inserted without children", () => { + editor.insertBlocks([{ type: "callout", id: "c-0" }], "p-1", "after"); + + const callout = editor.getBlock("c-0")!; + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + }); + + it("seeds defaultChildren when converting a block via updateBlock", () => { + editor.updateBlock("p-1", { type: "callout" }); + + const callout = editor.document[1]; + expect(callout.type).toBe("callout"); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + }); + + it("accepts arbitrary block children, including nested containers", () => { + editor.insertBlocks( + [ + { + type: "callout", + id: "c-0", + children: [ + { type: "heading", content: "In callout" }, + { + type: "callout", + id: "c-1", + children: [{ type: "paragraph", content: "Nested" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.type)).toEqual([ + "heading", + "callout", + ]); + expect(editor.getBlock("c-1")!.children[0].type).toBe("paragraph"); + }); + + it("rejects non-allowed children for a restricted container", () => { + expect(() => + editor.insertBlocks( + [ + { + type: "grid", + children: [ + { type: "paragraph", content: "not a cell" }, + { type: "paragraph", content: "not a cell" }, + ], + }, + ], + "p-1", + "after", + ), + ).toThrow(); + }); + + it("accepts allowed children for a restricted container", () => { + editor.insertBlocks( + [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + children: [{ type: "paragraph", content: "Cell A" }], + }, + { + type: "gridCell", + children: [{ type: "paragraph", content: "Cell B" }], + }, + ], + }, + ], + "p-1", + "after", + ); + + const grid = editor.getBlock("g-0")!; + expect(grid.children.map((child) => child.type)).toEqual([ + "gridCell", + "gridCell", + ]); + }); + + it("rejects inserting a topLevel: false container at the document root", () => { + expect(() => + editor.insertBlocks( + [{ type: "gridCell", children: [{ type: "paragraph" }] }], + "p-1", + "after", + ), + ).toThrow(); + }); +}); + +describe("childBlocks keyboard handling", () => { + it("Enter on an empty last child escapes the container", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "Hello" }, + { id: "c-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + editor.setTextCursorPosition("c-p-1", "end"); + + pressKey("Enter", 13); + + expect(editor.document).toMatchSnapshot(); + // The empty block has moved out of the callout. + const callout = editor.getBlock("c-0")!; + expect(callout.children.map((child) => child.id)).toEqual(["c-p-0"]); + expect(editor.document.map((block) => block.type)).toEqual([ + "callout", + "paragraph", + "paragraph", + ]); + }); + + it("Enter does not escape a container with meta.exitOnEnter: false", () => { + editor.replaceBlocks(editor.document, [ + { + type: "lockedBox", + id: "l-0", + children: [ + { id: "l-p-0", type: "paragraph", content: "Hello" }, + { id: "l-p-1", type: "paragraph", content: "" }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + editor.setTextCursorPosition("l-p-1", "end"); + + pressKey("Enter", 13); + + // Still exactly one top-level lockedBox followed by the trailing + // paragraph; the new block was created inside the container. + expect(editor.document.map((block) => block.type)).toEqual([ + "lockedBox", + "paragraph", + ]); + expect(editor.getBlock("l-0")!.children.length).toBeGreaterThanOrEqual(2); + }); + + it("Backspace at the start of a container's first child moves it out", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setTextCursorPosition("c-p-0", "start"); + + pressKey("Backspace", 8); + + expect(editor.document).toMatchSnapshot(); + // The first child has moved out, above the callout. + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + expect(editor.document.map((block) => block.id)[1]).toBe("c-p-0"); + }); + + it("Backspace at the start of a block after a container moves it inside", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + editor.setTextCursorPosition("after", "start"); + + pressKey("Backspace", 8); + + expect(editor.document).toMatchSnapshot(); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + }); + + it("Delete at the end of a block before a container pulls its first child out", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setTextCursorPosition("before", "end"); + + pressKey("Delete", 46); + + expect(editor.document).toMatchSnapshot(); + expect(editor.document.map((block) => block.id).slice(0, 2)).toEqual([ + "before", + "c-p-0", + ]); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-1", + ]); + }); + + it("Delete at the end of a container's last child pulls the next block in", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + { id: "after", type: "paragraph", content: "After" }, + ]); + editor.setTextCursorPosition("c-p-0", "end"); + + pressKey("Delete", 46); + + expect(editor.document).toMatchSnapshot(); + expect(editor.getBlock("c-0")!.children.map((child) => child.id)).toEqual([ + "c-p-0", + "after", + ]); + }); +}); + +describe("childBlocks repair", () => { + it("keeps a default container when its only child is removed (refilled)", () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Only child" }], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["c-p-0"]); + + const callout = editor.getBlock("c-0")!; + expect(callout).toBeDefined(); + expect(callout.children).toHaveLength(1); + expect(callout.children[0].type).toBe("paragraph"); + expect(callout.children[0].content).toEqual([]); + }); + + it("unwraps a repair-configured container when only one non-empty child remains", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "cell-a", + children: [{ id: "cell-a-p", type: "paragraph", content: "A" }], + }, + { + type: "gridCell", + id: "cell-b", + children: [{ id: "cell-b-p", type: "paragraph", content: "B" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + editor.removeBlocks(["cell-a-p"]); + + expect(editor.document).toMatchSnapshot(); + // The grid has been unwrapped: cell B's content replaced it. + expect(editor.getBlock("g-0")).toBeUndefined(); + expect(editor.document.map((block) => block.id)).toEqual([ + "cell-b-p", + "trailing", + ]); + }); +}); + +describe("childBlocks selection & conversion", () => { + it("getSelectionCutBlocks handles selections reaching into a container", () => { + editor.replaceBlocks(editor.document, [ + { id: "before", type: "paragraph", content: "Before" }, + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "First" }, + { id: "c-p-1", type: "paragraph", content: "Second" }, + ], + }, + ]); + editor.setSelection("before", "c-p-0"); + + // Previously threw "unexpected" for any partial selection touching a + // container (breaking comments/AI selection handling). + const result = editor.getSelectionCutBlocks(); + expect(result.blocks.length).toBeGreaterThanOrEqual(1); + expect(result.blocks.map((block) => block.id)).toContain("before"); + }); + + it("round-trips a container through full (internal) HTML", async () => { + const blocks = [ + { + type: "callout" as const, + id: "c-0", + props: { flavor: "warning" as const }, + children: [ + { id: "c-p-0", type: "paragraph" as const, content: "In callout" }, + ], + }, + ]; + editor.replaceBlocks(editor.document, blocks); + + const html = editor.blocksToFullHTML(editor.document); + expect(html).toContain('data-node-type="callout"'); + + const parsed = editor.tryParseHTMLToBlocks(html); + expect(parsed[0].type).toBe("callout"); + expect((parsed[0].props as any).flavor).toBe("warning"); + expect(parsed[0].children).toHaveLength(1); + expect(parsed[0].children[0].type).toBe("paragraph"); + }); + + it("exports containers to external HTML with type + prop attributes", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + props: { flavor: "warning" }, + children: [{ id: "c-p-0", type: "paragraph", content: "In callout" }], + }, + ]); + + const html = editor.blocksToHTMLLossy(editor.document); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain('data-flavor="warning"'); + // Container output is not wrapped in a blockContent div. + expect(html).not.toContain("bn-block-content"); + }); + + it("flattens containers to their children in markdown export", async () => { + editor.replaceBlocks(editor.document, [ + { + type: "callout", + id: "c-0", + children: [ + { id: "c-p-0", type: "paragraph", content: "In callout" }, + { id: "c-p-1", type: "heading", content: "Heading in callout" }, + ], + }, + ]); + + const markdown = editor.blocksToMarkdownLossy(editor.document); + expect(markdown).toContain("In callout"); + expect(markdown).toContain("# Heading in callout"); + }); +}); diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts new file mode 100644 index 0000000000..2622cac7ed --- /dev/null +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -0,0 +1,247 @@ +import { Fragment, Slice, type Node, type NodeType } from "prosemirror-model"; +import { type Transaction } from "prosemirror-state"; +import { ReplaceAroundStep } from "prosemirror-transform"; +import type { Schema } from "prosemirror-model"; + +import { getChildBlocksConfig } from "../../../schema/blocks/internal.js"; +import { getNodeById } from "../../nodeUtil.js"; +import { getBlockSchema, getPmSchema } from "../../pmUtil.js"; + +/** + * Whether a PM node type is a container block node — a `bnBlock` that holds + * child blocks directly (e.g. `column`, `columnList`, or any block declaring + * `childBlocks`). `blockGroup` shares the `childContainer` group (it's the + * child-holding node of regular indentation) but is not a container block. + */ +export function isContainerNode(type: NodeType): boolean { + return type.isInGroup("childContainer") && type.name !== "blockGroup"; +} + +/** + * Checks whether a direct child of a container is "empty": + * - a `blockContainer` holding a single empty paragraph and nothing else; + * - a nested container whose single child is itself empty. + * + * (A child holding several blocks is never considered empty, even if each of + * them is — collapsing multi-block structure the user built up would be + * destructive.) + */ +export function isEmptyContainerChild(node: Node): boolean { + if (node.type.name === "blockContainer") { + const blockContent = node.firstChild; + return ( + node.childCount === 1 && + !!blockContent && + blockContent.type.name === "paragraph" && + blockContent.childCount === 0 + ); + } + if (isContainerNode(node.type)) { + return node.childCount === 1 && isEmptyContainerChild(node.firstChild!); + } + return false; +} + +/** + * Removes all empty children (see `isEmptyContainerChild`) of the container + * node at `containerPos`. If the removals leave the container below its + * schema minimum, ProseMirror re-adds empty children to fit the schema — + * `fixContainer` detects that state via the non-empty child count. + * @param tr The `Transaction` to add the changes to. + * @param containerPos The position just before the container node. + */ +export function removeEmptyChildren(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + for ( + let childIndex = container.childCount - 1; + childIndex >= 0; + childIndex-- + ) { + const childPos = tr.doc.resolve(containerPos + 1).posAtIndex(childIndex); + const child = tr.doc.resolve(childPos).nodeAfter; + if (!child) { + throw new Error("Invalid childPos: does not point to a child node."); + } + + if (isEmptyContainerChild(child)) { + tr.delete(childPos, childPos + child.nodeSize); + } + } +} + +// A container child is directly insertable next to the container itself when +// its node can sit anywhere a regular block goes. Children that can't +// (`topLevel: false` containers, like `column`) are flattened into *their* +// children when a container is unwrapped. +function isInsertableChild(node: Node): boolean { + return ( + node.type.name === "blockContainer" || + node.type.isInGroup("blockGroupChild") + ); +} + +/** + * Repairs the container node at `containerPos` after children were (re)moved + * from it, according to the block's `childBlocks.repair` config: + * + * - When `repair.removeEmptyChildren` is set, drops empty children. If that + * leaves fewer than `min` non-empty children (ProseMirror pads the + * container back up to `min` with empty ones, so the *total* count never + * drops), applies `repair.belowMin`: + * - `"unwrap"` (default) — replaces the container with its remaining + * non-empty children (non-top-level container children are flattened + * into their own children), or deletes it when none remain; + * - `"remove"` — deletes the container; + * - `"fill"` — keeps the container with its padded empty children. + * - Containers without `repair.removeEmptyChildren` are left untouched: + * ProseMirror's schema fitting already guarantees they satisfy `min`. + * + * This generalizes what `fixColumnList` did for column lists (which set + * `removeEmptyChildren: true` + `belowMin: "unwrap"`). + * @param tr The `Transaction` to add the changes to. + * @param containerPos The position just before the container node. + */ +export function fixContainer(tr: Transaction, containerPos: number) { + const container = tr.doc.resolve(containerPos).nodeAfter; + if (!container || !isContainerNode(container.type)) { + throw new Error( + "Invalid containerPos: does not point to a container node.", + ); + } + + const blockConfig = getBlockSchema(getPmSchema(tr))[container.type.name]; + const config = blockConfig ? getChildBlocksConfig(blockConfig) : undefined; + + if (!config?.repair?.removeEmptyChildren) { + return; + } + + removeEmptyChildren(tr, containerPos); + + const refreshed = tr.doc.resolve(containerPos).nodeAfter; + if (!refreshed || refreshed.type !== container.type) { + // The container itself disappeared as a side effect of the deletions + // (shouldn't happen, but there is nothing left to repair). + return; + } + + const min = config.min ?? 1; + + const nonEmptyChildren: { child: Node; offset: number }[] = []; + refreshed.forEach((child, offset) => { + if (!isEmptyContainerChild(child)) { + nonEmptyChildren.push({ child, offset }); + } + }); + + if (nonEmptyChildren.length >= min) { + return; + } + + const belowMin = config.repair.belowMin ?? "unwrap"; + + if (belowMin === "fill") { + return; + } + + if (belowMin === "remove" || nonEmptyChildren.length === 0) { + tr.delete(containerPos, containerPos + refreshed.nodeSize); + return; + } + + // "unwrap": replace the container with its remaining non-empty children. + if (nonEmptyChildren.length === 1) { + // Single survivor: move its content out with a `ReplaceAroundStep` so the + // content is mapped (moved) rather than deleted-and-recreated — this + // keeps cursor positions and collaborative rebasing stable. + const { child, offset } = nonEmptyChildren[0]; + const childStart = containerPos + 1 + offset; + + const [gapFrom, gapTo] = isInsertableChild(child) + ? // The child node itself can go where the container was. + [childStart, childStart + child.nodeSize] + : // The child (e.g. a `column`) can't — its *content* can. + [childStart + 1, childStart + child.nodeSize - 1]; + + tr.step( + new ReplaceAroundStep( + containerPos, + containerPos + refreshed.nodeSize, + gapFrom, + gapTo, + Slice.empty, + 0, + false, + ), + ); + return; + } + + // Several survivors but still below `min` (only possible when `min` > 2): + // no single gap covers them, so rebuild the replacement content. + const replacement: Node[] = []; + for (const { child } of nonEmptyChildren) { + if (isInsertableChild(child)) { + replacement.push(child); + } else { + child.forEach((grandChild) => replacement.push(grandChild)); + } + } + tr.replaceWith( + containerPos, + containerPos + refreshed.nodeSize, + Fragment.from(replacement), + ); +} + +/** + * Runs `fixContainer` on a set of containers identified by id, deepest + * first. Containers are re-located by id before each repair, since a repair + * (or the caller's preceding mutations) shifts positions — a container that + * an earlier repair removed is skipped. + * @param tr The `Transaction` to add the changes to. + * @param containers The containers to repair, with the depth they were + * originally found at (used only for ordering). + */ +export function fixContainersById( + tr: Transaction, + containers: { id: string; depth: number }[], +) { + [...containers] + .sort((a, b) => b.depth - a.depth) + .forEach(({ id }) => { + const target = getNodeById(id, tr.doc); + if (!target) { + return; + } + fixContainer(tr, target.posBeforeNode); + }); +} + +/** + * Replaces blocks that cannot be inserted at the top level of a document + * region (containers with `childBlocks.topLevel: false`, like `column`) with + * their children, recursively. Used when moving/copying blocks out of a + * container into a regular block position. + */ +export function flattenNonInsertableBlocks< + T extends { type?: string; children?: T[] }, +>(blocks: T[], pmSchema: Schema): T[] { + return blocks.flatMap((block) => { + const nodeType = block.type ? pmSchema.nodes[block.type] : undefined; + if ( + nodeType && + nodeType.isInGroup("bnBlock") && + !nodeType.isInGroup("blockGroupChild") + ) { + return flattenNonInsertableBlocks(block.children ?? [], pmSchema); + } + return [block]; + }); +} diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index e2274140f7..f04663ae1d 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -8,7 +8,9 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { camelToDataKebab } from "../../../../util/string.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; +import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js"; import { inlineContentToNodes, tableContentToNodes, @@ -270,6 +272,29 @@ function serializeBlock< } elementFragment.append(...Array.from(ret.dom.childNodes)); } else { + const blockNodeType = editor.pmSchema.nodes[block.type as any]; + if (blockNodeType && isContainerNode(blockNodeType)) { + // Container blocks own their outer DOM. Make sure the attributes + // needed to parse the HTML back (the type marker and non-default + // props, in the same `data-*` convention `propsToAttributes` reads) + // are present even when the block's render didn't add them. + // Author-set attributes win. + const dom = ret.dom as HTMLElement; + if (!dom.hasAttribute("data-node-type")) { + dom.setAttribute("data-node-type", block.type!); + } + const propSchema = + editor.schema.blockSchema[block.type as any].propSchema; + for (const [propName, value] of Object.entries(props)) { + const attrName = camelToDataKebab(propName); + if ( + value !== (propSchema as any)[propName]?.default && + !dom.hasAttribute(attrName) + ) { + dom.setAttribute(attrName, String(value)); + } + } + } elementFragment.append(ret.dom); if (nestingLevel > 0) { (ret.dom as HTMLElement).setAttribute( diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 0f890b77ab..f765a3f183 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -7,7 +7,9 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { camelToDataKebab } from "../../../../util/string.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; +import { isContainerNode } from "../../../blockManipulation/containers/fixContainer.js"; import { inlineContentToNodes, tableContentToNodes, @@ -172,7 +174,26 @@ function serializeBlock< const pmType = editor.pmSchema.nodes[block.type as any]; - if (pmType.isInGroup("bnBlock")) { + if (isContainerNode(pmType)) { + // Container blocks own their outer DOM. Internal HTML must round-trip + // losslessly, so make sure the attributes the generated parse rules read + // (the type marker and non-default props as `data-*`) are present even + // when the block's render didn't add them. Author-set attributes win. + const dom = ret.dom as HTMLElement; + if (!dom.hasAttribute("data-node-type")) { + dom.setAttribute("data-node-type", block.type!); + } + const propSchema = editor.schema.blockSchema[block.type as any].propSchema; + for (const [propName, value] of Object.entries(props)) { + const attrName = camelToDataKebab(propName); + if ( + value !== (propSchema as any)[propName]?.default && + !dom.hasAttribute(attrName) + ) { + dom.setAttribute(attrName, String(value)); + } + } + if (block.children && block.children.length > 0) { const fragment = serializeBlocks( editor, diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index b0d1ebab21..7e71edbe32 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -16,7 +16,8 @@ import { isPartialLinkInlineContent, isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; -import type { ContainerConfig } from "../../schema/blocks/types.js"; +import { getChildBlocksConfig } from "../../schema/blocks/internal.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; import { getColspan, isPartialTableCell } from "../../util/table.js"; import { UnreachableCaseError } from "../../util/typescript.js"; import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js"; @@ -335,6 +336,8 @@ function blockOrInlineContentToContentNode( return contentNode; } +const EMPTY_SEEDING: ReadonlySet = new Set(); + /** * Converts a BlockNote block to a Prosemirror node. */ @@ -342,7 +345,11 @@ export function blockToNode( block: PartialBlock, schema: Schema, styleSchema: StyleSchema = getStyleSchema(schema), - _seenNodeTypes?: Set, + // Internal: container block types whose `defaultChildren` are currently + // being seeded further up the recursion. Used to fail a self-referential + // `defaultChildren` config with a clear error instead of a stack overflow. + // Not part of the public API. + seedingTypes: ReadonlySet = EMPTY_SEEDING, ) { let id = block.id; @@ -354,7 +361,7 @@ export function blockToNode( if (block.children) { for (const child of block.children) { - children.push(blockToNode(child, schema, styleSchema)); + children.push(blockToNode(child, schema, styleSchema, seedingTypes)); } } @@ -383,34 +390,52 @@ export function blockToNode( }, groupNode ? [contentNode, groupNode] : contentNode, ); - } else if (schema.nodes[block.type].isInGroup("bnBlock")) { + } else if (isContainerNode(schema.nodes[block.type])) { // 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). + // Seed `defaultChildren` for container blocks when no children would + // otherwise be present (covers both `block.children === undefined` and + // `block.children === []`, e.g. converting a leaf block 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) { - 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, - ), + const blockSchemaConfig = getBlockSchema(schema)[block.type]; + const childBlocksConfig = blockSchemaConfig + ? getChildBlocksConfig(blockSchemaConfig) + : undefined; + const defaultChildren = childBlocksConfig?.defaultChildren; + // Only seed to satisfy a positive `min`. A `min: 0` container is allowed + // to be empty, so it must NOT be re-populated on a round-trip — + // `nodeToBlock` emits `children: []` for any childless container, which + // would otherwise re-seed it every time it passes through here. + if ( + defaultChildren && + defaultChildren.length > 0 && + (childBlocksConfig!.min ?? 1) > 0 + ) { + // A `defaultChildren` that (transitively) seeds its own type would + // recurse forever; fail with a clear error instead of overflowing the + // stack. `validateChildBlocks` only checks types/cardinality, not this. + if (seedingTypes.has(block.type!)) { + throw new Error( + `Container block "${block.type}" has a \`defaultChildren\` cycle (seeding it requires seeding itself). Give the cyclic default explicit children, or remove the self-reference.`, ); + } + const nextSeeding = new Set(seedingTypes).add(block.type!); + effectiveChildren = defaultChildren.map((child) => + blockToNode( + child as PartialBlock, + schema, + styleSchema, + nextSeeding, + ), + ); + return schema.nodes[block.type].createChecked( + { + id: id, + ...block.props, + }, + effectiveChildren, + ); } } diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index 19f063d8bb..1a0eeca7db 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -1,10 +1,13 @@ -import { Fragment } from "@tiptap/pm/model"; +import { Fragment, Node } from "@tiptap/pm/model"; import { BlockNoDefaults, BlockSchema, InlineContentSchema, StyleSchema, + getChildBlocksConfig, } from "../../schema/index.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; +import { getBlockSchema } from "../pmUtil.js"; import { nodeToBlock } from "./nodeToBlock.js"; /** @@ -18,6 +21,32 @@ export function fragmentToBlocks< // first convert selection to blocknote-style blocks, and then // pass these to the exporter const blocks: BlockNoDefaults[] = []; + + // Pushes a bnBlock node as a block, flattening containers that shouldn't + // surface on their own: containers with fewer children than their `min` + // (e.g. a single selected column of a columnList — the user selected + // content within the container, not the container itself) and containers + // that can't stand outside their parent (`topLevel: false`, e.g. a + // `column`). + const pushFlattened = (node: Node, root: Node) => { + const config = getChildBlocksConfig( + getBlockSchema(node.type.schema)[node.type.name] ?? {}, + ); + const belowMin = + isContainerNode(node.type) && + config !== undefined && + node.childCount < (config.min ?? 1); + const nonInsertable = + isContainerNode(node.type) && !node.type.isInGroup("blockGroupChild"); + + if (belowMin || nonInsertable) { + node.forEach((child) => pushFlattened(child, root)); + return; + } + + blocks.push(nodeToBlock(node, root)); + }; + fragment.descendants((node) => { if (node.type.name === "blockContainer") { if (node.firstChild?.type.name === "blockGroup") { @@ -44,16 +73,8 @@ export function fragmentToBlocks< } } - if (node.type.name === "columnList" && node.childCount === 1) { - // column lists with a single column should be flattened (not the entire column list has been selected) - node.firstChild?.forEach((child) => { - blocks.push(nodeToBlock(child, node)); - }); - return false; - } - if (node.type.isInGroup("bnBlock")) { - blocks.push(nodeToBlock(node, node)); + pushFlattened(node, node); // don't descend into children, as they're already included in the block returned by nodeToBlock return false; } diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 6d3b7e23b2..67878115ce 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -1,5 +1,6 @@ import { Mark, Node, Slice } from "@tiptap/pm/model"; import type { Block } from "../../blocks/defaultBlocks.js"; +import { isContainerNode } from "../blockManipulation/containers/fixContainer.js"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; import type { BlockSchema, @@ -560,7 +561,9 @@ export function prosemirrorSliceToSlicedBlocks< blockCutAtStart: string | undefined; blockCutAtEnd: string | undefined; } { - if (node.type.name !== "blockGroup") { + // Both `blockGroup` and container nodes (columnList, column, callout, + // ...) hold bnBlock children directly, so both can be processed here. + if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) { throw new Error("unexpected"); } const blocks: Block[] = []; @@ -568,6 +571,44 @@ export function prosemirrorSliceToSlicedBlocks< let blockCutAtEnd: string | undefined; node.forEach((blockContainer, _offset, index) => { + const isFirstBlock = index === 0; + const isLastBlock = index === node.childCount - 1; + + if (isContainerNode(blockContainer.type)) { + // A container child. When the slice boundary is open inside it, the + // selection covers part of its children — skip the container wrapper + // and splice in the included children (mirroring the + // nested-blockGroup descent below). When fully enclosed, convert it + // wholesale. + const openAtStart = isFirstBlock && openStart > 0; + const openAtEnd = isLastBlock && openEnd > 0; + + if (openAtStart || openAtEnd) { + const ret = processNode( + blockContainer, + openAtStart ? Math.max(0, openStart - 1) : 0, + openAtEnd ? Math.max(0, openEnd - 1) : 0, + ); + if (openAtStart) { + blockCutAtStart = ret.blockCutAtStart; + } + if (openAtEnd) { + blockCutAtEnd = ret.blockCutAtEnd; + } + blocks.push(...ret.blocks); + return; + } + + blocks.push( + nodeToBlock(blockContainer, slice.content.firstChild!) as Block< + BSchema, + I, + S + >, + ); + return; + } + if (blockContainer.type.name !== "blockContainer") { throw new Error("unexpected"); } @@ -580,9 +621,6 @@ export function prosemirrorSliceToSlicedBlocks< ); } - const isFirstBlock = index === 0; - const isLastBlock = index === node.childCount - 1; - if (blockContainer.firstChild!.type.name === "blockGroup") { // this is the parent where a selection starts within one of its children, // e.g.: diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 71176e5576..5208af8173 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -36,6 +36,7 @@ import { UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; +import { isContainerType } from "../../../schema/blocks/internal.js"; import type { BlockNoteEditor, BlockNoteEditorOptions, @@ -61,22 +62,13 @@ export function getDefaultTiptapExtensions( // everything from bnBlock group (nodes that represent a BlockNote block should have an id) 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 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") - ); - }) - .map(([name]) => name), + // Container block specs whose PM node is itself in the `bnBlock` group + // (column, columnList, callout, etc.) — i.e. the bnBlock node IS the + // block, so the id lives on its attrs rather than on a wrapping + // blockContainer. + ...Object.keys(editor.schema.blockSpecs).filter((type) => + isContainerType(editor.schema.blockSpecs, type), + ), ], setIdAttribute: options.setIdAttribute, isWithinEditor: editor.isWithinEditor, diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index f42e89e6f4..00c8a3dcbc 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -8,6 +8,7 @@ import { StyleSchema, StyledText, Styles, + isContainerType, } from "../schema/index.js"; import type { @@ -44,15 +45,32 @@ export abstract class Exporter< RS, TS, > { + // Stored with erased generics: a generically-typed property would change + // the class's variance in B/I/S and break mapping inference at subclass + // construction sites (the schema param was previously inference-only). + private readonly blockNoteSchema: BlockNoteSchema; + public constructor( - _schema: BlockNoteSchema, // only used for type inference + schema: BlockNoteSchema, protected readonly mappings: { blockMapping: BlockMapping; inlineContentMapping: InlineContentMapping; styleMapping: StyleMapping; }, public readonly options: ExporterOptions, - ) {} + ) { + this.blockNoteSchema = schema; + } + + /** + * Whether a block type is a container block (declares `childBlocks`, e.g. + * `columnList`, `column`, or a custom callout). Container mappings own the + * placement of their children — exporters must not append the children + * after the container's own output. + */ + public isContainerBlock(blockType: string): boolean { + return isContainerType(this.blockNoteSchema.blockSpecs, blockType); + } public async resolveFile(url: string) { if (!this.options?.resolveFileUrl) { @@ -92,12 +110,17 @@ export abstract class Exporter< numberedListIndex: number, children?: Array>, ) { - return this.mappings.blockMapping[block.type]( - block, - this, - nestingLevel, - numberedListIndex, - children, - ); + const mapping = this.mappings.blockMapping[block.type]; + if (!mapping) { + // Without this, a missing mapping surfaces as an opaque "is not a + // function" TypeError. Container blocks are called out explicitly: they + // have no sensible generic representation, so a mapping is required. + throw new Error( + this.isContainerBlock(block.type) + ? `No mapping found for container block type "${block.type}" — container blocks require an explicit block mapping that places their children.` + : `No mapping found for block type "${block.type}".`, + ); + } + return mapping(block, this, nestingLevel, numberedListIndex, children); } } diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index d53a8444cc..b9d50bdbfe 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -20,6 +20,10 @@ import { InlineContentSchema, StyleSchema, } from "../../schema/index.js"; +import { + ContainerUIInfo, + getContainerUIInfo, +} from "../../api/blockManipulation/containers/containerUI.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; import { dragStart, unsetDragImage } from "./dragging.js"; @@ -37,7 +41,8 @@ const DISTANCE_TO_CONSIDER_EDITOR_BOUNDS = 250; function getBlockFromCoords( view: EditorView, coords: { left: number; top: number }, - adjustForColumns = true, + containerUIInfo: ContainerUIInfo, + adjustForHorizontalContainers = true, ) { const elements = view.root.elementsFromPoint(coords.left, coords.top); @@ -46,9 +51,17 @@ function getBlockFromCoords( // probably a ui overlay like formatting toolbar etc continue; } - if (adjustForColumns) { - const column = element.closest("[data-node-type=columnList]"); - if (column) { + if ( + adjustForHorizontalContainers && + containerUIInfo.horizontalContainerSelector + ) { + // Inside a container with side-by-side children (e.g. a columnList), + // the x position must be offset — the hovered coordinates land in the + // side menu's own gutter, which belongs to a different child. + const horizontalContainer = element.closest( + containerUIInfo.horizontalContainerSelector, + ); + if (horizontalContainer) { return getBlockFromCoords( view, { @@ -56,21 +69,70 @@ function getBlockFromCoords( left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself top: coords.top, }, + containerUIInfo, false, ); } } - return getDraggableBlockFromElement(element, view); + return getDraggableBlockFromElement( + element, + view, + containerUIInfo.draggableContainerTypes, + ); } return undefined; } +/** + * If `element` is a container block's element, finds its direct child block + * whose vertical range contains the cursor. Hovering a container's own + * chrome (padding, a title bar, the side-menu gutter) next to a child + * should attach the side menu to that child — mirroring how hovering a + * parent block's gutter next to a nested block attaches to the nested + * block. The container's own menu stays reachable on rows occupied only by + * its chrome. When children sit side-by-side, a child containing the + * cursor's x position wins over the first vertical match. + */ +function getContainerChildAtCursor( + element: Element, + mousePos: { x: number; y: number }, + containerUIInfo: ContainerUIInfo, +): Element | undefined { + const nodeType = element.getAttribute("data-node-type"); + if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) { + return undefined; + } + + const childSelector = containerUIInfo.containerSelector + ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` + : `[data-node-type="blockContainer"]`; + + let verticalMatch: Element | undefined = undefined; + for (const child of element.querySelectorAll(childSelector)) { + // Direct children only (in the block sense): the closest block element + // above the candidate must be the container itself. + if (child.parentElement?.closest(childSelector) !== element) { + continue; + } + const rect = child.getBoundingClientRect(); + if (mousePos.y < rect.top || mousePos.y > rect.bottom) { + continue; + } + if (mousePos.x >= rect.left && mousePos.x <= rect.right) { + return child; + } + verticalMatch = verticalMatch ?? child; + } + return verticalMatch; +} + function getBlockFromMousePos( mousePos: { x: number; y: number; }, view: EditorView, + containerUIInfo: ContainerUIInfo, ): { node: HTMLElement; id: string } | undefined { // Editor itself may have padding or other styling which affects // size/position, so we get the boundingRect of the first child (i.e. the @@ -94,7 +156,7 @@ function getBlockFromMousePos( top: mousePos.y, }; - const referenceBlock = getBlockFromCoords(view, coords); + const referenceBlock = getBlockFromCoords(view, coords, containerUIInfo); if (!referenceBlock) { // could not find the reference block @@ -109,15 +171,26 @@ function getBlockFromMousePos( * ``` * Hovering at position x (left edge of BlockB) would return BlockA. * Instead, we check at position y (right edge of BlockA) to correctly identify BlockB. + * `elementsFromPoint` returns the deepest element at a point, so this single + * probe descends through any depth of regular nesting. + * + * When the reference block is a (draggable) container block, the probe is + * aimed at the direct child under the cursor instead of the container + * itself — the container's own padding can exceed the probe inset, which + * would keep resolving the container even though the cursor is aligned with + * one of its children (making the child's menu jump away as the cursor + * moves towards it). */ - const referenceBlocksBoundingBox = - referenceBlock.node.getBoundingClientRect(); + const probeTarget = + getContainerChildAtCursor(referenceBlock.node, mousePos, containerUIInfo) ?? + referenceBlock.node; return getBlockFromCoords( view, { - left: referenceBlocksBoundingBox.right - 10, + left: probeTarget.getBoundingClientRect().right - 10, top: mousePos.y, }, + containerUIInfo, false, ); } @@ -214,7 +287,11 @@ export class SideMenuView< return; } - const block = getBlockFromMousePos(this.mousePos, this.pmView); + const block = getBlockFromMousePos( + this.mousePos, + this.pmView, + getContainerUIInfo(this.editor), + ); // Closes the menu if the mouse cursor is beyond the editor vertically. if (!block || !this.editor.isEditable) { @@ -240,7 +317,15 @@ export class SideMenuView< // Shows or updates elements. if (this.editor.isEditable) { const blockContentBoundingBox = block.node.getBoundingClientRect(); - const column = block.node.closest("[data-node-type=column]"); + // The closest container ancestor (a column, callout, ...) — excluding + // the hovered block itself, which may be a draggable container. Blocks + // inside a container anchor the side menu to the container's block + // area rather than the editor's left edge, which would put the menu + // over unrelated content (or off-screen inside columns). + const containerUIInfo = getContainerUIInfo(this.editor); + const container = containerUIInfo.containerSelector + ? block.node.parentElement?.closest(containerUIInfo.containerSelector) + : undefined; const sideMenuBlock = this.editor.getBlock( this.hoveredBlock!.getAttribute("data-id")!, ); @@ -255,12 +340,16 @@ export class SideMenuView< this.state = { show: true, referencePos: new DOMRect( - column - ? // We take the first child as column elements have some default - // padding. This is a little weird since this child element will - // be the first block, but since it's always non-nested and we - // only take the x coordinate, it's ok. - column.firstElementChild!.getBoundingClientRect().x + container + ? // We anchor to the container's first block element (rather + // than the container itself, which may have padding or its own + // chrome around the block area). This is a little weird since + // this element is the first block, but since it's always + // non-nested and we only take the x coordinate, it's ok. + ( + container.querySelector('[data-node-type="blockOuter"]') ?? + container.firstElementChild! + ).getBoundingClientRect().x : ( this.pmView.dom.firstChild as HTMLElement ).getBoundingClientRect().x, diff --git a/packages/core/src/extensions/getDraggableBlockFromElement.ts b/packages/core/src/extensions/getDraggableBlockFromElement.ts index abc6bd2906..4b4223ca82 100644 --- a/packages/core/src/extensions/getDraggableBlockFromElement.ts +++ b/packages/core/src/extensions/getDraggableBlockFromElement.ts @@ -1,18 +1,37 @@ import { EditorView } from "prosemirror-view"; +const EMPTY_SET: ReadonlySet = new Set(); + +/** + * Walks up from `element` to the closest element that can host a side-menu + * drag handle: a regular block (`blockContainer`) or a container block whose + * type is in `draggableContainerTypes` (derived from each spec's + * `meta.draggable`). + */ export function getDraggableBlockFromElement( element: Element, view: EditorView, + draggableContainerTypes: ReadonlySet = EMPTY_SET, ) { + const isDraggable = (el: Element) => { + const nodeType = el.getAttribute?.("data-node-type"); + return ( + nodeType === "blockContainer" || + (nodeType !== null && + nodeType !== undefined && + draggableContainerTypes.has(nodeType)) + ); + }; + while ( element && element.parentElement && element.parentElement !== view.dom && - element.getAttribute?.("data-node-type") !== "blockContainer" + !isDraggable(element) ) { element = element.parentElement; } - if (element.getAttribute?.("data-node-type") !== "blockContainer") { + if (!isDraggable(element)) { return undefined; } return { node: element as HTMLElement, id: element.getAttribute("data-id")! }; diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index b3a0b62550..d29d3aa939 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -14,7 +14,16 @@ import { nestBlock, unnestBlock, } from "../../../api/blockManipulation/commands/nestBlock/nestBlock.js"; -import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +import { + fixContainersById, + isContainerNode, +} from "../../../api/blockManipulation/containers/fixContainer.js"; +import { + ascendToInsertablePos, + descendToLastInsertionPos, + getAncestorContainers, + getFirstLeafBlock, +} from "../../../api/blockManipulation/containers/containerNav.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { @@ -127,8 +136,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the previous block is a columnList, moves the current block to - // the end of the last column in it. + // If the previous block is a container (e.g. a columnList or a + // callout), moves the current block to its deepest trailing insertion + // slot — descending through nested containers, e.g. to the end of the + // last column. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -150,17 +161,23 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - if (dispatch) { - const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1; - const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1); + const insertionPos = descendToLastInsertionPos( + prevBlockInfo.bnBlock.node, + prevBlockInfo.bnBlock.beforePos, + state.schema.nodes["blockContainer"], + ); + if (insertionPos === null) { + return false; + } + if (dispatch) { tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node); + tr.insert(insertionPos, blockInfo.bnBlock.node); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)), + TextSelection.near(tr.doc.resolve(insertionPos + 1)), ); return true; @@ -168,9 +185,11 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is the first in a column, moves it to the end of the - // previous column. If there is no previous column, moves it above the - // columnList. + // If the block is the first in a container (e.g. a column or a + // callout), moves it out: to the end of the previous sibling + // container if there is one (e.g. the previous column), otherwise to + // just before the closest enclosing boundary that accepts it (e.g. + // above the columnList / callout). () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -192,32 +211,55 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { return false; } - const $blockPos = tr.doc.resolve(blockInfo.bnBlock.beforePos); - const $columnPos = tr.doc.resolve($blockPos.before()); - const columnListPos = $columnPos.before(); + const blockContainerType = state.schema.nodes["blockContainer"]; + const containerBeforePos = $pos.before(); + const $containerPos = tr.doc.resolve(containerBeforePos); + + // A previous sibling inside an enclosing container (e.g. the + // previous column) is a target to descend into. A sibling at a + // regular block position is not — there the block moves out to + // before the container instead. + const prevSibling = + isContainerNode($containerPos.node().type) && + $containerPos.nodeBefore && + isContainerNode($containerPos.nodeBefore.type) + ? $containerPos.nodeBefore + : null; + + const insertionPos = prevSibling + ? descendToLastInsertionPos( + prevSibling, + containerBeforePos - prevSibling.nodeSize, + blockContainerType, + ) + : ascendToInsertablePos( + tr.doc, + containerBeforePos, + blockContainerType, + ); + if (insertionPos === null) { + return false; + } if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + tr.delete( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, ); - fixColumnList(tr, columnListPos); - - if ($columnPos.pos === columnListPos + 1) { - tr.insert(columnListPos, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve(columnListPos)), - ); - } else { - tr.insert($columnPos.pos - 1, blockInfo.bnBlock.node); - tr.setSelection( - TextSelection.near(tr.doc.resolve($columnPos.pos)), - ); - } + tr.insert(insertionPos, blockInfo.bnBlock.node); + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); } return true; @@ -468,8 +510,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the next block is a columnList, moves the first block from its - // first column to after the current block. + // If the next block is a container (e.g. a columnList or a callout), + // moves its first leaf block out, to after the current block. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -491,18 +533,28 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } + const firstLeaf = getFirstLeafBlock( + nextBlockInfo.bnBlock.node, + nextBlockInfo.bnBlock.beforePos, + ); + if (!firstLeaf) { + return false; + } + if (dispatch) { - const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1; - const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); + const containersToFix = getAncestorContainers( + tr.doc, + firstLeaf.beforePos, + ); tr.delete( - $blockBeforePos.pos, - $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, + firstLeaf.beforePos, + firstLeaf.beforePos + firstLeaf.node.nodeSize, ); - fixColumnList(tr, nextBlockInfo.bnBlock.beforePos); - tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!); + tr.insert(blockInfo.bnBlock.afterPos, firstLeaf.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), + TextSelection.near(tr.doc.resolve(firstLeaf.beforePos)), ); return true; @@ -510,9 +562,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), - // If the block is the last in a column, moves it to the start of the - // next column. If there is no next column, moves it below the - // columnList. + // If the block is the last in a container (e.g. a column or a + // callout), moves the next block — the first leaf of the next sibling + // container, or the block following the enclosing containers — to + // after it. () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); @@ -534,36 +587,49 @@ export const KeyboardShortcutsExtension = Extension.create<{ } const parentBlock = $pos.node(); - if (parentBlock.type.name !== "column") { + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // Climbs out of the containers the block is the last child of, + // to the first position with a following node. + let $boundary = $pos; + while ( + $boundary.nodeAfter === null && + $boundary.depth > 0 && + isContainerNode($boundary.node().type) + ) { + $boundary = tr.doc.resolve($boundary.after()); + } + + const nextNode = $boundary.nodeAfter; + if (!nextNode) { return false; } - const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos); - const $columnEndPos = tr.doc.resolve($blockEndPos.after()); - const columnListEndPos = $columnEndPos.after(); + // The block to pull in: the next node itself or — when it's a + // container — its first leaf block. + const target = isContainerNode(nextNode.type) + ? getFirstLeafBlock(nextNode, $boundary.pos) + : { node: nextNode, beforePos: $boundary.pos }; + if (!target) { + return false; + } if (dispatch) { - // Position before first block in next column, or first block - // after columnList if there is no next column. - const nextBlockBeforePos = - $columnEndPos.pos === columnListEndPos - 1 - ? columnListEndPos - : $columnEndPos.pos + 1; - const nextBlockInfo = getBlockInfoFromResolvedPos( - tr.doc.resolve(nextBlockBeforePos), + const containersToFix = getAncestorContainers( + tr.doc, + target.beforePos, ); tr.delete( - nextBlockInfo.bnBlock.beforePos, - nextBlockInfo.bnBlock.afterPos, - ); - fixColumnList( - tr, - columnListEndPos - $columnEndPos.node().nodeSize, + target.beforePos, + target.beforePos + target.node.nodeSize, ); - tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node); + tr.insert(blockInfo.bnBlock.afterPos, target.node); + fixContainersById(tr, containersToFix); tr.setSelection( - TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), + TextSelection.near(tr.doc.resolve(target.beforePos)), ); } @@ -839,6 +905,68 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; }), + // If the block is empty and the last child of a container with + // `exitOnEnter` behavior (the default for containers), moves the + // block out to after the container — one level per press, list-style. + // Without this, Enter only ever creates new blocks *within* the + // container, so a trailing container could trap the cursor. + () => + commands.command(({ state, tr, dispatch }) => { + const blockInfo = getBlockInfoFromSelection(state); + if (!blockInfo.isBlockContainer) { + return false; + } + + const selectionEmpty = + state.selection.anchor === state.selection.head; + const blockEmpty = blockInfo.blockContent.node.childCount === 0; + if (!selectionEmpty || !blockEmpty) { + return false; + } + + const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const parentBlock = $pos.node(); + if (!isContainerNode(parentBlock.type)) { + return false; + } + + // Only fires on the container's last child. + if (tr.doc.resolve(blockInfo.bnBlock.afterPos).nodeAfter !== null) { + return false; + } + + const exitOnEnter = + this.options.editor.schema.blockSpecs[parentBlock.type.name] + ?.implementation?.meta?.exitOnEnter ?? true; + if (!exitOnEnter) { + return false; + } + + const containerAfterPos = $pos.after(); + + if (dispatch) { + const containersToFix = getAncestorContainers( + tr.doc, + blockInfo.bnBlock.beforePos, + ); + + tr.delete( + blockInfo.bnBlock.beforePos, + blockInfo.bnBlock.afterPos, + ); + // The container's after-position, mapped through the deletion + // (and any schema-driven refill it triggered). + const insertionPos = tr.mapping.map(containerAfterPos); + tr.insert(insertionPos, blockInfo.bnBlock.node); + fixContainersById(tr, containersToFix); + tr.setSelection( + TextSelection.near(tr.doc.resolve(insertionPos + 1)), + ); + tr.scrollIntoView(); + } + + return true; + }), // Creates a new block and moves the selection to it if the current one is empty, while the selection is also // empty & at the start of the block. () => diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 981ba21e48..bd7edb94f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,8 @@ export * from "./api/blockManipulation/commands/insertBlocks/insertBlocks.js"; export * from "./api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; -export * from "./api/blockManipulation/commands/replaceBlocks/util/fixColumnList.js"; +export * from "./api/blockManipulation/containers/fixContainer.js"; +export * from "./api/blockManipulation/containers/containerNav.js"; +export * from "./api/blockManipulation/containers/containerUI.js"; export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; diff --git a/packages/core/src/pm-nodes/README.md b/packages/core/src/pm-nodes/README.md index be57ead212..d2c4682159 100644 --- a/packages/core/src/pm-nodes/README.md +++ b/packages/core/src/pm-nodes/README.md @@ -16,7 +16,7 @@ In the BlockNote API, recall that blocks look like this: } ``` -`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a `column` or `columnList`. +`children` describes child blocks that have their own `id` and also map to a `Block` type. Most of the cases these are nested blocks, but they can also be blocks within a container block (a block declaring `childBlocks`, such as a `column`, `columnList`, or a custom callout). `content` is the block's Inline Content. Inline content doesn't have any `id`, it's "loose" content within the node. @@ -61,41 +61,62 @@ group: "blockContent", Blocks that are part of the `blockContent` group define the appearance / behaviour of the main element of the block (i.e.: headings, paragraphs, list items, etc.). These are only used for "regular" blocks that are represented as `blockContainer` nodes. -## Multi-column +## Container blocks -The `multi-column` package makes it possible to order blocks side by side in -columns. It adds the `columnList` and `column` nodes to the schema. +A block config can declare `childBlocks`, marking the block as a _container +block_: a block that holds other blocks directly as its body. Container +blocks emit their own ProseMirror node (built by `createSpec`'s +`buildContainerNode`) with this shape: + +```typescript +name: blockConfig.type, +group: "bnBlock childContainer blockGroupChild", // blockGroupChild is dropped for `topLevel: false` +// From `childBlocks.allowedBlocks`/`min`/`max`; defaults to `blockGroupChild+`. +// Allowed regular blocks collapse to one `blockContainer` term (ordered first, +// so ProseMirror auto-fill picks it); allowed container types appear verbatim. +content: "blockGroupChild{min,max}", +priority: 40, // below blockContainer (50), so `blockGroupChild` auto-fill never recurses into containers +``` + +Unlike regular blocks, a container block's PM node **is** the `bnBlock` — there +is no `blockContainer` wrapper and no `blockGroup` around the children; child +blocks sit directly inside the container node. The children are exposed as +`block.children` in the BlockNote API. + +The `xl-multi-column` package's blocks are the canonical containers: ### ColumnList ```typescript +// childBlocks: { allowedBlocks: ["column"], min: 2, repair: { removeEmptyChildren: true, belowMin: "unwrap" } } name: "columnList", -group: "childContainer bnBlock blockGroupChild", -// A block always contains content, and optionally a blockGroup which contains nested blocks -content: "column column+", // min two columns +group: "bnBlock childContainer blockGroupChild", +content: "column{2,}", // min two columns ``` -The column list contains 2 or more columns. +The column list contains 2 or more columns. Its `repair` config makes +`fixContainer` drop emptied columns and unwrap the list when fewer than two +non-empty columns remain. ### Column ```typescript +// childBlocks: { topLevel: false } name: "column", -group: "bnBlock childContainer", -// A block always contains content, and optionally a blockGroup which contains nested blocks -content: "blockContainer+", +group: "bnBlock childContainer", // not blockGroupChild: only valid inside a columnList +content: "blockGroupChild+", ``` -The column contains 1 or more block containers. +The column contains 1 or more blocks. # Groups We use Prosemirror "groups" to help organize this schema. Here is a list of the different groups: - `blockContent`: described above (contain the content for blocks that are represented as `BlockContainer` nodes) -- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and `columnList` -- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`, but for columns, both `columnList` and `column` are considered to be `childContainer` nodes. -- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. Both `blockContainer`, `column` and `columnList` are part of this group. +- `blockGroupChild`: anything that is allowed inside a `blockGroup`. In practice, `blockContainer` and top-level container blocks (e.g. `columnList`) +- `childContainer`: think of this as the container node that can hold nodes corresponding to `block.children` in the BlockNote API. So for regular blocks, this is the `BlockGroup`; every container block node (`columnList`, `column`, custom containers) is also a `childContainer`. +- `bnBlock`: think of this as the node that directly maps to a `Block` in the BlockNote API. For example, this node will store the `id`. `blockContainer` and every container block node are part of this group. _Note that the last two groups, `bnBlock` and `childContainer`, are not used anywhere in the schema. They are however helpful while programming. For example, we can check whether a node is a `bnBlock`, and then we know it corresponds to a BlockNote Block. Or, we can check whether a node is a `childContainer`, and then we know it's a container of a BlockNote Block's `children`. See `getBlockInfoFromPos` for an example of how this is used._ diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index 98b08578f1..3a6254b4e3 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -7,6 +7,7 @@ import { } from "@tiptap/pm/model"; import { NodeView } from "@tiptap/pm/view"; import { nodeToBlock } from "../../api/nodeConversions/nodeToBlock.js"; +import { isContainerNode } from "../../api/blockManipulation/containers/fixContainer.js"; import { mergeParagraphs } from "../../blocks/defaultBlockHelpers.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js"; import { @@ -17,8 +18,9 @@ import { nonFormattingMarks } from "../markGroups.js"; import { suggestionMarks } from "../../pm-nodes/suggestionMarks.js"; import { PropSchema } from "../propTypes.js"; import { - containerContentExpression, + childBlocksContentExpression, getBlockFromNodeView, + getChildBlocksConfig, propsToAttributes, wrapInBlockStructure, } from "./internal.js"; @@ -28,7 +30,7 @@ import { BlockImplementation, BlockImplementationOrCreator, BlockSpec, - ContainerConfig, + ChildBlocksConfig, LooseBlockSpec, } from "./types.js"; @@ -174,16 +176,14 @@ export function getParseRules< 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, + childBlocksConfig: ChildBlocksConfig, + isContainerType?: (type: string) => boolean, ) { return Node.create({ name: blockConfig.type, - content: containerContentExpression(containerConfig), + content: childBlocksContentExpression(childBlocksConfig, isContainerType), group: - containerConfig.topLevel === false + childBlocksConfig.topLevel === false ? "bnBlock childContainer" : "bnBlock childContainer blockGroupChild", // All bnBlock-group structural nodes allow the block-level suggestion marks @@ -281,7 +281,12 @@ function buildContainerNode( this.options.domAttributes?.blockContent || {}; const nodeView = blockImplementation.render.call( - { blockContentDOMAttributes, props, renderType: "nodeView" }, + { + blockContentDOMAttributes, + props, + renderType: "nodeView", + propSchema: blockConfig.propSchema, + }, block as any, editor as any, ) as unknown as NodeView; @@ -290,6 +295,11 @@ function buildContainerNode( applyNonSelectableBlockFix(nodeView, 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(nodeView); + return nodeView; }; }, @@ -307,31 +317,24 @@ export function addNodeAndExtensionsToSpec< blockImplementation: BlockImplementation, extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, + // Resolves whether a block type is a container-type block. Provided by + // schema creation (which knows the full spec set); required to resolve + // `childBlocks.allowedBlocks` into a content expression. + isContainerType?: (type: string) => boolean, ): LooseBlockSpec { - // 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; - } + // Resolve the `childBlocks: true` shorthand at read time — the user's + // config object is never mutated (see `getChildBlocksConfig`). + const childBlocksConfig = getChildBlocksConfig(blockConfig); - if (containerConfig && blockConfig.content !== "none") { + if (childBlocksConfig && blockConfig.content !== "none") { throw new Error( - `Block "${blockConfig.type}" sets \`container\` but its \`content\` is "${blockConfig.content}". Container blocks must declare \`content: "none"\`.`, + `Block "${blockConfig.type}" sets \`childBlocks\` but its \`content\` is "${blockConfig.content}". Container blocks must declare \`content: "none"\`.`, ); } const node = ((blockImplementation as any).node as Node) || - (containerConfig + (childBlocksConfig ? buildContainerNode( blockConfig as unknown as BlockConfig, blockImplementation as unknown as BlockImplementation< @@ -339,8 +342,8 @@ export function addNodeAndExtensionsToSpec< TProps, "none" >, - containerConfig, - priority, + childBlocksConfig, + isContainerType, ) : Node.create({ name: blockConfig.type, @@ -634,7 +637,7 @@ export function createBlockSpec< // 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")) { + if (isContainerNode(editor.pmSchema.nodes[block.type])) { return output; } @@ -657,7 +660,7 @@ export function createBlockSpec< editor as any, ); - if (editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock")) { + if (isContainerNode(editor.pmSchema.nodes[block.type])) { return output; } diff --git a/packages/core/src/schema/blocks/internal.ts b/packages/core/src/schema/blocks/internal.ts index c24ff198d5..e60f9354b1 100644 --- a/packages/core/src/schema/blocks/internal.ts +++ b/packages/core/src/schema/blocks/internal.ts @@ -6,25 +6,94 @@ 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 { ContainerConfig, LooseBlockSpec } from "./types.js"; +import { BlockConfig, ChildBlocksConfig, LooseBlockSpec } from "./types.js"; + +// Normalizes the `childBlocks: true` shorthand at read time. Downstream code +// must use this instead of reading `config.childBlocks` directly — the user's +// config object is never mutated, so `blockSchema[type]` identity checks +// (e.g. `checkMultiColumnBlocksInSchema`) stay valid across schema instances. +export function getChildBlocksConfig( + config: Pick, +): ChildBlocksConfig | undefined { + return config.childBlocks === true ? {} : config.childBlocks; +} + +// Whether a block *type* is a container block, resolved from the block spec +// set alone (no compiled PM schema needed) — a type is a container if it +// declares `childBlocks`, or if its hand-written tiptap node is in the +// `bnBlock` group. Use this when you only hold the spec set (schema +// construction, the exporter); when you hold a live PM `NodeType` use +// `isContainerNode`, and when you already have the block config use +// `getChildBlocksConfig`. +export function isContainerType( + // Loosely typed: the block spec's tiptap `node` has a complex tiptap type + // that doesn't structurally match a narrow shape, and only `config` / + // `node.config.group` are read defensively here. + blockSpecs: Record, + type: string, +): boolean { + const spec = blockSpecs[type]; + if (!spec) { + return false; + } + if (getChildBlocksConfig(spec.config)) { + return true; + } + const group = spec.implementation?.node?.config?.group; + return typeof group === "string" && group.split(/\s+/).includes("bnBlock"); +} // Builds the ProseMirror content expression for a container block from its -// cardinality config. -export function containerContentExpression(config: ContainerConfig): string { +// `childBlocks` config. +// +// `allowedBlocks` entries are BlockNote block types, but container children +// are PM *nodes*: container-type blocks are their own node type, while every +// regular block lives inside a `blockContainer` node. So container entries +// are kept verbatim and regular entries collapse to a single `blockContainer` +// term. `blockContainer` is deliberately ordered FIRST in the union — PM's +// `fillBefore` picks the first matching type when auto-filling a non-optional +// node, and filling with `blockContainer` (rather than another container) +// keeps auto-fill from recursing through nested containers. +export function childBlocksContentExpression( + config: ChildBlocksConfig, + // Resolves whether a block type is a container-type block (its own bnBlock + // PM node). Provided by schema creation, which knows the full spec set. + isContainerType?: (type: string) => boolean, +): string { + let term = "blockGroupChild"; + + if (config.allowedBlocks && config.allowedBlocks.length > 0) { + if (!isContainerType) { + throw new Error( + "`childBlocks.allowedBlocks` requires full-schema context to resolve. " + + "Blocks using it must be registered through `BlockNoteSchema`.", + ); + } + const containerTypes = [ + ...new Set(config.allowedBlocks.filter((type) => isContainerType(type))), + ]; + const hasRegularTypes = containerTypes.length < config.allowedBlocks.length; + const terms = [ + ...(hasRegularTypes ? ["blockContainer"] : []), + ...containerTypes, + ]; + term = terms.length === 1 ? terms[0] : `(${terms.join(" | ")})`; + } + const min = config.min; const max = config.max; if (max !== undefined) { const effectiveMin = min ?? 1; - return `blockGroupChild{${effectiveMin},${max}}`; + return `${term}{${effectiveMin},${max}}`; } if (min === 0) { - return "blockGroupChild*"; + return `${term}*`; } if (min === undefined || min === 1) { - return "blockGroupChild+"; + return `${term}+`; } - return `blockGroupChild{${min},}`; + return `${term}{${min},}`; } // Function that uses the 'propSchema' of a blockConfig to create a TipTap @@ -251,6 +320,11 @@ export function createBlockSpecFromTiptapNode< node: Node; type: string; content: "inline" | "table" | "none" | "plain"; + // Declares the block's container semantics (min/max/repair etc.) even + // though the node itself is hand-written — the node's own content + // expression stays authoritative for the PM schema, while BlockNote-level + // behavior (repair, seeding, validation) reads this config. + childBlocks?: true | ChildBlocksConfig; }, P extends PropSchema, >( @@ -263,6 +337,9 @@ export function createBlockSpecFromTiptapNode< type: config.type as T["type"], content: config.content, propSchema, + ...(config.childBlocks !== undefined + ? { childBlocks: config.childBlocks } + : {}), }, implementation: { node: config.node, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index deaa3fa96a..5d32760a22 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -59,26 +59,64 @@ export interface BlockConfigMeta { * Whether the block is a {@link https://prosemirror.net/docs/ref/#model.NodeSpec.isolating} block */ isolating?: boolean; + + /** + * Whether this block type gets a side menu drag handle (and can be dragged + * by it). Applies to any block type, not just container blocks — e.g. a + * "locked" block can opt out of dragging entirely. + * @default true + */ + draggable?: boolean; + + /** + * Only applies to container blocks (blocks with `childBlocks`): whether + * pressing Enter on an empty block that is the last child of the container + * moves that block out of (after) the container, list-style. Without this, + * a container as the last block in the document can trap the cursor, as + * Enter only ever creates new blocks *within* the container. + * @default true + */ + exitOnEnter?: boolean; + + /** + * Only applies to container blocks (blocks with `childBlocks`): how the + * container visually lays out its children. `"horizontal"` (side-by-side, + * like a column list) drives UI behavior — side menu positioning and + * edge-drop handling — and is never consulted by the document model. + * @default "vertical" + */ + childLayout?: "vertical" | "horizontal"; } /** - * 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"`. + * Configuration for a block that hosts other blocks as its body (a + * "container" block). When set, the block's ProseMirror node is emitted in + * the `bnBlock` / `childContainer` groups with block-node content (by + * default `blockGroupChild{min,max}`) — the same shape that columns use. + * Child blocks live on `block.children` at runtime (matching the column + * model). Requires `content: "none"`. */ -export type ContainerConfig = { +export type ChildBlocksConfig = { + /** + * Block types allowed as direct children. Container-block entries (types + * that themselves declare `childBlocks`) are enforced exactly by the + * ProseMirror schema. Regular block entries collapse to "any regular + * block" at the node level — every regular block is wrapped in the same + * `blockContainer` node, so the schema cannot distinguish between them. + * Defaults to any block (`blockGroupChild`). + */ + allowedBlocks?: string[]; /** Minimum number of child blocks. Defaults to 1. */ min?: number; /** Maximum number of child blocks. Defaults to unbounded. */ max?: number; /** - * 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`. + * Children to seed the container with on first insert, as partial blocks + * (so props and nested children are expressible). Ignored when the + * inserted partial block already provides explicit `children`. Validated + * against `allowedBlocks`/`min`/`max` when the schema is created. */ - defaultBlocks?: string[]; + defaultChildren?: PartialBlockNoDefaults[]; /** * Whether the block can be inserted at any position where a regular block * goes — i.e. directly inside a `blockGroup` (the document root, or as a @@ -87,6 +125,37 @@ export type ContainerConfig = { * a `column` only ever lives inside a `columnList`). */ topLevel?: boolean; + /** + * Structural policy applied by `fixContainer` after child removal (e.g. + * Backspace merging a child out, or `replaceBlocks` deleting children). + * Coupled to `min`/`max` — the policy is meaningless without them, so it + * lives here rather than in `meta`. Without `removeEmptyChildren`, repair + * is a no-op: ProseMirror's schema fitting always pads a container back up + * to `min` with empty children, so a container can only be detected as + * "effectively below min" by discounting empty children. + */ + repair?: { + /** + * Whether repair drops empty children (a child holding nothing but a + * single empty paragraph, possibly through nested containers). Column + * lists use this so emptied columns disappear. + * @default false + */ + removeEmptyChildren?: boolean; + /** + * What to do when, after removing empty children, fewer than `min` + * non-empty children remain (ProseMirror pads the container back up to + * `min` with empty ones, so the *total* count never drops below `min`): + * - `"unwrap"` — replace the container with its remaining non-empty + * children (children that are themselves non-top-level containers are + * flattened into *their* children); the container is removed entirely + * when none remain. + * - `"remove"` — delete the container. + * - `"fill"` — keep the container with its padded empty children. + * @default "unwrap" + */ + belowMin?: "unwrap" | "remove" | "fill"; + }; }; /** @@ -117,12 +186,14 @@ export interface BlockConfig< content: C; /** * 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. + * emitted in the `bnBlock` / `childContainer` groups with block-node + * content (regular blocks wrapped in `blockContainer` nodes, plus + * container-type blocks); child blocks are exposed on `block.children`. + * Requires `content: "none"`. Pass `true` for defaults or an object to + * constrain which/how many children are allowed and seed the initial + * children. */ - container?: true | ContainerConfig; + childBlocks?: true | ChildBlocksConfig; } /** @@ -589,7 +660,6 @@ 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 @@ -597,8 +667,11 @@ export type BlockImplementation< * `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. + * Only honored for container blocks (blocks with `childBlocks`), where + * recreating the node view would remount every child block — e.g. column + * resizing patches widths in place through this hook. Non-container + * blocks always recreate on attr changes (see + * https://github.com/TypeCellOS/BlockNote/pull/1904#discussion_r2313461464). */ update?: (node: PMNode) => boolean | void; }; diff --git a/packages/core/src/schema/blocks/validateChildBlocks.test.ts b/packages/core/src/schema/blocks/validateChildBlocks.test.ts new file mode 100644 index 0000000000..b8a64de64e --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildBlocks.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { validateChildBlocksConfigs } from "./validateChildBlocks.js"; + +const paragraph = { config: { type: "paragraph", content: "inline" } } as any; + +function specsWith(containers: Record) { + return { + paragraph, + ...Object.fromEntries( + Object.entries(containers).map(([type, { childBlocks, meta }]) => [ + type, + { + config: { type, content: "none", childBlocks }, + implementation: meta ? { meta } : {}, + }, + ]), + ), + }; +} + +function isContainerType(specs: Record) { + return (type: string) => specs[type]?.config?.childBlocks !== undefined; +} + +describe("validateChildBlocksConfigs", () => { + it("accepts a plain container config", () => { + const specs = specsWith({ callout: { childBlocks: { min: 1 } } }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).not.toThrow(); + }); + + it("accepts the columnList shape (restricted children, min 2)", () => { + const specs = specsWith({ + grid: { childBlocks: { allowedBlocks: ["gridCell"], min: 2 } }, + gridCell: { childBlocks: { topLevel: false } }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).not.toThrow(); + }); + + it("rejects unknown allowedBlocks entries", () => { + const specs = specsWith({ + grid: { childBlocks: { allowedBlocks: ["doesNotExist"] } }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/doesNotExist/); + }); + + it("rejects negative or non-integer min", () => { + const specs = specsWith({ callout: { childBlocks: { min: -1 } } }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/min/); + }); + + it("rejects max smaller than min", () => { + const specs = specsWith({ callout: { childBlocks: { min: 3, max: 2 } } }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/max/); + }); + + it("rejects defaultChildren violating min/max", () => { + const specs = specsWith({ + callout: { + childBlocks: { + min: 2, + defaultChildren: [{ type: "paragraph" }], + }, + }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/defaultChildren/); + }); + + it("rejects defaultChildren of unknown types", () => { + const specs = specsWith({ + callout: { + childBlocks: { defaultChildren: [{ type: "doesNotExist" }] }, + }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/doesNotExist/); + }); + + it("rejects defaultChildren not allowed by allowedBlocks", () => { + const specs = specsWith({ + grid: { + childBlocks: { + allowedBlocks: ["gridCell"], + min: 1, + defaultChildren: [{ type: "paragraph" }], + }, + }, + gridCell: { childBlocks: { topLevel: false } }, + }); + expect(() => + validateChildBlocksConfigs(specs, isContainerType(specs)), + ).toThrow(/not allowed/); + }); +}); diff --git a/packages/core/src/schema/blocks/validateChildBlocks.ts b/packages/core/src/schema/blocks/validateChildBlocks.ts new file mode 100644 index 0000000000..a965e3f15c --- /dev/null +++ b/packages/core/src/schema/blocks/validateChildBlocks.ts @@ -0,0 +1,92 @@ +import { getChildBlocksConfig } from "./internal.js"; +import { BlockConfig } from "./types.js"; + +type SpecLike = { + config: BlockConfig; +}; + +/** + * Validates every `childBlocks` config in a spec set when a schema is + * created, so misconfigurations fail with a clear message at build time + * instead of surfacing as opaque ProseMirror errors deep inside schema + * compilation or document mutations. Kept to the cheap correctness guards + * only — it does not try to robustly detect fill cycles (a custom-schema dev + * foot-gun, out of scope). + */ +export function validateChildBlocksConfigs( + blockSpecs: Record, + isContainerType: (type: string) => boolean, +): void { + for (const [type, spec] of Object.entries(blockSpecs)) { + const config = getChildBlocksConfig(spec.config); + + if (!config) { + continue; + } + + const min = config.min ?? 1; + const max = config.max ?? Infinity; + + if (!Number.isInteger(min) || min < 0) { + throw new Error( + `Block "${type}": \`childBlocks.min\` must be a non-negative integer, got ${config.min}.`, + ); + } + if (config.max !== undefined && (!Number.isInteger(max) || max < 1)) { + throw new Error( + `Block "${type}": \`childBlocks.max\` must be a positive integer, got ${config.max}.`, + ); + } + if (max < min) { + throw new Error( + `Block "${type}": \`childBlocks.max\` (${max}) is smaller than \`min\` (${min}).`, + ); + } + + for (const allowed of config.allowedBlocks ?? []) { + if (!(allowed in blockSpecs)) { + throw new Error( + `Block "${type}": \`childBlocks.allowedBlocks\` entry "${allowed}" does not exist in the schema.`, + ); + } + } + + if (config.defaultChildren) { + if ( + config.defaultChildren.length < min || + config.defaultChildren.length > max + ) { + throw new Error( + `Block "${type}": \`childBlocks.defaultChildren\` has ${config.defaultChildren.length} entries, which does not satisfy min ${min}` + + (config.max !== undefined ? ` / max ${max}` : "") + + `.`, + ); + } + for (const child of config.defaultChildren) { + const childType = child.type ?? "paragraph"; + if (!(childType in blockSpecs)) { + throw new Error( + `Block "${type}": \`childBlocks.defaultChildren\` entry type "${childType}" does not exist in the schema.`, + ); + } + if (config.allowedBlocks && config.allowedBlocks.length > 0) { + // Mirror the node-level enforcement: container types must be listed + // explicitly; regular types pass if any regular type is allowed. + const allowedContainerTypes = config.allowedBlocks.filter((t) => + isContainerType(t), + ); + const allowsRegularTypes = + allowedContainerTypes.length < config.allowedBlocks.length; + const isAllowed = isContainerType(childType) + ? config.allowedBlocks.includes(childType) + : allowsRegularTypes; + if (!isAllowed) { + throw new Error( + `Block "${type}": \`childBlocks.defaultChildren\` entry type "${childType}" is not allowed by \`allowedBlocks\` [${config.allowedBlocks.join(", ")}].`, + ); + } + } + } + } + } +} diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 2f1e703007..5aec783978 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,6 +1,7 @@ export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; +export * from "./blocks/validateChildBlocks.js"; export * from "./inlineContent/createSpec.js"; export * from "./inlineContent/internal.js"; export * from "./inlineContent/types.js"; diff --git a/packages/core/src/schema/schema.ts b/packages/core/src/schema/schema.ts index a7a04e93dc..c4f45e454c 100644 --- a/packages/core/src/schema/schema.ts +++ b/packages/core/src/schema/schema.ts @@ -16,6 +16,8 @@ import { getInlineContentSchemaFromSpecs, getStyleSchemaFromSpecs, } from "./index.js"; +import { isContainerType } from "./blocks/internal.js"; +import { validateChildBlocksConfigs } from "./blocks/validateChildBlocks.js"; function removeUndefined | undefined>(obj: T): T { if (!obj) { @@ -91,6 +93,17 @@ export class CustomBlockNoteSchema< })), ); + // Resolves whether a block type is a container-type block, bound to this + // schema's spec set (see `isContainerType`). Threaded into the spec + // machinery as a single-arg callback. + const resolveIsContainerType = (type: string): boolean => + isContainerType(this.opts.blockSpecs as any, type); + + validateChildBlocksConfigs( + this.opts.blockSpecs as any, + resolveIsContainerType, + ); + const blockSpecs = Object.fromEntries( Object.entries(this.opts.blockSpecs).map(([key, blockSpec]) => { return [ @@ -100,6 +113,7 @@ export class CustomBlockNoteSchema< blockSpec.implementation, blockSpec.extensions, getPriority(key), + resolveIsContainerType, ), ]; }), diff --git a/packages/core/src/yjs/extensions/FixUpSchema.ts b/packages/core/src/yjs/extensions/FixUpSchema.ts index 37fb1fd4e9..7dc3f4253d 100644 --- a/packages/core/src/yjs/extensions/FixUpSchema.ts +++ b/packages/core/src/yjs/extensions/FixUpSchema.ts @@ -25,7 +25,15 @@ export const FixUpSchemaExtension = createExtension(({ editor }) => { // create a copy that we can mutate (otherwise, assigning attrs is not safe and corrupts the pm state) const jsonNode = JSON.parse(JSON.stringify(ret.toJSON())); - jsonNode.content[0].content[0].attrs.id = "initialBlockId"; + // The first fill of the doc's blockGroup is guaranteed to be a + // `blockContainer` (container block nodes register at lower priority + // precisely so auto-fill picks `blockContainer` first), but guard on + // the node actually carrying an id attr in case a custom schema + // changes that. + const firstBlock = jsonNode.content?.[0]?.content?.[0]; + if (firstBlock?.attrs && "id" in firstBlock.attrs) { + firstBlock.attrs.id = "initialBlockId"; + } cache = Node.fromJSON(schema, jsonNode); return cache; diff --git a/packages/react/src/components/Popovers/BlockPopover.tsx b/packages/react/src/components/Popovers/BlockPopover.tsx index 2bf0e4fa57..eed8e0ef93 100644 --- a/packages/react/src/components/Popovers/BlockPopover.tsx +++ b/packages/react/src/components/Popovers/BlockPopover.tsx @@ -1,4 +1,4 @@ -import { getNodeById } from "@blocknote/core"; +import { getNodeById, isContainerNode } from "@blocknote/core"; import { ReactNode, useMemo } from "react"; import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js"; @@ -29,6 +29,17 @@ export const BlockPopover = ( return undefined; } + // For container blocks the PM node IS the block, so a position + // inside it resolves to its contentDOM — the child-blocks area — + // which would anchor the popover to the first child's rows instead + // of the block's own element. + if (isContainerNode(nodePosInfo.node.type)) { + const dom = editor.prosemirrorView.nodeDOM(nodePosInfo.posBeforeNode); + if (dom instanceof Element) { + return { element: dom }; + } + } + const { node } = editor.prosemirrorView.domAtPos( nodePosInfo.posBeforeNode + 1, ); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 9fc6838754..586d7437b7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -137,17 +137,13 @@ export * from "./hooks/useExtension.js"; export * from "./hooks/useEditorState.js"; export * from "./schema/ReactBlockSpec.js"; +export * from "./schema/ChildBlocksWrapper.js"; export * from "./schema/ReactInlineContentSpec.js"; export * from "./schema/ReactStyleSpec.js"; export * from "./schema/useNodeViewBlock.js"; 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/ChildBlocksWrapper.tsx b/packages/react/src/schema/ChildBlocksWrapper.tsx new file mode 100644 index 0000000000..a742a886a5 --- /dev/null +++ b/packages/react/src/schema/ChildBlocksWrapper.tsx @@ -0,0 +1,57 @@ +import { BlockNoteEditor, camelToDataKebab } from "@blocknote/core"; +import { NodeViewWrapper } from "@tiptap/react"; +import { HTMLAttributes, ReactNode } from "react"; + +/** + * The root element for a container block's `render`. Container blocks own + * their outer DOM entirely (the framework doesn't wrap them in a + * `blockContent` div), so their root element must carry the attributes + * BlockNote relies on for HTML parsing and UI positioning. This component + * applies them automatically: + * + * - `data-node-type` — keys the block's HTML parse rule and the side menu / + * drag-and-drop selectors. + * - `data-id` — the block's id. + * - each non-default prop as a kebab-cased `data-*` attribute, so props + * round-trip through HTML serialization. + * + * Any other props (`className`, event handlers, additional attributes) are + * spread onto the element; explicitly passed attributes win over the + * generated ones. + */ +export function ChildBlocksWrapper( + props: { + block: { + id: string; + type: string; + props: Record; + }; + editor: BlockNoteEditor; + children: ReactNode; + } & Omit, "children">, +) { + const { block, editor, children, ...rest } = props; + + const propSchema = + (editor.schema.blockSchema as Record)[block.type] + ?.propSchema ?? {}; + + // Non-default props as kebab-cased `data-*` attributes — the same + // convention `propsToAttributes` parses back out of pasted/exported HTML. + const propAttributes = Object.fromEntries( + Object.entries(block.props ?? {}) + .filter(([prop, value]) => value !== propSchema[prop]?.default) + .map(([prop, value]) => [camelToDataKebab(prop), value]), + ); + + return ( + + {children} + + ); +} diff --git a/packages/react/src/schema/ReactBlockSpec.container.test.tsx b/packages/react/src/schema/ReactBlockSpec.container.test.tsx index 3b6789afeb..01680dd527 100644 --- a/packages/react/src/schema/ReactBlockSpec.container.test.tsx +++ b/packages/react/src/schema/ReactBlockSpec.container.test.tsx @@ -23,7 +23,7 @@ const Callout = createReactBlockSpec( type: "callout" as const, propSchema: {}, content: "none" as const, - container: { min: 1, defaultBlocks: ["paragraph"] }, + childBlocks: { min: 1, defaultChildren: [{ type: "paragraph" }] }, }, { render: ({ contentRef }) => ( @@ -41,7 +41,7 @@ const schema = BlockNoteSchema.create().extend({ } as const, }); -describe("React updateBlock → container with defaultBlocks (document-level)", () => { +describe("React updateBlock → container with defaultChildren (document-level)", () => { let editor: BlockNoteEditor< typeof schema.blockSchema, typeof schema.inlineContentSchema, @@ -72,4 +72,24 @@ describe("React updateBlock → container with defaultBlocks (document-level)", editor.updateBlock("p-0", { type: "callout" }); expect(editor.document).toMatchSnapshot(); }, 5000); + + it("does not wrap containers in a blockContent div in external HTML", async () => { + // A separate, unmounted (headless) editor: the React external-HTML path + // renders through a temporary root in headless mode. + const headlessEditor = BlockNoteEditor.create({ schema }); + + const html = headlessEditor.blocksToHTMLLossy([ + { + type: "callout", + id: "c-0", + children: [{ id: "c-p-0", type: "paragraph", content: "Hello" }], + }, + ] as any); + // Container blocks own their outer DOM entirely — regression test for + // the React `toExternalHTML` path wrapping them in a spurious + // `bn-block-content` div (core's `createBlockSpec` passes them through). + expect(html).not.toContain('data-content-type="callout"'); + expect(html).toContain('data-node-type="callout"'); + expect(html).toContain("Hello"); + }, 5000); }); diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index ae6e41c885..446cde75a3 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -10,6 +10,7 @@ import { Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, + isContainerNode, mergeCSSClasses, nodeToBlock, Props, @@ -237,8 +238,9 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { - const isContainer = - !!editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock"); + const isContainer = isContainerNode( + editor.pmSchema.nodes[block.type], + ); const BlockContent = blockImplementation.toExternalHTML || blockImplementation.render; const output = renderToDOMSpec((refCB) => { @@ -259,16 +261,11 @@ export function createReactBlockSpec< /> ); if (isContainer) { - return ( - - {content} - - ); + // Container blocks own their outer DOM entirely (the PM node IS + // the bnBlock — no `blockContent` wrapper), matching the core + // `createBlockSpec` pass-through and the node-view/dom render + // paths below. + return content; } return ( { - // Vanilla JS node views are recreated on each update. However, - // using `ReactNodeViewRenderer` makes it so the node view is - // only created once, so the block we get in the node view will - // be outdated. Therefore, we have to get the block in the - // `ReactNodeViewRenderer` instead. That position can be stale, - // so resolving it is guarded (see `useNodeViewBlock`). - const 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) ?? - nodeToBlock(props.node, editor.prosemirrorView.state.doc); - } else { - block = nodeViewBlock; - } + // Vanilla JS node views are recreated on each update. However, + // using `ReactNodeViewRenderer` makes it so the node view is only + // created once, so the block we get in the node view will be + // outdated. Therefore, both variants have to (re-)resolve the + // block inside the `ReactNodeViewRenderer` component. + + const ContainerNodeView = (props: NodeViewProps) => { + // Container blocks are bnBlock nodes (no `blockContainer` + // wrapper), so the id lives on the node's own attrs and the + // block resolves by id. Position-based resolution + // (`useNodeViewBlock`) would walk up to a *parent* bnBlock — + // the wrong block here — and ids are also immune to the stale + // positions it has to guard against. + const id = (props.node.attrs as Record).id; + if (!id) { + throw new Error( + `Container block "${blockConfig.type}" is missing an id attribute.`, + ); + } + // The id lookup misses when the node was just removed from the + // document (e.g. a suggestion-mode deletion still rendering); + // fall back to converting the node the view was handed. + const block = + editor.getBlock(id) ?? + nodeToBlock(props.node, props.view.state.doc); - const ref = useReactNodeView().nodeViewContentRef; + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } - if (!ref) { - throw new Error("nodeViewContentRef is not set"); - } + // Container blocks own their entire DOM: the user's render is + // responsible for returning a `` (with any + // `data-*` attrs they want exposed). The framework doesn't + // insert any wrapping element — letting authors build tag-pure + // structures (e.g. ``/``/`
`). + return ( + { + ref(element); + if (element) { + element.dataset.nodeViewContent = ""; + } + }} + /> + ); + }; - const BlockContent = blockImplementation.render; - const content = ( + const RegularNodeView = (props: NodeViewProps) => { + // The node view's position can be stale mid-render, so + // resolving it is guarded (see `useNodeViewBlock`). + const block = useNodeViewBlock(props, initialBlock); + + const ref = useReactNodeView().nodeViewContentRef; + if (!ref) { + throw new Error("nodeViewContentRef is not set"); + } + + return ( + { ref(element); if (element) { - if (!isContainer) { - element.className = mergeCSSClasses( - "bn-inline-content", - element.className, - ); - } + 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 ( - - {content} - - ); - }, + + ); + }; + + return ReactNodeViewRenderer( + isContainer ? ContainerNodeView : RegularNodeView, { className: "bn-react-node-view-renderer", }, )(this.props!) as ReturnType; } else { - const isContainer = - !!editor.pmSchema.nodes[block.type]?.isInGroup("bnBlock"); + const isContainer = isContainerNode( + editor.pmSchema.nodes[block.type], + ); const BlockContent = blockImplementation.render; const output = renderToDOMSpec((refCB) => { const content = ( diff --git a/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap b/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap index 810f3cc319..2a70aa4d12 100644 --- a/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap +++ b/packages/react/src/schema/__snapshots__/ReactBlockSpec.container.test.tsx.snap @@ -1,6 +1,6 @@ // 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`] = ` +exports[`React updateBlock → container with defaultChildren (document-level) > converts an empty paragraph to a callout via editor.updateBlock 1`] = ` [ { "children": [ diff --git a/packages/react/src/schema/useNodeViewBlock.ts b/packages/react/src/schema/useNodeViewBlock.ts index 02393a2fd0..74d2e84037 100644 --- a/packages/react/src/schema/useNodeViewBlock.ts +++ b/packages/react/src/schema/useNodeViewBlock.ts @@ -42,6 +42,17 @@ export function useNodeViewBlock( const lastBlockRef = useRef(initialBlock); const doc = props.view.state.doc; + // Position-based resolution finds the nearest bnBlock *parent* of the + // position — correct for blockContent node views, but wrong-by-construction + // for container blocks, whose node IS the bnBlock: it would return an + // ancestor block. Guarded loudly so a container node view can't silently + // render the wrong block. + if (props.node.type.isInGroup("bnBlock")) { + throw new Error( + `useNodeViewBlock cannot resolve container block "${props.node.type.name}": position-based resolution returns the nearest bnBlock parent, which is the wrong block when the node view's node is the block itself. Resolve container blocks by id instead, e.g. editor.getBlock(props.node.attrs.id).`, + ); + } + try { // Deliberate render-phase write: a monotonic "last good value" cache, so a // repeated render (e.g. StrictMode's double invoke) recomputes the same diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts index a24340a7ab..9dfe893326 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.test.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.test.ts @@ -1,5 +1,6 @@ import { BlockNoteSchema, + createBlockSpec, defaultBlockSpecs, createPageBreakBlockSpec, } from "@blocknote/core"; @@ -270,6 +271,82 @@ describe("exporter", () => { ); }); +describe("custom container blocks", () => { + const Box = createBlockSpec( + { + type: "box" as const, + propSchema: {}, + content: "none", + childBlocks: { min: 1 }, + }, + { + render: (block: any) => { + const dom = document.createElement("div"); + dom.setAttribute("data-node-type", "box"); + dom.setAttribute("data-id", block.id); + return { dom, contentDOM: dom }; + }, + }, + )(); + + const boxSchema = BlockNoteSchema.create({ + blockSpecs: { + ...defaultBlockSpecs, + box: Box, + }, + }); + + const boxDocument = partialBlocksToBlocksForTesting(boxSchema, [ + { + type: "box", + children: [ + { type: "paragraph", content: "First" }, + { type: "paragraph", content: "Second" }, + ], + }, + ] as any); + + it("passes children to a custom container mapping", async () => { + const exporter = new DOCXExporter( + boxSchema, + { + ...docxDefaultSchemaMappings, + blockMapping: { + ...docxDefaultSchemaMappings.blockMapping, + box: ( + _block: any, + _exporter: any, + _nesting: any, + _index: any, + children: any, + ) => + new Paragraph({ + children: [new TextRun(`BOX(${children?.length ?? 0})`)], + }), + }, + } as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + const transformed = await exporter.transformBlocks(boxDocument as any); + expect(transformed).toHaveLength(1); + const xml = JSON.stringify(transformed[0]); + expect(xml).toContain("BOX(2)"); + }); + + it("throws a clear error for an unmapped container block", async () => { + const exporter = new DOCXExporter( + boxSchema, + docxDefaultSchemaMappings as any, + { resolveFileUrl: testResolveFileUrl }, + ); + + await expect(exporter.transformBlocks(boxDocument as any)).rejects.toThrow( + /container block type "box"/, + ); + }); +}); + function prettify(sourceXml: string) { let ret = xmlFormat(sourceXml); diff --git a/packages/xl-docx-exporter/src/docx/docxExporter.ts b/packages/xl-docx-exporter/src/docx/docxExporter.ts index 6fce968a3e..bf4a991a44 100644 --- a/packages/xl-docx-exporter/src/docx/docxExporter.ts +++ b/packages/xl-docx-exporter/src/docx/docxExporter.ts @@ -115,7 +115,7 @@ export class DOCXExporter< for (const b of blocks) { let children = await this.transformBlocks(b.children, nestingLevel + 1); - if (!["columnList", "column"].includes(b.type)) { + if (!this.isContainerBlock(b.type)) { children = children.map((c, _i) => { // NOTE: nested tables not supported (we can't insert the new Tab before a table) if ( @@ -138,7 +138,7 @@ export class DOCXExporter< 0 /*unused*/, children, ); // TODO: any - if (["columnList", "column"].includes(b.type)) { + if (this.isContainerBlock(b.type)) { ret.push(self as Table); } else if (Array.isArray(self)) { ret.push(...self, ...children); diff --git a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx index 5f4eecf3c5..df9fafdf61 100644 --- a/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx +++ b/packages/xl-email-exporter/src/react-email/reactEmailExporter.tsx @@ -246,6 +246,24 @@ export class ReactEmailExporter< i = nextIndex; continue; } + if (this.isContainerBlock(b.type)) { + // Container blocks (columnList, column, custom containers): the + // mapping owns the placement of the children, so they are passed in + // and not rendered as an indented sibling list. + const containerChildren = await this.transformBlocks( + b.children, + nestingLevel + 1, + ); + const containerSelf = (await this.mapBlock( + b as any, + nestingLevel, + 0, + containerChildren as any, + )) as any; + ret.push({containerSelf}); + i++; + continue; + } // Non-list blocks const children = await this.transformBlocks(b.children, nestingLevel + 1); const self = (await this.mapBlock(b as any, nestingLevel, 0)) as any; diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index c1670e030d..edcdd12e50 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -1,11 +1,7 @@ -import { - createBlockSpec, - createBlockSpecFromTiptapNode, -} from "@blocknote/core"; +import { createBlockSpec } from "@blocknote/core"; import { ColumnResizeExtension } from "../../extensions/ColumnResize/ColumnResizeExtension.js"; import { MultiColumnDropHandlerExtension } from "../../extensions/DropCursor/multiColumnHandleDropPlugin.js"; -import { ColumnList } from "../../pm-nodes/ColumnList.js"; // 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 @@ -31,9 +27,17 @@ export const ColumnBlock = createBlockSpec( // 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 }, + childBlocks: { topLevel: false }, }, { + meta: { + // Enter on an empty last block stays inside the column (the generic + // container default would move it out below the columnList). + exitOnEnter: false, + // Columns are never dragged individually — whole columnLists are + // rearranged via column-specific drag handling instead. + draggable: false, + }, render: (block) => { const dom = document.createElement("div"); dom.className = "bn-block-column"; @@ -75,11 +79,64 @@ export const ColumnBlock = createBlockSpec( [MultiColumnDropHandlerExtension(), ColumnResizeExtension()], )(); -export const ColumnListBlock = createBlockSpecFromTiptapNode( +export const ColumnListBlock = createBlockSpec( { - node: ColumnList, - type: "columnList", + type: "columnList" as const, + propSchema: {}, content: "none", + // Generates the `column{2,}` content expression (equivalent to the + // previous hand-written node's `column column+`) and drives the generic + // container machinery: emptied columns are removed on repair, and a + // columnList left with fewer than two non-empty columns is replaced by + // the surviving column's content. + childBlocks: { + allowedBlocks: ["column"], + min: 2, + repair: { + removeEmptyChildren: true, + belowMin: "unwrap", + }, + }, + }, + { + meta: { + // Preserved from the hand-written ColumnList node (which used the PM + // default); container blocks otherwise default to `isolating: true`. + isolating: false, + // Columns are laid out side-by-side — drives side menu positioning + // and edge-drop behavior. + childLayout: "horizontal", + // Whole-columnList dragging stays disabled (matches previous + // behavior; columns are rearranged via column-specific drag handling). + draggable: false, + // Enter on an empty last block stays inside the column list's columns. + exitOnEnter: false, + }, + render: (block) => { + const dom = document.createElement("div"); + dom.className = "bn-block-column-list"; + dom.setAttribute("data-node-type", "columnList"); + dom.setAttribute("data-id", block.id); + dom.style.display = "flex"; + + return { + dom, + contentDOM: dom, + update: (newNode: { + type: { name: string }; + attrs: { id?: string }; + }) => { + if (newNode.type.name !== "columnList") { + return false; + } + if (newNode.attrs.id) { + dom.setAttribute("data-id", newNode.attrs.id); + } else { + dom.removeAttribute("data-id"); + } + return true; + }, + }; + }, }, - {}, -); +)(); diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts index 61defa7886..49dd6712d7 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts @@ -1,4 +1,10 @@ -import { type DropCursorHooks, getNearestBlockPos } from "@blocknote/core"; +import { + type BlockNoteEditor, + type DropCursorHooks, + getContainerUIInfo, + getNearestBlockPos, + isContainerNode, +} from "@blocknote/core"; import type { EditorState } from "prosemirror-state"; import type { EditorView } from "prosemirror-view"; @@ -16,6 +22,7 @@ export interface EdgeDropPosition { * Returns null when the event position cannot be resolved (e.g. drop outside editor bounds). */ export function detectEdgePosition( + editor: BlockNoteEditor, event: DragEvent, view: EditorView, state: EditorState, @@ -31,10 +38,20 @@ export function detectEdgePosition( const blockPos = getNearestBlockPos(state.doc, eventPos.pos); - // If we're at a block that's in a column, we want to compare the mouse position to the column, not the block inside it - // Why? Because we want to insert a new column in the columnList, instead of a new columnList inside of the column + // If we're at a block inside a child container of a horizontal-layout + // container (e.g. inside a column of a columnList), we want to compare the + // mouse position to the child container, not the block inside it. + // Why? Because we want to insert a new sibling child (a new column in the + // columnList) instead of a new container inside the child. + const { horizontalContainerTypes } = getContainerUIInfo(editor); let resolved = state.doc.resolve(blockPos.posBeforeNode); - if (resolved.parent.type.name === "column") { + if ( + isContainerNode(resolved.parent.type) && + resolved.depth > 0 && + horizontalContainerTypes.has( + state.doc.resolve(resolved.before()).parent.type.name, + ) + ) { resolved = state.doc.resolve(resolved.before()); } @@ -84,6 +101,7 @@ export const multiColumnDropCursor: { hooks: DropCursorHooks } = { hooks: { computeDropPosition: (context) => { const edgePos = detectEdgePosition( + context.editor, context.event, context.view, context.view.state, diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 7c8e0b312e..8fcd316691 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -3,6 +3,8 @@ import { UniqueID, createExtension, getBlockInfo, + getContainerUIInfo, + isContainerNode, nodeToBlock, } from "@blocknote/core"; import { Plugin } from "prosemirror-state"; @@ -20,7 +22,7 @@ export function createMultiColumnHandleDropPlugin( return new Plugin({ props: { handleDrop(view: EditorView, event: DragEvent, slice, _moved) { - const edgePos = detectEdgePosition(event, view, view.state); + const edgePos = detectEdgePosition(editor, event, view, view.state); if (edgePos === null) { return false; // Let ProseMirror handle the drop (e.g. outside editor bounds) } @@ -41,17 +43,33 @@ export function createMultiColumnHandleDropPlugin( view.state.doc, ); - if (blockInfo.blockNoteType === "column") { - // Insert new column in existing columnList - const parentBlock = view.state.doc - .resolve(blockInfo.bnBlock.beforePos) - .node(); + // Whether the edge target sits directly inside a horizontal-layout + // container (after `detectEdgePosition` hoisted blocks inside a + // column to the column itself) — e.g. the target is a `column` whose + // parent is a `columnList`. + const { horizontalContainerTypes } = getContainerUIInfo(editor); + const $target = view.state.doc.resolve(blockInfo.bnBlock.beforePos); + const targetInHorizontalContainer = horizontalContainerTypes.has( + $target.node().type.name, + ); + + if (targetInHorizontalContainer) { + // Insert a new sibling child in the existing horizontal container + // (e.g. a new column in the columnList). + const parentBlock = $target.node(); const columnList = nodeToBlock( parentBlock, view.state.doc, ); + // Whether the horizontal container's children are typed child + // containers (like `column`) that wrap the actual blocks, or plain + // blocks spliced in directly. + const targetIsChildContainer = isContainerNode( + blockInfo.bnBlock.node.type, + ); + // Normalize column widths to average of 1 // In a `columnList`, we expect that the average width of each column // is 1. However, there are cases in which this stops being true. For @@ -59,24 +77,31 @@ export function createMultiColumnHandleDropPlugin( // the average width to go down. This isn't really an issue until the // user tries to add a new column, which will, in this case, be wider // than expected. Therefore, we normalize the column widths to an - // average of 1 here to avoid this issue. - let sumColumnWidthPercent = 0; - columnList.children.forEach((column) => { - sumColumnWidthPercent += column.props.width as number; - }); - const avgColumnWidthPercent = - sumColumnWidthPercent / columnList.children.length; - - // If the average column width is not 1, normalize it. We're dealing - // with floats so we need a small margin to account for precision - // errors. - if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { - const scalingFactor = 1 / avgColumnWidthPercent; - + // average of 1 here to avoid this issue. (Only applies to child + // containers with a numeric `width` prop, i.e. columns.) + if ( + columnList.children.every( + (column) => typeof column.props.width === "number", + ) + ) { + let sumColumnWidthPercent = 0; columnList.children.forEach((column) => { - column.props.width = - (column.props.width as number) * scalingFactor; + sumColumnWidthPercent += column.props.width as number; }); + const avgColumnWidthPercent = + sumColumnWidthPercent / columnList.children.length; + + // If the average column width is not 1, normalize it. We're + // dealing with floats so we need a small margin to account for + // precision errors. + if (avgColumnWidthPercent < 0.99 || avgColumnWidthPercent > 1.01) { + const scalingFactor = 1 / avgColumnWidthPercent; + + columnList.children.forEach((column) => { + column.props.width = + (column.props.width as number) * scalingFactor; + }); + } } const index = columnList.children.findIndex( @@ -85,22 +110,36 @@ export function createMultiColumnHandleDropPlugin( const newChildren = columnList.children // If the dragged block is in one of the columns, remove it. - .map((column) => ({ - ...column, - children: column.children.filter( - (block) => block.id !== draggedBlock.id, - ), - })) + .map((column) => + targetIsChildContainer + ? { + ...column, + children: column.children.filter( + (block) => block.id !== draggedBlock.id, + ), + } + : column, + ) // Remove empty columns (can happen when dragged block is removed). - .filter((column) => column.children.length > 0) - // Insert the dragged block in the correct position. - .toSpliced(edgePos.position === "left" ? index : index + 1, 0, { - type: "column", - children: [draggedBlock], - props: {}, - content: undefined, - id: UniqueID.options.generateID(), - }); + .filter( + (column) => !targetIsChildContainer || column.children.length > 0, + ) + // Insert the dragged block in the correct position, wrapped in a + // new child container (e.g. a new `column`) when the container's + // children are typed containers. + .toSpliced( + edgePos.position === "left" ? index : index + 1, + 0, + targetIsChildContainer + ? { + type: blockInfo.blockNoteType, + children: [draggedBlock], + props: {}, + content: undefined, + id: UniqueID.options.generateID(), + } + : draggedBlock, + ); if (editor.getBlock(draggedBlock.id)) { editor.removeBlocks([draggedBlock]); diff --git a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts b/packages/xl-multi-column/src/pm-nodes/ColumnList.ts deleted file mode 100644 index eeb06f4d4e..0000000000 --- a/packages/xl-multi-column/src/pm-nodes/ColumnList.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { suggestionMarks } from "@blocknote/core"; -import { Node } from "@tiptap/core"; - -export const ColumnList = Node.create({ - name: "columnList", - group: "childContainer bnBlock blockGroupChild", - // A block always contains content, and optionally a blockGroup which contains nested blocks - content: "column column+", // min two columns - priority: 40, // should be below blockContainer - defining: true, - marks() { - return suggestionMarks(this.editor); - }, - parseHTML() { - return [ - { - tag: "div", - getAttrs: (element) => { - if (typeof element === "string") { - return false; - } - - if (element.getAttribute("data-node-type") === this.name) { - return {}; - } - - return false; - }, - }, - ]; - }, - - renderHTML({ HTMLAttributes }) { - const columnList = document.createElement("div"); - columnList.className = "bn-block-column-list"; - columnList.setAttribute("data-node-type", this.name); - for (const [attribute, value] of Object.entries(HTMLAttributes)) { - columnList.setAttribute(attribute, value as any); // TODO as any - } - columnList.style.display = "flex"; - - return { - dom: columnList, - contentDOM: columnList, - }; - }, -}); diff --git a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap similarity index 95% rename from packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap rename to packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap index 87b5f2e588..a5d8ddf91f 100644 --- a/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixColumnLists.test.ts.snap +++ b/packages/xl-multi-column/src/test/commands/util/__snapshots__/fixContainer.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Test fixColumnList > First of two columns empty 1`] = ` +exports[`Test fixContainer > First of two columns empty 1`] = ` { "content": [ { @@ -35,7 +35,7 @@ exports[`Test fixColumnList > First of two columns empty 1`] = ` } `; -exports[`Test fixColumnList > Last of two columns empty 1`] = ` +exports[`Test fixContainer > Last of two columns empty 1`] = ` { "content": [ { @@ -70,7 +70,7 @@ exports[`Test fixColumnList > Last of two columns empty 1`] = ` } `; -exports[`Test fixColumnList > Two empty columns 1`] = ` +exports[`Test fixContainer > Two empty columns 1`] = ` { "content": [ { @@ -99,7 +99,7 @@ exports[`Test fixColumnList > Two empty columns 1`] = ` } `; -exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` +exports[`Test removeEmptyChildren > First of two columns empty 1`] = ` { "content": [ { @@ -176,7 +176,7 @@ exports[`Test removeEmptyColumns > First of two columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` +exports[`Test removeEmptyChildren > Last of two columns empty 1`] = ` { "content": [ { @@ -253,7 +253,7 @@ exports[`Test removeEmptyColumns > Last of two columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` +exports[`Test removeEmptyChildren > Start and end columns empty 1`] = ` { "content": [ { @@ -336,7 +336,7 @@ exports[`Test removeEmptyColumns > Start and end columns empty 1`] = ` } `; -exports[`Test removeEmptyColumns > Two empty columns 1`] = ` +exports[`Test removeEmptyChildren > Two empty columns 1`] = ` { "content": [ { diff --git a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts similarity index 91% rename from packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts rename to packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts index b5bd190c6d..7a49e97907 100644 --- a/packages/xl-multi-column/src/test/commands/util/fixColumnLists.test.ts +++ b/packages/xl-multi-column/src/test/commands/util/fixContainer.test.ts @@ -2,14 +2,14 @@ import { describe, expect, it } from "vite-plus/test"; import { setupTestEnv } from "../../setupTestEnv.js"; import { - fixColumnList, - isEmptyColumn, - removeEmptyColumns, + fixContainer, + isEmptyContainerChild, + removeEmptyChildren, } from "@blocknote/core"; const getEditor = setupTestEnv(); -describe("Test isEmptyColumn", () => { +describe("Test isEmptyContainerChild", () => { it("Empty blocks", () => { const schema = getEditor()._tiptapEditor.schema; @@ -19,7 +19,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeTruthy(); + expect(isEmptyContainerChild(column)).toBeTruthy(); }); it("Multiple blocks", () => { @@ -34,7 +34,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Block with children", () => { @@ -51,7 +51,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Block with text", () => { @@ -65,7 +65,7 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); it("Non-text block", () => { @@ -77,11 +77,11 @@ describe("Test isEmptyColumn", () => { ]), ]); - expect(isEmptyColumn(column)).toBeFalsy(); + expect(isEmptyContainerChild(column)).toBeFalsy(); }); }); -describe("Test removeEmptyColumns", () => { +describe("Test removeEmptyChildren", () => { it("Start and end columns empty", () => { const editor = getEditor(); const schema = editor._tiptapEditor.schema; @@ -116,7 +116,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -143,7 +143,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -170,7 +170,7 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -195,13 +195,13 @@ describe("Test removeEmptyColumns", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - removeEmptyColumns(tr, 1); + removeEmptyChildren(tr, 1); expect(tr.doc).toMatchSnapshot(); }); }); -describe("Test fixColumnList", () => { +describe("Test fixContainer", () => { it("First of two columns empty", () => { const editor = getEditor(); const schema = editor._tiptapEditor.schema; @@ -224,7 +224,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -251,7 +251,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); @@ -276,7 +276,7 @@ describe("Test fixColumnList", () => { const tr = editor.prosemirrorState.tr; tr.replaceRangeWith(1, tr.doc.firstChild!.content.size, columnList); - fixColumnList(tr, 1); + fixContainer(tr, 1); expect(tr.doc).toMatchSnapshot(); }); diff --git a/packages/xl-odt-exporter/src/odt/odtExporter.tsx b/packages/xl-odt-exporter/src/odt/odtExporter.tsx index de9567c59e..c2ea317bff 100644 --- a/packages/xl-odt-exporter/src/odt/odtExporter.tsx +++ b/packages/xl-odt-exporter/src/odt/odtExporter.tsx @@ -126,7 +126,7 @@ export class ODTExporter< numberedListIndex = 0; } - if (["columnList", "column"].includes(block.type)) { + if (this.isContainerBlock(block.type)) { const children = await this.transformBlocks(block.children, 0); const content = await this.mapBlock( block as any, diff --git a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx index 6d8962eaaf..591e5db33e 100644 --- a/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx +++ b/packages/xl-pdf-exporter/src/pdf/pdfExporter.tsx @@ -156,7 +156,7 @@ export class PDFExporter< children, ); // TODO: any - if (["pageBreak", "columnList", "column"].includes(b.type)) { + if (b.type === "pageBreak" || this.isContainerBlock(b.type)) { ret.push(self); continue; } diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index efae7f0509..798b380806 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -1470,7 +1470,7 @@ export const examples = { 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)', + 'In this example, we create a custom `Callout` block that holds **other blocks** as its body — like a Notion-style callout that can wrap a paragraph followed by a code block, or any combination of nested blocks.\n\nThe block uses the new `childBlocks` config on `BlockConfig`. Setting `childBlocks: { defaultChildren: [{ type: "paragraph" }] }` (with `content: "none"`) tells BlockNote to emit a ProseMirror node that holds nested block children directly — the same shape that columns use under the hood. The contained blocks live on `block.children` at runtime.\n\nThe callout also has an editable **title**, demonstrating the complementary "string prop slot" pattern: content that doesn\'t need rich text, comments, or multiplayer cursors can live in a plain string prop, edited through a regular `` rendered inside the block (in a `contentEditable={false}` wrapper) and committed via `editor.updateBlock`.\n\nWe also wire up a Slash Menu item to insert the callout, and render the document JSON next to the editor so you can inspect the structure of the nested blocks.\n\n**Try it out:**\n\n- Press the "/" key inside the callout\'s body and add a code block, heading, or list — anything goes.\n- Type a title into the title field — it\'s stored on `block.props.title`, not as document content.\n- Watch the JSON panel on the right update as you edit; the callout\'s children appear in `block.children`.\n- Insert a new callout via the Slash Menu (search "callout").\n\n**Relevant Docs:**\n\n- [Custom Blocks](/docs/features/custom-schemas/custom-blocks)\n- [Editor Setup](/docs/getting-started/editor-setup)', }, { projectSlug: "draggable-inline-content", @@ -1880,7 +1880,7 @@ export const examples = { tags: ["Extension"], pro: true, dependencies: { - "@tiptap/core": "^3.13.0", + "@tiptap/core": "^3.29.2", } as any, }, title: "TipTap extension (arrow InputRule)", diff --git a/tests/src/unit/react/useNodeViewBlock.test.tsx b/tests/src/unit/react/useNodeViewBlock.test.tsx index 71101c7fa7..eb99225aa5 100644 --- a/tests/src/unit/react/useNodeViewBlock.test.tsx +++ b/tests/src/unit/react/useNodeViewBlock.test.tsx @@ -27,8 +27,15 @@ const createReproBlock = createReactBlockSpec( { render: (props) =>

}, ); +// A container block, whose node view's node IS the bnBlock — resolved by id +// instead of by position. +const createBoxBlock = createReactBlockSpec( + { type: "box", propSchema: {}, content: "none", childBlocks: true }, + { render: (props) =>

}, +); + const schema = BlockNoteSchema.create().extend({ - blockSpecs: { repro: createReproBlock() }, + blockSpecs: { repro: createReproBlock(), box: createBoxBlock() }, }); let editor: BlockNoteEditor; @@ -43,6 +50,7 @@ beforeEach(() => { { type: "paragraph", content: "first" }, { type: "repro", content: "target block" }, { type: "paragraph", content: "last" }, + { type: "box", children: [{ type: "paragraph", content: "inside" }] }, ], }) as BlockNoteEditor; @@ -78,11 +86,14 @@ function renderHook( return resolved; } -// Only the two fields `useNodeViewBlock` reads. Built structurally so `tests` -// doesn't need a dependency on `@tiptap/react` just for its prop types. -function makeProps(getPos: () => number | undefined) { +// Only the fields `useNodeViewBlock` reads. Built structurally so `tests` +// doesn't need a dependency on `@tiptap/react` just for its prop types. The +// `node` defaults to a regular (non-container) block's node shape; container +// tests pass the real PM node instead. +function makeProps(getPos: () => number | undefined, node?: unknown) { return { getPos, + node: node ?? { type: { isInGroup: () => false } }, view: { state: { doc: editor.prosemirrorState.doc } }, } as unknown as Parameters[0]; } @@ -170,4 +181,34 @@ describe("useNodeViewBlock", () => { expect(resolved.id).toBe(target.id); expect(resolved).not.toBe(seed); }); + + it("rejects container blocks loudly instead of resolving the wrong block", () => { + const box = editor.document[3]; + const { node } = getNodeById(box.id, editor.prosemirrorState.doc)!; + const props = makeProps(() => undefined, node); + + let captured: unknown; + + function Probe() { + useNodeViewBlock(props, box); + return null; + } + + root = createRoot(div, { + // React 19 reports uncaught render errors here instead of rethrowing + // out of `flushSync`. + onUncaughtError: (error: unknown) => { + captured = error; + }, + }); + try { + flushSync(() => { + root!.render(); + }); + } catch (error) { + captured = error; + } + + expect(String(captured)).toMatch(/cannot resolve container block "box"/); + }); }); From 65c1337fd7eddfce6eaed9af27c34c6997758033 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 11 Aug 2026 13:25:06 +0200 Subject: [PATCH 8/8] refactor(core): drop childLayout meta and collapse repair to a boolean Simplify the container-block API further: - Replace the `childBlocks.repair` object with a single `collapseWhenEmptied` boolean (the only shipped behavior was "unwrap"; the speculative "remove"/"fill" branches are gone). - Remove the `childLayout` meta flag. Horizontal layout is now derived from DOM geometry (children overlapping on the vertical axis) instead of a declared flag that duplicated the block's own CSS. Multi-column drop handling checks its own `columnList` type directly. - Extract the side menu's container hit-testing helpers into `sideMenuContainerGeometry.ts` and add unit tests covering direct-child detection, horizontality, ancestor walking, and cursor-to-child resolution. --- .../custom-schemas/container-blocks.mdx | 5 +- .../containers/containerUI.ts | 11 - .../containers/containers.test.ts | 8 +- .../containers/fixContainer.ts | 37 +-- .../core/src/extensions/SideMenu/SideMenu.ts | 79 ++---- .../sideMenuContainerGeometry.test.ts | 243 ++++++++++++++++++ .../SideMenu/sideMenuContainerGeometry.ts | 121 +++++++++ packages/core/src/pm-nodes/README.md | 8 +- packages/core/src/schema/blocks/types.ts | 54 ++-- .../src/blocks/Columns/index.ts | 8 +- .../DropCursor/multiColumnDropCursor.ts | 18 +- .../DropCursor/multiColumnHandleDropPlugin.ts | 16 +- 12 files changed, 432 insertions(+), 176 deletions(-) create mode 100644 packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts create mode 100644 packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx index 2fa4208d65..02b2c44124 100644 --- a/docs/content/docs/features/custom-schemas/container-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -79,14 +79,13 @@ For vanilla JS blocks (`createBlockSpec`), return a DOM element with `contentDOM | `min` / `max` | `1` / unbounded | How many children are allowed. Enforced by the editor schema. | | `defaultChildren` | — | Partial blocks seeded when the container is inserted without children. Validated against `allowedBlocks`/`min`/`max` when the schema is created. | | `topLevel` | `true` | Whether the block can appear anywhere a regular block goes. Set `false` for blocks that only make sense inside a specific parent (like a `column` inside a `columnList`). | -| `repair` | — | Structural cleanup after children are removed: `removeEmptyChildren` drops emptied children, and `belowMin` (`"unwrap"` / `"remove"` / `"fill"`) decides what happens when fewer than `min` non-empty children remain. Column lists use `{ removeEmptyChildren: true, belowMin: "unwrap" }`. | +| `collapseWhenEmptied` | `false` | Structural cleanup after children are removed: drops emptied children, and unwraps the container (replacing it with its remaining children, or removing it when none are left) once fewer than `min` non-empty children remain. Column lists set this to `true`. | Behavioral options live in the block implementation's `meta` instead, since they don't affect the document schema: | Meta option | Default | Description | | --- | --- | --- | | `exitOnEnter` | `true` | Pressing Enter on an empty last child moves it out of the container, list-style. Disable to keep the cursor inside (columns do this). | -| `childLayout` | `"vertical"` | Set `"horizontal"` for side-by-side children (like columns) — drives side menu positioning and edge-drop behavior. | | `draggable` | `true` | Whether the container itself gets a side menu drag handle. | ### Restricting children: a columnList-style pair @@ -98,7 +97,7 @@ Behavioral options live in the block implementation's `meta` instead, since they childBlocks: { allowedBlocks: ["column"], min: 2, - repair: { removeEmptyChildren: true, belowMin: "unwrap" }, + collapseWhenEmptied: true, } // The column: holds any blocks, but can only live inside a columnList. diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts index 772f8b34d8..5c523ea7c8 100644 --- a/packages/core/src/api/blockManipulation/containers/containerUI.ts +++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts @@ -9,14 +9,10 @@ import { isContainerType } from "../../../schema/blocks/internal.js"; export type ContainerUIInfo = { /** All container block types (blocks holding child blocks directly). */ containerTypes: ReadonlySet; - /** Container types whose children are laid out side-by-side. */ - horizontalContainerTypes: ReadonlySet; /** Container types that get their own side-menu drag handle. */ draggableContainerTypes: ReadonlySet; /** Selector matching any container element, or null if there are none. */ containerSelector: string | null; - /** Selector matching horizontal container elements, or null if none. */ - horizontalContainerSelector: string | null; }; // Minimal structural view of the editor, to avoid depending on the full @@ -48,7 +44,6 @@ export function getContainerUIInfo(editor: EditorWithSchema): ContainerUIInfo { } const containerTypes = new Set(); - const horizontalContainerTypes = new Set(); const draggableContainerTypes = new Set(); for (const [type, spec] of Object.entries( @@ -58,7 +53,6 @@ export function getContainerUIInfo(editor: EditorWithSchema): ContainerUIInfo { config: any; implementation?: { meta?: { - childLayout?: "vertical" | "horizontal"; draggable?: boolean; }; node?: { config?: { group?: string } }; @@ -72,9 +66,6 @@ export function getContainerUIInfo(editor: EditorWithSchema): ContainerUIInfo { containerTypes.add(type); const meta = spec.implementation?.meta; - if (meta?.childLayout === "horizontal") { - horizontalContainerTypes.add(type); - } if (meta?.draggable !== false) { draggableContainerTypes.add(type); } @@ -82,10 +73,8 @@ export function getContainerUIInfo(editor: EditorWithSchema): ContainerUIInfo { const info: ContainerUIInfo = { containerTypes, - horizontalContainerTypes, draggableContainerTypes, containerSelector: buildSelector(containerTypes), - horizontalContainerSelector: buildSelector(horizontalContainerTypes), }; cache.set(editor, info); return info; diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts index 0abf0ba2eb..fd2ebdc3ca 100644 --- a/packages/core/src/api/blockManipulation/containers/containers.test.ts +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -72,16 +72,10 @@ const Grid = createBlockSpec( childBlocks: { allowedBlocks: ["gridCell"], min: 2, - repair: { - removeEmptyChildren: true, - belowMin: "unwrap", - }, + collapseWhenEmptied: true, }, }, { - meta: { - childLayout: "horizontal", - }, render: (block) => { const dom = document.createElement("div"); dom.setAttribute("data-node-type", "grid"); diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts index 2622cac7ed..2c2eab721e 100644 --- a/packages/core/src/api/blockManipulation/containers/fixContainer.ts +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -88,22 +88,18 @@ function isInsertableChild(node: Node): boolean { /** * Repairs the container node at `containerPos` after children were (re)moved - * from it, according to the block's `childBlocks.repair` config: + * from it, according to the block's `childBlocks.collapseWhenEmptied` config: * - * - When `repair.removeEmptyChildren` is set, drops empty children. If that - * leaves fewer than `min` non-empty children (ProseMirror pads the - * container back up to `min` with empty ones, so the *total* count never - * drops), applies `repair.belowMin`: - * - `"unwrap"` (default) — replaces the container with its remaining - * non-empty children (non-top-level container children are flattened - * into their own children), or deletes it when none remain; - * - `"remove"` — deletes the container; - * - `"fill"` — keeps the container with its padded empty children. - * - Containers without `repair.removeEmptyChildren` are left untouched: - * ProseMirror's schema fitting already guarantees they satisfy `min`. + * - When `collapseWhenEmptied` is set, drops empty children. If that leaves + * fewer than `min` non-empty children (ProseMirror pads the container back + * up to `min` with empty ones, so the *total* count never drops), unwraps + * the container — replacing it with its remaining non-empty children + * (non-top-level container children are flattened into their own children), + * or deleting it when none remain. + * - Containers without `collapseWhenEmptied` are left untouched: ProseMirror's + * schema fitting already guarantees they satisfy `min`. * - * This generalizes what `fixColumnList` did for column lists (which set - * `removeEmptyChildren: true` + `belowMin: "unwrap"`). + * This generalizes what `fixColumnList` did for column lists. * @param tr The `Transaction` to add the changes to. * @param containerPos The position just before the container node. */ @@ -118,7 +114,7 @@ export function fixContainer(tr: Transaction, containerPos: number) { const blockConfig = getBlockSchema(getPmSchema(tr))[container.type.name]; const config = blockConfig ? getChildBlocksConfig(blockConfig) : undefined; - if (!config?.repair?.removeEmptyChildren) { + if (!config?.collapseWhenEmptied) { return; } @@ -144,18 +140,13 @@ export function fixContainer(tr: Transaction, containerPos: number) { return; } - const belowMin = config.repair.belowMin ?? "unwrap"; - - if (belowMin === "fill") { - return; - } - - if (belowMin === "remove" || nonEmptyChildren.length === 0) { + if (nonEmptyChildren.length === 0) { + // Nothing worth keeping — remove the container entirely. tr.delete(containerPos, containerPos + refreshed.nodeSize); return; } - // "unwrap": replace the container with its remaining non-empty children. + // Unwrap: replace the container with its remaining non-empty children. if (nonEmptyChildren.length === 1) { // Single survivor: move its content out with a `ReplaceAroundStep` so the // content is mapped (moved) rather than deleted-and-recreated — this diff --git a/packages/core/src/extensions/SideMenu/SideMenu.ts b/packages/core/src/extensions/SideMenu/SideMenu.ts index b9d50bdbfe..c6673dd7df 100644 --- a/packages/core/src/extensions/SideMenu/SideMenu.ts +++ b/packages/core/src/extensions/SideMenu/SideMenu.ts @@ -26,6 +26,10 @@ import { } from "../../api/blockManipulation/containers/containerUI.js"; import { getDraggableBlockFromElement } from "../getDraggableBlockFromElement.js"; import { dragStart, unsetDragImage } from "./dragging.js"; +import { + getContainerChildAtCursor, + hasHorizontalContainerAncestor, +} from "./sideMenuContainerGeometry.js"; export type SideMenuState< BSchema extends BlockSchema, @@ -53,26 +57,24 @@ function getBlockFromCoords( } if ( adjustForHorizontalContainers && - containerUIInfo.horizontalContainerSelector - ) { + containerUIInfo.containerSelector && // Inside a container with side-by-side children (e.g. a columnList), // the x position must be offset — the hovered coordinates land in the - // side menu's own gutter, which belongs to a different child. - const horizontalContainer = element.closest( - containerUIInfo.horizontalContainerSelector, + // side menu's own gutter, which belongs to a different child. The + // horizontal container can be any ancestor (the element may sit inside + // a vertical child of it, like a block inside a column). + hasHorizontalContainerAncestor(element, containerUIInfo) + ) { + return getBlockFromCoords( + view, + { + // TODO can we do better than this? + left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself + top: coords.top, + }, + containerUIInfo, + false, ); - if (horizontalContainer) { - return getBlockFromCoords( - view, - { - // TODO can we do better than this? - left: coords.left + 50, // bit hacky, but if we're inside a column, offset x position to right to account for the width of sidemenu itself - top: coords.top, - }, - containerUIInfo, - false, - ); - } } return getDraggableBlockFromElement( element, @@ -83,49 +85,6 @@ function getBlockFromCoords( return undefined; } -/** - * If `element` is a container block's element, finds its direct child block - * whose vertical range contains the cursor. Hovering a container's own - * chrome (padding, a title bar, the side-menu gutter) next to a child - * should attach the side menu to that child — mirroring how hovering a - * parent block's gutter next to a nested block attaches to the nested - * block. The container's own menu stays reachable on rows occupied only by - * its chrome. When children sit side-by-side, a child containing the - * cursor's x position wins over the first vertical match. - */ -function getContainerChildAtCursor( - element: Element, - mousePos: { x: number; y: number }, - containerUIInfo: ContainerUIInfo, -): Element | undefined { - const nodeType = element.getAttribute("data-node-type"); - if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) { - return undefined; - } - - const childSelector = containerUIInfo.containerSelector - ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` - : `[data-node-type="blockContainer"]`; - - let verticalMatch: Element | undefined = undefined; - for (const child of element.querySelectorAll(childSelector)) { - // Direct children only (in the block sense): the closest block element - // above the candidate must be the container itself. - if (child.parentElement?.closest(childSelector) !== element) { - continue; - } - const rect = child.getBoundingClientRect(); - if (mousePos.y < rect.top || mousePos.y > rect.bottom) { - continue; - } - if (mousePos.x >= rect.left && mousePos.x <= rect.right) { - return child; - } - verticalMatch = verticalMatch ?? child; - } - return verticalMatch; -} - function getBlockFromMousePos( mousePos: { x: number; diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts new file mode 100644 index 0000000000..d5da72e4b2 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; +import { + getContainerChildAtCursor, + getDirectChildBlocks, + hasHorizontalContainerAncestor, + isHorizontalContainer, +} from "./sideMenuContainerGeometry.js"; + +// jsdom does no layout, so `getBoundingClientRect` returns all-zero rects. +// These helpers are pure geometry over the DOM, so we build detached trees and +// stub each element's rect to simulate a laid-out editor. + +type Rect = { top: number; bottom: number; left: number; right: number }; + +function setRect(el: Element, rect: Rect) { + const full = { + ...rect, + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + }; + (el as HTMLElement).getBoundingClientRect = () => + ({ ...full, toJSON: () => full }) as DOMRect; +} + +/** Creates a `[data-node-type]` element with an optional stubbed rect. */ +function el(nodeType: string, rect?: Rect): HTMLElement { + const node = document.createElement("div"); + node.setAttribute("data-node-type", nodeType); + if (rect) { + setRect(node, rect); + } + return node; +} + +/** Wraps a block content node in the `blockOuter > blockContainer` chrome that + * BlockNote renders around every regular block, returning the outer wrapper. */ +function regularChild(rect?: Rect): { + outer: HTMLElement; + blockContainer: HTMLElement; +} { + const outer = el("blockOuter"); + const blockContainer = el("blockContainer", rect); + outer.append(blockContainer); + return { outer, blockContainer }; +} + +function uiInfo(containerTypes: string[]): ContainerUIInfo { + const set = new Set(containerTypes); + return { + containerTypes: set, + draggableContainerTypes: set, + containerSelector: containerTypes.length + ? containerTypes.map((t) => `[data-node-type="${t}"]`).join(",") + : null, + }; +} + +/** + * Builds a columnList with two columns laid out side-by-side, each holding a + * single regular block. Returns the pieces so tests can probe them. + */ +function buildColumnList() { + const info = uiInfo(["columnList", "column"]); + + const columnList = el("columnList", { + top: 0, + bottom: 100, + left: 0, + right: 200, + }); + const columnA = el("column", { top: 0, bottom: 100, left: 0, right: 100 }); + const columnB = el("column", { top: 0, bottom: 100, left: 100, right: 200 }); + + const childA = regularChild({ top: 0, bottom: 40, left: 0, right: 100 }); + const childB = regularChild({ top: 0, bottom: 40, left: 100, right: 200 }); + columnA.append(childA.outer); + columnB.append(childB.outer); + columnList.append(columnA, columnB); + + return { info, columnList, columnA, columnB, childA, childB }; +} + +/** + * Builds a vertical container (callout-like) holding two stacked regular + * blocks. + */ +function buildVerticalContainer() { + const info = uiInfo(["callout"]); + + const callout = el("callout", { top: 0, bottom: 80, left: 0, right: 200 }); + const first = regularChild({ top: 0, bottom: 40, left: 0, right: 200 }); + const second = regularChild({ top: 40, bottom: 80, left: 0, right: 200 }); + callout.append(first.outer, second.outer); + + return { info, callout, first, second }; +} + +describe("getDirectChildBlocks", () => { + it("returns direct child blocks, skipping nested grandchildren", () => { + const { info, columnList, columnA, columnB } = buildColumnList(); + + const children = getDirectChildBlocks(columnList, info); + + expect(children).toEqual([columnA, columnB]); + }); + + it("sees through blockOuter wrappers to the blockContainer child", () => { + const { info, columnA, childA } = buildColumnList(); + + // The column's own direct child is the wrapped blockContainer, not the + // blockOuter chrome. + expect(getDirectChildBlocks(columnA, info)).toEqual([ + childA.blockContainer, + ]); + }); + + it("returns nothing for a container with no block children", () => { + const info = uiInfo(["callout"]); + const empty = el("callout"); + + expect(getDirectChildBlocks(empty, info)).toEqual([]); + }); +}); + +describe("isHorizontalContainer", () => { + it("is true when direct children overlap vertically (side-by-side)", () => { + const { info, columnList } = buildColumnList(); + + expect(isHorizontalContainer(columnList, info)).toBe(true); + }); + + it("is false when direct children are stacked", () => { + const { info, callout } = buildVerticalContainer(); + + expect(isHorizontalContainer(callout, info)).toBe(false); + }); + + it("is false for a single child", () => { + const { info, columnA } = buildColumnList(); + + expect(isHorizontalContainer(columnA, info)).toBe(false); + }); + + it("treats abutting (non-overlapping) children as vertical", () => { + // Second child's top exactly meets the first child's bottom — a stack + // with no gap must not be misread as horizontal. + const info = uiInfo(["callout"]); + const callout = el("callout"); + const a = regularChild({ top: 0, bottom: 40, left: 0, right: 200 }); + const b = regularChild({ top: 40, bottom: 80, left: 0, right: 200 }); + callout.append(a.outer, b.outer); + + expect(isHorizontalContainer(callout, info)).toBe(false); + }); +}); + +describe("hasHorizontalContainerAncestor", () => { + it("is true for a block nested inside a column of a columnList", () => { + const { info, childA } = buildColumnList(); + + // The block sits inside a (vertical) column, whose parent columnList is + // the horizontal one — the walk must climb past the column. + expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe( + true, + ); + }); + + it("is false for a block inside a purely vertical container", () => { + const { info, first } = buildVerticalContainer(); + + expect(hasHorizontalContainerAncestor(first.blockContainer, info)).toBe( + false, + ); + }); + + it("is false when there is no container ancestor", () => { + const info = uiInfo(["columnList", "column"]); + const loose = regularChild(); + + expect(hasHorizontalContainerAncestor(loose.blockContainer, info)).toBe( + false, + ); + }); + + it("is false when the schema declares no containers", () => { + const { childA } = buildColumnList(); + const info = uiInfo([]); + + expect(hasHorizontalContainerAncestor(childA.blockContainer, info)).toBe( + false, + ); + }); +}); + +describe("getContainerChildAtCursor", () => { + it("returns undefined for a non-container element", () => { + const { info, childA } = buildColumnList(); + + expect( + getContainerChildAtCursor(childA.blockContainer, { x: 10, y: 10 }, info), + ).toBeUndefined(); + }); + + it("returns the child whose x range contains the cursor (horizontal)", () => { + const { info, columnList, columnB } = buildColumnList(); + + // x=150 lands in the second column's horizontal range. + expect(getContainerChildAtCursor(columnList, { x: 150, y: 50 }, info)).toBe( + columnB, + ); + }); + + it("prefers the x match over the first vertical match", () => { + const { info, columnList, columnA } = buildColumnList(); + + // x=10 is within column A; both columns share the y range. + expect(getContainerChildAtCursor(columnList, { x: 10, y: 50 }, info)).toBe( + columnA, + ); + }); + + it("falls back to the first vertical match when x is in the gutter", () => { + const { info, callout, first } = buildVerticalContainer(); + + // A vertical container: the cursor y is in the first child's band but x + // is left of it (the side-menu gutter). The first vertical match wins. + expect(getContainerChildAtCursor(callout, { x: -20, y: 20 }, info)).toBe( + first.blockContainer, + ); + }); + + it("returns undefined when the cursor is below all children", () => { + const { info, callout } = buildVerticalContainer(); + + expect( + getContainerChildAtCursor(callout, { x: 10, y: 999 }, info), + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts new file mode 100644 index 0000000000..8ff8a69278 --- /dev/null +++ b/packages/core/src/extensions/SideMenu/sideMenuContainerGeometry.ts @@ -0,0 +1,121 @@ +import type { ContainerUIInfo } from "../../api/blockManipulation/containers/containerUI.js"; + +/** + * DOM hit-testing helpers the side menu uses to resolve which block a cursor + * is over inside container blocks. They read live layout geometry + * (`getBoundingClientRect`) rather than any declared layout flag, so container + * blocks don't have to describe their own layout — a container whose children + * happen to sit side-by-side (like a column list) is detected as such. + */ + +/** + * The selector matching any block that can be a direct child of a container: + * a regular block (`blockContainer`) or a nested container element. + */ +function containerChildSelector(containerUIInfo: ContainerUIInfo): string { + return containerUIInfo.containerSelector + ? `[data-node-type="blockContainer"],${containerUIInfo.containerSelector}` + : `[data-node-type="blockContainer"]`; +} + +/** + * The direct child block elements of a container element (in the block + * sense): the closest block element above each match must be the container + * itself. Matches `blockContainer`s (regular children) and nested container + * elements. + */ +export function getDirectChildBlocks( + container: Element, + containerUIInfo: ContainerUIInfo, +): Element[] { + const childSelector = containerChildSelector(containerUIInfo); + + const children: Element[] = []; + for (const child of container.querySelectorAll(childSelector)) { + if (child.parentElement?.closest(childSelector) === container) { + children.push(child); + } + } + return children; +} + +/** + * Whether a container element lays its direct child blocks out side-by-side + * (like a column list), detected from geometry rather than a declared flag: + * two direct children that overlap along the vertical axis can only be + * sitting next to each other horizontally. + */ +export function isHorizontalContainer( + container: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + const rects = getDirectChildBlocks(container, containerUIInfo).map((child) => + child.getBoundingClientRect(), + ); + for (let i = 0; i < rects.length; i++) { + for (let j = i + 1; j < rects.length; j++) { + if (rects[i].top < rects[j].bottom && rects[j].top < rects[i].bottom) { + return true; + } + } + } + return false; +} + +/** + * Whether `element` sits inside a horizontal container at any depth (e.g. a + * block inside a column of a columnList — the columnList, not the column, is + * the horizontal one). Walks up the container ancestor chain. + */ +export function hasHorizontalContainerAncestor( + element: Element, + containerUIInfo: ContainerUIInfo, +): boolean { + if (!containerUIInfo.containerSelector) { + return false; + } + let container = element.closest(containerUIInfo.containerSelector); + while (container) { + if (isHorizontalContainer(container, containerUIInfo)) { + return true; + } + container = + container.parentElement?.closest(containerUIInfo.containerSelector) ?? + null; + } + return false; +} + +/** + * If `element` is a container block's element, finds its direct child block + * whose vertical range contains the cursor. Hovering a container's own + * chrome (padding, a title bar, the side-menu gutter) next to a child + * should attach the side menu to that child — mirroring how hovering a + * parent block's gutter next to a nested block attaches to the nested + * block. The container's own menu stays reachable on rows occupied only by + * its chrome. When children sit side-by-side, a child containing the + * cursor's x position wins over the first vertical match. + */ +export function getContainerChildAtCursor( + element: Element, + mousePos: { x: number; y: number }, + containerUIInfo: ContainerUIInfo, +): Element | undefined { + const nodeType = element.getAttribute("data-node-type"); + if (!nodeType || !containerUIInfo.containerTypes.has(nodeType)) { + return undefined; + } + + let verticalMatch: Element | undefined = undefined; + for (const child of getDirectChildBlocks(element, containerUIInfo)) { + const rect = child.getBoundingClientRect(); + if (mousePos.y < rect.top || mousePos.y > rect.bottom) { + continue; + } + if (mousePos.x >= rect.left && mousePos.x <= rect.right) { + return child; + } + verticalMatch = verticalMatch ?? child; + } + return verticalMatch; +} diff --git a/packages/core/src/pm-nodes/README.md b/packages/core/src/pm-nodes/README.md index d2c4682159..d0f7d408f7 100644 --- a/packages/core/src/pm-nodes/README.md +++ b/packages/core/src/pm-nodes/README.md @@ -88,15 +88,15 @@ The `xl-multi-column` package's blocks are the canonical containers: ### ColumnList ```typescript -// childBlocks: { allowedBlocks: ["column"], min: 2, repair: { removeEmptyChildren: true, belowMin: "unwrap" } } +// childBlocks: { allowedBlocks: ["column"], min: 2, collapseWhenEmptied: true } name: "columnList", group: "bnBlock childContainer blockGroupChild", content: "column{2,}", // min two columns ``` -The column list contains 2 or more columns. Its `repair` config makes -`fixContainer` drop emptied columns and unwrap the list when fewer than two -non-empty columns remain. +The column list contains 2 or more columns. Its `collapseWhenEmptied` config +makes `fixContainer` drop emptied columns and unwrap the list when fewer than +two non-empty columns remain. ### Column diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 5d32760a22..75c5ac4532 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -77,15 +77,6 @@ export interface BlockConfigMeta { * @default true */ exitOnEnter?: boolean; - - /** - * Only applies to container blocks (blocks with `childBlocks`): how the - * container visually lays out its children. `"horizontal"` (side-by-side, - * like a column list) drives UI behavior — side menu positioning and - * edge-drop handling — and is never consulted by the document model. - * @default "vertical" - */ - childLayout?: "vertical" | "horizontal"; } /** @@ -126,36 +117,23 @@ export type ChildBlocksConfig = { */ topLevel?: boolean; /** - * Structural policy applied by `fixContainer` after child removal (e.g. - * Backspace merging a child out, or `replaceBlocks` deleting children). - * Coupled to `min`/`max` — the policy is meaningless without them, so it - * lives here rather than in `meta`. Without `removeEmptyChildren`, repair - * is a no-op: ProseMirror's schema fitting always pads a container back up - * to `min` with empty children, so a container can only be detected as - * "effectively below min" by discounting empty children. + * When set, `fixContainer` collapses the container as its children empty + * out (after Backspace merges a child out, `replaceBlocks` deletes + * children, etc.): it drops emptied children (a child holding nothing but a + * single empty paragraph, possibly through nested containers) and, if that + * leaves fewer than `min` non-empty children, unwraps the container — + * replacing it with its remaining non-empty children (non-top-level + * container children are flattened into *their* children), or removing it + * entirely when none remain. Column lists use this so emptied columns + * disappear and a one-column list unwraps. + * + * Coupled to `min`, so it lives here rather than in `meta`: without it + * repair is a no-op, because ProseMirror's schema fitting always pads a + * container back up to `min` with empty children, so "effectively below + * min" can only be detected by discounting those empty children. + * @default false */ - repair?: { - /** - * Whether repair drops empty children (a child holding nothing but a - * single empty paragraph, possibly through nested containers). Column - * lists use this so emptied columns disappear. - * @default false - */ - removeEmptyChildren?: boolean; - /** - * What to do when, after removing empty children, fewer than `min` - * non-empty children remain (ProseMirror pads the container back up to - * `min` with empty ones, so the *total* count never drops below `min`): - * - `"unwrap"` — replace the container with its remaining non-empty - * children (children that are themselves non-top-level containers are - * flattened into *their* children); the container is removed entirely - * when none remain. - * - `"remove"` — delete the container. - * - `"fill"` — keep the container with its padded empty children. - * @default "unwrap" - */ - belowMin?: "unwrap" | "remove" | "fill"; - }; + collapseWhenEmptied?: boolean; }; /** diff --git a/packages/xl-multi-column/src/blocks/Columns/index.ts b/packages/xl-multi-column/src/blocks/Columns/index.ts index edcdd12e50..6951cec982 100644 --- a/packages/xl-multi-column/src/blocks/Columns/index.ts +++ b/packages/xl-multi-column/src/blocks/Columns/index.ts @@ -92,10 +92,7 @@ export const ColumnListBlock = createBlockSpec( childBlocks: { allowedBlocks: ["column"], min: 2, - repair: { - removeEmptyChildren: true, - belowMin: "unwrap", - }, + collapseWhenEmptied: true, }, }, { @@ -103,9 +100,6 @@ export const ColumnListBlock = createBlockSpec( // Preserved from the hand-written ColumnList node (which used the PM // default); container blocks otherwise default to `isolating: true`. isolating: false, - // Columns are laid out side-by-side — drives side menu positioning - // and edge-drop behavior. - childLayout: "horizontal", // Whole-columnList dragging stays disabled (matches previous // behavior; columns are rearranged via column-specific drag handling). draggable: false, diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts index 49dd6712d7..e5db7a8a9e 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnDropCursor.ts @@ -1,7 +1,5 @@ import { - type BlockNoteEditor, type DropCursorHooks, - getContainerUIInfo, getNearestBlockPos, isContainerNode, } from "@blocknote/core"; @@ -22,7 +20,6 @@ export interface EdgeDropPosition { * Returns null when the event position cannot be resolved (e.g. drop outside editor bounds). */ export function detectEdgePosition( - editor: BlockNoteEditor, event: DragEvent, view: EditorView, state: EditorState, @@ -38,19 +35,15 @@ export function detectEdgePosition( const blockPos = getNearestBlockPos(state.doc, eventPos.pos); - // If we're at a block inside a child container of a horizontal-layout - // container (e.g. inside a column of a columnList), we want to compare the - // mouse position to the child container, not the block inside it. - // Why? Because we want to insert a new sibling child (a new column in the - // columnList) instead of a new container inside the child. - const { horizontalContainerTypes } = getContainerUIInfo(editor); + // If we're at a block inside a column of a columnList, we want to compare + // the mouse position to the column, not the block inside it. + // Why? Because we want to insert a new sibling column in the columnList + // instead of a new container inside the column. let resolved = state.doc.resolve(blockPos.posBeforeNode); if ( isContainerNode(resolved.parent.type) && resolved.depth > 0 && - horizontalContainerTypes.has( - state.doc.resolve(resolved.before()).parent.type.name, - ) + state.doc.resolve(resolved.before()).parent.type.name === "columnList" ) { resolved = state.doc.resolve(resolved.before()); } @@ -101,7 +94,6 @@ export const multiColumnDropCursor: { hooks: DropCursorHooks } = { hooks: { computeDropPosition: (context) => { const edgePos = detectEdgePosition( - context.editor, context.event, context.view, context.view.state, diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 8fcd316691..8796cbb051 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -3,7 +3,6 @@ import { UniqueID, createExtension, getBlockInfo, - getContainerUIInfo, isContainerNode, nodeToBlock, } from "@blocknote/core"; @@ -22,7 +21,7 @@ export function createMultiColumnHandleDropPlugin( return new Plugin({ props: { handleDrop(view: EditorView, event: DragEvent, slice, _moved) { - const edgePos = detectEdgePosition(editor, event, view, view.state); + const edgePos = detectEdgePosition(event, view, view.state); if (edgePos === null) { return false; // Let ProseMirror handle the drop (e.g. outside editor bounds) } @@ -43,15 +42,12 @@ export function createMultiColumnHandleDropPlugin( view.state.doc, ); - // Whether the edge target sits directly inside a horizontal-layout - // container (after `detectEdgePosition` hoisted blocks inside a - // column to the column itself) — e.g. the target is a `column` whose - // parent is a `columnList`. - const { horizontalContainerTypes } = getContainerUIInfo(editor); + // Whether the edge target is a `columnList` (after `detectEdgePosition` + // hoisted blocks inside a column to the column itself, the target's + // parent is the columnList). const $target = view.state.doc.resolve(blockInfo.bnBlock.beforePos); - const targetInHorizontalContainer = horizontalContainerTypes.has( - $target.node().type.name, - ); + const targetInHorizontalContainer = + $target.node().type.name === "columnList"; if (targetInHorizontalContainer) { // Insert a new sibling child in the existing horizontal container