diff --git a/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts b/packages/core/src/api/blockManipulation/commands/insertBlocks/insertBlocks.ts index dc0ea2fd80..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, @@ -32,28 +33,23 @@ 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; } + 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. 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/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 + ); + }); } 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/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(() => { 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..5a5febda78 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,119 @@ 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) { + // 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()) + ); + } + }); + 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(); + } + + try { + // Enter transaction mode, by setting a start state + this.transactionState = this.prosemirrorState; + + // Capture all dispatch'd transactions + const result = callback(); + + // Any transactions captured by the `dispatch` call will be stored in `this.activeTransaction` + const activeTr = this.activeTransaction; + + this.transactionState = null; + if (activeTr) { + this.activeTransaction = null; + // Dispatch the transaction if it was modified + this.dispatch(activeTr); + } + 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; + } + } + + /** + * 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 +865,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 +1193,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 +1206,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 +1287,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 +1307,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 +1573,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) => {