diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts new file mode 100644 index 000000000..7bf75eecc --- /dev/null +++ b/packages/core/src/api/nodeUtil.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; + +import { setupTestEnv } from "./blockManipulation/setupTestEnv.js"; +import { getBlocksChangedByTransaction } from "./nodeUtil.js"; +import { Transaction } from "prosemirror-state"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import { Step } from "prosemirror-transform"; + +const getEditor = setupTestEnv(); + +describe("Test getBlocksChangedByTransaction", () => { + let transaction: Transaction; + let editor: BlockNoteEditor; + let originalDispatch: typeof editor.dispatch; + + beforeEach(() => { + transaction = undefined as unknown as Transaction; + editor = getEditor(); + originalDispatch = editor.dispatch; + const mockDispatch = vi.fn((tr) => { + editor._tiptapEditor.dispatch(tr); + if (transaction) { + tr.steps.forEach((step: Step) => { + transaction.step(step); + }); + } else { + transaction = tr; + } + }); + editor.dispatch = mockDispatch; + }); + + afterEach(() => { + editor.dispatch = originalDispatch; + }); + + it("should return the correct blocks changed by a transaction", () => { + const transaction = editor.transaction; + const blocksChanged = getBlocksChangedByTransaction(transaction, editor); + expect(blocksChanged).toEqual([]); + }); + + it("should return blocks inserted by a transaction", () => { + editor.insertBlocks([{ type: "paragraph" }], "paragraph-0", "after"); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [], + content: [], + id: "0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: undefined, + source: { type: "local" }, + type: "insert", + }, + ]); + }); + + it("should return blocks deleted by a transaction", () => { + editor.removeBlocks(["paragraph-0"]); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [], + id: "paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 0", + type: "text", + }, + ], + }, + prevBlock: undefined, + source: { type: "local" }, + type: "delete", + }, + ]); + }); + + it("should return blocks updated by a transaction", () => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [], + id: "paragraph-0", + props: { + backgroundColor: "red", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 0", + type: "text", + }, + ], + }, + prevBlock: { + children: [], + id: "paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 0", + type: "text", + }, + ], + }, + source: { type: "local" }, + type: "update", + }, + ]); + }); + + it("should only return a single block, if multiple updates change a single block in a transaction", () => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "blue", + }, + }); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [], + id: "paragraph-0", + props: { + backgroundColor: "blue", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 0", + type: "text", + }, + ], + }, + prevBlock: { + children: [], + id: "paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 0", + type: "text", + }, + ], + }, + source: { type: "local" }, + type: "update", + }, + ]); + }); + + it("should return multiple blocks, if multiple updates change multiple blocks in a transaction", () => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("paragraph-1", { + props: { + backgroundColor: "blue", + }, + }); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [], + id: "paragraph-0", + props: { + backgroundColor: "red", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 0", + type: "text", + }, + ], + }, + prevBlock: { + children: [], + id: "paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 0", + type: "text", + }, + ], + }, + source: { type: "local" }, + type: "update", + }, + { + block: { + children: [], + id: "paragraph-1", + props: { + backgroundColor: "blue", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 1", + type: "text", + }, + ], + }, + prevBlock: { + children: [], + id: "paragraph-1", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + content: [ + { + styles: {}, + text: "Paragraph 1", + type: "text", + }, + ], + }, + source: { type: "local" }, + type: "update", + }, + ]); + }); + + it("should return multiple blocks, if multiple inserts add new blocks in a transaction", () => { + editor.insertBlocks( + [{ type: "paragraph", content: "ABC" }], + "paragraph-0", + "after" + ); + editor.insertBlocks( + [{ type: "paragraph", content: "DEF" }], + "paragraph-1", + "after" + ); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [], + content: [ + { + styles: {}, + text: "ABC", + type: "text", + }, + ], + id: "0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: undefined, + source: { type: "local" }, + type: "insert", + }, + { + block: { + children: [], + content: [ + { + styles: {}, + text: "DEF", + type: "text", + }, + ], + id: "1", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: undefined, + source: { type: "local" }, + type: "insert", + }, + ]); + }); +}); diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 26a860f41..03efb9ce7 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -1,4 +1,21 @@ -import { Node } from "prosemirror-model"; +import { + combineTransactionSteps, + findChildrenInRange, + getChangedRanges, +} from "@tiptap/core"; +import type { Node } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; +import { + Block, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../blocks/defaultBlocks.js"; +import type { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; +import type { BlockSchema } from "../schema/index.js"; +import type { InlineContentSchema } from "../schema/inlineContent/types.js"; +import type { StyleSchema } from "../schema/styles/types.js"; +import { nodeToBlock } from "./nodeConversions/nodeToBlock.js"; /** * Get a TipTap node by id @@ -36,3 +53,192 @@ export function getNodeById( posBeforeNode: posBeforeNode, }; } + +/** + * This attributes the changes to a specific source. + */ +export type BlockChangeSource = + | { + /** + * When an event is triggered by the local user, the source is "local". + * This is the default source. + */ + type: "local"; + } + | { + /** + * When an event is triggered by a paste operation, the source is "paste". + */ + type: "paste"; + } + | { + /** + * When an event is triggered by a drop operation, the source is "drop". + */ + type: "drop"; + } + | { + /** + * When an event is triggered by an undo or redo operation, the source is "undo" or "redo". + */ + type: "undo" | "redo"; + } + | { + /** + * When an event is triggered by a remote user, the source is "remote". + */ + type: "remote"; + }; + +export type BlocksChanged< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema +> = Array< + { + /** + * The affected block. + */ + block: Block; + /** + * The source of the change. + */ + source: BlockChangeSource; + } & ( + | { + type: "insert" | "delete"; + /** + * Insert and delete changes don't have a previous block. + */ + prevBlock: undefined; + } + | { + type: "update"; + /** + * The block before the update. + */ + prevBlock: Block; + } + ) +>; + +/** + * Get the blocks that were changed by a transaction. + * @param transaction The transaction to get the changes from. + * @param editor The editor to get the changes from. + * @returns The blocks that were changed by the transaction. + */ +export function getBlocksChangedByTransaction< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema +>( + transaction: Transaction, + editor: BlockNoteEditor +): BlocksChanged { + let source: BlockChangeSource = { type: "local" }; + + if (transaction.getMeta("paste")) { + source = { type: "paste" }; + } else if (transaction.getMeta("uiEvent") === "drop") { + source = { type: "drop" }; + } else if (transaction.getMeta("history$")) { + source = { + type: transaction.getMeta("history$").redo ? "redo" : "undo", + }; + } else if (transaction.getMeta("y-sync$")) { + source = { type: "remote" }; + } + + const changes: BlocksChanged = []; + // TODO when we upgrade to Tiptap v3, we can get the appendedTransactions which would give us things like the actual inserted Block IDs. + // since they are appended to the transaction via the unique-id plugin + const combinedTransaction = combineTransactionSteps(transaction.before, [ + transaction, + ...[] /*appendedTransactions*/, + ]); + + let prevAffectedBlocks: Block[] = []; + let nextAffectedBlocks: Block[] = []; + + getChangedRanges(combinedTransaction).forEach((range) => { + // All the blocks that were in the range before the transaction + prevAffectedBlocks = prevAffectedBlocks.concat( + ...findChildrenInRange( + combinedTransaction.before, + range.oldRange, + (node) => node.type.isInGroup("bnBlock") + ).map(({ node }) => + nodeToBlock( + node, + editor.schema.blockSchema, + editor.schema.inlineContentSchema, + editor.schema.styleSchema, + editor.blockCache + ) + ) + ); + // All the blocks that were in the range after the transaction + nextAffectedBlocks = nextAffectedBlocks.concat( + findChildrenInRange(combinedTransaction.doc, range.newRange, (node) => + node.type.isInGroup("bnBlock") + ).map(({ node }) => + nodeToBlock( + node, + editor.schema.blockSchema, + editor.schema.inlineContentSchema, + editor.schema.styleSchema, + editor.blockCache + ) + ) + ); + }); + + // de-duplicate by block ID + const nextBlockIds = new Set(nextAffectedBlocks.map((block) => block.id)); + const prevBlockIds = new Set(prevAffectedBlocks.map((block) => block.id)); + + // All blocks that are newly inserted (since they did not exist in the previous state) + const addedBlockIds = Array.from(nextBlockIds).filter( + (id) => !prevBlockIds.has(id) + ); + + addedBlockIds.forEach((blockId) => { + changes.push({ + type: "insert", + block: nextAffectedBlocks.find((block) => block.id === blockId)!, + source, + prevBlock: undefined, + }); + }); + + // All blocks that are newly removed (since they did not exist in the next state) + const removedBlockIds = Array.from(prevBlockIds).filter( + (id) => !nextBlockIds.has(id) + ); + + removedBlockIds.forEach((blockId) => { + changes.push({ + type: "delete", + block: prevAffectedBlocks.find((block) => block.id === blockId)!, + source, + prevBlock: undefined, + }); + }); + + // All blocks that are updated (since they exist in both the previous and next state) + const updatedBlockIds = Array.from(nextBlockIds).filter((id) => + prevBlockIds.has(id) + ); + + updatedBlockIds.forEach((blockId) => { + changes.push({ + type: "update", + block: nextAffectedBlocks.find((block) => block.id === blockId)!, + prevBlock: prevAffectedBlocks.find((block) => block.id === blockId)!, + source, + }); + }); + + return changes; +} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index a5fe65db9..2df7a8dfe 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -107,6 +107,10 @@ 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 { + BlocksChanged, + getBlocksChangedByTransaction, +} from "../api/nodeUtil.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"; @@ -1466,15 +1470,22 @@ export class BlockNoteEditor< * @returns A function to remove the callback. */ public onChange( - callback: (editor: BlockNoteEditor) => void + callback: ( + editor: BlockNoteEditor, + context: { + getChanges(): BlocksChanged; + } + ) => void ) { if (this.headless) { // Note: would be nice if this is possible in headless mode as well return; } - const cb = () => { - callback(this); + const cb = ({ transaction }: { transaction: Transaction }) => { + callback(this, { + getChanges: () => getBlocksChangedByTransaction(transaction, this), + }); }; this._tiptapEditor.on("update", cb); diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 4737afd4b..45c4b7398 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -71,7 +71,9 @@ export type BlockNoteViewProps< /** * A callback function that runs whenever the editor's contents change. */ - onChange?: () => void; + onChange?: Parameters< + BlockNoteEditor["onChange"] + >[0]; children?: ReactNode; diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index 4df745720..2d13bb062 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; export function useEditorChange( - callback: () => void, + callback: Parameters["onChange"]>[0], editor?: BlockNoteEditor ) { const editorContext = useBlockNoteContext();