diff --git a/packages/core/src/api/__snapshots__/blocks-indented-changed.json b/packages/core/src/api/__snapshots__/blocks-indented-changed.json new file mode 100644 index 000000000..860b9dfaf --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-indented-changed.json @@ -0,0 +1,129 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "A", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-deeper-into-nesting.json b/packages/core/src/api/__snapshots__/blocks-moved-deeper-into-nesting.json new file mode 100644 index 000000000..23b74cd24 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-deeper-into-nesting.json @@ -0,0 +1,164 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Target", + "type": "text", + }, + ], + "id": "target", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Level 2", + "type": "text", + }, + ], + "id": "level-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Target", + "type": "text", + }, + ], + "id": "target", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Level 1", + "type": "text", + }, + ], + "id": "level-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Target", + "type": "text", + }, + ], + "id": "target", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Level 2", + "type": "text", + }, + ], + "id": "level-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Level 1", + "type": "text", + }, + ], + "id": "level-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Target", + "type": "text", + }, + ], + "id": "target", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Root", + "type": "text", + }, + ], + "id": "root", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-multiple-in-same-transaction.json b/packages/core/src/api/__snapshots__/blocks-moved-multiple-in-same-transaction.json new file mode 100644 index 000000000..9489e47c4 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-multiple-in-same-transaction.json @@ -0,0 +1,188 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": undefined, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 2", + "type": "text", + }, + ], + "id": "child-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent 1", + "type": "text", + }, + ], + "id": "parent-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 2", + "type": "text", + }, + ], + "id": "child-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": undefined, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 2", + "type": "text", + }, + ], + "id": "child-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 2", + "type": "text", + }, + ], + "id": "child-2", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent 1", + "type": "text", + }, + ], + "id": "parent-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-to-different-parent.json b/packages/core/src/api/__snapshots__/blocks-moved-to-different-parent.json new file mode 100644 index 000000000..bd702b07f --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-to-different-parent.json @@ -0,0 +1,78 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": undefined, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child 1", + "type": "text", + }, + ], + "id": "child-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent 1", + "type": "text", + }, + ], + "id": "parent-1", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-moved-to-root-level.json b/packages/core/src/api/__snapshots__/blocks-moved-to-root-level.json new file mode 100644 index 000000000..86598e5a2 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-moved-to-root-level.json @@ -0,0 +1,78 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child", + "type": "text", + }, + ], + "id": "child", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": undefined, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child", + "type": "text", + }, + ], + "id": "child", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "Child", + "type": "text", + }, + ], + "id": "child", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "Parent", + "type": "text", + }, + ], + "id": "parent", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/__snapshots__/blocks-outdented-changed.json b/packages/core/src/api/__snapshots__/blocks-outdented-changed.json new file mode 100644 index 000000000..f2916bd73 --- /dev/null +++ b/packages/core/src/api/__snapshots__/blocks-outdented-changed.json @@ -0,0 +1,129 @@ +[ + { + "block": { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "currentParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "A", + "type": "text", + }, + ], + "id": "paragraph-with-children", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevBlock": { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "prevParent": { + "children": [ + { + "children": [], + "content": [ + { + "styles": {}, + "text": "C", + "type": "text", + }, + ], + "id": "double-nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + ], + "content": [ + { + "styles": {}, + "text": "B", + "type": "text", + }, + ], + "id": "nested-paragraph-0", + "props": { + "backgroundColor": "default", + "textAlignment": "left", + "textColor": "default", + }, + "type": "paragraph", + }, + "source": { + "type": "local", + }, + "type": "move", + }, +] \ No newline at end of file diff --git a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts index 2ef86d71c..e8e97d77d 100644 --- a/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts +++ b/packages/core/src/api/blockManipulation/commands/nestBlock/nestBlock.ts @@ -1,5 +1,5 @@ import { Fragment, NodeType, Slice } from "prosemirror-model"; -import { EditorState, Transaction } from "prosemirror-state"; +import { Transaction } from "prosemirror-state"; import { ReplaceAroundStep } from "prosemirror-transform"; import { BlockNoteEditor } from "../../../../editor/BlockNoteEditor.js"; @@ -11,68 +11,67 @@ import { getBlockInfoFromTransaction } from "../../../getBlockInfoFromPos.js"; * * The original function derives too many information from the parentnode and itemtype */ -function sinkListItem(itemType: NodeType, groupType: NodeType) { - return function (state: EditorState, dispatch?: (tr: Transaction) => void) { - const { $from, $to } = state.selection; - const range = $from.blockRange( - $to, - (node) => - node.childCount > 0 && - (node.type.name === "blockGroup" || node.type.name === "column"), // change necessary to not look at first item child type - ); - if (!range) { - return false; - } - const startIndex = range.startIndex; - if (startIndex === 0) { - return false; - } - const parent = range.parent; - const nodeBefore = parent.child(startIndex - 1); - if (nodeBefore.type !== itemType) { - return false; - } - if (dispatch) { - const nestedBefore = - nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change necessary to check groupType instead of parent.type - const inner = Fragment.from(nestedBefore ? itemType.create() : null); - const slice = new Slice( - Fragment.from( - itemType.create(null, Fragment.from(groupType.create(null, inner))), // change necessary to create "groupType" instead of parent.type - ), - nestedBefore ? 3 : 1, - 0, - ); +function sinkListItem( + tr: Transaction, + itemType: NodeType, + groupType: NodeType, +) { + const { $from, $to } = tr.selection; + const range = $from.blockRange( + $to, + (node) => + node.childCount > 0 && + (node.type.name === "blockGroup" || node.type.name === "column"), // change necessary to not look at first item child type + ); + if (!range) { + return false; + } + const startIndex = range.startIndex; + if (startIndex === 0) { + return false; + } + const parent = range.parent; + const nodeBefore = parent.child(startIndex - 1); + if (nodeBefore.type !== itemType) { + return false; + } + const nestedBefore = + nodeBefore.lastChild && nodeBefore.lastChild.type === groupType; // change necessary to check groupType instead of parent.type + const inner = Fragment.from(nestedBefore ? itemType.create() : null); + const slice = new Slice( + Fragment.from( + itemType.create(null, Fragment.from(groupType.create(null, inner))), // change necessary to create "groupType" instead of parent.type + ), + nestedBefore ? 3 : 1, + 0, + ); + + const before = range.start; + const after = range.end; + + tr.step( + new ReplaceAroundStep( + before - (nestedBefore ? 3 : 1), + after, + before, + after, + slice, + 1, + true, + ), + ).scrollIntoView(); - const before = range.start; - const after = range.end; - dispatch( - state.tr - .step( - new ReplaceAroundStep( - before - (nestedBefore ? 3 : 1), - after, - before, - after, - slice, - 1, - true, - ), - ) - .scrollIntoView(), - ); - } - return true; - }; + return true; } export function nestBlock(editor: BlockNoteEditor) { - return editor.exec((state, dispatch) => - sinkListItem( - state.schema.nodes["blockContainer"], - state.schema.nodes["blockGroup"], - )(state, dispatch), - ); + return editor.transact((tr) => { + return sinkListItem( + tr, + editor.pmSchema.nodes["blockContainer"], + editor.pmSchema.nodes["blockGroup"], + ); + }); } export function unnestBlock(editor: BlockNoteEditor) { diff --git a/packages/core/src/api/nodeUtil.test.ts b/packages/core/src/api/nodeUtil.test.ts index 9fa46b431..ba6048895 100644 --- a/packages/core/src/api/nodeUtil.test.ts +++ b/packages/core/src/api/nodeUtil.test.ts @@ -6,7 +6,7 @@ import { BlockNoteEditor } from "../editor/BlockNoteEditor.js"; const getEditor = setupTestEnv(); -describe("Test getBlocksChangedByTransaction", () => { +describe("getBlocksChangedByTransaction", () => { let editor: BlockNoteEditor; beforeEach(() => { @@ -225,4 +225,231 @@ describe("Test getBlocksChangedByTransaction", () => { "__snapshots__/blocks-updated-content-inserted.json", ); }); + + it("should return blocks which have been indented", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-with-children", + type: "paragraph", + content: "A", + children: [ + { + id: "nested-paragraph-0", + type: "paragraph", + content: "B", + children: [], + }, + { + id: "double-nested-paragraph-0", + type: "paragraph", + content: "C", + }, + ], + }, + ]); + const blocksChanged = editor.transact((tr) => { + editor.setTextCursorPosition("double-nested-paragraph-0", "start"); + editor.nestBlock(); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-indented-changed.json", + ); + }); + + it("should return blocks which have been outdented", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "paragraph-with-children", + type: "paragraph", + content: "A", + children: [ + { + id: "nested-paragraph-0", + type: "paragraph", + content: "B", + children: [ + { + id: "double-nested-paragraph-0", + type: "paragraph", + content: "C", + }, + ], + }, + ], + }, + ]); + + // This test is different from the other tests because it uses the onChange hook to get the blocks changed + // This is because unnesting a block is not allowed within a transaction + let blocksChanged: any = null; + const unsubscribe = editor.onChange((_e, { getChanges }) => { + blocksChanged = getChanges(); + }); + + // Make the change + editor.setTextCursorPosition("double-nested-paragraph-0", "start"); + editor.unnestBlock(); + + // Clean up + if (unsubscribe) { + unsubscribe(); + } + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-outdented-changed.json", + ); + }); + + it("should return blocks which have been moved to a different parent", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "parent-1", + type: "paragraph", + content: "Parent 1", + children: [ + { + id: "child-1", + type: "paragraph", + content: "Child 1", + }, + ], + }, + { + id: "parent-2", + type: "paragraph", + content: "Parent 2", + children: [], + }, + ]); + + const blocksChanged = editor.transact((tr) => { + const childBlock = editor.getBlock("child-1"); + editor.removeBlocks(["child-1"]); + editor.insertBlocks([{ ...childBlock }], "parent-2", "after"); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-to-different-parent.json", + ); + }); + + it("should return blocks which have been moved to root level", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "parent", + type: "paragraph", + content: "Parent", + children: [ + { + id: "child", + type: "paragraph", + content: "Child", + }, + ], + }, + ]); + + const blocksChanged = editor.transact((tr) => { + const childBlock = editor.getBlock("child"); + editor.removeBlocks(["child"]); + editor.insertBlocks([{ ...childBlock }], "parent", "after"); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-to-root-level.json", + ); + }); + + it("should return blocks which have been moved deeper into nesting", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "root", + type: "paragraph", + content: "Root", + children: [ + { + id: "level-1", + type: "paragraph", + content: "Level 1", + children: [ + { + id: "level-2", + type: "paragraph", + content: "Level 2", + }, + ], + }, + { + id: "target", + type: "paragraph", + content: "Target", + }, + ], + }, + ]); + + const blocksChanged = editor.transact((tr) => { + const targetBlock = editor.getBlock("target"); + editor.removeBlocks(["target"]); + editor.insertBlocks([{ ...targetBlock }], "level-2", "after"); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-deeper-into-nesting.json", + ); + }); + + it("should return multiple blocks when multiple blocks are moved in the same transaction", async () => { + editor.replaceBlocks(editor.document, [ + { + id: "parent-1", + type: "paragraph", + content: "Parent 1", + children: [ + { + id: "child-1", + type: "paragraph", + content: "Child 1", + }, + { + id: "child-2", + type: "paragraph", + content: "Child 2", + }, + ], + }, + { + id: "parent-2", + type: "paragraph", + content: "Parent 2", + children: [], + }, + ]); + + const blocksChanged = editor.transact((tr) => { + const child1Block = editor.getBlock("child-1"); + const child2Block = editor.getBlock("child-2"); + editor.removeBlocks(["child-1", "child-2"]); + editor.insertBlocks( + [{ ...child1Block }, { ...child2Block }], + "parent-2", + "after", + ); + + return getBlocksChangedByTransaction(tr); + }); + + await expect(blocksChanged).toMatchFileSnapshot( + "__snapshots__/blocks-moved-multiple-in-same-transaction.json", + ); + }); }); diff --git a/packages/core/src/api/nodeUtil.ts b/packages/core/src/api/nodeUtil.ts index 45e3556fe..345b6ddde 100644 --- a/packages/core/src/api/nodeUtil.ts +++ b/packages/core/src/api/nodeUtil.ts @@ -1,8 +1,4 @@ -import { - combineTransactionSteps, - findChildrenInRange, - getChangedRanges, -} from "@tiptap/core"; +import { combineTransactionSteps } from "@tiptap/core"; import type { Node } from "prosemirror-model"; import type { Transaction } from "prosemirror-state"; import { @@ -17,6 +13,23 @@ import type { StyleSchema } from "../schema/styles/types.js"; import { nodeToBlock } from "./nodeConversions/nodeToBlock.js"; import { getPmSchema } from "./pmUtil.js"; +/** + * Gets the parent block of a node, if it has one. + */ +function getParentBlockId(doc: Node, pos: number): string | undefined { + if (pos === 0) { + return undefined; + } + const resolvedPos = doc.resolve(pos); + for (let i = resolvedPos.depth; i > 0; i--) { + const parent = resolvedPos.node(i); + if (isNodeBlock(parent)) { + return parent.attrs.id; + } + } + return undefined; +} + /** * Get a TipTap node by id */ @@ -62,38 +75,11 @@ export function isNodeBlock(node: Node): boolean { * 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"; - }; + | { type: "local" } + | { type: "paste" } + | { type: "drop" } + | { type: "undo" | "redo" | "undo-redo" } + | { type: "yjs-remote" }; export type BlocksChanged< BSchema extends BlockSchema = DefaultBlockSchema, @@ -120,10 +106,25 @@ export type BlocksChanged< | { type: "update"; /** - * The block before the update. + * The previous block. */ prevBlock: Block; } + | { + type: "move"; + /** + * The previous block. + */ + prevBlock: Block; + /** + * The previous parent block (if it existed). + */ + prevParent?: Block; + /** + * The current parent block (if it exists). + */ + currentParent?: Block; + } ) >; @@ -139,8 +140,6 @@ function areBlocksDifferentExcludingChildren< 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 || @@ -149,11 +148,63 @@ function areBlocksDifferentExcludingChildren< ); } +function determineChangeSource(transaction: Transaction): BlockChangeSource { + if (transaction.getMeta("paste")) { + return { type: "paste" }; + } + if (transaction.getMeta("uiEvent") === "drop") { + return { type: "drop" }; + } + if (transaction.getMeta("history$")) { + return { + type: transaction.getMeta("history$").redo ? "redo" : "undo", + }; + } + if (transaction.getMeta("y-sync$")) { + if (transaction.getMeta("y-sync$").isUndoRedoOperation) { + return { type: "undo-redo" }; + } + return { type: "yjs-remote" }; + } + return { type: "local" }; +} + +function collectAllBlocks< + BSchema extends BlockSchema, + ISchema extends InlineContentSchema, + SSchema extends StyleSchema, +>( + doc: Node, +): Record< + string, + { + block: Block; + parentId: string | undefined; + } +> { + const blocks: Record< + string, + { + block: Block; + parentId: string | undefined; + } + > = {}; + const pmSchema = getPmSchema(doc); + doc.descendants((node, pos) => { + if (isNodeBlock(node)) { + const parentId = getParentBlockId(doc, pos); + blocks[node.attrs.id] = { + block: nodeToBlock(node, pmSchema), + parentId, + }; + } + return true; + }); + return blocks; +} + /** * Get the blocks that were changed by a transaction. - * @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, @@ -163,109 +214,75 @@ export function getBlocksChangedByTransaction< 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 source = determineChangeSource(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 = collectAllBlocks( + combinedTransaction.before, ); - const prevBlocks = new Map( - prevAffectedBlocks.map((block) => { - return [block.id, block]; - }), + const nextBlocks = collectAllBlocks( + combinedTransaction.doc, ); 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)) { + // Handle inserted blocks + Object.keys(nextBlocks) + .filter((id) => !(id in prevBlocks)) + .forEach((id) => { changes.push({ type: "insert", - block, + block: nextBlocks[id].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)) { + // Handle deleted blocks + Object.keys(prevBlocks) + .filter((id) => !(id in nextBlocks)) + .forEach((id) => { changes.push({ type: "delete", - block, + block: prevBlocks[id].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)!; + // Handle updated, moved, indented, outdented blocks + Object.keys(nextBlocks) + .filter((id) => id in prevBlocks) + .forEach((id) => { + const prev = prevBlocks[id]; + const next = nextBlocks[id]; + const isParentDifferent = prev.parentId !== next.parentId; - // Only include the update if the block itself changed (excluding children) - if (areBlocksDifferentExcludingChildren(prevBlock, block)) { + if (isParentDifferent) { + changes.push({ + type: "move", + block: next.block, + prevBlock: prev.block, + source, + prevParent: prev.parentId + ? prevBlocks[prev.parentId]?.block + : undefined, + currentParent: next.parentId + ? nextBlocks[next.parentId]?.block + : undefined, + }); + } else if (areBlocksDifferentExcludingChildren(prev.block, next.block)) { changes.push({ type: "update", - block, - prevBlock, + block: next.block, + prevBlock: prev.block, source, }); } - } - } + }); return changes; }