diff --git a/docs/pages/docs/editor-api/events.mdx b/docs/pages/docs/editor-api/events.mdx index 9cfbe1885..cfc2610f5 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/api/__snapshots__/blocks-deleted-nested-deep.json b/packages/core/src/api/__snapshots__/blocks-deleted-nested-deep.json new file mode 100644 index 000000000..7a78484b7 --- /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 new file mode 100644 index 000000000..2a9bf12ad --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-deleted-nested.json @@ -0,0 +1,68 @@ +[ + { + "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", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-deleted.json b/packages/core/src/api/__snapshots__/blocks-deleted.json new file mode 100644 index 000000000..8f8bb1f53 --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-inserted-nested.json b/packages/core/src/api/__snapshots__/blocks-inserted-nested.json new file mode 100644 index 000000000..ba74c2faf --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-inserted.json b/packages/core/src/api/__snapshots__/blocks-inserted.json new file mode 100644 index 000000000..02c4d9bd9 --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-updated-content-inserted.json b/packages/core/src/api/__snapshots__/blocks-updated-content-inserted.json new file mode 100644 index 000000000..8e768c6b8 --- /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/__snapshots__/blocks-updated-multiple-insert.json b/packages/core/src/api/__snapshots__/blocks-updated-multiple-insert.json new file mode 100644 index 000000000..6fd7f9fcf --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-updated-multiple.json b/packages/core/src/api/__snapshots__/blocks-updated-multiple.json new file mode 100644 index 000000000..621a233be --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-updated-nested-deep.json b/packages/core/src/api/__snapshots__/blocks-updated-nested-deep.json new file mode 100644 index 000000000..ee0020ed6 --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-updated-nested-multiple.json b/packages/core/src/api/__snapshots__/blocks-updated-nested-multiple.json new file mode 100644 index 000000000..9b26b44d8 --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-updated-nested.json b/packages/core/src/api/__snapshots__/blocks-updated-nested.json new file mode 100644 index 000000000..ba2627ab0 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-updated-nested.json @@ -0,0 +1,78 @@ +[ + { + "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/__snapshots__/blocks-updated-single.json b/packages/core/src/api/__snapshots__/blocks-updated-single.json new file mode 100644 index 000000000..9b02bfb75 --- /dev/null +++ b/packages/core/src/api/__snapshots__/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/__snapshots__/blocks-updated.json b/packages/core/src/api/__snapshots__/blocks-updated.json new file mode 100644 index 000000000..fb042475a --- /dev/null +++ b/packages/core/src/api/__snapshots__/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 new file mode 100644 index 000000000..d7c3486e2 --- /dev/null +++ b/packages/core/src/api/nodeUtil.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it, beforeEach } from "vitest"; + +import { setupTestEnv } from "./blockManipulation/setupTestEnv.js"; +import { getBlocksChangedByTransaction } from "./nodeUtil.js"; +import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; + +const getEditor = setupTestEnv(); + +describe("Test getBlocksChangedByTransaction", () => { + let editor: BlockNoteEditor; + + beforeEach(() => { + editor = getEditor(); + }); + + it("should return the correct blocks changed by a transaction", () => { + const blocksChanged = editor.transact((tr) => { + return getBlocksChangedByTransaction(tr); + }); + expect(blocksChanged).toEqual([]); + }); + + it("should return blocks inserted by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.insertBlocks([{ type: "paragraph" }], "paragraph-0", "after"); + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-inserted.json" + ); + }); + + it("should return nested blocks inserted by a transaction", async () => { + 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" + ); + }); + + it("should return blocks deleted by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.removeBlocks(["paragraph-0"]); + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-deleted.json" + ); + }); + + 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); + }); + + 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" + ); + }); + + it("should return blocks updated by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated.json" + ); + }); + + it("should return nested blocks updated by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("nested-paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated-nested.json" + ); + }); + + it("should return deeply nested blocks updated by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("double-nested-paragraph-0", { + content: "Example Text", + }); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated-nested-deep.json" + ); + }); + + it("should return multiple nested blocks updated by a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("nested-paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("double-nested-paragraph-0", { + content: "Example Text", + }); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated-nested-multiple.json" + ); + }); + + it("should only return a single block, if multiple updates change a single block in a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "blue", + }, + }); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated-single.json" + ); + }); + + it("should return multiple blocks, if multiple updates change multiple blocks in a transaction", async () => { + const blocksChanged = editor.transact((tr) => { + editor.updateBlock("paragraph-0", { + props: { + backgroundColor: "red", + }, + }); + editor.updateBlock("paragraph-1", { + props: { + backgroundColor: "blue", + }, + }); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-updated-multiple.json" + ); + }); + + it("should return multiple blocks, if multiple inserts add new blocks in a transaction", async () => { + 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" + ); + }); + + 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-content-inserted.json" + ); + }); +}); diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 26a860f41..450698bcc 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 { 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 @@ -17,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; } @@ -36,3 +53,219 @@ export function getNodeById( posBeforeNode: posBeforeNode, }; } + +export function isNodeBlock(node: Node): boolean { + return node.type.isInGroup("bnBlock"); +} + +/** + * 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". + * @note Y.js undo/redo are not differentiated. + */ + type: "undo" | "redo" | "undo-redo"; + } + | { + /** + * When an event is triggered by a remote user, the source is "remote". + */ + type: "yjs-remote"; + }; + +export type BlocksChanged< + BSchema extends BlockSchema = DefaultBlockSchema, + ISchema extends InlineContentSchema = DefaultInlineContentSchema, + SSchema extends StyleSchema = DefaultStyleSchema +> = Array< + { + /** + * The affected block. + */ + block: Block; + /** + * The source of the change. + */ + source: BlockChangeSource; + } & ( + | { + type: "insert" | "delete"; + /** + * Insert and delete changes don't have a previous block. + */ + prevBlock: undefined; + } + | { + type: "update"; + /** + * The block before the update. + */ + prevBlock: Block; + } + ) +>; + +/** + * Compares two blocks, ignoring their children. + * Returns true if the blocks are different (excluding children). + */ +function areBlocksDifferentExcludingChildren< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema +>( + block1: Block, + block2: Block +): boolean { + // 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) + ); +} + +/** + * 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, + appendedTransactions: Transaction[] = [] +): 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$")) { + if (transaction.getMeta("y-sync$").isUndoRedoOperation) { + source = { + type: "undo-redo", + }; + } else { + source = { + type: "yjs-remote", + }; + } + } + + // Get affected blocks before and after the change + const pmSchema = getPmSchema(transaction); + const combinedTransaction = combineTransactionSteps(transaction.before, [ + transaction, + ...appendedTransactions, + ]); + + const changedRanges = getChangedRanges(combinedTransaction); + const prevAffectedBlocks = changedRanges + .flatMap((range) => { + return findChildrenInRange( + combinedTransaction.before, + range.oldRange, + isNodeBlock + ); + }) + .map(({ node }) => nodeToBlock(node, pmSchema)); + + const nextAffectedBlocks = changedRanges + .flatMap((range) => { + return findChildrenInRange( + combinedTransaction.doc, + range.newRange, + isNodeBlock + ); + }) + .map(({ node }) => nodeToBlock(node, pmSchema)); + + const nextBlocks = new Map( + nextAffectedBlocks.map((block) => { + return [block.id, block]; + }) + ); + const prevBlocks = new Map( + prevAffectedBlocks.map((block) => { + return [block.id, block]; + }) + ); + + const changes: BlocksChanged = []; + + // 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, + }); + } + } + + // 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, + }); + } + } + + // 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)!; + + // 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 ff173c346..8f37c3428 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,21 +1461,38 @@ export class BlockNoteEditor< * @returns A function to remove the callback. */ public onChange( - callback: (editor: BlockNoteEditor) => void + callback: ( + editor: BlockNoteEditor, + context: { + /** + * Returns the blocks that were inserted, updated, or deleted by the change that occurred. + */ + 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, + appendedTransactions, + }: { + transaction: Transaction; + appendedTransactions: Transaction[]; + }) => { + callback(this, { + getChanges: () => + getBlocksChangedByTransaction(transaction, 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 3d1eb1e28..66ee5055e 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[]; + }; + } +} diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx index 4737afd4b..45c4b7398 100644 --- a/packages/react/src/editor/BlockNoteView.tsx +++ b/packages/react/src/editor/BlockNoteView.tsx @@ -71,7 +71,9 @@ export type BlockNoteViewProps< /** * A callback function that runs whenever the editor's contents change. */ - onChange?: () => void; + onChange?: Parameters< + BlockNoteEditor["onChange"] + >[0]; children?: ReactNode; diff --git a/packages/react/src/hooks/useEditorChange.ts b/packages/react/src/hooks/useEditorChange.ts index 4df745720..2d13bb062 100644 --- a/packages/react/src/hooks/useEditorChange.ts +++ b/packages/react/src/hooks/useEditorChange.ts @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { useBlockNoteContext } from "../editor/BlockNoteContext.js"; export function useEditorChange( - callback: () => void, + callback: Parameters["onChange"]>[0], editor?: BlockNoteEditor ) { const editorContext = useBlockNoteContext();