From e9bb1b210fbb1b977794927b1359235a9dc690f0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 4 Sep 2026 17:45:15 +0200 Subject: [PATCH 1/2] refactor(core): simplify the BlockInfo API into the single vocabulary for block plumbing Replaces the BlockInfo union's isBlockContainer/childContainer/blockContent shape with block/content/children, and annotates it with the facts callers kept re-deriving by hand: contentStart/contentEnd, childrenStart/ childrenEnd, contentKind (read off the spec config stored on the node), and isContentEmpty. The +1/-1 position arithmetic around content edges, tables and child ranges moves into blockEdgePos/blockEdgeSelection/ tableContentCaretPos and the ChildrenInfo fields. The six producers collapse to four named by the input you already have: getBlockInfoFromNode, getBlockInfoAt, getBlockInfoNearPos, getBlockInfoFromSelection. Block navigation (parent/prev/next/last- descendant) joins them here instead of living beside the merge command. All block manipulation (insert/move/nest/replace/split/update, selections, clipboard, serialization, conversions, keyboard shortcuts) is rewired onto the new vocabulary. insertBlocks gains "first-child"/"last-child" placements resolved through getInsertionPos, shared with the move commands so "can this block go here?" has one schema-driven answer; hand-written nodes are checked against their declared content kind when the schema is built (checkNodeMatchesConfig). --- .../reference/editor/manipulating-content.mdx | 13 +- .../commands/insertBlocks/insertBlocks.ts | 158 +++++- .../insertBlocks/insertPlacement.test.ts | 113 ++++ .../commands/mergeBlocks/mergeBlocks.test.ts | 5 +- .../commands/mergeBlocks/mergeBlocks.ts | 236 +++----- .../commands/moveBlocks/moveBlocks.test.ts | 24 +- .../commands/moveBlocks/moveBlocks.ts | 174 ++++-- .../commands/nestBlock/nestBlock.ts | 75 +-- .../replaceBlocks/replaceBlocks.test.ts | 71 +++ .../commands/splitBlock/splitBlock.test.ts | 12 +- .../commands/splitBlock/splitBlock.ts | 17 +- .../commands/updateBlock/updateBlock.test.ts | 44 +- .../commands/updateBlock/updateBlock.ts | 153 +++--- .../blockManipulation/getBlock/getBlock.ts | 15 +- .../blockManipulation/selections/selection.ts | 70 +-- .../selections/textCursorPosition.ts | 89 +--- .../src/api/blockManipulation/setupTestEnv.ts | 44 +- .../fromClipboard/handleFileInsertion.ts | 6 +- .../core/src/api/getBlockInfoFromPos.test.ts | 261 ++++++++- packages/core/src/api/getBlockInfoFromPos.ts | 504 ++++++++++++++---- .../api/getBlocksChangedByTransaction.test.ts | 12 +- .../src/api/nodeConversions/nodeToBlock.ts | 25 +- packages/core/src/api/nodeUtil.ts | 2 +- .../ListItem/ListItemKeyboardShortcuts.ts | 16 +- .../NumberedListItem/IndexingPlugin.ts | 31 +- .../src/blocks/utils/listItemEnterHandler.ts | 10 +- .../core/src/editor/BlockNoteEditor.test.ts | 4 +- packages/core/src/editor/BlockNoteEditor.ts | 15 +- .../core/src/editor/managers/BlockManager.ts | 11 +- .../editor/managers/ExtensionManager/index.ts | 6 +- packages/core/src/editor/transformPasted.ts | 27 +- .../KeyboardShortcutsExtension.test.ts | 346 ++++++++++++ .../KeyboardShortcutsExtension.ts | 356 ++++++------- .../tiptap-extensions/UniqueID/UniqueID.ts | 9 +- packages/core/src/schema/blocks/createSpec.ts | 80 ++- packages/core/src/schema/blocks/types.ts | 12 + packages/core/vitestSetup.ts | 15 +- packages/react/vitestSetup.ts | 66 ++- .../formats/html-blocks/collabUpdate.test.ts | 11 +- packages/xl-ai/src/prosemirror/agent.test.ts | 36 +- .../xl-ai/src/prosemirror/rebaseTool.test.ts | 30 +- .../cases/combinedOperationsTestCases.ts | 12 +- .../cases/updateOperationTestCases.ts | 28 +- .../DropCursor/multiColumnHandleDropPlugin.ts | 15 +- .../keyboardhandlers.test.tsx | 8 + tests/vitestSetup.browser.ts | 14 + 46 files changed, 2285 insertions(+), 996 deletions(-) create mode 100644 packages/core/src/api/blockManipulation/commands/insertBlocks/insertPlacement.test.ts 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..120e09bd1c 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,45 +253,36 @@ 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; - const tableBlockContentEndPos = tableBlockEndPos - 1; - const lastRowEndPos = tableBlockContentEndPos - 1; - const lastCellEndPos = lastRowEndPos - 1; - const lastCellParagraphEndPos = lastCellEndPos - 1; - + if (bottomNestedPrevBlockInfo.contentKind === "table") { chainedCommands = chainedCommands.setTextSelection( - lastCellParagraphEndPos, + tableContentCaretPos( + bottomNestedPrevBlockInfo.content, + "end", + ), ); - } 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 +297,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 +358,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 +422,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 +455,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 +498,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 +520,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 +531,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 +557,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 +583,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 +630,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 +681,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 +734,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 +828,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 +853,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ [ state.schema.nodes["paragraph"].createAndFill() || undefined, - blockInfo.childContainer?.node, + blockInfo.children?.node, ].filter((node) => node !== undefined), )!; @@ -902,10 +866,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 +884,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(() => { From 70fc8f69b34bf9d4086945f3cc7daab5d32ae410 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 8 Sep 2026 09:36:34 +0200 Subject: [PATCH 2/2] refactor(core): consolidate BlockInfo shape and insertion helpers Resolve block shape directly, validate wrapper structure at the BlockInfo boundary, and convert content from the established content kind. Move insertion resolution into BlockInfo and use node bounds for last-descendant navigation, updating callers and regression coverage. --- .../commands/insertBlocks/insertBlocks.ts | 116 +-------- .../commands/mergeBlocks/mergeBlocks.ts | 5 +- .../commands/moveBlocks/moveBlocks.ts | 6 +- .../core/src/api/getBlockInfoFromPos.test.ts | 23 +- packages/core/src/api/getBlockInfoFromPos.ts | 234 ++++++++++-------- .../src/api/nodeConversions/nodeToBlock.ts | 58 ++--- packages/core/src/editor/BlockNoteEditor.ts | 2 +- .../core/src/editor/managers/BlockManager.ts | 6 +- .../KeyboardShortcutsExtension.ts | 11 +- 9 files changed, 195 insertions(+), 266 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index 53973834eb..9d695e38b6 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, Node, NodeType, Slice } from "prosemirror-model"; +import { Fragment, Slice } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; import { ReplaceStep } from "prosemirror-transform"; import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; @@ -9,121 +9,15 @@ import { StyleSchema, } from "../../../../schema/index.js"; import { - BlockInfo, - getBlockInfoFromNode, + type BlockPlacement, + getInsertionPos, + getBlockInfoAt, } 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, @@ -154,7 +48,7 @@ export function insertBlocks< const target = getInsertionPos( tr.doc, - posInfo, + getBlockInfoAt(tr.doc, posInfo.posBeforeNode), placement, nodesToInsert[0].type, ); diff --git a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts index 6cdf0f06cf..7101698a39 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.ts @@ -41,10 +41,7 @@ export const mergeBlocksCommand = // 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, - ); + const bottomNestedBlockInfo = getLastDescendantBlockInfo(prevBlockInfo); // Only inline-content blocks can merge, and merging into an empty block // is handled elsewhere (by deleting the empty block instead). Merging diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index 0393335024..51ed7e6852 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -12,10 +12,12 @@ import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; import { BlockIdentifier } from "../../../../schema/index.js"; import { getBlockInfoNearPos, + getBlockInfoAt, + getInsertionPos, getNodeId, } from "../../../getBlockInfoFromPos.js"; import { getNodeById } from "../../../nodeUtil.js"; -import { getInsertionPos, insertBlocks } from "../insertBlocks/insertBlocks.js"; +import { insertBlocks } from "../insertBlocks/insertBlocks.js"; import { removeAndInsertBlocks } from "../replaceBlocks/replaceBlocks.js"; type BlockSelectionData = ( @@ -261,7 +263,7 @@ function checkPlacementIsValid( const target = getInsertionPos( tr.doc, - posInfo, + getBlockInfoAt(tr.doc, posInfo.posBeforeNode), placement, movedBlock.nodeType, ); diff --git a/packages/core/src/api/getBlockInfoFromPos.test.ts b/packages/core/src/api/getBlockInfoFromPos.test.ts index f2e90af123..3e45c14330 100644 --- a/packages/core/src/api/getBlockInfoFromPos.test.ts +++ b/packages/core/src/api/getBlockInfoFromPos.test.ts @@ -281,6 +281,21 @@ describe("derived position and content fields", () => { expect(info.contentKind).toBe("plain"); }); + it("rejects malformed wrapper structure at the block-info boundary", () => { + const { blockContainer, paragraph, blockGroup } = getSchema().nodes; + const content = paragraph.create(); + for (const children of [ + [], + [blockGroup.create()], + [content, content], + [content, blockGroup.create(), blockGroup.create()], + ]) { + // Deliberately bypass schema checking, as transaction intermediates can. + const node = blockContainer.create(null, children); + expect(() => getBlockInfoFromNode(node, 0)).toThrow(/blockContainer/); + } + }); + 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 @@ -410,18 +425,14 @@ describe("navigation helpers on plain nested blocks", () => { doc.nodeAt(posOf(doc, "A"))!, posOf(doc, "A"), ); - expect(getLastDescendantBlockInfo(doc, infoA).block.node.attrs.id).toBe( - "D", - ); + expect(getLastDescendantBlockInfo(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", - ); + expect(getLastDescendantBlockInfo(infoE).block.node.attrs.id).toBe("E"); }); }); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 66298388d7..a9512f0f6a 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,4 +1,4 @@ -import { Node } from "prosemirror-model"; +import { Node, type NodeType } from "prosemirror-model"; import { EditorState, NodeSelection, @@ -58,12 +58,12 @@ export type BlockInfo = { blockNoteType: string; } & ( | { - // 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. + // A container block (Column, ColumnList, a custom container): its own + // node holds its children directly, and it has no content node of + // its own. /** - * The Prosemirror node that holds block.children. For such a wrapper, + * The Prosemirror node that holds block.children. For a container block, * this node is the same as `block`. */ children: ChildrenInfo; @@ -101,7 +101,7 @@ export type BlockInfo = { * 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 + * Note this is the opposite of "is a container block": a column has * `hasContent: false`. */ hasContent: true; @@ -123,7 +123,7 @@ export function tableContentCaretPos( /** * 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). + * has none there: a container block, or content that holds no text (an image). */ export function blockEdgePos( info: BlockInfo, @@ -140,9 +140,10 @@ export function blockEdgePos( } /** - * A selection at an edge of a block. Where there is no caret position the + * A selection at an edge of a block. A container resolves to the same edge of + * its first/last child, recursively. Where there is no caret position the * nearest node is selected instead: the content node of a block holding no - * text. + * text, or the block itself for a container holding no children. */ export function blockEdgeSelection( doc: Node, @@ -172,57 +173,6 @@ export function blockEdgeSelection( ); } -/** - * 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 @@ -363,33 +313,49 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { ); } - // The one place block shape is resolved; everything below is position - // annotation over the regions. - const regions = getBlockRegions(node); - const block: SingleBlockInfo = { node, beforePos, afterPos: beforePos + node.nodeSize, }; - if (regions.content) { + if (node.type.isInGroup("bnBlock") && node.type.isInGroup("childContainer")) { + return { + hasContent: false, + block, + children: { + ...block, + childrenStart: block.beforePos + 1, + childrenEnd: block.afterPos - 1, + }, + blockNoteType: node.type.name, + }; + } + + if (node.type.name === "blockContainer") { + const contentNode = node.firstChild; + if (!contentNode || !contentNode.type.isInGroup("blockContent")) { + throw new Error("blockContainer must start with a block content node."); + } const content: SingleBlockInfo = { - node: regions.content.node, - beforePos: beforePos + regions.content.offset, - afterPos: - beforePos + regions.content.offset + regions.content.node.nodeSize, + node: contentNode, + beforePos: beforePos + 1, + afterPos: beforePos + 1 + contentNode.nodeSize, }; - const holder = regions.childrenHolder; let children: ChildrenInfo | undefined; - if (holder) { - const holderBeforePos = beforePos + holder.offset; + if (node.childCount > 1) { + const holder = node.child(1); + if (node.childCount !== 2 || holder.type.name !== "blockGroup") { + throw new Error( + "blockContainer may only have a blockGroup after its content.", + ); + } children = { - node: holder.node, - beforePos: holderBeforePos, - afterPos: holderBeforePos + holder.node.nodeSize, - childrenStart: holderBeforePos + 1, - childrenEnd: holderBeforePos + holder.node.nodeSize - 1, + node: holder, + beforePos: content.afterPos, + afterPos: block.afterPos - 1, + childrenStart: content.afterPos + 1, + childrenEnd: block.afterPos - 2, }; } @@ -422,18 +388,9 @@ export function getBlockInfoFromNode(node: Node, beforePos: number): BlockInfo { }; } - 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, - }; + throw new Error( + `Node "${node.type.name}" is not a container or blockContainer.`, + ); } /** @@ -478,8 +435,8 @@ export function getBlockInfoFromSelection(source: EditorState | Transaction) { /** * 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 + * `posBeforeBlock`, or `undefined` for a top-level block. A container is the + * parent of its direct children (a block inside a column → the column, not * the columnList); a regular block's children live in its `blockGroup`, so * the parent is the group's own parent. */ @@ -552,18 +509,93 @@ export function getNextBlockInfo( * * Then the last descendant block returned is D. */ -export function getLastDescendantBlockInfo( - doc: Node, - blockInfo: BlockInfo, -): BlockInfo { +export function getLastDescendantBlockInfo(blockInfo: BlockInfo): BlockInfo { + // A container that allows zero children can have an empty child container, + // in which case the block itself is the bottom one. while (blockInfo.children && blockInfo.children.node.childCount) { - const group = blockInfo.children.node; - - const newPos = doc - .resolve(blockInfo.children.beforePos + 1) - .posAtIndex(group.childCount - 1); - blockInfo = getBlockInfoAt(doc, newPos); + const child = blockInfo.children.node.lastChild!; + blockInfo = getBlockInfoFromNode( + child, + blockInfo.children.childrenEnd - child.nodeSize, + ); } return blockInfo; } + +/** + * 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. They + * likewise cover containers that have no children to point at: a `min: 0` + * container that is currently empty has no child block to insert before or + * after. + */ +export type BlockPlacement = "before" | "after" | "first-child" | "last-child"; + +/** + * 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, so a container's `children` + * config decides it. + * + * `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, + info: BlockInfo, + placement: BlockPlacement, + nodeType: NodeType, +): { pos: number; wrapIn?: NodeType } | null { + if (placement === "before" || placement === "after") { + const pos = + placement === "before" ? info.block.beforePos : info.block.afterPos; + const $pos = doc.resolve(pos); + return $pos.parent.canReplaceWith($pos.index(), $pos.index(), nodeType) + ? { pos } + : null; + } + + // Ordinary nesting creates its blockGroup lazily. + if (!info.children) { + const group = nodeType.schema.nodes["blockGroup"]; + return info.hasContent && group?.contentMatch.matchType(nodeType) + ? { pos: info.content.afterPos, wrapIn: group } + : null; + } + + const last = placement === "last-child"; + while (info.children) { + const children = info.children; + const index = last ? children.node.childCount : 0; + if (children.node.canReplaceWith(index, index, nodeType)) { + return { pos: last ? children.childrenEnd : children.childrenStart }; + } + // A restricted container can route insertion into its edge container, + // e.g. inserting a paragraph into the last column of a column list. + const child = last ? children.node.lastChild : children.node.firstChild; + if ( + !child || + !( + child.type.isInGroup("bnBlock") && + child.type.isInGroup("childContainer") + ) + ) { + break; + } + info = getBlockInfoFromNode( + child, + last ? children.childrenEnd - child.nodeSize : children.childrenStart, + ); + } + return null; +} diff --git a/packages/core/src/api/nodeConversions/nodeToBlock.ts b/packages/core/src/api/nodeConversions/nodeToBlock.ts index 8f43164b34..004b7fd44b 100644 --- a/packages/core/src/api/nodeConversions/nodeToBlock.ts +++ b/packages/core/src/api/nodeConversions/nodeToBlock.ts @@ -448,36 +448,36 @@ export function nodeToBlock< let content: Block["content"]; - if (blockConfig.content === "inline") { - if (!blockInfo.hasContent) { - throw new Error("impossible"); - } - content = contentNodeToInlineContent( - blockInfo.content.node, - inlineContentSchema, - styleSchema, - ); - } else if (blockConfig.content === "table") { - if (!blockInfo.hasContent) { - throw new Error("impossible"); - } - content = contentNodeToTableContent( - blockInfo.content.node, - inlineContentSchema, - styleSchema, - ); - } else if (blockConfig.content === "plain") { - if (!blockInfo.hasContent) { - throw new Error("impossible"); + // BlockInfo has already established whether content exists and its kind. + // Conversion only interprets that content; it does not resolve shape again. + switch (blockInfo.contentKind) { + case "inline": + content = contentNodeToInlineContent( + blockInfo.content.node, + inlineContentSchema, + styleSchema, + ); + break; + case "table": + content = contentNodeToTableContent( + blockInfo.content.node, + inlineContentSchema, + styleSchema, + ); + break; + case "plain": { + // Plain content is a single unstyled text item; an empty block is an + // empty array, matching inline content. + const text = blockInfo.content.node.textContent; + content = text.length > 0 ? [{ type: "text", text, styles: {} }] : []; + break; } - // Plain content is a single unstyled text item; an empty block is an - // empty array, matching inline content. - const text = blockInfo.content.node.textContent; - content = text.length > 0 ? [{ type: "text", text, styles: {} }] : []; - } else if (blockConfig.content === "none") { - content = undefined; - } else { - throw new UnreachableCaseError(blockConfig.content); + case "none": + case undefined: + content = undefined; + break; + default: + throw new UnreachableCaseError(blockInfo); } const block = { diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index d685993714..216ae64815 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -7,7 +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 { BlockPlacement } from "../api/getBlockInfoFromPos.js"; import type { BlocksChanged } from "../api/getBlocksChangedByTransaction.js"; import { blockToNode } from "../api/nodeConversions/blockToNode.js"; import { diff --git a/packages/core/src/editor/managers/BlockManager.ts b/packages/core/src/editor/managers/BlockManager.ts index ca4c62555e..5b16ec0fcc 100644 --- a/packages/core/src/editor/managers/BlockManager.ts +++ b/packages/core/src/editor/managers/BlockManager.ts @@ -1,7 +1,5 @@ -import { - BlockPlacement, - insertBlocks, -} from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; +import { insertBlocks } from "../../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; +import { BlockPlacement } from "../../api/getBlockInfoFromPos.js"; import { moveBlocksDown, moveBlocksUp, diff --git a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts index 120e09bd1c..20e5941fb6 100644 --- a/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts +++ b/packages/core/src/extensions/tiptap-extensions/KeyboardShortcuts/KeyboardShortcutsExtension.ts @@ -236,10 +236,8 @@ export const KeyboardShortcutsExtension = Extension.create<{ if (!prevBlockInfo) { return false; } - const bottomNestedPrevBlockInfo = getLastDescendantBlockInfo( - state.doc, - prevBlockInfo, - ); + const bottomNestedPrevBlockInfo = + getLastDescendantBlockInfo(prevBlockInfo); if (!bottomNestedPrevBlockInfo.hasContent) { return false; } @@ -311,10 +309,7 @@ export const KeyboardShortcutsExtension = Extension.create<{ ); if (prevBlockInfo && selectionAtBlockStart && selectionEmpty) { - const bottomBlock = getLastDescendantBlockInfo( - state.doc, - prevBlockInfo, - ); + const bottomBlock = getLastDescendantBlockInfo(prevBlockInfo); if (!bottomBlock.hasContent) { return false;