From 2060a71472460238558378070f98a22303b5e6cb Mon Sep 17 00:00:00 2001 From: yousefed Date: Sun, 13 Oct 2024 20:43:18 +0200 Subject: [PATCH] add support for blocknote transactions --- .../commands/updateBlock/updateBlock.ts | 332 +++++++++--------- packages/core/src/editor/BlockNoteEditor.ts | 29 +- .../DefaultSelects/BlockTypeSelect.tsx | 14 +- 3 files changed, 204 insertions(+), 171 deletions(-) diff --git a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts index 37888809b..b5c4cc4f3 100644 --- a/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/updateBlock/updateBlock.ts @@ -1,5 +1,10 @@ import { Fragment, Node as PMNode, Slice } from "prosemirror-model"; -import { EditorState, NodeSelection, TextSelection } from "prosemirror-state"; +import { + EditorState, + NodeSelection, + TextSelection, + Transaction, +} from "prosemirror-state"; import { Block, PartialBlock } from "../../../../blocks/defaultBlocks.js"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; @@ -19,6 +24,161 @@ import { } from "../../../nodeConversions/nodeConversions.js"; import { getNodeById } from "../../../nodeUtil.js"; +export const updateBlockCommandWithTr = < + BSchema extends BlockSchema, + I extends InlineContentSchema, + S extends StyleSchema +>( + posInBlock: number, + tr: Transaction, + block: PartialBlock, + editor: BlockNoteEditor +) => { + const blockInfo = getBlockInfoFromPos(tr.doc, posInBlock); + if (blockInfo === undefined) { + return false; + } + + const { blockContainer, blockContent, blockGroup } = blockInfo; + + // Adds blockGroup node with child blocks if necessary. + if (block.children !== undefined) { + const childNodes = []; + + // Creates ProseMirror nodes for each child block, including their descendants. + for (const child of block.children) { + childNodes.push( + blockToNode(child, editor.pmSchema, editor.schema.styleSchema) + ); + } + + // Checks if a blockGroup node already exists. + if (blockGroup) { + // Replaces all child nodes in the existing blockGroup with the ones created earlier. + tr.replace( + blockGroup.beforePos + 1, + blockGroup.afterPos - 1, + new Slice(Fragment.from(childNodes), 0, 0) + ); + } else { + // Inserts a new blockGroup containing the child nodes created earlier. + tr.insert( + blockContent.afterPos, + editor.pmSchema.nodes["blockGroup"].create({}, childNodes) + ); + } + } + + const oldType = blockContent.node.type.name; + const newType = block.type || oldType; + + // The code below determines the new content of the block. + // or "keep" to keep as-is + let content: PMNode[] | "keep" = "keep"; + + // Has there been any custom content provided? + if (block.content) { + if (typeof block.content === "string") { + // Adds a single text node with no marks to the content. + content = inlineContentToNodes( + [block.content], + editor.pmSchema, + editor.schema.styleSchema + ); + } else if (Array.isArray(block.content)) { + // Adds a text node with the provided styles converted into marks to the content, + // for each InlineContent object. + content = inlineContentToNodes( + block.content, + editor.pmSchema, + editor.schema.styleSchema + ); + } else if (block.content.type === "tableContent") { + content = tableContentToNodes( + block.content, + editor.pmSchema, + editor.schema.styleSchema + ); + } else { + throw new UnreachableCaseError(block.content.type); + } + } else { + // no custom content has been provided, use existing content IF possible + + // Since some block types contain inline content and others don't, + // we either need to call setNodeMarkup to just update type & + // attributes, or replaceWith to replace the whole blockContent. + const oldContentType = editor.pmSchema.nodes[oldType].spec.content; + const newContentType = editor.pmSchema.nodes[newType].spec.content; + + if (oldContentType === "") { + // keep old content, because it's empty anyway and should be compatible with + // any newContentType + } else if (newContentType !== oldContentType) { + // the content type changed, replace the previous content + content = []; + } else { + // keep old content, because the content type is the same and should be compatible + } + } + + // Now, changes the blockContent node type and adds the provided props + // as attributes. Also preserves all existing attributes that are + // compatible with the new type. + // + // Use either setNodeMarkup or replaceWith depending on whether the + // content is being replaced or not. + if (content === "keep") { + // use setNodeMarkup to only update the type and attributes + tr.setNodeMarkup( + blockContent.beforePos, + block.type === undefined ? undefined : editor.pmSchema.nodes[block.type], + { + ...blockContent.node.attrs, + ...block.props, + } + ); + } else { + // 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. + tr.replaceWith( + blockContent.beforePos, + blockContent.afterPos, + editor.pmSchema.nodes[newType].create( + { + ...blockContent.node.attrs, + ...block.props, + }, + content + ) + ) + // If the node doesn't contain editable content, we want to + // select the whole node. But if it does have editable content, + // we want to set the selection to the start of it. + .setSelection( + editor.pmSchema.nodes[newType].spec.content === "" + ? new NodeSelection(tr.doc.resolve(blockContent.beforePos)) + : editor.pmSchema.nodes[newType].spec.content === "inline*" + ? new TextSelection(tr.doc.resolve(blockContent.beforePos)) + : // Need to offset the position as we have to get through the + // `tableRow` and `tableCell` nodes to get to the + // `tableParagraph` node we want to set the selection in. + new TextSelection(tr.doc.resolve(blockContent.beforePos + 4)) + ); + } + + // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing + // attributes. + tr.setNodeMarkup(blockContainer.beforePos, undefined, { + ...blockContainer.node.attrs, + ...block.props, + }); + return true; +}; + +// TODO: this entire command should be removed as we're deprecating tiptap style transactions and commands +// Calls should be replaced with editor.updateBlock() or updateBlock below export const updateBlockCommand = < BSchema extends BlockSchema, @@ -29,160 +189,11 @@ export const updateBlockCommand = posInBlock: number, block: PartialBlock ) => - ({ - state, - dispatch, - }: { + (args: { state: EditorState; dispatch: ((args?: any) => any) | undefined; }) => { - const blockInfo = getBlockInfoFromPos(state.doc, posInBlock); - if (blockInfo === undefined) { - return false; - } - - const { blockContainer, blockContent, blockGroup } = blockInfo; - - if (dispatch) { - // Adds blockGroup node with child blocks if necessary. - if (block.children !== undefined) { - const childNodes = []; - - // Creates ProseMirror nodes for each child block, including their descendants. - for (const child of block.children) { - childNodes.push( - blockToNode(child, state.schema, editor.schema.styleSchema) - ); - } - - // Checks if a blockGroup node already exists. - if (blockGroup) { - // Replaces all child nodes in the existing blockGroup with the ones created earlier. - state.tr.replace( - blockGroup.beforePos + 1, - blockGroup.afterPos - 1, - new Slice(Fragment.from(childNodes), 0, 0) - ); - } else { - // Inserts a new blockGroup containing the child nodes created earlier. - state.tr.insert( - blockContent.afterPos, - state.schema.nodes["blockGroup"].create({}, childNodes) - ); - } - } - - const oldType = blockContent.node.type.name; - const newType = block.type || oldType; - - // The code below determines the new content of the block. - // or "keep" to keep as-is - let content: PMNode[] | "keep" = "keep"; - - // Has there been any custom content provided? - if (block.content) { - if (typeof block.content === "string") { - // Adds a single text node with no marks to the content. - content = inlineContentToNodes( - [block.content], - state.schema, - editor.schema.styleSchema - ); - } else if (Array.isArray(block.content)) { - // Adds a text node with the provided styles converted into marks to the content, - // for each InlineContent object. - content = inlineContentToNodes( - block.content, - state.schema, - editor.schema.styleSchema - ); - } else if (block.content.type === "tableContent") { - content = tableContentToNodes( - block.content, - state.schema, - editor.schema.styleSchema - ); - } else { - throw new UnreachableCaseError(block.content.type); - } - } else { - // no custom content has been provided, use existing content IF possible - - // Since some block types contain inline content and others don't, - // we either need to call setNodeMarkup to just update type & - // attributes, or replaceWith to replace the whole blockContent. - const oldContentType = state.schema.nodes[oldType].spec.content; - const newContentType = state.schema.nodes[newType].spec.content; - - if (oldContentType === "") { - // keep old content, because it's empty anyway and should be compatible with - // any newContentType - } else if (newContentType !== oldContentType) { - // the content type changed, replace the previous content - content = []; - } else { - // keep old content, because the content type is the same and should be compatible - } - } - - // Now, changes the blockContent node type and adds the provided props - // as attributes. Also preserves all existing attributes that are - // compatible with the new type. - // - // Use either setNodeMarkup or replaceWith depending on whether the - // content is being replaced or not. - if (content === "keep") { - // use setNodeMarkup to only update the type and attributes - state.tr.setNodeMarkup( - blockContent.beforePos, - block.type === undefined ? undefined : state.schema.nodes[block.type], - { - ...blockContent.node.attrs, - ...block.props, - } - ); - } else { - // 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( - blockContent.beforePos, - blockContent.afterPos, - state.schema.nodes[newType].create( - { - ...blockContent.node.attrs, - ...block.props, - }, - content - ) - ) - // If the node doesn't contain editable content, we want to - // select the whole node. But if it does have editable content, - // we want to set the selection to the start of it. - .setSelection( - state.schema.nodes[newType].spec.content === "" - ? new NodeSelection(state.tr.doc.resolve(blockContent.beforePos)) - : state.schema.nodes[newType].spec.content === "inline*" - ? new TextSelection(state.tr.doc.resolve(blockContent.beforePos)) - : // Need to offset the position as we have to get through the - // `tableRow` and `tableCell` nodes to get to the - // `tableParagraph` node we want to set the selection in. - new TextSelection( - state.tr.doc.resolve(blockContent.beforePos + 4) - ) - ); - } - - // Adds all provided props as attributes to the parent blockContainer node too, and also preserves existing - // attributes. - state.tr.setNodeMarkup(blockContainer.beforePos, undefined, { - ...blockContainer.node.attrs, - ...block.props, - }); - } - - return true; + return updateBlockCommandWithTr(posInBlock, args.state.tr, block, editor); }; export function updateBlock< @@ -192,22 +203,21 @@ export function updateBlock< >( editor: BlockNoteEditor, blockToUpdate: BlockIdentifier, - update: PartialBlock + update: PartialBlock, + existingTr?: Transaction ): Block { const ttEditor = editor._tiptapEditor; + const tr = existingTr ?? ttEditor.state.tr; const id = typeof blockToUpdate === "string" ? blockToUpdate : blockToUpdate.id; - const { posBeforeNode } = getNodeById(id, ttEditor.state.doc); + const { posBeforeNode } = getNodeById(id, tr.doc); - ttEditor.commands.command(({ state, dispatch }) => { - updateBlockCommand(editor, posBeforeNode + 1, update)({ state, dispatch }); - return true; - }); + updateBlockCommandWithTr(posBeforeNode + 1, tr, update, editor); + + const blockContainerNode = tr.doc.resolve(posBeforeNode + 1).node(); - const blockContainerNode = ttEditor.state.doc - .resolve(posBeforeNode + 1) - .node(); + editor.dispatch(tr); return nodeToBlock( blockContainerNode, diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index d947f70db..7ac9dd405 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -2,7 +2,6 @@ import { EditorOptions, Extension, getSchema } from "@tiptap/core"; import { Node, Schema } from "prosemirror-model"; // import "./blocknote.css"; import * as Y from "yjs"; -import { insertContentAt } from "../api/blockManipulation/insertContentAt.js"; import { insertBlocks } from "../api/blockManipulation/commands/insertBlocks/insertBlocks.js"; import { moveBlockDown, @@ -11,6 +10,7 @@ import { import { removeBlocks } from "../api/blockManipulation/commands/removeBlocks/removeBlocks.js"; import { replaceBlocks } from "../api/blockManipulation/commands/replaceBlocks/replaceBlocks.js"; import { updateBlock } from "../api/blockManipulation/commands/updateBlock/updateBlock.js"; +import { insertContentAt } from "../api/blockManipulation/insertContentAt.js"; import { getTextCursorPosition, setTextCursorPosition, @@ -207,6 +207,7 @@ export class BlockNoteEditor< SSchema extends StyleSchema = DefaultStyleSchema > { private readonly _pmSchema: Schema; + private activeTransaction: Transaction | undefined = undefined; /** * Boolean indicating whether the editor is in headless mode. @@ -484,8 +485,28 @@ export class BlockNoteEditor< } } - dispatch(tr: Transaction) { - this._tiptapEditor.dispatch(tr); + /** + * Dispatch a transaction, but only if not in a "blocknote transact". + */ + public dispatch(tr: Transaction) { + if (!this.activeTransaction) { + this._tiptapEditor.dispatch(tr); + } + } + + /** + * 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) + */ + public transact(func: () => void) { + this.activeTransaction = this._tiptapEditor.state.tr; + try { + func(); + this._tiptapEditor.dispatch(this.activeTransaction); + } finally { + this.activeTransaction = undefined; + } } /** @@ -795,7 +816,7 @@ export class BlockNoteEditor< blockToUpdate: BlockIdentifier, update: PartialBlock ) { - return updateBlock(this, blockToUpdate, update); + return updateBlock(this, blockToUpdate, update, this.activeTransaction); } /** diff --git a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx index 957227963..197b877c3 100644 --- a/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx +++ b/packages/react/src/components/FormattingToolbar/DefaultSelects/BlockTypeSelect.tsx @@ -125,12 +125,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) => {