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 0434cd7a9e..783eb19b44 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,139 +1,28 @@ -import { Node } from "prosemirror-model"; import { EditorState, TextSelection } from "prosemirror-state"; -import { - isContentContainerNode, - isSealed, -} from "../../../../schema/blocks/children.js"; +import { isContentContainerNode } from "../../../../schema/blocks/children.js"; import { getAncestorContainers } from "../../containers/containerNav.js"; import { fixContainersById } from "../../containers/fixContainer.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 && - // A content-bearing container is `isWrappedBlock` with an `inline*` + prevBlockInfo.hasContent && + prevBlockInfo.contentKind === "inline" && + !prevBlockInfo.isContentEmpty && + // A content-bearing container is `hasContent` with an `inline` // title, but stitching across its boundary would orphan its required // `__children` node. `mergeIntoContainerContent` is the only supported // merge involving one. - !isContentContainerNode(prevBlockInfo.bnBlock.node) && - nextBlockInfo.isWrappedBlock && - nextBlockInfo.blockContent.node.type.spec.content === "inline*" && - !isContentContainerNode(nextBlockInfo.bnBlock.node) + !isContentContainerNode(prevBlockInfo.block.node) && + nextBlockInfo.hasContent && + nextBlockInfo.contentKind === "inline" && + !isContentContainerNode(nextBlockInfo.block.node) ); }; @@ -144,25 +33,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); } } @@ -171,9 +60,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`, ); } @@ -183,10 +72,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), ); } @@ -211,16 +97,15 @@ export const mergeIntoContainerContent = ( containerInfo: BlockInfo, childInfo: BlockInfo, ) => { - if (!containerInfo.isWrappedBlock || !childInfo.isWrappedBlock) { + if (!containerInfo.hasContent || !childInfo.hasContent) { return false; } - const title = containerInfo.blockContent; - const childContent = childInfo.blockContent; + const childContent = childInfo.content; if ( - title.node.type.spec.content !== "inline*" || - childContent.node.type.spec.content !== "inline*" + containerInfo.contentKind !== "inline" || + childInfo.contentKind !== "inline" ) { return false; } @@ -234,20 +119,17 @@ export const mergeIntoContainerContent = ( // deletes, applied after. const containersToFix = getAncestorContainers( state.doc, - childInfo.bnBlock.beforePos, + childInfo.block.beforePos, ); // The title lies before the children, so none of these positions shift the // ones used after them. - if (childInfo.childContainer?.node.childCount) { - tr.insert( - childInfo.bnBlock.afterPos, - childInfo.childContainer.node.content, - ); + if (childInfo.children?.node.childCount) { + tr.insert(childInfo.block.afterPos, childInfo.children.node.content); } - tr.delete(childInfo.bnBlock.beforePos, childInfo.bnBlock.afterPos); + tr.delete(childInfo.block.beforePos, childInfo.block.afterPos); - const titleEndPos = title.afterPos - 1; + const titleEndPos = containerInfo.contentEnd; tr.insert(titleEndPos, childContent.node.content); const stepsBeforeFix = tr.steps.length; @@ -275,19 +157,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/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 a468ea0d18..9b0257c541 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"; @@ -35,9 +35,12 @@ 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; } // A content-bearing container's own node can't be split: its content @@ -45,19 +48,19 @@ export const splitBlockTr = ( // `tr.split` (which would start a second container with a bare paragraph) // throws. Splitting a title has no meaning anyway, so refuse it — callers // fall through to a no-op. - if (isContentContainerNode(info.bnBlock.node)) { + if (isContentContainerNode(info.block.node)) { 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 432490a7be..f5f6d432db 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, @@ -69,7 +69,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") { @@ -98,27 +98,27 @@ export function updateBlockTr< // with its own content keeps that content in a generated node rather than in // its own, so routing on the block's node type would send an update of its // content to the full-replace arm, where it used to be silently dropped. - const isContentContainer = isContentContainerNode(blockInfo.bnBlock.node); + const isContentContainer = isContentContainerNode(blockInfo.block.node); 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); @@ -134,13 +134,13 @@ export function updateBlockTr< replaceToOffset, ); } else if ( - blockInfo.isWrappedBlock && + blockInfo.hasContent && isContentContainer && newBlockType === blockInfo.blockNoteType ) { // Same container, so its generated content node stays as it is. Only what // that node holds may change. - const contentNodeType = blockInfo.blockContent.node.type; + const contentNodeType = blockInfo.content.node.type; updateChildren(block, tr, blockInfo); updateBlockContentNode( @@ -153,13 +153,13 @@ export function updateBlockTr< replaceToOffset, ); } else if ( - !blockInfo.isWrappedBlock && + !blockInfo.hasContent && newNodeType.isInGroup("bnBlock") && !getContentContainerNodeTypes(pmSchema, newBlockType) ) { 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 @@ -168,7 +168,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, @@ -190,8 +190,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, ); @@ -202,7 +202,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, }); @@ -256,10 +256,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, @@ -289,8 +289,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 @@ -305,7 +305,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 @@ -313,7 +313,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. // @@ -321,7 +321,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) { @@ -329,7 +329,7 @@ function updateBlockContentNode< // position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, - blockInfo.blockContent.beforePos, + blockInfo.content.beforePos, newNodeType, { ...block.props }, ); @@ -338,7 +338,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 @@ -358,7 +358,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. @@ -368,7 +368,7 @@ function updateBlockContentNode< // get its (possibly shifted) position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, - blockInfo.blockContent.beforePos, + blockInfo.content.beforePos, newNodeType, { ...block.props }, ); @@ -381,11 +381,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, @@ -599,22 +599,22 @@ function updateChildren< }); // Checks if a blockGroup node already exists. - if (blockInfo.childContainer) { + if (blockInfo.children) { // Replaces the child nodes in the existing blockGroup, 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) { + if (!blockInfo.hasContent) { throw new Error("impossible"); } // Inserts a new blockGroup containing the child nodes created earlier. tr.insert( - blockInfo.blockContent.afterPos, + blockInfo.content.afterPos, pmSchema.nodes["blockGroup"].createChecked({}, childNodes), ); } @@ -725,12 +725,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/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..01eac6b3e6 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -13,7 +13,7 @@ import type { } from "../../../schema/index.js"; import { UnreachableCaseError } from "../../../util/typescript.js"; import { - getBlockInfo, + getBlockInfoFromNode, getBlockInfoFromSelection, getNodeId, } from "../../getBlockInfoFromPos.js"; @@ -26,14 +26,14 @@ 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; @@ -47,7 +47,7 @@ export function getTextCursorPosition< } 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,40 +69,32 @@ 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), - ); + tr.setSelection(TextSelection.create(tr.doc, content.beforePos + 4)); } else { - tr.setSelection( - TextSelection.create(tr.doc, blockContent.afterPos - 4), - ); + tr.setSelection(TextSelection.create(tr.doc, content.afterPos - 4)); } } else { throw new UnreachableCaseError(contentType); @@ -110,13 +102,13 @@ export function setTextCursorPosition( } 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/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 04ad1c6ba3..a4c2ecd41d 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,63 +1,127 @@ -import { Node, ResolvedPos } from "prosemirror-model"; +import { Node } from "prosemirror-model"; import { EditorState, Transaction } from "prosemirror-state"; import { CHILD_CONTAINER_GROUP, CONTAINER_CONTENT_GROUP, isContentContainerNode, + 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; beforePos: number; 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: - * either a `blockContainer` (an ordinary block wrapped for nesting), or - * a container block that has its own content as well as children. Both - * have the same shape: a content node, then an optional child container. + * Whether the block has a content node: either a `blockContainer` (an + * ordinary block wrapped for nesting), or a container block that has its + * own content as well as children. Both have the same shape: a content + * node, then an optional child container. * * Note this is roughly the opposite of "is a container block": a - * column has `isWrappedBlock: false`. Sites that need "is this literally - * a `blockContainer`" should read `bnBlock.node.type.name`. + * column has `hasContent: false`. Sites that need "is this literally + * a `blockContainer`" should read `block.node.type.name`. */ - isWrappedBlock: true; + hasContent: true; } ); @@ -171,138 +235,237 @@ 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; + const blockNode = node; + const blockBeforePos = beforePos; + const blockAfterPos = blockBeforePos + blockNode.nodeSize; - const bnBlock: SingleBlockInfo = { - node: bnBlockNode, - beforePos: bnBlockBeforePos, - afterPos: bnBlockAfterPos, + const block: SingleBlockInfo = { + node: blockNode, + beforePos: blockBeforePos, + afterPos: blockAfterPos, }; // A container block that has its own content is shaped like a // `blockContainer`: a content node followed by a node holding its children. // Discriminating on that shape rather than on the node's name lets every // branch written against `blockContainer` cover it too. - const isContentContainer = isContentContainerNode(bnBlockNode); + const isContentContainer = isContentContainerNode(blockNode); - if (bnBlockNode.type.name === "blockContainer" || isContentContainer) { - let blockContent: SingleBlockInfo | undefined; - let childContainer: SingleBlockInfo | undefined; + if (blockNode.type.name === "blockContainer" || isContentContainer) { + let content: SingleBlockInfo | undefined; + let children: SingleBlockInfo | undefined; - bnBlockNode.forEach((node, offset) => { - const beforePos = bnBlockBeforePos + offset + 1; + blockNode.forEach((node, offset) => { + const beforePos = blockBeforePos + offset + 1; const afterPos = beforePos + node.nodeSize; if ( node.type.spec.group === "blockContent" || node.type.isInGroup(CONTAINER_CONTENT_GROUP) ) { - blockContent = { node, beforePos, afterPos }; + content = { node, beforePos, afterPos }; } else if (node.type.isInGroup(CHILD_CONTAINER_GROUP)) { - childContainer = { node, beforePos, afterPos }; + children = { node, beforePos, afterPos }; } }); - if (!blockContent) { + if (!content) { 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}`, + `${blockNode.type.name} node does not contain a content node in its children: ${blockNode}`, ); } return { - isWrappedBlock: true, - bnBlock, - blockContent, - childContainer, + hasContent: true, + block, + content, + children: children && toChildrenInfo(children), + 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. A container block's node type is the block // type itself. blockNoteType: isContentContainer - ? bnBlockNode.type.name - : blockContent.node.type.name, + ? blockNode.type.name + : content.node.type.name, }; } else { - if (!bnBlock.node.type.isInGroup("childContainer")) { + if (!block.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}`, + `block node is not in the childContainer group: ${block.node}`, ); } return { - isWrappedBlock: false, - bnBlock: bnBlock, - childContainer: bnBlock, - blockNoteType: bnBlock.node.type.name, + hasContent: false, + block, + children: toChildrenInfo(block), + blockNoteType: block.node.type.name, }; } } /** - * 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 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 getBlockInfo(posInfo: { posBeforeNode: number; node: Node }) { - return getBlockInfoWithManualOffset(posInfo.node, posInfo.posBeforeNode); +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 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 + * 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 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`, - ); - } - return getBlockInfoWithManualOffset(resolvedPos.nodeAfter, resolvedPos.pos); +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. The - * block chosen is the one currently containing the current ProseMirror - * selection. - * @param source The ProseMirror editor state. + * 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 getBlockInfoFromSelection(source: EditorState | Transaction) { - return getBlockInfoAtNearest(source, source.selection.anchor); + return getBlockInfoNearPos(source, source.selection.anchor); } -export function getBlockInfoAtNearest( - source: EditorState | Transaction, - pos: number, -) { - return getBlockInfo(getNearestBlockPos(source.doc, pos)); +/** + * Returns the block info from the parent block + * or undefined if we're at the root + */ +export function 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); + } + + return getBlockInfoAt(doc, parentBeforePos); +} + +/** + * 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 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); +} + +/** + * 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/contentContainers.test.ts b/packages/core/src/api/nodeConversions/contentContainers.test.ts index 22f48bbdbf..4880694ebd 100644 --- a/packages/core/src/api/nodeConversions/contentContainers.test.ts +++ b/packages/core/src/api/nodeConversions/contentContainers.test.ts @@ -7,10 +7,10 @@ import { defaultBlockSpecs } from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; import { createBlockSpec } from "../../schema/blocks/createSpec.js"; import { - getBottomNestedBlockInfo, + getBlockInfoFromNode, + getLastDescendantBlockInfo, getPrevBlockInfo, -} from "../blockManipulation/commands/mergeBlocks/mergeBlocks.js"; -import { getBlockInfoWithManualOffset } from "../getBlockInfoFromPos.js"; +} from "../getBlockInfoFromPos.js"; import { blockToNode } from "./blockToNode.js"; import { nodeToBlock } from "./nodeToBlock.js"; @@ -152,7 +152,7 @@ describe("content-bearing container: node shape", () => { const contentNode = node.child(0); expect(contentNode.type.name).toBe("toggle__content"); expect(contentNode.type.isInGroup("containerContent")).toBe(true); - // Deliberately not in `blockContent`. `blockContainer` accepts that + // Deliberately not in the `blockContent` group. `blockContainer` accepts that // group, so a paste could otherwise produce // `blockContainer > toggle__content`. expect(contentNode.type.isInGroup("blockContent")).toBe(false); @@ -245,22 +245,59 @@ describe("content-bearing container: BlockInfo", () => { pmSchema, ); - const info = getBlockInfoWithManualOffset(node, 0); + const info = getBlockInfoFromNode(node, 0); // Structurally identical to a `blockContainer`, so every keyboard branch // written against one covers this too. - expect(info.isWrappedBlock).toBe(true); - expect(info.blockContent!.node.type.name).toBe("toggle__content"); - expect(info.childContainer!.node.type.name).toBe("toggle__children"); + expect(info.hasContent).toBe(true); + expect(info.content!.node.type.name).toBe("toggle__content"); + expect(info.children!.node.type.name).toBe("toggle__children"); // The type comes from the outer node. A `blockContainer` is a generic // wrapper, but a container block is its own type. expect(info.blockNoteType).toBe("toggle"); // Positions are those of the nodes themselves. - expect(info.bnBlock.beforePos).toBe(0); - expect(info.blockContent!.beforePos).toBe(1); - expect(info.blockContent!.afterPos).toBe(1 + node.child(0).nodeSize); - expect(info.childContainer!.beforePos).toBe(1 + node.child(0).nodeSize); + expect(info.block.beforePos).toBe(0); + expect(info.content!.beforePos).toBe(1); + expect(info.content!.afterPos).toBe(1 + node.child(0).nodeSize); + expect(info.children!.beforePos).toBe(1 + node.child(0).nodeSize); + + // Derived positions and predicates. + 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!.childrenStart).toBe(info.children!.beforePos + 1); + expect(info.children!.childrenEnd).toBe(info.children!.afterPos - 1); + }); + + it("a pure container has children bounds but no content fields", () => { + const node = blockToNode( + { + id: "pc-0", + type: "pureDefault", + children: [{ id: "c-0", type: "paragraph", content: "Child" }], + } as any, + pmSchema, + ); + + const info = getBlockInfoFromNode(node, 0); + + expect(info.hasContent).toBe(false); + if (info.hasContent) { + throw new Error("expected a pure container"); + } + expect(info.content).toBeUndefined(); + expect(info.contentStart).toBeUndefined(); + expect(info.contentEnd).toBeUndefined(); + expect(info.contentKind).toBeUndefined(); + expect(info.isContentEmpty).toBeUndefined(); + + // A pure container holds its children directly: `children` is the block + // node itself, and the bounds point just inside it. + expect(info.children.node).toBe(node); + expect(info.children.childrenStart).toBe(info.block.beforePos + 1); + expect(info.children.childrenEnd).toBe(info.block.afterPos - 1); }); it("handles a container with zero children", () => { @@ -280,13 +317,13 @@ describe("content-bearing container: BlockInfo", () => { const doc = wrapInDoc(paragraphNode, toggleNode); const togglePos = 1 + paragraphNode.nodeSize; - const info = getBlockInfoWithManualOffset(toggleNode, togglePos); - expect(info.childContainer!.node.childCount).toBe(0); + const info = getBlockInfoFromNode(toggleNode, togglePos); + expect(info.children!.node.childCount).toBe(0); // An empty child container has no last child to descend into, so the // block itself is the bottom one. - expect(() => getBottomNestedBlockInfo(doc, info)).not.toThrow(); - expect(getBottomNestedBlockInfo(doc, info).bnBlock.node).toBe(toggleNode); + expect(() => getLastDescendantBlockInfo(doc, info)).not.toThrow(); + expect(getLastDescendantBlockInfo(doc, info).block.node).toBe(toggleNode); expect(() => getPrevBlockInfo(doc, togglePos)).not.toThrow(); expect(getPrevBlockInfo(doc, togglePos)!.blockNoteType).toBe("paragraph"); diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 29ba5d235b..76d28f2f53 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -23,10 +23,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, @@ -407,7 +404,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); @@ -416,11 +413,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(); @@ -435,7 +432,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; @@ -450,37 +447,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; @@ -569,7 +566,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"); } 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/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/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 2a8de84895..e0843676ff 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -3,10 +3,6 @@ import { Fragment, Node } from "prosemirror-model"; import { NodeSelection, TextSelection, Transaction } from "prosemirror-state"; import { - getBottomNestedBlockInfo, - getNextBlockInfo, - getParentBlockInfo, - getPrevBlockInfo, mergeBlocksCommand, mergeIntoContainerContent, } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; @@ -32,8 +28,12 @@ import { 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, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; @@ -75,28 +75,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; @@ -123,18 +123,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: {}, }), @@ -147,13 +147,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( @@ -174,28 +173,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; @@ -216,35 +215,35 @@ 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, ); - // A content-bearing container is `isWrappedBlock` but still a + // A content-bearing container is `hasContent` but still a // container to descend into. Its non-empty-body merges are // handled by the merge branch above; this catches the rest // (e.g. an empty body, which refuses to merge). if ( !prevBlockInfo || - (prevBlockInfo.isWrappedBlock && - !isContentContainerNode(prevBlockInfo.bnBlock.node)) + (prevBlockInfo.hasContent && + !isContentContainerNode(prevBlockInfo.block.node)) ) { return false; } const insertionPos = descendToLastInsertionPos( - prevBlockInfo.bnBlock.node, - prevBlockInfo.bnBlock.beforePos, + prevBlockInfo.block.node, + prevBlockInfo.block.beforePos, state.schema.nodes["blockContainer"], { respectSealed: true }, ); @@ -257,20 +256,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ // seals.) const blockedBySeal = descendToLastInsertionPos( - prevBlockInfo.bnBlock.node, - prevBlockInfo.bnBlock.beforePos, + prevBlockInfo.block.node, + prevBlockInfo.block.beforePos, state.schema.nodes["blockContainer"], ) !== null; if ( blockedBySeal && - NodeSelection.isSelectable(prevBlockInfo.bnBlock.node) + 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; @@ -279,11 +275,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)), ); @@ -300,29 +293,26 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, 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 || !state.selection.empty) { return false; } // Only the container's first child. - if (state.doc.resolve(blockInfo.bnBlock.beforePos).nodeBefore) { + if (state.doc.resolve(blockInfo.block.beforePos).nodeBefore) { return false; } const parentInfo = getParentBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if ( - !parentInfo || - !isContentContainerNode(parentInfo.bnBlock.node) - ) { + if (!parentInfo || !isContentContainerNode(parentInfo.block.node)) { return false; } @@ -341,17 +331,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) { @@ -403,9 +393,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, }); } @@ -417,45 +407,44 @@ 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 tableBlockEndPos = blockInfo.block.beforePos - 1; const tableBlockContentEndPos = tableBlockEndPos - 1; const lastRowEndPos = tableBlockContentEndPos - 1; const lastCellEndPos = lastRowEndPos - 1; @@ -465,15 +454,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ lastCellParagraphEndPos, ); } 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); @@ -481,8 +468,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(); @@ -497,56 +484,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(); } @@ -568,42 +554,41 @@ 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( @@ -611,15 +596,15 @@ export const KeyboardShortcutsExtension = Extension.create<{ // child. A container with its own content always keeps // its children node (it's part of its content // expression), so there only the child is deleted. - childContainer.node.childCount === 1 && - !isContentContainerNode(blockInfo.bnBlock.node) + children.node.childCount === 1 && + !isContentContainerNode(blockInfo.block.node) ? { - 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. @@ -647,21 +632,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; @@ -680,27 +665,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) { @@ -712,7 +697,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; @@ -727,17 +712,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) { @@ -786,7 +771,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, }); } @@ -800,13 +785,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) { @@ -824,42 +808,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( @@ -881,30 +863,26 @@ 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; + if (nextBlockInfo.contentKind === "table") { + const tableBlockStartPos = blockInfo.block.afterPos + 1; const tableBlockContentStartPos = tableBlockStartPos + 1; const firstRowStartPos = tableBlockContentStartPos + 1; const firstCellStartPos = firstRowStartPos + 1; @@ -913,22 +891,20 @@ export const KeyboardShortcutsExtension = Extension.create<{ chainedCommands = chainedCommands.setTextSelection( firstCellParagraphStartPos, ); - } 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(); @@ -943,45 +919,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(); } @@ -998,10 +969,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); @@ -1009,7 +980,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 ( @@ -1096,17 +1067,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); if ( - !blockInfo.isWrappedBlock || - !blockInfo.childContainer || - !isContentContainerNode(blockInfo.bnBlock.node) + !blockInfo.hasContent || + !blockInfo.children || + !isContentContainerNode(blockInfo.block.node) ) { return false; } - const { blockContent, childContainer } = blockInfo; + const { children } = blockInfo; - const titleEndPos = blockContent.afterPos - 1; + const titleEndPos = blockInfo.contentEnd; if ( - state.selection.from < blockContent.beforePos + 1 || + state.selection.from < blockInfo.contentStart || state.selection.to > titleEndPos ) { return false; @@ -1114,8 +1085,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (dispatch) { // The tail of the title, empty when the cursor is at its end. - const tail = blockContent.node.content.cut( - state.selection.to - blockContent.beforePos - 1, + const tail = blockInfo.content.node.content.cut( + state.selection.to - blockInfo.contentStart, ); const newChild = state.schema.nodes[ "blockContainer" @@ -1127,7 +1098,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ // Removes the tail (and anything selected) from the title, then // prepends it to the container's children. tr.delete(state.selection.from, titleEndPos); - const insertionPos = tr.mapping.map(childContainer.beforePos + 1); + const insertionPos = tr.mapping.map(children.childrenStart); tr.insert(insertionPos, newChild); tr.setSelection( TextSelection.near(tr.doc.resolve(insertionPos + 1)), @@ -1147,25 +1118,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; } @@ -1187,9 +1158,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(); @@ -1202,16 +1173,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; @@ -1227,7 +1198,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ [ state.schema.nodes["paragraph"].createAndFill() || undefined, - blockInfo.childContainer?.node, + blockInfo.children?.node, ].filter((node) => node !== undefined), )!; @@ -1240,10 +1211,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, ); } } @@ -1258,14 +1229,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/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 a762f78d96..319c02a3bc 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, - getBlockInfo, + getBlockInfoFromNode, nodeToBlock, } from "@blocknote/core"; import { Plugin } from "prosemirror-state"; @@ -26,7 +26,10 @@ export function createMultiColumnHandleDropPlugin( return false; // Let ProseMirror handle the drop (e.g. outside editor bounds) } - const blockInfo = getBlockInfo(edgePos); + const blockInfo = getBlockInfoFromNode( + edgePos.node, + edgePos.posBeforeNode, + ); // Only handle edge drops (left/right) if (edgePos.position === "regular") { @@ -48,7 +51,11 @@ export function createMultiColumnHandleDropPlugin( // emptied target in the same position, so do nothing. This also // keeps the column's ID and width instead of resetting them. let allTargetChildrenDragged = true; - blockInfo.bnBlock.node.forEach((child) => { + // A column is a pure container: its `children` node is the column + // node itself. + const columnChildren = + blockInfo.children?.node ?? blockInfo.block.node; + columnChildren.forEach((child) => { if (!draggedBlockIds.has(child.attrs.id)) { allTargetChildrenDragged = false; } @@ -59,7 +66,7 @@ export function createMultiColumnHandleDropPlugin( // Insert new column in existing columnList const parentBlock = view.state.doc - .resolve(blockInfo.bnBlock.beforePos) + .resolve(blockInfo.block.beforePos) .node(); const columnList = nodeToBlock( @@ -94,7 +101,7 @@ export function createMultiColumnHandleDropPlugin( }); } - const targetColumnId = blockInfo.bnBlock.node.attrs.id; + const targetColumnId = blockInfo.block.node.attrs.id; // Tracks which of the dragged blocks were already in the column // list - removing those from their old position is handled by @@ -158,7 +165,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.