diff --git a/docs/content/docs/reference/editor/manipulating-content.mdx b/docs/content/docs/reference/editor/manipulating-content.mdx index 1a9c97c222..bcc3d57ceb 100644 --- a/docs/content/docs/reference/editor/manipulating-content.mdx +++ b/docs/content/docs/reference/editor/manipulating-content.mdx @@ -141,11 +141,11 @@ editor.forEachBlock((block) => { insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before" -): void + placement: "before" | "after" | "first-child" | "last-child" = "before" +): Block[] ``` -Inserts new blocks relative to an existing block. +Inserts new blocks relative to an existing block. `"before"` and `"after"` make the new blocks siblings of the reference block; `"first-child"` and `"last-child"` nest them inside it. Returns the inserted blocks. ```typescript // Insert a paragraph before an existing block @@ -164,6 +164,13 @@ editor.insertBlocks( "existing-block-id", "after", ); + +// Insert a paragraph as the last child of an existing block +editor.insertBlocks( + [{ type: "paragraph", content: "Nested paragraph" }], + "existing-block-id", + "last-child", +); ``` ### Updating Blocks diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index b41b268617..53973834eb 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -1,4 +1,4 @@ -import { Fragment, Slice } from "prosemirror-model"; +import { Fragment, Node, NodeType, Slice } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; import { ReplaceStep } from "prosemirror-transform"; import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; @@ -8,11 +8,122 @@ import { InlineContentSchema, StyleSchema, } from "../../../../schema/index.js"; +import { + BlockInfo, + getBlockInfoFromNode, +} from "../../../getBlockInfoFromPos.js"; import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; import { getPmSchema } from "../../../pmUtil.js"; +/** + * Where blocks go relative to a reference block. `"before"`/`"after"` make + * them siblings of it; `"first-child"`/`"last-child"` nest them inside it. + * + * The nested placements also cover blocks that have no children to point at: + * a regular block's `blockGroup` is lazy (`blockContent blockGroup?`), so a + * block without children has no child block to insert before or after. + */ +export type BlockPlacement = "before" | "after" | "first-child" | "last-child"; + +/** + * Walks one edge of a block's children, descending through nested + * child-holding wrapper blocks (e.g. a Column inside a ColumnList), to the + * deepest position where `nodeType` fits. `edge` picks the trailing edge + * (where a new last child goes) or the leading edge. + */ +function descendToInsertionPos( + info: BlockInfo, + nodeType: NodeType, + edge: "first" | "last", +): { pos: number } | { pos?: undefined; blockedBy: "schema" } { + const children = info.children; + if (!children) { + return { blockedBy: "schema" }; + } + + const last = edge === "last"; + const index = last ? children.node.childCount : 0; + // `canReplaceWith` rather than a bare content match: the children already + // after the position have to still fit once the new node is spliced in. + if (children.node.canReplaceWith(index, index, nodeType)) { + return { pos: last ? children.childrenEnd : children.childrenStart }; + } + + const child = last ? children.node.lastChild : children.node.firstChild; + if ( + !child || + !(child.type.isInGroup("bnBlock") && child.type.isInGroup("childContainer")) + ) { + return { blockedBy: "schema" }; + } + return descendToInsertionPos( + getBlockInfoFromNode( + child, + last ? children.childrenEnd - child.nodeSize : children.childrenStart, + ), + nodeType, + edge, + ); +} + +/** + * Resolves a `placement` against a reference block into the document position + * a node of `nodeType` should be inserted at, or `null` when the reference + * block cannot take it there. + * + * Shared by `insertBlocks` and the move commands, so "does this block fit + * here?" is answered in one place. The answer comes from the schema's content + * matches rather than from a hand-written rule. + * + * `wrapIn` is set when the position only becomes valid once the nodes are + * wrapped: a regular block with no children yet has no `blockGroup` for them + * to go in, so one is created around them. + */ +export function getInsertionPos( + doc: Node, + reference: { node: Node; posBeforeNode: number }, + placement: BlockPlacement, + nodeType: NodeType, +): { pos: number; wrapIn?: NodeType } | null { + const { node, posBeforeNode } = reference; + + if (placement === "before" || placement === "after") { + const pos = + placement === "before" ? posBeforeNode : posBeforeNode + node.nodeSize; + const $pos = doc.resolve(pos); + + // `canReplaceWith` rather than a bare content match: the nodes already + // after the position have to still fit once the new one is spliced in. + return $pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType) + ? { pos } + : null; + } + + const info = getBlockInfoFromNode(node, posBeforeNode); + + if (info.children) { + const { pos } = descendToInsertionPos( + info, + nodeType, + placement === "first-child" ? "first" : "last", + ); + + return pos === undefined ? null : { pos }; + } + + // No children holder implies a `blockContainer` with no children yet: its + // `blockGroup` is lazy (`blockContent blockGroup?`), so the position after + // the content node only becomes valid once the nodes are wrapped in a new + // group. + const blockGroupType = nodeType.schema.nodes["blockGroup"]; + + return info.hasContent && blockGroupType?.contentMatch.matchType(nodeType) + ? { pos: info.content.afterPos, wrapIn: blockGroupType } + : null; +} + export function insertBlocks< BSchema extends BlockSchema, I extends InlineContentSchema, @@ -21,7 +132,7 @@ export function insertBlocks< tr: Transaction, blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ): Block[] { const id = typeof referenceBlock === "string" ? referenceBlock : referenceBlock.id; @@ -37,14 +148,47 @@ export function insertBlocks< throw new Error(`Block with ID ${id} not found`); } - let pos = posInfo.posBeforeNode; - if (placement === "after") { - pos += posInfo.node.nodeSize; + if (nodesToInsert.length === 0) { + return []; } - tr.step( - new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)), + const target = getInsertionPos( + tr.doc, + posInfo, + placement, + nodesToInsert[0].type, ); + if (!target) { + throw new Error( + `Cannot insert blocks at "${placement}" of block "${id}": no valid position for them`, + ); + } + + // `getInsertionPos` can only answer for the first node's type: the fragment + // doesn't exist yet when it runs. The whole fragment still has to fit, so it + // is checked here, where the nodes are known, rather than left to `tr.step` + // to reject with a ProseMirror-level message. + if ( + target.wrapIn && + !target.wrapIn.validContent(Fragment.from(nodesToInsert)) + ) { + throw new Error( + `Cannot insert blocks at "${placement}" of block "${id}": a "${target.wrapIn.name}" doesn't accept them`, + ); + } + + const fragment = target.wrapIn + ? Fragment.from(target.wrapIn.create(null, nodesToInsert)) + : Fragment.from(nodesToInsert); + + const $target = tr.doc.resolve(target.pos); + if (!$target.parent.canReplace($target.index(), $target.index(), fragment)) { + throw new Error( + `Cannot insert blocks at "${placement}" of block "${id}": a "${$target.parent.type.name}" doesn't accept them`, + ); + } + + tr.step(new ReplaceStep(target.pos, target.pos, new Slice(fragment, 0, 0))); // Now that the `PartialBlock`s have been converted to nodes, we can // re-convert them into full `Block`s. diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts new file mode 100644 index 0000000000..8da2132a40 --- /dev/null +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts @@ -0,0 +1,113 @@ +// @vitest-environment node +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, +} from "vite-plus/test"; + +import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; + +let editor: BlockNoteEditor; + +beforeAll(() => { + editor = BlockNoteEditor.create() as any; +}); + +afterAll(() => { + editor._tiptapEditor.destroy(); + editor = undefined as any; +}); + +beforeEach(() => { + editor.replaceBlocks(editor.document, [ + { id: "p-0", type: "paragraph", content: "Paragraph 0" }, + ]); +}); + +describe('insertBlocks "first-child" / "last-child"', () => { + it("nests under a childless block, creating the blockGroup", () => { + expect(editor.getBlock("p-0")!.children).toHaveLength(0); + + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "p-0", + "first-child", + ); + editor.insertBlocks( + [{ id: "last", type: "paragraph" }], + "p-0", + "last-child", + ); + + expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + "first", + "last", + ]); + }); + + it("prepends and appends around existing children", () => { + editor.replaceBlocks(editor.document, [ + { + id: "p-0", + type: "paragraph", + content: "Paragraph 0", + children: [{ id: "existing", type: "paragraph", content: "Existing" }], + }, + ]); + + editor.insertBlocks( + [{ id: "first", type: "paragraph" }], + "p-0", + "first-child", + ); + editor.insertBlocks( + [{ id: "last", type: "paragraph" }], + "p-0", + "last-child", + ); + + expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + "first", + "existing", + "last", + ]); + }); + + it("still inserts siblings with the default and explicit placements", () => { + editor.insertBlocks([{ id: "after", type: "paragraph" }], "p-0"); + editor.insertBlocks([{ id: "before", type: "paragraph" }], "p-0", "before"); + editor.insertBlocks([{ id: "sibling", type: "paragraph" }], "p-0", "after"); + + expect(editor.document.map((block) => block.id)).toEqual([ + "after", + "before", + "p-0", + "sibling", + ]); + }); + + it("still inserts a batch that fits in full", () => { + editor.insertBlocks( + [ + { id: "one", type: "paragraph" }, + { id: "two", type: "paragraph" }, + ], + "p-0", + "last-child", + ); + + expect(editor.getBlock("p-0")!.children.map((child) => child.id)).toEqual([ + "one", + "two", + ]); + }); + + it("throws when the reference block does not exist", () => { + expect(() => + editor.insertBlocks([{ type: "paragraph" }], "missing-id", "last-child"), + ).toThrow(/Block with ID missing-id not found/); + }); +}); 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 ce1a9455db..6cdf0f06cf 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -1,170 +1,24 @@ -import { Node } from "prosemirror-model"; import { EditorState } from "prosemirror-state"; 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 + * Merges the block starting at `posBetweenBlocks` into the block visually + * above it, by deleting the boundary between the two. * - * Then the bottom nested block returned is D. + * @param posBetweenBlocks The position of the boundary between the two blocks: + * the position just before the outer node of the block being merged upwards, + * i.e. its `BlockInfo`'s `block.beforePos`. The block above is found by walking + * back from there. + * @returns A tiptap command that returns `false` (leaving the doc untouched) + * when the two blocks can't merge: no block above, either side isn't an + * inline-content block, or the block above is empty (deleting it is handled + * elsewhere). */ -export const getBottomNestedBlockInfo = (doc: Node, blockInfo: BlockInfo) => { - while (blockInfo.childContainer) { - 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.isBlockContainer && - prevBlockInfo.blockContent.node.type.spec.content === "inline*" && - prevBlockInfo.blockContent.node.childCount > 0 && - nextBlockInfo.isBlockContainer && - nextBlockInfo.blockContent.node.type.spec.content === "inline*" - ); -}; - -const mergeBlocks = ( - state: EditorState, - dispatch: ((args?: any) => any) | undefined, - prevBlockInfo: BlockInfo, - nextBlockInfo: BlockInfo, -) => { - // Un-nests all children of the next block. - if (!nextBlockInfo.isBlockContainer) { - 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`, - ); - } - - // 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) { - const childBlocksStart = state.doc.resolve( - nextBlockInfo.childContainer.beforePos + 1, - ); - const childBlocksEnd = state.doc.resolve( - nextBlockInfo.childContainer.afterPos - 1, - ); - const childBlocksRange = childBlocksStart.blockRange(childBlocksEnd); - - if (dispatch) { - const pos = state.doc.resolve(nextBlockInfo.bnBlock.beforePos); - state.tr.lift(childBlocksRange!, pos.depth); - } - } - - // Deletes the boundary between the two blocks. Can be thought of as - // removing the closing tags of the first block and the opening tags of the - // second one to stitch them together. - if (dispatch) { - if (!prevBlockInfo.isBlockContainer) { - 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`, - ); - } - - // TODO: test merging between a columnList and paragraph, between two columnLists, and v.v. - dispatch( - state.tr.delete( - prevBlockInfo.blockContent.afterPos - 1, - nextBlockInfo.blockContent.beforePos + 1, - ), - ); - } - - return true; -}; - export const mergeBlocksCommand = (posBetweenBlocks: number) => ({ @@ -174,26 +28,78 @@ 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( + // The block we merge into is the last descendant of the previous block: + // visually, that's the block directly above the boundary. + const bottomNestedBlockInfo = getLastDescendantBlockInfo( state.doc, prevBlockInfo, ); - if (!canMerge(bottomNestedBlockInfo, nextBlockInfo)) { + // Only inline-content blocks can merge, and merging into an empty block + // is handled elsewhere (by deleting the empty block instead). Merging + // into or out of container blocks (columnLists, callouts, ...) is + // intentionally unsupported; the container-boundary Backspace/Delete + // branches in `KeyboardShortcutsExtension` handle those cases by moving + // blocks across the boundary instead of merging their content. + if ( + !bottomNestedBlockInfo.hasContent || + bottomNestedBlockInfo.contentKind !== "inline" || + bottomNestedBlockInfo.isContentEmpty || + !nextBlockInfo.hasContent || + nextBlockInfo.contentKind !== "inline" + ) { return false; } - return mergeBlocks(state, dispatch, bottomNestedBlockInfo, nextBlockInfo); + // Un-nests the next block's children by one level, so they survive as + // siblings of the merged block rather than as children of a block that no + // longer exists once the boundary below is deleted. + // + // Note `state.tr` is tiptap's chainable state, whose getter returns the one + // transaction shared by the command chain (not a fresh `Transaction` like + // `EditorState.tr`), so this lift carries over into the `dispatch` below. + if (dispatch && nextBlockInfo.children) { + const childBlocksRange = state.doc + .resolve(nextBlockInfo.children.childrenStart) + .blockRange(state.doc.resolve(nextBlockInfo.children.childrenEnd)); + + // A block's children always sit at the same depth in the same parent, so + // they form a block range. No range means the doc is malformed, which is + // a bug rather than a case to merge around. + if (!childBlocksRange) { + throw new Error( + "Children of a block are expected to form a block range", + ); + } + + state.tr.lift( + childBlocksRange, + state.doc.resolve(nextBlockInfo.block.beforePos).depth, + ); + } + + // Deletes the boundary between the two blocks. Can be thought of as + // removing the closing tags of the first block and the opening tags of the + // second one to stitch them together. + if (dispatch) { + dispatch( + state.tr.delete( + bottomNestedBlockInfo.contentEnd, + nextBlockInfo.contentStart, + ), + ); + } + + return true; }; diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts index 61964a49ee..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.isBlockContainer) { + 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 71598b7d69..0393335024 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -1,3 +1,4 @@ +import type { NodeType } from "prosemirror-model"; import { NodeSelection, Selection, @@ -10,11 +11,11 @@ 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"; -import { insertBlocks } from "../insertBlocks/insertBlocks.js"; +import { getInsertionPos, insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; type BlockSelectionData = ( @@ -49,18 +50,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 { @@ -68,15 +69,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, }; } }); @@ -207,26 +207,91 @@ export function moveSelectedBlocksAndSelection( }); } -// Checks if a block is in a valid place after being moved. This check is -// primitive at the moment and only returns false if the block's parent is a -// `columnList` block. This is because regular blocks cannot be direct children -// of `columnList` blocks. -function checkPlacementIsValid(parentBlock?: Block): boolean { - return !parentBlock || parentBlock.type !== "columnList"; +/** + * All a placement check needs to know about the block being moved: where it + * currently sits, and what would land at the destination. Neither changes as a + * placement search walks the document, so both are resolved once up front. + */ +type MovedBlock = { + /** The moved block's ID, to locate it in the doc. */ + id: string; + /** + * The PM node type that would actually be inserted: a child-holding wrapper + * block (e.g. a `columnList`) goes in as its own node type; anything else + * as a generic `blockContainer` wrapper. + */ + nodeType: NodeType; +}; + +function toMovedBlock( + editor: BlockNoteEditor, + block: Block, +): MovedBlock { + const type = editor.pmSchema.nodes[block.type]; + + return { + id: block.id, + nodeType: + type && type.isInGroup("bnBlock") && type.isInGroup("childContainer") + ? type + : editor.pmSchema.nodes["blockContainer"], + }; +} + +// Checks if a block would be in a valid place after being moved +// before/after `referenceBlock`. A regular block nests under any block (it +// goes into that block's `blockGroup`), but a wrapper block (e.g. a +// `columnList`) only accepts what its content expression allows. +// +// Deferred to `getInsertionPos` so that "can a block go here?" has exactly +// one answer, shared with `insertBlocks`, and comes from the schema rather +// than from a rule restated here. +function checkPlacementIsValid( + editor: BlockNoteEditor, + referenceBlock: Block, + placement: "before" | "after", + movedBlock: MovedBlock, +): boolean { + return editor.transact((tr) => { + const posInfo = getNodeById(referenceBlock.id, tr.doc); + const movedPosInfo = getNodeById(movedBlock.id, tr.doc); + if (!posInfo || !movedPosInfo) { + return false; + } + + const target = getInsertionPos( + tr.doc, + posInfo, + placement, + movedBlock.nodeType, + ); + return target !== null; + }); } -// Gets the placement for moving a block up. This has 3 cases: -// 1. If the block has a previous sibling without children, the placement is -// before it. -// 2. If the block has a previous sibling with children, the placement is after -// the last child. -// 3. If the block has no previous sibling, but is nested, the placement is -// before its parent. -// If the placement is invalid, the function is called recursively until a valid -// placement is found. Returns undefined if no valid placement is found, meaning -// the block is already at the top of the document. +/** + * Gets the placement for moving a block up. This has 3 cases: + * 1. If the block has a previous sibling without children, the placement is + * before it. + * 2. If the block has a previous sibling with children, the placement is after + * the last child. + * 3. If the block has no previous sibling, but is nested, the placement is + * before its parent. + * If the placement is invalid, the function is called recursively until a valid + * placement is found. Returns undefined if no valid placement is found, meaning + * the block is already at the top of the document. + * + * @param movedBlock What is being moved (see {@link MovedBlock}). Carried + * through the recursion because "is this placement valid?" depends on it: a + * candidate destination has to accept the moved node's type. Only read by + * `checkPlacementIsValid`. + * @param prevBlock The candidate previous sibling, i.e. the block the + * placement is measured against. Steps further back on each recursion. + * @param parentBlock The parent of `prevBlock`'s level, used for case 3. + */ function getMoveUpPlacement( editor: BlockNoteEditor, + movedBlock: MovedBlock, prevBlock?: Block, parentBlock?: Block, ): @@ -253,10 +318,11 @@ function getMoveUpPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveUpPlacement( editor, + movedBlock, placement === "after" ? referenceBlock : editor.getPrevBlock(referenceBlock), @@ -267,18 +333,26 @@ function getMoveUpPlacement( return { referenceBlock, placement }; } -// Gets the placement for moving a block down. This has 3 cases: -// 1. If the block has a next sibling without children, the placement is after -// it. -// 2. If the block has a next sibling with children, the placement is before the -// first child. -// 3. If the block has no next sibling, but is nested, the placement is -// after its parent. -// If the placement is invalid, the function is called recursively until a valid -// placement is found. Returns undefined if no valid placement is found, meaning -// the block is already at the bottom of the document. +/** + * Gets the placement for moving a block down. This has 3 cases: + * 1. If the block has a next sibling without children, the placement is after + * it. + * 2. If the block has a next sibling with children, the placement is before the + * first child. + * 3. If the block has no next sibling, but is nested, the placement is + * after its parent. + * If the placement is invalid, the function is called recursively until a valid + * placement is found. Returns undefined if no valid placement is found, meaning + * the block is already at the bottom of the document. + * + * @param movedBlock What is being moved; see `getMoveUpPlacement`. + * @param nextBlock The candidate next sibling, i.e. the block the placement is + * measured against. Steps further forward on each recursion. + * @param parentBlock The parent of `nextBlock`'s level, used for case 3. + */ function getMoveDownPlacement( editor: BlockNoteEditor, + movedBlock: MovedBlock, nextBlock?: Block, parentBlock?: Block, ): @@ -305,10 +379,11 @@ function getMoveDownPlacement( return undefined; } - const referenceBlockParent = editor.getParentBlock(referenceBlock); - if (!checkPlacementIsValid(referenceBlockParent)) { + if (!checkPlacementIsValid(editor, referenceBlock, placement, movedBlock)) { + const referenceBlockParent = editor.getParentBlock(referenceBlock); return getMoveDownPlacement( editor, + movedBlock, placement === "before" ? referenceBlock : editor.getNextBlock(referenceBlock), @@ -338,6 +413,10 @@ export function moveBlocksUp( const moveUpPlacement = getMoveUpPlacement( editor, + // `moveBlocks` inserts the flattened selection (a `column` goes in as + // its children), so the placement is validated for the block that + // actually lands at the destination, not for the raw block. + toMovedBlock(editor, flattenColumns([sourceBlock])[0] ?? sourceBlock), editor.getPrevBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); @@ -369,20 +448,33 @@ export function moveBlocksDown( ) { editor.transact(() => { let sourceBlock: Block | undefined; + // The block whose position anchors the move (the last of a selection when + // moving down) vs. the first block that gets inserted, which is what the + // placement check must validate against. + let firstMovedBlock: Block | undefined; if (blockIdentifier) { sourceBlock = editor.getBlock(blockIdentifier); if (!sourceBlock) { return; } + firstMovedBlock = sourceBlock; } else { const selection = editor.getSelection(); sourceBlock = selection?.blocks[selection?.blocks.length - 1] || editor.getTextCursorPosition().block; + firstMovedBlock = + selection?.blocks[0] || editor.getTextCursorPosition().block; } const moveDownPlacement = getMoveDownPlacement( editor, + // See `moveBlocksUp`: validate for the flattened block that actually + // lands at the destination. + toMovedBlock( + editor, + flattenColumns([firstMovedBlock])[0] ?? firstMovedBlock, + ), editor.getNextBlock(sourceBlock), editor.getParentBlock(sourceBlock), ); diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index a0f76fdff0..55f7200f3f 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -1,9 +1,27 @@ -import { Fragment, NodeRange, NodeType, Slice } from "prosemirror-model"; +import { Fragment, Node, NodeRange, NodeType, Slice } from "prosemirror-model"; import { Transaction } from "prosemirror-state"; import { canJoin, liftTarget, ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; -import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js"; +/** + * Whether `node` is the sibling list that nesting and unnesting operate on: a + * node that holds child blocks, and can hold the kind of node being moved. + * + * `blockRange` stops at the *deepest* ancestor matching this, so the second + * condition is load-bearing. A `columnList` holds child blocks, but only + * `column`s — matching it would resolve the range at the columns themselves, + * where `sinkItem`'s `nodeBefore` is a `column` rather than a `blockContainer` + * and `liftItem`'s parent is a `blockGroup` rather than a `blockContainer`, so + * both bail. Skipping it lets the walk continue out to the `blockGroup` the + * list sits in, and Tab across two columns indents the list as a unit. + */ +function holdsItems(node: Node, itemType: NodeType) { + return ( + node.childCount > 0 && + node.type.isInGroup("childContainer") && + node.type.contentMatch.matchType(itemType) !== null + ); +} /** * Modified version of prosemirror-schema-list's sinkItem. @@ -17,12 +35,7 @@ import { getBlockInfoFromSelection } from "../../../getBlockInfoFromPos.js"; */ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { const { $from, $to } = tr.selection; - const range = $from.blockRange( - $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 - ); + const range = $from.blockRange($to, (node) => holdsItems(node, itemType)); // change 1 if (!range) { return false; } @@ -64,14 +77,17 @@ function sinkItem(tr: Transaction, itemType: NodeType, groupType: NodeType) { return true; } -export function nestBlock(editor: BlockNoteEditor) { - return editor.transact((tr) => { - return sinkItem( +function nestCommand(editor: BlockNoteEditor) { + return (tr: Transaction) => + sinkItem( tr, editor.pmSchema.nodes["blockContainer"], editor.pmSchema.nodes["blockGroup"], ); - }); +} + +export function nestBlock(editor: BlockNoteEditor) { + return editor.transact(nestCommand(editor)); } /** @@ -161,12 +177,7 @@ export function liftItem( groupType: NodeType, // change 2 ) { const { $from, $to } = tr.selection; - const range = $from.blockRange( - $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change 1 - ); + const range = $from.blockRange($to, (node) => holdsItems(node, itemType)); // change 1 if (!range) { return false; } @@ -181,28 +192,28 @@ export function liftItem( return false; } -export function unnestBlock(editor: BlockNoteEditor) { - return editor.transact((tr) => +function unnestCommand(editor: BlockNoteEditor) { + return (tr: Transaction) => liftItem( tr, editor.pmSchema.nodes["blockContainer"], editor.pmSchema.nodes["blockGroup"], - ), - ); + ); } -export function canNestBlock(editor: BlockNoteEditor) { - return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); +export function unnestBlock(editor: BlockNoteEditor) { + return editor.transact(unnestCommand(editor)); +} - return tr.doc.resolve(blockContainer.beforePos).nodeBefore !== null; - }); +// `canExec` hands the command a transaction it never dispatches, so "can I +// nest?" is answered by nesting and throwing the result away. A second +// statement of the preconditions would drift from the command it describes — +// and did: it read a previous sibling's mere existence, so a block before the +// cursor enabled the button while `nestBlock` did nothing. +export function canNestBlock(editor: BlockNoteEditor) { + return editor.canExec((state) => nestCommand(editor)(state.tr)); } export function canUnnestBlock(editor: BlockNoteEditor) { - return editor.transact((tr) => { - const { bnBlock: blockContainer } = getBlockInfoFromSelection(tr); - - return tr.doc.resolve(blockContainer.beforePos).depth > 1; - }); + return editor.canExec((state) => unnestCommand(editor)(state.tr)); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts index 5a968c49bf..4951b09fb4 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { setupTestEnv } from "../../setupTestEnv.js"; +import { updateBlock } from "../updateBlock/updateBlock.js"; import { removeAndInsertBlocks } from "./replaceBlocks.js"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; import { PartialBlock } from "../../../../blocks/defaultBlocks.js"; @@ -233,3 +234,73 @@ describe("Test replaceBlocks", () => { expect(getEditor().document).toMatchSnapshot(); }); }); + +// `removeAndInsertBlocks` walks the document while mutating it, so the +// positions it reads go stale as it goes. It corrects for that with +// `tr.mapping.slice(stepsBefore)`, where `stepsBefore` is the step count on +// entry. The slice is what makes the function safe to call on a transaction +// that already carries steps: an unsliced `tr.mapping` would re-apply the +// caller's earlier steps to positions that already account for them, and the +// resulting delete ranges would land on the wrong nodes. +describe("Test replaceBlocks on a transaction that already has steps", () => { + it("Removes the right blocks across two calls in one transaction", () => { + const editor = getEditor(); + const before = editor.document; + + editor.transact((tr) => { + removeAndInsertBlocks(tr, ["paragraph-0"], []); + removeAndInsertBlocks(tr, ["paragraph-2"], []); + }); + + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + expect(editor.document).toEqual( + before.filter( + (block) => block.id !== "paragraph-0" && block.id !== "paragraph-2", + ), + ); + }); + + it("Removes the right block after the caller has already updated one", () => { + const editor = getEditor(); + const before = editor.document; + + editor.transact((tr) => { + // Changes the size of a block that sits before the one removed below, + // so the removal's positions are only correct if the earlier step is + // accounted for exactly once. + updateBlock(tr, "paragraph-0", { + type: "heading", + content: "Updated heading", + }); + const inserted: PartialBlock[] = [ + { id: "inserted-paragraph", type: "paragraph", content: "Inserted" }, + ]; + removeAndInsertBlocks(tr, ["paragraph-2"], inserted); + }); + + expect(() => editor.prosemirrorState.doc.check()).not.toThrow(); + + const updated = editor.getBlock("paragraph-0")!; + expect(updated.type).toBe("heading"); + expect(updated.content).toEqual([ + { type: "text", text: "Updated heading", styles: {} }, + ]); + + expect(editor.document.map((block) => block.id)).toEqual( + before.map((block) => + block.id === "paragraph-2" ? "inserted-paragraph" : block.id, + ), + ); + // Every block the two operations didn't target is left exactly as it was. + expect( + editor.document.filter( + (block) => + block.id !== "paragraph-0" && block.id !== "inserted-paragraph", + ), + ).toEqual( + before.filter( + (block) => block.id !== "paragraph-0" && block.id !== "paragraph-2", + ), + ); + }); +}); 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 ab02a865f0..b403aec535 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,16 +33,14 @@ function setSelectionWithOffset( throw new Error(`Block with ID ${targetBlockId} not found`); } - const info = getBlockInfo(posInfo); + const info = getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); - if (!info.isBlockContainer) { + 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), - ), + tr.setSelection(TextSelection.create(doc, info.contentStart + offset)), ); } @@ -139,7 +137,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 1e73471d23..d5229da6bf 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.ts @@ -1,7 +1,7 @@ import { EditorState, Transaction } from "prosemirror-state"; import { - getBlockInfo, + getBlockInfoFromNode, getNearestBlockPos, } from "../../../getBlockInfoFromPos.js"; import { getPmSchema } from "../../../pmUtil.js"; @@ -34,21 +34,24 @@ export const splitBlockTr = ( ): boolean => { const nearestBlockContainerPos = getNearestBlockPos(tr.doc, posInBlock); - const info = getBlockInfo(nearestBlockContainerPos); + const info = getBlockInfoFromNode( + nearestBlockContainerPos.node, + nearestBlockContainerPos.posBeforeNode, + ); - if (!info.isBlockContainer) { + if (!info.hasContent) { return false; } const schema = getPmSchema(tr); const types = [ { - type: info.bnBlock.node.type, // always keep blockcontainer type - attrs: keepProps ? { ...info.bnBlock.node.attrs, id: undefined } : {}, + type: info.block.node.type, // always keep blockcontainer type + attrs: keepProps ? { ...info.block.node.attrs, id: undefined } : {}, }, { - type: keepType ? info.blockContent.node.type : schema.nodes["paragraph"], - attrs: keepProps ? { ...info.blockContent.node.attrs } : {}, + type: keepType ? info.content.node.type : schema.nodes["paragraph"], + attrs: keepProps ? { ...info.content.node.attrs } : {}, }, ]; diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.test.ts index e44e4a6380..77d2cad826 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,13 @@ 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"; + +// Adapter over the renamed producer: `getNodeById` already returns the +// `{ node, posBeforeNode }` pair it takes. +function getBlockInfo(posInfo: { node: any; posBeforeNode: number }) { + return getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode); +} import { getNodeById } from "../../../nodeUtil.js"; import { setupTestEnv } from "../../setupTestEnv.js"; import { updateBlock } from "./updateBlock.js"; @@ -181,7 +187,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); } @@ -198,7 +204,7 @@ describe("Test updateBlock", () => { }, ], }, - info.blockContent.beforePos + 9, + info.content.beforePos + 9, ), ); @@ -210,7 +216,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + if (!info.hasContent) { throw new Error("heading-with-everything is not a block container"); } @@ -227,8 +233,8 @@ describe("Test updateBlock", () => { }, ], }, - info.blockContent.beforePos + 9, - info.blockContent.beforePos + 9, + info.content.beforePos + 9, + info.content.beforePos + 9, ), ); @@ -240,7 +246,7 @@ describe("Test updateBlock", () => { getNodeById("heading-with-everything", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + 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, ); }); @@ -273,11 +279,11 @@ describe("Test updateBlock", () => { getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + 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 +296,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, ), ); @@ -303,11 +309,11 @@ describe("Test updateBlock", () => { getNodeById("table-0", getEditor().prosemirrorState.doc)!, ); - if (!info.isBlockContainer) { + 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 +330,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, ), ); @@ -940,7 +946,7 @@ describe("Test updateBlock minimal steps", () => { editor.prosemirrorState.doc, )!, ); - if (!info.isBlockContainer) { + if (!info.hasContent) { throw new Error("paragraph-with-styled-content is not a block container"); } @@ -959,8 +965,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 6edfc434d5..e487f99fd4 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -18,7 +18,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, @@ -63,7 +63,12 @@ export function updateBlockTr< replaceFromPos?: number, replaceToPos?: number, ) { - const blockInfo = getBlockInfoFromResolvedPos(tr.doc.resolve(posBeforeBlock)); + // Positions in `blockInfo` are valid in the doc as it stands now, i.e. + // after any steps the caller already added to `tr`. Remapping them through + // the whole `tr.mapping` would apply those caller steps twice, so + // `restoreCellAnchor` maps through only the steps added below. + const stepsBefore = tr.mapping.maps.length; + const blockInfo = getBlockInfoAt(tr.doc, posBeforeBlock); let cellAnchor: CellAnchor | null = null; if (blockInfo.blockNoteType === "table") { @@ -82,44 +87,33 @@ export function updateBlockTr< // Adds blockGroup node with child blocks if necessary. - const oldNodeType = pmSchema.nodes[blockInfo.blockNoteType]; - const newNodeType = pmSchema.nodes[block.type || blockInfo.blockNoteType]; + const newBlockType = block.type || blockInfo.blockNoteType; + const newNodeType = pmSchema.nodes[newBlockType]; const newBnBlockNodeType = newNodeType.isInGroup("bnBlock") ? newNodeType : pmSchema.nodes["blockContainer"]; - if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) { - const replaceFromOffset = - replaceFromPos !== undefined && - replaceFromPos > blockInfo.blockContent.beforePos && - replaceFromPos < blockInfo.blockContent.afterPos - ? replaceFromPos - blockInfo.blockContent.beforePos - 1 - : undefined; - - const replaceToOffset = - replaceToPos !== undefined && - replaceToPos > blockInfo.blockContent.beforePos && - replaceToPos < blockInfo.blockContent.afterPos - ? replaceToPos - blockInfo.blockContent.beforePos - 1 - : undefined; - - updateChildren(block, tr, blockInfo); - // The code below determines the new content of the block. - // or "keep" to keep as-is - updateBlockContentNode( - block, - tr, - oldNodeType, - newNodeType, - blockInfo, - replaceFromOffset, - replaceToOffset, - ); - } else if (!blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock")) { - updateChildren(block, tr, blockInfo); - // old node was a bnBlock type (like column or columnList) and new block as well - // No op, we just update the bnBlock below (at end of function) and have already updated the children - } else { + const replaceFromOffset = + blockInfo.hasContent && + replaceFromPos !== undefined && + replaceFromPos >= blockInfo.contentStart && + replaceFromPos <= blockInfo.contentEnd + ? replaceFromPos - blockInfo.contentStart + : undefined; + + const replaceToOffset = + blockInfo.hasContent && + replaceToPos !== undefined && + replaceToPos >= blockInfo.contentStart && + replaceToPos <= blockInfo.contentEnd + ? replaceToPos - blockInfo.contentStart + : undefined; + + // `hasContent` is exactly `blockContainer`-ness, and a block type resolves + // to either a `blockContent` node (a regular block) or a `bnBlock` one (a + // wrapper), so the two together say whether the update keeps the block's + // shape. Only a same-shape update can happen in place. + if (blockInfo.hasContent !== newNodeType.isInGroup("blockContent")) { // switching from blockContainer to non-blockContainer or v.v. // currently breaking for column slash menu items converting empty block // to column. @@ -127,7 +121,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 replacementNode = blockToNode( { children: existingBlock.children, // if no children are passed in, use existing children @@ -137,24 +131,40 @@ 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, ); return; } + updateChildren(block, tr, blockInfo); + + if (blockInfo.hasContent) { + // The code below determines the new content of the block. + // or "keep" to keep as-is + updateBlockContentNode( + block, + tr, + pmSchema.nodes[blockInfo.blockNoteType], + newNodeType, + blockInfo, + replaceFromOffset, + replaceToOffset, + ); + } + // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing // 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, }); if (cellAnchor) { - restoreCellAnchor(tr, blockInfo, cellAnchor); + restoreCellAnchor(tr, blockInfo, cellAnchor, stepsBefore); } } @@ -168,10 +178,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, @@ -202,7 +212,7 @@ function updateBlockContentNode< // 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; + 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 @@ -217,7 +227,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 @@ -233,7 +243,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) { @@ -241,7 +251,7 @@ function updateBlockContentNode< // position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, - blockInfo.blockContent.beforePos, + blockInfo.content.beforePos, newNodeType, { ...block.props }, ); @@ -250,7 +260,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 @@ -270,7 +280,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. @@ -280,7 +290,7 @@ function updateBlockContentNode< // get its (possibly shifted) position back. const contentBeforePos = setNodeMarkupMinimalAndRemap( tr, - blockInfo.blockContent.beforePos, + blockInfo.content.beforePos, newNodeType, { ...block.props }, ); @@ -293,11 +303,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, @@ -511,22 +521,21 @@ 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.isBlockContainer) { - throw new Error("impossible"); - } - // Inserts a new blockGroup containing the child nodes created earlier. + } else if (blockInfo.hasContent) { + // A `blockContainer` with no children yet: its `blockGroup` is lazy + // (`blockContent blockGroup?`), so insert a new one after the content + // node. tr.insert( - blockInfo.blockContent.afterPos, + blockInfo.content.afterPos, pmSchema.nodes["blockGroup"].createChecked({}, childNodes), ); } @@ -559,11 +568,14 @@ export function updateBlock< replaceToPos, ); - const blockContainerNode = tr.doc - .resolve(posInfo.posBeforeNode + 1) // TODO: clean? - .node(); + // `updateBlockTr` may have replaced the node, so re-resolve it at the same + // position (an update never moves the block). + const updatedNode = tr.doc.resolve(posInfo.posBeforeNode).nodeAfter; + if (!updatedNode) { + throw new Error(`Block with ID ${id} not found after update`); + } - return nodeToBlock(blockContainerNode, tr.doc); + return nodeToBlock(updatedNode, tr.doc); } type CellAnchor = { row: number; col: number; offset: number }; @@ -629,6 +641,7 @@ function restoreCellAnchor( tr: Transform | Transaction, blockInfo: BlockInfo, a: CellAnchor, + stepsBefore: number, ): boolean { if (blockInfo.blockNoteType !== "table") { return false; @@ -637,12 +650,12 @@ function restoreCellAnchor( // 1) Resolve the table node in the current document let tablePos = -1; - if (blockInfo.isBlockContainer) { - // 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.slice(stepsBefore).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.slice(stepsBefore).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/getBlock/getBlock.ts b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts index 1d87f58b49..98fc3b7eef 100644 --- a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts +++ b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts @@ -6,6 +6,7 @@ import type { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; +import { getParentBlockInfo } from "../../getBlockInfoFromPos.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../nodeUtil.js"; @@ -94,18 +95,10 @@ export function getParentBlock< return undefined; } - const $posBeforeNode = doc.resolve(posInfo.posBeforeNode); - const parentNode = $posBeforeNode.node(); - const grandparentNode = $posBeforeNode.node(-1); - const nodeToConvert = - grandparentNode.type.name !== "doc" - ? parentNode.type.name === "blockGroup" - ? grandparentNode - : parentNode - : undefined; - if (!nodeToConvert) { + const parentInfo = getParentBlockInfo(doc, posInfo.posBeforeNode); + if (!parentInfo) { return undefined; } - return nodeToBlock(nodeToConvert, doc); + return nodeToBlock(parentInfo.block.node, doc); } diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index d6229a3f0a..34591c8c8d 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -1,5 +1,4 @@ import { TextSelection, type Transaction } from "prosemirror-state"; -import { TableMap } from "prosemirror-tables"; import { Block } from "../../../blocks/defaultBlocks.js"; import { Selection } from "../../../editor/selectionTypes.js"; import { @@ -9,13 +8,16 @@ import { StyleSchema, } from "../../../schema/index.js"; import { expandPMRangeToWords } from "../../../util/expandToWords.js"; -import { getBlockInfo, getNearestBlockPos } from "../../getBlockInfoFromPos.js"; +import { + blockEdgePos, + getBlockInfoFromNode, + getNearestBlockPos, +} from "../../getBlockInfoFromPos.js"; import { nodeToBlock, prosemirrorSliceToSlicedBlocks, } from "../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../nodeUtil.js"; -import { getBlockNoteSchema, getPmSchema } from "../../pmUtil.js"; export function getSelection< BSchema extends BlockSchema, @@ -140,8 +142,6 @@ export function setSelection( const startBlockId = typeof startBlock === "string" ? startBlock : startBlock.id; const endBlockId = typeof endBlock === "string" ? endBlock : endBlock.id; - const pmSchema = getPmSchema(tr); - const schema = getBlockNoteSchema(pmSchema); if (startBlockId === endBlockId) { throw new Error( @@ -157,62 +157,28 @@ export function setSelection( throw new Error(`Block with ID ${endBlockId} not found`); } - const anchorBlockInfo = getBlockInfo(anchorPosInfo); - const headBlockInfo = getBlockInfo(headPosInfo); - - const anchorBlockConfig = - schema.blockSchema[ - anchorBlockInfo.blockNoteType as keyof typeof schema.blockSchema - ]; - const headBlockConfig = - schema.blockSchema[ - headBlockInfo.blockNoteType as keyof typeof schema.blockSchema - ]; + const anchorBlockInfo = getBlockInfoFromNode( + anchorPosInfo.node, + anchorPosInfo.posBeforeNode, + ); + const headBlockInfo = getBlockInfoFromNode( + headPosInfo.node, + headPosInfo.posBeforeNode, + ); - if ( - !anchorBlockInfo.isBlockContainer || - anchorBlockConfig.content === "none" - ) { + const startPos = blockEdgePos(anchorBlockInfo, "start"); + if (startPos === null) { throw new Error( `Attempting to set selection anchor in block without content (id ${startBlockId})`, ); } - if (!headBlockInfo.isBlockContainer || headBlockConfig.content === "none") { + const endPos = blockEdgePos(headBlockInfo, "end"); + if (endPos === null) { throw new Error( - `Attempting to set selection anchor in block without content (id ${endBlockId})`, + `Attempting to set selection head in block without content (id ${endBlockId})`, ); } - let startPos: number; - let endPos: number; - - if (anchorBlockConfig.content === "table") { - const tableMap = TableMap.get(anchorBlockInfo.blockContent.node); - const firstCellPos = - anchorBlockInfo.blockContent.beforePos + - tableMap.positionAt(0, 0, anchorBlockInfo.blockContent.node) + - 1; - startPos = firstCellPos + 2; - } else { - startPos = anchorBlockInfo.blockContent.beforePos + 1; - } - - if (headBlockConfig.content === "table") { - const tableMap = TableMap.get(headBlockInfo.blockContent.node); - const lastCellPos = - headBlockInfo.blockContent.beforePos + - tableMap.positionAt( - tableMap.height - 1, - tableMap.width - 1, - headBlockInfo.blockContent.node, - ) + - 1; - const lastCellNodeSize = tr.doc.resolve(lastCellPos).nodeAfter!.nodeSize; - endPos = lastCellPos + lastCellNodeSize - 2; - } else { - endPos = headBlockInfo.blockContent.afterPos - 1; - } - // TODO: We should polish up the `MultipleNodeSelection` and use that instead. // Right now it's missing a few things like a jsonID and styling to show // which nodes are selected. `TextSelection` is ok for now, but has the diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts index b0b2cc078d..0130af1992 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition.ts @@ -1,9 +1,4 @@ -import type { Node } from "prosemirror-model"; -import { - NodeSelection, - TextSelection, - type Transaction, -} from "prosemirror-state"; +import { type Transaction } from "prosemirror-state"; import type { TextCursorPosition } from "../../../editor/cursorPositionTypes.js"; import type { BlockIdentifier, @@ -11,43 +6,34 @@ import type { InlineContentSchema, StyleSchema, } from "../../../schema/index.js"; -import { UnreachableCaseError } from "../../../util/typescript.js"; import { - getBlockInfo, + blockEdgeSelection, + getBlockInfoFromNode, getBlockInfoFromSelection, - getNodeId, + getParentBlockInfo, } from "../../getBlockInfoFromPos.js"; import { nodeToBlock } from "../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../nodeUtil.js"; -import { getBlockNoteSchema, getPmSchema } from "../../pmUtil.js"; export function getTextCursorPosition< BSchema extends BlockSchema, I extends InlineContentSchema, S extends StyleSchema, >(tr: Transaction): TextCursorPosition { - const { bnBlock } = getBlockInfoFromSelection(tr); + const { block } = getBlockInfoFromSelection(tr); - const resolvedPos = tr.doc.resolve(bnBlock.beforePos); + const resolvedPos = tr.doc.resolve(block.beforePos); // Gets previous blockContainer node at the same nesting level, if the current node isn't the first child. const prevNode = resolvedPos.nodeBefore; // Gets next blockContainer node at the same nesting level, if the current node isn't the last child. - const nextNode = tr.doc.resolve(bnBlock.afterPos).nodeAfter; + const nextNode = tr.doc.resolve(block.afterPos).nodeAfter; - // Gets parent blockContainer node, if the current node is nested. - let parentNode: Node | undefined = undefined; - if (resolvedPos.depth > 1) { - // for nodes nested in bnBlocks - parentNode = resolvedPos.node(); - if (!parentNode.type.isInGroup("bnBlock")) { - // for blockGroups, we need to go one level up - parentNode = resolvedPos.node(resolvedPos.depth - 1); - } - } + // Gets the parent block's node, if the current block is nested. + const parentNode = getParentBlockInfo(tr.doc, block.beforePos)?.block.node; return { - block: nodeToBlock(bnBlock.node, tr.doc), + block: nodeToBlock(block.node, tr.doc), prevBlock: prevNode === null ? undefined : nodeToBlock(prevNode, tr.doc), nextBlock: nextNode === null ? undefined : nodeToBlock(nextNode, tr.doc), parentBlock: @@ -61,58 +47,17 @@ export function setTextCursorPosition( placement: "start" | "end" = "start", ) { const id = typeof targetBlock === "string" ? targetBlock : targetBlock.id; - const pmSchema = getPmSchema(tr.doc); - const schema = getBlockNoteSchema(pmSchema); const posInfo = getNodeById(id, tr.doc); if (!posInfo) { throw new Error(`Block with ID ${id} not found`); } - const info = getBlockInfo(posInfo); - - const contentType: "none" | "inline" | "table" | "plain" = - schema.blockSchema[info.blockNoteType]!.content; - - if (info.isBlockContainer) { - const blockContent = info.blockContent; - if (contentType === "none") { - tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos)); - return; - } - - if (contentType === "inline" || contentType === "plain") { - if (placement === "start") { - tr.setSelection( - TextSelection.create(tr.doc, blockContent.beforePos + 1), - ); - } else { - tr.setSelection( - TextSelection.create(tr.doc, blockContent.afterPos - 1), - ); - } - } else if (contentType === "table") { - if (placement === "start") { - // Need to offset the position as we have to get through the `tableRow` - // and `tableCell` nodes to get to the `tableParagraph` node we want to - // set the selection in. - tr.setSelection( - TextSelection.create(tr.doc, blockContent.beforePos + 4), - ); - } else { - tr.setSelection( - TextSelection.create(tr.doc, blockContent.afterPos - 4), - ); - } - } else { - throw new UnreachableCaseError(contentType); - } - } else { - const child = - placement === "start" - ? info.childContainer.node.firstChild! - : info.childContainer.node.lastChild!; - - setTextCursorPosition(tr, getNodeId(child, tr.doc), placement); - } + tr.setSelection( + blockEdgeSelection( + tr.doc, + getBlockInfoFromNode(posInfo.node, posInfo.posBeforeNode), + placement, + ), + ); } diff --git a/packages/core/src/api/blockManipulation/setupTestEnv.ts b/packages/core/src/api/blockManipulation/setupTestEnv.ts index c1da2be25e..c54fea1f09 100644 --- a/packages/core/src/api/blockManipulation/setupTestEnv.ts +++ b/packages/core/src/api/blockManipulation/setupTestEnv.ts @@ -1,14 +1,43 @@ import { afterAll, beforeAll, beforeEach } from "vite-plus/test"; -import { PartialBlock } from "../../blocks/defaultBlocks.js"; +import { BlockNoteSchema } from "../../blocks/BlockNoteSchema.js"; +import { + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, + PartialBlock, +} from "../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; +import { + BlockSchema, + InlineContentSchema, + StyleSchema, +} from "../../schema/index.js"; -export function setupTestEnv() { - let editor: BlockNoteEditor; +/** + * Mounts an editor for a test file and resets its document before each test. + * + * Called without arguments it uses the default schema and {@link testDocument}. + * A custom schema and document have to be passed together, since a document is + * only valid against the schema it was written for. + */ +export function setupTestEnv< + B extends BlockSchema = DefaultBlockSchema, + I extends InlineContentSchema = DefaultInlineContentSchema, + S extends StyleSchema = DefaultStyleSchema, +>(options?: { + schema: BlockNoteSchema; + document: PartialBlock[]; +}): () => BlockNoteEditor { + let editor: BlockNoteEditor; const div = document.createElement("div"); beforeAll(() => { - editor = BlockNoteEditor.create(); + // `B`/`I`/`S` fall back to the default schema's types, but TS can't see + // that from inside the body, so the no-options branch needs a cast. + editor = options + ? BlockNoteEditor.create({ schema: options.schema }) + : (BlockNoteEditor.create() as unknown as BlockNoteEditor); editor.mount(div); }); @@ -18,13 +47,16 @@ export function setupTestEnv() { }); beforeEach(() => { - editor.replaceBlocks(editor.document, testDocument); + editor.replaceBlocks( + editor.document, + options?.document ?? (testDocument as unknown as PartialBlock[]), + ); }); return () => editor; } -const testDocument: PartialBlock[] = [ +export const testDocument: PartialBlock[] = [ { id: "paragraph-0", type: "paragraph", 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..f2e90af123 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,255 @@ 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 plain", () => { + 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("plain"); + }); + + it("rejects a content node that was not built from a block spec", () => { + // A node dropped straight into the `blockContent` group of a ProseMirror + // schema, with no block spec behind it: nothing declares what its content + // is, so there is no content kind to report. + const schema = new Schema({ + nodes: { + doc: { content: "blockGroup" }, + blockGroup: { content: "blockContainer+" }, + blockContainer: { + content: "blockContent", + group: "bnBlock", + attrs: { id: { default: null } }, + }, + rogue: { content: "inline*", group: "blockContent" }, + text: { group: "inline" }, + }, + }); + + const node = schema.nodes["blockContainer"].createChecked( + { id: "0" }, + schema.nodes["rogue"].createChecked({}, schema.text("Hello")), + ); + + expect(() => getBlockInfoFromNode(node, 0)).toThrow( + /was not built from a block spec/, + ); + }); +}); + +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 04ed789c98..66298388d7 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,53 +1,246 @@ -import { Node, ResolvedPos } from "prosemirror-model"; -import { EditorState, Transaction } from "prosemirror-state"; +import { Node } from "prosemirror-model"; +import { + EditorState, + NodeSelection, + Selection, + TextSelection, + Transaction, +} from "prosemirror-state"; +import type { BlockConfig } from "../schema/blocks/types.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. + */ + +/** A ProseMirror node making up (part of) a block, and where it sits. */ type SingleBlockInfo = { + /** The node itself. */ node: Node; + /** The position just before the node, i.e. `node`'s own position. */ beforePos: number; + /** The position just after the node: `beforePos + node.nodeSize`. */ 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; +}; + 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; } & ( | { - // In case we're not dealing with a BlockContainer, we're dealing with a "wrapper node" (like a Column or ColumnList), so it will always have children + // A wrapper block (e.g. a Column or ColumnList from `xl-multi-column`): + // its own node holds its children directly, and it has no content node + // of its own. /** - * The Prosemirror node that holds block.children. For non-blockContainer, this node will be the same as bnBlock. + * The Prosemirror node that holds block.children. For such a wrapper, + * this node is the same as `block`. */ - childContainer: SingleBlockInfo; - isBlockContainer: 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; /** - * Whether bnBlock is a blockContainer node + * What the content node holds: its block spec's `content`, which is what + * the node's ProseMirror content expression was generated from (and what + * a hand-written node's expression is checked against when the schema is + * built). */ - isBlockContainer: true; + contentKind: BlockConfig["content"]; + /** `content.node.childCount === 0`. */ + isContentEmpty: boolean; + /** + * Whether the block has a content node: a `blockContainer` (an + * ordinary block wrapped for nesting), shaped as a content node + * followed by an optional child container. + * + * Note this is the opposite of "is a wrapper block": a column has + * `hasContent: false`. + */ + hasContent: true; } ); +/** + * The caret position at an edge of a table content region: 4 levels in + * (`table` → `tableRow` → `tableCell` → `tableParagraph`) from the region's + * boundary — the first cell's paragraph start, or the last cell's paragraph + * end. + */ +export function tableContentCaretPos( + content: { beforePos: number; afterPos: number }, + edge: "start" | "end", +): number { + return edge === "start" ? content.beforePos + 4 : content.afterPos - 4; +} + +/** + * The caret position at an edge of a block's content, or `null` when the block + * has none there: content that holds no text (an image). + */ +export function blockEdgePos( + info: BlockInfo, + edge: "start" | "end", +): number | null { + if (!info.hasContent || info.contentKind === "none") { + return null; + } + return info.contentKind === "table" + ? tableContentCaretPos(info.content, edge) + : edge === "start" + ? info.contentStart + : info.contentEnd; +} + +/** + * A selection at an edge of a block. Where there is no caret position the + * nearest node is selected instead: the content node of a block holding no + * text. + */ +export function blockEdgeSelection( + doc: Node, + info: BlockInfo, + edge: "start" | "end", +): Selection { + const pos = blockEdgePos(info, edge); + if (pos !== null) { + return TextSelection.create(doc, pos); + } + if (info.hasContent) { + return NodeSelection.create(doc, info.content.beforePos); + } + + const { node, childrenStart, childrenEnd } = info.children; + const child = edge === "start" ? node.firstChild : node.lastChild; + if (!child) { + return NodeSelection.create(doc, info.block.beforePos); + } + return blockEdgeSelection( + doc, + getBlockInfoFromNode( + child, + edge === "start" ? childrenStart : childrenEnd - child.nodeSize, + ), + edge, + ); +} + +/** + * The regions a block node resolves into, answered once for every shape so no + * other code asks "which shape am I": + * + * - a wrapper block (in the `bnBlock` and `childContainer` groups, e.g. a + * Column): its own node holds the children (`childrenHolder.node === outer`, + * offset 0), no content region; + * - a `blockContainer`: a content head at offset 1, and a `blockGroup` + * children holder only once it has children. + * + * `offset` measures from just before `outer` to just before the region's + * node, so with `beforePos` pointing at `outer`, a region's node starts at + * `beforePos + offset` and its inside begins at `beforePos + offset + 1` — + * uniformly across shapes. + */ +function getBlockRegions(node: Node): { + outer: Node; + content?: { node: Node; offset: number }; + childrenHolder?: { node: Node; offset: number }; +} { + if (node.type.isInGroup("bnBlock") && node.type.isInGroup("childContainer")) { + return { outer: node, childrenHolder: { node, offset: 0 } }; + } + + if (node.type.name === "blockContainer") { + const content = node.firstChild; + if (!content) { + throw new Error( + "blockContainer node has no content node. This is a bug in BlockNote.", + ); + } + const lastChild = node.lastChild; + const holder = + lastChild !== content && + lastChild && + lastChild.type.isInGroup("childContainer") + ? { node: lastChild, offset: 1 + content.nodeSize } + : undefined; + + return { + outer: node, + content: { node: content, offset: 1 }, + ...(holder ? { childrenHolder: holder } : {}), + }; + } + + throw new Error( + `Node "${node.type.name}" is not a block node (wrapper or blockContainer).`, + ); +} + +/** + * Whether `node` is only in the document because suggestion mode keeps deleted + * content around: yjs marks such a node `y-attributed-delete` rather than + * removing it, so it shares its ID with the node it stands in for. + */ export function isSuggestedDeletionNode(node: Node): boolean { return node.marks.some((m) => ["y-attributed-delete"].includes(m.type.name)); } +/** + * The block ID to address `node` by. Normally its `id` attribute, but a node + * kept around by suggestion mode (see {@link isSuggestedDeletionNode}) shares + * that attribute with the node it duplicates, so it gets an `-${index}` suffix + * counting the same-ID nodes before it in `doc`. + * + * @throws If `node` has no `id` attribute (every block node does), or if it + * isn't in `doc`. + */ export function getNodeId(node: Node, doc: Node): string { const id = node.attrs.id; if (!id) { @@ -154,138 +347,223 @@ export function getNearestBlockPos(doc: Node, pos: number) { /** * Gets information regarding the ProseMirror nodes that make up a block in a - * BlockNote document. This includes the main `blockContainer` node, the - * `blockContent` node with the block's main body, and the optional `blockGroup` - * node which contains the block's children. As well as the nodes, also returns - * the ProseMirror positions just before & after each node. - * @param node The main `blockContainer` node that the block information should - * be retrieved from, - * @param bnBlockBeforePosOffset the position just before the - * `blockContainer` node in the document. + * BlockNote document, given the block's outer node and the position just + * before it. This includes the outer node with the block's ID, the content + * node with the block's main body, and the optional node which contains the + * block's children. As well as the nodes, also returns the ProseMirror + * positions just before & after each node. + * @param node The outer node that the block information should be retrieved + * from. + * @param beforePos The position just before the outer node in the document. */ -export function getBlockInfoWithManualOffset( - node: Node, - bnBlockBeforePosOffset: number, -): BlockInfo { +export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { if (!node.type.isInGroup("bnBlock")) { throw new Error( - `Attempted to get bnBlock node at position but found node of different type ${node.type.name}`, + `Attempted to get block node at position but found node of different type ${node.type.name}`, ); } - const bnBlockNode = node; - const bnBlockBeforePos = bnBlockBeforePosOffset; - const bnBlockAfterPos = bnBlockBeforePos + bnBlockNode.nodeSize; + // The one place block shape is resolved; everything below is position + // annotation over the regions. + const regions = getBlockRegions(node); - const bnBlock: SingleBlockInfo = { - node: bnBlockNode, - beforePos: bnBlockBeforePos, - afterPos: bnBlockAfterPos, + const block: SingleBlockInfo = { + node, + beforePos, + afterPos: beforePos + node.nodeSize, }; - if (bnBlockNode.type.name === "blockContainer") { - let blockContent: SingleBlockInfo | undefined; - let blockGroup: SingleBlockInfo | undefined; - - bnBlockNode.forEach((node, offset) => { - if (node.type.spec.group === "blockContent") { - // console.log(beforePos, offset); - const blockContentNode = node; - const blockContentBeforePos = bnBlockBeforePos + offset + 1; - const blockContentAfterPos = blockContentBeforePos + node.nodeSize; - - blockContent = { - node: blockContentNode, - beforePos: blockContentBeforePos, - afterPos: blockContentAfterPos, - }; - } else if (node.type.name === "blockGroup") { - const blockGroupNode = node; - const blockGroupBeforePos = bnBlockBeforePos + offset + 1; - const blockGroupAfterPos = blockGroupBeforePos + node.nodeSize; - - blockGroup = { - node: blockGroupNode, - beforePos: blockGroupBeforePos, - afterPos: blockGroupAfterPos, - }; - } - }); - - if (!blockContent) { - throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `blockContainer node does not contain a blockContent node in its children: ${bnBlockNode}`, - ); + if (regions.content) { + const content: SingleBlockInfo = { + node: regions.content.node, + beforePos: beforePos + regions.content.offset, + afterPos: + beforePos + regions.content.offset + regions.content.node.nodeSize, + }; + const holder = regions.childrenHolder; + let children: ChildrenInfo | undefined; + if (holder) { + const holderBeforePos = beforePos + holder.offset; + children = { + node: holder.node, + beforePos: holderBeforePos, + afterPos: holderBeforePos + holder.node.nodeSize, + childrenStart: holderBeforePos + 1, + childrenEnd: holderBeforePos + holder.node.nodeSize - 1, + }; } - return { - isBlockContainer: true, - bnBlock, - blockContent, - childContainer: blockGroup, - blockNoteType: blockContent.node.type.name, - }; - } else { - if (!bnBlock.node.type.isInGroup("childContainer")) { + // Only a node built from a block spec can be a block's content, and such a + // node carries the spec's config, which states the content kind outright. + // A bare node put in the `blockContent` group has no block to speak for + // it, so it is rejected rather than guessed at. + const blockConfig = content.node.type.spec.blockConfig; + if (!blockConfig) { throw new Error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `bnBlock node is not in the childContainer group: ${bnBlock.node}`, + `Block content node "${content.node.type.name}" was not built from a ` + + "block spec, so it has no content kind. Register it with " + + "`createBlockSpec`/`createBlockSpecFromTiptapNode` instead of " + + "joining the `blockContent` group directly.", ); } return { - isBlockContainer: false, - bnBlock: bnBlock, - childContainer: bnBlock, - blockNoteType: bnBlock.node.type.name, + hasContent: true, + block, + content, + children, + contentStart: content.beforePos + 1, + contentEnd: content.afterPos - 1, + contentKind: blockConfig.content, + isContentEmpty: content.node.childCount === 0, + // A `blockContainer` is a generic wrapper, so its type comes from the + // content node inside it. + blockNoteType: content.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. - */ -export function getBlockInfo(posInfo: { posBeforeNode: number; node: Node }) { - return getBlockInfoWithManualOffset(posInfo.node, posInfo.posBeforeNode); + return { + hasContent: false, + block, + // A wrapper block holds its children directly, so the holder is the block + // node itself. + children: { + ...block, + childrenStart: block.beforePos + 1, + childrenEnd: block.afterPos - 1, + }, + blockNoteType: node.type.name, + }; } /** - * 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 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 getBlockInfoFromResolvedPos(resolvedPos: ResolvedPos) { - if (!resolvedPos.nodeAfter) { +export function getBlockInfoAt(doc: Node, posBeforeBlock: number): BlockInfo { + const $pos = doc.resolve(posBeforeBlock); + if (!$pos.nodeAfter) { throw new Error( - `Attempted to get blockContainer node at position ${resolvedPos.pos} but a node at this position does not exist`, + `Attempted to get block node at position ${posBeforeBlock} but a node at this position does not exist`, ); } - return getBlockInfoWithManualOffset(resolvedPos.nodeAfter, resolvedPos.pos); + return getBlockInfoFromNode($pos.nodeAfter, $pos.pos); } /** - * 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 + * 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 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 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)); +/** + * The parent block's info: the block whose `children` contains the block at + * `posBeforeBlock`, or `undefined` for a top-level block. A wrapper block is + * the parent of its direct children (a block inside a column → the column, not + * the columnList); a regular block's children live in its `blockGroup`, so + * the parent is the group's own parent. + */ +export function getParentBlockInfo( + doc: Node, + posBeforeBlock: number, +): BlockInfo | undefined { + const $pos = doc.resolve(posBeforeBlock); + const parent = $pos.node(); + + if (parent.type.isInGroup("bnBlock")) { + return getBlockInfoAt(doc, $pos.before($pos.depth)); + } + // A `blockGroup`: its own parent block is the real parent, unless it's the + // document root group. + if (parent.type.isInGroup("childContainer") && $pos.depth > 1) { + return getBlockInfoAt(doc, $pos.before($pos.depth - 1)); + } + return undefined; +} + +/** + * 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, +): BlockInfo { + while (blockInfo.children && blockInfo.children.node.childCount) { + 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 828894cf1d..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.isBlockContainer) { - throw new Error("expected a block container"); + 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/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index fead006657..8f43164b34 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -18,10 +18,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, @@ -411,11 +408,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(); @@ -430,7 +427,7 @@ export function nodeToBlock< const props: any = {}; for (const [attr, value] of Object.entries({ ...node.attrs, - ...(blockInfo.isBlockContainer ? blockInfo.blockContent.node.attrs : {}), + ...(blockInfo.hasContent ? blockInfo.content.node.attrs : {}), })) { const propSchema = blockSpec.propSchema; @@ -445,37 +442,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.isBlockContainer) { + if (!blockInfo.hasContent) { throw new Error("impossible"); } content = contentNodeToInlineContent( - blockInfo.blockContent.node, + blockInfo.content.node, inlineContentSchema, styleSchema, ); } else if (blockConfig.content === "table") { - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { throw new Error("impossible"); } content = contentNodeToTableContent( - blockInfo.blockContent.node, + blockInfo.content.node, inlineContentSchema, styleSchema, ); } else if (blockConfig.content === "plain") { - if (!blockInfo.isBlockContainer) { + 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; 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 0b33335788..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.isBlockContainer) { + 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 b268598218..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.isBlockContainer) { + 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.isBlockContainer) { + 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 12e558a453..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.isBlockContainer) { + 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/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 25b93d03f4..d685993714 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -7,6 +7,7 @@ import { } from "@tiptap/core"; import { type Command, type Transaction } from "@tiptap/pm/state"; import { Node, Schema } from "prosemirror-model"; +import type { BlockPlacement } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; import { @@ -558,6 +559,13 @@ export class BlockNoteEditor< tiptapOptions.parseOptions, ); + // `blockToNode` is lenient, and `createDocument` builds from JSON + // without validating, so without this check the initial document is + // never validated. A container below its `children.min` would reach + // the editor and stay there, while the same blocks passed to + // `insertBlocks` would have been rejected. + doc.check(); + this._tiptapEditor = new TiptapEditor({ ...tiptapOptions, content: doc.toJSON(), @@ -1051,13 +1059,14 @@ export class BlockNoteEditor< * error if the reference block could not be found. * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. - * @param placement Whether the blocks should be inserted just before, just after, or nested inside the - * `referenceBlock`. + * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next + * (`"after"`) sibling, or nested inside it as its first (`"first-child"`) or last (`"last-child"`) children. Throws + * an error if the `referenceBlock` (or its parent, for `"before"`/`"after"`) doesn't accept the blocks there. */ public insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ) { return this._blockManager.insertBlocks( blocksToInsert, diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index f086444ecc..ca4c62555e 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -1,4 +1,7 @@ -import { insertBlocks } from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; +import { + BlockPlacement, + insertBlocks, +} from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import { moveBlocksDown, moveBlocksUp, @@ -150,13 +153,13 @@ export class BlockManager< * error if the reference block could not be found. * @param blocksToInsert An array of partial blocks that should be inserted. * @param referenceBlock An identifier for an existing block, at which the new blocks should be inserted. - * @param placement Whether the blocks should be inserted just before, just after, or nested inside the - * `referenceBlock`. + * @param placement Where the blocks go relative to the `referenceBlock`: as its previous (`"before"`) or next + * (`"after"`) sibling, or nested inside it as its first (`"first-child"`) or last (`"last-child"`) children. */ public insertBlocks( blocksToInsert: PartialBlock[], referenceBlock: BlockIdentifier, - placement: "before" | "after" = "before", + placement: BlockPlacement = "before", ) { return this.editor.transact((tr) => insertBlocks(tr, blocksToInsert, referenceBlock, placement), diff --git a/packages/core/src/editor/managers/ExtensionManager/index.ts b/packages/core/src/editor/managers/ExtensionManager/index.ts index 5cf6e74c1c..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.isBlockContainer || + !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 4f0515df95..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[] = []; @@ -118,7 +118,11 @@ export function transformPasted(slice: Slice, view: EditorView) { return retyped; } - if (isInTableCell(view)) { + // `tableParagraph` only exists in schemas with the default table blocks. A + // schema with a custom table implementation (e.g. container-block cells, + // which hold real blocks and need no inline conversion) skips this branch. + const tableParagraph = view.state.schema.nodes.tableParagraph; + if (tableParagraph && isInTableCell(view)) { let hasTableContent = false; f.descendants((node) => { if (node.type.isInGroup("tableContent")) { @@ -128,7 +132,7 @@ export function transformPasted(slice: Slice, view: EditorView) { if ( !hasTableContent && // is the content valid for a table paragraph? - !view.state.schema.nodes.tableParagraph.validContent(f) + !tableParagraph.validContent(f) ) { // if not, convert the content to inline content return new Slice( @@ -213,17 +217,15 @@ function retypeLeadingParagraphForEmptyTarget( } const blockInfo = getBlockInfoFromSelection(view.state); - const target = blockInfo.isBlockContainer - ? 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; @@ -275,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.isBlockContainer) { - 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 4d1758094a..12e39ed6ff 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -2,13 +2,7 @@ import { Extension } from "@tiptap/core"; import { Fragment, Node } from "prosemirror-model"; import { TextSelection } from "prosemirror-state"; -import { - getBottomNestedBlockInfo, - getNextBlockInfo, - getParentBlockInfo, - getPrevBlockInfo, - mergeBlocksCommand, -} from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; +import { mergeBlocksCommand } from "../../../api/blockManipulation/commands/mergeBlocks/mergeBlocks.js"; import { liftItem, nestBlock, @@ -18,8 +12,13 @@ import { fixColumnList } from "../../../api/blockManipulation/commands/replaceBl import { splitBlockCommand } from "../../../api/blockManipulation/commands/splitBlock/splitBlock.js"; import { updateBlockCommand } from "../../../api/blockManipulation/commands/updateBlock/updateBlock.js"; import { - getBlockInfoFromResolvedPos, + getBlockInfoAt, getBlockInfoFromSelection, + getLastDescendantBlockInfo, + getNextBlockInfo, + getParentBlockInfo, + getPrevBlockInfo, + tableContentCaretPos, } from "../../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../../editor/BlockNoteEditor.js"; import { FilePanelExtension } from "../../FilePanel/FilePanel.js"; @@ -45,18 +44,18 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + state.selection.from === blockInfo.content.beforePos + 1; 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: {}, }), @@ -69,13 +68,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } - const { blockContent } = blockInfo; + const { content } = blockInfo; const selectionAtBlockStart = - state.selection.from === blockContent.beforePos + 1; + state.selection.from === content.beforePos + 1; if (selectionAtBlockStart) { return liftItem( @@ -92,31 +91,31 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block, content } = 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.isBlockContainer || - 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 === content.beforePos + 1; const selectionEmpty = state.selection.empty; - const posBetweenBlocks = blockContainer.beforePos; + const posBetweenBlocks = block.beforePos; if (selectionAtBlockStart && selectionEmpty) { return chain() @@ -132,33 +131,30 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + state.selection.from === blockInfo.content.beforePos + 1; if (!selectionAtBlockStart) { return false; } const prevBlockInfo = getPrevBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!prevBlockInfo || prevBlockInfo.isBlockContainer) { + if (!prevBlockInfo || prevBlockInfo.hasContent) { return false; } if (dispatch) { - const columnAfterPos = prevBlockInfo.bnBlock.afterPos - 1; + const columnAfterPos = prevBlockInfo.block.afterPos - 1; const $blockAfterPos = tr.doc.resolve(columnAfterPos - 1); - tr.delete( - blockInfo.bnBlock.beforePos, - blockInfo.bnBlock.afterPos, - ); - tr.insert($blockAfterPos.pos, blockInfo.bnBlock.node); + tr.delete(blockInfo.block.beforePos, blockInfo.block.afterPos); + tr.insert($blockAfterPos.pos, blockInfo.block.node); tr.setSelection( TextSelection.near(tr.doc.resolve($blockAfterPos.pos + 1)), ); @@ -174,17 +170,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - tr.selection.from === blockInfo.blockContent.beforePos + 1; + tr.selection.from === blockInfo.content.beforePos + 1; 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) { @@ -196,24 +192,21 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - const $blockPos = tr.doc.resolve(blockInfo.bnBlock.beforePos); + const $blockPos = tr.doc.resolve(blockInfo.block.beforePos); const $columnPos = tr.doc.resolve($blockPos.before()); const columnListPos = $columnPos.before(); if (dispatch) { - tr.delete( - blockInfo.bnBlock.beforePos, - blockInfo.bnBlock.afterPos, - ); + tr.delete(blockInfo.block.beforePos, blockInfo.block.afterPos); fixColumnList(tr, columnListPos); if ($columnPos.pos === columnListPos + 1) { - tr.insert(columnListPos, blockInfo.bnBlock.node); + tr.insert(columnListPos, blockInfo.block.node); tr.setSelection( TextSelection.near(tr.doc.resolve(columnListPos)), ); } else { - tr.insert($columnPos.pos - 1, blockInfo.bnBlock.node); + tr.insert($columnPos.pos - 1, blockInfo.block.node); tr.setSelection( TextSelection.near(tr.doc.resolve($columnPos.pos)), ); @@ -227,32 +220,32 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const blockEmpty = - blockInfo.blockContent.node.childCount === 0 && - blockInfo.blockContent.node.type.spec.content === "inline*"; + blockInfo.content.node.childCount === 0 && + 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.isBlockContainer) { + if (!bottomNestedPrevBlockInfo.hasContent) { return false; } if ( !bottomNestedPrevBlockInfo || - !bottomNestedPrevBlockInfo.isBlockContainer + !bottomNestedPrevBlockInfo.hasContent ) { return false; } @@ -260,18 +253,15 @@ export const KeyboardShortcutsExtension = Extension.create<{ 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+" - ) { - const tableBlockEndPos = blockInfo.bnBlock.beforePos - 1; + if (bottomNestedPrevBlockInfo.contentKind === "table") { + const tableBlockEndPos = blockInfo.block.beforePos - 1; const tableBlockContentEndPos = tableBlockEndPos - 1; const lastRowEndPos = tableBlockContentEndPos - 1; const lastCellEndPos = lastRowEndPos - 1; @@ -280,25 +270,22 @@ export const KeyboardShortcutsExtension = Extension.create<{ chainedCommands = chainedCommands.setTextSelection( lastCellParagraphEndPos, ); - } else if ( - bottomNestedPrevBlockInfo.blockContent.node.type.spec - .content === "" - ) { + } else if (bottomNestedPrevBlockInfo.contentKind === "none") { chainedCommands = chainedCommands.setNodeSelection( - bottomNestedPrevBlockInfo.blockContent.beforePos, + bottomNestedPrevBlockInfo.content.beforePos, ); } else { - const blockContentEndPos = - bottomNestedPrevBlockInfo.blockContent.afterPos - 1; + const contentEndPos = + bottomNestedPrevBlockInfo.content.afterPos - 1; chainedCommands = - chainedCommands.setTextSelection(blockContentEndPos); + chainedCommands.setTextSelection(contentEndPos); } return chainedCommands .deleteRange({ - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, + from: blockInfo.block.beforePos, + to: blockInfo.block.afterPos, }) .scrollIntoView() .run(); @@ -313,47 +300,46 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockStart = - state.selection.from === blockInfo.blockContent.beforePos + 1; + state.selection.from === blockInfo.content.beforePos + 1; const selectionEmpty = state.selection.empty; const prevBlockInfo = getPrevBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) { - const bottomBlock = getBottomNestedBlockInfo( + const bottomBlock = getLastDescendantBlockInfo( state.doc, prevBlockInfo, ); - if (!bottomBlock.isBlockContainer) { + if (!bottomBlock.hasContent) { 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(); } @@ -375,48 +361,47 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer || !blockInfo.childContainer) { + if (!blockInfo.hasContent || !blockInfo.children) { return false; } - const { blockContent, childContainer } = blockInfo; + const { content, children } = blockInfo; const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + state.selection.from === content.afterPos - 1; const selectionEmpty = state.selection.empty; - const firstChildBlockInfo = getBlockInfoFromResolvedPos( - state.doc.resolve(childContainer.beforePos + 1), + const firstChildBlockInfo = getBlockInfoAt( + state.doc, + children.beforePos + 1, ); - if (!firstChildBlockInfo.isBlockContainer) { + if (!firstChildBlockInfo.hasContent) { return false; } if (selectionAtBlockEnd && selectionEmpty) { - const firstChildBlockContent = - firstChildBlockInfo.blockContent.node; + const firstChildBlockContent = firstChildBlockInfo.content.node; const firstChildBlockHasInlineContent = - firstChildBlockContent.type.spec.content === "inline*"; - const blockHasInlineContent = - blockContent.node.type.spec.content === "inline*"; + firstChildBlockInfo.contentKind === "inline"; + const blockHasInlineContent = blockInfo.contentKind === "inline"; return ( chain() // Un-nests child block's children if necessary. .insertContentAt( - firstChildBlockInfo.bnBlock.afterPos, - firstChildBlockInfo.childContainer?.node.content || + firstChildBlockInfo.block.afterPos, + firstChildBlockInfo.children?.node.content || Fragment.empty, ) .deleteRange( // Deletes whole child container if there's only one child. - childContainer.node.childCount === 1 + children.node.childCount === 1 ? { - from: childContainer.beforePos, - to: childContainer.afterPos, + from: children.beforePos, + to: children.afterPos, } : { - from: firstChildBlockInfo.bnBlock.beforePos, - to: firstChildBlockInfo.bnBlock.afterPos, + from: firstChildBlockInfo.block.beforePos, + to: firstChildBlockInfo.block.afterPos, }, ) // Appends inline content from child block if possible. @@ -440,24 +425,24 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block, content } = blockInfo; const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.hasContent) { return false; } const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + state.selection.from === content.afterPos - 1; const selectionEmpty = state.selection.empty; - const posBetweenBlocks = blockContainer.afterPos; + const posBetweenBlocks = block.afterPos; if (selectionAtBlockEnd && selectionEmpty) { return chain() @@ -473,34 +458,34 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockEnd = - state.selection.from === blockInfo.blockContent.afterPos - 1; + state.selection.from === blockInfo.content.afterPos - 1; if (!selectionAtBlockEnd) { return false; } const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || nextBlockInfo.hasContent) { return false; } if (dispatch) { - const columnBeforePos = nextBlockInfo.bnBlock.beforePos + 1; + const columnBeforePos = nextBlockInfo.block.beforePos + 1; const $blockBeforePos = tr.doc.resolve(columnBeforePos + 1); tr.delete( $blockBeforePos.pos, $blockBeforePos.pos + $blockBeforePos.nodeAfter!.nodeSize, ); - fixColumnList(tr, nextBlockInfo.bnBlock.beforePos); - tr.insert(blockInfo.bnBlock.afterPos, $blockBeforePos.nodeAfter!); + fixColumnList(tr, nextBlockInfo.block.beforePos); + tr.insert(blockInfo.block.afterPos, $blockBeforePos.nodeAfter!); tr.setSelection( TextSelection.near(tr.doc.resolve($blockBeforePos.pos)), ); @@ -516,17 +501,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr, dispatch }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockEnd = - tr.selection.from === blockInfo.blockContent.afterPos - 1; + tr.selection.from === blockInfo.content.afterPos - 1; 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) { @@ -538,7 +523,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ return false; } - const $blockEndPos = tr.doc.resolve(blockInfo.bnBlock.afterPos); + const $blockEndPos = tr.doc.resolve(blockInfo.block.afterPos); const $columnEndPos = tr.doc.resolve($blockEndPos.after()); const columnListEndPos = $columnEndPos.after(); @@ -549,19 +534,17 @@ export const KeyboardShortcutsExtension = Extension.create<{ $columnEndPos.pos === columnListEndPos - 1 ? columnListEndPos : $columnEndPos.pos + 1; - const nextBlockInfo = getBlockInfoFromResolvedPos( - tr.doc.resolve(nextBlockBeforePos), - ); + const nextBlockInfo = getBlockInfoAt(tr.doc, nextBlockBeforePos); tr.delete( - nextBlockInfo.bnBlock.beforePos, - nextBlockInfo.bnBlock.afterPos, + nextBlockInfo.block.beforePos, + nextBlockInfo.block.afterPos, ); fixColumnList( tr, columnListEndPos - $columnEndPos.node().nodeSize, ); - tr.insert($blockEndPos.pos, nextBlockInfo.bnBlock.node); + tr.insert($blockEndPos.pos, nextBlockInfo.block.node); tr.setSelection( TextSelection.near(tr.doc.resolve(nextBlockBeforePos)), ); @@ -577,13 +560,13 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } - const { blockContent } = blockInfo; + const { content } = blockInfo; const selectionAtBlockEnd = - state.selection.from === blockContent.afterPos - 1; + state.selection.from === content.afterPos - 1; const selectionEmpty = state.selection.empty; if (selectionAtBlockEnd && selectionEmpty) { @@ -603,41 +586,38 @@ export const KeyboardShortcutsExtension = Extension.create<{ return getNextBlockInfoAtAnyLevel( doc, - parentBlockInfo.bnBlock.beforePos, + parentBlockInfo.block.beforePos, ); }; const nextBlockInfo = getNextBlockInfoAtAnyLevel( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.hasContent) { return false; } - const nextBlockContent = nextBlockInfo.blockContent.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( state.selection.from, nextBlockHasInlineContent && blockHasInlineContent - ? nextBlockContent.content + ? nextBlockInfo.content.node.content : null, ) .setTextSelection(state.selection.from) @@ -653,54 +633,43 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const blockEmpty = - blockInfo.blockContent.node.childCount === 0 && - blockInfo.blockContent.node.type.spec.content === "inline*"; + blockInfo.content.node.childCount === 0 && + blockInfo.contentKind === "inline"; if (blockEmpty) { const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); - if (!nextBlockInfo || !nextBlockInfo.isBlockContainer) { + if (!nextBlockInfo || !nextBlockInfo.hasContent) { return false; } let chainedCommands = chain(); - if ( - nextBlockInfo.blockContent.node.type.spec.content === - "tableRow+" - ) { - const tableBlockStartPos = blockInfo.bnBlock.afterPos + 1; - const tableBlockContentStartPos = tableBlockStartPos + 1; - const firstRowStartPos = tableBlockContentStartPos + 1; - const firstCellStartPos = firstRowStartPos + 1; - const firstCellParagraphStartPos = firstCellStartPos + 1; - + if (nextBlockInfo.contentKind === "table") { chainedCommands = chainedCommands.setTextSelection( - firstCellParagraphStartPos, + tableContentCaretPos(nextBlockInfo.content, "start"), ); - } else if ( - nextBlockInfo.blockContent.node.type.spec.content === "" - ) { + } else if (nextBlockInfo.contentKind === "none") { chainedCommands = chainedCommands.setNodeSelection( - nextBlockInfo.blockContent.beforePos, + nextBlockInfo.content.beforePos, ); } else { chainedCommands = chainedCommands.setTextSelection( - nextBlockInfo.blockContent.beforePos + 1, + nextBlockInfo.content.beforePos + 1, ); } return chainedCommands .deleteRange({ - from: blockInfo.bnBlock.beforePos, - to: blockInfo.bnBlock.afterPos, + from: blockInfo.block.beforePos, + to: blockInfo.block.afterPos, }) .scrollIntoView() .run(); @@ -715,43 +684,41 @@ export const KeyboardShortcutsExtension = Extension.create<{ commands.command(({ state }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } const selectionAtBlockEnd = - state.selection.from === blockInfo.blockContent.afterPos - 1; + state.selection.from === blockInfo.content.afterPos - 1; const selectionEmpty = state.selection.empty; const nextBlockInfo = getNextBlockInfo( state.doc, - blockInfo.bnBlock.beforePos, + blockInfo.block.beforePos, ); if (!nextBlockInfo) { return false; } - if (!nextBlockInfo.isBlockContainer) { + 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; + const childBlocks = nextBlockInfo.block.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 + blockInfo.block.afterPos, + nextBlockInfo.block.node.childCount === 2 ? childBlocks : null, ) @@ -770,18 +737,18 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block, content } = blockInfo; - const { depth } = state.doc.resolve(blockContainer.beforePos); + const { depth } = state.doc.resolve(block.beforePos); const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; const selectionEmpty = state.selection.anchor === state.selection.head; - const blockEmpty = blockContent.node.childCount === 0; + const blockEmpty = content.node.childCount === 0; const blockIndented = depth > 1; if ( @@ -864,19 +831,19 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, dispatch, tr }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } - const { bnBlock: blockContainer, blockContent } = blockInfo; + const { block, content } = blockInfo; const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; const selectionEmpty = state.selection.anchor === state.selection.head; - const blockEmpty = blockContent.node.childCount === 0; + const blockEmpty = content.node.childCount === 0; if (selectionAtBlockStart && selectionEmpty && blockEmpty) { - const newBlockInsertionPos = blockContainer.afterPos; + const newBlockInsertionPos = block.afterPos; const newBlockContentPos = newBlockInsertionPos + 2; if (dispatch) { @@ -889,7 +856,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ [ state.schema.nodes["paragraph"].createAndFill() || undefined, - blockInfo.childContainer?.node, + blockInfo.children?.node, ].filter((node) => node !== undefined), )!; @@ -902,10 +869,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, ); } } @@ -920,14 +887,14 @@ export const KeyboardShortcutsExtension = Extension.create<{ () => commands.command(({ state, chain }) => { const blockInfo = getBlockInfoFromSelection(state); - if (!blockInfo.isBlockContainer) { + if (!blockInfo.hasContent) { return false; } - const { blockContent } = blockInfo; + const { content } = blockInfo; const selectionAtBlockStart = state.selection.$anchor.parentOffset === 0; - const blockEmpty = blockContent.node.childCount === 0; + const blockEmpty = content.node.childCount === 0; if (!blockEmpty) { chain() diff --git a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts index 7ab30b78aa..c6c57a72c9 100644 --- a/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts +++ b/packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts @@ -67,9 +67,12 @@ const UniqueID = Extension.create({ setIdAttribute: false, isWithinEditor: undefined as ((element: Element) => boolean) | undefined, generateID: () => { - // Use mock ID if tests are running. - if (typeof window !== "undefined" && (window as any).__TEST_OPTIONS) { - const testOptions = (window as any).__TEST_OPTIONS; + // Use mock ID if tests are running. Resolved off `globalThis` rather + // than a bare `window` so that tests running in the plain `node` + // environment (no `window`) still get deterministic IDs. + const testHost: any = (globalThis as any).window ?? globalThis; + if (testHost.__TEST_OPTIONS) { + const testOptions = testHost.__TEST_OPTIONS; if (testOptions.mockID === undefined) { testOptions.mockID = 0; } else { diff --git a/packages/core/src/schema/blocks/createSpec.ts b/packages/core/src/schema/blocks/createSpec.ts index b1e54d640a..22fb91c321 100644 --- a/packages/core/src/schema/blocks/createSpec.ts +++ b/packages/core/src/schema/blocks/createSpec.ts @@ -167,6 +167,68 @@ export function getParseRules< return rules; } +// What the generated node's content expression is for each `content` kind. +const CONTENT_EXPRESSIONS: Record = { + inline: "inline*", + plain: "text*", + none: "", + table: "tableRow+", +}; + +/** + * Content expressions that are spelled differently can still mean the same + * thing, e.g. `"(text)*"` and `"text*"`. Unwraps a parenthesized single + * term, with or without a trailing quantifier, so equivalent spellings + * compare equal. Anything with real structure (sequences, alternation) is + * left as-is: unwrapping those would change the expression's meaning. + */ +function normalizeContentExpression(expression: string): string { + const trimmed = expression.trim(); + const match = trimmed.match(/^\(([A-Za-z_][A-Za-z0-9_]*)\)([*+?])?$/); + return match ? `${match[1]}${match[2] ?? ""}` : trimmed; +} + +/** + * Checks a hand-written node against its config: that the node name matches + * the block type, and that the node's content expression matches the + * `content` the spec declares — the one `getBlockInfoFromPos` reports as the + * block's `contentKind`, without looking at the node. A generated node's name + * and expression come from that same config, so this only bites on a + * hand-written one (`createBlockSpecFromTiptapNode`). + */ +function checkNodeMatchesConfig(node: Node, blockConfig: BlockConfig) { + if (node.name !== blockConfig.type) { + throw new Error( + "Node name does not match block type. This is a bug in BlockNote.", + ); + } + + // A wrapper node that holds child blocks directly (e.g. a hand-written + // `column`) has no block content expression to compare against. + const groups = typeof node.config.group === "string" ? node.config.group : ""; + if (groups.split(" ").includes("bnBlock")) { + return; + } + + // tiptap allows the expression to be a function of the editor, in which case + // there is nothing to compare yet. + const content = node.config.content; + if (content !== undefined && typeof content !== "string") { + return; + } + + const expected = CONTENT_EXPRESSIONS[blockConfig.content]; + if ( + normalizeContentExpression(content ?? "") !== + normalizeContentExpression(expected) + ) { + throw new Error( + `Block "${blockConfig.type}" declares \`content: "${blockConfig.content}"\`, ` + + `but its node holds "${content ?? ""}" rather than "${expected}".`, + ); + } +} + // A function to create custom block for API consumers // we want to hide the tiptap node from API consumers and provide a simpler API surface instead export function addNodeAndExtensionsToSpec< @@ -179,7 +241,7 @@ export function addNodeAndExtensionsToSpec< extensions?: (ExtensionFactoryInstance | Extension)[], priority?: number, ): LooseBlockSpec { - const node = + const builtNode = ((blockImplementation as any).node as Node) || Node.create({ name: blockConfig.type, @@ -292,11 +354,17 @@ export function addNodeAndExtensionsToSpec< }, }); - if (node.name !== blockConfig.type) { - throw new Error( - "Node name does not match block type. This is a bug in BlockNote.", - ); - } + checkNodeMatchesConfig(builtNode, blockConfig as BlockConfig); + + // The block's config is stored on its node's PM spec + // (`NodeSpec.blockConfig`), so code holding a bare `Node` can consult it + // without an editor or schema reference. (`extendNodeSchema` hooks run + // for every node in the schema, hence the name gate.) + const node = builtNode.extend({ + extendNodeSchema(extension) { + return extension.name === builtNode.name ? { blockConfig } : {}; + }, + }); return { config: blockConfig, diff --git a/packages/core/src/schema/blocks/types.ts b/packages/core/src/schema/blocks/types.ts index 8d7e203e61..d91ee10f89 100644 --- a/packages/core/src/schema/blocks/types.ts +++ b/packages/core/src/schema/blocks/types.ts @@ -110,6 +110,18 @@ export interface BlockConfig< // e.g. tables, alerts (with title & content) } +declare module "prosemirror-model" { + interface NodeSpec { + /** + * The config of the BlockNote block this node was built from, so code + * holding a bare `Node` can read block-level facts (like the content + * kind) without an editor or schema reference. Set on every node built + * from a block spec. + */ + blockConfig?: BlockConfig; + } +} + /** * BlockConfigOrCreator is a union type of BlockConfig and a function that returns a BlockConfig. * This is used to create block configs that can be passed to the createBlockSpec function. diff --git a/packages/core/vitestSetup.ts b/packages/core/vitestSetup.ts index bf9678c8f8..cc3bdd45f1 100644 --- a/packages/core/vitestSetup.ts +++ b/packages/core/vitestSetup.ts @@ -1,11 +1,18 @@ import { afterEach, beforeEach } from "vite-plus/test"; +// This setup file also runs for test files that opt into the plain `node` +// environment (`@vitest-environment node`), where there is no `window` at +// all. `__TEST_OPTIONS` (which drives deterministic block IDs) is therefore +// set on `window` when there is one and on `globalThis` otherwise, matching +// the resolution `UniqueID`'s `generateID` uses. +const testHost: any = (globalThis as any).window ?? globalThis; + beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; + testHost.__TEST_OPTIONS = {}; }); afterEach(() => { - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + delete testHost.__TEST_OPTIONS; }); // Mock ClipboardEvent @@ -19,7 +26,7 @@ class ClipboardEventMock extends Event { }, }; } -(global as any).ClipboardEvent = ClipboardEventMock; +(globalThis as any).ClipboardEvent = ClipboardEventMock; // Mock DragEvent class DragEventMock extends Event { @@ -32,4 +39,4 @@ class DragEventMock extends Event { }, }; } -(global as any).DragEvent = DragEventMock; +(globalThis as any).DragEvent = DragEventMock; diff --git a/packages/react/vitestSetup.ts b/packages/react/vitestSetup.ts index beafe25357..07c283c583 100644 --- a/packages/react/vitestSetup.ts +++ b/packages/react/vitestSetup.ts @@ -1,11 +1,21 @@ import { afterEach, beforeEach } from "vite-plus/test"; +// This setup file also runs for test files that opt into the plain `node` +// environment (`@vitest-environment node`), where there is no `window` at +// all. The DOM mocks below are a no-op there. +const hasWindow = typeof window !== "undefined"; + +// Match the core setup: the deterministic-ID options live on `window` when it +// exists and on `globalThis` in the node environment, since `generateID` reads +// them from `(globalThis.window ?? globalThis).__TEST_OPTIONS`. +const testHost: any = (globalThis as any).window ?? globalThis; + beforeEach(() => { - (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS = {}; + testHost.__TEST_OPTIONS = {}; }); afterEach(() => { - delete (window as Window & { __TEST_OPTIONS?: any }).__TEST_OPTIONS; + delete testHost.__TEST_OPTIONS; }); // Mock ClipboardEvent @@ -19,7 +29,7 @@ class ClipboardEventMock extends Event { }, }; } -(global as any).ClipboardEvent = ClipboardEventMock; +(globalThis as any).ClipboardEvent = ClipboardEventMock; // Mock DragEvent class DragEventMock extends Event { @@ -32,28 +42,30 @@ class DragEventMock extends Event { }, }; } -Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - onchange: null, - addListener: () => { - // - }, // Deprecated - removeListener: () => { - // - }, // Deprecated - addEventListener: () => { - // - }, - removeEventListener: () => { - // - }, - dispatchEvent: () => { - // - }, - }), -}); +if (hasWindow) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => { + // + }, // Deprecated + removeListener: () => { + // + }, // Deprecated + addEventListener: () => { + // + }, + removeEventListener: () => { + // + }, + dispatchEvent: () => { + // + }, + }), + }); +} -(global as any).DragEvent = DragEventMock; +(globalThis as any).DragEvent = DragEventMock; 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 fec31293a5..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.isBlockContainer) { + 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 44d87c8108..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.isBlockContainer) { + 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.isBlockContainer) { + 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.isBlockContainer) { + 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.isBlockContainer) { + 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.isBlockContainer) { + 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 21454b7b79..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.isBlockContainer) { + 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.isBlockContainer) { + 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.isBlockContainer) { + 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 6262f505cb..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.isBlockContainer) { + 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 2261863430..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.isBlockContainer) { + 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.isBlockContainer) { + 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.isBlockContainer) { + 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..1c64a25d1b 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,7 @@ 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) => { + blockInfo.block.node.forEach((child: any) => { if (!draggedBlockIds.has(child.attrs.id)) { allTargetChildrenDragged = false; } @@ -59,7 +62,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 +97,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 +161,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. diff --git a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx index c33f704dd2..3e9d9af22c 100644 --- a/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx +++ b/tests/src/end-to-end/keyboardhandlers/keyboardhandlers.test.tsx @@ -313,6 +313,14 @@ describe("Check Keyboard Handlers' Behaviour", () => { await insertParagraph(); await userEvent.keyboard("{ArrowUp}"); + // ArrowUp moves the caret by visual x-position, and the target block above + // is nested (indented 24px further right than the block the caret starts + // in), so the caret lands mid-text rather than at the block's end. + // Normalize to the end of the word/line before deleting, like the "with + // children" variant below does, so Delete exercises the merge-with-next + // branch this test is about. + await userEvent.keyboard(`{${MOD}>}{ArrowLeft}{/${MOD}}`); + await userEvent.keyboard(`{${MOD}>}{ArrowRight}{/${MOD}}`); await userEvent.keyboard("{Delete}"); await compareDocToSnapshot("deleteShallowerBlock"); diff --git a/tests/vitestSetup.browser.ts b/tests/vitestSetup.browser.ts index 469a859137..70b3220bab 100644 --- a/tests/vitestSetup.browser.ts +++ b/tests/vitestSetup.browser.ts @@ -23,6 +23,20 @@ beforeAll(async () => { const style = document.createElement("style"); style.textContent = `.bn-container { max-width: 731px; margin: 0 auto; padding-top: 8px; }`; document.head.appendChild(style); + + // Disable CSS transitions & animations for the whole suite. The editor + // animates block geometry (e.g. `.bn-block-outer { transition: margin 0.2s }` + // driven by the PreviousBlockType depth-change decorations), so for ~200ms + // after a Tab/Shift+Tab the blocks' x-positions are mid-flight. Visual caret + // movement (ArrowUp/ArrowDown) then lands at a timing-dependent text offset, + // which made document snapshots race the animation clock under CPU + // contention. With motion disabled, layout is always in its settled state and + // caret geometry is deterministic. (No product code listens for + // transitionend/animationend, and no keyframes rely on fill-mode, so + // suppressing motion only removes the timing dependency.) + const noMotion = document.createElement("style"); + noMotion.textContent = `*, *::before, *::after { transition: none !important; animation: none !important; }`; + document.head.appendChild(noMotion); }); beforeEach(() => {