From 6aa92ca63a35964d152b2454f2c0e8ebf1bb0994 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 25 Aug 2025 14:10:25 +0200 Subject: [PATCH] fix(core): report block moves in `getBlocksChangedByTransaction` #1924 --- packages/core/package.json | 1 + ...locks-moved-down-twice-in-same-parent.json | 44 ++ ...ks-moved-insert-changes-sibling-order.json | 26 ++ .../blocks-moved-nested-sibling-reorder.json | 180 ++++++++ .../blocks-moved-up-down-in-same-parent.json | 44 ++ ...cks-moved-up-down-in-same-transaction.json | 44 ++ ... => getBlocksChangedByTransaction.test.ts} | 118 ++++- .../src/api/getBlocksChangedByTransaction.ts | 422 ++++++++++++++++++ packages/core/src/api/nodeUtil.ts | 250 ----------- packages/core/src/editor/BlockNoteEditor.ts | 2 +- .../BlockChange/BlockChangePlugin.ts | 6 +- packages/core/src/index.ts | 1 + pnpm-lock.yaml | 3 + 13 files changed, 887 insertions(+), 254 deletions(-) create mode 100644 packages/core/src/api/__snapshots__/blocks-moved-down-twice-in-same-parent.json create mode 100644 packages/core/src/api/__snapshots__/blocks-moved-insert-changes-sibling-order.json create mode 100644 packages/core/src/api/__snapshots__/blocks-moved-nested-sibling-reorder.json create mode 100644 packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-parent.json create mode 100644 packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-transaction.json rename packages/core/src/api/{nodeUtil.test.ts => getBlocksChangedByTransaction.test.ts} (77%) create mode 100644 packages/core/src/api/getBlocksChangedByTransaction.ts diff --git a/packages/core/package.json b/packages/core/package.json index 6bf40604b5..0a83868725 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -92,6 +92,7 @@ "@tiptap/extension-underline": "^2.11.5", "@tiptap/pm": "^2.12.0", "emoji-mart": "^5.6.0", + "fast-deep-equal": "^3", "hast-util-from-dom": "^5.0.1", "prosemirror-dropcursor": "^1.8.2", "prosemirror-highlight": "^0.13.0", diff --git a/packages/core/src/api/__snapshots__/blocks-moved-down-twice-in-same-parent.json b/packages/core/src/api/__snapshots__/blocks-moved-down-twice-in-same-parent.json new file mode 100644 index 0000000000..c5ac2b79ff --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-down-twice-in-same-parent.json @@ -0,0 +1,44 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "A", + "type": "text", + }, + ], + "id": "a", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": undefined, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "A", + "type": "text", + }, + ], + "id": "a", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": undefined, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-insert-changes-sibling-order.json b/packages/core/src/api/__snapshots__/blocks-moved-insert-changes-sibling-order.json new file mode 100644 index 0000000000..9b4445d20d --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-insert-changes-sibling-order.json @@ -0,0 +1,26 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "X", + "type": "text", + }, + ], + "id": "x", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": undefined, + "source": { + "type": "local", + }, + "type": "insert", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-nested-sibling-reorder.json b/packages/core/src/api/__snapshots__/blocks-moved-nested-sibling-reorder.json new file mode 100644 index 0000000000..60e2b51881 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-nested-sibling-reorder.json @@ -0,0 +1,180 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "child-b", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "child-b", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "A", + "type": "text", + }, + ], + "id": "child-a", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "child-c", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "child-b", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "A", + "type": "text", + }, + ], + "id": "child-a", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "child-b", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "child-c", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-parent.json b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-parent.json new file mode 100644 index 0000000000..9e60af8ded --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-parent.json @@ -0,0 +1,44 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Bottom", + "type": "text", + }, + ], + "id": "bottom", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": undefined, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Bottom", + "type": "text", + }, + ], + "id": "bottom", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": undefined, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-transaction.json b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-transaction.json new file mode 100644 index 0000000000..766843b6bf --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-up-down-in-same-transaction.json @@ -0,0 +1,44 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Middle", + "type": "text", + }, + ], + "id": "middle", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": undefined, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Middle", + "type": "text", + }, + ], + "id": "middle", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": undefined, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/getBlocksChangedByTransaction.test.ts similarity index 77% rename from packages/core/src/api/nodeUtil.test.ts rename to packages/core/src/api/getBlocksChangedByTransaction.test.ts index ba6048895d..03a3b464e8 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/getBlocksChangedByTransaction.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, beforeEach } from "vitest"; import { setupTestEnv } from "./blockManipulation/setupTestEnv.js"; -import { getBlocksChangedByTransaction } from "./nodeUtil.js"; +import { getBlocksChangedByTransaction } from "./getBlocksChangedByTransaction.js"; import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; const getEditor = setupTestEnv(); @@ -452,4 +452,120 @@ describe("getBlocksChangedByTransaction", () => { "__snapshots__/blocks-moved-multiple-in-same-transaction.json", ); }); + + it("should return blocks which have been moved up or down in the same transaction", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "top", + type: "paragraph", + content: "Top", + }, + { + id: "middle", + type: "paragraph", + content: "Middle", + }, + { + id: "bottom", + type: "paragraph", + content: "Bottom", + }, + ]); + + const blocksChanged = editor.transact((tr) => { + editor.setTextCursorPosition("top"); + editor.moveBlocksDown(); + + return getBlocksChangedByTransaction(tr); + }); + + // Should report a single minimal move within the same parent + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-up-down-in-same-transaction.json", + ); + }); + + it("should detect moving the bottom block up within the same parent", async () => { + editor.replaceBlocks(editor.document, [ + { id: "top", type: "paragraph", content: "Top" }, + { id: "middle", type: "paragraph", content: "Middle" }, + { id: "bottom", type: "paragraph", content: "Bottom" }, + ]); + + const blocksChanged = editor.transact((tr) => { + editor.setTextCursorPosition("bottom"); + editor.moveBlocksUp(); + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-up-down-in-same-parent.json", + ); + }); + + it("should detect moving a block down twice within the same parent as a single move", async () => { + editor.replaceBlocks(editor.document, [ + { id: "a", type: "paragraph", content: "A" }, + { id: "b", type: "paragraph", content: "B" }, + { id: "c", type: "paragraph", content: "C" }, + ]); + + const blocksChanged = editor.transact((tr) => { + editor.setTextCursorPosition("a"); + editor.moveBlocksDown(); + editor.moveBlocksDown(); + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-down-twice-in-same-parent.json", + ); + }); + + it("should detect nested sibling reorder within the same parent", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [ + { id: "child-a", type: "paragraph", content: "A" }, + { id: "child-b", type: "paragraph", content: "B" }, + { id: "child-c", type: "paragraph", content: "C" }, + ], + }, + { id: "sibling", type: "paragraph", content: "S" }, + ]); + + const blocksChanged = editor.transact((tr) => { + editor.setTextCursorPosition("child-a"); + editor.moveBlocksDown(); + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-nested-sibling-reorder.json", + ); + }); + + it("should not report moves when an insert changes sibling order", async () => { + editor.replaceBlocks(editor.document, [ + { id: "a", type: "paragraph", content: "A" }, + { id: "b", type: "paragraph", content: "B" }, + { id: "c", type: "paragraph", content: "C" }, + ]); + + const blocksChanged = editor.transact((tr) => { + editor.insertBlocks( + [{ id: "x", type: "paragraph", content: "X" }], + "a", + "after", + ); + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-insert-changes-sibling-order.json", + ); + }); }); diff --git a/packages/core/src/api/getBlocksChangedByTransaction.ts b/packages/core/src/api/getBlocksChangedByTransaction.ts new file mode 100644 index 0000000000..c45af4cb71 --- /dev/null +++ b/packages/core/src/api/getBlocksChangedByTransaction.ts @@ -0,0 +1,422 @@ +import { combineTransactionSteps } from "@tiptap/core"; +import deepEqual from "fast-deep-equal"; +import type { Node } from "prosemirror-model"; +import type { Transaction } from "prosemirror-state"; +import { + Block, + DefaultBlockSchema, + DefaultInlineContentSchema, + DefaultStyleSchema, +} from "../blocks/defaultBlocks.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"; +import { isNodeBlock } from "./nodeUtil.js"; +import { getPmSchema } from "./pmUtil.js"; + +/** + * Change detection utilities for BlockNote. + * + * High-level algorithm used by getBlocksChangedByTransaction: + * 1) Merge appended transactions into one document change. + * 2) Collect a snapshot of blocks before and after (flat map by id, and per-parent child order). + * 3) Emit inserts and deletes by diffing ids between snapshots. + * 4) For ids present in both snapshots: + * - If parentId changed, emit a move + * - Else if block changed (ignoring children), emit an update + * 5) Finally, detect same-parent sibling reorders by comparing child order per parent. + * We use an inlined O(n log n) LIS inside detectReorderedChildren to keep a + * longest already-ordered subsequence and mark only the remaining items as moved. + */ +/** + * Gets the parent block of a node, if it has one. + */ +function getParentBlockId(doc: Node, pos: number): string | undefined { + if (pos === 0) { + return undefined; + } + const resolvedPos = doc.resolve(pos); + for (let i = resolvedPos.depth; i > 0; i--) { + const parent = resolvedPos.node(i); + if (isNodeBlock(parent)) { + return parent.attrs.id; + } + } + return undefined; +} + +/** + * This attributes the changes to a specific source. + */ +export type BlockChangeSource = + | { type: "local" } + | { type: "paste" } + | { type: "drop" } + | { type: "undo" | "redo" | "undo-redo" } + | { type: "yjs-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 previous block. + */ + prevBlock: Block; + } + | { + type: "move"; + /** + * The affected block. + */ + block: Block; + /** + * The block before the move. + */ + prevBlock: Block; + /** + * The previous parent block (if it existed). + */ + prevParent?: Block; + /** + * The current parent block (if it exists). + */ + currentParent?: Block; + } + ) +>; + +function determineChangeSource(transaction: Transaction): BlockChangeSource { + if (transaction.getMeta("paste")) { + return { type: "paste" }; + } + if (transaction.getMeta("uiEvent") === "drop") { + return { type: "drop" }; + } + if (transaction.getMeta("history$")) { + return { + type: transaction.getMeta("history$").redo ? "redo" : "undo", + }; + } + if (transaction.getMeta("y-sync$")) { + if (transaction.getMeta("y-sync$").isUndoRedoOperation) { + return { type: "undo-redo" }; + } + return { type: "yjs-remote" }; + } + return { type: "local" }; +} + +type BlockSnapshot< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema, +> = { + byId: Record< + string, + { + block: Block; + parentId: string | undefined; + } + >; + childrenByParent: Record; +}; + +/** + * Collects a snapshot of blocks and per-parent child order in a single traversal. + * Uses "__root__" to represent the root level where parentId is undefined. + */ +function collectSnapshot< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema, +>(doc: Node): BlockSnapshot { + const ROOT_KEY = "__root__"; + const byId: Record< + string, + { + block: Block; + parentId: string | undefined; + } + > = {}; + const childrenByParent: Record = {}; + const pmSchema = getPmSchema(doc); + doc.descendants((node, pos) => { + if (!isNodeBlock(node)) { + return true; + } + const parentId = getParentBlockId(doc, pos); + const key = parentId ?? ROOT_KEY; + if (!childrenByParent[key]) { + childrenByParent[key] = []; + } + const block = nodeToBlock(node, pmSchema); + byId[node.attrs.id] = { block, parentId }; + childrenByParent[key].push(node.attrs.id); + return true; + }); + return { byId, childrenByParent }; +} + +/** + * Determines which child ids have been reordered (moved) within the same parent. + * Uses LIS to keep the longest ordered subsequence and marks the rest as moved. + */ +function detectReorderedChildren( + prevOrder: string[] | undefined, + nextOrder: string[] | undefined, +): Set { + const moved = new Set(); + if (!prevOrder || !nextOrder) { + return moved; + } + // Consider only ids present in both orders (ignore inserts/deletes handled elsewhere) + const prevIds = new Set(prevOrder); + const commonNext: string[] = nextOrder.filter((id) => prevIds.has(id)); + const commonPrev: string[] = prevOrder.filter((id) => + commonNext.includes(id), + ); + + if (commonPrev.length <= 1 || commonNext.length <= 1) { + return moved; + } + + // Map ids to their index in previous order + const indexInPrev: Record = {}; + for (let i = 0; i < commonPrev.length; i++) { + indexInPrev[commonPrev[i]] = i; + } + + // Build sequence of indices representing next order in terms of previous indices + const sequence: number[] = commonNext.map((id) => indexInPrev[id]); + + // Inline O(n log n) LIS with reconstruction. + // Why LIS? We want the smallest set of siblings to label as "moved". + // Keeping the longest subsequence that is already in order achieves this, + // so only items outside the LIS are reported as moves. + const n = sequence.length; + const tailsValues: number[] = []; + const tailsEndsAtIndex: number[] = []; + const previousIndexInLis: number[] = new Array(n).fill(-1); + + const lowerBound = (arr: number[], target: number): number => { + let lo = 0; + let hi = arr.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (arr[mid] < target) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; + }; + + for (let i = 0; i < n; i++) { + const value = sequence[i]; + const pos = lowerBound(tailsValues, value); + if (pos > 0) { + previousIndexInLis[i] = tailsEndsAtIndex[pos - 1]; + } + if (pos === tailsValues.length) { + tailsValues.push(value); + tailsEndsAtIndex.push(i); + } else { + tailsValues[pos] = value; + tailsEndsAtIndex[pos] = i; + } + } + + const lisIndexSet = new Set(); + let k = tailsEndsAtIndex[tailsEndsAtIndex.length - 1] ?? -1; + while (k !== -1) { + lisIndexSet.add(k); + k = previousIndexInLis[k]; + } + + // Items not part of LIS are considered moved + for (let i = 0; i < commonNext.length; i++) { + if (!lisIndexSet.has(i)) { + moved.add(commonNext[i]); + } + } + return moved; +} + +/** + * Get the blocks that were changed by a transaction. + */ +export function getBlocksChangedByTransaction< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema, +>( + transaction: Transaction, + appendedTransactions: Transaction[] = [], +): BlocksChanged { + const source = determineChangeSource(transaction); + const combinedTransaction = combineTransactionSteps(transaction.before, [ + transaction, + ...appendedTransactions, + ]); + + const prevSnap = collectSnapshot( + combinedTransaction.before, + ); + const nextSnap = collectSnapshot( + combinedTransaction.doc, + ); + + const changes: BlocksChanged = []; + const changedIds = new Set(); + + // Handle inserted blocks + Object.keys(nextSnap.byId) + .filter((id) => !(id in prevSnap.byId)) + .forEach((id) => { + changes.push({ + type: "insert", + block: nextSnap.byId[id].block, + source, + prevBlock: undefined, + }); + changedIds.add(id); + }); + + // Handle deleted blocks + Object.keys(prevSnap.byId) + .filter((id) => !(id in nextSnap.byId)) + .forEach((id) => { + changes.push({ + type: "delete", + block: prevSnap.byId[id].block, + source, + prevBlock: undefined, + }); + changedIds.add(id); + }); + + // Handle updated, moved to different parent, indented, outdented blocks + Object.keys(nextSnap.byId) + .filter((id) => id in prevSnap.byId) + .forEach((id) => { + const prev = prevSnap.byId[id]; + const next = nextSnap.byId[id]; + const isParentDifferent = prev.parentId !== next.parentId; + + if (isParentDifferent) { + changes.push({ + type: "move", + block: next.block, + prevBlock: prev.block, + source, + prevParent: prev.parentId + ? prevSnap.byId[prev.parentId]?.block + : undefined, + currentParent: next.parentId + ? nextSnap.byId[next.parentId]?.block + : undefined, + }); + changedIds.add(id); + } else if ( + // Compare blocks while ignoring children to avoid reporting a parent + // update when only descendants changed. + !deepEqual( + { ...prev.block, children: undefined } as any, + { ...next.block, children: undefined } as any, + ) + ) { + changes.push({ + type: "update", + block: next.block, + prevBlock: prev.block, + source, + }); + changedIds.add(id); + } + }); + + // Handle sibling reorders (parent unchanged but relative order changed) + const prevOrderByParent = prevSnap.childrenByParent; + const nextOrderByParent = nextSnap.childrenByParent; + + // Use a special key for root-level siblings + const ROOT_KEY = "__root__"; + const parents = new Set([ + ...Object.keys(prevOrderByParent), + ...Object.keys(nextOrderByParent), + ]); + + const addedMoveForId = new Set(); + + parents.forEach((parentKey) => { + const movedWithinParent = detectReorderedChildren( + prevOrderByParent[parentKey], + nextOrderByParent[parentKey], + ); + if (movedWithinParent.size === 0) { + return; + } + movedWithinParent.forEach((id) => { + // Only consider ids that exist in both snapshots and whose parent truly did not change + const prev = prevSnap.byId[id]; + const next = nextSnap.byId[id]; + if (!prev || !next) { + return; + } + if (prev.parentId !== next.parentId) { + return; + } + // Skip if already accounted for by insert/delete/update/parent move + if (changedIds.has(id)) { + return; + } + // Verify we're addressing the right parent bucket + const bucketKey = prev.parentId ?? ROOT_KEY; + if (bucketKey !== parentKey) { + return; + } + if (addedMoveForId.has(id)) { + return; + } + addedMoveForId.add(id); + changes.push({ + type: "move", + block: next.block, + prevBlock: prev.block, + source, + prevParent: prev.parentId + ? prevSnap.byId[prev.parentId]?.block + : undefined, + currentParent: next.parentId + ? nextSnap.byId[next.parentId]?.block + : undefined, + }); + changedIds.add(id); + }); + }); + + return changes; +} diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index f0d8ce54e7..3388c95413 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -1,34 +1,4 @@ -import { combineTransactionSteps } 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 { 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"; -import { getPmSchema } from "./pmUtil.js"; - -/** - * Gets the parent block of a node, if it has one. - */ -function getParentBlockId(doc: Node, pos: number): string | undefined { - if (pos === 0) { - return undefined; - } - const resolvedPos = doc.resolve(pos); - for (let i = resolvedPos.depth; i > 0; i--) { - const parent = resolvedPos.node(i); - if (isNodeBlock(parent)) { - return parent.attrs.id; - } - } - return undefined; -} /** * Get a TipTap node by id @@ -70,223 +40,3 @@ export function getNodeById( export function isNodeBlock(node: Node): boolean { return node.type.isInGroup("bnBlock"); } - -/** - * This attributes the changes to a specific source. - */ -export type BlockChangeSource = - | { type: "local" } - | { type: "paste" } - | { type: "drop" } - | { type: "undo" | "redo" | "undo-redo" } - | { type: "yjs-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 previous block. - */ - prevBlock: Block; - } - | { - type: "move"; - /** - * The affected block. - */ - block: Block; - /** - * The block before the move. - */ - prevBlock: Block; - /** - * The previous parent block (if it existed). - */ - prevParent?: Block; - /** - * The current parent block (if it exists). - */ - currentParent?: Block; - } - ) ->; - -/** - * Compares two blocks, ignoring their children. - * Returns true if the blocks are different (excluding children). - */ -function areBlocksDifferentExcludingChildren< - BSchema extends BlockSchema, - ISchema extends InlineContentSchema, - SSchema extends StyleSchema, ->( - block1: Block, - block2: Block, -): boolean { - return ( - block1.id !== block2.id || - block1.type !== block2.type || - JSON.stringify(block1.props) !== JSON.stringify(block2.props) || - JSON.stringify(block1.content) !== JSON.stringify(block2.content) - ); -} - -function determineChangeSource(transaction: Transaction): BlockChangeSource { - if (transaction.getMeta("paste")) { - return { type: "paste" }; - } - if (transaction.getMeta("uiEvent") === "drop") { - return { type: "drop" }; - } - if (transaction.getMeta("history$")) { - return { - type: transaction.getMeta("history$").redo ? "redo" : "undo", - }; - } - if (transaction.getMeta("y-sync$")) { - if (transaction.getMeta("y-sync$").isUndoRedoOperation) { - return { type: "undo-redo" }; - } - return { type: "yjs-remote" }; - } - return { type: "local" }; -} - -function collectAllBlocks< - BSchema extends BlockSchema, - ISchema extends InlineContentSchema, - SSchema extends StyleSchema, ->( - doc: Node, -): Record< - string, - { - block: Block; - parentId: string | undefined; - } -> { - const blocks: Record< - string, - { - block: Block; - parentId: string | undefined; - } - > = {}; - const pmSchema = getPmSchema(doc); - doc.descendants((node, pos) => { - if (isNodeBlock(node)) { - const parentId = getParentBlockId(doc, pos); - blocks[node.attrs.id] = { - block: nodeToBlock(node, pmSchema), - parentId, - }; - } - return true; - }); - return blocks; -} - -/** - * Get the blocks that were changed by a transaction. - */ -export function getBlocksChangedByTransaction< - BSchema extends BlockSchema = DefaultBlockSchema, - ISchema extends InlineContentSchema = DefaultInlineContentSchema, - SSchema extends StyleSchema = DefaultStyleSchema, ->( - transaction: Transaction, - appendedTransactions: Transaction[] = [], -): BlocksChanged { - const source = determineChangeSource(transaction); - const combinedTransaction = combineTransactionSteps(transaction.before, [ - transaction, - ...appendedTransactions, - ]); - - const prevBlocks = collectAllBlocks( - combinedTransaction.before, - ); - const nextBlocks = collectAllBlocks( - combinedTransaction.doc, - ); - - const changes: BlocksChanged = []; - - // Handle inserted blocks - Object.keys(nextBlocks) - .filter((id) => !(id in prevBlocks)) - .forEach((id) => { - changes.push({ - type: "insert", - block: nextBlocks[id].block, - source, - prevBlock: undefined, - }); - }); - - // Handle deleted blocks - Object.keys(prevBlocks) - .filter((id) => !(id in nextBlocks)) - .forEach((id) => { - changes.push({ - type: "delete", - block: prevBlocks[id].block, - source, - prevBlock: undefined, - }); - }); - - // Handle updated, moved, indented, outdented blocks - Object.keys(nextBlocks) - .filter((id) => id in prevBlocks) - .forEach((id) => { - const prev = prevBlocks[id]; - const next = nextBlocks[id]; - const isParentDifferent = prev.parentId !== next.parentId; - - if (isParentDifferent) { - changes.push({ - type: "move", - block: next.block, - prevBlock: prev.block, - source, - prevParent: prev.parentId - ? prevBlocks[prev.parentId]?.block - : undefined, - currentParent: next.parentId - ? nextBlocks[next.parentId]?.block - : undefined, - }); - } else if (areBlocksDifferentExcludingChildren(prev.block, next.block)) { - changes.push({ - type: "update", - block: next.block, - prevBlock: prev.block, - source, - }); - } - }); - - return changes; -} diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index ecdabb4272..43c640794b 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -110,7 +110,7 @@ import { docToBlocks } from "../api/nodeConversions/nodeToBlock.js"; import { BlocksChanged, getBlocksChangedByTransaction, -} from "../api/nodeUtil.js"; +} from "../api/getBlocksChangedByTransaction.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"; diff --git a/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts b/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts index 51fe89b0f6..5937afe1cb 100644 --- a/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts +++ b/packages/core/src/extensions/BlockChange/BlockChangePlugin.ts @@ -1,7 +1,9 @@ import { Plugin, Transaction } from "prosemirror-state"; -import { getBlocksChangedByTransaction } from "../../api/nodeUtil.js"; +import { + BlocksChanged, + getBlocksChangedByTransaction, +} from "../../api/getBlocksChangedByTransaction.js"; import { BlockNoteExtension } from "../../editor/BlockNoteExtension.js"; -import { BlocksChanged } from "../../index.js"; /** * This plugin can filter transactions before they are applied to the editor, but with a higher-level API than `filterTransaction` from prosemirror. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 74f91bee5c..fa6172c017 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export * from "./api/blockManipulation/commands/updateBlock/updateBlock.js"; export * from "./api/exporters/html/externalHTMLExporter.js"; export * from "./api/exporters/html/internalHTMLSerializer.js"; export * from "./api/getBlockInfoFromPos.js"; +export * from "./api/getBlocksChangedByTransaction.js"; export * from "./api/nodeUtil.js"; export * from "./api/pmUtil.js"; export * from "./blocks/AudioBlockContent/AudioBlockContent.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f47b0c3168..f82e79d0b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3602,6 +3602,9 @@ importers: emoji-mart: specifier: ^5.6.0 version: 5.6.0 + fast-deep-equal: + specifier: ^3 + version: 3.1.3 hast-util-from-dom: specifier: ^5.0.1 version: 5.0.1