diff --git a/.changeset/fluffy-donuts-roll.md b/.changeset/fluffy-donuts-roll.md new file mode 100644 index 0000000000..23dd3bfb19 --- /dev/null +++ b/.changeset/fluffy-donuts-roll.md @@ -0,0 +1,21 @@ +--- +"@udecode/plate-diff": minor +--- + +- Add `shouldDiffDescendants` option to `computeDiff` to control whether a pair of descendant lists should be diffed. If false, the parent node will be deleted and re-inserted. Defaults to `() => true`. + - Example use case: To prevent `computeDiff` from diffing the text of unrelated paragraphs, use a text similarity checking algorithm to determine whether the paragraphs are sufficiently similar, and return false if not. +- When multiple consecutive nodes have been deleted and inserted, `computeDiff` now groups all consecutive deletions together and does the same with all consecutive insertions. + - Example of a diff prior to this change: + ```diff + - Old paragraph 1 + + New paragraph 1 + - Old paragraph 2 + + New paragraph 2 + ``` + - Example of a diff after this change: + ```diff + - Old paragraph 1 + - Old paragraph 2 + + New paragraph 1 + + New paragraph 2 + ``` diff --git a/packages/diff/src/computeDiff.spec.ts b/packages/diff/src/computeDiff.spec.ts index 69902b37a8..0e6d3f56db 100644 --- a/packages/diff/src/computeDiff.spec.ts +++ b/packages/diff/src/computeDiff.spec.ts @@ -3,15 +3,15 @@ * contributors. See /packages/diff/LICENSE for more information. */ -import { Value } from '@udecode/plate-common'; +import { isText, Value } from '@udecode/plate-common'; -import { computeDiff } from './computeDiff'; +import { computeDiff, ComputeDiffOptions } from './computeDiff'; const ELEMENT_INLINE_VOID = 'inline-void'; -interface ComputeDiffFixture { +interface ComputeDiffFixture + extends Pick { it?: typeof it; - lineBreakChar?: string; input1: Value; input2: Value; expected: Value; @@ -1448,16 +1448,113 @@ const fixtures: Record = { }, ], }, + + shouldNotDiffDescendants: { + shouldDiffDescendants: ([firstNode]) => + !firstNode || + !isText(firstNode) || + !firstNode.text.startsWith('NO_DIFF_INLINE'), + input1: [ + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE FirstA' }], + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE SecondA' }], + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE ThirdA' }], + }, + { + type: 'paragraph', + children: [{ text: 'Same' }], + }, + ], + input2: [ + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE FirstB' }], + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE SecondB' }], + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE ThirdB' }], + }, + { + type: 'paragraph', + children: [{ text: 'Same' }], + }, + ], + expected: [ + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE FirstA' }], + diff: true, + diffOperation: { + type: 'delete', + }, + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE SecondA' }], + diff: true, + diffOperation: { + type: 'delete', + }, + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE ThirdA' }], + diff: true, + diffOperation: { + type: 'delete', + }, + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE FirstB' }], + diff: true, + diffOperation: { + type: 'insert', + }, + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE SecondB' }], + diff: true, + diffOperation: { + type: 'insert', + }, + }, + { + type: 'paragraph', + children: [{ text: 'NO_DIFF_INLINE ThirdB' }], + diff: true, + diffOperation: { + type: 'insert', + }, + }, + { + type: 'paragraph', + children: [{ text: 'Same' }], + }, + ], + }, }; describe('computeDiff', () => { Object.entries(fixtures).forEach( - ([name, { it: itFn = it, input1, input2, expected, lineBreakChar }]) => { + ([name, { it: itFn = it, input1, input2, expected, ...options }]) => { itFn(name, () => { const output = computeDiff(input1, input2, { isInline: (node) => node.type === ELEMENT_INLINE_VOID, ignoreProps: ['id'], - lineBreakChar, + ...options, }); expect(output).toEqual(expected); diff --git a/packages/diff/src/computeDiff.ts b/packages/diff/src/computeDiff.ts index fbd89eb05f..db5670e3fa 100644 --- a/packages/diff/src/computeDiff.ts +++ b/packages/diff/src/computeDiff.ts @@ -14,6 +14,10 @@ export interface ComputeDiffOptions { isInline: PlateEditor['isInline']; ignoreProps?: string[]; lineBreakChar?: string; + shouldDiffDescendants?: ( + nodes: TDescendant[], + nextNodes: TDescendant[] + ) => boolean; getInsertProps: (node: TDescendant) => any; getDeleteProps: (node: TDescendant) => any; getUpdateProps: ( diff --git a/packages/diff/src/internal/transforms/transformDiffDescendants.ts b/packages/diff/src/internal/transforms/transformDiffDescendants.ts index 44d4503d39..b8752914f2 100644 --- a/packages/diff/src/internal/transforms/transformDiffDescendants.ts +++ b/packages/diff/src/internal/transforms/transformDiffDescendants.ts @@ -32,21 +32,32 @@ export function transformDiffDescendants( let i = 0; const children: TDescendant[] = []; - const insertNodes = (...nodes: TDescendant[]) => - children.push( - ...nodes.map((node) => ({ - ...node, - ...getInsertProps(node), - })) - ); + let insertBuffer: TDescendant[] = []; + let deleteBuffer: TDescendant[] = []; + + const flushBuffers = () => { + // Return all deletions followed by all insertions + children.push(...deleteBuffer, ...insertBuffer); + insertBuffer = []; + deleteBuffer = []; + }; - const removeNodes = (...nodes: TDescendant[]) => - children.push( - ...nodes.map((node) => ({ - ...node, - ...getDeleteProps(node), - })) - ); + const insertNode = (node: TDescendant) => + insertBuffer.push({ + ...node, + ...getInsertProps(node), + }); + + const deleteNode = (node: TDescendant) => + deleteBuffer.push({ + ...node, + ...getDeleteProps(node), + }); + + const passThroughNodes = (...nodes: TDescendant[]) => { + flushBuffers(); + children.push(...nodes); + }; const areNodeListsEquivalent = ( nodes0: TDescendant[], @@ -72,7 +83,7 @@ export function transformDiffDescendants( // If operation code is 0, it means the chunk is unchanged if (op === 0) { - children.push(...nodes); + passThroughNodes(...nodes); // Move to the next diff chunk i += 1; continue; @@ -92,7 +103,7 @@ export function transformDiffDescendants( * just return nextNodes. */ if (areNodeListsEquivalent(nodes, nextNodes)) { - children.push(...nextNodes); + passThroughNodes(...nextNodes); // Consume two diff chunks (delete and insert) i += 2; continue; @@ -100,7 +111,7 @@ export function transformDiffDescendants( // If both current and next chunks are text nodes, use transformTextNodes if (isInlineList(nodes) && isInlineList(nextNodes)) { - children.push(...transformDiffTexts(nodes, nextNodes, options)); + passThroughNodes(...transformDiffTexts(nodes, nextNodes, options)); // Consume two diff chunks (delete and insert) i += 2; continue; @@ -110,15 +121,24 @@ export function transformDiffDescendants( const diffResult = diffNodes(nodes, nextNodes); diffResult.forEach((item: NodeRelatedItem) => { if (item.delete) { - removeNodes(item.originNode); + deleteNode(item.originNode); } if (item.insert) { - insertNodes(item.originNode); + insertNode(item.originNode); } if (item.relatedNode) { - children.push( - ...transformDiffNodes(item.originNode, item.relatedNode, options) + const diffNodesResult = transformDiffNodes( + item.originNode, + item.relatedNode, + options ); + + if (diffNodesResult) { + passThroughNodes(...diffNodesResult); + } else { + deleteNode(item.originNode); + insertNode(item.relatedNode); + } } }); i += 2; // this consumed two entries from the diff array. @@ -126,7 +146,7 @@ export function transformDiffDescendants( } else { // Plain delete of some nodes (with no insert immediately after) for (const node of nodes) { - removeNodes(node); + deleteNode(node); } i += 1; // consumes only one entry from diff array. continue; @@ -135,7 +155,7 @@ export function transformDiffDescendants( if (op === 1) { // insert new nodes. for (const node of nodes) { - insertNodes(node); + insertNode(node); } i += 1; continue; @@ -145,5 +165,7 @@ export function transformDiffDescendants( ); } + flushBuffers(); + return children; } diff --git a/packages/diff/src/internal/transforms/transformDiffNodes.ts b/packages/diff/src/internal/transforms/transformDiffNodes.ts index 6bf1d1249b..49cc20664f 100644 --- a/packages/diff/src/internal/transforms/transformDiffNodes.ts +++ b/packages/diff/src/internal/transforms/transformDiffNodes.ts @@ -28,12 +28,18 @@ type Handler = ( * algorithm on the children. */ const childrenOnlyStrategy: Handler = (node, nextNode, options) => { + const { shouldDiffDescendants = () => true } = options; + if ( node['children'] != null && nextNode['children'] != null && isEqual( copyWithout(node, ['children']), copyWithout(nextNode, ['children']) + ) && + shouldDiffDescendants( + node['children'] as TDescendant[], + nextNode['children'] as TDescendant[] ) ) { const children = computeDiff( @@ -83,36 +89,14 @@ const propsOnlyStrategy: Handler = (node, nextNode, { getUpdateProps }) => { ]; }; -// No other strategy applies, so remove and insert the node. -const fallbackStrategy: Handler = ( - node, - nextNode, - { getInsertProps, getDeleteProps } -) => { - return [ - { - ...node, - ...getDeleteProps(node), - }, - { - ...nextNode, - ...getInsertProps(nextNode), - }, - ]; -}; - -const strategies: Handler[] = [ - childrenOnlyStrategy, - propsOnlyStrategy, - fallbackStrategy, -]; +const strategies: Handler[] = [childrenOnlyStrategy, propsOnlyStrategy]; // Replace node at path by nextNode using the first strategy that works. export function transformDiffNodes( node: TDescendant, nextNode: TDescendant, options: ComputeDiffOptions -): TDescendant[] { +): TDescendant[] | false { // Try each strategy in turn for (const strategy of strategies) { // Attempt to generate operations with the current strategy and return the operations if the strategy succeeds @@ -121,6 +105,7 @@ export function transformDiffNodes( return ops; } } - // If no strategy succeeds, throw an error (should never happen because of the fallback strategy) - throw new Error('transformDiffNodes: No strategy succeeded'); + + // If no strategy succeeds, tell the caller that the nodes are not comparable + return false; }