From 8f8844100fb900fba1e61cb4317805f474204b92 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Fri, 4 Apr 2025 14:41:52 +0200 Subject: [PATCH 01/12] feat: `change` event allows getting a list of the changes made --- packages/core/src/api/nodeUtil.ts | 208 +++++++++++++++++++- packages/core/src/editor/BlockNoteEditor.ts | 17 +- packages/react/src/editor/BlockNoteView.tsx | 4 +- packages/react/src/hooks/useEditorChange.ts | 2 +- 4 files changed, 225 insertions(+), 6 deletions(-) diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 26a860f41b..e74ea531f6 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 previous 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 ff173c3468..7b5583713b 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -105,6 +105,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"; @@ -1457,15 +1461,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 4737afd4b2..45c4b73987 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 4df7457208..2d13bb0622 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(); From 3a613693eb89730953b8c4fe039d7dc21ee43784 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 7 Apr 2025 10:53:05 +0200 Subject: [PATCH 02/12] test: add tests for the change handler --- packages/core/src/api/nodeUtil.test.ts | 140 +++++++++++++++++++++++++ packages/core/src/api/nodeUtil.ts | 2 +- 2 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/api/nodeUtil.test.ts diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts new file mode 100644 index 0000000000..a3a49f1800 --- /dev/null +++ b/packages/core/src/api/nodeUtil.test.ts @@ -0,0 +1,140 @@ +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"; + +const getEditor = setupTestEnv(); + +describe("Test getBlocksChangedByTransaction", () => { + let transaction: Transaction; + let editor: BlockNoteEditor; + let originalDispatch: typeof editor.dispatch; + + beforeEach(() => { + editor = getEditor(); + originalDispatch = editor.dispatch; + const mockDispatch = vi.fn((tr) => { + 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", + }, + ]); + }); +}); diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index e74ea531f6..03efb9ce77 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -212,7 +212,7 @@ export function getBlocksChangedByTransaction< }); }); - // All blocks that are newly removed (since they did not exist in the previous state) + // 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) ); From 47ceeec3993601eefdbc53fe6ad437787d7bb5d0 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Mon, 7 Apr 2025 11:17:29 +0200 Subject: [PATCH 03/12] test: add even more tests --- packages/core/src/api/nodeUtil.test.ts | 223 ++++++++++++++++++++++++- 1 file changed, 222 insertions(+), 1 deletion(-) diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts index a3a49f1800..7bf75eecc1 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/nodeUtil.test.ts @@ -4,6 +4,7 @@ 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(); @@ -13,10 +14,18 @@ describe("Test getBlocksChangedByTransaction", () => { let originalDispatch: typeof editor.dispatch; beforeEach(() => { + transaction = undefined as unknown as Transaction; editor = getEditor(); originalDispatch = editor.dispatch; const mockDispatch = vi.fn((tr) => { - transaction = tr; + editor._tiptapEditor.dispatch(tr); + if (transaction) { + tr.steps.forEach((step: Step) => { + transaction.step(step); + }); + } else { + transaction = tr; + } }); editor.dispatch = mockDispatch; }); @@ -137,4 +146,216 @@ describe("Test getBlocksChangedByTransaction", () => { }, ]); }); + + 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", + }, + ]); + }); }); From a38bcaf9ed91e858f5b9312812512785386bc080 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 8 Apr 2025 11:56:17 +0200 Subject: [PATCH 04/12] test: add tests for nested blocks --- packages/core/src/api/nodeUtil.test.ts | 426 +++++++++++++++++++++++++ 1 file changed, 426 insertions(+) diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts index 7bf75eecc1..8201b97b38 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/nodeUtil.test.ts @@ -65,6 +65,80 @@ describe("Test getBlocksChangedByTransaction", () => { ]); }); + it("should return nested blocks inserted by a transaction", () => { + editor.insertBlocks( + [ + { + type: "paragraph", + children: [{ type: "paragraph", content: "Nested" }], + }, + ], + "paragraph-0", + "after" + ); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [ + { + children: [], + content: [ + { + styles: {}, + text: "Nested", + type: "text", + }, + ], + id: "1", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + id: "0", + content: [], + }, + prevBlock: undefined, + source: { type: "local" }, + type: "insert", + }, + { + block: { + children: [], + content: [ + { + styles: {}, + text: "Nested", + type: "text", + }, + ], + id: "1", + 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"]); @@ -96,6 +170,157 @@ describe("Test getBlocksChangedByTransaction", () => { ]); }); + it("should return nested blocks deleted by a transaction", () => { + editor.removeBlocks(["nested-paragraph-0"]); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [ + { + children: [], + content: [ + { + styles: {}, + text: "Double Nested Paragraph 0", + type: "text", + }, + ], + id: "double-nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Nested Paragraph 0", + type: "text", + }, + ], + id: "nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: undefined, + source: { + type: "local", + }, + type: "delete", + }, + { + block: { + children: [], + content: [ + { + styles: {}, + text: "Double Nested Paragraph 0", + type: "text", + }, + ], + id: "double-nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: undefined, + source: { + type: "local", + }, + type: "delete", + }, + { + block: { + children: [], + content: [ + { + styles: {}, + text: "Paragraph with children", + type: "text", + }, + ], + id: "paragraph-with-children", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: { + children: [ + { + children: [ + { + children: [], + content: [ + { + styles: {}, + text: "Double Nested Paragraph 0", + type: "text", + }, + ], + id: "double-nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Nested Paragraph 0", + type: "text", + }, + ], + id: "nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Paragraph with children", + type: "text", + }, + ], + id: "paragraph-with-children", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + source: { + type: "local", + }, + type: "update", + }, + ]); + }); + it("should return blocks updated by a transaction", () => { editor.updateBlock("paragraph-0", { props: { @@ -147,6 +372,207 @@ describe("Test getBlocksChangedByTransaction", () => { ]); }); + it("should return nested blocks updated by a transaction", () => { + editor.updateBlock("nested-paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + expect(blocksChanged).toEqual([ + { + block: { + children: [ + { + children: [ + { + children: [], + content: [ + { + styles: {}, + text: "Double Nested Paragraph 0", + type: "text", + }, + ], + id: "double-nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Nested Paragraph 0", + type: "text", + }, + ], + id: "nested-paragraph-0", + props: { + backgroundColor: "red", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Paragraph with children", + type: "text", + }, + ], + id: "paragraph-with-children", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: { + children: [ + { + children: [ + { + children: [], + content: [ + { + styles: {}, + text: "Double Nested Paragraph 0", + type: "text", + }, + ], + id: "double-nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Nested Paragraph 0", + type: "text", + }, + ], + id: "nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Paragraph with children", + type: "text", + }, + ], + id: "paragraph-with-children", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + source: { + type: "local", + }, + type: "update", + }, + { + block: { + children: [ + { + children: [], + content: [ + { + styles: {}, + text: "Double Nested Paragraph 0", + type: "text", + }, + ], + id: "double-nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Nested Paragraph 0", + type: "text", + }, + ], + id: "nested-paragraph-0", + props: { + backgroundColor: "red", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + prevBlock: { + children: [ + { + children: [], + content: [ + { + styles: {}, + text: "Double Nested Paragraph 0", + type: "text", + }, + ], + id: "double-nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + ], + content: [ + { + styles: {}, + text: "Nested Paragraph 0", + type: "text", + }, + ], + id: "nested-paragraph-0", + props: { + backgroundColor: "default", + textAlignment: "left", + textColor: "default", + }, + type: "paragraph", + }, + 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: { From 69f09f07a0f1bc4a175f5e6c194d1d522759efdf Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Tue, 8 Apr 2025 13:30:07 +0200 Subject: [PATCH 05/12] feat: add isChangeOrigin and isUndoRedoOperation to yjs-remote event --- examples/07-collaboration/01-partykit/App.tsx | 9 ++++++++- packages/core/src/api/nodeUtil.ts | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/examples/07-collaboration/01-partykit/App.tsx b/examples/07-collaboration/01-partykit/App.tsx index cecfb6767e..e12dc75629 100644 --- a/examples/07-collaboration/01-partykit/App.tsx +++ b/examples/07-collaboration/01-partykit/App.tsx @@ -30,5 +30,12 @@ export default function App() { }); // Renders the editor instance. - return ; + return ( + { + console.log(getChanges()); + }} + /> + ); } diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 03efb9ce77..da57efb539 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -87,7 +87,15 @@ export type BlockChangeSource = /** * When an event is triggered by a remote user, the source is "remote". */ - type: "remote"; + type: "yjs-remote"; + /** + * Whether the change is from this client or another client. + */ + isChangeOrigin: boolean; + /** + * Whether the change is an undo or redo operation. + */ + isUndoRedoOperation: boolean; }; export type BlocksChanged< @@ -147,7 +155,11 @@ export function getBlocksChangedByTransaction< type: transaction.getMeta("history$").redo ? "redo" : "undo", }; } else if (transaction.getMeta("y-sync$")) { - source = { type: "remote" }; + source = { + type: "yjs-remote", + isChangeOrigin: transaction.getMeta("y-sync$").isChangeOrigin, + isUndoRedoOperation: transaction.getMeta("y-sync$").isUndoRedoOperation, + }; } const changes: BlocksChanged = []; From 0a439787dc4f98132c7541a19b4163a87084bfb9 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 9 Apr 2025 15:48:14 +0200 Subject: [PATCH 06/12] test: update tests to use snapshots --- examples/07-collaboration/01-partykit/App.tsx | 9 +- .../core/src/api/blocks-deleted-nested.json | 144 ++++ packages/core/src/api/blocks-deleted.json | 26 + .../core/src/api/blocks-inserted-nested.json | 62 ++ packages/core/src/api/blocks-inserted.json | 20 + .../api/blocks-updated-multiple-insert.json | 50 ++ .../core/src/api/blocks-updated-multiple.json | 82 +++ .../core/src/api/blocks-updated-nested.json | 190 +++++ .../core/src/api/blocks-updated-single.json | 42 ++ packages/core/src/api/blocks-updated.json | 42 ++ packages/core/src/api/nodeUtil.test.ts | 677 +----------------- 11 files changed, 689 insertions(+), 655 deletions(-) create mode 100644 packages/core/src/api/blocks-deleted-nested.json create mode 100644 packages/core/src/api/blocks-deleted.json create mode 100644 packages/core/src/api/blocks-inserted-nested.json create mode 100644 packages/core/src/api/blocks-inserted.json create mode 100644 packages/core/src/api/blocks-updated-multiple-insert.json create mode 100644 packages/core/src/api/blocks-updated-multiple.json create mode 100644 packages/core/src/api/blocks-updated-nested.json create mode 100644 packages/core/src/api/blocks-updated-single.json create mode 100644 packages/core/src/api/blocks-updated.json diff --git a/examples/07-collaboration/01-partykit/App.tsx b/examples/07-collaboration/01-partykit/App.tsx index e12dc75629..cecfb6767e 100644 --- a/examples/07-collaboration/01-partykit/App.tsx +++ b/examples/07-collaboration/01-partykit/App.tsx @@ -30,12 +30,5 @@ export default function App() { }); // Renders the editor instance. - return ( - { - console.log(getChanges()); - }} - /> - ); + return ; } diff --git a/packages/core/src/api/blocks-deleted-nested.json b/packages/core/src/api/blocks-deleted-nested.json new file mode 100644 index 0000000000..15bbf2028c --- /dev/null +++ b/packages/core/src/api/blocks-deleted-nested.json @@ -0,0 +1,144 @@ +[ + { + "block": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": undefined, + "source": { + "type": "local", + }, + "type": "delete", + }, + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": undefined, + "source": { + "type": "local", + }, + "type": "delete", + }, + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph with children", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Paragraph with children", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-deleted.json b/packages/core/src/api/blocks-deleted.json new file mode 100644 index 0000000000..8f8bb1f537 --- /dev/null +++ b/packages/core/src/api/blocks-deleted.json @@ -0,0 +1,26 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": undefined, + "source": { + "type": "local", + }, + "type": "delete", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-inserted-nested.json b/packages/core/src/api/blocks-inserted-nested.json new file mode 100644 index 0000000000..ba74c2faf8 --- /dev/null +++ b/packages/core/src/api/blocks-inserted-nested.json @@ -0,0 +1,62 @@ +[ + { + "block": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Nested", + "type": "text", + }, + ], + "id": "1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [], + "id": "0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": undefined, + "source": { + "type": "local", + }, + "type": "insert", + }, + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Nested", + "type": "text", + }, + ], + "id": "1", + "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/blocks-inserted.json b/packages/core/src/api/blocks-inserted.json new file mode 100644 index 0000000000..02c4d9bd94 --- /dev/null +++ b/packages/core/src/api/blocks-inserted.json @@ -0,0 +1,20 @@ +[ + { + "block": { + "children": [], + "content": [], + "id": "0", + "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/blocks-updated-multiple-insert.json b/packages/core/src/api/blocks-updated-multiple-insert.json new file mode 100644 index 0000000000..6fd7f9fcf5 --- /dev/null +++ b/packages/core/src/api/blocks-updated-multiple-insert.json @@ -0,0 +1,50 @@ +[ + { + "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", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-updated-multiple.json b/packages/core/src/api/blocks-updated-multiple.json new file mode 100644 index 0000000000..621a233bea --- /dev/null +++ b/packages/core/src/api/blocks-updated-multiple.json @@ -0,0 +1,82 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "red", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 1", + "type": "text", + }, + ], + "id": "paragraph-1", + "props": { + "backgroundColor": "blue", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 1", + "type": "text", + }, + ], + "id": "paragraph-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-updated-nested.json b/packages/core/src/api/blocks-updated-nested.json new file mode 100644 index 0000000000..be2d6eb7a5 --- /dev/null +++ b/packages/core/src/api/blocks-updated-nested.json @@ -0,0 +1,190 @@ +[ + { + "block": { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "red", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Paragraph with children", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Paragraph with children", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, + { + "block": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "red", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-updated-single.json b/packages/core/src/api/blocks-updated-single.json new file mode 100644 index 0000000000..9b02bfb755 --- /dev/null +++ b/packages/core/src/api/blocks-updated-single.json @@ -0,0 +1,42 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "blue", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-updated.json b/packages/core/src/api/blocks-updated.json new file mode 100644 index 0000000000..fb042475aa --- /dev/null +++ b/packages/core/src/api/blocks-updated.json @@ -0,0 +1,42 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "red", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 0", + "type": "text", + }, + ], + "id": "paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts index 8201b97b38..7e642ec15e 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/nodeUtil.test.ts @@ -40,32 +40,15 @@ describe("Test getBlocksChangedByTransaction", () => { expect(blocksChanged).toEqual([]); }); - it("should return blocks inserted by a transaction", () => { + it("should return blocks inserted by a transaction", async () => { 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", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot("blocks-inserted.json"); }); - it("should return nested blocks inserted by a transaction", () => { + it("should return nested blocks inserted by a transaction", async () => { editor.insertBlocks( [ { @@ -79,249 +62,30 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); - expect(blocksChanged).toEqual([ - { - block: { - children: [ - { - children: [], - content: [ - { - styles: {}, - text: "Nested", - type: "text", - }, - ], - id: "1", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - id: "0", - content: [], - }, - prevBlock: undefined, - source: { type: "local" }, - type: "insert", - }, - { - block: { - children: [], - content: [ - { - styles: {}, - text: "Nested", - type: "text", - }, - ], - id: "1", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - prevBlock: undefined, - source: { type: "local" }, - type: "insert", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-inserted-nested.json" + ); }); - it("should return blocks deleted by a transaction", () => { + it("should return blocks deleted by a transaction", async () => { 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", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot("blocks-deleted.json"); }); - it("should return nested blocks deleted by a transaction", () => { + it("should return nested blocks deleted by a transaction", async () => { editor.removeBlocks(["nested-paragraph-0"]); const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); - expect(blocksChanged).toEqual([ - { - block: { - children: [ - { - children: [], - content: [ - { - styles: {}, - text: "Double Nested Paragraph 0", - type: "text", - }, - ], - id: "double-nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Nested Paragraph 0", - type: "text", - }, - ], - id: "nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - prevBlock: undefined, - source: { - type: "local", - }, - type: "delete", - }, - { - block: { - children: [], - content: [ - { - styles: {}, - text: "Double Nested Paragraph 0", - type: "text", - }, - ], - id: "double-nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - prevBlock: undefined, - source: { - type: "local", - }, - type: "delete", - }, - { - block: { - children: [], - content: [ - { - styles: {}, - text: "Paragraph with children", - type: "text", - }, - ], - id: "paragraph-with-children", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - prevBlock: { - children: [ - { - children: [ - { - children: [], - content: [ - { - styles: {}, - text: "Double Nested Paragraph 0", - type: "text", - }, - ], - id: "double-nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Nested Paragraph 0", - type: "text", - }, - ], - id: "nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Paragraph with children", - type: "text", - }, - ], - id: "paragraph-with-children", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - source: { - type: "local", - }, - type: "update", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-deleted-nested.json" + ); }); - it("should return blocks updated by a transaction", () => { + it("should return blocks updated by a transaction", async () => { editor.updateBlock("paragraph-0", { props: { backgroundColor: "red", @@ -330,49 +94,10 @@ describe("Test getBlocksChangedByTransaction", () => { 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", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot("blocks-updated.json"); }); - it("should return nested blocks updated by a transaction", () => { + it("should return nested blocks updated by a transaction", async () => { editor.updateBlock("nested-paragraph-0", { props: { backgroundColor: "red", @@ -381,199 +106,12 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); - expect(blocksChanged).toEqual([ - { - block: { - children: [ - { - children: [ - { - children: [], - content: [ - { - styles: {}, - text: "Double Nested Paragraph 0", - type: "text", - }, - ], - id: "double-nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Nested Paragraph 0", - type: "text", - }, - ], - id: "nested-paragraph-0", - props: { - backgroundColor: "red", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Paragraph with children", - type: "text", - }, - ], - id: "paragraph-with-children", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - prevBlock: { - children: [ - { - children: [ - { - children: [], - content: [ - { - styles: {}, - text: "Double Nested Paragraph 0", - type: "text", - }, - ], - id: "double-nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Nested Paragraph 0", - type: "text", - }, - ], - id: "nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Paragraph with children", - type: "text", - }, - ], - id: "paragraph-with-children", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - source: { - type: "local", - }, - type: "update", - }, - { - block: { - children: [ - { - children: [], - content: [ - { - styles: {}, - text: "Double Nested Paragraph 0", - type: "text", - }, - ], - id: "double-nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Nested Paragraph 0", - type: "text", - }, - ], - id: "nested-paragraph-0", - props: { - backgroundColor: "red", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - prevBlock: { - children: [ - { - children: [], - content: [ - { - styles: {}, - text: "Double Nested Paragraph 0", - type: "text", - }, - ], - id: "double-nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - ], - content: [ - { - styles: {}, - text: "Nested Paragraph 0", - type: "text", - }, - ], - id: "nested-paragraph-0", - props: { - backgroundColor: "default", - textAlignment: "left", - textColor: "default", - }, - type: "paragraph", - }, - source: { - type: "local", - }, - type: "update", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-updated-nested.json" + ); }); - it("should only return a single block, if multiple updates change a single block in a transaction", () => { + it("should only return a single block, if multiple updates change a single block in a transaction", async () => { editor.updateBlock("paragraph-0", { props: { backgroundColor: "red", @@ -587,49 +125,12 @@ describe("Test getBlocksChangedByTransaction", () => { 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", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-updated-single.json" + ); }); - it("should return multiple blocks, if multiple updates change multiple blocks in a transaction", () => { + it("should return multiple blocks, if multiple updates change multiple blocks in a transaction", async () => { editor.updateBlock("paragraph-0", { props: { backgroundColor: "red", @@ -643,87 +144,12 @@ describe("Test getBlocksChangedByTransaction", () => { 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", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-updated-multiple.json" + ); }); - it("should return multiple blocks, if multiple inserts add new blocks in a transaction", () => { + it("should return multiple blocks, if multiple inserts add new blocks in a transaction", async () => { editor.insertBlocks( [{ type: "paragraph", content: "ABC" }], "paragraph-0", @@ -737,51 +163,8 @@ describe("Test getBlocksChangedByTransaction", () => { 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", - }, - ]); + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-updated-multiple-insert.json" + ); }); }); From 07704645234b678cc8dcec666e1e244c59b3a0ad Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 10 Apr 2025 15:58:13 +0200 Subject: [PATCH 07/12] feat: implement the `dispatchTransaction` method from Tiptap to provide a new `v3-update` method --- packages/core/src/api/nodeUtil.ts | 7 +- packages/core/src/editor/BlockNoteEditor.ts | 19 +++- .../core/src/editor/BlockNoteTipTapEditor.ts | 99 +++++++++++++++++-- 3 files changed, 109 insertions(+), 16 deletions(-) diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index da57efb539..e4d36f43ff 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -142,7 +142,8 @@ export function getBlocksChangedByTransaction< SSchema extends StyleSchema = DefaultStyleSchema >( transaction: Transaction, - editor: BlockNoteEditor + editor: BlockNoteEditor, + appendedTransactions: Transaction[] = [] ): BlocksChanged { let source: BlockChangeSource = { type: "local" }; @@ -163,11 +164,9 @@ export function getBlocksChangedByTransaction< } 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*/, + ...appendedTransactions, ]); let prevAffectedBlocks: Block[] = []; diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 7b5583713b..84843b1f9d 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1473,16 +1473,27 @@ export class BlockNoteEditor< return; } - const cb = ({ transaction }: { transaction: Transaction }) => { + const cb = ({ + transaction, + appendedTransactions, + }: { + transaction: Transaction; + appendedTransactions: Transaction[]; + }) => { callback(this, { - getChanges: () => getBlocksChangedByTransaction(transaction, this), + getChanges: () => + getBlocksChangedByTransaction( + transaction, + this, + appendedTransactions + ), }); }; - this._tiptapEditor.on("update", cb); + this._tiptapEditor.on("v3-update", cb); return () => { - this._tiptapEditor.off("update", cb); + this._tiptapEditor.off("v3-update", cb); }; } diff --git a/packages/core/src/editor/BlockNoteTipTapEditor.ts b/packages/core/src/editor/BlockNoteTipTapEditor.ts index 3d1eb1e284..66ee5055ef 100644 --- a/packages/core/src/editor/BlockNoteTipTapEditor.ts +++ b/packages/core/src/editor/BlockNoteTipTapEditor.ts @@ -1,5 +1,4 @@ -import { EditorOptions, createDocument } from "@tiptap/core"; -// import "./blocknote.css"; +import { Editor, EditorOptions, createDocument } from "@tiptap/core"; import { Editor as TiptapEditor } from "@tiptap/core"; import { Node } from "@tiptap/pm/model"; @@ -138,13 +137,83 @@ export class BlockNoteTipTapEditor extends TiptapEditor { return this._state; } - dispatch(tr: Transaction) { - if (this.view) { - this.view.dispatch(tr); - } else { + dispatch(transaction: Transaction) { + if (!this.view) { // before view has been initialized - this._state = this.state.apply(tr); + this._state = this.state.apply(transaction); + return; + } + // This is a verbatim copy of the default dispatch method, but with the following changes: + // - We provide the appendedTransactions to a new `v3-update` event + // In the future, we can remove this dispatch method entirely and rely on the new `update` event signature which does what we want by providing the appendedTransactions + //////////////////////////////////////////////////////////////////////////////// + // if the editor / the view of the editor was destroyed + // the transaction should not be dispatched as there is no view anymore. + if (this.view.isDestroyed) { + return; + } + + if (this.isCapturingTransaction) { + // Do the default capture behavior + (this as any).dispatchTransaction(transaction); + + return; + } + + const { state, transactions: appendedTransactions } = + this.state.applyTransaction(transaction); + const selectionHasChanged = !this.state.selection.eq(state.selection); + + this.emit("beforeTransaction", { + editor: this, + transaction, + nextState: state, + }); + this.view.updateState(state); + this.emit("transaction", { + editor: this, + transaction, + }); + + if (selectionHasChanged) { + this.emit("selectionUpdate", { + editor: this, + transaction, + }); + } + + const focus = transaction.getMeta("focus"); + const blur = transaction.getMeta("blur"); + + if (focus) { + this.emit("focus", { + editor: this, + event: focus.event, + transaction, + }); + } + + if (blur) { + this.emit("blur", { + editor: this, + event: blur.event, + transaction, + }); } + + if (!transaction.docChanged || transaction.getMeta("preventUpdate")) { + return; + } + + this.emit("update", { + editor: this, + transaction, + }); + this.emit("v3-update", { + editor: this, + transaction, + appendedTransactions: appendedTransactions.slice(1), + }); } /** @@ -171,7 +240,7 @@ export class BlockNoteTipTapEditor extends TiptapEditor { { ...this.options.editorProps, // @ts-ignore - dispatchTransaction: this.dispatchTransaction.bind(this), + dispatchTransaction: this.dispatch.bind(this), state: this.state, markViews, nodeViews: this.extensionManager.nodeViews, @@ -228,3 +297,17 @@ export class BlockNoteTipTapEditor extends TiptapEditor { // (note: can probably be removed after tiptap upgrade fixed in 2.8.0) this.options.onPaste = this.options.onDrop = undefined; }; + +declare module "@tiptap/core" { + interface EditorEvents { + /** + * This is a custom event that will be emitted in Tiptap V3. + * We use it to provide the appendedTransactions, until Tiptap V3 is released. + */ + "v3-update": { + editor: Editor; + transaction: Transaction; + appendedTransactions: Transaction[]; + }; + } +} From 079baf4cf209f50b3fec1c3a2c593abd5c12ad20 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 10 Apr 2025 17:52:11 +0200 Subject: [PATCH 08/12] fix: filter out parent blocks from child changes --- .../src/api/blocks-updated-nested-deep.json | 42 +++++++ .../api/blocks-updated-nested-multiple.json | 118 ++++++++++++++++++ .../core/src/api/blocks-updated-nested.json | 112 ----------------- packages/core/src/api/nodeUtil.test.ts | 29 +++++ packages/core/src/api/nodeUtil.ts | 109 ++++++++++++---- 5 files changed, 277 insertions(+), 133 deletions(-) create mode 100644 packages/core/src/api/blocks-updated-nested-deep.json create mode 100644 packages/core/src/api/blocks-updated-nested-multiple.json diff --git a/packages/core/src/api/blocks-updated-nested-deep.json b/packages/core/src/api/blocks-updated-nested-deep.json new file mode 100644 index 0000000000..ee0020ed68 --- /dev/null +++ b/packages/core/src/api/blocks-updated-nested-deep.json @@ -0,0 +1,42 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Example Text", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-updated-nested-multiple.json b/packages/core/src/api/blocks-updated-nested-multiple.json new file mode 100644 index 0000000000..9b26b44d8e --- /dev/null +++ b/packages/core/src/api/blocks-updated-nested-multiple.json @@ -0,0 +1,118 @@ +[ + { + "block": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Example Text", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "red", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Nested Paragraph 0", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Example Text", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blocks-updated-nested.json b/packages/core/src/api/blocks-updated-nested.json index be2d6eb7a5..ba2627ab0f 100644 --- a/packages/core/src/api/blocks-updated-nested.json +++ b/packages/core/src/api/blocks-updated-nested.json @@ -1,116 +1,4 @@ [ - { - "block": { - "children": [ - { - "children": [ - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Double Nested Paragraph 0", - "type": "text", - }, - ], - "id": "double-nested-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "content": [ - { - "styles": {}, - "text": "Nested Paragraph 0", - "type": "text", - }, - ], - "id": "nested-paragraph-0", - "props": { - "backgroundColor": "red", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "content": [ - { - "styles": {}, - "text": "Paragraph with children", - "type": "text", - }, - ], - "id": "paragraph-with-children", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - "prevBlock": { - "children": [ - { - "children": [ - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Double Nested Paragraph 0", - "type": "text", - }, - ], - "id": "double-nested-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "content": [ - { - "styles": {}, - "text": "Nested Paragraph 0", - "type": "text", - }, - ], - "id": "nested-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "content": [ - { - "styles": {}, - "text": "Paragraph with children", - "type": "text", - }, - ], - "id": "paragraph-with-children", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - "source": { - "type": "local", - }, - "type": "update", - }, { "block": { "children": [ diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts index 7e642ec15e..60085f865e 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/nodeUtil.test.ts @@ -111,6 +111,35 @@ describe("Test getBlocksChangedByTransaction", () => { ); }); + it("should return deeply nested blocks updated by a transaction", async () => { + editor.updateBlock("double-nested-paragraph-0", { + content: "Example Text", + }); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-updated-nested-deep.json" + ); + }); + + it("should return multiple nested blocks updated by a transaction", async () => { + editor.updateBlock("nested-paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("double-nested-paragraph-0", { + content: "Example Text", + }); + + const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + + await expect(blocksChanged).toMatchFileSnapshot( + "blocks-updated-nested-multiple.json" + ); + }); + it("should only return a single block, if multiple updates change a single block in a transaction", async () => { editor.updateBlock("paragraph-0", { props: { diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index e4d36f43ff..89ac5cd855 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -1,9 +1,9 @@ import { combineTransactionSteps, - findChildrenInRange, getChangedRanges, + findChildrenInRange, } from "@tiptap/core"; -import type { Node } from "prosemirror-model"; +import type { Node, ResolvedPos } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; import { Block, @@ -34,7 +34,7 @@ export function getNodeById( } // Keeps traversing nodes if block with target ID has not been found. - if (!node.type.isInGroup("bnBlock") || node.attrs.id !== id) { + if (!isNodeBlock(node) || node.attrs.id !== id) { return true; } @@ -54,6 +54,10 @@ export function getNodeById( }; } +export function isNodeBlock(node: Node): boolean { + return node.type.isInGroup("bnBlock"); +} + /** * This attributes the changes to a specific source. */ @@ -130,6 +134,23 @@ export type BlocksChanged< ) >; +/** + * Get the closest block to a resolved position. + */ +function getClosestBlock(resolve: ResolvedPos): Node | undefined { + let depth = resolve.depth; + let node = resolve.node(); + // Recursively traverse up the node tree until a block is found + while (!isNodeBlock(node)) { + if (depth === 0) { + return undefined; + } + depth--; + node = resolve.node(depth); + } + return node; +} + /** * Get the blocks that were changed by a transaction. * @param transaction The transaction to get the changes from. @@ -173,35 +194,81 @@ export function getBlocksChangedByTransaction< let nextAffectedBlocks: Block[] = []; getChangedRanges(combinedTransaction).forEach((range) => { + const oldClosestBlocks = { + from: getClosestBlock( + combinedTransaction.before.resolve(range.oldRange.from) + ), + to: getClosestBlock( + combinedTransaction.before.resolve(range.oldRange.to) + ), + }; + // 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 - ) + (node) => isNodeBlock(node) ) + .filter(({ node, pos }) => { + // This will filter out blocks which are modified, but not the closest block to the change + // This is to prevent emitting events for parent blocks when the child block is modified + if ( + // If the block is out of the changed range + (pos < range.oldRange.from || pos > range.oldRange.to) && + // and, not the closest to the start or end of the changed range + (oldClosestBlocks.from !== node || oldClosestBlocks.to !== node) + ) { + // Then it should be skipped + return false; + } + return true; + }) + .map(({ node }) => + nodeToBlock( + node, + editor.schema.blockSchema, + editor.schema.inlineContentSchema, + editor.schema.styleSchema, + editor.blockCache + ) + ) ); + + const newClosestBlocks = { + from: getClosestBlock( + combinedTransaction.doc.resolve(range.newRange.from) + ), + to: getClosestBlock(combinedTransaction.doc.resolve(range.newRange.to)), + }; // 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 - ) + isNodeBlock(node) ) + .filter(({ node, pos }) => { + // This will filter out blocks which are modified, but not the closest block to the change + // This is to prevent emitting events for parent blocks when the child block is modified + if ( + // If the block is out of the changed range + (pos < range.newRange.from || pos > range.newRange.to) && + // and, not the closest to the start or end of the changed range + (newClosestBlocks.from !== node || newClosestBlocks.to !== node) + ) { + // Then it should be skipped + return false; + } + return true; + }) + .map(({ node }) => + nodeToBlock( + node, + editor.schema.blockSchema, + editor.schema.inlineContentSchema, + editor.schema.styleSchema, + editor.blockCache + ) + ) ); }); From 9d228770b6628a19e89bd532ae3509ed0e3a6047 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 10 Apr 2025 18:00:17 +0200 Subject: [PATCH 09/12] chore: move to snapshots folder --- .../blocks-deleted-nested.json | 0 .../{ => __snapshots__}/blocks-deleted.json | 0 .../blocks-inserted-nested.json | 0 .../{ => __snapshots__}/blocks-inserted.json | 0 .../blocks-updated-multiple-insert.json | 0 .../blocks-updated-multiple.json | 0 .../blocks-updated-nested-deep.json | 0 .../blocks-updated-nested-multiple.json | 0 .../blocks-updated-nested.json | 0 .../blocks-updated-single.json | 0 .../{ => __snapshots__}/blocks-updated.json | 0 packages/core/src/api/nodeUtil.test.ts | 28 +++++++++++-------- 12 files changed, 17 insertions(+), 11 deletions(-) rename packages/core/src/api/{ => __snapshots__}/blocks-deleted-nested.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-deleted.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-inserted-nested.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-inserted.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-updated-multiple-insert.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-updated-multiple.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-updated-nested-deep.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-updated-nested-multiple.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-updated-nested.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-updated-single.json (100%) rename packages/core/src/api/{ => __snapshots__}/blocks-updated.json (100%) diff --git a/packages/core/src/api/blocks-deleted-nested.json b/packages/core/src/api/__snapshots__/blocks-deleted-nested.json similarity index 100% rename from packages/core/src/api/blocks-deleted-nested.json rename to packages/core/src/api/__snapshots__/blocks-deleted-nested.json diff --git a/packages/core/src/api/blocks-deleted.json b/packages/core/src/api/__snapshots__/blocks-deleted.json similarity index 100% rename from packages/core/src/api/blocks-deleted.json rename to packages/core/src/api/__snapshots__/blocks-deleted.json diff --git a/packages/core/src/api/blocks-inserted-nested.json b/packages/core/src/api/__snapshots__/blocks-inserted-nested.json similarity index 100% rename from packages/core/src/api/blocks-inserted-nested.json rename to packages/core/src/api/__snapshots__/blocks-inserted-nested.json diff --git a/packages/core/src/api/blocks-inserted.json b/packages/core/src/api/__snapshots__/blocks-inserted.json similarity index 100% rename from packages/core/src/api/blocks-inserted.json rename to packages/core/src/api/__snapshots__/blocks-inserted.json diff --git a/packages/core/src/api/blocks-updated-multiple-insert.json b/packages/core/src/api/__snapshots__/blocks-updated-multiple-insert.json similarity index 100% rename from packages/core/src/api/blocks-updated-multiple-insert.json rename to packages/core/src/api/__snapshots__/blocks-updated-multiple-insert.json diff --git a/packages/core/src/api/blocks-updated-multiple.json b/packages/core/src/api/__snapshots__/blocks-updated-multiple.json similarity index 100% rename from packages/core/src/api/blocks-updated-multiple.json rename to packages/core/src/api/__snapshots__/blocks-updated-multiple.json diff --git a/packages/core/src/api/blocks-updated-nested-deep.json b/packages/core/src/api/__snapshots__/blocks-updated-nested-deep.json similarity index 100% rename from packages/core/src/api/blocks-updated-nested-deep.json rename to packages/core/src/api/__snapshots__/blocks-updated-nested-deep.json diff --git a/packages/core/src/api/blocks-updated-nested-multiple.json b/packages/core/src/api/__snapshots__/blocks-updated-nested-multiple.json similarity index 100% rename from packages/core/src/api/blocks-updated-nested-multiple.json rename to packages/core/src/api/__snapshots__/blocks-updated-nested-multiple.json diff --git a/packages/core/src/api/blocks-updated-nested.json b/packages/core/src/api/__snapshots__/blocks-updated-nested.json similarity index 100% rename from packages/core/src/api/blocks-updated-nested.json rename to packages/core/src/api/__snapshots__/blocks-updated-nested.json diff --git a/packages/core/src/api/blocks-updated-single.json b/packages/core/src/api/__snapshots__/blocks-updated-single.json similarity index 100% rename from packages/core/src/api/blocks-updated-single.json rename to packages/core/src/api/__snapshots__/blocks-updated-single.json diff --git a/packages/core/src/api/blocks-updated.json b/packages/core/src/api/__snapshots__/blocks-updated.json similarity index 100% rename from packages/core/src/api/blocks-updated.json rename to packages/core/src/api/__snapshots__/blocks-updated.json diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts index 60085f865e..939f5ca719 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/nodeUtil.test.ts @@ -45,7 +45,9 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); - await expect(blocksChanged).toMatchFileSnapshot("blocks-inserted.json"); + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-inserted.json" + ); }); it("should return nested blocks inserted by a transaction", async () => { @@ -63,7 +65,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-inserted-nested.json" + "__snapshots__/blocks-inserted-nested.json" ); }); @@ -72,7 +74,9 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); - await expect(blocksChanged).toMatchFileSnapshot("blocks-deleted.json"); + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-deleted.json" + ); }); it("should return nested blocks deleted by a transaction", async () => { @@ -81,7 +85,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-deleted-nested.json" + "__snapshots__/blocks-deleted-nested.json" ); }); @@ -94,7 +98,9 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); - await expect(blocksChanged).toMatchFileSnapshot("blocks-updated.json"); + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated.json" + ); }); it("should return nested blocks updated by a transaction", async () => { @@ -107,7 +113,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-updated-nested.json" + "__snapshots__/blocks-updated-nested.json" ); }); @@ -119,7 +125,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-updated-nested-deep.json" + "__snapshots__/blocks-updated-nested-deep.json" ); }); @@ -136,7 +142,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-updated-nested-multiple.json" + "__snapshots__/blocks-updated-nested-multiple.json" ); }); @@ -155,7 +161,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-updated-single.json" + "__snapshots__/blocks-updated-single.json" ); }); @@ -174,7 +180,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-updated-multiple.json" + "__snapshots__/blocks-updated-multiple.json" ); }); @@ -193,7 +199,7 @@ describe("Test getBlocksChangedByTransaction", () => { const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); await expect(blocksChanged).toMatchFileSnapshot( - "blocks-updated-multiple-insert.json" + "__snapshots__/blocks-updated-multiple-insert.json" ); }); }); From 2da36d10659bad45c86dd2caff038967a725bef9 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 16 Apr 2025 16:52:53 +0200 Subject: [PATCH 10/12] feat: filter out parent updates --- examples/01-basic/01-minimal/App.tsx | 9 +- .../blocks-deleted-nested-deep.json | 26 ++ .../__snapshots__/blocks-deleted-nested.json | 76 ------ .../blocks-updated-content-inserted.json | 42 ++++ packages/core/src/api/nodeUtil.test.ts | 229 ++++++++++-------- packages/core/src/api/nodeUtil.ts | 222 +++++++---------- packages/core/src/editor/BlockNoteEditor.ts | 6 +- 7 files changed, 290 insertions(+), 320 deletions(-) create mode 100644 packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json create mode 100644 packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json diff --git a/examples/01-basic/01-minimal/App.tsx b/examples/01-basic/01-minimal/App.tsx index a3b92bafd2..2851f9dfe5 100644 --- a/examples/01-basic/01-minimal/App.tsx +++ b/examples/01-basic/01-minimal/App.tsx @@ -8,5 +8,12 @@ export default function App() { const editor = useCreateBlockNote(); // Renders the editor instance using a React component. - return ; + return ( + { + console.log(getChanges()); + }} + /> + ); } diff --git a/packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json b/packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json new file mode 100644 index 0000000000..7a78484b71 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json @@ -0,0 +1,26 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Double Nested Paragraph 0", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": undefined, + "source": { + "type": "local", + }, + "type": "delete", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-deleted-nested.json b/packages/core/src/api/__snapshots__/blocks-deleted-nested.json index 15bbf2028c..2a9bf12ad5 100644 --- a/packages/core/src/api/__snapshots__/blocks-deleted-nested.json +++ b/packages/core/src/api/__snapshots__/blocks-deleted-nested.json @@ -65,80 +65,4 @@ }, "type": "delete", }, - { - "block": { - "children": [], - "content": [ - { - "styles": {}, - "text": "Paragraph with children", - "type": "text", - }, - ], - "id": "paragraph-with-children", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - "prevBlock": { - "children": [ - { - "children": [ - { - "children": [], - "content": [ - { - "styles": {}, - "text": "Double Nested Paragraph 0", - "type": "text", - }, - ], - "id": "double-nested-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "content": [ - { - "styles": {}, - "text": "Nested Paragraph 0", - "type": "text", - }, - ], - "id": "nested-paragraph-0", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - ], - "content": [ - { - "styles": {}, - "text": "Paragraph with children", - "type": "text", - }, - ], - "id": "paragraph-with-children", - "props": { - "backgroundColor": "default", - "textAlignment": "left", - "textColor": "default", - }, - "type": "paragraph", - }, - "source": { - "type": "local", - }, - "type": "update", - }, ] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json b/packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json new file mode 100644 index 0000000000..8e768c6b85 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json @@ -0,0 +1,42 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "HelloParagraph 2", + "type": "text", + }, + ], + "id": "paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Paragraph 2", + "type": "text", + }, + ], + "id": "paragraph-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "update", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts index 939f5ca719..d7c3486e21 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/nodeUtil.test.ts @@ -1,49 +1,30 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { describe, expect, it, beforeEach } 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); + const blocksChanged = editor.transact((tr) => { + return getBlocksChangedByTransaction(tr); + }); expect(blocksChanged).toEqual([]); }); it("should return blocks inserted by a transaction", async () => { - editor.insertBlocks([{ type: "paragraph" }], "paragraph-0", "after"); - - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + const blocksChanged = editor.transact((tr) => { + editor.insertBlocks([{ type: "paragraph" }], "paragraph-0", "after"); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-inserted.json" @@ -51,18 +32,20 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return nested blocks inserted by a transaction", async () => { - editor.insertBlocks( - [ - { - type: "paragraph", - children: [{ type: "paragraph", content: "Nested" }], - }, - ], - "paragraph-0", - "after" - ); - - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + const blocksChanged = editor.transact((tr) => { + editor.insertBlocks( + [ + { + type: "paragraph", + children: [{ type: "paragraph", content: "Nested" }], + }, + ], + "paragraph-0", + "after" + ); + + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-inserted-nested.json" @@ -70,19 +53,32 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return blocks deleted by a transaction", async () => { - editor.removeBlocks(["paragraph-0"]); - - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + const blocksChanged = editor.transact((tr) => { + editor.removeBlocks(["paragraph-0"]); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-deleted.json" ); }); - it("should return nested blocks deleted by a transaction", async () => { - editor.removeBlocks(["nested-paragraph-0"]); + it("should return deeply nested blocks deleted by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.removeBlocks(["double-nested-paragraph-0"]); + return getBlocksChangedByTransaction(tr); + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-deleted-nested-deep.json" + ); + }); + + it("should return nested blocks deleted by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.removeBlocks(["nested-paragraph-0"]); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-deleted-nested.json" @@ -90,13 +86,15 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return blocks updated by a transaction", async () => { - editor.updateBlock("paragraph-0", { - props: { - backgroundColor: "red", - }, - }); + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-updated.json" @@ -104,13 +102,15 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return nested blocks updated by a transaction", async () => { - editor.updateBlock("nested-paragraph-0", { - props: { - backgroundColor: "red", - }, - }); + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("nested-paragraph-0", { + props: { + backgroundColor: "red", + }, + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-updated-nested.json" @@ -118,11 +118,13 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return deeply nested blocks updated by a transaction", async () => { - editor.updateBlock("double-nested-paragraph-0", { - content: "Example Text", - }); + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("double-nested-paragraph-0", { + content: "Example Text", + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-updated-nested-deep.json" @@ -130,16 +132,18 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return multiple nested blocks updated by a transaction", async () => { - editor.updateBlock("nested-paragraph-0", { - props: { - backgroundColor: "red", - }, - }); - editor.updateBlock("double-nested-paragraph-0", { - content: "Example Text", - }); + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("nested-paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("double-nested-paragraph-0", { + content: "Example Text", + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-updated-nested-multiple.json" @@ -147,18 +151,20 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should only return a single block, if multiple updates change a single block in a transaction", async () => { - editor.updateBlock("paragraph-0", { - props: { - backgroundColor: "red", - }, - }); - editor.updateBlock("paragraph-0", { - props: { - backgroundColor: "blue", - }, - }); + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "blue", + }, + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-updated-single.json" @@ -166,18 +172,20 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return multiple blocks, if multiple updates change multiple blocks in a transaction", async () => { - editor.updateBlock("paragraph-0", { - props: { - backgroundColor: "red", - }, - }); - editor.updateBlock("paragraph-1", { - props: { - backgroundColor: "blue", - }, - }); + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("paragraph-1", { + props: { + backgroundColor: "blue", + }, + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( "__snapshots__/blocks-updated-multiple.json" @@ -185,21 +193,36 @@ describe("Test getBlocksChangedByTransaction", () => { }); it("should return multiple blocks, if multiple inserts add new blocks in a transaction", async () => { - editor.insertBlocks( - [{ type: "paragraph", content: "ABC" }], - "paragraph-0", - "after" - ); - editor.insertBlocks( - [{ type: "paragraph", content: "DEF" }], - "paragraph-1", - "after" + const blocksChanged = editor.transact((tr) => { + editor.insertBlocks( + [{ type: "paragraph", content: "ABC" }], + "paragraph-0", + "after" + ); + editor.insertBlocks( + [{ type: "paragraph", content: "DEF" }], + "paragraph-1", + "after" + ); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated-multiple-insert.json" ); + }); - const blocksChanged = getBlocksChangedByTransaction(transaction!, editor); + it("should return blocks which have had content inserted into them", async () => { + const blocksChanged = editor.transact((tr) => { + editor.setTextCursorPosition("paragraph-2", "start"); + editor.insertInlineContent("Hello"); + + return getBlocksChangedByTransaction(tr); + }); await expect(blocksChanged).toMatchFileSnapshot( - "__snapshots__/blocks-updated-multiple-insert.json" + "__snapshots__/blocks-updated-content-inserted.json" ); }); }); diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 89ac5cd855..f0c84c538b 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -1,9 +1,9 @@ import { combineTransactionSteps, - getChangedRanges, findChildrenInRange, + getChangedRanges, } from "@tiptap/core"; -import type { Node, ResolvedPos } from "prosemirror-model"; +import type { Node } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; import { Block, @@ -11,11 +11,11 @@ import { 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"; +import { getPmSchema } from "./pmUtil.js"; /** * Get a TipTap node by id @@ -135,20 +135,25 @@ export type BlocksChanged< >; /** - * Get the closest block to a resolved position. + * Compares two blocks, ignoring their children. + * Returns true if the blocks are different (excluding children). */ -function getClosestBlock(resolve: ResolvedPos): Node | undefined { - let depth = resolve.depth; - let node = resolve.node(); - // Recursively traverse up the node tree until a block is found - while (!isNodeBlock(node)) { - if (depth === 0) { - return undefined; - } - depth--; - node = resolve.node(depth); - } - return node; +function areBlocksDifferentExcludingChildren< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>( + block1: Block, + block2: Block +): boolean { + // TODO use an actual diff algorithm + // Compare all properties except children + 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) + ); } /** @@ -163,7 +168,6 @@ export function getBlocksChangedByTransaction< SSchema extends StyleSchema = DefaultStyleSchema >( transaction: Transaction, - editor: BlockNoteEditor, appendedTransactions: Transaction[] = [] ): BlocksChanged { let source: BlockChangeSource = { type: "local" }; @@ -184,139 +188,87 @@ export function getBlocksChangedByTransaction< }; } - const changes: BlocksChanged = []; + // Get affected blocks before and after the change + const pmSchema = getPmSchema(transaction); const combinedTransaction = combineTransactionSteps(transaction.before, [ transaction, ...appendedTransactions, ]); - let prevAffectedBlocks: Block[] = []; - let nextAffectedBlocks: Block[] = []; - - getChangedRanges(combinedTransaction).forEach((range) => { - const oldClosestBlocks = { - from: getClosestBlock( - combinedTransaction.before.resolve(range.oldRange.from) - ), - to: getClosestBlock( - combinedTransaction.before.resolve(range.oldRange.to) - ), - }; - - // All the blocks that were in the range before the transaction - prevAffectedBlocks = prevAffectedBlocks.concat( - ...findChildrenInRange( + const changedRanges = getChangedRanges(combinedTransaction); + const prevAffectedBlocks = changedRanges + .flatMap((range) => { + return findChildrenInRange( combinedTransaction.before, range.oldRange, - (node) => isNodeBlock(node) - ) - .filter(({ node, pos }) => { - // This will filter out blocks which are modified, but not the closest block to the change - // This is to prevent emitting events for parent blocks when the child block is modified - if ( - // If the block is out of the changed range - (pos < range.oldRange.from || pos > range.oldRange.to) && - // and, not the closest to the start or end of the changed range - (oldClosestBlocks.from !== node || oldClosestBlocks.to !== node) - ) { - // Then it should be skipped - return false; - } - return true; - }) - .map(({ node }) => - nodeToBlock( - node, - editor.schema.blockSchema, - editor.schema.inlineContentSchema, - editor.schema.styleSchema, - editor.blockCache - ) - ) - ); + isNodeBlock + ); + }) + .map(({ node }) => nodeToBlock(node, pmSchema)); - const newClosestBlocks = { - from: getClosestBlock( - combinedTransaction.doc.resolve(range.newRange.from) - ), - to: getClosestBlock(combinedTransaction.doc.resolve(range.newRange.to)), - }; - // All the blocks that were in the range after the transaction - nextAffectedBlocks = nextAffectedBlocks.concat( - findChildrenInRange(combinedTransaction.doc, range.newRange, (node) => - isNodeBlock(node) - ) - .filter(({ node, pos }) => { - // This will filter out blocks which are modified, but not the closest block to the change - // This is to prevent emitting events for parent blocks when the child block is modified - if ( - // If the block is out of the changed range - (pos < range.newRange.from || pos > range.newRange.to) && - // and, not the closest to the start or end of the changed range - (newClosestBlocks.from !== node || newClosestBlocks.to !== node) - ) { - // Then it should be skipped - return false; - } - return true; - }) - .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)); + const nextAffectedBlocks = changedRanges + .flatMap((range) => { + return findChildrenInRange( + combinedTransaction.doc, + range.newRange, + isNodeBlock + ); + }) + .map(({ node }) => nodeToBlock(node, pmSchema)); - // 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) + const nextBlocks = new Map( + nextAffectedBlocks.map((block) => { + return [block.id, block]; + }) + ); + const prevBlocks = new Map( + prevAffectedBlocks.map((block) => { + return [block.id, block]; + }) ); - addedBlockIds.forEach((blockId) => { - changes.push({ - type: "insert", - block: nextAffectedBlocks.find((block) => block.id === blockId)!, - source, - prevBlock: undefined, - }); - }); + const changes: BlocksChanged = []; - // 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) - ); + // Inserted blocks are blocks that were not in the previous state and are in the next state + for (const [id, block] of nextBlocks) { + if (!prevBlocks.has(id)) { + changes.push({ + type: "insert", + block, + source, + prevBlock: undefined, + }); + } + } - removedBlockIds.forEach((blockId) => { - changes.push({ - type: "delete", - block: prevAffectedBlocks.find((block) => block.id === blockId)!, - source, - prevBlock: undefined, - }); - }); + // Deleted blocks are blocks that were in the previous state but not in the next state + for (const [id, block] of prevBlocks) { + if (!nextBlocks.has(id)) { + changes.push({ + type: "delete", + block, + 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) - ); + // Updated blocks are blocks that were in the previous state and are in the next state + for (const [id, block] of nextBlocks) { + if (prevBlocks.has(id)) { + const prevBlock = prevBlocks.get(id)!; - updatedBlockIds.forEach((blockId) => { - changes.push({ - type: "update", - block: nextAffectedBlocks.find((block) => block.id === blockId)!, - prevBlock: prevAffectedBlocks.find((block) => block.id === blockId)!, - source, - }); - }); + // Only include the update if the block itself changed (excluding children) + if (areBlocksDifferentExcludingChildren(prevBlock, block)) { + changes.push({ + type: "update", + block, + prevBlock, + source, + }); + } + } + } return changes; } diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index 84843b1f9d..a7e50abee1 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1482,11 +1482,7 @@ export class BlockNoteEditor< }) => { callback(this, { getChanges: () => - getBlocksChangedByTransaction( - transaction, - this, - appendedTransactions - ), + getBlocksChangedByTransaction(transaction, appendedTransactions), }); }; From 31aa9c5f2a6ad45ba82c17b910261b87461ab86b Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Wed, 16 Apr 2025 17:06:20 +0200 Subject: [PATCH 11/12] chore: cleanup event types --- examples/01-basic/01-minimal/App.tsx | 9 +-------- packages/core/src/api/nodeUtil.ts | 25 +++++++++++-------------- 2 files changed, 12 insertions(+), 22 deletions(-) diff --git a/examples/01-basic/01-minimal/App.tsx b/examples/01-basic/01-minimal/App.tsx index 2851f9dfe5..a3b92bafd2 100644 --- a/examples/01-basic/01-minimal/App.tsx +++ b/examples/01-basic/01-minimal/App.tsx @@ -8,12 +8,5 @@ export default function App() { const editor = useCreateBlockNote(); // Renders the editor instance using a React component. - return ( - { - console.log(getChanges()); - }} - /> - ); + return ; } diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index f0c84c538b..450698bcc0 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -84,22 +84,15 @@ export type BlockChangeSource = | { /** * When an event is triggered by an undo or redo operation, the source is "undo" or "redo". + * @note Y.js undo/redo are not differentiated. */ - type: "undo" | "redo"; + type: "undo" | "redo" | "undo-redo"; } | { /** * When an event is triggered by a remote user, the source is "remote". */ type: "yjs-remote"; - /** - * Whether the change is from this client or another client. - */ - isChangeOrigin: boolean; - /** - * Whether the change is an undo or redo operation. - */ - isUndoRedoOperation: boolean; }; export type BlocksChanged< @@ -181,11 +174,15 @@ export function getBlocksChangedByTransaction< type: transaction.getMeta("history$").redo ? "redo" : "undo", }; } else if (transaction.getMeta("y-sync$")) { - source = { - type: "yjs-remote", - isChangeOrigin: transaction.getMeta("y-sync$").isChangeOrigin, - isUndoRedoOperation: transaction.getMeta("y-sync$").isUndoRedoOperation, - }; + if (transaction.getMeta("y-sync$").isUndoRedoOperation) { + source = { + type: "undo-redo", + }; + } else { + source = { + type: "yjs-remote", + }; + } } // Get affected blocks before and after the change From 91c1d3d66ce88e2918081df2696bdd3339add6f6 Mon Sep 17 00:00:00 2001 From: Nick the Sick Date: Thu, 17 Apr 2025 09:34:20 +0200 Subject: [PATCH 12/12] docs: add docs --- docs/pages/docs/editor-api/events.mdx | 58 +++++++++++++++++++-- packages/core/src/editor/BlockNoteEditor.ts | 3 ++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/docs/pages/docs/editor-api/events.mdx b/docs/pages/docs/editor-api/events.mdx index 9cfbe18854..cfc2610f55 100644 --- a/docs/pages/docs/editor-api/events.mdx +++ b/docs/pages/docs/editor-api/events.mdx @@ -26,11 +26,64 @@ editor.onCreate(() => { The `onChange` callback is called when the editor content changes. ```typescript -editor.onChange(() => { +editor.onChange((editor, { getChanges }) => { console.log("Editor updated"); + const changes = getChanges(); + console.log(changes); }); ``` +You can see what specific changes occurred in the editor by calling `getChanges()` in the callback. This function returns an array of block changes which looks like: + +```typescript +/** + * The changes that occurred in the editor. + */ +type BlocksChanged = 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; + } + | { + // The affected block + block: Block; + // The source of the change + source: BlockChangeSource; + type: "update"; + // The block before the update + prevBlock: Block; + } +)>; + +/** + * This attributes the changes to a specific source. + */ +type BlockChangeSource = { + /** + * The type of change source: + * - "local": Triggered by local user (default) + * - "paste": From paste operation + * - "drop": From drop operation + * - "undo"/"redo"/"undo-redo": From undo/redo operations + * - "yjs-remote": From remote user + */ + type: + | "local" + | "paste" + | "drop" + | "undo" + | "redo" + | "undo-redo" + | "yjs-remote"; +}; +``` + + ## `onSelectionChange` The `onSelectionChange` callback is called when the editor selection changes. @@ -39,5 +92,4 @@ The `onSelectionChange` callback is called when the editor selection changes. editor.onSelectionChange(() => { console.log("Editor selection changed"); }); -``` - +``` \ No newline at end of file diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts index a7e50abee1..8f37c34284 100644 --- a/packages/core/src/editor/BlockNoteEditor.ts +++ b/packages/core/src/editor/BlockNoteEditor.ts @@ -1464,6 +1464,9 @@ export class BlockNoteEditor< callback: ( editor: BlockNoteEditor, context: { + /** + * Returns the blocks that were inserted, updated, or deleted by the change that occurred. + */ getChanges(): BlocksChanged; } ) => void