From 26440c0a60669e2a08c50def066dc028f9817599 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 4 Apr 2025 11:28:16 +0200 Subject: [PATCH 1/6] feat(core): add blocknote transactions --- .../commands/insertBlocks/insertBlocks.ts | 21 +- .../commands/mergeBlocks/mergeBlocks.test.ts | 6 +- .../commands/moveBlocks/moveBlocks.test.ts | 63 ++---- .../commands/moveBlocks/moveBlocks.ts | 135 ++++++----- .../commands/nestBlock/nestBlock.ts | 10 +- .../commands/replaceBlocks/replaceBlocks.ts | 12 +- .../commands/splitBlock/splitBlock.test.ts | 52 ++--- .../blockManipulation/getBlock/getBlock.ts | 14 +- .../api/blockManipulation/insertContentAt.ts | 3 +- .../blockManipulation/selections/selection.ts | 19 +- .../textCursorPosition.test.ts | 7 +- .../textCursorPosition/textCursorPosition.ts | 8 +- .../api/clipboard/clipboardExternal.test.ts | 7 +- .../api/clipboard/clipboardInternal.test.ts | 12 +- .../fromClipboard/handleFileInsertion.ts | 2 +- .../clipboard/toClipboard/copyExtension.ts | 10 +- packages/core/src/api/getBlockInfoFromPos.ts | 14 +- .../src/api/parsers/html/parseHTML.test.ts | 5 +- .../helpers/render/createAddFileButton.ts | 2 +- .../core/src/editor/BlockNoteEditor.test.ts | 2 +- packages/core/src/editor/BlockNoteEditor.ts | 211 ++++++++++++++---- .../src/extensions/Comments/CommentsPlugin.ts | 66 +++--- .../ShowSelection/ShowSelectionPlugin.ts | 4 +- .../SideMenu/MultipleNodeSelection.ts | 4 +- .../SuggestionMenu/SuggestionPlugin.ts | 4 +- .../getDefaultSlashMenuItems.ts | 8 +- .../TableHandles/TableHandlesPlugin.ts | 15 +- .../helpers/render/AddFileButton.tsx | 9 +- .../components/Comments/ThreadsSidebar.tsx | 4 +- .../DefaultButtons/CreateLinkButton.tsx | 4 +- .../DefaultSelects/BlockTypeSelect.tsx | 14 +- 31 files changed, 415 insertions(+), 332 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index dc0ea2fd80..251130e059 100644 --- a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts @@ -32,28 +32,19 @@ export function insertBlocks< ); } - const posInfo = getNodeById(id, editor._tiptapEditor.state.doc); + const tr = editor.transaction; + const posInfo = getNodeById(id, tr.doc); if (!posInfo) { throw new Error(`Block with ID ${id} not found`); } - // TODO: we might want to use the ReplaceStep directly here instead of insert, - // because the fitting algorithm should not be necessary and might even cause unexpected behavior - if (placement === "before") { - editor.dispatch( - editor._tiptapEditor.state.tr.insert(posInfo.posBeforeNode, nodesToInsert) - ); - } - + let pos = posInfo.posBeforeNode; if (placement === "after") { - editor.dispatch( - editor._tiptapEditor.state.tr.insert( - posInfo.posBeforeNode + posInfo.node.nodeSize, - nodesToInsert - ) - ); + pos += posInfo.node.nodeSize; } + editor.dispatch(tr.insert(pos, nodesToInsert)); + // Now that the `PartialBlock`s have been converted to nodes, we can // re-convert them into full `Block`s. const insertedBlocks: Block[] = []; 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 9c9613d0b0..a3b497a206 100644 --- a/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/mergeBlocks/mergeBlocks.test.ts @@ -13,7 +13,7 @@ function mergeBlocks(posBetweenBlocks: number) { } function getPosBeforeSelectedBlock() { - return getBlockInfoFromSelection(getEditor()._tiptapEditor.state).bnBlock + return getBlockInfoFromSelection(getEditor().prosemirrorState).bnBlock .beforePos; } @@ -62,14 +62,14 @@ describe("Test mergeBlocks", () => { getEditor().setTextCursorPosition("paragraph-0", "end"); const firstBlockEndOffset = - getEditor()._tiptapEditor.state.selection.$anchor.parentOffset; + getEditor().prosemirrorState.selection.$anchor.parentOffset; getEditor().setTextCursorPosition("paragraph-1"); mergeBlocks(getPosBeforeSelectedBlock()); const anchorIsAtOldFirstBlockEndPos = - getEditor()._tiptapEditor.state.selection.$anchor.parentOffset === + getEditor().prosemirrorState.selection.$anchor.parentOffset === firstBlockEndOffset; expect(anchorIsAtOldFirstBlockEndPos).toBeTruthy(); 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 d8b4e5d40d..4d081d59e4 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.test.ts @@ -13,7 +13,7 @@ import { const getEditor = setupTestEnv(); function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { - const blockInfo = getBlockInfoFromSelection(getEditor()._tiptapEditor.state); + const blockInfo = getBlockInfoFromSelection(getEditor().prosemirrorState); if (!blockInfo.isBlockContainer) { throw new Error( `Selection points to a ${blockInfo.blockNoteType} node, not a blockContainer node` @@ -21,34 +21,27 @@ function makeSelectionSpanContent(selectionType: "text" | "node" | "cell") { } const { blockContent } = blockInfo; + const editor = getEditor(); + const tr = editor.transaction; if (selectionType === "cell") { - getEditor().dispatch( - getEditor()._tiptapEditor.state.tr.setSelection( + editor.dispatch( + tr.setSelection( CellSelection.create( - getEditor()._tiptapEditor.state.doc, - getEditor() - ._tiptapEditor.state.doc.resolve(blockContent.beforePos + 3) - .before(), - getEditor() - ._tiptapEditor.state.doc.resolve(blockContent.afterPos - 3) - .before() + tr.doc, + tr.doc.resolve(blockContent.beforePos + 3).before(), + tr.doc.resolve(blockContent.afterPos - 3).before() ) ) ); } else if (selectionType === "node") { - getEditor().dispatch( - getEditor()._tiptapEditor.state.tr.setSelection( - NodeSelection.create( - getEditor()._tiptapEditor.state.doc, - blockContent.beforePos - ) - ) + editor.dispatch( + tr.setSelection(NodeSelection.create(tr.doc, blockContent.beforePos)) ); } else { - getEditor().dispatch( - getEditor()._tiptapEditor.state.tr.setSelection( + editor.dispatch( + tr.setSelection( TextSelection.create( - getEditor()._tiptapEditor.state.doc, + tr.doc, blockContent.beforePos + 1, blockContent.afterPos - 1 ) @@ -64,13 +57,11 @@ describe("Test moveSelectedBlockAndSelection", () => { moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before"); - const selection = getEditor()._tiptapEditor.state.selection; + const selection = getEditor().transaction.selection; getEditor().setTextCursorPosition("paragraph-1"); makeSelectionSpanContent("text"); - expect( - selection.eq(getEditor()._tiptapEditor.state.selection) - ).toBeTruthy(); + expect(selection.eq(getEditor().transaction.selection)).toBeTruthy(); }); it("Node selection", () => { @@ -79,13 +70,11 @@ describe("Test moveSelectedBlockAndSelection", () => { moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before"); - const selection = getEditor()._tiptapEditor.state.selection; + const selection = getEditor().transaction.selection; getEditor().setTextCursorPosition("image-0"); makeSelectionSpanContent("node"); - expect( - selection.eq(getEditor()._tiptapEditor.state.selection) - ).toBeTruthy(); + expect(selection.eq(getEditor().transaction.selection)).toBeTruthy(); }); it("Cell selection", () => { @@ -94,13 +83,11 @@ describe("Test moveSelectedBlockAndSelection", () => { moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before"); - const selection = getEditor()._tiptapEditor.state.selection; + const selection = getEditor().transaction.selection; getEditor().setTextCursorPosition("table-0"); makeSelectionSpanContent("cell"); - expect( - selection.eq(getEditor()._tiptapEditor.state.selection) - ).toBeTruthy(); + expect(selection.eq(getEditor().transaction.selection)).toBeTruthy(); }); it("Multiple block selection", () => { @@ -108,12 +95,10 @@ describe("Test moveSelectedBlockAndSelection", () => { moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before"); - const selection = getEditor()._tiptapEditor.state.selection; + const selection = getEditor().transaction.selection; getEditor().setSelection("paragraph-1", "paragraph-2"); - expect( - selection.eq(getEditor()._tiptapEditor.state.selection) - ).toBeTruthy(); + expect(selection.eq(getEditor().transaction.selection)).toBeTruthy(); }); it("Multiple block selection with table", () => { @@ -121,12 +106,10 @@ describe("Test moveSelectedBlockAndSelection", () => { moveSelectedBlocksAndSelection(getEditor(), "paragraph-0", "before"); - const selection = getEditor()._tiptapEditor.state.selection; + const selection = getEditor().transaction.selection; getEditor().setSelection("paragraph-6", "table-0"); - expect( - selection.eq(getEditor()._tiptapEditor.state.selection) - ).toBeTruthy(); + expect(selection.eq(getEditor().transaction.selection)).toBeTruthy(); }); }); diff --git a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts index c99d92de6f..e89f787240 100644 --- a/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/moveBlocks/moveBlocks.ts @@ -38,34 +38,33 @@ type BlockSelectionData = ( function getBlockSelectionData( editor: BlockNoteEditor ): BlockSelectionData { - const state = editor._tiptapEditor.state; - const selection = state.selection; + const tr = editor.transaction; - const anchorBlockPosInfo = getNearestBlockPos(state.doc, selection.anchor); + const anchorBlockPosInfo = getNearestBlockPos(tr.doc, tr.selection.anchor); - if (selection instanceof CellSelection) { + if (tr.selection instanceof CellSelection) { return { type: "cell" as const, anchorBlockId: anchorBlockPosInfo.node.attrs.id, anchorCellOffset: - selection.$anchorCell.pos - anchorBlockPosInfo.posBeforeNode, + tr.selection.$anchorCell.pos - anchorBlockPosInfo.posBeforeNode, headCellOffset: - selection.$headCell.pos - anchorBlockPosInfo.posBeforeNode, + tr.selection.$headCell.pos - anchorBlockPosInfo.posBeforeNode, }; - } else if (editor._tiptapEditor.state.selection instanceof NodeSelection) { + } else if (tr.selection instanceof NodeSelection) { return { type: "node" as const, anchorBlockId: anchorBlockPosInfo.node.attrs.id, }; } else { - const headBlockPosInfo = getNearestBlockPos(state.doc, selection.head); + const headBlockPosInfo = getNearestBlockPos(tr.doc, tr.selection.head); return { type: "text" as const, anchorBlockId: anchorBlockPosInfo.node.attrs.id, headBlockId: headBlockPosInfo.node.attrs.id, - anchorOffset: selection.anchor - anchorBlockPosInfo.posBeforeNode, - headOffset: selection.head - headBlockPosInfo.posBeforeNode, + anchorOffset: tr.selection.anchor - anchorBlockPosInfo.posBeforeNode, + headOffset: tr.selection.head - headBlockPosInfo.posBeforeNode, }; } } @@ -85,10 +84,8 @@ function updateBlockSelectionFromData( editor: BlockNoteEditor, data: BlockSelectionData ) { - const anchorBlockPos = getNodeById( - data.anchorBlockId, - editor._tiptapEditor.state.doc - )?.posBeforeNode; + const tr = editor.transaction; + const anchorBlockPos = getNodeById(data.anchorBlockId, tr.doc)?.posBeforeNode; if (anchorBlockPos === undefined) { throw new Error( `Could not find block with ID ${data.anchorBlockId} to update selection` @@ -98,20 +95,14 @@ function updateBlockSelectionFromData( let selection: Selection; if (data.type === "cell") { selection = CellSelection.create( - editor._tiptapEditor.state.doc, + tr.doc, anchorBlockPos + data.anchorCellOffset, anchorBlockPos + data.headCellOffset ); } else if (data.type === "node") { - selection = NodeSelection.create( - editor._tiptapEditor.state.doc, - anchorBlockPos + 1 - ); + selection = NodeSelection.create(tr.doc, anchorBlockPos + 1); } else { - const headBlockPos = getNodeById( - data.headBlockId, - editor._tiptapEditor.state.doc - )?.posBeforeNode; + const headBlockPos = getNodeById(data.headBlockId, tr.doc)?.posBeforeNode; if (headBlockPos === undefined) { throw new Error( `Could not find block with ID ${data.headBlockId} to update selection` @@ -119,13 +110,12 @@ function updateBlockSelectionFromData( } selection = TextSelection.create( - editor._tiptapEditor.state.doc, + tr.doc, anchorBlockPos + data.anchorOffset, headBlockPos + data.headOffset ); } - - editor.dispatch(editor._tiptapEditor.state.tr.setSelection(selection)); + editor.dispatch(tr.setSelection(selection)); } /** @@ -168,15 +158,18 @@ export function moveSelectedBlocksAndSelection( referenceBlock: BlockIdentifier, placement: "before" | "after" ) { - const blocks = editor.getSelection()?.blocks || [ - editor.getTextCursorPosition().block, - ]; - const selectionData = getBlockSelectionData(editor); - - editor.removeBlocks(blocks); - editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement); - - updateBlockSelectionFromData(editor, selectionData); + // We want this to be a single step in the undo history + editor.transact(() => { + const blocks = editor.getSelection()?.blocks || [ + editor.getTextCursorPosition().block, + ]; + const selectionData = getBlockSelectionData(editor); + + editor.removeBlocks(blocks); + editor.insertBlocks(flattenColumns(blocks), referenceBlock, placement); + + updateBlockSelectionFromData(editor, selectionData); + }); } // Checks if a block is in a valid place after being moved. This check is @@ -292,45 +285,49 @@ function getMoveDownPlacement( } export function moveBlocksUp(editor: BlockNoteEditor) { - const selection = editor.getSelection(); - const block = selection?.blocks[0] || editor.getTextCursorPosition().block; + editor.transact(() => { + const selection = editor.getSelection(); + const block = selection?.blocks[0] || editor.getTextCursorPosition().block; - const moveUpPlacement = getMoveUpPlacement( - editor, - editor.getPrevBlock(block), - editor.getParentBlock(block) - ); + const moveUpPlacement = getMoveUpPlacement( + editor, + editor.getPrevBlock(block), + editor.getParentBlock(block) + ); - if (!moveUpPlacement) { - return; - } + if (!moveUpPlacement) { + return; + } - moveSelectedBlocksAndSelection( - editor, - moveUpPlacement.referenceBlock, - moveUpPlacement.placement - ); + moveSelectedBlocksAndSelection( + editor, + moveUpPlacement.referenceBlock, + moveUpPlacement.placement + ); + }); } export function moveBlocksDown(editor: BlockNoteEditor) { - const selection = editor.getSelection(); - const block = - selection?.blocks[selection?.blocks.length - 1] || - editor.getTextCursorPosition().block; - - const moveDownPlacement = getMoveDownPlacement( - editor, - editor.getNextBlock(block), - editor.getParentBlock(block) - ); - - if (!moveDownPlacement) { - return; - } + editor.transact(() => { + const selection = editor.getSelection(); + const block = + selection?.blocks[selection?.blocks.length - 1] || + editor.getTextCursorPosition().block; + + const moveDownPlacement = getMoveDownPlacement( + editor, + editor.getNextBlock(block), + editor.getParentBlock(block) + ); - moveSelectedBlocksAndSelection( - editor, - moveDownPlacement.referenceBlock, - moveDownPlacement.placement - ); + if (!moveDownPlacement) { + return; + } + + moveSelectedBlocksAndSelection( + editor, + moveDownPlacement.referenceBlock, + moveDownPlacement.placement + ); + }); } diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index 0f0463660d..e7369c9792 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -81,20 +81,20 @@ export function unnestBlock(editor: BlockNoteEditor) { export function canNestBlock(editor: BlockNoteEditor) { const { bnBlock: blockContainer } = getBlockInfoFromSelection( - editor._tiptapEditor.state + editor.prosemirrorState ); return ( - editor._tiptapEditor.state.doc.resolve(blockContainer.beforePos) - .nodeBefore !== null + editor.prosemirrorState.doc.resolve(blockContainer.beforePos).nodeBefore !== + null ); } export function canUnnestBlock(editor: BlockNoteEditor) { const { bnBlock: blockContainer } = getBlockInfoFromSelection( - editor._tiptapEditor.state + editor.prosemirrorState ); return ( - editor._tiptapEditor.state.doc.resolve(blockContainer.beforePos).depth > 1 + editor.prosemirrorState.doc.resolve(blockContainer.beforePos).depth > 1 ); } diff --git a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts index bd6ad6687e..2479dfb2d7 100644 --- a/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts +++ b/packages/core/src/api/blockManipulation/commands/replaceBlocks/replaceBlocks.ts @@ -22,9 +22,7 @@ export function removeAndInsertBlocks< insertedBlocks: Block[]; removedBlocks: Block[]; } { - const ttEditor = editor._tiptapEditor; - let tr = ttEditor.state.tr; - + const tr = editor.transaction; // Converts the `PartialBlock`s to ProseMirror nodes to insert them into the // document. const nodesToInsert: Node[] = []; @@ -47,7 +45,7 @@ export function removeAndInsertBlocks< : blocksToRemove[0].id; let removedSize = 0; - ttEditor.state.doc.descendants((node, pos) => { + tr.doc.descendants((node, pos) => { // Skips traversing nodes after all target blocks have been removed. if (idsOfBlocksToRemove.size === 0) { return false; @@ -75,7 +73,7 @@ export function removeAndInsertBlocks< if (blocksToInsert.length > 0 && node.attrs.id === idOfFirstBlock) { const oldDocSize = tr.doc.nodeSize; - tr = tr.insert(pos, nodesToInsert); + tr.insert(pos, nodesToInsert); const newDocSize = tr.doc.nodeSize; removedSize += oldDocSize - newDocSize; @@ -91,9 +89,9 @@ export function removeAndInsertBlocks< $pos.node($pos.depth - 1).type.name !== "doc" && $pos.node().childCount === 1 ) { - tr = tr.delete($pos.before(), $pos.after()); + tr.delete($pos.before(), $pos.after()); } else { - tr = tr.delete(pos - removedSize, pos - removedSize + node.nodeSize); + tr.delete(pos - removedSize, pos - removedSize + node.nodeSize); } const newDocSize = tr.doc.nodeSize; removedSize += oldDocSize - newDocSize; 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 444729e69c..7068c62389 100644 --- a/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts +++ b/packages/core/src/api/blockManipulation/commands/splitBlock/splitBlock.test.ts @@ -39,7 +39,7 @@ function setSelectionWithOffset( } getEditor().dispatch( - getEditor()._tiptapEditor.state.tr.setSelection( + getEditor().prosemirrorState.tr.setSelection( TextSelection.create(doc, info.blockContent.beforePos + offset + 1) ) ); @@ -47,97 +47,83 @@ function setSelectionWithOffset( describe("Test splitBlocks", () => { it("Basic", () => { - setSelectionWithOffset( - getEditor()._tiptapEditor.state.doc, - "paragraph-0", - 4 - ); + setSelectionWithOffset(getEditor().prosemirrorState.doc, "paragraph-0", 4); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor); + splitBlock(getEditor().prosemirrorState.selection.anchor); expect(getEditor().document).toMatchSnapshot(); }); it("End of content", () => { - setSelectionWithOffset( - getEditor()._tiptapEditor.state.doc, - "paragraph-0", - 11 - ); + setSelectionWithOffset(getEditor().prosemirrorState.doc, "paragraph-0", 11); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor); + splitBlock(getEditor().prosemirrorState.selection.anchor); expect(getEditor().document).toMatchSnapshot(); }); it("Block has children", () => { setSelectionWithOffset( - getEditor()._tiptapEditor.state.doc, + getEditor().prosemirrorState.doc, "paragraph-with-children", 4 ); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor); + splitBlock(getEditor().prosemirrorState.selection.anchor); expect(getEditor().document).toMatchSnapshot(); }); it("Keep type", () => { - setSelectionWithOffset(getEditor()._tiptapEditor.state.doc, "heading-0", 4); + setSelectionWithOffset(getEditor().prosemirrorState.doc, "heading-0", 4); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor, true); + splitBlock(getEditor().prosemirrorState.selection.anchor, true); expect(getEditor().document).toMatchSnapshot(); }); it("Don't keep type", () => { - setSelectionWithOffset(getEditor()._tiptapEditor.state.doc, "heading-0", 4); + setSelectionWithOffset(getEditor().prosemirrorState.doc, "heading-0", 4); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor, false); + splitBlock(getEditor().prosemirrorState.selection.anchor, false); expect(getEditor().document).toMatchSnapshot(); }); it.skip("Keep props", () => { setSelectionWithOffset( - getEditor()._tiptapEditor.state.doc, + getEditor().prosemirrorState.doc, "paragraph-with-props", 4 ); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor, false, true); + splitBlock(getEditor().prosemirrorState.selection.anchor, false, true); expect(getEditor().document).toMatchSnapshot(); }); it("Don't keep props", () => { setSelectionWithOffset( - getEditor()._tiptapEditor.state.doc, + getEditor().prosemirrorState.doc, "paragraph-with-props", 4 ); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor, false, false); + splitBlock(getEditor().prosemirrorState.selection.anchor, false, false); expect(getEditor().document).toMatchSnapshot(); }); it("Selection is set", () => { - setSelectionWithOffset( - getEditor()._tiptapEditor.state.doc, - "paragraph-0", - 4 - ); + setSelectionWithOffset(getEditor().prosemirrorState.doc, "paragraph-0", 4); - splitBlock(getEditor()._tiptapEditor.state.selection.anchor); + splitBlock(getEditor().prosemirrorState.selection.anchor); - const { bnBlock } = getBlockInfoFromSelection( - getEditor()._tiptapEditor.state - ); + const { bnBlock } = getBlockInfoFromSelection(getEditor().prosemirrorState); const anchorIsAtStartOfNewBlock = bnBlock.node.attrs.id === "0" && - getEditor()._tiptapEditor.state.selection.$anchor.parentOffset === 0; + getEditor().prosemirrorState.selection.$anchor.parentOffset === 0; expect(anchorIsAtStartOfNewBlock).toBeTruthy(); }); diff --git a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts index 4bf9ed225b..00966c3b20 100644 --- a/packages/core/src/api/blockManipulation/getBlock/getBlock.ts +++ b/packages/core/src/api/blockManipulation/getBlock/getBlock.ts @@ -20,7 +20,7 @@ export function getBlock< const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - const posInfo = getNodeById(id, editor._tiptapEditor.state.doc); + const posInfo = getNodeById(id, editor.prosemirrorState.doc); if (!posInfo) { return undefined; } @@ -45,12 +45,12 @@ export function getPrevBlock< const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - const posInfo = getNodeById(id, editor._tiptapEditor.state.doc); + const posInfo = getNodeById(id, editor.prosemirrorState.doc); if (!posInfo) { return undefined; } - const $posBeforeNode = editor._tiptapEditor.state.doc.resolve( + const $posBeforeNode = editor.prosemirrorState.doc.resolve( posInfo.posBeforeNode ); const nodeToConvert = $posBeforeNode.nodeBefore; @@ -78,12 +78,12 @@ export function getNextBlock< const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - const posInfo = getNodeById(id, editor._tiptapEditor.state.doc); + const posInfo = getNodeById(id, editor.prosemirrorState.doc); if (!posInfo) { return undefined; } - const $posAfterNode = editor._tiptapEditor.state.doc.resolve( + const $posAfterNode = editor.prosemirrorState.doc.resolve( posInfo.posBeforeNode + posInfo.node.nodeSize ); const nodeToConvert = $posAfterNode.nodeAfter; @@ -111,12 +111,12 @@ export function getParentBlock< const id = typeof blockIdentifier === "string" ? blockIdentifier : blockIdentifier.id; - const posInfo = getNodeById(id, editor._tiptapEditor.state.doc); + const posInfo = getNodeById(id, editor.prosemirrorState.doc); if (!posInfo) { return undefined; } - const $posBeforeNode = editor._tiptapEditor.state.doc.resolve( + const $posBeforeNode = editor.prosemirrorState.doc.resolve( posInfo.posBeforeNode ); const parentNode = $posBeforeNode.node(); diff --git a/packages/core/src/api/blockManipulation/insertContentAt.ts b/packages/core/src/api/blockManipulation/insertContentAt.ts index 64791083d3..8322c6a9ea 100644 --- a/packages/core/src/api/blockManipulation/insertContentAt.ts +++ b/packages/core/src/api/blockManipulation/insertContentAt.ts @@ -21,8 +21,7 @@ export function insertContentAt< updateSelection: boolean; } = { updateSelection: true } ) { - const tr = editor._tiptapEditor.state.tr; - + const tr = editor.transaction; // don’t dispatch an empty fragment because this can lead to strange errors // if (content.toString() === "<>") { // return true; diff --git a/packages/core/src/api/blockManipulation/selections/selection.ts b/packages/core/src/api/blockManipulation/selections/selection.ts index 2017581e41..3340f4315a 100644 --- a/packages/core/src/api/blockManipulation/selections/selection.ts +++ b/packages/core/src/api/blockManipulation/selections/selection.ts @@ -21,8 +21,7 @@ export function getSelection< >( editor: BlockNoteEditor ): Selection | undefined { - const state = editor._tiptapEditor.state; - + const state = editor.prosemirrorState; // Return undefined if the selection is collapsed or a node is selected. if (state.selection.empty || "node" in state.selection) { return undefined; @@ -164,14 +163,12 @@ export function setSelection< `Attempting to set selection with the same anchor and head blocks (id ${startBlockId})` ); } - - const doc = editor._tiptapEditor.state.doc; - - const anchorPosInfo = getNodeById(startBlockId, doc); + const tr = editor.transaction; + const anchorPosInfo = getNodeById(startBlockId, tr.doc); if (!anchorPosInfo) { throw new Error(`Block with ID ${startBlockId} not found`); } - const headPosInfo = getNodeById(endBlockId, doc); + const headPosInfo = getNodeById(endBlockId, tr.doc); if (!headPosInfo) { throw new Error(`Block with ID ${endBlockId} not found`); } @@ -226,7 +223,7 @@ export function setSelection< headBlockInfo.blockContent.node ) + 1; - const lastCellNodeSize = doc.resolve(lastCellPos).nodeAfter!.nodeSize; + const lastCellNodeSize = tr.doc.resolve(lastCellPos).nodeAfter!.nodeSize; endPos = lastCellPos + lastCellNodeSize - 2; } else { endPos = headBlockInfo.blockContent.afterPos - 1; @@ -236,9 +233,7 @@ export function setSelection< // 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 // restriction that the start/end blocks must have content. - editor._tiptapEditor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - TextSelection.create(editor._tiptapEditor.state.doc, startPos, endPos) - ) + editor.dispatch( + tr.setSelection(TextSelection.create(tr.doc, startPos, endPos)) ); } diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.test.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.test.ts index f7ed692796..0adf0a8643 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.test.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.test.ts @@ -37,7 +37,7 @@ describe("Test getTextCursorPosition & setTextCursorPosition", () => { setTextCursorPosition(getEditor(), "paragraph-1", "start"); expect( - getEditor()._tiptapEditor.state.selection.$from.parentOffset === 0 + getEditor().prosemirrorState.selection.$from.parentOffset === 0 ).toBeTruthy(); }); @@ -45,9 +45,8 @@ describe("Test getTextCursorPosition & setTextCursorPosition", () => { setTextCursorPosition(getEditor(), "paragraph-1", "end"); expect( - getEditor()._tiptapEditor.state.selection.$from.parentOffset === - getEditor()._tiptapEditor.state.selection.$from.node().firstChild! - .nodeSize + getEditor().prosemirrorState.selection.$from.parentOffset === + getEditor().prosemirrorState.selection.$from.node().firstChild!.nodeSize ).toBeTruthy(); }); }); diff --git a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts b/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts index b7013162e6..1f2ae77475 100644 --- a/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts +++ b/packages/core/src/api/blockManipulation/selections/textCursorPosition/textCursorPosition.ts @@ -21,14 +21,14 @@ export function getTextCursorPosition< I extends InlineContentSchema, S extends StyleSchema >(editor: BlockNoteEditor): TextCursorPosition { - const { bnBlock } = getBlockInfoFromSelection(editor._tiptapEditor.state); + const { bnBlock } = getBlockInfoFromSelection(editor.prosemirrorState); - const resolvedPos = editor._tiptapEditor.state.doc.resolve(bnBlock.beforePos); + const resolvedPos = editor.prosemirrorState.doc.resolve(bnBlock.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 = editor._tiptapEditor.state.doc.resolve( + const nextNode = editor.prosemirrorState.doc.resolve( bnBlock.afterPos ).nodeAfter; @@ -95,7 +95,7 @@ export function setTextCursorPosition< ) { const id = typeof targetBlock === "string" ? targetBlock : targetBlock.id; - const posInfo = getNodeById(id, editor._tiptapEditor.state.doc); + const posInfo = getNodeById(id, editor.prosemirrorState.doc); if (!posInfo) { throw new Error(`Block with ID ${id} not found`); } diff --git a/packages/core/src/api/clipboard/clipboardExternal.test.ts b/packages/core/src/api/clipboard/clipboardExternal.test.ts index 97ad51bedc..b31d19c19a 100644 --- a/packages/core/src/api/clipboard/clipboardExternal.test.ts +++ b/packages/core/src/api/clipboard/clipboardExternal.test.ts @@ -83,11 +83,8 @@ describe("Test external clipboard HTML", () => { throw new Error("Editor view not initialized."); } - editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - testCase.createSelection(editor.prosemirrorView.state.doc) - ) - ); + const tr = editor.transaction; + editor.dispatch(tr.setSelection(testCase.createSelection(tr.doc))); doPaste( editor.prosemirrorView, diff --git a/packages/core/src/api/clipboard/clipboardInternal.test.ts b/packages/core/src/api/clipboard/clipboardInternal.test.ts index 9f958ee60f..e159dfacb6 100644 --- a/packages/core/src/api/clipboard/clipboardInternal.test.ts +++ b/packages/core/src/api/clipboard/clipboardInternal.test.ts @@ -297,11 +297,8 @@ describe("Test ProseMirror selection clipboard HTML", () => { throw new Error("Editor view not initialized."); } - editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - testCase.createCopySelection(editor.prosemirrorView.state.doc) - ) - ); + const tr = editor.transaction; + editor.dispatch(tr.setSelection(testCase.createCopySelection(tr.doc))); const { clipboardHTML, externalHTML } = selectedFragmentToHTML( editor.prosemirrorView, @@ -312,11 +309,10 @@ describe("Test ProseMirror selection clipboard HTML", () => { `./__snapshots__/internal/${testCase.testName}.html` ); + const nextTr = editor.transaction; if (testCase.createPasteSelection) { editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - testCase.createPasteSelection(editor.prosemirrorView.state.doc) - ) + nextTr.setSelection(testCase.createPasteSelection(nextTr.doc)) ); } diff --git a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts index 6615b048a2..226e1ac17e 100644 --- a/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts +++ b/packages/core/src/api/clipboard/fromClipboard/handleFileInsertion.ts @@ -161,7 +161,7 @@ export async function handleFileInsertion< } const posInfo = getNearestBlockPos( - editor._tiptapEditor.state.doc, + editor.prosemirrorState.doc, pos.pos ); diff --git a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts index 30d57bd66f..65b83033cb 100644 --- a/packages/core/src/api/clipboard/toClipboard/copyExtension.ts +++ b/packages/core/src/api/clipboard/toClipboard/copyExtension.ts @@ -120,9 +120,10 @@ export function selectedFragmentToHTML< "node" in view.state.selection && (view.state.selection.node as Node).type.spec.group === "blockContent" ) { + const tr = editor.transaction; editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( - new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1)) + tr.setSelection( + new NodeSelection(tr.doc.resolve(view.state.selection.from - 1)) ) ); } @@ -251,10 +252,11 @@ export const createCopyToClipboardExtension = < } // Expands the selection to the parent `blockContainer` node. + const tr = editor.transaction; editor.dispatch( - editor._tiptapEditor.state.tr.setSelection( + tr.setSelection( new NodeSelection( - view.state.doc.resolve(view.state.selection.from - 1) + tr.doc.resolve(view.state.selection.from - 1) ) ) ); diff --git a/packages/core/src/api/getBlockInfoFromPos.ts b/packages/core/src/api/getBlockInfoFromPos.ts index 525773a335..e071373e23 100644 --- a/packages/core/src/api/getBlockInfoFromPos.ts +++ b/packages/core/src/api/getBlockInfoFromPos.ts @@ -1,5 +1,5 @@ import { Node, ResolvedPos } from "prosemirror-model"; -import { EditorState } from "prosemirror-state"; +import { EditorState, Transaction } from "prosemirror-state"; type SingleBlockInfo = { node: Node; @@ -239,3 +239,15 @@ export function getBlockInfoFromSelection(state: EditorState) { return getBlockInfo(posInfo); } + +/** + * Gets information regarding the ProseMirror nodes that make up a block. The + * block chosen is the one currently containing the current ProseMirror + * selection. + * @param tr The ProseMirror transaction. + */ +export function getBlockInfoFromTransaction(tr: Transaction) { + const posInfo = getNearestBlockPos(tr.doc, tr.selection.anchor); + + return getBlockInfo(posInfo); +} diff --git a/packages/core/src/api/parsers/html/parseHTML.test.ts b/packages/core/src/api/parsers/html/parseHTML.test.ts index 7602d0be1c..a4bfcdce64 100644 --- a/packages/core/src/api/parsers/html/parseHTML.test.ts +++ b/packages/core/src/api/parsers/html/parseHTML.test.ts @@ -31,14 +31,15 @@ async function parseHTMLAndCompareSnapshots( (window as any).__TEST_OPTIONS.mockID = 0; // reset id counter const htmlNode = nestedListsToBlockNoteStructure(html); + const tr = editor.transaction; const slice = (pmView as any).__parseFromClipboard( editor.prosemirrorView, "", htmlNode.innerHTML, false, - editor._tiptapEditor.state.selection.$from + tr.selection.$from ); - editor.dispatch(editor._tiptapEditor.state.tr.replaceSelection(slice)); + editor.dispatch(tr.replaceSelection(slice)); // alternative paste simulation doesn't work in a non-browser vitest env // editor._tiptapEditor.view.pasteHTML(html, { diff --git a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts b/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts index 1b033a4bc5..f8b8981d5e 100644 --- a/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts +++ b/packages/core/src/blocks/FileBlockContent/helpers/render/createAddFileButton.ts @@ -33,7 +33,7 @@ export const createAddFileButton = ( // Opens the file toolbar. const addFileButtonClickHandler = () => { editor.dispatch( - editor._tiptapEditor.state.tr.setMeta(editor.filePanel!.plugin, { + editor.transaction.setMeta(editor.filePanel!.plugin, { block: block, }) ); diff --git a/packages/core/src/editor/BlockNoteEditor.test.ts b/packages/core/src/editor/BlockNoteEditor.test.ts index f0f4669437..1e9dc20776 100644 --- a/packages/core/src/editor/BlockNoteEditor.test.ts +++ b/packages/core/src/editor/BlockNoteEditor.test.ts @@ -10,7 +10,7 @@ import { BlockNoteEditor } from "./BlockNoteEditor.js"; */ it("creates an editor", () => { const editor = BlockNoteEditor.create(); - const posInfo = getNearestBlockPos(editor._tiptapEditor.state.doc, 2); + const posInfo = getNearestBlockPos(editor.prosemirrorState.doc, 2); const info = getBlockInfo(posInfo); expect(info.blockNoteType).toEqual("paragraph"); }); diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index aa8ffea6e3..c8bb91fd32 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -94,18 +94,24 @@ import { import { Dictionary } from "../i18n/dictionary.js"; import { en } from "../i18n/locales/index.js"; -import { Plugin, TextSelection, Transaction } from "@tiptap/pm/state"; +import { + EditorState, + Plugin, + Selection as ProsemirrorSelection, + TextSelection, + Transaction, +} from "@tiptap/pm/state"; import { dropCursor } from "prosemirror-dropcursor"; import { EditorView } from "prosemirror-view"; import { ySyncPluginKey } from "y-prosemirror"; import { createInternalHTMLSerializer } from "../api/exporters/html/internalHTMLSerializer.js"; import { inlineContentToNodes } from "../api/nodeConversions/blockToNode.js"; import { nodeToBlock } from "../api/nodeConversions/nodeToBlock.js"; +import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js"; +import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js"; import type { ThreadStore, User } from "../comments/index.js"; import "../style.css"; import { EventEmitter } from "../util/EventEmitter.js"; -import { CodeBlockOptions } from "../blocks/CodeBlockContent/CodeBlockContent.js"; -import { nestedListsToBlockNoteStructure } from "../api/parsers/html/util/nestedLists.js"; export type BlockNoteExtensionFactory = ( editor: BlockNoteEditor @@ -726,9 +732,131 @@ export class BlockNoteEditor< this.emit("create"); } - dispatch = (tr: Transaction) => { + /** + * Dispatch a ProseMirror transaction. + * + * @example + * ```ts + * const tr = editor.transaction; + * tr.insertText("Hello, world!"); + * editor.dispatch(tr); + * ``` + */ + public dispatch(tr: Transaction) { + if (this.transactionState) { + const { state, transactions } = + this.transactionState.applyTransaction(tr); + // Set a default value if needed + const accTr = this.activeTransaction ?? this.transactionState.tr; + + // Copy over the newly applied transactions into our "active transaction" which accumulates all transaction steps during a `transact` call + transactions.forEach((tr) => { + tr.steps.forEach((step) => { + accTr.step(step); + }); + if (tr.selectionSet) { + accTr.setSelection( + ProsemirrorSelection.fromJSON(accTr.doc, tr.selection.toJSON()) + ); + } + }); + this.activeTransaction = accTr; + this.transactionState = state; + + // We don't want the editor to actually apply the state, so all of this is manipulating the state in-memory + return; + } + this._tiptapEditor.dispatch(tr); - }; + } + + /** + * Stores the currently active transaction, which is the accumulated transaction from all {@link dispatch} calls during a {@link transact} calls + */ + private activeTransaction: Transaction | null = null; + + /** + * Execute a function within a "blocknote transaction". + * All changes to the editor within the transaction will be grouped together, so that + * we can dispatch them as a single operation (thus creating only a single undo step) + * + * @example + * ```ts + * // All changes to the editor will be grouped together + * editor.transact(() => { + * const tr = editor.transaction; + * tr.insertText("Hello, world!"); + * editor.dispatch(tr); + * // These two operations will be grouped together in a single undo step + * const otherTr = editor.transaction; + * otherTr.insertText("Hello, world!"); + * editor.dispatch(otherTr); + * }); + * ``` + */ + public transact(callback: () => T): T { + if (this.transactionState) { + // Already in a transaction, so we can just callback immediately + return callback(); + } + + let result: T = undefined as T; + + try { + // Enter transaction mode, by setting a start state + this.transactionState = this.prosemirrorState; + + // Capture all tiptap transactions (tiptapEditor.dispatch'd transactions) + const tiptapTr = this._tiptapEditor.captureTransaction(() => { + // This is more of a safety mechanism, as we catch blocknote transactions separately + result = callback(); + }); + + // Any transactions captured by the `dispatch` call will be stored in `this.activeTransaction` + let activeTr = this.activeTransaction; + + if (tiptapTr && activeTr) { + // If we have both tiptap & blocknote transactions, there is not a clean way to merge them as you'd need to know the order of operations + throw new Error( + "Cannot mix tiptap transactions with BlockNote transactions" + ); + } else if (tiptapTr) { + // If we only have tiptap caught transactions, we can just use that + activeTr = tiptapTr; + } + + if (activeTr) { + // Dispatch the transaction if it was modified + this._tiptapEditor.dispatch(activeTr); + } + } finally { + this.activeTransaction = null; + this.transactionState = null; + } + + return result; + } + + /** + * Start a new ProseMirror transaction. + * + * @example + * ```ts + * const tr = editor.transaction + * + * tr.insertText("Hello, world!"); + * + * editor.dispatch(tr); + * ``` + */ + public get transaction(): Transaction { + if (this.transactionState) { + // We are in a `transact` call, so we should return the state that was active when the transaction started + return this.transactionState.tr; + } + // Otherwise, we are not in a `transact` call, so we can just return the current state + return this.prosemirrorState.tr; + } /** * Mount the editor to a parent DOM element. Call mount(undefined) to clean up @@ -749,10 +877,18 @@ export class BlockNoteEditor< return this._tiptapEditor.view; } + /** + * The state of the editor when a transaction is captured, this can be continuously updated during the {@link transact} call + */ + private transactionState: EditorState | null = null; + /** * Get the underlying prosemirror state */ public get prosemirrorState() { + if (this.transactionState) { + return this.transactionState; + } return this._tiptapEditor.state; } @@ -1069,8 +1205,8 @@ export class BlockNoteEditor< insertContentAt( { - from: this._tiptapEditor.state.selection.from, - to: this._tiptapEditor.state.selection.to, + from: this.prosemirrorState.selection.from, + to: this.prosemirrorState.selection.to, }, nodes, this @@ -1082,7 +1218,7 @@ export class BlockNoteEditor< */ public getActiveStyles() { const styles: Styles = {}; - const marks = this._tiptapEditor.state.selection.$to.marks(); + const marks = this.prosemirrorState.selection.$to.marks(); for (const mark of marks) { const config = this.schema.styleSchema[mark.type.name]; @@ -1163,10 +1299,8 @@ export class BlockNoteEditor< * Gets the currently selected text. */ public getSelectedText() { - return this._tiptapEditor.state.doc.textBetween( - this._tiptapEditor.state.selection.from, - this._tiptapEditor.state.selection.to - ); + const state = this.prosemirrorState; + return state.doc.textBetween(state.selection.from, state.selection.to); } /** @@ -1185,21 +1319,21 @@ export class BlockNoteEditor< if (url === "") { return; } - - const { from, to } = this._tiptapEditor.state.selection; const mark = this.pmSchema.mark("link", { href: url }); + const tr = this.transaction; + const { from, to } = tr.selection; - this.dispatch( - text - ? this._tiptapEditor.state.tr - .insertText(text, from, to) - .addMark(from, from + text.length, mark) - : this._tiptapEditor.state.tr - .setSelection( - TextSelection.create(this._tiptapEditor.state.tr.doc, to) - ) - .addMark(from, to, mark) - ); + if (text) { + this.dispatch( + tr.insertText(text, from, to).addMark(from, from + text.length, mark) + ); + } else { + this.dispatch( + tr + .setSelection(TextSelection.create(tr.doc, to)) + .addMark(from, to, mark) + ); + } } /** @@ -1451,24 +1585,21 @@ export class BlockNoteEditor< ignoreQueryLength?: boolean; } ) { - const tr = this.prosemirrorView?.state.tr; - if (!tr) { + if (!this.prosemirrorView) { return; } - const transaction = - pluginState && pluginState.deleteTriggerCharacter - ? tr.insertText(triggerCharacter) - : tr; - - this.prosemirrorView.focus(); - this.prosemirrorView.dispatch( - transaction.scrollIntoView().setMeta(this.suggestionMenus.plugin, { - triggerCharacter: triggerCharacter, - deleteTriggerCharacter: pluginState?.deleteTriggerCharacter || false, - ignoreQueryLength: pluginState?.ignoreQueryLength || false, - }) - ); + this.focus(); + const tr = this.transaction; + if (pluginState && pluginState.deleteTriggerCharacter) { + tr.insertText(triggerCharacter); + } + tr.scrollIntoView().setMeta(this.suggestionMenus.plugin, { + triggerCharacter: triggerCharacter, + deleteTriggerCharacter: pluginState?.deleteTriggerCharacter || false, + ignoreQueryLength: pluginState?.ignoreQueryLength || false, + }); + this.dispatch(tr); } // `forceSelectionVisible` determines whether the editor selection is shows diff --git a/packages/core/src/extensions/Comments/CommentsPlugin.ts b/packages/core/src/extensions/Comments/CommentsPlugin.ts index e7498060a2..0b3bdfb7e5 100644 --- a/packages/core/src/extensions/Comments/CommentsPlugin.ts +++ b/packages/core/src/extensions/Comments/CommentsPlugin.ts @@ -89,41 +89,41 @@ export class CommentsPlugin extends EventEmitter { * when a thread is resolved or deleted, we need to update the marks to reflect the new state */ private updateMarksFromThreads = (threads: Map) => { - const ttEditor = this.editor._tiptapEditor; - - ttEditor.state.doc.descendants((node, pos) => { - node.marks.forEach((mark) => { - if (mark.type.name === this.markType) { - const markType = mark.type; - const markThreadId = mark.attrs.threadId; - const thread = threads.get(markThreadId); - const isOrphan = !!(!thread || thread.resolved || thread.deletedAt); - - if (isOrphan !== mark.attrs.orphan) { - const { tr } = ttEditor.state; - const trimmedFrom = Math.max(pos, 0); - const trimmedTo = Math.min( - pos + node.nodeSize, - ttEditor.state.doc.content.size - 1 - ); - tr.removeMark(trimmedFrom, trimmedTo, mark); - tr.addMark( - trimmedFrom, - trimmedTo, - markType.create({ - ...mark.attrs, - orphan: isOrphan, - }) - ); - ttEditor.dispatch(tr); + const tr = this.editor.transaction; + this.editor.transact(() => { + tr.doc.descendants((node, pos) => { + node.marks.forEach((mark) => { + if (mark.type.name === this.markType) { + const markType = mark.type; + const markThreadId = mark.attrs.threadId; + const thread = threads.get(markThreadId); + const isOrphan = !!(!thread || thread.resolved || thread.deletedAt); + + if (isOrphan !== mark.attrs.orphan) { + const trimmedFrom = Math.max(pos, 0); + const trimmedTo = Math.min( + pos + node.nodeSize, + tr.doc.content.size - 1 + ); + tr.removeMark(trimmedFrom, trimmedTo, mark); + tr.addMark( + trimmedFrom, + trimmedTo, + markType.create({ + ...mark.attrs, + orphan: isOrphan, + }) + ); + this.editor.dispatch(tr); - if (isOrphan && this.selectedThreadId === markThreadId) { - // unselect - this.selectedThreadId = undefined; - this.emitStateUpdate(); + if (isOrphan && this.selectedThreadId === markThreadId) { + // unselect + this.selectedThreadId = undefined; + this.emitStateUpdate(); + } } } - } + }); }); }); }; @@ -263,7 +263,7 @@ export class CommentsPlugin extends EventEmitter { this.selectedThreadId = threadId; this.emitStateUpdate(); this.editor.dispatch( - this.editor.prosemirrorView!.state.tr.setMeta(PLUGIN_KEY, { + this.editor.transaction.setMeta(PLUGIN_KEY, { name: SET_SELECTED_THREAD_ID, }) ); diff --git a/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts b/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts index acb8a61cc3..a4ae0e99fb 100644 --- a/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts +++ b/packages/core/src/extensions/ShowSelection/ShowSelectionPlugin.ts @@ -41,9 +41,7 @@ export class ShowSelectionPlugin { this.enabled = enabled; - this.editor.prosemirrorView?.dispatch( - this.editor.prosemirrorView?.state.tr.setMeta(PLUGIN_KEY, {}) - ); + this.editor.dispatch(this.editor.transaction.setMeta(PLUGIN_KEY, {})); } public getEnabled() { diff --git a/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts b/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts index c3733d4619..a002143e1f 100644 --- a/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts +++ b/packages/core/src/extensions/SideMenu/MultipleNodeSelection.ts @@ -82,6 +82,8 @@ export class MultipleNodeSelection extends Selection { } toJSON(): any { - return { type: "node", anchor: this.anchor, head: this.head }; + return { type: "multiple-node", anchor: this.anchor, head: this.head }; } } + +Selection.jsonID("multiple-node", MultipleNodeSelection); diff --git a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts index c21a8ddb5e..9d2e6b3016 100644 --- a/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts +++ b/packages/core/src/extensions/SuggestionMenu/SuggestionPlugin.ts @@ -115,7 +115,7 @@ class SuggestionMenuView< closeMenu = () => { this.editor.dispatch( - this.editor._tiptapEditor.state.tr.setMeta(suggestionMenuPluginKey, null) + this.editor.transaction.setMeta(suggestionMenuPluginKey, null) ); }; @@ -133,7 +133,7 @@ class SuggestionMenuView< (this.pluginState.deleteTriggerCharacter ? this.pluginState.triggerCharacter!.length : 0), - to: this.editor._tiptapEditor.state.selection.from, + to: this.editor.transaction.selection.from, }) .run(); }; diff --git a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts index 2c070b2366..bcffa50f8b 100644 --- a/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts +++ b/packages/core/src/extensions/SuggestionMenu/getDefaultSlashMenuItems.ts @@ -235,7 +235,7 @@ export function getDefaultSlashMenuItems< // Immediately open the file toolbar editor.dispatch( - editor._tiptapEditor.state.tr.setMeta(editor.filePanel!.plugin, { + editor.transaction.setMeta(editor.filePanel!.plugin, { block: insertedBlock, }) ); @@ -254,7 +254,7 @@ export function getDefaultSlashMenuItems< // Immediately open the file toolbar editor.dispatch( - editor._tiptapEditor.state.tr.setMeta(editor.filePanel!.plugin, { + editor.transaction.setMeta(editor.filePanel!.plugin, { block: insertedBlock, }) ); @@ -273,7 +273,7 @@ export function getDefaultSlashMenuItems< // Immediately open the file toolbar editor.dispatch( - editor._tiptapEditor.state.tr.setMeta(editor.filePanel!.plugin, { + editor.transaction.setMeta(editor.filePanel!.plugin, { block: insertedBlock, }) ); @@ -292,7 +292,7 @@ export function getDefaultSlashMenuItems< // Immediately open the file toolbar editor.dispatch( - editor._tiptapEditor.state.tr.setMeta(editor.filePanel!.plugin, { + editor.transaction.setMeta(editor.filePanel!.plugin, { block: insertedBlock, }) ); diff --git a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts index f4ccfcb6a2..bc4b9a1f98 100644 --- a/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts +++ b/packages/core/src/extensions/TableHandles/TableHandlesPlugin.ts @@ -263,10 +263,7 @@ export class TableHandlesView< | BlockFromConfigNoChildren | undefined; - const pmNodeInfo = getNodeById( - blockEl.id, - this.editor._tiptapEditor.state.doc - ); + const pmNodeInfo = getNodeById(blockEl.id, this.editor.transaction.doc); if (!pmNodeInfo) { throw new Error(`Block with ID ${blockEl.id} not found`); } @@ -815,7 +812,7 @@ export class TableHandlesProsemirrorPlugin< this.view!.emitUpdate(); this.editor.dispatch( - this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + this.editor.transaction.setMeta(tableHandlesPluginKey, { draggedCellOrientation: this.view!.state.draggingState.draggedCellOrientation, originalIndex: this.view!.state.colIndex, @@ -858,7 +855,7 @@ export class TableHandlesProsemirrorPlugin< this.view!.emitUpdate(); this.editor.dispatch( - this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, { + this.editor.transaction.setMeta(tableHandlesPluginKey, { draggedCellOrientation: this.view!.state.draggingState.draggedCellOrientation, originalIndex: this.view!.state.rowIndex, @@ -891,7 +888,7 @@ export class TableHandlesProsemirrorPlugin< this.view!.emitUpdate(); this.editor.dispatch( - this.editor._tiptapEditor.state.tr.setMeta(tableHandlesPluginKey, null) + this.editor.transaction.setMeta(tableHandlesPluginKey, null) ); if (!this.editor.prosemirrorView) { @@ -1144,9 +1141,9 @@ export class TableHandlesProsemirrorPlugin< | undefined ) => { const isSelectingTableCells = isTableCellSelection( - this.editor.prosemirrorState.selection + this.editor.transaction.selection ) - ? this.editor.prosemirrorState.selection + ? this.editor.transaction.selection : undefined; if ( diff --git a/packages/react/src/blocks/FileBlockContent/helpers/render/AddFileButton.tsx b/packages/react/src/blocks/FileBlockContent/helpers/render/AddFileButton.tsx index 0e13dfbb31..424747638d 100644 --- a/packages/react/src/blocks/FileBlockContent/helpers/render/AddFileButton.tsx +++ b/packages/react/src/blocks/FileBlockContent/helpers/render/AddFileButton.tsx @@ -26,12 +26,9 @@ export const AddFileButton = ( // Opens the file toolbar. const addFileButtonClickHandler = useCallback(() => { props.editor.dispatch( - props.editor._tiptapEditor.state.tr.setMeta( - props.editor.filePanel!.plugin, - { - block: props.block, - } - ) + props.editor.transaction.setMeta(props.editor.filePanel!.plugin, { + block: props.block, + }) ); }, [props.block, props.editor]); diff --git a/packages/react/src/components/Comments/ThreadsSidebar.tsx b/packages/react/src/components/Comments/ThreadsSidebar.tsx index 5f6ba726d2..0ac0097761 100644 --- a/packages/react/src/components/Comments/ThreadsSidebar.tsx +++ b/packages/react/src/components/Comments/ThreadsSidebar.tsx @@ -139,11 +139,11 @@ export function getReferenceText( // is not yet fetched (causing it to be empty). We should store the original // reference text in the data model, as not only is it a general improvement, // but it also means we won't have to handle this edge case. - if (editor.prosemirrorState.doc.nodeSize < threadPosition.to) { + if (editor.transaction.doc.nodeSize < threadPosition.to) { return ""; } - const referenceText = editor.prosemirrorState.doc.textBetween( + const referenceText = editor.transaction.doc.textBetween( threadPosition.from, threadPosition.to ); diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx index 5fb2ab974a..6dd4ba1cba 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/CreateLinkButton.tsx @@ -93,8 +93,8 @@ export const CreateLinkButton = () => { } } - return !isTableCellSelection(editor.prosemirrorState.selection); - }, [linkInSchema, selectedBlocks, editor.prosemirrorState.selection]); + return !isTableCellSelection(editor.transaction.selection); + }, [linkInSchema, selectedBlocks, editor.transaction.selection]); if ( !show || diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index 12d6776b34..c37e27b967 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -132,12 +132,14 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => { const onClick = (item: BlockTypeSelectItem) => { editor.focus(); - for (const block of selectedBlocks) { - editor.updateBlock(block, { - type: item.type as any, - props: item.props as any, - }); - } + editor.transact(() => { + for (const block of selectedBlocks) { + editor.updateBlock(block, { + type: item.type as any, + props: item.props as any, + }); + } + }); }; return filteredItems.map((item) => { From 760d76ee538b943702ae6cc37f5d350a4083acae Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 4 Apr 2025 14:32:31 +0200 Subject: [PATCH 2/6] fix: do not even worry about tiptap transactions --- packages/core/src/editor/BlockNoteEditor.ts | 28 ++++++--------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index c8bb91fd32..a5fe65db9e 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -800,41 +800,27 @@ export class BlockNoteEditor< return callback(); } - let result: T = undefined as T; - try { // Enter transaction mode, by setting a start state this.transactionState = this.prosemirrorState; - // Capture all tiptap transactions (tiptapEditor.dispatch'd transactions) - const tiptapTr = this._tiptapEditor.captureTransaction(() => { - // This is more of a safety mechanism, as we catch blocknote transactions separately - result = callback(); - }); + // Capture all dispatch'd transactions + const result = callback(); // Any transactions captured by the `dispatch` call will be stored in `this.activeTransaction` - let activeTr = this.activeTransaction; - - if (tiptapTr && activeTr) { - // If we have both tiptap & blocknote transactions, there is not a clean way to merge them as you'd need to know the order of operations - throw new Error( - "Cannot mix tiptap transactions with BlockNote transactions" - ); - } else if (tiptapTr) { - // If we only have tiptap caught transactions, we can just use that - activeTr = tiptapTr; - } + const activeTr = this.activeTransaction; + this.transactionState = null; if (activeTr) { + this.activeTransaction = null; // Dispatch the transaction if it was modified - this._tiptapEditor.dispatch(activeTr); + this.dispatch(activeTr); } + return result; } finally { this.activeTransaction = null; this.transactionState = null; } - - return result; } /** From 77f1bee8f7ce5349be8635443cfc23056f6573a1 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 4 Apr 2025 14:32:52 +0200 Subject: [PATCH 3/6] fix: execute `updateBlock` as a transaction --- .../commands/updateBlock/updateBlock.ts | 98 ++++++++++--------- 1 file changed, 51 insertions(+), 47 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index a1cd756a3c..ccadf2b756 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -1,5 +1,5 @@ import { Fragment, NodeType, Node as PMNode, Slice } from "prosemirror-model"; -import { EditorState } from "prosemirror-state"; +import { Transaction } from "prosemirror-state"; import { ReplaceStep } from "prosemirror-transform"; import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; @@ -34,33 +34,33 @@ export const updateBlockCommand = block: PartialBlock ) => ({ - state, + tr, dispatch, }: { - state: EditorState; - dispatch: ((args?: any) => any) | undefined; + tr: Transaction; + dispatch: (() => void) | undefined; }) => { const blockInfo = getBlockInfoFromResolvedPos( - state.doc.resolve(posBeforeBlock) + tr.doc.resolve(posBeforeBlock) ); if (dispatch) { // Adds blockGroup node with child blocks if necessary. - const oldNodeType = state.schema.nodes[blockInfo.blockNoteType]; + const oldNodeType = editor.pmSchema.nodes[blockInfo.blockNoteType]; const newNodeType = - state.schema.nodes[block.type || blockInfo.blockNoteType]; + editor.pmSchema.nodes[block.type || blockInfo.blockNoteType]; const newBnBlockNodeType = newNodeType.isInGroup("bnBlock") ? newNodeType - : state.schema.nodes["blockContainer"]; + : editor.pmSchema.nodes["blockContainer"]; if (blockInfo.isBlockContainer && newNodeType.isInGroup("blockContent")) { - updateChildren(block, state, editor, blockInfo); + updateChildren(block, tr, editor, blockInfo); // The code below determines the new content of the block. // or "keep" to keep as-is updateBlockContentNode( block, - state, + tr, editor, oldNodeType, newNodeType, @@ -70,7 +70,7 @@ export const updateBlockCommand = !blockInfo.isBlockContainer && newNodeType.isInGroup("bnBlock") ) { - updateChildren(block, state, editor, blockInfo); + updateChildren(block, tr, editor, 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 { @@ -88,7 +88,7 @@ export const updateBlockCommand = editor.schema.styleSchema, editor.blockCache ); - state.tr.replaceWith( + tr.replaceWith( blockInfo.bnBlock.beforePos, blockInfo.bnBlock.afterPos, blockToNode( @@ -96,7 +96,7 @@ export const updateBlockCommand = children: existingBlock.children, // if no children are passed in, use existing children ...block, }, - state.schema, + editor.pmSchema, editor.schema.styleSchema ) ); @@ -106,7 +106,7 @@ export const updateBlockCommand = // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing // attributes. - state.tr.setNodeMarkup(blockInfo.bnBlock.beforePos, newBnBlockNodeType, { + tr.setNodeMarkup(blockInfo.bnBlock.beforePos, newBnBlockNodeType, { ...blockInfo.bnBlock.node.attrs, ...block.props, }); @@ -121,7 +121,7 @@ function updateBlockContentNode< S extends StyleSchema >( block: PartialBlock, - state: EditorState, + tr: Transaction, editor: BlockNoteEditor, oldNodeType: NodeType, newNodeType: NodeType, @@ -140,7 +140,7 @@ function updateBlockContentNode< // Adds a single text node with no marks to the content. content = inlineContentToNodes( [block.content], - state.schema, + editor.pmSchema, editor.schema.styleSchema, newNodeType.name ); @@ -149,14 +149,14 @@ function updateBlockContentNode< // for each InlineContent object. content = inlineContentToNodes( block.content, - state.schema, + editor.pmSchema, editor.schema.styleSchema, newNodeType.name ); } else if (block.content.type === "tableContent") { content = tableContentToNodes( block.content, - state.schema, + editor.pmSchema, editor.schema.styleSchema ); } else { @@ -186,9 +186,9 @@ function updateBlockContentNode< // content is being replaced or not. if (content === "keep") { // use setNodeMarkup to only update the type and attributes - state.tr.setNodeMarkup( + tr.setNodeMarkup( blockInfo.blockContent.beforePos, - block.type === undefined ? undefined : state.schema.nodes[block.type], + block.type === undefined ? undefined : editor.pmSchema.nodes[block.type], { ...blockInfo.blockContent.node.attrs, ...block.props, @@ -198,7 +198,7 @@ function updateBlockContentNode< // use replaceWith to replace the content and the block itself // also reset the selection since replacing the block content // sets it to the next block. - state.tr.replaceWith( + tr.replaceWith( blockInfo.blockContent.beforePos, blockInfo.blockContent.afterPos, newNodeType.createChecked( @@ -218,13 +218,13 @@ function updateChildren< S extends StyleSchema >( block: PartialBlock, - state: EditorState, + tr: Transaction, editor: BlockNoteEditor, blockInfo: BlockInfo ) { if (block.children !== undefined && block.children.length > 0) { const childNodes = block.children.map((child) => { - return blockToNode(child, state.schema, editor.schema.styleSchema); + return blockToNode(child, editor.pmSchema, editor.schema.styleSchema); }); // Checks if a blockGroup node already exists. @@ -232,7 +232,7 @@ function updateChildren< // Replaces all child nodes in the existing blockGroup with the ones created earlier. // use a replacestep to avoid the fitting algorithm - state.tr.step( + tr.step( new ReplaceStep( blockInfo.childContainer.beforePos + 1, blockInfo.childContainer.afterPos - 1, @@ -244,9 +244,9 @@ function updateChildren< throw new Error("impossible"); } // Inserts a new blockGroup containing the child nodes created earlier. - state.tr.insert( + tr.insert( blockInfo.blockContent.afterPos, - state.schema.nodes["blockGroup"].createChecked({}, childNodes) + editor.pmSchema.nodes["blockGroup"].createChecked({}, childNodes) ); } } @@ -261,34 +261,38 @@ export function updateBlock< blockToUpdate: BlockIdentifier, update: PartialBlock ): Block { - const ttEditor = editor._tiptapEditor; - const id = typeof blockToUpdate === "string" ? blockToUpdate : blockToUpdate.id; + return editor.transact(() => { + const tr = editor.transaction; + const posInfo = getNodeById(id, tr.doc); + if (!posInfo) { + throw new Error(`Block with ID ${id} not found`); + } - const posInfo = getNodeById(id, ttEditor.state.doc); - if (!posInfo) { - throw new Error(`Block with ID ${id} not found`); - } - - ttEditor.commands.command(({ state, dispatch }) => { updateBlockCommand( editor, posInfo.posBeforeNode, update - )({ state, dispatch }); - return true; - }); + )({ + tr, + dispatch: () => { + // no-op + }, + }); + // Actually dispatch that transaction + editor.dispatch(tr); - const blockContainerNode = ttEditor.state.doc - .resolve(posInfo.posBeforeNode + 1) // TODO: clean? - .node(); + const blockContainerNode = tr.doc + .resolve(posInfo.posBeforeNode + 1) // TODO: clean? + .node(); - return nodeToBlock( - blockContainerNode, - editor.schema.blockSchema, - editor.schema.inlineContentSchema, - editor.schema.styleSchema, - editor.blockCache - ); + return nodeToBlock( + blockContainerNode, + editor.schema.blockSchema, + editor.schema.inlineContentSchema, + editor.schema.styleSchema, + editor.blockCache + ); + }); } From 4a3d36488bc18af58723652c8972a9a8b0dcc1b6 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 4 Apr 2025 14:52:01 +0200 Subject: [PATCH 4/6] chore: use replace step to insert blocks --- .../commands/insertBlocks/insertBlocks.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index 251130e059..b83d57779d 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 { Node } from "prosemirror-model"; +import { Fragment, Node, Slice } from "prosemirror-model"; import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; import type { BlockNoteEditor } from "../../../../editor/BlockNoteEditor"; @@ -11,6 +11,7 @@ import { import { blockToNode } from "../../../nodeConversions/blockToNode.js"; import { nodeToBlock } from "../../../nodeConversions/nodeToBlock.js"; import { getNodeById } from "../../../nodeUtil.js"; +import { ReplaceStep } from "prosemirror-transform"; export function insertBlocks< BSchema extends BlockSchema, @@ -43,7 +44,11 @@ export function insertBlocks< pos += posInfo.node.nodeSize; } - editor.dispatch(tr.insert(pos, nodesToInsert)); + tr.step( + new ReplaceStep(pos, pos, new Slice(Fragment.from(nodesToInsert), 0, 0)) + ); + + editor.dispatch(tr); // Now that the `PartialBlock`s have been converted to nodes, we can // re-convert them into full `Block`s. From eb849cd50d904db9a65c6361e843aff8f35a23a8 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 7 Apr 2025 17:39:26 +0200 Subject: [PATCH 5/6] chore: remove use of ttEditor --- .../ListItemBlockContent/ListItemKeyboardShortcuts.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts index 93122fdce2..bfff866963 100644 --- a/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts +++ b/packages/core/src/blocks/ListItemBlockContent/ListItemKeyboardShortcuts.ts @@ -4,15 +4,14 @@ import { getBlockInfoFromSelection } from "../../api/getBlockInfoFromPos.js"; import { BlockNoteEditor } from "../../editor/BlockNoteEditor.js"; export const handleEnter = (editor: BlockNoteEditor) => { - const ttEditor = editor._tiptapEditor; - const blockInfo = getBlockInfoFromSelection(ttEditor.state); + const state = editor.prosemirrorState; + const blockInfo = getBlockInfoFromSelection(state); if (!blockInfo.isBlockContainer) { return false; } const { bnBlock: blockContainer, blockContent } = blockInfo; - const selectionEmpty = - ttEditor.state.selection.anchor === ttEditor.state.selection.head; + const selectionEmpty = state.selection.anchor === state.selection.head; if ( !( @@ -25,7 +24,7 @@ export const handleEnter = (editor: BlockNoteEditor) => { return false; } - return ttEditor.commands.first(({ state, chain, commands }) => [ + return editor._tiptapEditor.commands.first(({ state, chain, commands }) => [ () => // Changes list item block to a paragraph block if the content is empty. commands.command(() => { From fc601215309e3ce473d06f0b4e7e15b750149f7a Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 9 Apr 2025 14:21:28 +0200 Subject: [PATCH 6/6] chore: clarifying comments --- packages/core/src/editor/BlockNoteEditor.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index a5fe65db9e..5a5febda78 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -755,6 +755,7 @@ export class BlockNoteEditor< accTr.step(step); }); if (tr.selectionSet) { + // Serialize the selection to JSON, because the document between the `activeTransaction` and the dispatch'd tr are different references accTr.setSelection( ProsemirrorSelection.fromJSON(accTr.doc, tr.selection.toJSON()) ); @@ -818,6 +819,7 @@ export class BlockNoteEditor< } return result; } finally { + // We wrap this in a finally block to ensure we don't disable future transactions just because of an error in the callback this.activeTransaction = null; this.transactionState = null; }