From 727a5dfb0f8760a069fd537dcc9a754fba451c2d Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 25 Aug 2026 16:42:43 +0200 Subject: [PATCH 1/2] refactor(core): simplify the BlockInfo API and make it the single vocabulary for block/children plumbing Combines the former block-info and blockinfo-consolidation changes into one API-simplification pass over BlockInfo. Field renames (drop internal jargon; PM group name strings unchanged): - bnBlock -> block - blockContent -> content - childContainer -> children - isWrappedBlock -> hasContent Precomputed derived fields, replacing hand arithmetic at dozens of callsites (blockContent.beforePos + 1, afterPos - 1, empty-inline checks): - contentStart / contentEnd - children.childrenStart / children.childrenEnd - contentKind: "inline" | "none" | "table" | "other" - isContentEmpty Producer consolidation: 6 overlapping producers -> 4, named by what you have (getBlockInfoFromNode, getBlockInfoAt, getBlockInfoNearPos, getBlockInfoFromSelection). getBlockInfo and getBlockInfoFromResolvedPos are deleted; getBlockInfoWithManualOffset, getBlockInfoAtNearest and getBottomNestedBlockInfo are renamed. One shape resolver: getBlockRegions(node) -> { outer, content?, childrenHolder? } resolves container vs blockContainer shape in one place, consumed by getBlockInfoFromNode. Deleted the synonym vocabularies that answered "where do children live" in parallel: childrenHolder.ts, ChildrenWriteTarget, fixContainer's private repair targets, and the descend seal-variant trio (now one self-recursive descendToLastInsertionPos returning { pos, crossedSeal }). Deleted helpers that were bare property reads: getChildrenConfig, isContainerType, isPlaceableAnywhere, isInsertableChild; inlined deleteBlockCollapsingSingletonGroup and the table-caret +-4 arithmetic. Navigation helpers (getParentBlockInfo / getPrevBlockInfo / getNextBlockInfo / getLastDescendantBlockInfo) move from mergeBlocks.ts to getBlockInfoFromPos.ts and become public. getParentBlockInfo now has block-model semantics: a block inside a column parents to the column, not the columnList, fixing the Delete-at-end climb's seal check for container children. --- .../commands/insertBlocks/insertBlocks.ts | 45 +- .../commands/mergeBlocks/mergeBlocks.test.ts | 5 +- .../commands/mergeBlocks/mergeBlocks.ts | 153 +------ .../commands/moveBlocks/moveBlocks.test.ts | 24 +- .../commands/moveBlocks/moveBlocks.ts | 19 +- .../commands/nestBlock/nestBlock.ts | 2 +- .../commands/replaceBlocks/replaceBlocks.ts | 17 +- .../commands/splitBlock/splitBlock.test.ts | 10 +- .../commands/splitBlock/splitBlock.ts | 17 +- .../commands/updateBlock/updateBlock.test.ts | 83 ++-- .../commands/updateBlock/updateBlock.ts | 119 +++--- .../containers/containerNav.ts | 63 +-- .../containers/containerUI.ts | 3 +- .../containers/containers.test.ts | 92 ++++ .../containers/fixContainer.ts | 122 +++--- .../blockManipulation/getBlock/getBlock.ts | 18 +- .../blockManipulation/selections/selection.ts | 35 +- .../selections/textCursorPosition.ts | 65 ++- .../fromClipboard/handleFileInsertion.ts | 6 +- .../html/util/serializeBlocksExternalHTML.ts | 3 +- .../html/util/serializeBlocksInternalHTML.ts | 3 +- .../core/src/api/getBlockInfoFromPos.test.ts | 233 ++++++++++- packages/core/src/api/getBlockInfoFromPos.ts | 378 ++++++++++++----- .../api/getBlocksChangedByTransaction.test.ts | 10 +- .../src/api/nodeConversions/blockToNode.ts | 14 +- .../api/nodeConversions/fragmentToBlocks.ts | 10 +- .../src/api/nodeConversions/nodeToBlock.ts | 87 ++-- packages/core/src/api/nodeUtil.ts | 2 +- .../ListItem/ListItemKeyboardShortcuts.ts | 16 +- .../NumberedListItem/IndexingPlugin.ts | 31 +- .../src/blocks/utils/listItemEnterHandler.ts | 10 +- .../core/src/editor/BlockNoteEditor.test.ts | 4 +- .../managers/ExtensionManager/extensions.ts | 3 +- .../editor/managers/ExtensionManager/index.ts | 6 +- packages/core/src/editor/transformPasted.ts | 17 +- packages/core/src/exporter/Exporter.ts | 3 +- .../KeyboardShortcutsExtension.test.ts | 346 +++++++++++++++ .../KeyboardShortcutsExtension.ts | 394 ++++++++---------- packages/core/src/internal.ts | 7 +- packages/core/src/pm-nodes/README.md | 4 + .../blocks/assertSchemaInvariants.test.ts | 47 +++ .../schema/blocks/assertSchemaInvariants.ts | 31 +- packages/core/src/schema/blocks/children.ts | 101 +++-- packages/core/src/schema/blocks/createSpec.ts | 10 +- .../src/schema/blocks/validateChildren.ts | 24 +- packages/core/src/schema/index.ts | 1 - packages/react/src/schema/ReactBlockSpec.tsx | 7 +- .../formats/html-blocks/collabUpdate.test.ts | 11 +- packages/xl-ai/src/prosemirror/agent.test.ts | 36 +- .../xl-ai/src/prosemirror/rebaseTool.test.ts | 30 +- .../cases/combinedOperationsTestCases.ts | 12 +- .../cases/updateOperationTestCases.ts | 28 +- .../DropCursor/multiColumnHandleDropPlugin.ts | 14 +- 53 files changed, 1811 insertions(+), 1020 deletions(-) create mode 100644 packages/core/src/schema/blocks/assertSchemaInvariants.test.ts diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index 21a5006fad..f869f9c3d8 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -9,6 +9,7 @@ import { StyleSchema, } from "../../../../schema/index.js"; import { isContainerNode } from "../../../../schema/blocks/children.js"; +import { getBlockInfoFromNode } from "../../../getBlockInfoFromPos.js"; import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; @@ -51,10 +52,10 @@ export function getInsertionPos( ): { pos: number; wrapIn?: NodeType } | null { const { node, posBeforeNode } = reference; - const descend = (holder: Node, pos: number) => + const descend = (holder: { node: Node; beforePos: number }) => placement === "start" - ? descendToFirstInsertionPos(holder, pos, nodeType) - : descendToLastInsertionPos(holder, pos, nodeType); + ? descendToFirstInsertionPos(holder, nodeType) + : descendToLastInsertionPos(holder, nodeType).pos; if (placement === "before" || placement === "after") { const pos = @@ -66,33 +67,31 @@ export function getInsertionPos( : null; } - // A container holds its children itself. The descent helpers ignore sealed - // boundaries by default, which is correct here: an explicit `insertBlocks` - // placement is an intentional crossing. - if (isContainerNode(node.type)) { - const pos = descend(node, posBeforeNode); - - return pos === null ? null : { pos }; - } - - // A regular block keeps its children in a `blockGroup` that only exists once - // it has some. - const blockGroupType = nodeType.schema.nodes["blockGroup"]; - if (node.type.name !== "blockContainer" || !blockGroupType) { + // Neither a container nor a `blockContainer` (possible only for exotic + // hand-written specs): nothing can nest inside it. + if (!isContainerNode(node.type) && node.type.name !== "blockContainer") { return null; } - const blockGroupPos = posBeforeNode + 1 + node.firstChild!.nodeSize; + const info = getBlockInfoFromNode(node, posBeforeNode); - if (node.childCount < 2) { - return blockGroupType.contentMatch.matchType(nodeType) - ? { pos: blockGroupPos, wrapIn: blockGroupType } - : null; + if (info.children) { + // The descent helpers report sealed boundaries but this caller ignores + // them: an explicit `insertBlocks` placement is an intentional crossing. + const pos = descend(info.children); + + return pos === null ? null : { pos }; } - const pos = descend(node.lastChild!, blockGroupPos); + // No children holder implies a `blockContainer` with no children yet + // (containers always have one): its `blockGroup` is lazy (`blockContent + // blockGroup?`), so the position after the content node only becomes valid + // once the nodes are wrapped in a new group. + const blockGroupType = nodeType.schema.nodes["blockGroup"]; - return pos === null ? null : { pos }; + return info.hasContent && blockGroupType?.contentMatch.matchType(nodeType) + ? { pos: info.content.afterPos, wrapIn: blockGroupType } + : null; } export function insertBlocks< diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts index ebe8ae9eff..b339f66ec1 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "vite-plus/test"; import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js"; import { setupTestEnv } from "../../setupTestEnv.js"; -import { getParentBlockInfo, mergeBlocksCommand } from "./mergeBlocks.js"; +import { getParentBlockInfo } from "../../../getBlockInfoFromPos.js"; +import { mergeBlocksCommand } from "./mergeBlocks.js"; const getEditor = setupTestEnv(); @@ -14,7 +15,7 @@ function mergeBlocks(posBetweenBlocks: number) { function getPosBeforeSelectedBlock() { return getEditor().transact( - (tr) => getBlockInfoFromSelection(tr).bnBlock.beforePos, + (tr) => getBlockInfoFromSelection(tr).block.beforePos, ); } diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index 5d1f0e3b51..0be289e479 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,128 +1,19 @@ -import { Node } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; -import { isSealed } from "../../../../schema/blocks/children.js"; import { BlockInfo, - getBlockInfoFromResolvedPos, + getBlockInfoAt, + getLastDescendantBlockInfo, + getPrevBlockInfo, } from "../../../getBlockInfoFromPos.js"; -/** - * Returns the block info from the parent block - * or undefined if we're at the root - */ -export const getParentBlockInfo = ( - doc: Node, - beforePos: number, -): BlockInfo | undefined => { - const $pos = doc.resolve(beforePos); - const depth = $pos.depth - 1; - - if (depth < 1) { - return undefined; - } - - const parentBeforePos = $pos.before(depth); - const parentNode = doc.resolve(parentBeforePos).nodeAfter; - - if (!parentNode) { - return undefined; - } - - if (!parentNode.type.spec.group?.includes("bnBlock")) { - return getParentBlockInfo(doc, parentBeforePos); - } - - const parentBlockInfo = getBlockInfoFromResolvedPos( - doc.resolve(parentBeforePos), - ); - - return parentBlockInfo; -}; - -/** - * Returns the block info from the sibling block before (above) the given block, - * or undefined if the given block is the first sibling. - */ -export const getPrevBlockInfo = (doc: Node, beforePos: number) => { - const $pos = doc.resolve(beforePos); - - const indexInParent = $pos.index(); - - if (indexInParent === 0) { - return undefined; - } - - const prevBlockBeforePos = $pos.posAtIndex(indexInParent - 1); - - const prevBlockInfo = getBlockInfoFromResolvedPos( - doc.resolve(prevBlockBeforePos), - ); - return prevBlockInfo; -}; - -/** - * Returns the block info from the sibling block after (below) the given block, - * or undefined if the given block is the last sibling. - */ -export const getNextBlockInfo = (doc: Node, beforePos: number) => { - const $pos = doc.resolve(beforePos); - - const indexInParent = $pos.index(); - - if (indexInParent === $pos.node().childCount - 1) { - return undefined; - } - - const nextBlockBeforePos = $pos.posAtIndex(indexInParent + 1); - - const nextBlockInfo = getBlockInfoFromResolvedPos( - doc.resolve(nextBlockBeforePos), - ); - return nextBlockInfo; -}; - -/** - * If a block has children like this: - * A - * - B - * - C - * -- D - * - * Then the bottom nested block returned is D. - */ -export const getBottomNestedBlockInfo = ( - doc: Node, - blockInfo: BlockInfo, - // Callers that move content stop the descent at a sealed container, getting - // the container itself rather than a block inside it. Caret-only callers - // descend through. Sealed boundaries govern content, not navigation. - opts?: { stopAtSealed?: boolean }, -) => { - // A container that allows zero children can have an empty child container, - // in which case the block itself is the bottom one. - while (blockInfo.childContainer && blockInfo.childContainer.node.childCount) { - if (opts?.stopAtSealed && isSealed(blockInfo.childContainer.node)) { - break; - } - const group = blockInfo.childContainer.node; - - const newPos = doc - .resolve(blockInfo.childContainer.beforePos + 1) - .posAtIndex(group.childCount - 1); - blockInfo = getBlockInfoFromResolvedPos(doc.resolve(newPos)); - } - - return blockInfo; -}; - const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => { return ( - prevBlockInfo.isWrappedBlock && - prevBlockInfo.blockContent.node.type.spec.content === "inline*" && - prevBlockInfo.blockContent.node.childCount > 0 && - nextBlockInfo.isWrappedBlock && - nextBlockInfo.blockContent.node.type.spec.content === "inline*" + prevBlockInfo.hasContent && + prevBlockInfo.contentKind === "inline" && + !prevBlockInfo.isContentEmpty && + nextBlockInfo.hasContent && + nextBlockInfo.contentKind === "inline" ); }; @@ -133,25 +24,25 @@ const mergeBlocks = ( nextBlockInfo: BlockInfo, ) => { // Un-nests all children of the next block. - if (!nextBlockInfo.isWrappedBlock) { + if (!nextBlockInfo.hasContent) { throw new Error( - `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but next block is not a block container`, + `Attempted to merge block at position ${nextBlockInfo.block.beforePos} into previous block at position ${prevBlockInfo.block.beforePos}, but next block is not a block container`, ); } // Removes a level of nesting all children of the next block by 1 level, if it contains both content and block // group nodes. - if (nextBlockInfo.childContainer) { + if (nextBlockInfo.children) { const childBlocksStart = state.doc.resolve( - nextBlockInfo.childContainer.beforePos + 1, + nextBlockInfo.children.childrenStart, ); const childBlocksEnd = state.doc.resolve( - nextBlockInfo.childContainer.afterPos - 1, + nextBlockInfo.children.childrenEnd, ); const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd); if (dispatch) { - const pos = state.doc.resolve(nextBlockInfo.bnBlock.beforePos); + const pos = state.doc.resolve(nextBlockInfo.block.beforePos); state.tr.lift(childBlocksRange!, pos.depth); } } @@ -160,9 +51,9 @@ const mergeBlocks = ( // removing the closing tags of the first block and the opening tags of the // second one to stitch them together. if (dispatch) { - if (!prevBlockInfo.isWrappedBlock) { + if (!prevBlockInfo.hasContent) { throw new Error( - `Attempted to merge block at position ${nextBlockInfo.bnBlock.beforePos} into previous block at position ${prevBlockInfo.bnBlock.beforePos}, but previous block is not a block container`, + `Attempted to merge block at position ${nextBlockInfo.block.beforePos} into previous block at position ${prevBlockInfo.block.beforePos}, but previous block is not a block container`, ); } @@ -172,10 +63,7 @@ const mergeBlocks = ( // `KeyboardShortcutsExtension` handle those cases by moving blocks // across the boundary instead of merging their content. dispatch( - state.tr.delete( - prevBlockInfo.blockContent.afterPos - 1, - nextBlockInfo.blockContent.beforePos + 1, - ), + state.tr.delete(prevBlockInfo.contentEnd, nextBlockInfo.contentStart), ); } @@ -191,19 +79,18 @@ export const mergeBlocksCommand = state: EditorState; dispatch: ((args?: any) => any) | undefined; }) => { - const $pos = state.doc.resolve(posBetweenBlocks); - const nextBlockInfo = getBlockInfoFromResolvedPos($pos); + const nextBlockInfo = getBlockInfoAt(state.doc, posBetweenBlocks); const prevBlockInfo = getPrevBlockInfo( state.doc, - nextBlockInfo.bnBlock.beforePos, + nextBlockInfo.block.beforePos, ); if (!prevBlockInfo) { return false; } - const bottomNestedBlockInfo = getBottomNestedBlockInfo( + const bottomNestedBlockInfo = getLastDescendantBlockInfo( state.doc, prevBlockInfo, ); diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts index f034506f44..f9bba17c3f 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts @@ -3,7 +3,7 @@ import { CellSelection } from "prosemirror-tables"; import { describe, expect, it } from "vite-plus/test"; import { - getBlockInfoAtNearest, + getBlockInfoNearPos, getBlockInfoFromSelection, getNodeId, } from "../../../getBlockInfoFromPos.js"; @@ -18,12 +18,12 @@ const getEditor = setupTestEnv(); function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { const blockInfo = getEditor().transact((tr) => getBlockInfoFromSelection(tr)); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { throw new Error( `Selection points to a ${blockInfo.blockNoteType} node, not a blockContainer node`, ); } - const { blockContent } = blockInfo; + const { content } = blockInfo; const editor = getEditor(); if (selectionType === "cell") { @@ -31,22 +31,22 @@ function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { tr.setSelection( CellSelection.create( tr.doc, - tr.doc.resolve(blockContent.beforePos + 3).before(), - tr.doc.resolve(blockContent.afterPos - 3).before(), + tr.doc.resolve(content.beforePos + 3).before(), + tr.doc.resolve(content.afterPos - 3).before(), ), ), ); } else if (selectionType === "node") { editor.transact((tr) => - tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos)), + tr.setSelection(NodeSelection.create(tr.doc, content.beforePos)), ); } else { editor.transact((tr) => tr.setSelection( TextSelection.create( tr.doc, - blockContent.beforePos + 1, - blockContent.afterPos - 1, + content.beforePos + 1, + content.afterPos - 1, ), ), ); @@ -223,11 +223,11 @@ describe("Test moveBlocksUp", () => { const { anchorBlockId, headBlockId } = getEditor().transact((tr) => ({ anchorBlockId: getNodeId( - getBlockInfoAtNearest(tr, tr.selection.anchor).bnBlock.node, + getBlockInfoNearPos(tr, tr.selection.anchor).block.node, tr.doc, ), headBlockId: getNodeId( - getBlockInfoAtNearest(tr, tr.selection.head).bnBlock.node, + getBlockInfoNearPos(tr, tr.selection.head).block.node, tr.doc, ), })); @@ -347,11 +347,11 @@ describe("Test moveBlocksDown", () => { const { anchorBlockId, headBlockId } = getEditor().transact((tr) => ({ anchorBlockId: getNodeId( - getBlockInfoAtNearest(tr, tr.selection.anchor).bnBlock.node, + getBlockInfoNearPos(tr, tr.selection.anchor).block.node, tr.doc, ), headBlockId: getNodeId( - getBlockInfoAtNearest(tr, tr.selection.head).bnBlock.node, + getBlockInfoNearPos(tr, tr.selection.head).block.node, tr.doc, ), })); diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index ea97ae8869..b8fcf61f23 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -11,7 +11,7 @@ import { Block } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; import { BlockIdentifier } from "../../../../schema/index.js"; import { - getBlockInfoAtNearest, + getBlockInfoNearPos, getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; @@ -51,18 +51,18 @@ function getBlockSelectionData( editor: BlockNoteEditor, ): BlockSelectionData { return editor.transact((tr) => { - const anchorBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.anchor); + const anchorBlockPosInfo = getBlockInfoNearPos(tr, tr.selection.anchor); - const anchorBlockId = getNodeId(anchorBlockPosInfo.bnBlock.node, tr.doc); + const anchorBlockId = getNodeId(anchorBlockPosInfo.block.node, tr.doc); if (tr.selection instanceof CellSelection) { return { type: "cell" as const, anchorBlockId, anchorCellOffset: - tr.selection.$anchorCell.pos - anchorBlockPosInfo.bnBlock.beforePos, + tr.selection.$anchorCell.pos - anchorBlockPosInfo.block.beforePos, headCellOffset: - tr.selection.$headCell.pos - anchorBlockPosInfo.bnBlock.beforePos, + tr.selection.$headCell.pos - anchorBlockPosInfo.block.beforePos, }; } else if (tr.selection instanceof NodeSelection) { return { @@ -70,15 +70,14 @@ function getBlockSelectionData( anchorBlockId, }; } else { - const headBlockPosInfo = getBlockInfoAtNearest(tr, tr.selection.head); + const headBlockPosInfo = getBlockInfoNearPos(tr, tr.selection.head); return { type: "text" as const, anchorBlockId, - headBlockId: getNodeId(headBlockPosInfo.bnBlock.node, tr.doc), - anchorOffset: - tr.selection.anchor - anchorBlockPosInfo.bnBlock.beforePos, - headOffset: tr.selection.head - headBlockPosInfo.bnBlock.beforePos, + headBlockId: getNodeId(headBlockPosInfo.block.node, tr.doc), + anchorOffset: tr.selection.anchor - anchorBlockPosInfo.block.beforePos, + headOffset: tr.selection.head - headBlockPosInfo.block.beforePos, }; } }); diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index 243e4532dd..371048741b 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -189,7 +189,7 @@ export function unnestBlock(editor: BlockNoteEditor) { export function canNestBlock(editor: BlockNoteEditor) { return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); + const { block: blockContainer } = getBlockInfoFromSelection(tr); // Mirrors `sinkItem`'s precondition: nesting is only possible under a // previous sibling that is itself a `blockContainer`. (A previous sibling diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index 75305b501d..a8582f5068 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -94,17 +94,22 @@ export function removeAndInsertBlocks< } } + // When the block is the only child of a nested `blockGroup`, delete the + // group with it (`blockGroup` acting as a `min: 1, whenEmptied: "unwrap"` + // container). This can't route through `fixContainer`: repair runs after + // the delete, and by then ProseMirror's replace-fitting has padded the + // `blockGroupChild+` group with a fresh empty `blockContainer` + // indistinguishable from an intentional one. Only here, before the + // delete, is "this was the group's last child" still knowable. + const parent = $pos.node(); if ( - $pos.node().type.name === "blockGroup" && + parent.type.name === "blockGroup" && $pos.node($pos.depth - 1).type.name !== "doc" && - $pos.node().childCount === 1 + parent.childCount === 1 ) { - // Checks if the block is the only child of a parent `blockGroup` node. - // In this case, we need to delete the parent `blockGroup` node instead - // of just the `blockContainer`. tr.delete($pos.before(), $pos.after()); } else { - tr.delete(pos - removedSize, pos - removedSize + node.nodeSize); + tr.delete($pos.pos, $pos.pos + node.nodeSize); } const newDocSize = tr.doc.nodeSize; diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts index 9a83857cd1..814f505a41 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -3,7 +3,7 @@ import { TextSelection } from "prosemirror-state"; import { describe, expect, it } from "vite-plus/test"; import { - getBlockInfo, + getBlockInfoFromNode, getBlockInfoFromSelection, getNodeId, } from "../../../getBlockInfoFromPos.js"; @@ -33,15 +33,15 @@ function setSelectionWithOffset( throw new Error(`Block with ID ${targetBlockId} not found`); } - const info = getBlockInfo(posInfo); + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); - if (!info.isWrappedBlock) { + if (!info.hasContent) { throw new Error("Target block is not a block container"); } getEditor().transact((tr) => tr.setSelection( - TextSelection.create(doc, info.blockContent.beforePos + offset + 1), + TextSelection.create(doc, info.content.beforePos + offset + 1), ), ); } @@ -139,7 +139,7 @@ describe("Test splitBlocks", () => { splitBlock(getEditor().transact((tr) => tr.selection.anchor)); const blockId = getEditor().transact((tr) => - getNodeId(getBlockInfoFromSelection(tr).bnBlock.node, tr.doc), + getNodeId(getBlockInfoFromSelection(tr).block.node, tr.doc), ); const anchorIsAtStartOfNewBlock = diff --git a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts index ef74f8e898..d5229da6bf 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -1,7 +1,7 @@ import { EditorState, Transaction } from "prosemirror-state"; import { - getBlockInfo, + getBlockInfoFromNode, getNearestBlockPos, } from "../../../getBlockInfoFromPos.js"; import { getPmSchema } from "../../../pmUtil.js"; @@ -34,21 +34,24 @@ export const splitBlockTr = ( ): boolean => { const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock); - const info = getBlockInfo(nearestBlockContainerPos); + const info = getBlockInfoFromNode( + nearestBlockContainerPos.node, + nearestBlockContainerPos.posBeforeNode, + ); - if (!info.isWrappedBlock) { + if (!info.hasContent) { return false; } const schema = getPmSchema(tr); const types = [ { - type: info.bnBlock.node.type, // always keep blockcontainer type - attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {}, + type: info.block.node.type, // always keep blockcontainer type + attrs: keepProps ? { ...info.block.node.attrs, id: undefined } : {}, }, { - type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"], - attrs: keepProps ? { ...info.blockContent.node.attrs } : {}, + type: keepType ? info.content.node.type : schema.nodes["paragraph"], + attrs: keepProps ? { ...info.content.node.attrs } : {}, }, ]; diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts index c695de98ae..d994791e80 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { PartialBlock } from "../../../../blocks/defaultBlocks.js"; -import { getBlockInfo } from "../../../getBlockInfoFromPos.js"; +import { getBlockInfoFromNode } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { updateBlock } from "./updateBlock.js"; @@ -177,11 +177,13 @@ describe("Test updateBlock", () => { }); it("Update partial (offset start)", () => { - const info = getBlockInfo( - getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById( + "heading-with-everything", + getEditor().prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); - if (!info.isWrappedBlock) { + if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); } @@ -198,7 +200,7 @@ describe("Test updateBlock", () => { }, ], }, - info.blockContent.beforePos + 9, + info.content.beforePos + 9, ), ); @@ -206,11 +208,13 @@ describe("Test updateBlock", () => { }); it("Update partial (offset start + end)", () => { - const info = getBlockInfo( - getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById( + "heading-with-everything", + getEditor().prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); - if (!info.isWrappedBlock) { + if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); } @@ -227,8 +231,8 @@ describe("Test updateBlock", () => { }, ], }, - info.blockContent.beforePos + 9, - info.blockContent.beforePos + 9, + info.content.beforePos + 9, + info.content.beforePos + 9, ), ); @@ -236,11 +240,13 @@ describe("Test updateBlock", () => { }); it("Update partial (props + offset end)", () => { - const info = getBlockInfo( - getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById( + "heading-with-everything", + getEditor().prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); - if (!info.isWrappedBlock) { + if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); } @@ -261,7 +267,7 @@ describe("Test updateBlock", () => { ], }, undefined, - info.blockContent.beforePos + 8, + info.content.beforePos + 8, ); }); @@ -269,15 +275,14 @@ describe("Test updateBlock", () => { }); it("Update partial (table cell)", () => { - const info = getBlockInfo( - getNodeById("table-0", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById("table-0", getEditor().prosemirrorState.doc)!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); - if (!info.isWrappedBlock) { + if (!info.hasContent) { throw new Error("table-0 is not a block container"); } - const cell = info.blockContent.node.resolve(2); + const cell = info.content.node.resolve(2); getEditor().transact((tr) => updateBlock( @@ -290,8 +295,8 @@ describe("Test updateBlock", () => { rows: [{ cells: ["updated cell 1"] }], }, }, - info.blockContent.beforePos + 2, - info.blockContent.beforePos + 2 + cell.node().nodeSize, + info.content.beforePos + 2, + info.content.beforePos + 2 + cell.node().nodeSize, ), ); @@ -299,15 +304,14 @@ describe("Test updateBlock", () => { }); it("Update partial (table row)", () => { - const info = getBlockInfo( - getNodeById("table-0", getEditor().prosemirrorState.doc)!, - ); + const posInfo = getNodeById("table-0", getEditor().prosemirrorState.doc)!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); - if (!info.isWrappedBlock) { + if (!info.hasContent) { throw new Error("table-0 is not a block container"); } - const cell = info.blockContent.node.resolve(1); + const cell = info.content.node.resolve(1); getEditor().transact((tr) => updateBlock( @@ -324,8 +328,8 @@ describe("Test updateBlock", () => { ], }, }, - info.blockContent.beforePos + 1, - info.blockContent.beforePos + 1 + cell.node().nodeSize, + info.content.beforePos + 1, + info.content.beforePos + 1 + cell.node().nodeSize, ), ); @@ -934,13 +938,12 @@ describe("Test updateBlock minimal steps", () => { it("Type change with offset content replace stays minimal and valid", () => { const editor = getEditor(); - const info = getBlockInfo( - getNodeById( - "paragraph-with-styled-content", - editor.prosemirrorState.doc, - )!, - ); - if (!info.isWrappedBlock) { + const posInfo = getNodeById( + "paragraph-with-styled-content", + editor.prosemirrorState.doc, + )!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + if (!info.hasContent) { throw new Error("paragraph-with-styled-content is not a block container"); } @@ -959,8 +962,8 @@ describe("Test updateBlock minimal steps", () => { props: { level: 3 }, content: [{ type: "text", text: " with NEW ", styles: {} }], }, - info.blockContent.beforePos + 1 + "Paragraph".length, - info.blockContent.beforePos + 1 + "Paragraph with styled ".length, + info.content.beforePos + 1 + "Paragraph".length, + info.content.beforePos + 1 + "Paragraph with styled ".length, ); steps = tr.steps.map((s) => s.toJSON()); }); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index ad2cc151f3..cf1dc003b1 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -19,7 +19,7 @@ import type { StyleSchema } from "../../../../schema/styles/types.js"; import { UnreachableCaseError } from "../../../../util/typescript.js"; import { type BlockInfo, - getBlockInfoFromResolvedPos, + getBlockInfoAt, } from "../../../getBlockInfoFromPos.js"; import { blockToNode, @@ -29,7 +29,7 @@ import { import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; import { getBlockSchema, getPmSchema } from "../../../pmUtil.js"; -import { isContainerType } from "../../../../schema/blocks/children.js"; +import { createBlockGroup } from "../../../../schema/blocks/children.js"; // for compatibility with tiptap. TODO: remove as we want to remove dependency on tiptap command interface export const updateBlockCommand = < @@ -65,7 +65,7 @@ export function updateBlockTr< replaceFromPos?: number, replaceToPos?: number, ) { - const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock)); + const blockInfo = getBlockInfoAt(tr.doc, posBeforeBlock); let cellAnchor: CellAnchor | null = null; if (blockInfo.blockNoteType === "table") { @@ -91,24 +91,24 @@ export function updateBlockTr< : pmSchema.nodes["blockContainer"]; const replaceFromOffset = - blockInfo.blockContent && + blockInfo.hasContent && replaceFromPos !== undefined && - replaceFromPos > blockInfo.blockContent.beforePos && - replaceFromPos < blockInfo.blockContent.afterPos - ? replaceFromPos - blockInfo.blockContent.beforePos - 1 + replaceFromPos >= blockInfo.contentStart && + replaceFromPos <= blockInfo.contentEnd + ? replaceFromPos - blockInfo.contentStart : undefined; const replaceToOffset = - blockInfo.blockContent && + blockInfo.hasContent && replaceToPos !== undefined && - replaceToPos > blockInfo.blockContent.beforePos && - replaceToPos < blockInfo.blockContent.afterPos - ? replaceToPos - blockInfo.blockContent.beforePos - 1 + replaceToPos >= blockInfo.contentStart && + replaceToPos <= blockInfo.contentEnd + ? replaceToPos - blockInfo.contentStart : undefined; if ( - blockInfo.isWrappedBlock && - blockInfo.bnBlock.node.type.name === "blockContainer" && + blockInfo.hasContent && + blockInfo.block.node.type.name === "blockContainer" && newNodeType.isInGroup("blockContent") ) { updateChildren(block, tr, blockInfo); @@ -123,13 +123,10 @@ export function updateBlockTr< replaceFromOffset, replaceToOffset, ); - } else if ( - !blockInfo.isWrappedBlock && - newNodeType.isInGroup("bnBlock") - ) { + } else if (!blockInfo.hasContent && newNodeType.isInGroup("bnBlock")) { updateChildren(block, tr, blockInfo); - // old node was a bnBlock type (like column or columnList) and new block as well - // No op, we just update the bnBlock below (at end of function) and have already updated the children + // old node was a block type (like column or columnList) and new block as well + // No op, we just update the block below (at end of function) and have already updated the children } else { // switching from blockContainer to non-blockContainer or v.v. // currently breaking for column slash menu items converting empty block @@ -138,7 +135,7 @@ export function updateBlockTr< // currently, we calculate the new node and replace the entire node with the desired new node. // for this, we do a nodeToBlock on the existing block to get the children. // it would be cleaner to use a ReplaceAroundStep, but this is a bit simpler and it's quite an edge case - const existingBlock = nodeToBlock(blockInfo.bnBlock.node, tr.doc); + const existingBlock = nodeToBlock(blockInfo.block.node, tr.doc); const carried = carryOverContent( existingBlock.content, newBlockType, @@ -160,8 +157,8 @@ export function updateBlockTr< ); replacementNode.check(); // `blockToNode` is lenient; validate before mutating the doc tr.replaceWith( - blockInfo.bnBlock.beforePos, - blockInfo.bnBlock.afterPos, + blockInfo.block.beforePos, + blockInfo.block.afterPos, replacementNode, ); @@ -172,7 +169,7 @@ export function updateBlockTr< // attributes. Uses minimal steps so that an unchanged container (e.g. when // only children or content changed) doesn't emit a step at all. - setNodeMarkupMinimal(tr, blockInfo.bnBlock.beforePos, newBnBlockNodeType, { + setNodeMarkupMinimal(tr, blockInfo.block.beforePos, newBnBlockNodeType, { ...block.props, }); @@ -207,7 +204,7 @@ function carryOverContent( return { content: existingContent, children: [] }; } - if (isContainerType(targetConfig)) { + if (targetConfig.children !== undefined) { return { children: [{ type: "paragraph", content: existingContent } as any], }; @@ -226,10 +223,10 @@ function updateBlockContentNode< oldNodeType: NodeType, newNodeType: NodeType, blockInfo: { - childContainer?: + children?: | { node: PMNode; beforePos: number; afterPos: number } | undefined; - blockContent: { node: PMNode; beforePos: number; afterPos: number }; + content: { node: PMNode; beforePos: number; afterPos: number }; }, replaceFromOffset?: number, replaceToOffset?: number, @@ -259,8 +256,8 @@ function updateBlockContentNode< // no custom content has been provided, use existing content IF possible // Since some block types contain inline content and others don't, // we either need to call setNodeMarkup to just update type & - // attributes, or replaceWith to replace the whole blockContent. - const oldContent = blockInfo.blockContent.node.content; + // attributes, or replaceWith to replace the whole content. + const oldContent = blockInfo.content.node.content; if (oldNodeType.spec.content === "") { // keep old content, because it's empty anyway and should be compatible with // any newContentType @@ -275,7 +272,7 @@ function updateBlockContentNode< // for the new type (e.g. converting styled/complex inline content into a // plain block that disallows formatting marks and inline nodes). Preserve // the text, dropping the styling the new type can't represent. - const text = blockInfo.blockContent.node.textContent; + const text = blockInfo.content.node.textContent; content = text.length > 0 ? [pmSchema.text(text)] : []; } else { // the content type changed and is incompatible, replace the previous content @@ -283,7 +280,7 @@ function updateBlockContentNode< } } - // Now, changes the blockContent node type and adds the provided props + // Now, changes the content node type and adds the provided props // as attributes. Also preserves all existing attributes that are // compatible with the new type. // @@ -291,7 +288,7 @@ function updateBlockContentNode< // content is being replaced or not. if (content === "keep") { // only update the type and attributes, keeping the content as-is - setNodeMarkupMinimal(tr, blockInfo.blockContent.beforePos, newNodeType, { + setNodeMarkupMinimal(tr, blockInfo.content.beforePos, newNodeType, { ...block.props, }); } else if (replaceFromOffset !== undefined || replaceToOffset !== undefined) { @@ -299,7 +296,7 @@ function updateBlockContentNode< // position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, - blockInfo.blockContent.beforePos, + blockInfo.content.beforePos, newNodeType, { ...block.props }, ); @@ -308,7 +305,7 @@ function updateBlockContentNode< const end = contentBeforePos + 1 + - (replaceToOffset ?? blockInfo.blockContent.node.content.size); + (replaceToOffset ?? blockInfo.content.node.content.size); // for content like table cells (where the blockcontent has nested PM nodes), // we need to figure out the correct openStart and openEnd for the slice when replacing @@ -328,7 +325,7 @@ function updateBlockContentNode< ); } else if ( newNodeType === oldNodeType || - newNodeType.validContent(blockInfo.blockContent.node.content) + newNodeType.validContent(blockInfo.content.node.content) ) { // The new type can hold the existing content, so we can update the markup // first and then diff the content. This keeps both steps minimal. @@ -338,7 +335,7 @@ function updateBlockContentNode< // get its (possibly shifted) position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, - blockInfo.blockContent.beforePos, + blockInfo.content.beforePos, newNodeType, { ...block.props }, ); @@ -351,11 +348,11 @@ function updateBlockContentNode< // between inline content, table content, and no content). We can't update // the markup in-place, so replace the whole content node atomically. tr.replaceWith( - blockInfo.blockContent.beforePos, - blockInfo.blockContent.afterPos, + blockInfo.content.beforePos, + blockInfo.content.afterPos, newNodeType.createChecked( { - ...blockInfo.blockContent.node.attrs, + ...blockInfo.content.node.attrs, ...block.props, }, content, @@ -568,24 +565,23 @@ function updateChildren< return node; }); - // Checks if a blockGroup node already exists. - if (blockInfo.childContainer) { - // Replaces the child nodes in the existing blockGroup, only touching the - // range that actually changed (keeping unchanged leading/trailing - // children untouched). + if (blockInfo.children) { + // Replaces the child nodes in the existing children holder, only + // touching the range that actually changed (keeping unchanged + // leading/trailing children untouched). replaceContentMinimal( tr, - blockInfo.childContainer.beforePos, + blockInfo.children.beforePos, Fragment.from(childNodes), ); - } else { - if (!blockInfo.isWrappedBlock) { - throw new Error("impossible"); - } - // Inserts a new blockGroup containing the child nodes created earlier. + } else if (blockInfo.hasContent) { + // A `blockContainer` with no children yet: its `blockGroup` is lazy + // (`blockContent blockGroup?`), so create it around the child nodes and + // insert it after the content node. (Containers always have a children + // holder, so no holder implies a `blockContainer`.) tr.insert( - blockInfo.blockContent.afterPos, - pmSchema.nodes["blockGroup"].createChecked({}, childNodes), + blockInfo.content.afterPos, + createBlockGroup(pmSchema, childNodes), ); } } @@ -617,11 +613,14 @@ export function updateBlock< replaceToPos, ); - const blockContainerNode = tr.doc - .resolve(posInfo.posBeforeNode + 1) // TODO: clean? - .node(); + // `updateBlockTr` may have replaced the node, so re-resolve it at the same + // position (an update never moves the block). + const updatedNode = tr.doc.resolve(posInfo.posBeforeNode).nodeAfter; + if (!updatedNode) { + throw new Error(`Block with ID ${id} not found after update`); + } - return nodeToBlock(blockContainerNode, tr.doc); + return nodeToBlock(updatedNode, tr.doc); } type CellAnchor = { row: number; col: number; offset: number }; @@ -695,12 +694,12 @@ function restoreCellAnchor( // 1) Resolve the table node in the current document let tablePos = -1; - if (blockInfo.isWrappedBlock) { - // Prefer the blockContent position when available (points directly at the PM table node) - tablePos = tr.mapping.map(blockInfo.blockContent.beforePos); + if (blockInfo.hasContent) { + // Prefer the content position when available (points directly at the PM table node) + tablePos = tr.mapping.map(blockInfo.content.beforePos); } else { - // Fallback: scan within the mapped bnBlock range to find the inner table node - const start = tr.mapping.map(blockInfo.bnBlock.beforePos); + // Fallback: scan within the mapped block range to find the inner table node + const start = tr.mapping.map(blockInfo.block.beforePos); const end = start + (tr.doc.nodeAt(start)?.nodeSize || 0); tr.doc.nodesBetween(start, end, (node, pos) => { if (node.type.name === "table") { diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts index 211fee190c..da18e99edc 100644 --- a/packages/core/src/api/blockManipulation/containers/containerNav.ts +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -11,45 +11,60 @@ import { isContainerNode, isSealed } from "../../../schema/blocks/children.js"; */ type SealOpts = { respectSealed?: boolean }; +/** + * Walks the trailing edge of `holder` (a children holder: `BlockInfo`'s + * `children`, or a container's own `block` entry — anything with a node and + * the position before it), descending through nested containers, to the + * deepest position where `nodeType` fits. + * + * The walk ignores seals but reports them: `crossedSeal` is true when a + * sealed container sat on the path, `holder` itself included. Callers decide + * the policy — the block manipulation API uses `pos` as-is (an explicit + * placement is an intentional crossing); gesture code treats + * `pos !== null && crossedSeal` as "blocked by a seal" (select the sealed + * container instead of entering it). One walk answers both questions because + * the descent follows a single path (each container's last child), so the + * seal-blind and seal-respecting positions are the same — the modes differ + * only in whether a seal sat on the way. + */ export function descendToLastInsertionPos( - container: Node, - containerBeforePos: number, + holder: { node: Node; beforePos: number }, nodeType: NodeType, - opts?: SealOpts, -): number | null { - if (opts?.respectSealed && isSealed(container)) { - return null; +): { pos: number | null; crossedSeal: boolean } { + const { node, beforePos } = holder; + const sealed = isSealed(node); + const endPos = beforePos + 1 + node.content.size; + if (node.contentMatchAt(node.childCount).matchType(nodeType)) { + return { pos: endPos, crossedSeal: sealed }; } - const endPos = containerBeforePos + 1 + container.content.size; - if (container.contentMatchAt(container.childCount).matchType(nodeType)) { - return endPos; - } - const lastChild = container.lastChild; + const lastChild = node.lastChild; if (lastChild && isContainerNode(lastChild.type)) { - return descendToLastInsertionPos( - lastChild, - endPos - lastChild.nodeSize, + const inner = descendToLastInsertionPos( + { node: lastChild, beforePos: endPos - lastChild.nodeSize }, nodeType, - opts, ); + return { pos: inner.pos, crossedSeal: sealed || inner.crossedSeal }; } - return null; + return { pos: null, crossedSeal: sealed }; } -// No seal handling: its only callers are API code, which crosses seals by -// construction. +// The leading-edge counterpart. No seal reporting: its only callers are API +// code, which crosses seals by construction. export function descendToFirstInsertionPos( - container: Node, - containerBeforePos: number, + holder: { node: Node; beforePos: number }, nodeType: NodeType, ): number | null { - const startPos = containerBeforePos + 1; - if (container.contentMatchAt(0).matchType(nodeType)) { + const { node, beforePos } = holder; + const startPos = beforePos + 1; + if (node.contentMatchAt(0).matchType(nodeType)) { return startPos; } - const firstChild = container.firstChild; + const firstChild = node.firstChild; if (firstChild && isContainerNode(firstChild.type)) { - return descendToFirstInsertionPos(firstChild, startPos, nodeType); + return descendToFirstInsertionPos( + { node: firstChild, beforePos: startPos }, + nodeType, + ); } return null; } diff --git a/packages/core/src/api/blockManipulation/containers/containerUI.ts b/packages/core/src/api/blockManipulation/containers/containerUI.ts index b4633d5ead..cfcf391e7a 100644 --- a/packages/core/src/api/blockManipulation/containers/containerUI.ts +++ b/packages/core/src/api/blockManipulation/containers/containerUI.ts @@ -1,5 +1,4 @@ import type { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; -import { isContainerType } from "../../../schema/blocks/children.js"; export type ContainerUIInfo = { containerTypes: ReadonlySet; @@ -40,7 +39,7 @@ export function getContainerUIInfo( )) { const draggable = spec.implementation?.meta?.draggable !== false; - if (!isContainerType(spec.config)) { + if (spec.config.children === undefined) { if (!draggable) { nonDraggableBlockTypes.add(type); } diff --git a/packages/core/src/api/blockManipulation/containers/containers.test.ts b/packages/core/src/api/blockManipulation/containers/containers.test.ts index 405c22f8a5..add97f66b3 100644 --- a/packages/core/src/api/blockManipulation/containers/containers.test.ts +++ b/packages/core/src/api/blockManipulation/containers/containers.test.ts @@ -9,6 +9,8 @@ import { } from "vite-plus/test"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; +import { getParentBlockInfo } from "../../getBlockInfoFromPos.js"; +import { getNodeById } from "../../nodeUtil.js"; import { containerSchema } from "./containers.fixture.js"; type PartialBlock = (typeof containerSchema)["PartialBlock"]; @@ -320,6 +322,96 @@ describe("children repair", () => { "trailing", ]); }); + + // Unlike `refillContainer` (which leaves empty children alone at or above + // `min` — they may be intentional), the unwrap repair drops emptied + // children unconditionally: an emptied third column disappears rather than + // lingering, even though the list stays valid without unwrapping. The + // multicolumn e2e snapshots pin the same behavior from the keyboard side. + it("drops emptied children of an unwrap container even at or above `min`", () => { + editor.replaceBlocks(editor.document, [ + { + type: "grid", + id: "g-0", + children: [ + { + type: "gridCell", + id: "cell-a", + children: [ + { id: "cell-a-p", type: "paragraph", content: "A" }, + { id: "cell-a-extra", type: "paragraph", content: "A2" }, + ], + }, + { + type: "gridCell", + id: "cell-b", + children: [{ id: "cell-b-p", type: "paragraph", content: "B" }], + }, + { + type: "gridCell", + id: "cell-c", + children: [{ id: "cell-c-p", type: "paragraph", content: "" }], + }, + ], + }, + { id: "trailing", type: "paragraph", content: "" }, + ]); + + // Removing a block inside cell A runs repair on the grid; the emptied + // cell C is dropped, and with cells A and B still meeting `min: 2` the + // grid itself survives. + editor.removeBlocks(["cell-a-extra"]); + + const grid = editor.getBlock("g-0")!; + expect(grid.children.map((cell) => cell.id)).toEqual(["cell-a", "cell-b"]); + }); +}); + +describe("parent lookups for container children", () => { + // Regression: `getParentBlockInfo` used to skip the container level for + // container children (returning the grid for a block inside a gridCell), + // which made the Delete-at-end climb run its sealed-container check on the + // wrong node. The parent of a block is the block whose `children` contains + // it: the cell. + it("returns the container as the parent of its direct children", () => { + 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.transact((tr) => { + // The block directly containing a cell's paragraph is the cell. + const cellChild = getNodeById("cell-a-p", tr.doc)!; + expect( + getParentBlockInfo(tr.doc, cellChild.posBeforeNode)?.blockNoteType, + ).toBe("gridCell"); + + // The parent of a cell is the grid; the parent of the grid (a + // top-level block) is undefined. + const cell = getNodeById("cell-a", tr.doc)!; + expect( + getParentBlockInfo(tr.doc, cell.posBeforeNode)?.blockNoteType, + ).toBe("grid"); + + const grid = getNodeById("g-0", tr.doc)!; + expect(getParentBlockInfo(tr.doc, grid.posBeforeNode)).toBeUndefined(); + }); + }); }); describe("children selection", () => { diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts index 325e0622d2..b579f94183 100644 --- a/packages/core/src/api/blockManipulation/containers/fixContainer.ts +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -1,11 +1,15 @@ -import { Fragment, Slice, type Node } from "prosemirror-model"; +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 { - BLOCK_GROUP_CHILD_GROUP, - getChildrenConfig, + type BlockInfo, + getBlockInfoFromNode, +} from "../../getBlockInfoFromPos.js"; + +import { + isBlockGroupInsertable, isContainerNode, resolveChildren, } from "../../../schema/blocks/children.js"; @@ -59,58 +63,40 @@ export function removeEmptyChildren(tr: Transaction, containerPos: number) { } } -function isInsertableChild(node: Node): boolean { - return ( - node.type.name === "blockContainer" || - node.type.isInGroup(BLOCK_GROUP_CHILD_GROUP) - ); -} - -type ContainerRepairTarget = { - blockPos: number; - blockNode: Node; -}; - -function getContainerRepairTarget( - doc: Node, - containerPos: number, -): ContainerRepairTarget | undefined { - const node = doc.resolve(containerPos).nodeAfter; - if (!node || !isContainerNode(node.type)) { - return undefined; - } - - return { blockPos: containerPos, blockNode: node }; -} - /** - * The (possibly rebuilt) block at the repair target, with where its children - * now start. Recomputed after each mutation of `tr`. + * The container's BlockInfo at `containerPos` in `tr`'s current doc, or + * `undefined` when the node there is gone or no longer of `type`. */ -function refreshRepairTarget( +function getContainerInfo( tr: Transaction, - target: ContainerRepairTarget, -): { children: Node; childrenStart: number } | undefined { - const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter; - if (!refreshedBlock || refreshedBlock.type !== target.blockNode.type) { + containerPos: number, + type: NodeType, +): Extract | undefined { + const node = tr.doc.resolve(containerPos).nodeAfter; + if (!node || node.type !== type) { return undefined; } - - return { children: refreshedBlock, childrenStart: target.blockPos + 1 }; + const info = getBlockInfoFromNode(node, containerPos); + if (info.hasContent) { + // `type` is a container node type, so its BlockInfo always takes the + // no-content arm. + throw new Error( + `Container node "${type.name}" unexpectedly resolved with a content node.`, + ); + } + return info; } export function fixContainer(tr: Transaction, containerPos: number) { - const target = getContainerRepairTarget(tr.doc, containerPos); - if (!target) { + const node = tr.doc.resolve(containerPos).nodeAfter; + if (!node || !isContainerNode(node.type)) { throw new Error( "Invalid containerPos: does not point to a container node.", ); } - const blockConfig = target.blockNode.type.spec.blockConfig; - const childrenConfig = blockConfig - ? getChildrenConfig(blockConfig) - : undefined; + const blockConfig = node.type.spec.blockConfig; + const childrenConfig = blockConfig?.children; const config = childrenConfig ? resolveChildren(childrenConfig) : undefined; if (!config) { @@ -118,28 +104,34 @@ export function fixContainer(tr: Transaction, containerPos: number) { } if (config.whenEmptied === "unwrap") { - unwrapContainer(tr, target, config); + unwrapContainer(tr, containerPos, node.type, config); } else { // `blockConfig` is set whenever `config` is. - refillContainer(tr, target, config, blockConfig!.type); + refillContainer(tr, containerPos, node.type, config, blockConfig!.type); } } function unwrapContainer( tr: Transaction, - target: ContainerRepairTarget, + containerPos: number, + type: NodeType, config: ResolvedChildren, ) { - removeEmptyChildren(tr, target.blockPos); - - const refreshed = refreshRepairTarget(tr, target); - if (!refreshed) { + // Emptied children are dropped unconditionally, even when the container + // sits at or above `min` afterwards: for an unwrap container (a + // columnList), a child the user emptied is done for — an emptied third + // column disappears rather than lingering. This deliberately differs from + // `refillContainer`, which leaves empty children alone at or above `min`. + removeEmptyChildren(tr, containerPos); + + const info = getContainerInfo(tr, containerPos, type); + if (!info) { return; } - const { children: refreshedChildren, childrenStart } = refreshed; + const { childrenStart } = info.children; const nonEmptyChildren: { child: Node; offset: number }[] = []; - refreshedChildren.forEach((child, offset) => { + info.children.node.forEach((child, offset) => { if (!isEmptyContainerChild(child)) { nonEmptyChildren.push({ child, offset }); } @@ -149,11 +141,10 @@ function unwrapContainer( return; } - const refreshedBlock = tr.doc.resolve(target.blockPos).nodeAfter!; - const blockEnd = target.blockPos + refreshedBlock.nodeSize; + const blockEnd = info.block.afterPos; if (nonEmptyChildren.length === 0) { - tr.delete(target.blockPos, blockEnd); + tr.delete(info.block.beforePos, blockEnd); return; } @@ -162,13 +153,13 @@ function unwrapContainer( const { child, offset } = nonEmptyChildren[0]; const childStart = childrenStart + offset; - const [gapFrom, gapTo] = isInsertableChild(child) + const [gapFrom, gapTo] = isBlockGroupInsertable(child.type) ? [childStart, childStart + child.nodeSize] : [childStart + 1, childStart + child.nodeSize - 1]; tr.step( new ReplaceAroundStep( - target.blockPos, + info.block.beforePos, blockEnd, gapFrom, gapTo, @@ -183,13 +174,13 @@ function unwrapContainer( // Several survivors but still below `min`: rebuild replacement content. const replacement: Node[] = []; for (const { child } of nonEmptyChildren) { - if (isInsertableChild(child)) { + if (isBlockGroupInsertable(child.type)) { replacement.push(child); } else { child.forEach((grandChild) => replacement.push(grandChild)); } } - tr.replaceWith(target.blockPos, blockEnd, Fragment.from(replacement)); + tr.replaceWith(info.block.beforePos, blockEnd, Fragment.from(replacement)); } /** @@ -205,15 +196,16 @@ function unwrapContainer( */ function refillContainer( tr: Transaction, - target: ContainerRepairTarget, + containerPos: number, + type: NodeType, config: ResolvedChildren, blockType: string, ) { - const current = refreshRepairTarget(tr, target); - if (!current) { + const info = getContainerInfo(tr, containerPos, type); + if (!info) { return; } - const { children, childrenStart } = current; + const { node: children, childrenStart, childrenEnd } = info.children; const survivors: Node[] = []; children.forEach((child) => { @@ -241,7 +233,7 @@ function refillContainer( const match = children.type.contentMatch.matchFragment(children.content); const fill = match?.fillBefore(Fragment.empty, true); if (fill && fill.size > 0) { - tr.insert(childrenStart + children.content.size, fill); + tr.insert(childrenEnd, fill); } return; } @@ -255,7 +247,7 @@ function refillContainer( content = content.append(fill); } - tr.replaceWith(childrenStart, childrenStart + children.content.size, content); + tr.replaceWith(childrenStart, childrenEnd, content); } export function fixContainersById( @@ -281,7 +273,7 @@ export function flattenNonInsertableBlocks< if ( nodeType && nodeType.isInGroup("bnBlock") && - !nodeType.isInGroup(BLOCK_GROUP_CHILD_GROUP) + !isBlockGroupInsertable(nodeType) ) { const children = flattenNonInsertableBlocks( block.children ?? [], diff --git a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts index 9982402a4e..98fc3b7eef 100644 --- a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts +++ b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts @@ -6,6 +6,7 @@ import type { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; +import { getParentBlockInfo } from "../../getBlockInfoFromPos.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../nodeUtil.js"; @@ -94,21 +95,10 @@ export function getParentBlock< return undefined; } - const $posBeforeNode = doc.resolve(posInfo.posBeforeNode); - const parentNode = $posBeforeNode.node(); - const grandparentNode = $posBeforeNode.node(-1); - // A block's children live in its parent's `blockGroup` (regular nesting), - // in which case the actual parent block is the grandparent. A container - // holds its children directly, so its own node is the parent. - const nodeToConvert = - grandparentNode.type.name !== "doc" - ? parentNode.type.name === "blockGroup" - ? grandparentNode - : parentNode - : undefined; - if (!nodeToConvert) { + const parentInfo = getParentBlockInfo(doc, posInfo.posBeforeNode); + if (!parentInfo) { return undefined; } - return nodeToBlock(nodeToConvert, doc); + return nodeToBlock(parentInfo.block.node, doc); } diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index 466845d94a..d8b15401e7 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -9,7 +9,10 @@ import { StyleSchema, } from "../../../schema/index.js"; import { expandPMRangeToWords } from "../../../util/expandToWords.js"; -import { getBlockInfo, getNearestBlockPos } from "../../getBlockInfoFromPos.js"; +import { + getBlockInfoFromNode, + getNearestBlockPos, +} from "../../getBlockInfoFromPos.js"; import { nodeToBlock, prosemirrorSliceToSlicedBlocks, @@ -157,8 +160,14 @@ export function setSelection( throw new Error(`Block with ID ${endBlockId} not found`); } - const anchorBlockInfo = getBlockInfo(anchorPosInfo); - const headBlockInfo = getBlockInfo(headPosInfo); + const anchorBlockInfo = getBlockInfoFromNode( + anchorPosInfo.node, + anchorPosInfo.posBeforeNode, + ); + const headBlockInfo = getBlockInfoFromNode( + headPosInfo.node, + headPosInfo.posBeforeNode, + ); const anchorBlockConfig = schema.blockSchema[ @@ -169,12 +178,12 @@ export function setSelection( headBlockInfo.blockNoteType as keyof typeof schema.blockSchema ]; - if (!anchorBlockInfo.isWrappedBlock || anchorBlockConfig.content === "none") { + if (!anchorBlockInfo.hasContent || anchorBlockConfig.content === "none") { throw new Error( `Attempting to set selection anchor in block without content (id ${startBlockId})`, ); } - if (!headBlockInfo.isWrappedBlock || headBlockConfig.content === "none") { + if (!headBlockInfo.hasContent || headBlockConfig.content === "none") { throw new Error( `Attempting to set selection anchor in block without content (id ${endBlockId})`, ); @@ -184,30 +193,30 @@ export function setSelection( let endPos: number; if (anchorBlockConfig.content === "table") { - const tableMap = TableMap.get(anchorBlockInfo.blockContent.node); + const tableMap = TableMap.get(anchorBlockInfo.content.node); const firstCellPos = - anchorBlockInfo.blockContent.beforePos + - tableMap.positionAt(0, 0, anchorBlockInfo.blockContent.node) + + anchorBlockInfo.content.beforePos + + tableMap.positionAt(0, 0, anchorBlockInfo.content.node) + 1; startPos = firstCellPos + 2; } else { - startPos = anchorBlockInfo.blockContent.beforePos + 1; + startPos = anchorBlockInfo.contentStart; } if (headBlockConfig.content === "table") { - const tableMap = TableMap.get(headBlockInfo.blockContent.node); + const tableMap = TableMap.get(headBlockInfo.content.node); const lastCellPos = - headBlockInfo.blockContent.beforePos + + headBlockInfo.content.beforePos + tableMap.positionAt( tableMap.height - 1, tableMap.width - 1, - headBlockInfo.blockContent.node, + headBlockInfo.content.node, ) + 1; const lastCellNodeSize = tr.doc.resolve(lastCellPos).nodeAfter!.nodeSize; endPos = lastCellPos + lastCellNodeSize - 2; } else { - endPos = headBlockInfo.blockContent.afterPos - 1; + endPos = headBlockInfo.contentEnd; } // TODO: We should polish up the `MultipleNodeSelection` and use that instead. diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts index 38ad256457..553fb91cda 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -1,4 +1,3 @@ -import type { Node } from "prosemirror-model"; import { NodeSelection, TextSelection, @@ -13,9 +12,10 @@ import type { } from "../../../schema/index.js"; import { UnreachableCaseError } from "../../../util/typescript.js"; import { - getBlockInfo, + getBlockInfoFromNode, getBlockInfoFromSelection, getNodeId, + getParentBlockInfo, } from "../../getBlockInfoFromPos.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../nodeUtil.js"; @@ -26,28 +26,20 @@ export function getTextCursorPosition< I extends InlineContentSchema, S extends StyleSchema, >(tr: Transaction): TextCursorPosition { - const { bnBlock } = getBlockInfoFromSelection(tr); + const { block } = getBlockInfoFromSelection(tr); - const resolvedPos = tr.doc.resolve(bnBlock.beforePos); + const resolvedPos = tr.doc.resolve(block.beforePos); // Gets previous blockContainer node at the same nesting level, if the current node isn't the first child. const prevNode = resolvedPos.nodeBefore; // Gets next blockContainer node at the same nesting level, if the current node isn't the last child. - const nextNode = tr.doc.resolve(bnBlock.afterPos).nodeAfter; + const nextNode = tr.doc.resolve(block.afterPos).nodeAfter; - // Gets parent blockContainer node, if the current node is nested. - let parentNode: Node | undefined = undefined; - if (resolvedPos.depth > 1) { - // for nodes nested in bnBlocks - parentNode = resolvedPos.node(); - if (!parentNode.type.isInGroup("bnBlock")) { - // for blockGroups, we need to go one level up - parentNode = resolvedPos.node(resolvedPos.depth - 1); - } - } + // Gets the parent block's node, if the current block is nested. + const parentNode = getParentBlockInfo(tr.doc, block.beforePos)?.block.node; return { - block: nodeToBlock(bnBlock.node, tr.doc), + block: nodeToBlock(block.node, tr.doc), prevBlock: prevNode === null ? undefined : nodeToBlock(prevNode, tr.doc), nextBlock: nextNode === null ? undefined : nodeToBlock(nextNode, tr.doc), parentBlock: @@ -69,54 +61,45 @@ export function setTextCursorPosition( throw new Error(`Block with ID ${id} not found`); } - const info = getBlockInfo(posInfo); + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); const contentType: "none" | "inline" | "table" | "plain" = schema.blockSchema[info.blockNoteType]!.content; - if (info.isWrappedBlock) { - const blockContent = info.blockContent; + if (info.hasContent) { + const content = info.content; if (contentType === "none") { - tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos)); + tr.setSelection(NodeSelection.create(tr.doc, content.beforePos)); return; } if (contentType === "inline" || contentType === "plain") { if (placement === "start") { - tr.setSelection( - TextSelection.create(tr.doc, blockContent.beforePos + 1), - ); + tr.setSelection(TextSelection.create(tr.doc, info.contentStart)); } else { - tr.setSelection( - TextSelection.create(tr.doc, blockContent.afterPos - 1), - ); + tr.setSelection(TextSelection.create(tr.doc, info.contentEnd)); } } else if (contentType === "table") { - if (placement === "start") { - // Need to offset the position as we have to get through the `tableRow` - // and `tableCell` nodes to get to the `tableParagraph` node we want to - // set the selection in. - tr.setSelection( - TextSelection.create(tr.doc, blockContent.beforePos + 4), - ); - } else { - tr.setSelection( - TextSelection.create(tr.doc, blockContent.afterPos - 4), - ); - } + // 4 levels in from the table's edge: table > row > cell > paragraph. + tr.setSelection( + TextSelection.create( + tr.doc, + placement === "start" ? content.beforePos + 4 : content.afterPos - 4, + ), + ); } else { throw new UnreachableCaseError(contentType); } } else { const child = placement === "start" - ? info.childContainer.node.firstChild - : info.childContainer.node.lastChild; + ? info.children.node.firstChild + : info.children.node.lastChild; if (!child) { // A container allowed to hold no children has no text to put a cursor // in, so the container itself is selected instead. - tr.setSelection(NodeSelection.create(tr.doc, info.bnBlock.beforePos)); + tr.setSelection(NodeSelection.create(tr.doc, info.block.beforePos)); return; } diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index aa422bb23a..74af5ba031 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -5,7 +5,7 @@ import { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; -import { getBlockInfoAtNearest, getNodeId } from "../../getBlockInfoFromPos.js"; +import { getBlockInfoNearPos, getNodeId } from "../../getBlockInfoFromPos.js"; import { acceptedMIMETypes } from "./acceptedMIMETypes.js"; function checkFileExtensionsMatch( @@ -159,8 +159,8 @@ export async function handleFileInsertion< } insertedBlockId = editor.transact((tr) => { - const blockInfo = getBlockInfoAtNearest(tr, pos.pos); - const id = getNodeId(blockInfo.bnBlock.node, tr.doc); + const blockInfo = getBlockInfoNearPos(tr, pos.pos); + const id = getNodeId(blockInfo.block.node, tr.doc); // TODO technically data-id will always be the non-rewritten id, so there might be multiple in the document. // getNodeId might find the wrong one (aka point to a deleted node when it should be a non-deleted on) // This is acceptable right now, given that we don't expect edits on the document content diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index e9942518b5..36acaed470 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -6,7 +6,6 @@ import { BlockImplementation, BlockSchema, InlineContentSchema, - isContainerType, StyleSchema, } from "../../../../schema/index.js"; import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; @@ -284,7 +283,7 @@ function serializeBlock< } else { // Asked of the block config rather than of its ProseMirror node. See the // same check in `serializeBlocksInternalHTML`. - if (isContainerType(editor.schema.blockSchema[block.type as any])) { + if (editor.schema.blockSchema[block.type as any].children !== undefined) { // 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) diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 9bc719c41c..319cafd3fc 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -5,7 +5,6 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { BlockSchema, InlineContentSchema, - isContainerType, StyleSchema, } from "../../../../schema/index.js"; import { fillContainerAttributes } from "../../../../schema/blocks/containerAttributes.js"; @@ -163,7 +162,7 @@ function serializeBlock< ); const blockConfig = editor.schema.blockSchema[block.type as any]; - const isContainer = isContainerType(blockConfig); + const isContainer = blockConfig.children !== undefined; if (ret.contentDOM && block.content) { const ic = serializeInlineContentInternalHTML( diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts index 6af6e7b6d8..4a52d27cbd 100644 --- a/packages/core/src/api/getBlockInfoFromPos.test.ts +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -1,9 +1,17 @@ -import { Schema } from "prosemirror-model"; +import { Node, Schema } from "prosemirror-model"; import { describe, expect, it } from "vite-plus/test"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { blockToNode } from "./nodeConversions/blockToNode.js"; import { docToBlocks } from "./nodeConversions/nodeToBlock.js"; -import { getNodeId } from "./getBlockInfoFromPos.js"; +import { + getBlockInfoFromNode, + getLastDescendantBlockInfo, + getNextBlockInfo, + getNodeId, + getParentBlockInfo, + getPrevBlockInfo, +} from "./getBlockInfoFromPos.js"; import { YAttributionMarksExtension } from "../y/extensions/YAttributionMarks.js"; /** @@ -168,6 +176,227 @@ describe("getNodeId", () => { }); }); +describe("derived position and content fields", () => { + let editor: BlockNoteEditor; + + // Only the schema is needed to construct nodes; a single non-mounted editor + // instance is enough for all cases here. + function getSchema() { + if (!editor) { + editor = BlockNoteEditor.create(); + } + return editor.pmSchema; + } + + it("precomputes content bounds for an inline-content block", () => { + const schema = getSchema(); + const node = blockToNode( + { id: "0", type: "paragraph", content: "Hello" } as any, + schema, + ); + + // A non-zero offset, so the derived positions provably include it. + const info = getBlockInfoFromNode(node, 10); + + expect(info.hasContent).toBe(true); + expect(info.contentStart).toBe(info.content!.beforePos + 1); + expect(info.contentEnd).toBe(info.content!.afterPos - 1); + expect(info.contentKind).toBe("inline"); + expect(info.isContentEmpty).toBe(false); + expect(info.children).toBeUndefined(); + }); + + it("flags an empty inline-content block", () => { + const schema = getSchema(); + const node = blockToNode( + { id: "0", type: "paragraph", content: "" } as any, + schema, + ); + + const info = getBlockInfoFromNode(node, 0); + + expect(info.contentKind).toBe("inline"); + expect(info.isContentEmpty).toBe(true); + // An empty content node still has an inside: start and end coincide. + expect(info.contentStart).toBe(info.contentEnd); + }); + + it("precomputes children bounds when a block has children", () => { + const schema = getSchema(); + const node = blockToNode( + { + id: "0", + type: "paragraph", + content: "Parent", + children: [{ id: "1", type: "paragraph", content: "Child" }], + } as any, + schema, + ); + + const info = getBlockInfoFromNode(node, 0); + + expect(info.children).toBeDefined(); + expect(info.children!.childrenStart).toBe(info.children!.beforePos + 1); + expect(info.children!.childrenEnd).toBe(info.children!.afterPos - 1); + }); + + it("classifies a table's content", () => { + const schema = getSchema(); + const node = blockToNode( + { + id: "0", + type: "table", + content: { type: "tableContent", rows: [{ cells: ["A"] }] }, + } as any, + schema, + ); + + const info = getBlockInfoFromNode(node, 0); + + expect(info.contentKind).toBe("table"); + expect(info.isContentEmpty).toBe(false); + }); + + it("classifies a content-less block", () => { + const schema = getSchema(); + const node = blockToNode({ id: "0", type: "image" } as any, schema); + + const info = getBlockInfoFromNode(node, 0); + + // The block HAS a content node; that node just accepts no content. + expect(info.hasContent).toBe(true); + expect(info.contentKind).toBe("none"); + expect(info.isContentEmpty).toBe(true); + }); + + it("classifies plain-text content as other", () => { + const schema = getSchema(); + const node = blockToNode( + { id: "0", type: "codeBlock", content: "let x;" } as any, + schema, + ); + + const info = getBlockInfoFromNode(node, 0); + + expect(info.contentKind).toBe("other"); + }); +}); + +describe("navigation helpers on plain nested blocks", () => { + let editor: BlockNoteEditor; + + function getSchema() { + if (!editor) { + editor = BlockNoteEditor.create(); + } + return editor.pmSchema; + } + + // doc + // └ blockGroup + // ├ A + // │ ├ B + // │ └ C + // │ └ D + // └ E + function buildDoc() { + const schema = getSchema(); + const nodeA = blockToNode( + { + id: "A", + type: "paragraph", + content: "A", + children: [ + { id: "B", type: "paragraph", content: "B" }, + { + id: "C", + type: "paragraph", + content: "C", + children: [{ id: "D", type: "paragraph", content: "D" }], + }, + ], + } as any, + schema, + ); + const nodeE = blockToNode( + { id: "E", type: "paragraph", content: "E" } as any, + schema, + ); + return schema.nodes["doc"].createChecked( + {}, + schema.nodes["blockGroup"].createChecked({}, [nodeA, nodeE]), + ); + } + + function posOf(doc: Node, id: string): number { + let found: number | undefined; + doc.descendants((node, pos) => { + if (node.attrs.id === id) { + found = pos; + return false; + } + return true; + }); + if (found === undefined) { + throw new Error(`Block ${id} not found`); + } + return found; + } + + it("finds the parent block, or undefined at the top level", () => { + const doc = buildDoc(); + expect(getParentBlockInfo(doc, posOf(doc, "B"))?.block.node.attrs.id).toBe( + "A", + ); + expect(getParentBlockInfo(doc, posOf(doc, "D"))?.block.node.attrs.id).toBe( + "C", + ); + expect(getParentBlockInfo(doc, posOf(doc, "A"))).toBeUndefined(); + }); + + it("finds the previous sibling, or undefined for a first child", () => { + const doc = buildDoc(); + expect(getPrevBlockInfo(doc, posOf(doc, "C"))?.block.node.attrs.id).toBe( + "B", + ); + expect(getPrevBlockInfo(doc, posOf(doc, "E"))?.block.node.attrs.id).toBe( + "A", + ); + expect(getPrevBlockInfo(doc, posOf(doc, "B"))).toBeUndefined(); + }); + + it("finds the next sibling, or undefined for a last child", () => { + const doc = buildDoc(); + expect(getNextBlockInfo(doc, posOf(doc, "B"))?.block.node.attrs.id).toBe( + "C", + ); + expect(getNextBlockInfo(doc, posOf(doc, "A"))?.block.node.attrs.id).toBe( + "E", + ); + expect(getNextBlockInfo(doc, posOf(doc, "C"))).toBeUndefined(); + }); + + it("descends to the deepest last block", () => { + const doc = buildDoc(); + const infoA = getBlockInfoFromNode( + doc.nodeAt(posOf(doc, "A"))!, + posOf(doc, "A"), + ); + expect(getLastDescendantBlockInfo(doc, infoA).block.node.attrs.id).toBe( + "D", + ); + + const infoE = getBlockInfoFromNode( + doc.nodeAt(posOf(doc, "E"))!, + posOf(doc, "E"), + ); + // No children: the block itself is the bottom one. + expect(getLastDescendantBlockInfo(doc, infoE).block.node.attrs.id).toBe( + "E", + ); + }); +}); + describe("docToBlocks round trip with suggested deletions", () => { let editor: BlockNoteEditor; diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index f09627a1d0..37754a270a 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,7 +1,24 @@ -import { Node, ResolvedPos } from "prosemirror-model"; +import { Node } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; -import { CHILD_CONTAINER_GROUP } from "../schema/blocks/children.js"; +import { + CHILD_CONTAINER_GROUP, + getBlockRegions, + isSealed, +} from "../schema/blocks/children.js"; + +/** + * Producers for {@link BlockInfo}, named by the input you already have: + * + * - `getBlockInfoFromNode(node, beforePos)` — you hold the block's ProseMirror + * node and the position just before it. + * - `getBlockInfoAt(doc, posBeforeBlock)` — you know the exact position just + * before a block node (throws if no node starts there). + * - `getBlockInfoNearPos(source, pos)` — you have an arbitrary position; walks + * up/over to the nearest block. + * - `getBlockInfoFromSelection(source)` — you want the block containing the + * current selection anchor. + */ type SingleBlockInfo = { node: Node; @@ -9,52 +26,115 @@ type SingleBlockInfo = { afterPos: number; }; +/** + * The node holding a block's children, plus the bounds of the child range. + */ +export type ChildrenInfo = SingleBlockInfo & { + /** + * `beforePos + 1`: the position of the first child; also the insertion + * position for a new first child. + */ + childrenStart: number; + /** `afterPos - 1`: the position just after the last child. */ + childrenEnd: number; +}; + +/** + * What a block's content node holds, derived from its ProseMirror content + * expression. + */ +export type BlockContentKind = "inline" | "none" | "table" | "other"; + +function getContentKind(contentNode: Node): BlockContentKind { + const content = contentNode.type.spec.content; + return content === "inline*" + ? "inline" + : content === "" + ? "none" + : content === "tableRow+" + ? "table" + : "other"; +} + +function toChildrenInfo(info: SingleBlockInfo): ChildrenInfo { + return { + ...info, + childrenStart: info.beforePos + 1, + childrenEnd: info.afterPos - 1, + }; +} + export type BlockInfo = { /** * The outer node that represents a BlockNote block. This is the node that has the ID. * Most of the time, this will be a blockContainer node, but it could also be a Column or ColumnList */ - bnBlock: SingleBlockInfo; + block: SingleBlockInfo; /** * The type of BlockNote block that this node represents. - * When dealing with a blockContainer, this is retrieved from the blockContent node, otherwise it's retrieved from the bnBlock node. + * When dealing with a blockContainer, this is retrieved from the content node, otherwise it's retrieved from the block node. */ blockNoteType: string; } & ( | { // A container block (Column, ColumnList, a custom container): its own - // node holds its children directly, and it has no `blockContent` of + // node holds its children directly, and it has no content node of // its own. /** * The Prosemirror node that holds block.children. For a container block, - * this node is the same as bnBlock. + * this node is the same as `block`. */ - childContainer: SingleBlockInfo; - blockContent?: undefined; - isWrappedBlock: false; + children: ChildrenInfo; + content?: undefined; + hasContent: false; + contentStart?: undefined; + contentEnd?: undefined; + contentKind?: undefined; + isContentEmpty?: undefined; } | { /** * The Prosemirror node that holds block.children. For blockContainers, this is the blockGroup node, if it exists. */ - childContainer?: SingleBlockInfo; + children?: ChildrenInfo; /** * The Prosemirror node that wraps block.content and has most of the props */ - blockContent: SingleBlockInfo; + content: SingleBlockInfo; + /** `content.beforePos + 1`: the first position inside the content. */ + contentStart: number; + /** `content.afterPos - 1`: the last position inside the content. */ + contentEnd: number; + /** What the content node holds, from its ProseMirror content expression. */ + contentKind: BlockContentKind; + /** `content.node.childCount === 0`. */ + isContentEmpty: boolean; /** - * Whether `bnBlock` wraps the block's content in a node of its own: a - * `blockContainer` (an ordinary block wrapped for nesting), shaped as a - * content node followed by an optional child container. + * Whether the block has a content node: a `blockContainer` (an + * ordinary block wrapped for nesting), shaped as a content node + * followed by an optional child container. * * Note this is the opposite of "is a container block": a column has - * `isWrappedBlock: false`. + * `hasContent: false`. */ - isWrappedBlock: true; + hasContent: true; } ); +/** + * The caret position at an edge of a table content region: 4 levels in + * (`table` → `tableRow` → `tableCell` → `tableParagraph`) from the region's + * boundary — the first cell's paragraph start, or the last cell's paragraph + * end. + */ +export function tableContentCaretPos( + content: { beforePos: number; afterPos: number }, + edge: "start" | "end", +): number { + return edge === "start" ? content.beforePos + 4 : content.afterPos - 4; +} + export function isSuggestedDeletionNode(node: Node): boolean { return node.marks.some((m) => ["y-attributed-delete"].includes(m.type.name)); } @@ -165,126 +245,208 @@ export function getNearestBlockPos(doc: Node, pos: number) { /** * Gets information regarding the ProseMirror nodes that make up a block in a - * BlockNote document. This includes the main `blockContainer` node, the - * `blockContent` node with the block's main body, and the optional `blockGroup` - * node which contains the block's children. As well as the nodes, also returns - * the ProseMirror positions just before & after each node. - * @param node The main `blockContainer` node that the block information should - * be retrieved from, - * @param bnBlockBeforePosOffset the position just before the - * `blockContainer` node in the document. + * BlockNote document, given the block's outer node and the position just + * before it. This includes the outer node with the block's ID, the content + * node with the block's main body, and the optional node which contains the + * block's children. As well as the nodes, also returns the ProseMirror + * positions just before & after each node. + * @param node The outer node that the block information should be retrieved + * from. + * @param beforePos The position just before the outer node in the document. */ -export function getBlockInfoWithManualOffset( - node: Node, - bnBlockBeforePosOffset: number, -): BlockInfo { +export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { if (!node.type.isInGroup("bnBlock")) { throw new Error( - `Attempted to get bnBlock node at position but found node of different type ${node.type.name}`, + `Attempted to get block node at position but found node of different type ${node.type.name}`, ); } - const bnBlockNode = node; - const bnBlockBeforePos = bnBlockBeforePosOffset; - const bnBlockAfterPos = bnBlockBeforePos + bnBlockNode.nodeSize; + // The one place block shape is resolved; everything below is position + // annotation over the regions. + const regions = getBlockRegions(node); - const bnBlock: SingleBlockInfo = { - node: bnBlockNode, - beforePos: bnBlockBeforePos, - afterPos: bnBlockAfterPos, + const block: SingleBlockInfo = { + node, + beforePos, + afterPos: beforePos + node.nodeSize, }; - if (bnBlockNode.type.name === "blockContainer") { - let blockContent: SingleBlockInfo | undefined; - let childContainer: SingleBlockInfo | undefined; - - bnBlockNode.forEach((node, offset) => { - const beforePos = bnBlockBeforePos + offset + 1; - const afterPos = beforePos + node.nodeSize; - - if (node.type.spec.group === "blockContent") { - blockContent = { node, beforePos, afterPos }; - } else if (node.type.isInGroup(CHILD_CONTAINER_GROUP)) { - childContainer = { node, beforePos, afterPos }; - } - }); - - if (!blockContent) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${bnBlockNode.type.name} node does not contain a content node in its children: ${bnBlockNode}`, - ); - } - + if (regions.content) { + const content: SingleBlockInfo = { + node: regions.content.node, + beforePos: beforePos + regions.content.offset, + afterPos: + beforePos + regions.content.offset + regions.content.node.nodeSize, + }; + const holder = regions.childrenHolder; return { - isWrappedBlock: true, - bnBlock, - blockContent, - childContainer, + hasContent: true, + block, + content, + children: holder + ? toChildrenInfo({ + node: holder.node, + beforePos: beforePos + holder.offset, + afterPos: beforePos + holder.offset + holder.node.nodeSize, + }) + : undefined, + contentStart: content.beforePos + 1, + contentEnd: content.afterPos - 1, + contentKind: getContentKind(content.node), + isContentEmpty: content.node.childCount === 0, // A `blockContainer` is a generic wrapper, so its type comes from the // content node inside it. - blockNoteType: blockContent.node.type.name, + blockNoteType: content.node.type.name, }; - } else { - if (!bnBlock.node.type.isInGroup("childContainer")) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `bnBlock node is not in the childContainer group: ${bnBlock.node}`, - ); - } + } - return { - isWrappedBlock: false, - bnBlock: bnBlock, - childContainer: bnBlock, - blockNoteType: bnBlock.node.type.name, - }; + return { + hasContent: false, + block, + // A container holds its children directly, so the holder is the block + // node itself. + children: toChildrenInfo(block), + blockNoteType: node.type.name, + }; +} + +/** + * Gets information regarding the ProseMirror nodes that make up a block, given + * a position known to be just before a block node. Throws if no node starts at + * that position. + * @param doc The ProseMirror doc. + * @param posBeforeBlock The position just before the block's outer node. + */ +export function getBlockInfoAt(doc: Node, posBeforeBlock: number): BlockInfo { + const $pos = doc.resolve(posBeforeBlock); + if (!$pos.nodeAfter) { + throw new Error( + `Attempted to get block node at position ${posBeforeBlock} but a node at this position does not exist`, + ); } + return getBlockInfoFromNode($pos.nodeAfter, $pos.pos); } /** - * Gets information regarding the ProseMirror nodes that make up a block in a - * BlockNote document. This includes the main `blockContainer` node, the - * `blockContent` node with the block's main body, and the optional `blockGroup` - * node which contains the block's children. As well as the nodes, also returns - * the ProseMirror positions just before & after each node. - * @param posInfo An object with the main `blockContainer` node that the block - * information should be retrieved from, and the position just before it in the - * document. + * Gets information regarding the ProseMirror nodes that make up the block + * nearest to an arbitrary position (see {@link getNearestBlockPos}). + * @param source The ProseMirror editor state or transaction. + * @param pos An integer position in the document. */ -export function getBlockInfo(posInfo: { posBeforeNode: number; node: Node }) { - return getBlockInfoWithManualOffset(posInfo.node, posInfo.posBeforeNode); +export function getBlockInfoNearPos( + source: EditorState | Transaction, + pos: number, +): BlockInfo { + const posInfo = getNearestBlockPos(source.doc, pos); + return getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); } /** - * Gets information regarding the ProseMirror nodes that make up a block from a - * resolved position just before the `blockContainer` node in the document that - * corresponds to it. - * @param resolvedPos The resolved position just before the `blockContainer` - * node. + * Gets information regarding the ProseMirror nodes that make up the block + * containing the current ProseMirror selection anchor. + * @param source The ProseMirror editor state or transaction. */ -export function getBlockInfoFromResolvedPos(resolvedPos: ResolvedPos) { - if (!resolvedPos.nodeAfter) { - throw new Error( - `Attempted to get blockContainer node at position ${resolvedPos.pos} but a node at this position does not exist`, - ); +export function getBlockInfoFromSelection(source: EditorState | Transaction) { + return getBlockInfoNearPos(source, source.selection.anchor); +} + +/** + * The parent block's info: the block whose `children` contains the block at + * `posBeforeBlock`, or `undefined` for a top-level block. A container is the + * parent of its direct children (a block inside a column → the column, not + * the columnList); a regular block's children live in its `blockGroup`, so + * the parent is the group's own parent. + */ +export function getParentBlockInfo( + doc: Node, + posBeforeBlock: number, +): BlockInfo | undefined { + const $pos = doc.resolve(posBeforeBlock); + const parent = $pos.node(); + + if (parent.type.isInGroup("bnBlock")) { + return getBlockInfoAt(doc, $pos.before($pos.depth)); + } + // A `blockGroup`: its own parent block is the real parent, unless it's the + // document root group. + if (parent.type.isInGroup(CHILD_CONTAINER_GROUP) && $pos.depth > 1) { + return getBlockInfoAt(doc, $pos.before($pos.depth - 1)); } - return getBlockInfoWithManualOffset(resolvedPos.nodeAfter, resolvedPos.pos); + return undefined; } /** - * Gets information regarding the ProseMirror nodes that make up a block. The - * block chosen is the one currently containing the current ProseMirror - * selection. - * @param source The ProseMirror editor state. + * Returns the block info from the sibling block before (above) the given block, + * or undefined if the given block is the first sibling. */ -export function getBlockInfoFromSelection(source: EditorState | Transaction) { - return getBlockInfoAtNearest(source, source.selection.anchor); +export function getPrevBlockInfo( + doc: Node, + beforePos: number, +): BlockInfo | undefined { + const $pos = doc.resolve(beforePos); + + const indexInParent = $pos.index(); + + if (indexInParent === 0) { + return undefined; + } + + const prevBlockBeforePos = $pos.posAtIndex(indexInParent - 1); + + return getBlockInfoAt(doc, prevBlockBeforePos); } -export function getBlockInfoAtNearest( - source: EditorState | Transaction, - pos: number, -) { - return getBlockInfo(getNearestBlockPos(source.doc, pos)); +/** + * Returns the block info from the sibling block after (below) the given block, + * or undefined if the given block is the last sibling. + */ +export function getNextBlockInfo( + doc: Node, + beforePos: number, +): BlockInfo | undefined { + const $pos = doc.resolve(beforePos); + + const indexInParent = $pos.index(); + + if (indexInParent === $pos.node().childCount - 1) { + return undefined; + } + + const nextBlockBeforePos = $pos.posAtIndex(indexInParent + 1); + + return getBlockInfoAt(doc, nextBlockBeforePos); +} + +/** + * If a block has children like this: + * A + * - B + * - C + * -- D + * + * Then the last descendant block returned is D. + */ +export function getLastDescendantBlockInfo( + doc: Node, + blockInfo: BlockInfo, + // Callers that move content stop the descent at a sealed container, getting + // the container itself rather than a block inside it. Caret-only callers + // descend through. Sealed boundaries govern content, not navigation. + opts?: { stopAtSealed?: boolean }, +): BlockInfo { + // A container that allows zero children can have an empty child container, + // in which case the block itself is the bottom one. + while (blockInfo.children && blockInfo.children.node.childCount) { + if (opts?.stopAtSealed && isSealed(blockInfo.children.node)) { + break; + } + const group = blockInfo.children.node; + + const newPos = doc + .resolve(blockInfo.children.beforePos + 1) + .posAtIndex(group.childCount - 1); + blockInfo = getBlockInfoAt(doc, newPos); + } + + return blockInfo; } diff --git a/packages/core/src/api/getBlocksChangedByTransaction.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts index 2186fefe7d..b2853b9181 100644 --- a/packages/core/src/api/getBlocksChangedByTransaction.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, beforeEach } from "vite-plus/test"; import { setupTestEnv } from "./blockManipulation/setupTestEnv.js"; import { getBlocksChangedByTransaction } from "./getBlocksChangedByTransaction.js"; -import { getBlockInfo } from "./getBlockInfoFromPos.js"; +import { getBlockInfoFromNode } from "./getBlockInfoFromPos.js"; import { getNodeById } from "./nodeUtil.js"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; import { PartialBlock } from "../blocks/defaultBlocks.js"; @@ -651,15 +651,15 @@ describe("getBlocksChangedByTransaction - ranged optimization", () => { if (!posInfo) { throw new Error("block not found"); } - const info = getBlockInfo(posInfo); - if (!info.isWrappedBlock) { + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + if (!info.hasContent) { throw new Error("expected a wrapped block"); } // Adding a mark produces an AddMarkStep, whose StepMap is empty — the case // getChangedRange has to recover from the step's own from/to. tr.addMark( - info.blockContent.beforePos + 1, - info.blockContent.afterPos - 1, + info.content.beforePos + 1, + info.content.afterPos - 1, editor.pmSchema.marks.bold.create(), ); return getBlocksChangedByTransaction(tr); diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index 81f6937040..caeef05d2d 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -27,7 +27,7 @@ import { // `fixContainer.js` re-export) because `fixContainer.js` imports the seeding // machinery below; going through it would create an import cycle. import { - getChildrenConfig, + createBlockGroup, isContainerNode, resolveChildren, } from "../../schema/blocks/children.js"; @@ -357,7 +357,7 @@ const EMPTY_SEEDING: ReadonlySet = new Set(); function unwrapsWhenEmptied(blockType: string, schema: Schema): boolean { const blockConfig = getBlockSchema(schema)[blockType]; - const children = blockConfig ? getChildrenConfig(blockConfig) : undefined; + const children = blockConfig?.children; return !!children && resolveChildren(children).whenEmptied === "unwrap"; } @@ -394,9 +394,7 @@ function seedDefaultChildren( seedingTypes: ReadonlySet, ): Node[] | undefined { const blockSchemaConfig = getBlockSchema(schema)[blockType]; - const childrenConfig = blockSchemaConfig - ? getChildrenConfig(blockSchemaConfig) - : undefined; + const childrenConfig = blockSchemaConfig?.children; if (!childrenConfig) { return undefined; @@ -439,7 +437,7 @@ export function seedRefillChildren( min: number, ): Node[] { const blockConfig = getBlockSchema(schema)[blockType]; - const children = blockConfig ? getChildrenConfig(blockConfig) : undefined; + const children = blockConfig?.children; const defaultChildren = children ? resolveChildren(children).default : undefined; @@ -545,9 +543,7 @@ export function blockToNode( ); const groupNode = - children.length > 0 - ? schema.nodes["blockGroup"].createChecked({}, children) - : undefined; + children.length > 0 ? createBlockGroup(schema, children) : undefined; return schema.nodes["blockContainer"].createChecked( { diff --git a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts index ddd3de46f2..5b67cdb3bc 100644 --- a/packages/core/src/api/nodeConversions/fragmentToBlocks.ts +++ b/packages/core/src/api/nodeConversions/fragmentToBlocks.ts @@ -6,9 +6,7 @@ import { StyleSchema, } from "../../schema/index.js"; import { - getChildrenConfig, isContainerNode, - isPlaceableAnywhere, resolveChildren, } from "../../schema/blocks/children.js"; import { getBlockSchema } from "../pmUtil.js"; @@ -18,13 +16,13 @@ function isSelfContainedContainer(node: Node): boolean { if (!isContainerNode(node.type)) { return false; } - const blockConfig = getBlockSchema(node.type.schema)[node.type.name] ?? {}; - const childrenConfig = getChildrenConfig(blockConfig); - if (!childrenConfig) { + const blockConfig = getBlockSchema(node.type.schema)[node.type.name]; + const childrenConfig = blockConfig?.children; + if (!blockConfig || !childrenConfig) { return false; } return ( - isPlaceableAnywhere(blockConfig) && + blockConfig.placement !== "containerOnly" && node.childCount >= resolveChildren(childrenConfig).min ); } diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 0037759daa..f6a2234c54 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -19,10 +19,7 @@ import { isStyledTextInlineContent, } from "../../schema/inlineContent/types.js"; import { UnreachableCaseError } from "../../util/typescript.js"; -import { - getBlockInfoWithManualOffset, - getNodeId, -} from "../getBlockInfoFromPos.js"; +import { getBlockInfoFromNode, getNodeId } from "../getBlockInfoFromPos.js"; import { getBlockCache, getBlockSchema, @@ -403,7 +400,7 @@ export function nodeToBlock< const styleSchema = getStyleSchema(schema) as S; const blockCache = getBlockCache(schema); if (!node.type.isInGroup("bnBlock")) { - throw Error("Node should be a bnBlock, but is instead: " + node.type.name); + throw Error("Node should be a block, but is instead: " + node.type.name); } const cachedBlock = blockCache?.get(node); @@ -412,11 +409,11 @@ export function nodeToBlock< return cachedBlock; } - const blockInfo = getBlockInfoWithManualOffset(node, 0); + const blockInfo = getBlockInfoFromNode(node, 0); let id: string; try { - id = getNodeId(blockInfo.bnBlock.node, doc); + id = getNodeId(blockInfo.block.node, doc); } catch { // Only used for blocks converted from other formats. id = UniqueID.options.generateID(); @@ -431,7 +428,7 @@ export function nodeToBlock< const props: any = {}; for (const [attr, value] of Object.entries({ ...node.attrs, - ...(blockInfo.isWrappedBlock ? blockInfo.blockContent.node.attrs : {}), + ...(blockInfo.hasContent ? blockInfo.content.node.attrs : {}), })) { const propSchema = blockSpec.propSchema; @@ -446,37 +443,37 @@ export function nodeToBlock< const blockConfig = blockSchema[blockInfo.blockNoteType]; const children: Block[] = []; - blockInfo.childContainer?.node.forEach((child) => { + blockInfo.children?.node.forEach((child) => { children.push(nodeToBlock(child, doc)); }); let content: Block["content"]; if (blockConfig.content === "inline") { - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { throw new Error("impossible"); } content = contentNodeToInlineContent( - blockInfo.blockContent.node, + blockInfo.content.node, inlineContentSchema, styleSchema, ); } else if (blockConfig.content === "table") { - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { throw new Error("impossible"); } content = contentNodeToTableContent( - blockInfo.blockContent.node, + blockInfo.content.node, inlineContentSchema, styleSchema, ); } else if (blockConfig.content === "plain") { - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { throw new Error("impossible"); } // Plain content is a single unstyled text item; an empty block is an // empty array, matching inline content. - const text = blockInfo.blockContent.node.textContent; + const text = blockInfo.content.node.textContent; content = text.length > 0 ? [{ type: "text", text, styles: {} }] : []; } else if (blockConfig.content === "none") { content = undefined; @@ -565,7 +562,7 @@ export function prosemirrorSliceToSlicedBlocks< blockCutAtEnd: string | undefined; } { // Both `blockGroup` and container nodes (columnList, column, callout, - // ...) hold bnBlock children directly, so both can be processed here. + // ...) hold block children directly, so both can be processed here. if (node.type.name !== "blockGroup" && !isContainerNode(node.type)) { throw new Error("unexpected"); } @@ -573,32 +570,43 @@ export function prosemirrorSliceToSlicedBlocks< let blockCutAtStart: string | undefined; let blockCutAtEnd: string | undefined; + // Descends into a child-holding node the slice boundary is open inside + // of: the holder wrapper is skipped and the included children are spliced + // in, propagating cut ids from whichever ends are open. Shared by open + // container children and the degenerate `blockContainer`-around- + // `blockGroup` wrapper — regular nesting's version of the same shape. + function descendOpenHolder( + holder: Node, + openAtStart: boolean, + openAtEnd: boolean, + ) { + const ret = processNode( + holder, + 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); + } + 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, so skip the container - // wrapper and splice in the included children (mirroring the - // nested-blockGroup descent below). When fully enclosed, convert it - // wholesale. + // selection covers part of its children; 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); + descendOpenHolder(blockContainer, openAtStart, openAtEnd); return; } @@ -634,16 +642,11 @@ export function prosemirrorSliceToSlicedBlocks< if (!isFirstBlock) { throw new Error("unexpected"); } - const ret = processNode( - blockContainer.firstChild!, - Math.max(0, openStart - 1), - isLastBlock ? Math.max(0, openEnd - 1) : 0, - ); - blockCutAtStart = ret.blockCutAtStart; - if (isLastBlock) { - blockCutAtEnd = ret.blockCutAtEnd; - } - blocks.push(...ret.blocks); + // Open at the start by construction (a `blockContainer` can only lead + // with its `blockGroup` when the slice cut its content node away); + // open at the end whenever it is also the last block, matching the + // pre-refactor cut propagation. + descendOpenHolder(blockContainer.firstChild!, true, isLastBlock); return; } diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 9214a9ad42..efb41ba1c2 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -18,7 +18,7 @@ export function getNodeById( } // Keeps traversing nodes if block with target ID has not been found. Some - // bnBlock nodes we merely pass over (e.g. `column`/`columnList`) may not + // block nodes we merely pass over (e.g. `column`/`columnList`) may not // carry an id — skip them without calling the throwing `getNodeId`, which // errors on id-less nodes. Only nodes that actually have an id are compared. if (!isNodeBlock(node) || !node.attrs.id || getNodeId(node, doc) !== id) { diff --git a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts index 71f3ecaf35..218618ca1f 100644 --- a/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts +++ b/packages/core/src/blocks/ListItem/ListItemKeyboardShortcuts.ts @@ -11,17 +11,17 @@ export const handleEnter = (editor: BlockNoteEditor) => { }; }); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block: blockContainer, content } = blockInfo; if ( !( - blockContent.node.type.name === "toggleListItem" || - blockContent.node.type.name === "bulletListItem" || - blockContent.node.type.name === "numberedListItem" || - blockContent.node.type.name === "checkListItem" + content.node.type.name === "toggleListItem" || + content.node.type.name === "bulletListItem" || + content.node.type.name === "numberedListItem" || + content.node.type.name === "checkListItem" ) || !selectionEmpty ) { @@ -32,7 +32,7 @@ export const handleEnter = (editor: BlockNoteEditor) => { () => // Changes list item block to a paragraph block if the content is empty. commands.command(() => { - if (blockContent.node.childCount === 0) { + if (blockInfo.isContentEmpty) { return commands.command( updateBlockCommand(blockContainer.beforePos, { type: "paragraph", @@ -48,7 +48,7 @@ export const handleEnter = (editor: BlockNoteEditor) => { // Splits the current block, moving content inside that's after the cursor // to a new block of the same type below. commands.command(() => { - if (blockContent.node.childCount > 0) { + if (content.node.childCount > 0) { chain() .deleteSelection() .command(splitBlockCommand(state.selection.from, true)) diff --git a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts index 5e52c8c76f..0222fff46a 100644 --- a/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts +++ b/packages/core/src/blocks/ListItem/NumberedListItem/IndexingPlugin.ts @@ -3,7 +3,7 @@ import type { Transaction } from "@tiptap/pm/state"; import { Plugin, PluginKey } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; -import { getBlockInfo } from "../../../api/getBlockInfoFromPos.js"; +import { getBlockInfoFromNode } from "../../../api/getBlockInfoFromPos.js"; // Loosely based on https://github.com/ueberdosis/tiptap/blob/7ac01ef0b816a535e903b5ca92492bff110a71ae/packages/extension-mathematics/src/MathematicsPlugin.ts (MIT) @@ -31,11 +31,11 @@ function calculateListItemIndex( const hasStart = !!node.firstChild!.attrs["start"]; // Fast path: previous sibling already in cache - const blockInfo = getBlockInfo({ posBeforeNode: pos, node }); - if (!blockInfo.isWrappedBlock) { + const blockInfo = getBlockInfoFromNode(node, pos); + if (!blockInfo.hasContent) { throw new Error("impossible"); } - const prevBlock = tr.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore; + const prevBlock = tr.doc.resolve(blockInfo.block.beforePos).nodeBefore; const prevBlockIndex = prevBlock ? map.get(prevBlock) : undefined; if (prevBlockIndex !== undefined) { const index = prevBlockIndex + 1; @@ -48,7 +48,7 @@ function calculateListItemIndex( // or the start of the parent. const chain: { node: Node; pos: number }[] = [{ node, pos }]; let curNode = prevBlock; - let curBeforePos = blockInfo.bnBlock.beforePos; + let curBeforePos = blockInfo.block.beforePos; while (curNode) { const cachedIndex = map.get(curNode); @@ -56,16 +56,16 @@ function calculateListItemIndex( // Found a cached predecessor — start counting from here break; } - const curInfo = getBlockInfo({ - posBeforeNode: curBeforePos - curNode.nodeSize, - node: curNode, - }); + const curInfo = getBlockInfoFromNode( + curNode, + curBeforePos - curNode.nodeSize, + ); if (curInfo.blockNoteType !== "numberedListItem") { break; } chain.push({ node: curNode, pos: curBeforePos - curNode.nodeSize }); - const nextPrev = tr.doc.resolve(curInfo.bnBlock.beforePos).nodeBefore; - curBeforePos = curInfo.bnBlock.beforePos; + const nextPrev = tr.doc.resolve(curInfo.block.beforePos).nodeBefore; + curBeforePos = curInfo.block.beforePos; curNode = nextPrev; } @@ -76,14 +76,11 @@ function calculateListItemIndex( // Determine starting index from the block just before the chain const lastInChain = chain[chain.length - 1]; - const lastInfo = getBlockInfo({ - posBeforeNode: lastInChain.pos, - node: lastInChain.node, - }); - if (!lastInfo.isWrappedBlock) { + const lastInfo = getBlockInfoFromNode(lastInChain.node, lastInChain.pos); + if (!lastInfo.hasContent) { throw new Error("impossible"); } - const predecessorNode = tr.doc.resolve(lastInfo.bnBlock.beforePos).nodeBefore; + const predecessorNode = tr.doc.resolve(lastInfo.block.beforePos).nodeBefore; const predecessorIndex = predecessorNode ? map.get(predecessorNode) : undefined; diff --git a/packages/core/src/blocks/utils/listItemEnterHandler.ts b/packages/core/src/blocks/utils/listItemEnterHandler.ts index 578d3aae8b..6008c1a023 100644 --- a/packages/core/src/blocks/utils/listItemEnterHandler.ts +++ b/packages/core/src/blocks/utils/listItemEnterHandler.ts @@ -14,16 +14,16 @@ export const handleEnter = ( }; }); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block: blockContainer, content } = blockInfo; - if (!(blockContent.node.type.name === listItemType) || !selectionEmpty) { + if (!(content.node.type.name === listItemType) || !selectionEmpty) { return false; } - if (blockContent.node.childCount === 0) { + if (blockInfo.isContentEmpty) { editor.transact((tr) => { updateBlockTr(tr, blockContainer.beforePos, { type: "paragraph", @@ -31,7 +31,7 @@ export const handleEnter = ( }); }); return true; - } else if (blockContent.node.childCount > 0) { + } else if (content.node.childCount > 0) { return editor.transact((tr) => { tr.deleteSelection(); tr.scrollIntoView(); diff --git a/packages/core/src/editor/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts index bf4253711e..680a663b98 100644 --- a/packages/core/src/editor/BlockNoteEditor.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.test.ts @@ -2,7 +2,7 @@ import { afterEach, expect, it } from "vite-plus/test"; import * as Y from "yjs"; import { - getBlockInfo, + getBlockInfoFromNode, getNearestBlockPos, } from "../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "./BlockNoteEditor.js"; @@ -26,7 +26,7 @@ it("creates an editor", () => { const editor = BlockNoteEditor.create(); editorsToCleanup.push(editor); const posInfo = editor.transact((tr) => getNearestBlockPos(tr.doc, 2)); - const info = getBlockInfo(posInfo); + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); expect(info.blockNoteType).toEqual("paragraph"); }); diff --git a/packages/core/src/editor/managers/ExtensionManager/extensions.ts b/packages/core/src/editor/managers/ExtensionManager/extensions.ts index 90cb91e432..3d2a3e5bda 100644 --- a/packages/core/src/editor/managers/ExtensionManager/extensions.ts +++ b/packages/core/src/editor/managers/ExtensionManager/extensions.ts @@ -39,7 +39,6 @@ import { UniqueID, } from "../../../extensions/tiptap-extensions/index.js"; import { BlockContainer, BlockGroup, Doc } from "../../../pm-nodes/index.js"; -import { isContainerType } from "../../../schema/blocks/children.js"; import type { BlockNoteEditor, BlockNoteEditorOptions, @@ -70,7 +69,7 @@ export function getDefaultTiptapExtensions( // block itself, so the id lives on its attrs rather than on a // wrapping blockContainer. ...Object.entries(editor.schema.blockSpecs) - .filter(([, spec]) => isContainerType((spec as any).config)) + .filter(([, spec]) => (spec as any).config.children !== undefined) .map(([type]) => type), ], setIdAttribute: options.setIdAttribute, diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 71167e8f5a..b8973e3394 100644 --- a/packages/core/src/editor/managers/ExtensionManager/index.ts +++ b/packages/core/src/editor/managers/ExtensionManager/index.ts @@ -563,7 +563,7 @@ export class ExtensionManager { const blockInfo = getBlockInfoFromSelection(tr); if ( - !blockInfo.isWrappedBlock || + !blockInfo.hasContent || this.editor.schema.blockSchema[blockInfo.blockNoteType] ?.content !== "inline" ) { @@ -571,14 +571,14 @@ export class ExtensionManager { } tr.deleteRange(start, end); - updateBlockTr(tr, blockInfo.bnBlock.beforePos, replaceWith); + updateBlockTr(tr, blockInfo.block.beforePos, replaceWith); // updateBlockTr's replaceWith path leaves the selection after // the new block when the content is replaced wholesale (e.g. // when the rule returns content: []). Move the cursor back // inside the new block so the user can keep typing. setTextCursorPosition( tr, - getNodeId(blockInfo.bnBlock.node, tr.doc), + getNodeId(blockInfo.block.node, tr.doc), "start", ); return tr; diff --git a/packages/core/src/editor/transformPasted.ts b/packages/core/src/editor/transformPasted.ts index 033df48484..935e2b5bd2 100644 --- a/packages/core/src/editor/transformPasted.ts +++ b/packages/core/src/editor/transformPasted.ts @@ -66,7 +66,7 @@ function removeChild(node: Fragment, n: number) { * Wrap adjacent tableRow items in a table. * * This makes sure the content that we paste is always a table (and not a tableRow) - * A table works better for the remaing paste handling logic, as it's actually a blockContent node + * A table works better for the remaing paste handling logic, as it's actually a content node */ export function wrapTableRows(f: Fragment, schema: Schema) { const newItems: any[] = []; @@ -217,15 +217,15 @@ function retypeLeadingParagraphForEmptyTarget( } const blockInfo = getBlockInfoFromSelection(view.state); - const target = blockInfo.isWrappedBlock ? blockInfo.blockContent.node : null; if ( - !target || - target.type.name === "paragraph" || - target.type.spec.content !== "inline*" || - target.childCount > 0 + !blockInfo.hasContent || + blockInfo.content.node.type.name === "paragraph" || + blockInfo.contentKind !== "inline" || + !blockInfo.isContentEmpty ) { return null; } + const target = blockInfo.content.node; const blockGroup = fragment.firstChild; const blockContainer = blockGroup?.firstChild; @@ -277,9 +277,8 @@ function shouldApplyFix(fragment: Fragment, view: EditorView) { // for both paste and drop events. Drop events can potentially cause // issues as they don't always happen at the current selection. const blockInfo = getBlockInfoFromSelection(view.state); - if (blockInfo.isWrappedBlock) { - const selectedBlockHasTableContent = - blockInfo.blockContent.node.type.spec.content === "tableRow+"; + if (blockInfo.hasContent) { + const selectedBlockHasTableContent = blockInfo.contentKind === "table"; // Case for when we paste a single node with table content, i.e. a // table. Normally, we return true as we want to ensure the table is diff --git a/packages/core/src/exporter/Exporter.ts b/packages/core/src/exporter/Exporter.ts index 4430c5f399..e23cd3748b 100644 --- a/packages/core/src/exporter/Exporter.ts +++ b/packages/core/src/exporter/Exporter.ts @@ -11,7 +11,6 @@ import { StyledText, Styles, } from "../schema/index.js"; -import { isContainerType } from "../schema/blocks/children.js"; import type { BlockMapping, @@ -88,7 +87,7 @@ export abstract class Exporter< const spec = (this.blockNoteSchema.blockSpecs as Record)[ blockType ]; - return !!spec && isContainerType(spec.config); + return !!spec && spec.config.children !== undefined; } /** diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts index 2f1e601a35..2f3464a2db 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.test.ts @@ -110,6 +110,352 @@ function getTextContent(editor: BlockNoteEditor) { return text; } +/** + * Characterization tests for the Backspace/Delete/Enter/Tab handlers: they pin + * the current document transformations so the BlockInfo migration inside the + * handlers is provably behavior-preserving. + */ +function createEditorWithBlocks( + initialContent: any[], + cursor: { id: string; placement: "start" | "end" }, +) { + const editor = BlockNoteEditor.create({ schema, initialContent }); + editor.mount(document.createElement("div")); + editor.setTextCursorPosition(cursor.id, cursor.placement); + return editor; +} + +/** Compact structural view of the document for snapshotting. */ +function outline(blocks: any[]): any[] { + return blocks.map((b) => ({ + type: b.type, + text: Array.isArray(b.content) + ? b.content.map((c: any) => c.text ?? "").join("") + : undefined, + ...(b.children.length > 0 ? { children: outline(b.children) } : {}), + })); +} + +describe("KeyboardShortcutsExtension Backspace", () => { + it("merges a block into the previous one at block start", () => { + const editor = createEditorWithBlocks( + [ + { id: "a", type: "paragraph", content: "Hello" }, + { id: "b", type: "paragraph", content: "World" }, + ], + { id: "b", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "HelloWorld", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("merges into the previous block's deepest descendant", () => { + const editor = createEditorWithBlocks( + [ + { + id: "a", + type: "paragraph", + content: "Parent", + children: [{ id: "a1", type: "paragraph", content: "Nested" }], + }, + { id: "b", type: "paragraph", content: "World" }, + ], + { id: "b", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "children": [ + { + "text": "NestedWorld", + "type": "paragraph", + }, + ], + "text": "Parent", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("lifts a nested first child at block start", () => { + const editor = createEditorWithBlocks( + [ + { + id: "a", + type: "paragraph", + content: "Parent", + children: [{ id: "a1", type: "paragraph", content: "Nested" }], + }, + ], + { id: "a1", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "Parent", + "type": "paragraph", + }, + { + "text": "Nested", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("deletes an empty block, moving its children out", () => { + const editor = createEditorWithBlocks( + [ + { id: "a", type: "paragraph", content: "Before" }, + { + id: "b", + type: "paragraph", + content: "", + children: [{ id: "b1", type: "paragraph", content: "Child" }], + }, + ], + { id: "b", placement: "start" }, + ); + + pressKeys(editor, "Backspace"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "Before", + "type": "paragraph", + }, + { + "text": "Child", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); +}); + +describe("KeyboardShortcutsExtension Delete", () => { + it("merges the next block in at block end", () => { + const editor = createEditorWithBlocks( + [ + { id: "a", type: "paragraph", content: "Hello" }, + { id: "b", type: "paragraph", content: "World" }, + ], + { id: "a", placement: "end" }, + ); + + pressKeys(editor, "Delete"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "HelloWorld", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("merges a next block that has children, un-nesting them", () => { + const editor = createEditorWithBlocks( + [ + { id: "a", type: "paragraph", content: "Hello" }, + { + id: "b", + type: "paragraph", + content: "World", + children: [ + { id: "b1", type: "paragraph", content: "Child 1" }, + { id: "b2", type: "paragraph", content: "Child 2" }, + ], + }, + ], + { id: "a", placement: "end" }, + ); + + pressKeys(editor, "Delete"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "HelloWorld", + "type": "paragraph", + }, + { + "text": "Child 1", + "type": "paragraph", + }, + { + "text": "Child 2", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("removes an empty next block, adopting its children", () => { + const editor = createEditorWithBlocks( + [ + { id: "a", type: "paragraph", content: "Hello" }, + { + id: "b", + type: "paragraph", + content: "", + children: [{ id: "b1", type: "paragraph", content: "Child" }], + }, + ], + { id: "a", placement: "end" }, + ); + + pressKeys(editor, "Delete"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "Hello", + "type": "paragraph", + }, + { + "text": "Child", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("removes an empty current block on Delete", () => { + const editor = createEditorWithBlocks( + [ + { id: "a", type: "paragraph", content: "" }, + { id: "b", type: "paragraph", content: "After" }, + ], + { id: "a", placement: "start" }, + ); + + pressKeys(editor, "Delete"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "After", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); +}); + +describe("KeyboardShortcutsExtension Enter", () => { + it("inserts an empty block above when Enter is pressed at the start", () => { + const editor = createEditorWithBlocks( + [{ id: "a", type: "paragraph", content: "Hello" }], + { id: "a", placement: "start" }, + ); + + pressKeys(editor, "Enter"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "", + "type": "paragraph", + }, + { + "text": "Hello", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); + + it("lifts an empty nested block on Enter", () => { + const editor = createEditorWithBlocks( + [ + { + id: "a", + type: "paragraph", + content: "Parent", + children: [{ id: "a1", type: "paragraph", content: "" }], + }, + ], + { id: "a1", placement: "start" }, + ); + + pressKeys(editor, "Enter"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "Parent", + "type": "paragraph", + }, + { + "text": "", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); +}); + +describe("KeyboardShortcutsExtension Shift-Tab", () => { + it("un-nests a nested block", () => { + const editor = createEditorWithBlocks( + [ + { + id: "a", + type: "paragraph", + content: "Parent", + children: [{ id: "a1", type: "paragraph", content: "Nested" }], + }, + ], + { id: "a1", placement: "start" }, + ); + + pressKeys(editor, "Shift-Tab"); + + expect(outline(editor.document)).toMatchInlineSnapshot(` + [ + { + "text": "Parent", + "type": "paragraph", + }, + { + "text": "Nested", + "type": "paragraph", + }, + ] + `); + editor._tiptapEditor.destroy(); + }); +}); + describe("KeyboardShortcutsExtension hardBreakShortcut", () => { it("inserts a hard break on Shift-Enter by default", () => { const editor = createEditor("paragraph"); diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 31734d0096..c8fb459a58 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -2,13 +2,7 @@ import { CommandProps, Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; import { NodeSelection, TextSelection, Transaction } from "prosemirror-state"; -import { - getBottomNestedBlockInfo, - getNextBlockInfo, - getParentBlockInfo, - getPrevBlockInfo, - mergeBlocksCommand, -} from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; +import { mergeBlocksCommand } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; import { liftItem, nestBlock, @@ -28,8 +22,13 @@ import { isSealed } from "../../../schema/blocks/children.js"; import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { - getBlockInfoFromResolvedPos, + getBlockInfoAt, getBlockInfoFromSelection, + getLastDescendantBlockInfo, + getNextBlockInfo, + getParentBlockInfo, + getPrevBlockInfo, + tableContentCaretPos, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; @@ -71,28 +70,28 @@ function moveBlockOutAndPlaceCaret( function selectSealedSiblingCommand(direction: "prev" | "next") { return ({ state, tr, dispatch }: CommandProps) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const atEdge = direction === "prev" - ? state.selection.from === blockInfo.blockContent.beforePos + 1 - : state.selection.from === blockInfo.blockContent.afterPos - 1; + ? state.selection.from === blockInfo.contentStart + : state.selection.from === blockInfo.contentEnd; if (!atEdge || !state.selection.empty) { return false; } const sibling = ( direction === "prev" ? getPrevBlockInfo : getNextBlockInfo - )(state.doc, blockInfo.bnBlock.beforePos); - if (!sibling || !isSealed(sibling.bnBlock.node)) { + )(state.doc, blockInfo.block.beforePos); + if (!sibling || !isSealed(sibling.block.node)) { return false; } - if (dispatch && NodeSelection.isSelectable(sibling.bnBlock.node)) { + if (dispatch && NodeSelection.isSelectable(sibling.block.node)) { tr.setSelection( - NodeSelection.create(tr.doc, sibling.bnBlock.beforePos), + NodeSelection.create(tr.doc, sibling.block.beforePos), ).scrollIntoView(); } return true; @@ -119,18 +118,18 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + state.selection.from === blockInfo.contentStart; const isParagraph = - blockInfo.blockContent.node.type.name === "paragraph"; + blockInfo.content.node.type.name === "paragraph"; if (selectionAtBlockStart && !isParagraph) { return commands.command( - updateBlockCommand(blockInfo.bnBlock.beforePos, { + updateBlockCommand(blockInfo.block.beforePos, { type: "paragraph", props: {}, }), @@ -143,13 +142,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { blockContent } = blockInfo; const selectionAtBlockStart = - state.selection.from === blockContent.beforePos + 1; + state.selection.from === blockInfo.contentStart; if (selectionAtBlockStart) { return liftItem( @@ -169,28 +167,28 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block: blockContainer } = blockInfo; const prevBlockInfo = getPrevBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); // If the previous block has no inline content, it can't be merged. // It's instead deleted, which is done later in the chan, so we // return early here. if ( !prevBlockInfo || - !prevBlockInfo.isWrappedBlock || - prevBlockInfo.blockContent.node.type.spec.content !== "inline*" + !prevBlockInfo.hasContent || + prevBlockInfo.contentKind !== "inline" ) { return false; } const selectionAtBlockStart = - state.selection.from === blockContent.beforePos + 1; + state.selection.from === blockInfo.contentStart; const selectionEmpty = state.selection.empty; const posBetweenBlocks = blockContainer.beforePos; @@ -211,53 +209,42 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + state.selection.from === blockInfo.contentStart; if (!selectionAtBlockStart) { return false; } const prevBlockInfo = getPrevBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!prevBlockInfo || prevBlockInfo.isWrappedBlock) { + if (!prevBlockInfo || prevBlockInfo.hasContent) { return false; } - const insertionPos = descendToLastInsertionPos( - prevBlockInfo.bnBlock.node, - prevBlockInfo.bnBlock.beforePos, + const descent = descendToLastInsertionPos( + prevBlockInfo.block, state.schema.nodes["blockContainer"], - { respectSealed: true }, ); + const insertionPos = descent.crossedSeal ? null : descent.pos; if (insertionPos === null) { // When only a sealed boundary blocked the descent, the // container can't be entered, so it's selected instead, and a // second Backspace deletes it explicitly. A container with // nowhere a `blockContainer` can land falls through as before. - // (The probe descends without `respectSealed`, i.e. through - // seals.) - const blockedBySeal = - descendToLastInsertionPos( - prevBlockInfo.bnBlock.node, - prevBlockInfo.bnBlock.beforePos, - state.schema.nodes["blockContainer"], - ) !== null; if ( - blockedBySeal && - NodeSelection.isSelectable(prevBlockInfo.bnBlock.node) + descent.pos !== null && + descent.crossedSeal && + NodeSelection.isSelectable(prevBlockInfo.block.node) ) { if (dispatch) { tr.setSelection( - NodeSelection.create( - tr.doc, - prevBlockInfo.bnBlock.beforePos, - ), + NodeSelection.create(tr.doc, prevBlockInfo.block.beforePos), ).scrollIntoView(); } return true; @@ -266,11 +253,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ } if (dispatch) { - tr.delete( - blockInfo.bnBlock.beforePos, - blockInfo.bnBlock.afterPos, - ); - tr.insert(insertionPos, blockInfo.bnBlock.node); + tr.delete(blockInfo.block.beforePos, blockInfo.block.afterPos); + tr.insert(insertionPos, blockInfo.block.node); tr.setSelection( TextSelection.near(tr.doc.resolve(insertionPos + 1)), ); @@ -288,17 +272,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - tr.selection.from === blockInfo.blockContent.beforePos + 1; + tr.selection.from === blockInfo.contentStart; if (!selectionAtBlockStart) { return false; } - const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const $pos = tr.doc.resolve(blockInfo.block.beforePos); const prevBlock = $pos.nodeBefore; if (prevBlock) { @@ -331,13 +315,22 @@ export const KeyboardShortcutsExtension = Extension.create<{ ? $containerPos.nodeBefore : null; - const insertionPos = prevSibling + // A gesture move respects seals: a descent that crossed one is + // treated as having nowhere to land. + const descent = prevSibling ? descendToLastInsertionPos( - prevSibling, - containerBeforePos - prevSibling.nodeSize, + { + node: prevSibling, + beforePos: containerBeforePos - prevSibling.nodeSize, + }, blockContainerType, - { respectSealed: true }, ) + : null; + + const insertionPos = descent + ? descent.crossedSeal + ? null + : descent.pos : ascendToInsertablePos( tr.doc, containerBeforePos, @@ -350,9 +343,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (dispatch) { moveBlockOutAndPlaceCaret(tr, { - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, - node: blockInfo.bnBlock.node, + from: blockInfo.block.beforePos, + to: blockInfo.block.afterPos, + node: blockInfo.block.node, insertAt: insertionPos, }); } @@ -364,63 +357,57 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const blockEmpty = - blockInfo.blockContent.node.childCount === 0 && - blockInfo.blockContent.node.type.spec.content === "inline*"; + blockInfo.isContentEmpty && blockInfo.contentKind === "inline"; if (blockEmpty) { const prevBlockInfo = getPrevBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); if (!prevBlockInfo) { return false; } - const bottomNestedPrevBlockInfo = getBottomNestedBlockInfo( + const bottomNestedPrevBlockInfo = getLastDescendantBlockInfo( state.doc, prevBlockInfo, ); - if (!bottomNestedPrevBlockInfo.isWrappedBlock) { + if (!bottomNestedPrevBlockInfo.hasContent) { return false; } let chainedCommands = chain(); // Moves the children the current block. - if (blockInfo.childContainer) { + if (blockInfo.children) { chainedCommands.insertContentAt( - blockInfo.bnBlock.afterPos, - blockInfo.childContainer?.node.content, + blockInfo.block.afterPos, + blockInfo.children?.node.content, ); } if ( - bottomNestedPrevBlockInfo.blockContent.node.type.spec - .content === "tableRow+" + bottomNestedPrevBlockInfo.content.node.type.spec.content === + "tableRow+" ) { - const tableBlockEndPos = blockInfo.bnBlock.beforePos - 1; - const tableBlockContentEndPos = tableBlockEndPos - 1; - const lastRowEndPos = tableBlockContentEndPos - 1; - const lastCellEndPos = lastRowEndPos - 1; - const lastCellParagraphEndPos = lastCellEndPos - 1; - chainedCommands = chainedCommands.setTextSelection( - lastCellParagraphEndPos, + tableContentCaretPos( + bottomNestedPrevBlockInfo.content, + "end", + ), ); } else if ( - bottomNestedPrevBlockInfo.blockContent.node.type.spec - .content === "" + bottomNestedPrevBlockInfo.content.node.type.spec.content === "" ) { chainedCommands = chainedCommands.setNodeSelection( - bottomNestedPrevBlockInfo.blockContent.beforePos, + bottomNestedPrevBlockInfo.content.beforePos, ); } else { - const blockContentEndPos = - bottomNestedPrevBlockInfo.blockContent.afterPos - 1; + const blockContentEndPos = bottomNestedPrevBlockInfo.contentEnd; chainedCommands = chainedCommands.setTextSelection(blockContentEndPos); @@ -428,8 +415,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ return chainedCommands .deleteRange({ - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, + from: blockInfo.block.beforePos, + to: blockInfo.block.afterPos, }) .scrollIntoView() .run(); @@ -444,56 +431,55 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + state.selection.from === blockInfo.contentStart; const selectionEmpty = state.selection.empty; const prevBlockInfo = getPrevBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) { // The sealed-aware descent stops at a sealed container instead // of finding an (empty) block inside it, so the current block // is never cut in across the boundary. - const bottomBlock = getBottomNestedBlockInfo( + const bottomBlock = getLastDescendantBlockInfo( state.doc, prevBlockInfo, { stopAtSealed: true }, ); - if (!bottomBlock.isWrappedBlock) { + if (!bottomBlock.hasContent) { return false; } // A sealed content container also stops the descent; deleting // it here would take its children with it. - if (isSealed(bottomBlock.bnBlock.node)) { + if (isSealed(bottomBlock.block.node)) { return false; } const prevBlockNotTableAndNoContent = - bottomBlock.blockContent.node.type.spec.content === "" || - (bottomBlock.blockContent.node.type.spec.content === - "inline*" && - bottomBlock.blockContent.node.childCount === 0); + bottomBlock.contentKind === "none" || + (bottomBlock.contentKind === "inline" && + bottomBlock.isContentEmpty); if (prevBlockNotTableAndNoContent) { return chain() .cut( { - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, + from: blockInfo.block.beforePos, + to: blockInfo.block.afterPos, }, - bottomBlock.bnBlock.afterPos, + bottomBlock.block.afterPos, ) .deleteRange({ - from: bottomBlock.bnBlock.beforePos, - to: bottomBlock.bnBlock.afterPos, + from: bottomBlock.block.beforePos, + to: bottomBlock.block.afterPos, }) .run(); } @@ -515,55 +501,54 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock || !blockInfo.childContainer) { + if (!blockInfo.hasContent || !blockInfo.children) { return false; } - const { blockContent, childContainer } = blockInfo; + const { children } = blockInfo; // A container allowed to hold no children still has a child // container node, but no first child to pull anything out of. - if (childContainer.node.childCount === 0) { + if (children.node.childCount === 0) { return false; } const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; - const firstChildBlockInfo = getBlockInfoFromResolvedPos( - state.doc.resolve(childContainer.beforePos + 1), + const firstChildBlockInfo = getBlockInfoAt( + state.doc, + children.childrenStart, ); - if (!firstChildBlockInfo.isWrappedBlock) { + if (!firstChildBlockInfo.hasContent) { return false; } if (selectionAtBlockEnd && selectionEmpty) { - const firstChildBlockContent = - firstChildBlockInfo.blockContent.node; + const firstChildBlockContent = firstChildBlockInfo.content.node; const firstChildBlockHasInlineContent = - firstChildBlockContent.type.spec.content === "inline*"; - const blockHasInlineContent = - blockContent.node.type.spec.content === "inline*"; + firstChildBlockInfo.contentKind === "inline"; + const blockHasInlineContent = blockInfo.contentKind === "inline"; return ( chain() // Un-nests child block's children if necessary. .insertContentAt( - firstChildBlockInfo.bnBlock.afterPos, - firstChildBlockInfo.childContainer?.node.content || + firstChildBlockInfo.block.afterPos, + firstChildBlockInfo.children?.node.content || Fragment.empty, ) .deleteRange( // Deletes whole child container if there's only one // child. - childContainer.node.childCount === 1 + children.node.childCount === 1 ? { - from: childContainer.beforePos, - to: childContainer.afterPos, + from: children.beforePos, + to: children.afterPos, } : { - from: firstChildBlockInfo.bnBlock.beforePos, - to: firstChildBlockInfo.bnBlock.afterPos, + from: firstChildBlockInfo.block.beforePos, + to: firstChildBlockInfo.block.afterPos, }, ) // Appends inline content from child block if possible. @@ -591,21 +576,21 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block: blockContainer } = blockInfo; const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { + if (!nextBlockInfo || !nextBlockInfo.hasContent) { return false; } const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; const posBetweenBlocks = blockContainer.afterPos; @@ -624,27 +609,27 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockEnd = - state.selection.from === blockInfo.blockContent.afterPos - 1; + state.selection.from === blockInfo.contentEnd; if (!selectionAtBlockEnd) { return false; } const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || nextBlockInfo.isWrappedBlock) { + if (!nextBlockInfo || nextBlockInfo.hasContent) { return false; } const firstLeaf = getFirstLeafBlock( - nextBlockInfo.bnBlock.node, - nextBlockInfo.bnBlock.beforePos, + nextBlockInfo.block.node, + nextBlockInfo.block.beforePos, { respectSealed: true }, ); if (!firstLeaf) { @@ -656,7 +641,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ from: firstLeaf.beforePos, to: firstLeaf.beforePos + firstLeaf.node.nodeSize, node: firstLeaf.node, - insertAt: blockInfo.bnBlock.afterPos, + insertAt: blockInfo.block.afterPos, }); return true; @@ -671,17 +656,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockEnd = - tr.selection.from === blockInfo.blockContent.afterPos - 1; + tr.selection.from === blockInfo.contentEnd; if (!selectionAtBlockEnd) { return false; } - const $pos = tr.doc.resolve(blockInfo.bnBlock.afterPos); + const $pos = tr.doc.resolve(blockInfo.block.afterPos); const nextBlock = $pos.nodeAfter; if (nextBlock) { @@ -730,7 +715,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ from: target.beforePos, to: target.beforePos + target.node.nodeSize, node: target.node, - insertAt: blockInfo.bnBlock.afterPos, + insertAt: blockInfo.block.afterPos, }); } @@ -744,13 +729,12 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { blockContent } = blockInfo; const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; if (selectionAtBlockEnd && selectionEmpty) { @@ -768,42 +752,40 @@ export const KeyboardShortcutsExtension = Extension.create<{ !parentBlockInfo || // Never climbs past a sealed boundary. A block found // there would be pulled in across it. - isSealed(parentBlockInfo.bnBlock.node) + isSealed(parentBlockInfo.block.node) ) { return undefined; } return getNextBlockInfoAtAnyLevel( doc, - parentBlockInfo.bnBlock.beforePos, + parentBlockInfo.block.beforePos, ); }; const nextBlockInfo = getNextBlockInfoAtAnyLevel( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { + if (!nextBlockInfo || !nextBlockInfo.hasContent) { return false; } - const nextBlockContent = nextBlockInfo.blockContent.node; + const nextBlockContent = nextBlockInfo.content.node; const nextBlockHasInlineContent = - nextBlockContent.type.spec.content === "inline*"; - const blockHasInlineContent = - blockContent.node.type.spec.content === "inline*"; + nextBlockInfo.contentKind === "inline"; + const blockHasInlineContent = blockInfo.contentKind === "inline"; return ( chain() // Un-nests next block's children if necessary. .insertContentAt( - nextBlockInfo.bnBlock.afterPos, - nextBlockInfo.childContainer?.node.content || - Fragment.empty, + nextBlockInfo.block.afterPos, + nextBlockInfo.children?.node.content || Fragment.empty, ) .deleteRange({ - from: nextBlockInfo.bnBlock.beforePos, - to: nextBlockInfo.bnBlock.afterPos, + from: nextBlockInfo.block.beforePos, + to: nextBlockInfo.block.afterPos, }) // Appends inline content from child block if possible. .insertContentAt( @@ -825,54 +807,42 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const blockEmpty = - blockInfo.blockContent.node.childCount === 0 && - blockInfo.blockContent.node.type.spec.content === "inline*"; + blockInfo.isContentEmpty && blockInfo.contentKind === "inline"; if (blockEmpty) { const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isWrappedBlock) { + if (!nextBlockInfo || !nextBlockInfo.hasContent) { return false; } let chainedCommands = chain(); - if ( - nextBlockInfo.blockContent.node.type.spec.content === - "tableRow+" - ) { - const tableBlockStartPos = blockInfo.bnBlock.afterPos + 1; - const tableBlockContentStartPos = tableBlockStartPos + 1; - const firstRowStartPos = tableBlockContentStartPos + 1; - const firstCellStartPos = firstRowStartPos + 1; - const firstCellParagraphStartPos = firstCellStartPos + 1; - + if (nextBlockInfo.contentKind === "table") { chainedCommands = chainedCommands.setTextSelection( - firstCellParagraphStartPos, + tableContentCaretPos(nextBlockInfo.content, "start"), ); - } else if ( - nextBlockInfo.blockContent.node.type.spec.content === "" - ) { + } else if (nextBlockInfo.contentKind === "none") { chainedCommands = chainedCommands.setNodeSelection( - nextBlockInfo.blockContent.beforePos, + nextBlockInfo.content.beforePos, ); } else { chainedCommands = chainedCommands.setTextSelection( - nextBlockInfo.blockContent.beforePos + 1, + nextBlockInfo.contentStart, ); } return chainedCommands .deleteRange({ - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, + from: blockInfo.block.beforePos, + to: blockInfo.block.afterPos, }) .scrollIntoView() .run(); @@ -887,45 +857,40 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockEnd = - state.selection.from === blockInfo.blockContent.afterPos - 1; + state.selection.from === blockInfo.contentEnd; const selectionEmpty = state.selection.empty; const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); if (!nextBlockInfo) { return false; } - if (!nextBlockInfo.isWrappedBlock) { + if (!nextBlockInfo.hasContent) { return false; } if (nextBlockInfo && selectionAtBlockEnd && selectionEmpty) { const nextBlockNotTableAndNoContent = - nextBlockInfo.blockContent.node.type.spec.content === "" || - (nextBlockInfo.blockContent.node.type.spec.content === - "inline*" && - nextBlockInfo.blockContent.node.childCount === 0); + nextBlockInfo.contentKind === "none" || + (nextBlockInfo.contentKind === "inline" && + nextBlockInfo.isContentEmpty); if (nextBlockNotTableAndNoContent) { - const childBlocks = - nextBlockInfo.bnBlock.node.lastChild!.content; return chain() .deleteRange({ - from: nextBlockInfo.bnBlock.beforePos, - to: nextBlockInfo.bnBlock.afterPos, + from: nextBlockInfo.block.beforePos, + to: nextBlockInfo.block.afterPos, }) .insertContentAt( - blockInfo.bnBlock.afterPos, - nextBlockInfo.bnBlock.node.childCount === 2 - ? childBlocks - : null, + blockInfo.block.afterPos, + nextBlockInfo.children?.node.content ?? null, ) .run(); } @@ -942,10 +907,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block: blockContainer } = blockInfo; const { depth } = state.doc.resolve(blockContainer.beforePos); @@ -953,7 +918,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ state.selection.$anchor.parentOffset === 0; const selectionEmpty = state.selection.anchor === state.selection.head; - const blockEmpty = blockContent.node.childCount === 0; + const blockEmpty = blockInfo.isContentEmpty; const blockIndented = depth > 1; if ( @@ -1041,25 +1006,25 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } const selectionEmpty = state.selection.anchor === state.selection.head; - const blockEmpty = blockInfo.blockContent.node.childCount === 0; + const blockEmpty = blockInfo.isContentEmpty; if (!selectionEmpty || !blockEmpty) { return false; } - const $pos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const $pos = tr.doc.resolve(blockInfo.block.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) { + if (tr.doc.resolve(blockInfo.block.afterPos).nodeAfter !== null) { return false; } @@ -1081,9 +1046,9 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (dispatch) { moveBlockOutAndPlaceCaret(tr, { - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, - node: blockInfo.bnBlock.node, + from: blockInfo.block.beforePos, + to: blockInfo.block.afterPos, + node: blockInfo.block.node, insertAt: containerAfterPos, }); tr.scrollIntoView(); @@ -1096,16 +1061,16 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, dispatch, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block: blockContainer } = blockInfo; const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; const selectionEmpty = state.selection.anchor === state.selection.head; - const blockEmpty = blockContent.node.childCount === 0; + const blockEmpty = blockInfo.isContentEmpty; if (selectionAtBlockStart && selectionEmpty && blockEmpty) { const newBlockInsertionPos = blockContainer.afterPos; @@ -1121,7 +1086,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ [ state.schema.nodes["paragraph"].createAndFill() || undefined, - blockInfo.childContainer?.node, + blockInfo.children?.node, ].filter((node) => node !== undefined), )!; @@ -1134,10 +1099,10 @@ export const KeyboardShortcutsExtension = Extension.create<{ // Deletes old block's children, as they have been moved to // the new one. - if (blockInfo.childContainer) { + if (blockInfo.children) { tr.delete( - blockInfo.childContainer.beforePos, - blockInfo.childContainer.afterPos, + blockInfo.children.beforePos, + blockInfo.children.afterPos, ); } } @@ -1152,14 +1117,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, chain }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isWrappedBlock) { + if (!blockInfo.hasContent) { return false; } - const { blockContent } = blockInfo; const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; - const blockEmpty = blockContent.node.childCount === 0; + const blockEmpty = blockInfo.isContentEmpty; if (!blockEmpty) { chain() diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index 8a50e9f2f3..9c514d7ee5 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -6,9 +6,8 @@ * can use it. Not part of the public API: anything here may change in any * release, without a major version bump or a deprecation. * - * The public counterparts stay on the root entrypoint: `isContainerType`, - * `isContainerNode`, and the `children` config types (`ChildrenConfig`, - * `ChildrenAllow`). + * The public counterparts stay on the root entrypoint: `isContainerNode` and + * the `children` config types (`ChildrenConfig`, `ChildrenAllow`). */ // How a `children` config compiles to a ProseMirror content expression, and @@ -20,8 +19,6 @@ export { CONTAINER_NODE_PRIORITY, childrenContentExpression, containerNodePriority, - getChildrenConfig, - isPlaceableAnywhere, resolveChildren, } from "./schema/blocks/children.js"; diff --git a/packages/core/src/pm-nodes/README.md b/packages/core/src/pm-nodes/README.md index be57ead212..4577caf841 100644 --- a/packages/core/src/pm-nodes/README.md +++ b/packages/core/src/pm-nodes/README.md @@ -99,6 +99,10 @@ We use Prosemirror "groups" to help organize this schema. Here is a list of the _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._ +## Relation to container blocks + +The `blockContainer` + `blockGroup` pair predates the container-blocks API, but it behaves exactly like a container block configured `children: { allow: "any", min: 1, whenEmptied: "unwrap", boundary: "open" }`: any block may nest, a nested `blockGroup` disappears when its last child does, and nothing blocks selections at its edge. The code shares what it can (reading children through `BlockInfo.children`, writing them through `childrenHolder.ts`, the group-membership predicates in `children.ts`), but the node pair itself stays: collapsing regular blocks onto the container mechanism would change the ProseMirror tree of every existing document, which is a document-format migration (collaboration data, HTML round-trip, and snapshots all pin the current shape), not a refactor. + ## Example document ```xml diff --git a/packages/core/src/schema/blocks/assertSchemaInvariants.test.ts b/packages/core/src/schema/blocks/assertSchemaInvariants.test.ts new file mode 100644 index 0000000000..5a26acc1d3 --- /dev/null +++ b/packages/core/src/schema/blocks/assertSchemaInvariants.test.ts @@ -0,0 +1,47 @@ +// @vitest-environment node +import { Schema } from "prosemirror-model"; +import { describe, expect, it } from "vite-plus/test"; + +import { assertContainerSchemaInvariants } from "./assertSchemaInvariants.js"; + +// A minimal schema with the structural pieces the invariants inspect: +// `blockContainer`/`blockGroup` regular nesting plus one generated-style +// container node. `extraNodes` lets a case add a deliberately broken node. +function buildSchema(extraNodes: Record = {}) { + return new Schema({ + nodes: { + doc: { content: "blockGroup" }, + paragraph: { content: "text*", group: "blockContent" }, + blockContainer: { + content: "blockContent blockGroup?", + group: "blockGroupChild bnBlock", + }, + blockGroup: { content: "blockGroupChild+", group: "childContainer" }, + callout: { + content: "blockGroupChild+", + group: "bnBlock childContainer blockGroupChild anyContainer", + }, + ...extraNodes, + text: {}, + }, + }); +} + +describe("assertContainerSchemaInvariants", () => { + it("accepts a schema where every childContainer is blockGroup or a block", () => { + expect(() => assertContainerSchemaInvariants(buildSchema())).not.toThrow(); + }); + + // `isContainerNode` treats "childContainer but not bnBlock" as blockGroup's + // exclusive shape; a hand-written node in that state would silently be + // skipped by all container handling, so it must fail at startup instead. + it("rejects a childContainer node that is not in bnBlock", () => { + const schema = buildSchema({ + badHolder: { content: "blockGroupChild+", group: "childContainer" }, + }); + + expect(() => assertContainerSchemaInvariants(schema)).toThrow( + /"badHolder".*childContainer.*not in "bnBlock"/, + ); + }); +}); diff --git a/packages/core/src/schema/blocks/assertSchemaInvariants.ts b/packages/core/src/schema/blocks/assertSchemaInvariants.ts index 3fc2263159..0722fb51da 100644 --- a/packages/core/src/schema/blocks/assertSchemaInvariants.ts +++ b/packages/core/src/schema/blocks/assertSchemaInvariants.ts @@ -2,9 +2,8 @@ import { Fragment, type Schema } from "prosemirror-model"; import { ANY_CONTAINER_GROUP, - getChildrenConfig, + CHILD_CONTAINER_GROUP, isContainerNode, - isPlaceableAnywhere, } from "./children.js"; /** @@ -17,6 +16,7 @@ import { */ export function assertContainerSchemaInvariants(pmSchema: Schema) { assertBlockGroupFillsWithBlockContainer(pmSchema); + assertChildContainersAreBlocks(pmSchema); assertContainersAreFillable(pmSchema); assertAnyContainerGroupMatchesConfigs(pmSchema); } @@ -45,6 +45,29 @@ function assertBlockGroupFillsWithBlockContainer(pmSchema: Schema) { } } +/** + * `isContainerNode` classifies a child-holding node as a container by its + * `bnBlock` membership: every `childContainer` node is either a container + * block (in `bnBlock`) or `blockGroup` (regular blocks' nesting machinery). + * Generated nodes always get this right; a hand-written `childContainer` + * node without `bnBlock` would silently be treated like `blockGroup` + * everywhere, so the mismatch is reported here instead. + */ +function assertChildContainersAreBlocks(pmSchema: Schema) { + for (const type of Object.values(pmSchema.nodes)) { + if ( + type.isInGroup(CHILD_CONTAINER_GROUP) && + !type.isInGroup("bnBlock") && + type.name !== "blockGroup" + ) { + throw new Error( + `BlockNote schema invariant broken: node "${type.name}" is in the "${CHILD_CONTAINER_GROUP}" group but not in "bnBlock". ` + + `Only \`blockGroup\` may hold children without being a block; a hand-written container node must include the "bnBlock" group itself.`, + ); + } + } +} + /** * The `anyContainer` group must contain exactly the container blocks * placeable anywhere. It is what the `allow` container wildcards (`"any"`, @@ -60,8 +83,8 @@ function assertAnyContainerGroupMatchesConfigs(pmSchema: Schema) { } const shouldBeInGroup = - getChildrenConfig(blockConfig) !== undefined && - isPlaceableAnywhere(blockConfig); + blockConfig.children !== undefined && + blockConfig.placement !== "containerOnly"; if (shouldBeInGroup !== type.isInGroup(ANY_CONTAINER_GROUP)) { throw new Error( shouldBeInGroup diff --git a/packages/core/src/schema/blocks/children.ts b/packages/core/src/schema/blocks/children.ts index 3f67820b15..3613da7133 100644 --- a/packages/core/src/schema/blocks/children.ts +++ b/packages/core/src/schema/blocks/children.ts @@ -1,7 +1,6 @@ -import type { Node, NodeType } from "prosemirror-model"; +import type { Node, NodeType, Schema } from "prosemirror-model"; import type { - BlockConfig, ChildrenAllow, ChildrenConfig, PartialBlockNoDefaults, @@ -31,10 +30,82 @@ export const BLOCK_GROUP_CHILD_GROUP = "blockGroupChild"; export const ANY_CONTAINER_GROUP = "anyContainer"; // Whether `type` is a node that holds child blocks directly: a container -// block's own node. (`blockGroup` is in the group too but is regular-block -// nesting machinery, not a container.) +// block's own node. A container is a child-holding node that is itself a +// block; `blockGroup` also holds children but is not a block (it's regular +// blocks' nesting machinery), so the `bnBlock` check excludes it. +// `assertChildContainersAreBlocks` guarantees these two groups classify every +// child-holding node. export function isContainerNode(type: NodeType): boolean { - return type.isInGroup(CHILD_CONTAINER_GROUP) && type.name !== "blockGroup"; + return type.isInGroup(CHILD_CONTAINER_GROUP) && type.isInGroup("bnBlock"); +} + +/** + * The regions a block node resolves into, answered once for every shape so no + * other code asks "which shape am I": + * + * - a container block: its own node holds the children (`childrenHolder.node + * === outer`, offset 0), no content region; + * - a `blockContainer`: a content head at offset 1, and a `blockGroup` + * children holder only once it has children. + * + * `offset` measures from just before `outer` to just before the region's + * node, so with `beforePos` pointing at `outer`, a region's node starts at + * `beforePos + offset` and its inside begins at `beforePos + offset + 1` — + * uniformly across shapes. + */ +export type BlockRegions = { + outer: Node; + content?: { node: Node; offset: number }; + childrenHolder?: { node: Node; offset: number }; +}; + +export function getBlockRegions(node: Node): BlockRegions { + if (isContainerNode(node.type)) { + return { outer: node, childrenHolder: { node, offset: 0 } }; + } + + if (node.type.name === "blockContainer") { + const content = node.firstChild; + if (!content) { + throw new Error( + "blockContainer node has no content node. This is a bug in BlockNote.", + ); + } + const lastChild = node.lastChild; + const holder = + lastChild !== content && + lastChild && + lastChild.type.isInGroup(CHILD_CONTAINER_GROUP) + ? { node: lastChild, offset: 1 + content.nodeSize } + : undefined; + + return { + outer: node, + content: { node: content, offset: 1 }, + ...(holder ? { childrenHolder: holder } : {}), + }; + } + + throw new Error( + `Node "${node.type.name}" is not a block node (container or blockContainer).`, + ); +} + +// The one place a `blockGroup` node gets created around child blocks. +export function createBlockGroup( + schema: Schema, + children: readonly Node[], +): Node { + return schema.nodes["blockGroup"].createChecked({}, children as Node[]); +} + +// Whether a node of `type` can sit where regular blocks go: as a direct child +// of a `blockGroup` or of an `allow: "any"` container. `blockContainer` and +// every anywhere-placeable container qualify; `containerOnly` containers +// don't, and must be dissolved into their children before landing in such a +// slot (see `flattenNonInsertableBlocks`). +export function isBlockGroupInsertable(type: NodeType): boolean { + return type.isInGroup(BLOCK_GROUP_CHILD_GROUP); } // Below `blockContainer`'s priority (50) so PM's `fillBefore` picks @@ -59,24 +130,6 @@ export function containerNodePriority(priority: number | undefined): number { ); } -export function getChildrenConfig(config: { - children?: ChildrenConfig; -}): ChildrenConfig | undefined { - return config.children; -} - -export function isContainerType(config: { - children?: ChildrenConfig; -}): boolean { - return config.children !== undefined; -} - -export function isPlaceableAnywhere(config: { - placement?: BlockConfig["placement"]; -}): boolean { - return config.placement !== "containerOnly"; -} - const resolvedCache = new WeakMap(); export function resolveChildren(children: ChildrenConfig): ResolvedChildren { @@ -119,7 +172,7 @@ function resolveAllow( * Reads the block config off the node's spec. */ export function isSealed(node: Node): boolean { - const children = getChildrenConfig(node.type.spec.blockConfig ?? {}); + const children = node.type.spec.blockConfig?.children; return ( children !== undefined && resolveChildren(children).boundary === "sealed" ); diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index a2784278e7..06bc49caa1 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -23,8 +23,6 @@ import { CHILD_CONTAINER_GROUP, childrenContentExpression, containerNodePriority, - getChildrenConfig, - isPlaceableAnywhere, resolveChildren, } from "./children.js"; import { applyContainerAttributes } from "./containerAttributes.js"; @@ -269,10 +267,10 @@ function buildContainerNode( blockImplementation: BlockImplementation, priority?: number, ) { - const children = getChildrenConfig(blockConfig)!; + const children = blockConfig.children!; const groups = ["bnBlock", CHILD_CONTAINER_GROUP]; - if (isPlaceableAnywhere(blockConfig)) { + if (blockConfig.placement !== "containerOnly") { groups.push(BLOCK_GROUP_CHILD_GROUP, ANY_CONTAINER_GROUP); } @@ -548,7 +546,7 @@ export function addNodeAndExtensionsToSpec< ): LooseBlockSpec { // A `children` config combined with any `content` other than `"none"` is // rejected by `validateChildrenConfigs` when the schema is built. - const childrenConfig = getChildrenConfig(blockConfig); + const childrenConfig = blockConfig.children; const isContainer = childrenConfig !== undefined; @@ -759,7 +757,7 @@ export function createBlockSpec< : extensionsOrCreator : undefined; - const isContainer = getChildrenConfig(blockConfig) !== undefined; + const isContainer = blockConfig.children !== undefined; return { config: blockConfig, diff --git a/packages/core/src/schema/blocks/validateChildren.ts b/packages/core/src/schema/blocks/validateChildren.ts index 942f4eddfe..8d2c6c16bf 100644 --- a/packages/core/src/schema/blocks/validateChildren.ts +++ b/packages/core/src/schema/blocks/validateChildren.ts @@ -1,9 +1,4 @@ -import { - getChildrenConfig, - isContainerType, - isPlaceableAnywhere, - resolveChildren, -} from "./children.js"; +import { resolveChildren } from "./children.js"; import type { ResolvedChildren } from "./children.js"; import type { BlockConfig, ChildrenConfig } from "./types.js"; @@ -23,15 +18,16 @@ export function validateChildrenConfigs( blockConfigs: Record, ) { const isContainerBlockType = (blockType: string) => - !!blockConfigs[blockType] && isContainerType(blockConfigs[blockType]); + !!blockConfigs[blockType] && blockConfigs[blockType].children !== undefined; const acceptCtx = { isContainerBlockType, isPlaceableAnywhereType: (blockType: string) => - !!blockConfigs[blockType] && isPlaceableAnywhere(blockConfigs[blockType]), + !!blockConfigs[blockType] && + blockConfigs[blockType].placement !== "containerOnly", }; for (const [type, config] of Object.entries(blockConfigs)) { - const children = getChildrenConfig(config); + const children = config.children; if (!children) { // `placement: "anywhere"` is the documented default for every block, so @@ -282,7 +278,7 @@ export function validateContainerRunsBefore( runsBefore: Record, ) { for (const [type, config] of Object.entries(blockConfigs)) { - if (!isContainerType(config)) { + if (config.children === undefined) { continue; } @@ -293,7 +289,7 @@ export function validateContainerRunsBefore( if (other === "default" || !(other in blockConfigs)) { continue; } - if (!isContainerType(blockConfigs[other])) { + if (blockConfigs[other].children === undefined) { throw new Error( `Invalid \`runsBefore\` for container block "${type}": it names "${other}", which is a regular block, not a container block. ` + "Container block nodes always register below regular ones, so a container can never be ordered before a regular block. " + @@ -319,7 +315,7 @@ function validateContainerOnlyIsReachable( ) { const accepted = new Set(); for (const config of Object.values(blockConfigs)) { - const children = getChildrenConfig(config); + const children = config.children; if (!children) { continue; } @@ -333,7 +329,7 @@ function validateContainerOnlyIsReachable( } for (const [type, config] of Object.entries(blockConfigs)) { - if (!isPlaceableAnywhere(config) && !accepted.has(type)) { + if (config.placement === "containerOnly" && !accepted.has(type)) { fail( type, `it declares \`placement: "containerOnly"\`, but no container's \`children.allow\` array includes it, so it could never be inserted.`, @@ -355,7 +351,7 @@ function validateNoCycles( // A container that allows regular blocks can always be filled with a plain // paragraph, so it never forces recursion. Only container-only lists do. const requiredContainers = (type: string): string[] => { - const children = getChildrenConfig(blockConfigs[type]); + const children = blockConfigs[type].children; if (!children) { return []; } diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 967a65bb5e..e64132cb28 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -4,7 +4,6 @@ // `@blocknote/core/internal` (see `src/internal.ts`). Only the question a // block author asks, "is this a container?", belongs here; the config types // come from `./blocks/types.js` below. -export { isContainerType } from "./blocks/children.js"; export * from "./blocks/createSpec.js"; export * from "./blocks/internal.js"; export * from "./blocks/types.js"; diff --git a/packages/react/src/schema/ReactBlockSpec.tsx b/packages/react/src/schema/ReactBlockSpec.tsx index 0d49889901..85bd65f377 100644 --- a/packages/react/src/schema/ReactBlockSpec.tsx +++ b/packages/react/src/schema/ReactBlockSpec.tsx @@ -12,7 +12,6 @@ import { Extension, ExtensionFactoryInstance, ExtractBlockConfigFromConfigOrCreator, - isContainerType, mergeCSSClasses, nodeToBlock, Props, @@ -249,7 +248,7 @@ export function createReactBlockSpec< implementation: { ...blockImplementation, toExternalHTML(block, editor, context) { - const isContainer = isContainerType(blockConfig); + const isContainer = blockConfig.children !== undefined; const BlockContent = (blockImplementation.toExternalHTML || blockImplementation.render) as FC; const output = renderToDOMSpec((refCB) => { @@ -300,7 +299,7 @@ export function createReactBlockSpec< // Container-ness is fixed per spec, so the node-view component // can be chosen once. Each variant uses only the hooks and // wrappers it needs. - const isContainer = isContainerType(blockConfig); + const isContainer = blockConfig.children !== undefined; const BlockContent = blockImplementation.render as FC; const blockContentDOMAttributes = this.blockContentDOMAttributes; @@ -464,7 +463,7 @@ export function createReactBlockSpec< return nodeView; } else { - const isContainer = isContainerType(blockConfig); + const isContainer = blockConfig.children !== undefined; const BlockContent = blockImplementation.render as FC; const output = renderToDOMSpec((refCB) => { const content = ( diff --git a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts index 34d60aa6bf..b8a4405285 100644 --- a/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts +++ b/packages/xl-ai/src/api/formats/html-blocks/collabUpdate.test.ts @@ -10,7 +10,7 @@ import { BlockNoteEditor, expandPMRangeToWords, - getBlockInfo, + getBlockInfoFromNode, getNodeById, } from "@blocknote/core"; import type { ForkYDocExtension } from "@blocknote/core/yjs"; @@ -79,12 +79,13 @@ function createCollabEditor(text: string) { */ function selectWholeFirstBlock(editor: BlockNoteEditor) { const id = editor.document[0].id; - const info = getBlockInfo(getNodeById(id, editor.prosemirrorState.doc)!); - if (!info.isWrappedBlock) { + const posInfo = getNodeById(id, editor.prosemirrorState.doc)!; + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + if (!info.hasContent) { throw new Error("not a block container"); } - const from = info.blockContent.beforePos + 1; - const to = info.blockContent.afterPos - 1; + const from = info.content.beforePos + 1; + const to = info.content.afterPos - 1; editor.transact((tr) => { tr.setSelection(TextSelection.create(tr.doc, from, to)); diff --git a/packages/xl-ai/src/prosemirror/agent.test.ts b/packages/xl-ai/src/prosemirror/agent.test.ts index d2a7d9178b..bc3392941c 100644 --- a/packages/xl-ai/src/prosemirror/agent.test.ts +++ b/packages/xl-ai/src/prosemirror/agent.test.ts @@ -1,7 +1,7 @@ import { BlockNoteEditor, expandPMRangeToWords, - getBlockInfo, + getBlockInfoFromNode, getNodeById, } from "@blocknote/core"; import { Fragment, Slice } from "prosemirror-model"; @@ -38,12 +38,12 @@ describe.skip("getStepsAsAgent", () => { const doc = editor.prosemirrorState.doc; // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; - const block = getBlockInfo(blockPos); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a container"); } - const contentStart = block.blockContent.beforePos; + const contentStart = block.content.beforePos; // Create a ReplaceStep that replaces "Hello" with "Hi" const from = contentStart + 1; // +1 to skip the initial position @@ -71,13 +71,13 @@ describe.skip("getStepsAsAgent", () => { const doc = editor.prosemirrorState.doc; // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; - const block = getBlockInfo(blockPos); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a container"); } const tr = editor.prosemirrorState.tr.setNodeMarkup( - block.blockContent.beforePos, + block.content.beforePos, editor.pmSchema.nodes.heading, ); @@ -97,13 +97,13 @@ describe.skip("getStepsAsAgent", () => { const doc = editor.prosemirrorState.doc; // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; - const block = getBlockInfo(blockPos); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a container"); } const tr = editor.prosemirrorState.tr.setNodeMarkup( - block.blockContent.beforePos, + block.content.beforePos, undefined, { textAlignment: "right", @@ -127,17 +127,17 @@ describe.skip("getStepsAsAgent", () => { const doc = editor.prosemirrorState.doc; // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; - const block = getBlockInfo(blockPos); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a container"); } const step = new ReplaceStep( - block.blockContent.beforePos, - block.blockContent.beforePos + 3, + block.content.beforePos, + block.content.beforePos + 3, // for simplicity, we're not actually changing the node type and content, but we just use the existing document // as replacement content - doc.slice(block.blockContent.beforePos, block.blockContent.beforePos + 3), + doc.slice(block.content.beforePos, block.content.beforePos + 3), ); const tr = new Transform(doc); @@ -156,12 +156,12 @@ describe.skip("getStepsAsAgent", () => { // Get the position of the content in the paragraph const blockPos = getNodeById("1", doc)!; - const block = getBlockInfo(blockPos); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a container"); } - const contentStart = block.blockContent.beforePos; + const contentStart = block.content.beforePos; // Create two ReplaceSteps // 1. Replace "Hello" with "Hi" diff --git a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts index edd8a3b1bb..24b0e712ee 100644 --- a/packages/xl-ai/src/prosemirror/rebaseTool.test.ts +++ b/packages/xl-ai/src/prosemirror/rebaseTool.test.ts @@ -1,4 +1,8 @@ -import { BlockNoteEditor, getBlockInfo, getNodeById } from "@blocknote/core"; +import { + BlockNoteEditor, + getBlockInfoFromNode, + getNodeById, +} from "@blocknote/core"; import { expect, it } from "vite-plus/test"; import { AttributionMarksExtension } from "./AttributionMarks.js"; import { getApplySuggestionsTr, rebaseTool } from "./rebaseTool.js"; @@ -20,21 +24,21 @@ function getExampleEditorWithSuggestions() { const blockPos = getNodeById("1", editor.prosemirrorState.doc)!; - const block = getBlockInfo(blockPos); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a container"); } editor.transact((tr) => { tr.addMark( - block.blockContent.beforePos + 1, - block.blockContent.beforePos + 6, + block.content.beforePos + 1, + block.content.beforePos + 6, editor.pmSchema.mark("deletion", { id: 1 }), ); tr.addMark( - block.blockContent.beforePos + 6, - block.blockContent.beforePos + 8, + block.content.beforePos + 6, + block.content.beforePos + 8, editor.pmSchema.mark("insertion", { id: 2 }), ); }); @@ -54,13 +58,13 @@ it("should be able to apply changes to a clean doc (use invertMap)", async () => const blockPos = getNodeById("1", cleaned.doc)!; - const block = getBlockInfo(blockPos); + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); - if (!block.isWrappedBlock) { + if (!block.hasContent) { throw new Error("Block is not a container"); } - const start = block.blockContent.beforePos + 1; + const start = block.content.beforePos + 1; const end = start + 2; expect(cleaned.doc.textBetween(start, end)).toBe("Hi"); @@ -83,13 +87,13 @@ it("should be able to apply changes to a clean doc (use rebaseTr)", async () => const blockPos = getNodeById("1", cleaned.doc)!; - const block = getBlockInfo(blockPos); + const block = getBlockInfoFromNode(blockPos.node, blockPos.posBeforeNode); - if (!block.isWrappedBlock) { + if (!block.hasContent) { throw new Error("Block is not a container"); } - const start = block.blockContent.beforePos + 1; + const start = block.content.beforePos + 1; const end = start + 2; expect(cleaned.doc.textBetween(start, end)).toBe("Hi"); diff --git a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts index 8bbcb29315..8b004c9130 100644 --- a/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts +++ b/packages/xl-ai/src/testUtil/cases/combinedOperationsTestCases.ts @@ -1,5 +1,5 @@ -// import { BlockNoteEditor, getBlockInfo, getNodeById } from "@blocknote/core"; -import { getBlockInfo, getNodeById } from "@blocknote/core"; +// import { BlockNoteEditor, getBlockInfoFromNode, getNodeById } from "@blocknote/core"; +import { getBlockInfoFromNode, getNodeById } from "@blocknote/core"; import { getEditorWithFormattingAndMentions } from "./editors/formattingAndMentions.js"; import { DocumentOperationTestCase } from "./index.js"; @@ -46,13 +46,13 @@ export const combinedOperationsTestCases: DocumentOperationTestCase[] = [ getTestSelection: (editor) => { const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; - const block = getBlockInfo(posInfo); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a block container"); } return { - from: block.blockContent.beforePos + 1, - to: block.blockContent.beforePos + 1 + "Hello".length, + from: block.content.beforePos + 1, + to: block.content.beforePos + 1 + "Hello".length, }; }, userPrompt: diff --git a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts index 3d4d25f152..d483314f06 100644 --- a/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts +++ b/packages/xl-ai/src/testUtil/cases/updateOperationTestCases.ts @@ -1,4 +1,8 @@ -import { BlockNoteEditor, getBlockInfo, getNodeById } from "@blocknote/core"; +import { + BlockNoteEditor, + getBlockInfoFromNode, + getNodeById, +} from "@blocknote/core"; import { AIExtension } from "../../AIExtension.js"; import { getEditorWithBlockFormatting } from "./editors/blockFormatting.js"; import { getEditorWithFormattingAndMentions } from "./editors/formattingAndMentions.js"; @@ -40,13 +44,13 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ ], getTestSelection: (editor: BlockNoteEditor) => { const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; - const block = getBlockInfo(posInfo); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a block container"); } return { - from: block.blockContent.beforePos + 1, - to: block.blockContent.beforePos + 1 + "Hello".length, + from: block.content.beforePos + 1, + to: block.content.beforePos + 1 + "Hello".length, }; }, userPrompt: "translate to German", @@ -67,14 +71,14 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ ], getTestSelection: (editor: BlockNoteEditor) => { const posInfo = getNodeById("ref1", editor.prosemirrorState.doc)!; - const block = getBlockInfo(posInfo); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a block container"); } // 'ello, world! Dow are yo' return { - from: block.blockContent.beforePos + 2, - to: block.blockContent.afterPos - 3, + from: block.content.beforePos + 2, + to: block.content.afterPos - 3, }; }, userPrompt: "fix spelling", @@ -736,12 +740,12 @@ export const updateOperationTestCases: DocumentOperationTestCase[] = [ userPrompt: "turn into list (update existing blocks)", getTestSelection(editor) { const posInfo = getNodeById("ref2", editor.prosemirrorState.doc)!; - const block = getBlockInfo(posInfo); - if (!block.isWrappedBlock) { + const block = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); + if (!block.hasContent) { throw new Error("Block is not a block container"); } return { - from: block.blockContent.beforePos + 1, + from: block.content.beforePos + 1, to: editor.prosemirrorState.doc.content.size, }; }, diff --git a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts index 6f26bce4e8..f12865903e 100644 --- a/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts +++ b/packages/xl-multi-column/src/extensions/DropCursor/multiColumnHandleDropPlugin.ts @@ -3,7 +3,7 @@ import { UniqueID, createExtension, fragmentToBlocks, - getBlockInfoWithManualOffset, + getBlockInfoFromNode, isContainerNode, nodeToBlock, } from "@blocknote/core"; @@ -27,7 +27,7 @@ export function createMultiColumnHandleDropPlugin( return false; // Let ProseMirror handle the drop (e.g. outside editor bounds) } - const blockInfo = getBlockInfoWithManualOffset( + const blockInfo = getBlockInfoFromNode( edgePos.node, edgePos.posBeforeNode, ); @@ -49,7 +49,7 @@ export function createMultiColumnHandleDropPlugin( // 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 $target = view.state.doc.resolve(blockInfo.block.beforePos); const targetInHorizontalContainer = $target.node().type.name === "columnList"; @@ -62,7 +62,7 @@ export function createMultiColumnHandleDropPlugin( // A column is a pure container: its `children` node is the column // node itself. const columnChildren = - blockInfo.childContainer?.node ?? blockInfo.bnBlock.node; + blockInfo.children?.node ?? blockInfo.block.node; columnChildren.forEach((child) => { if (!draggedBlockIds.has(child.attrs.id)) { allTargetChildrenDragged = false; @@ -85,7 +85,7 @@ export function createMultiColumnHandleDropPlugin( // containers (like `column`) that wrap the actual blocks, or plain // blocks spliced in directly. const targetIsChildContainer = isContainerNode( - blockInfo.bnBlock.node.type, + blockInfo.block.node.type, ); // Normalize column widths to average of 1 @@ -122,7 +122,7 @@ export function createMultiColumnHandleDropPlugin( } } - const targetColumnId = blockInfo.bnBlock.node.attrs.id; + const targetColumnId = blockInfo.block.node.attrs.id; // The target itself is one of the dragged blocks (only possible // when the container holds plain blocks directly) - the dragged @@ -221,7 +221,7 @@ export function createMultiColumnHandleDropPlugin( }); } else { // Create new columnList with blocks as columns - const block = nodeToBlock(blockInfo.bnBlock.node, view.state.doc); + const block = nodeToBlock(blockInfo.block.node, view.state.doc); // The user is dropping next to one of the blocks being dragged - do // nothing. From a1769631709e152308e3550f2956a5c82518f1bf Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 25 Aug 2026 17:27:38 +0200 Subject: [PATCH 2/2] refactor(core): address review feedback on the BlockInfo API surface - Rename `insertBlocks` placements "start"/"end" to "first-child"/"last-child" (clearer about nesting; docs, tests, and jsdoc updated). - containerNav helpers now take a BlockInfo and share one shape: `descendToFirst/LastInsertionPos` both return `number | null` and accept `SealOpts` instead of the `crossedSeal` flag (callers that need "was a seal the only blocker" ask with a second seal-blind call); `getFirstLeafBlock` takes and returns BlockInfo. `ascendToInsertablePos` and `getAncestorContainers` stay position-based on purpose (their inputs are arbitrary gap positions, not blocks) and now say so in jsdoc. - Inline single-use indirections: `canMerge` + `mergeBlocks` fold into `mergeBlocksCommand` (the boolean guard makes the defensive throws statically unreachable, so they are gone); `movedNodeType` folds into `checkPlacementIsValid`; `seedRefillChildren` folds into `refillContainer`; `seedDefaultChildren` + `createContainerChildrenNode` fold into `blockToNode`; the `descend` closure folds into `getInsertionPos`. - Drop the unreachable exotic-shape guard in `getInsertionPos`: `getBlockRegions` already throws for bnBlock nodes that are neither containers nor blockContainer. - Split the inline/table-content conversion layer out of `blockToNode.ts` into `contentToNodes.ts`. - Add jsdocs to `fixContainersById` and `flattenNonInsertableBlocks`. --- .../custom-schemas/container-blocks.mdx | 4 +- .../reference/editor/manipulating-content.mdx | 6 +- .../commands/insertBlocks/insertBlocks.ts | 31 +- .../insertBlocks/insertPlacement.test.ts | 58 +- .../commands/mergeBlocks/mergeBlocks.ts | 111 ++-- .../commands/moveBlocks/moveBlocks.ts | 47 +- .../commands/updateBlock/updateBlock.ts | 4 +- .../containers/containerNav.ts | 125 +++-- .../containers/fixContainer.ts | 37 +- .../html/util/serializeBlocksExternalHTML.ts | 2 +- .../html/util/serializeBlocksInternalHTML.ts | 2 +- .../src/api/nodeConversions/blockToNode.ts | 506 ++---------------- .../src/api/nodeConversions/contentToNodes.ts | 338 ++++++++++++ packages/core/src/editor/BlockNoteEditor.ts | 4 +- .../core/src/editor/managers/BlockManager.ts | 2 +- .../core/src/editor/managers/StyleManager.ts | 2 +- .../KeyboardShortcutsExtension.ts | 73 ++- packages/core/src/index.ts | 1 + .../src/schema/inlineContent/createSpec.ts | 2 +- 19 files changed, 670 insertions(+), 685 deletions(-) create mode 100644 packages/core/src/api/nodeConversions/contentToNodes.ts diff --git a/docs/content/docs/features/custom-schemas/container-blocks.mdx b/docs/content/docs/features/custom-schemas/container-blocks.mdx index 0d0f1364c4..3e0af58565 100644 --- a/docs/content/docs/features/custom-schemas/container-blocks.mdx +++ b/docs/content/docs/features/custom-schemas/container-blocks.mdx @@ -169,8 +169,8 @@ editor.insertBlocks([{ type: "paragraph" }], calloutId, "before"); editor.insertBlocks([{ type: "paragraph" }], calloutId, "after"); // Nested inside it, as its first or last child: -editor.insertBlocks([{ type: "paragraph" }], calloutId, "start"); -editor.insertBlocks([{ type: "paragraph" }], calloutId, "end"); +editor.insertBlocks([{ type: "paragraph" }], calloutId, "first-child"); +editor.insertBlocks([{ type: "paragraph" }], calloutId, "last-child"); ``` The nested placements are what addresses a container with no children to point at. A `min: 0` container that is currently empty has no child block to insert before or after. Whether a block fits is answered by the schema, so it's your `children` config that decides. diff --git a/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx index 873a186f17..5cde60c29c 100644 --- a/docs/content/docs/reference/editor/manipulating-content.mdx +++ b/docs/content/docs/reference/editor/manipulating-content.mdx @@ -141,11 +141,11 @@ editor.forEachBlock((block) => { insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" | "start" | "end" = "before" + placement: "before" | "after" | "first-child" | "last-child" = "before" ): void ``` -Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"start"` and `"end"` nest them inside it, as its first or last children. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container). +Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"first-child"` and `"last-child"` nest them inside it. See [Inserting into a container](/docs/features/custom-schemas/container-blocks#inserting-into-a-container). ```typescript // Insert a paragraph before an existing block @@ -169,7 +169,7 @@ editor.insertBlocks( editor.insertBlocks( [{ type: "paragraph", content: "Nested paragraph" }], "container-block-id", - "end", + "last-child", ); ``` diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index f869f9c3d8..158e12ffd7 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -8,7 +8,6 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; -import { isContainerNode } from "../../../../schema/blocks/children.js"; import { getBlockInfoFromNode } from "../../../getBlockInfoFromPos.js"; import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; @@ -20,15 +19,14 @@ import { } from "../../containers/containerNav.js"; /** - * Where blocks go relative to a reference block. `"before"`/`"after"` make them - * siblings of it; `"start"`/`"end"` nest them inside it, as its first or last - * children. + * Where blocks go relative to a reference block. `"before"`/`"after"` make + * them siblings of it; `"first-child"`/`"last-child"` nest them inside it. * * The nested placements cover containers that have no children to point at: * a `min: 0` container that is currently empty has no child block to insert * before or after. */ -export type BlockPlacement = "before" | "after" | "start" | "end"; +export type BlockPlacement = "before" | "after" | "first-child" | "last-child"; /** * Resolves a `placement` against a reference block into the document position @@ -52,11 +50,6 @@ export function getInsertionPos( ): { pos: number; wrapIn?: NodeType } | null { const { node, posBeforeNode } = reference; - const descend = (holder: { node: Node; beforePos: number }) => - placement === "start" - ? descendToFirstInsertionPos(holder, nodeType) - : descendToLastInsertionPos(holder, nodeType).pos; - if (placement === "before" || placement === "after") { const pos = placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize; @@ -67,18 +60,16 @@ export function getInsertionPos( : null; } - // Neither a container nor a `blockContainer` (possible only for exotic - // hand-written specs): nothing can nest inside it. - if (!isContainerNode(node.type) && node.type.name !== "blockContainer") { - return null; - } - const info = getBlockInfoFromNode(node, posBeforeNode); if (info.children) { - // The descent helpers report sealed boundaries but this caller ignores - // them: an explicit `insertBlocks` placement is an intentional crossing. - const pos = descend(info.children); + // The descent helpers can stop at sealed boundaries but this caller lets + // them cross: an explicit `insertBlocks` placement is an intentional + // crossing. + const pos = + placement === "first-child" + ? descendToFirstInsertionPos(info, nodeType) + : descendToLastInsertionPos(info, nodeType); return pos === null ? null : { pos }; } @@ -133,7 +124,7 @@ export function insertBlocks< `Cannot insert a block of type "${blocksToInsert[0].type ?? "paragraph"}" ` + (placement === "before" || placement === "after" ? `${placement} block with ID ${id}: its parent does not accept it.` - : `at the ${placement} of block with ID ${id}: the block does not accept it as a child.`), + : `as the ${placement} of block with ID ${id}: the block does not accept it as a child.`), ); } diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts index 132eafe1b1..36dc2419e5 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -25,9 +25,9 @@ const container = (type: string, config: Record) => const schema = BlockNoteSchema.create().extend({ blockSpecs: { ...defaultBlockSpecs, - // Why `"start"`/`"end"` exist: a container that may legally hold nothing - // has no child block to address, so `"before"`/`"after"` cannot reach - // inside it. + // Why `"first-child"`/`"last-child"` exist: a container that may legally + // hold nothing has no child block to address, so `"before"`/`"after"` + // cannot reach inside it. box: container("box", { content: "none", children: { allow: "any", min: 0 }, @@ -68,7 +68,7 @@ beforeEach(() => { ]); }); -describe('insertBlocks "start" / "end"', () => { +describe('insertBlocks "first-child" / "last-child"', () => { it("inserts into a childless container", () => { editor.replaceBlocks(editor.document, [ { id: "b-0", type: "box" }, @@ -76,8 +76,16 @@ describe('insertBlocks "start" / "end"', () => { ]); expect(editor.getBlock("b-0")!.children).toHaveLength(0); - editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start"); - editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end"); + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "b-0", + "first-child", + ); + editor.insertBlocks( + [{ id: "last", type: "paragraph" }], + "b-0", + "last-child", + ); expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ "first", @@ -95,8 +103,16 @@ describe('insertBlocks "start" / "end"', () => { { id: "trailing", type: "paragraph", content: "" }, ]); - editor.insertBlocks([{ id: "first", type: "paragraph" }], "b-0", "start"); - editor.insertBlocks([{ id: "last", type: "paragraph" }], "b-0", "end"); + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "b-0", + "first-child", + ); + editor.insertBlocks( + [{ id: "last", type: "paragraph" }], + "b-0", + "last-child", + ); expect(editor.getBlock("b-0")!.children.map((child) => child.id)).toEqual([ "first", @@ -120,8 +136,16 @@ describe('insertBlocks "start" / "end"', () => { // `grid` itself only accepts `cell`s, so both placements have to find the // leading/trailing cell rather than giving up. - editor.insertBlocks([{ id: "first", type: "paragraph" }], "g-0", "start"); - editor.insertBlocks([{ id: "last", type: "paragraph" }], "g-0", "end"); + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "g-0", + "first-child", + ); + editor.insertBlocks( + [{ id: "last", type: "paragraph" }], + "g-0", + "last-child", + ); const grid = editor.getBlock("g-0")!; expect(grid.children[0].children.map((child: any) => child.id)).toContain( @@ -137,8 +161,16 @@ describe('insertBlocks "start" / "end"', () => { { id: "p-0", type: "paragraph", content: "Paragraph 0" }, ]); - editor.insertBlocks([{ id: "existing", type: "paragraph" }], "p-0", "end"); - editor.insertBlocks([{ id: "first", type: "paragraph" }], "p-0", "start"); + editor.insertBlocks( + [{ id: "existing", type: "paragraph" }], + "p-0", + "last-child", + ); + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "p-0", + "first-child", + ); expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ "first", @@ -157,7 +189,7 @@ describe('insertBlocks "start" / "end"', () => { ]); expect(() => - editor.insertBlocks([{ type: "paragraph" }], "s-0", "end"), + editor.insertBlocks([{ type: "paragraph" }], "s-0", "last-child"), ).toThrow(/does not accept it as a child/); }); diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index 0be289e479..372deedeab 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,75 +1,11 @@ import { EditorState } from "prosemirror-state"; import { - BlockInfo, getBlockInfoAt, getLastDescendantBlockInfo, getPrevBlockInfo, } from "../../../getBlockInfoFromPos.js"; -const canMerge = (prevBlockInfo: BlockInfo, nextBlockInfo: BlockInfo) => { - return ( - prevBlockInfo.hasContent && - prevBlockInfo.contentKind === "inline" && - !prevBlockInfo.isContentEmpty && - nextBlockInfo.hasContent && - nextBlockInfo.contentKind === "inline" - ); -}; - -const mergeBlocks = ( - state: EditorState, - dispatch: ((args?: any) => any) | undefined, - prevBlockInfo: BlockInfo, - nextBlockInfo: BlockInfo, -) => { - // Un-nests all children of the next block. - if (!nextBlockInfo.hasContent) { - throw new Error( - `Attempted to merge block at position ${nextBlockInfo.block.beforePos} into previous block at position ${prevBlockInfo.block.beforePos}, but next block is not a block container`, - ); - } - - // Removes a level of nesting all children of the next block by 1 level, if it contains both content and block - // group nodes. - if (nextBlockInfo.children) { - const childBlocksStart = state.doc.resolve( - nextBlockInfo.children.childrenStart, - ); - const childBlocksEnd = state.doc.resolve( - nextBlockInfo.children.childrenEnd, - ); - const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd); - - if (dispatch) { - const pos = state.doc.resolve(nextBlockInfo.block.beforePos); - state.tr.lift(childBlocksRange!, pos.depth); - } - } - - // Deletes the boundary between the two blocks. Can be thought of as - // removing the closing tags of the first block and the opening tags of the - // second one to stitch them together. - if (dispatch) { - if (!prevBlockInfo.hasContent) { - throw new Error( - `Attempted to merge block at position ${nextBlockInfo.block.beforePos} into previous block at position ${prevBlockInfo.block.beforePos}, but previous block is not a block container`, - ); - } - - // 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.contentEnd, nextBlockInfo.contentStart), - ); - } - - return true; -}; - export const mergeBlocksCommand = (posBetweenBlocks: number) => ({ @@ -90,14 +26,57 @@ export const mergeBlocksCommand = return false; } + // The block we merge into is the last descendant of the previous block: + // visually, that's the block directly above the boundary. const bottomNestedBlockInfo = getLastDescendantBlockInfo( state.doc, prevBlockInfo, ); - if (!canMerge(bottomNestedBlockInfo, nextBlockInfo)) { + // Only inline-content blocks can merge, and merging into an empty block + // is handled elsewhere (by deleting the empty block instead). Merging + // into or out of container blocks (columnLists, callouts, ...) is + // intentionally unsupported; the container-boundary Backspace/Delete + // branches in `KeyboardShortcutsExtension` handle those cases by moving + // blocks across the boundary instead of merging their content. + if ( + !bottomNestedBlockInfo.hasContent || + bottomNestedBlockInfo.contentKind !== "inline" || + bottomNestedBlockInfo.isContentEmpty || + !nextBlockInfo.hasContent || + nextBlockInfo.contentKind !== "inline" + ) { return false; } - return mergeBlocks(state, dispatch, bottomNestedBlockInfo, nextBlockInfo); + // Removes a level of nesting all children of the next block by 1 level, if + // it contains both content and block group nodes. + if (nextBlockInfo.children) { + const childBlocksStart = state.doc.resolve( + nextBlockInfo.children.childrenStart, + ); + const childBlocksEnd = state.doc.resolve( + nextBlockInfo.children.childrenEnd, + ); + const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd); + + if (dispatch) { + const pos = state.doc.resolve(nextBlockInfo.block.beforePos); + state.tr.lift(childBlocksRange!, pos.depth); + } + } + + // Deletes the boundary between the two blocks. Can be thought of as + // removing the closing tags of the first block and the opening tags of the + // second one to stitch them together. + if (dispatch) { + dispatch( + state.tr.delete( + bottomNestedBlockInfo.contentEnd, + nextBlockInfo.contentStart, + ), + ); + } + + return true; }; diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index b8fcf61f23..1219e62696 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -1,4 +1,3 @@ -import { NodeType } from "prosemirror-model"; import { NodeSelection, Selection, @@ -212,8 +211,20 @@ function checkPlacementIsValid( editor: BlockNoteEditor, referenceBlock: Block, placement: "before" | "after", - nodeType: NodeType, + movedBlock: Block, ): boolean { + // The PM node type to validate the destination against: the first flattened + // block's own node type when it's a container (e.g. `callout`), otherwise + // the generic `blockContainer` wrapper. Mirrors what `moveBlocks` inserts + // (`flattenNonInsertableBlocks` + `insertBlocks`), so the placement + // pre-check agrees with the insertion instead of always assuming a regular + // block. + const first = flattenNonInsertableBlocks([movedBlock], editor.pmSchema)[0]; + const firstType = first?.type ? editor.pmSchema.nodes[first.type] : undefined; + const nodeType = firstType?.isInGroup("bnBlock") + ? firstType + : editor.pmSchema.nodes["blockContainer"]; + return editor.transact((tr) => { const posInfo = getNodeById(referenceBlock.id, tr.doc); if (!posInfo) { @@ -223,22 +234,6 @@ function checkPlacementIsValid( }); } -// The PM node type `insertBlocks` validates a destination against: the first -// flattened block's own node type when it's a container (e.g. `callout`), -// otherwise the generic `blockContainer` wrapper. Mirrors what `moveBlocks` -// inserts (`flattenNonInsertableBlocks` + `insertBlocks`), so the placement -// pre-check agrees with the insertion instead of always assuming a regular -// block. -function movedNodeType( - editor: BlockNoteEditor, - block: Block, -): NodeType { - const blockContainer = editor.pmSchema.nodes["blockContainer"]; - const first = flattenNonInsertableBlocks([block], editor.pmSchema)[0]; - const nodeType = first?.type ? editor.pmSchema.nodes[first.type] : undefined; - return nodeType?.isInGroup("bnBlock") ? nodeType : blockContainer; -} - // Gets the placement for moving a block up. This has 3 cases: // 1. If the block has a previous sibling without children, the placement is // before it. @@ -251,7 +246,7 @@ function movedNodeType( // the block is already at the top of the document. function getMoveUpPlacement( editor: BlockNoteEditor, - nodeType: NodeType, + movedBlock: Block, prevBlock?: Block, parentBlock?: Block, ): @@ -278,11 +273,11 @@ function getMoveUpPlacement( return undefined; } - if (!checkPlacementIsValid(editor, referenceBlock, placement, nodeType)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) { const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveUpPlacement( editor, - nodeType, + movedBlock, placement === "after" ? referenceBlock : editor.getPrevBlock(referenceBlock), @@ -305,7 +300,7 @@ function getMoveUpPlacement( // the block is already at the bottom of the document. function getMoveDownPlacement( editor: BlockNoteEditor, - nodeType: NodeType, + movedBlock: Block, nextBlock?: Block, parentBlock?: Block, ): @@ -332,11 +327,11 @@ function getMoveDownPlacement( return undefined; } - if (!checkPlacementIsValid(editor, referenceBlock, placement, nodeType)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) { const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveDownPlacement( editor, - nodeType, + movedBlock, placement === "before" ? referenceBlock : editor.getNextBlock(referenceBlock), @@ -366,7 +361,7 @@ export function moveBlocksUp( const moveUpPlacement = getMoveUpPlacement( editor, - movedNodeType(editor, sourceBlock), + sourceBlock, editor.getPrevBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); @@ -419,7 +414,7 @@ export function moveBlocksDown( const moveDownPlacement = getMoveDownPlacement( editor, - movedNodeType(editor, firstMovedBlock), + firstMovedBlock, editor.getNextBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index cf1dc003b1..b34c2ec4ba 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -21,11 +21,11 @@ import { type BlockInfo, getBlockInfoAt, } from "../../../getBlockInfoFromPos.js"; +import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { - blockToNode, inlineContentToNodes, tableContentToNodes, -} from "../../../nodeConversions/blockToNode.js"; +} from "../../../nodeConversions/contentToNodes.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; import { getBlockSchema, getPmSchema } from "../../../pmUtil.js"; diff --git a/packages/core/src/api/blockManipulation/containers/containerNav.ts b/packages/core/src/api/blockManipulation/containers/containerNav.ts index da18e99edc..05054583fb 100644 --- a/packages/core/src/api/blockManipulation/containers/containerNav.ts +++ b/packages/core/src/api/blockManipulation/containers/containerNav.ts @@ -1,6 +1,10 @@ import type { Node, NodeType } from "prosemirror-model"; import { isContainerNode, isSealed } from "../../../schema/blocks/children.js"; +import { + type BlockInfo, + getBlockInfoFromNode, +} from "../../getBlockInfoFromPos.js"; /** * Seal handling for the navigation helpers below. By default the helpers @@ -12,82 +16,102 @@ import { isContainerNode, isSealed } from "../../../schema/blocks/children.js"; type SealOpts = { respectSealed?: boolean }; /** - * Walks the trailing edge of `holder` (a children holder: `BlockInfo`'s - * `children`, or a container's own `block` entry — anything with a node and - * the position before it), descending through nested containers, to the - * deepest position where `nodeType` fits. - * - * The walk ignores seals but reports them: `crossedSeal` is true when a - * sealed container sat on the path, `holder` itself included. Callers decide - * the policy — the block manipulation API uses `pos` as-is (an explicit - * placement is an intentional crossing); gesture code treats - * `pos !== null && crossedSeal` as "blocked by a seal" (select the sealed - * container instead of entering it). One walk answers both questions because - * the descent follows a single path (each container's last child), so the - * seal-blind and seal-respecting positions are the same — the modes differ - * only in whether a seal sat on the way. + * Walks the trailing edge of a block's children, descending through nested + * containers, to the deepest position where `nodeType` fits. Returns `null` + * when the block has no children holder, when nothing on the trailing edge + * accepts the type, or (with `respectSealed`) when a sealed container sits on + * the path. */ export function descendToLastInsertionPos( - holder: { node: Node; beforePos: number }, + info: BlockInfo, nodeType: NodeType, -): { pos: number | null; crossedSeal: boolean } { - const { node, beforePos } = holder; - const sealed = isSealed(node); - const endPos = beforePos + 1 + node.content.size; - if (node.contentMatchAt(node.childCount).matchType(nodeType)) { - return { pos: endPos, crossedSeal: sealed }; + opts?: SealOpts, +): number | null { + const children = info.children; + if (!children) { + return null; + } + if (opts?.respectSealed && isSealed(children.node)) { + return null; } - const lastChild = node.lastChild; + if ( + children.node.contentMatchAt(children.node.childCount).matchType(nodeType) + ) { + return children.childrenEnd; + } + const lastChild = children.node.lastChild; if (lastChild && isContainerNode(lastChild.type)) { - const inner = descendToLastInsertionPos( - { node: lastChild, beforePos: endPos - lastChild.nodeSize }, + return descendToLastInsertionPos( + getBlockInfoFromNode( + lastChild, + children.childrenEnd - lastChild.nodeSize, + ), nodeType, + opts, ); - return { pos: inner.pos, crossedSeal: sealed || inner.crossedSeal }; } - return { pos: null, crossedSeal: sealed }; + return null; } -// The leading-edge counterpart. No seal reporting: its only callers are API -// code, which crosses seals by construction. +/** + * The leading-edge counterpart of `descendToLastInsertionPos`: the shallowest + * position on the block's leading edge where `nodeType` fits as a first + * child, descending through nested containers. + */ export function descendToFirstInsertionPos( - holder: { node: Node; beforePos: number }, + info: BlockInfo, nodeType: NodeType, + opts?: SealOpts, ): number | null { - const { node, beforePos } = holder; - const startPos = beforePos + 1; - if (node.contentMatchAt(0).matchType(nodeType)) { - return startPos; + const children = info.children; + if (!children) { + return null; + } + if (opts?.respectSealed && isSealed(children.node)) { + return null; } - const firstChild = node.firstChild; + if (children.node.contentMatchAt(0).matchType(nodeType)) { + return children.childrenStart; + } + const firstChild = children.node.firstChild; if (firstChild && isContainerNode(firstChild.type)) { return descendToFirstInsertionPos( - { node: firstChild, beforePos: startPos }, + getBlockInfoFromNode(firstChild, children.childrenStart), nodeType, + opts, ); } return null; } +/** + * Resolves a block to its first leaf block: the block itself when it is not a + * container, otherwise the first leaf of its first child. Returns `null` for + * an empty container, or (with `respectSealed`) when reaching the leaf would + * cross a sealed container's boundary. + */ export function getFirstLeafBlock( - container: Node, - containerBeforePos: number, + info: BlockInfo, opts?: SealOpts, -): { node: Node; beforePos: number } | null { +): BlockInfo | null { + const children = info.children; + if (!children || !isContainerNode(info.block.node.type)) { + // Not a container: the block is its own first leaf. + return info; + } // With `respectSealed`, a sealed container's leaf blocks are not reachable // from outside. - if (opts?.respectSealed && isSealed(container)) { + if (opts?.respectSealed && isSealed(info.block.node)) { return null; } - const firstChild = container.firstChild; + const firstChild = children.node.firstChild; if (!firstChild) { return null; } - const firstChildBeforePos = containerBeforePos + 1; - if (isContainerNode(firstChild.type)) { - return getFirstLeafBlock(firstChild, firstChildBeforePos, opts); - } - return { node: firstChild, beforePos: firstChildBeforePos }; + return getFirstLeafBlock( + getBlockInfoFromNode(firstChild, children.childrenStart), + opts, + ); } /** @@ -95,6 +119,10 @@ export function getFirstLeafBlock( * `side` picks which edge of each climbed container to land on: `"before"` for * moves that put a block above the containers it leaves (Backspace move-out), * `"after"` for moves that put it below them (Enter-exit). + * + * Position-based rather than `BlockInfo`-based (unlike the descend/leaf + * helpers above) because its input is an arbitrary gap position — a point + * between blocks, not a block. */ export function ascendToInsertablePos( doc: Node, @@ -122,6 +150,13 @@ export function ascendToInsertablePos( } } +/** + * The container ancestors of a position, outermost last, each with its block + * id and resolution depth. Used to re-run container repair (`fixContainersById`) + * on every container a mutation may have emptied. Position-based for the same + * reason as `ascendToInsertablePos`: selections and mapped positions are the + * natural inputs. + */ export function getAncestorContainers( doc: Node, pos: number, diff --git a/packages/core/src/api/blockManipulation/containers/fixContainer.ts b/packages/core/src/api/blockManipulation/containers/fixContainer.ts index b579f94183..b86557c330 100644 --- a/packages/core/src/api/blockManipulation/containers/fixContainer.ts +++ b/packages/core/src/api/blockManipulation/containers/fixContainer.ts @@ -14,7 +14,8 @@ import { resolveChildren, } from "../../../schema/blocks/children.js"; import type { ResolvedChildren } from "../../../schema/blocks/children.js"; -import { seedRefillChildren } from "../../nodeConversions/blockToNode.js"; +import type { PartialBlock } from "../../../blocks/defaultBlocks.js"; +import { blockToNode } from "../../nodeConversions/blockToNode.js"; import { getNodeById } from "../../nodeUtil.js"; // Defined in `children.ts` (it answers a schema-level question); re-exported @@ -106,8 +107,7 @@ export function fixContainer(tr: Transaction, containerPos: number) { if (config.whenEmptied === "unwrap") { unwrapContainer(tr, containerPos, node.type, config); } else { - // `blockConfig` is set whenever `config` is. - refillContainer(tr, containerPos, node.type, config, blockConfig!.type); + refillContainer(tr, containerPos, node.type, config); } } @@ -199,7 +199,6 @@ function refillContainer( containerPos: number, type: NodeType, config: ResolvedChildren, - blockType: string, ) { const info = getContainerInfo(tr, containerPos, type); if (!info) { @@ -219,12 +218,15 @@ function refillContainer( return; } - const seeds = seedRefillChildren( - blockType, - tr.doc.type.schema, - survivors.length, - config.min, - ); + // The refill seeds are the unconsumed tail of the container's `default` + // (`default[survivors.length..min-1]`), each converted exactly like an + // inserted block. Empty when the container has no `default`; the remainder + // is padded with empty fill below. + const seeds = (config.default ?? []) + .slice(survivors.length, config.min) + .map((child) => + blockToNode(child as PartialBlock, tr.doc.type.schema), + ); if (seeds.length === 0) { // No `default` to seed from, so empty children are the right fill, and @@ -250,6 +252,14 @@ function refillContainer( tr.replaceWith(childrenStart, childrenEnd, content); } +/** + * Runs `fixContainer` on each of the given containers, looked up by ID in + * `tr`'s current doc. Containers are repaired deepest-first so that an inner + * repair (e.g. a column emptying out) is observed by the outer container's + * repair (e.g. its columnList unwrapping) in the same pass. Containers that + * no longer exist by the time their turn comes are skipped — an earlier + * repair may have removed them. + */ export function fixContainersById( tr: Transaction, containers: { id: string; depth: number }[], @@ -265,6 +275,13 @@ export function fixContainersById( }); } +/** + * Replaces blocks that can't live directly in a `blockGroup` (container-only + * blocks like `column`) with their flattened children, so the result can be + * inserted anywhere regular blocks go. A replaced block's inline content + * survives as a paragraph preceding its children; blocks that are already + * insertable pass through unchanged. + */ export function flattenNonInsertableBlocks< T extends { type?: string; content?: unknown; children?: T[] }, >(blocks: T[], pmSchema: Schema): T[] { diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts index 36acaed470..f3ba27954b 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksExternalHTML.ts @@ -14,7 +14,7 @@ import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, tableContentToNodes, -} from "../../../nodeConversions/blockToNode.js"; +} from "../../../nodeConversions/contentToNodes.js"; import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js"; /** diff --git a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts index 319cafd3fc..191904571f 100644 --- a/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts +++ b/packages/core/src/api/exporters/html/util/serializeBlocksInternalHTML.ts @@ -13,7 +13,7 @@ import { UnreachableCaseError } from "../../../../util/typescript.js"; import { inlineContentToNodes, tableContentToNodes, -} from "../../../nodeConversions/blockToNode.js"; +} from "../../../nodeConversions/contentToNodes.js"; import { nodeToCustomInlineContent } from "../../../nodeConversions/nodeToBlock.js"; export function serializeInlineContentInternalHTML< diff --git a/packages/core/src/api/nodeConversions/blockToNode.ts b/packages/core/src/api/nodeConversions/blockToNode.ts index caeef05d2d..2450a09d73 100644 --- a/packages/core/src/api/nodeConversions/blockToNode.ts +++ b/packages/core/src/api/nodeConversions/blockToNode.ts @@ -1,357 +1,19 @@ -import { - Attrs, - Fragment, - Mark, - Node, - NodeType, - Schema, -} from "@tiptap/pm/model"; +import { Attrs, Fragment, Node, NodeType, Schema } from "@tiptap/pm/model"; import UniqueID from "../../extensions/tiptap-extensions/UniqueID/UniqueID.js"; -import type { - InlineContentSchema, - PartialCustomInlineContentFromConfig, - PartialInlineContent, - PartialLink, - PartialTableContent, - StyleSchema, - StyledText, -} from "../../schema"; +import type { StyleSchema } from "../../schema"; import type { PartialBlock } from "../../blocks/defaultBlocks"; -import { - isPartialLinkInlineContent, - isStyledTextInlineContent, -} from "../../schema/inlineContent/types.js"; // `isContainerNode` comes from `children.js` directly (rather than via its -// `fixContainer.js` re-export) because `fixContainer.js` imports the seeding -// machinery below; going through it would create an import cycle. +// `fixContainer.js` re-export) because `fixContainer.js` imports `blockToNode` +// below; going through it would create an import cycle. import { createBlockGroup, isContainerNode, resolveChildren, } from "../../schema/blocks/children.js"; -import { getColspan, isPartialTableCell } from "../../util/table.js"; -import { UnreachableCaseError } from "../../util/typescript.js"; -import { getAbsoluteTableCells } from "../blockManipulation/tables/tables.js"; -import { - getBlockSchema, - getStyleSchema, - isPlainContentNodeType, -} from "../pmUtil.js"; - -/** - * Convert a StyledText inline element to a - * prosemirror text node with the appropriate marks - */ -function styledTextToNodes( - styledText: StyledText, - schema: Schema, - styleSchema: T, - blockType?: string, -): Node[] { - const marks: Mark[] = []; - - for (const [style, value] of Object.entries(styledText.styles || {})) { - const config = styleSchema[style]; - if (!config) { - throw new Error(`style ${style} not found in styleSchema`); - } - - if (config.propSchema === "boolean") { - if (value) { - marks.push(schema.mark(style)); - } - } else if (config.propSchema === "string") { - if (value) { - marks.push(schema.mark(style, { stringValue: value })); - } - } else { - throw new UnreachableCaseError(config.propSchema); - } - } - - // Backwards compat: old BlockNote JSON may carry formatting marks (e.g. bold) - // on a block whose content type is now "plain". Those marks aren't allowed on - // the node, and would make `createChecked` throw when the block is assembled. - // Drop them here (for plain blocks only) — comment/suggestion (annotation) - // marks are allowed and kept by `allowedMarks`. - const allowedMarks = - blockType && - schema.nodes[blockType] && - isPlainContentNodeType(schema, schema.nodes[blockType]) - ? [...schema.nodes[blockType].allowedMarks(marks)] - : marks; - - // Plain content nodes hold raw text — including newlines — - // rather than inline content, so they can't contain `hardBreak` nodes. Keep - // newlines as text characters for them instead of splitting into hard breaks. - const parseHardBreaks = - !blockType || !isPlainContentNodeType(schema, schema.nodes[blockType]); - - if (!parseHardBreaks) { - return styledText.text.length > 0 - ? [schema.text(styledText.text, allowedMarks)] - : []; - } - - return ( - styledText.text - // Splits text & line breaks. - .split(/(\n)/g) - // If the content ends with a line break, an empty string is added to the - // end, which this removes. - .filter((text) => text.length > 0) - // Converts text & line breaks to nodes. - .map((text) => { - if (text === "\n") { - return schema.nodes["hardBreak"].createChecked(); - } else { - return schema.text(text, allowedMarks); - } - }) - ); -} - -/** - * Converts a Link inline content element to - * prosemirror text nodes with the appropriate marks - */ -function linkToNodes( - link: PartialLink, - schema: Schema, - styleSchema: StyleSchema, -): Node[] { - const linkMark = schema.marks.link.create({ - href: link.href, - }); - - return styledTextArrayToNodes(link.content, schema, styleSchema).map( - (node) => { - if (node.type.name === "text") { - return node.mark([...node.marks, linkMark]); - } - - if (node.type.name === "hardBreak") { - return node; - } - throw new Error("unexpected node type"); - }, - ); -} - -/** - * Converts an array of StyledText inline content elements to - * prosemirror text nodes with the appropriate marks - */ -function styledTextArrayToNodes( - content: string | StyledText[], - schema: Schema, - styleSchema: S, - blockType?: string, -): Node[] { - const nodes: Node[] = []; - - if (typeof content === "string") { - nodes.push( - ...styledTextToNodes( - { type: "text", text: content, styles: {} }, - schema, - styleSchema, - blockType, - ), - ); - return nodes; - } - - for (const styledText of content) { - nodes.push( - ...styledTextToNodes(styledText, schema, styleSchema, blockType), - ); - } - return nodes; -} - -/** - * converts an array of inline content elements to prosemirror nodes - */ -export function inlineContentToNodes< - I extends InlineContentSchema, - S extends StyleSchema, ->( - blockContent: PartialInlineContent, - schema: Schema, - blockType?: string, - styleSchema: S = getStyleSchema(schema), -): Node[] { - const nodes: Node[] = []; - - for (const content of blockContent) { - if (typeof content === "string") { - nodes.push( - ...styledTextArrayToNodes(content, schema, styleSchema, blockType), - ); - } else if (isPartialLinkInlineContent(content)) { - nodes.push(...linkToNodes(content, schema, styleSchema)); - } else if (isStyledTextInlineContent(content)) { - nodes.push( - ...styledTextArrayToNodes([content], schema, styleSchema, blockType), - ); - } else { - nodes.push( - blockOrInlineContentToContentNode(content, schema, styleSchema), - ); - } - } - return nodes; -} - -/** - * converts an array of inline content elements to prosemirror nodes - */ -export function tableContentToNodes< - I extends InlineContentSchema, - S extends StyleSchema, ->( - tableContent: PartialTableContent, - schema: Schema, - styleSchema: StyleSchema = getStyleSchema(schema), -): Node[] { - const rowNodes: Node[] = []; - // Header rows and columns are used to determine the type of the cell - // If headerRows is 1, then the first row is a header row - const headerRows = new Array(tableContent.headerRows ?? 0).fill(true); - // If headerCols is 1, then the first column is a header column - const headerCols = new Array(tableContent.headerCols ?? 0).fill(true); - - const columnWidths: (number | undefined)[] = tableContent.columnWidths ?? []; - - for (let rowIndex = 0; rowIndex < tableContent.rows.length; rowIndex++) { - const row = tableContent.rows[rowIndex]; - const columnNodes: Node[] = []; - const isHeaderRow = headerRows[rowIndex]; - for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) { - const cell = row.cells[cellIndex]; - const isHeaderCol = headerCols[cellIndex]; - /** - * The attributes of the cell to apply to the node - */ - const attrs: Attrs | undefined = undefined; - /** - * The content of the cell to apply to the node - */ - let content: Fragment | Node | readonly Node[] | null = null; - - // Colwidths are absolutely referenced to the table, so we need to resolve the relative cell index to the absolute cell index - const absoluteCellIndex = getAbsoluteTableCells( - { - row: rowIndex, - col: cellIndex, - }, - { type: "table", content: tableContent } as any, - ); - - // Assume the column width is the width of the cell at the absolute cell index - let colwidth: (number | undefined)[] | null = columnWidths[ - absoluteCellIndex.col - ] - ? [columnWidths[absoluteCellIndex.col]] - : null; - - if (!cell) { - // No-op - } else if (typeof cell === "string") { - content = schema.text(cell); - } else if (isPartialTableCell(cell)) { - if (cell.content) { - content = inlineContentToNodes( - cell.content, - schema, - "tableParagraph", - styleSchema, - ); - } - const colspan = getColspan(cell); - - if (colspan > 1) { - // If the cell has a > 1 colspan, we need to get the column width for each cell in the span - colwidth = new Array(colspan).fill(false).map((_, i) => { - // Starting from the absolute column index, get the column width for each cell in the span - return columnWidths[absoluteCellIndex.col + i] ?? undefined; - }); - } - } else { - content = inlineContentToNodes( - cell, - schema, - "tableParagraph", - styleSchema, - ); - } - - const cellNode = schema.nodes[ - isHeaderCol || isHeaderRow ? "tableHeader" : "tableCell" - ].createChecked( - { - ...(isPartialTableCell(cell) ? cell.props : {}), - colwidth, - }, - schema.nodes["tableParagraph"].createChecked(attrs, content), - ); - columnNodes.push(cellNode); - } - - const rowNode = schema.nodes["tableRow"].createChecked({}, columnNodes); - rowNodes.push(rowNode); - } - return rowNodes; -} - -function blockOrInlineContentToContentNode( - block: - | PartialBlock - | PartialCustomInlineContentFromConfig, - schema: Schema, - styleSchema: StyleSchema, -) { - let contentNode: Node; - let type = block.type; - - // TODO: needed? came from previous code - if (type === undefined) { - type = "paragraph"; - } - - if (!schema.nodes[type]) { - throw new Error(`node type ${type} not found in schema`); - } - - if (!block.content) { - contentNode = schema.nodes[type].createChecked(block.props); - } else if (typeof block.content === "string") { - const nodes = inlineContentToNodes( - [block.content], - schema, - type, - styleSchema, - ); - contentNode = schema.nodes[type].createChecked(block.props, nodes); - } else if (Array.isArray(block.content)) { - const nodes = inlineContentToNodes( - block.content, - schema, - type, - styleSchema, - ); - contentNode = schema.nodes[type].createChecked(block.props, nodes); - } else if (block.content.type === "tableContent") { - const nodes = tableContentToNodes(block.content, schema, styleSchema); - contentNode = schema.nodes[type].createChecked(block.props, nodes); - } else { - throw new UnreachableCaseError(block.content.type); - } - return contentNode; -} +import { getBlockSchema, getStyleSchema } from "../pmUtil.js"; +import { blockOrInlineContentToContentNode } from "./contentToNodes.js"; const EMPTY_SEEDING: ReadonlySet = new Set(); @@ -387,104 +49,6 @@ function withGeneratedIds(node: Node): Node { ); } -function seedDefaultChildren( - blockType: string, - schema: Schema, - styleSchema: StyleSchema, - seedingTypes: ReadonlySet, -): Node[] | undefined { - const blockSchemaConfig = getBlockSchema(schema)[blockType]; - const childrenConfig = blockSchemaConfig?.children; - - if (!childrenConfig) { - return undefined; - } - - const defaultChildren = resolveChildren(childrenConfig).default; - if (!defaultChildren || defaultChildren.length === 0) { - return undefined; - } - - if (seedingTypes.has(blockType)) { - throw new Error( - `Seeding "${blockType}" ends up seeding it again (${[...seedingTypes, blockType].join(" -> ")}). ` + - "Give the cyclic default explicit children, or remove the self-reference.", - ); - } - - const nextSeeding = new Set(seedingTypes).add(blockType); - return defaultChildren.map((child) => - blockToNode( - child as PartialBlock, - schema, - styleSchema, - nextSeeding, - ), - ); -} - -/** - * The nodes `whenEmptied: "refill"` appends when a container's non-empty - * children drop below `min`: the unconsumed tail of its `default` - * (`default[from..min-1]`), each converted exactly like an inserted block. - * Empty when the container has no `default`; the caller pads any remainder - * with empty fill. - */ -export function seedRefillChildren( - blockType: string, - schema: Schema, - from: number, - min: number, -): Node[] { - const blockConfig = getBlockSchema(schema)[blockType]; - const children = blockConfig?.children; - const defaultChildren = children - ? resolveChildren(children).default - : undefined; - if (!defaultChildren) { - return []; - } - - return defaultChildren - .slice(from, min) - .map((child) => blockToNode(child as PartialBlock, schema)); -} - -function createContainerChildrenNode( - blockType: string, - type: NodeType, - schema: Schema, - styleSchema: StyleSchema, - seedingTypes: ReadonlySet, - attrs: Attrs | null = null, -): Node { - const seeded = seedDefaultChildren( - blockType, - schema, - styleSchema, - seedingTypes, - ); - - if (!seeded && unwrapsWhenEmptied(blockType, schema)) { - // Fill so the node satisfies its own content expression for the - // `node.check()` that runs before the repair pass (e.g. in - // `removeAndInsertBlocks`); that pass then unwraps the still-empty - // container. Without the fill, a `min >= 1` unwrap container with no - // `default` produces a schema-invalid node and `check()` throws. - return type.createAndFill(attrs) ?? type.create(attrs); - } - - const node = type.createAndFill(attrs, seeded); - if (!node) { - throw new Error( - `Cannot create block "${blockType}": its \`default\` children don't fit its \`children\` config ` + - `(it accepts \`${type.spec.content}\`).`, - ); - } - - return node; -} - // Passes explicit children straight through for unwrap-on-empty containers // (fill would be undone by the next repair pass) and for unfittable content // (let `node.check()` report it). An empty child list is the exception: it @@ -562,16 +126,54 @@ export function blockToNode( ); } - return withGeneratedIds( - createContainerChildrenNode( - block.type, - type, - schema, - styleSchema, - seedingTypes, - attrs, - ), - ); + // No explicit `children`: seed the container from its `children` config's + // `default`, converting each default child exactly like an inserted block. + // `seedingTypes` tracks the container types currently being seeded so a + // cyclic `default` (a container whose default children seed it again) + // fails loudly instead of recursing forever. + const childrenConfig = getBlockSchema(schema)[block.type]?.children; + const defaultChildren = childrenConfig + ? resolveChildren(childrenConfig).default + : undefined; + + let seeded: Node[] | undefined; + if (defaultChildren && defaultChildren.length > 0) { + if (seedingTypes.has(block.type)) { + throw new Error( + `Seeding "${block.type}" ends up seeding it again (${[...seedingTypes, block.type].join(" -> ")}). ` + + "Give the cyclic default explicit children, or remove the self-reference.", + ); + } + + const nextSeeding = new Set(seedingTypes).add(block.type); + seeded = defaultChildren.map((child) => + blockToNode( + child as PartialBlock, + schema, + styleSchema, + nextSeeding, + ), + ); + } + + if (!seeded && unwrapsWhenEmptied(block.type, schema)) { + // Fill so the node satisfies its own content expression for the + // `node.check()` that runs before the repair pass (e.g. in + // `removeAndInsertBlocks`); that pass then unwraps the still-empty + // container. Without the fill, a `min >= 1` unwrap container with no + // `default` produces a schema-invalid node and `check()` throws. + return withGeneratedIds(type.createAndFill(attrs) ?? type.create(attrs)); + } + + const node = type.createAndFill(attrs, seeded); + if (!node) { + throw new Error( + `Cannot create block "${block.type}": its \`default\` children don't fit its \`children\` config ` + + `(it accepts \`${type.spec.content}\`).`, + ); + } + + return withGeneratedIds(node); } else { throw new Error( `block type ${block.type} doesn't match blockContent or bnBlock group`, diff --git a/packages/core/src/api/nodeConversions/contentToNodes.ts b/packages/core/src/api/nodeConversions/contentToNodes.ts new file mode 100644 index 0000000000..4c097a8bb1 --- /dev/null +++ b/packages/core/src/api/nodeConversions/contentToNodes.ts @@ -0,0 +1,338 @@ +import { Attrs, Fragment, Mark, Node, Schema } from "@tiptap/pm/model"; + +import type { + InlineContentSchema, + PartialCustomInlineContentFromConfig, + PartialInlineContent, + PartialLink, + PartialTableContent, + StyleSchema, + StyledText, +} from "../../schema"; + +import type { PartialBlock } from "../../blocks/defaultBlocks"; +import { + isPartialLinkInlineContent, + isStyledTextInlineContent, +} from "../../schema/inlineContent/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"; + +/** + * Convert a StyledText inline element to a + * prosemirror text node with the appropriate marks + */ +function styledTextToNodes( + styledText: StyledText, + schema: Schema, + styleSchema: T, + blockType?: string, +): Node[] { + const marks: Mark[] = []; + + for (const [style, value] of Object.entries(styledText.styles || {})) { + const config = styleSchema[style]; + if (!config) { + throw new Error(`style ${style} not found in styleSchema`); + } + + if (config.propSchema === "boolean") { + if (value) { + marks.push(schema.mark(style)); + } + } else if (config.propSchema === "string") { + if (value) { + marks.push(schema.mark(style, { stringValue: value })); + } + } else { + throw new UnreachableCaseError(config.propSchema); + } + } + + // Backwards compat: old BlockNote JSON may carry formatting marks (e.g. bold) + // on a block whose content type is now "plain". Those marks aren't allowed on + // the node, and would make `createChecked` throw when the block is assembled. + // Drop them here (for plain blocks only) — comment/suggestion (annotation) + // marks are allowed and kept by `allowedMarks`. + const allowedMarks = + blockType && + schema.nodes[blockType] && + isPlainContentNodeType(schema, schema.nodes[blockType]) + ? [...schema.nodes[blockType].allowedMarks(marks)] + : marks; + + // Plain content nodes hold raw text — including newlines — + // rather than inline content, so they can't contain `hardBreak` nodes. Keep + // newlines as text characters for them instead of splitting into hard breaks. + const parseHardBreaks = + !blockType || !isPlainContentNodeType(schema, schema.nodes[blockType]); + + if (!parseHardBreaks) { + return styledText.text.length > 0 + ? [schema.text(styledText.text, allowedMarks)] + : []; + } + + return ( + styledText.text + // Splits text & line breaks. + .split(/(\n)/g) + // If the content ends with a line break, an empty string is added to the + // end, which this removes. + .filter((text) => text.length > 0) + // Converts text & line breaks to nodes. + .map((text) => { + if (text === "\n") { + return schema.nodes["hardBreak"].createChecked(); + } else { + return schema.text(text, allowedMarks); + } + }) + ); +} + +/** + * Converts a Link inline content element to + * prosemirror text nodes with the appropriate marks + */ +function linkToNodes( + link: PartialLink, + schema: Schema, + styleSchema: StyleSchema, +): Node[] { + const linkMark = schema.marks.link.create({ + href: link.href, + }); + + return styledTextArrayToNodes(link.content, schema, styleSchema).map( + (node) => { + if (node.type.name === "text") { + return node.mark([...node.marks, linkMark]); + } + + if (node.type.name === "hardBreak") { + return node; + } + throw new Error("unexpected node type"); + }, + ); +} + +/** + * Converts an array of StyledText inline content elements to + * prosemirror text nodes with the appropriate marks + */ +function styledTextArrayToNodes( + content: string | StyledText[], + schema: Schema, + styleSchema: S, + blockType?: string, +): Node[] { + const nodes: Node[] = []; + + if (typeof content === "string") { + nodes.push( + ...styledTextToNodes( + { type: "text", text: content, styles: {} }, + schema, + styleSchema, + blockType, + ), + ); + return nodes; + } + + for (const styledText of content) { + nodes.push( + ...styledTextToNodes(styledText, schema, styleSchema, blockType), + ); + } + return nodes; +} + +/** + * converts an array of inline content elements to prosemirror nodes + */ +export function inlineContentToNodes< + I extends InlineContentSchema, + S extends StyleSchema, +>( + blockContent: PartialInlineContent, + schema: Schema, + blockType?: string, + styleSchema: S = getStyleSchema(schema), +): Node[] { + const nodes: Node[] = []; + + for (const content of blockContent) { + if (typeof content === "string") { + nodes.push( + ...styledTextArrayToNodes(content, schema, styleSchema, blockType), + ); + } else if (isPartialLinkInlineContent(content)) { + nodes.push(...linkToNodes(content, schema, styleSchema)); + } else if (isStyledTextInlineContent(content)) { + nodes.push( + ...styledTextArrayToNodes([content], schema, styleSchema, blockType), + ); + } else { + nodes.push( + blockOrInlineContentToContentNode(content, schema, styleSchema), + ); + } + } + return nodes; +} + +/** + * converts an array of inline content elements to prosemirror nodes + */ +export function tableContentToNodes< + I extends InlineContentSchema, + S extends StyleSchema, +>( + tableContent: PartialTableContent, + schema: Schema, + styleSchema: StyleSchema = getStyleSchema(schema), +): Node[] { + const rowNodes: Node[] = []; + // Header rows and columns are used to determine the type of the cell + // If headerRows is 1, then the first row is a header row + const headerRows = new Array(tableContent.headerRows ?? 0).fill(true); + // If headerCols is 1, then the first column is a header column + const headerCols = new Array(tableContent.headerCols ?? 0).fill(true); + + const columnWidths: (number | undefined)[] = tableContent.columnWidths ?? []; + + for (let rowIndex = 0; rowIndex < tableContent.rows.length; rowIndex++) { + const row = tableContent.rows[rowIndex]; + const columnNodes: Node[] = []; + const isHeaderRow = headerRows[rowIndex]; + for (let cellIndex = 0; cellIndex < row.cells.length; cellIndex++) { + const cell = row.cells[cellIndex]; + const isHeaderCol = headerCols[cellIndex]; + /** + * The attributes of the cell to apply to the node + */ + const attrs: Attrs | undefined = undefined; + /** + * The content of the cell to apply to the node + */ + let content: Fragment | Node | readonly Node[] | null = null; + + // Colwidths are absolutely referenced to the table, so we need to resolve the relative cell index to the absolute cell index + const absoluteCellIndex = getAbsoluteTableCells( + { + row: rowIndex, + col: cellIndex, + }, + { type: "table", content: tableContent } as any, + ); + + // Assume the column width is the width of the cell at the absolute cell index + let colwidth: (number | undefined)[] | null = columnWidths[ + absoluteCellIndex.col + ] + ? [columnWidths[absoluteCellIndex.col]] + : null; + + if (!cell) { + // No-op + } else if (typeof cell === "string") { + content = schema.text(cell); + } else if (isPartialTableCell(cell)) { + if (cell.content) { + content = inlineContentToNodes( + cell.content, + schema, + "tableParagraph", + styleSchema, + ); + } + const colspan = getColspan(cell); + + if (colspan > 1) { + // If the cell has a > 1 colspan, we need to get the column width for each cell in the span + colwidth = new Array(colspan).fill(false).map((_, i) => { + // Starting from the absolute column index, get the column width for each cell in the span + return columnWidths[absoluteCellIndex.col + i] ?? undefined; + }); + } + } else { + content = inlineContentToNodes( + cell, + schema, + "tableParagraph", + styleSchema, + ); + } + + const cellNode = schema.nodes[ + isHeaderCol || isHeaderRow ? "tableHeader" : "tableCell" + ].createChecked( + { + ...(isPartialTableCell(cell) ? cell.props : {}), + colwidth, + }, + schema.nodes["tableParagraph"].createChecked(attrs, content), + ); + columnNodes.push(cellNode); + } + + const rowNode = schema.nodes["tableRow"].createChecked({}, columnNodes); + rowNodes.push(rowNode); + } + return rowNodes; +} + +/** + * Converts a block's (or custom inline content element's) `content` field to a + * `blockContent` (or custom inline content) prosemirror node. + */ +export function blockOrInlineContentToContentNode( + block: + | PartialBlock + | PartialCustomInlineContentFromConfig, + schema: Schema, + styleSchema: StyleSchema, +): Node { + let contentNode: Node; + let type = block.type; + + // TODO: needed? came from previous code + if (type === undefined) { + type = "paragraph"; + } + + if (!schema.nodes[type]) { + throw new Error(`node type ${type} not found in schema`); + } + + if (!block.content) { + contentNode = schema.nodes[type].createChecked(block.props); + } else if (typeof block.content === "string") { + const nodes = inlineContentToNodes( + [block.content], + schema, + type, + styleSchema, + ); + contentNode = schema.nodes[type].createChecked(block.props, nodes); + } else if (Array.isArray(block.content)) { + const nodes = inlineContentToNodes( + block.content, + schema, + type, + styleSchema, + ); + contentNode = schema.nodes[type].createChecked(block.props, nodes); + } else if (block.content.type === "tableContent") { + const nodes = tableContentToNodes(block.content, schema, styleSchema); + contentNode = schema.nodes[type].createChecked(block.props, nodes); + } else { + throw new UnreachableCaseError(block.content.type); + } + return contentNode; +} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 5f0f34a746..851b9bebdc 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1063,8 +1063,8 @@ export class BlockNoteEditor< * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next - * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children. Throws an error if - * the `referenceBlock` (or its parent, for `"before"`/`"after"`) doesn't accept the blocks there. + * (`"after"`) sibling, or nested inside it as its first (`"first-child"`) or last (`"last-child"`) children. Throws + * an error if the `referenceBlock` (or its parent, for `"before"`/`"after"`) doesn't accept the blocks there. */ public insertBlocks( blocksToInsert: PartialBlock[], diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index a33bfcab4b..ca4c62555e 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -154,7 +154,7 @@ export class BlockManager< * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next - * (`"after"`) sibling, or nested inside it as its first (`"start"`) or last (`"end"`) children. + * (`"after"`) sibling, or nested inside it as its first (`"first-child"`) or last (`"last-child"`) children. */ public insertBlocks( blocksToInsert: PartialBlock[], diff --git a/packages/core/src/editor/managers/StyleManager.ts b/packages/core/src/editor/managers/StyleManager.ts index 123ac6187b..cc842cc44e 100644 --- a/packages/core/src/editor/managers/StyleManager.ts +++ b/packages/core/src/editor/managers/StyleManager.ts @@ -1,6 +1,6 @@ import { getMarkRange } from "@tiptap/core"; import { insertContentAt } from "../../api/blockManipulation/insertContentAt.js"; -import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; +import { inlineContentToNodes } from "../../api/nodeConversions/contentToNodes.js"; import { BlockSchema, InlineContentSchema, diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index c8fb459a58..6df88628d7 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -23,6 +23,7 @@ import { splitBlockCommand } from "../../../api/blockManipulation/commands/split import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { getBlockInfoAt, + getBlockInfoFromNode, getBlockInfoFromSelection, getLastDescendantBlockInfo, getNextBlockInfo, @@ -227,19 +228,21 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - const descent = descendToLastInsertionPos( - prevBlockInfo.block, - state.schema.nodes["blockContainer"], + const blockContainerType = state.schema.nodes["blockContainer"]; + const insertionPos = descendToLastInsertionPos( + prevBlockInfo, + blockContainerType, + { respectSealed: true }, ); - const insertionPos = descent.crossedSeal ? null : descent.pos; if (insertionPos === null) { - // When only a sealed boundary blocked the descent, the - // container can't be entered, so it's selected instead, and a - // second Backspace deletes it explicitly. A container with - // nowhere a `blockContainer` can land falls through as before. + // When only a sealed boundary blocked the descent (a seal-blind + // walk does find a slot), the container can't be entered, so + // it's selected instead, and a second Backspace deletes it + // explicitly. A container with nowhere a `blockContainer` can + // land falls through as before. if ( - descent.pos !== null && - descent.crossedSeal && + descendToLastInsertionPos(prevBlockInfo, blockContainerType) !== + null && NodeSelection.isSelectable(prevBlockInfo.block.node) ) { if (dispatch) { @@ -315,22 +318,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ ? $containerPos.nodeBefore : null; - // A gesture move respects seals: a descent that crossed one is - // treated as having nowhere to land. - const descent = prevSibling + // A gesture move respects seals: a descent blocked by one has + // nowhere to land. + const insertionPos = prevSibling ? descendToLastInsertionPos( - { - node: prevSibling, - beforePos: containerBeforePos - prevSibling.nodeSize, - }, + getBlockInfoFromNode( + prevSibling, + containerBeforePos - prevSibling.nodeSize, + ), blockContainerType, + { respectSealed: true }, ) - : null; - - const insertionPos = descent - ? descent.crossedSeal - ? null - : descent.pos : ascendToInsertablePos( tr.doc, containerBeforePos, @@ -627,20 +625,18 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - const firstLeaf = getFirstLeafBlock( - nextBlockInfo.block.node, - nextBlockInfo.block.beforePos, - { respectSealed: true }, - ); + const firstLeaf = getFirstLeafBlock(nextBlockInfo, { + respectSealed: true, + }); if (!firstLeaf) { return false; } if (dispatch) { moveBlockOutAndPlaceCaret(tr, { - from: firstLeaf.beforePos, - to: firstLeaf.beforePos + firstLeaf.node.nodeSize, - node: firstLeaf.node, + from: firstLeaf.block.beforePos, + to: firstLeaf.block.afterPos, + node: firstLeaf.block.node, insertAt: blockInfo.block.afterPos, }); @@ -701,20 +697,19 @@ export const KeyboardShortcutsExtension = Extension.create<{ // The block to pull in: the next node itself, or its first leaf // block when it's a container. - const target = isContainerNode(nextNode.type) - ? getFirstLeafBlock(nextNode, $boundary.pos, { - respectSealed: true, - }) - : { node: nextNode, beforePos: $boundary.pos }; + const target = getFirstLeafBlock( + getBlockInfoFromNode(nextNode, $boundary.pos), + { respectSealed: true }, + ); if (!target) { return false; } if (dispatch) { moveBlockOutAndPlaceCaret(tr, { - from: target.beforePos, - to: target.beforePos + target.node.nodeSize, - node: target.node, + from: target.block.beforePos, + to: target.block.afterPos, + node: target.block.node, insertAt: blockInfo.block.afterPos, }); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 240b0c762a..49071667b7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -46,6 +46,7 @@ export { selectedFragmentToHTML } from "./api/clipboard/toClipboard/copyExtensio // Node conversions export * from "./api/nodeConversions/blockToNode.js"; +export * from "./api/nodeConversions/contentToNodes.js"; export * from "./api/nodeConversions/fragmentToBlocks.js"; export * from "./api/nodeConversions/nodeToBlock.js"; export * from "./extensions/tiptap-extensions/UniqueID/UniqueID.js"; diff --git a/packages/core/src/schema/inlineContent/createSpec.ts b/packages/core/src/schema/inlineContent/createSpec.ts index 103dec52a8..8fe0b36ebe 100644 --- a/packages/core/src/schema/inlineContent/createSpec.ts +++ b/packages/core/src/schema/inlineContent/createSpec.ts @@ -7,7 +7,7 @@ import { Schema, TagParseRule, } from "@tiptap/pm/model"; -import { inlineContentToNodes } from "../../api/nodeConversions/blockToNode.js"; +import { inlineContentToNodes } from "../../api/nodeConversions/contentToNodes.js"; import { nodeToCustomInlineContent } from "../../api/nodeConversions/nodeToBlock.js"; import type { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { ignoreNonContentMutations } from "../nodeViewMutations.js";