Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add shouldDiffDescendants #3009

Merged
merged 1 commit into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/fluffy-donuts-roll.md
Original file line number Diff line number Diff line change
@@ -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
```
109 changes: 103 additions & 6 deletions packages/diff/src/computeDiff.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComputeDiffOptions, 'lineBreakChar' | 'shouldDiffDescendants'> {
it?: typeof it;
lineBreakChar?: string;
input1: Value;
input2: Value;
expected: Value;
Expand Down Expand Up @@ -1448,16 +1448,113 @@ const fixtures: Record<string, ComputeDiffFixture> = {
},
],
},

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);
Expand Down
4 changes: 4 additions & 0 deletions packages/diff/src/computeDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down
68 changes: 45 additions & 23 deletions packages/diff/src/internal/transforms/transformDiffDescendants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand All @@ -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;
Expand All @@ -92,15 +103,15 @@ 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;
}

// 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;
Expand All @@ -110,23 +121,32 @@ 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.
continue;
} 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;
Expand All @@ -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;
Expand All @@ -145,5 +165,7 @@ export function transformDiffDescendants(
);
}

flushBuffers();

return children;
}
37 changes: 11 additions & 26 deletions packages/diff/src/internal/transforms/transformDiffNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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;
}