Skip to content

Commit

Permalink
Merge pull request #2982 from udecode/feat/diff-line-break-char
Browse files Browse the repository at this point in the history
Add `lineBreakChar` option
  • Loading branch information
12joan authored Feb 18, 2024
2 parents e869883 + 3ab7d98 commit 07a8375
Show file tree
Hide file tree
Showing 9 changed files with 243 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/tiny-wolves-love.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@udecode/plate-diff": minor
---

`computeDiff`: Add `lineBreakChar?: string` option to replace `\n` characters in inserted and removed text with a character such as '¶'. Without this option, added or removed line breaks may be difficult to notice in the diff.
10 changes: 9 additions & 1 deletion apps/www/src/registry/default/example/version-history-demo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { createSoftBreakPlugin } from '@/../../../packages/break/dist';
import { cn, withProps } from '@udecode/cn';
import {
createBoldPlugin,
Expand All @@ -20,7 +21,12 @@ import {
PlateProps,
Value,
} from '@udecode/plate-common';
import { computeDiff, DiffOperation, DiffUpdate, withGetFragmentExcludeDiff } from '@udecode/plate-diff';
import {
computeDiff,
DiffOperation,
DiffUpdate,
withGetFragmentExcludeDiff,
} from '@udecode/plate-diff';
import {
createParagraphPlugin,
ELEMENT_PARAGRAPH,
Expand Down Expand Up @@ -197,6 +203,7 @@ const plugins = createPlugins(
createBoldPlugin(),
createItalicPlugin(),
createDiffPlugin(),
createSoftBreakPlugin(),
],
{
components: {
Expand Down Expand Up @@ -259,6 +266,7 @@ function Diff({ previous, current }: DiffProps) {
const editor = createPlateEditor({ plugins });
return computeDiff(previous, current, {
isInline: editor.isInline,
lineBreakChar: '¶',
}) as Value;
}, [previous, current]);

Expand Down
121 changes: 120 additions & 1 deletion packages/diff/src/computeDiff.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const ELEMENT_INLINE_VOID = 'inline-void';

interface ComputeDiffFixture {
it?: typeof it;
lineBreakChar?: string;
input1: Value;
input2: Value;
expected: Value;
Expand Down Expand Up @@ -1330,15 +1331,133 @@ const fixtures: Record<string, ComputeDiffFixture> = {
},
],
},

insertWithoutLineBreakChar: {
input1: [
{
type: 'paragraph',
children: [{ text: 'PingCode' }],
},
],
input2: [
{
type: 'paragraph',
children: [{ text: 'Ping\nCode' }],
},
],
expected: [
{
type: 'paragraph',
children: [
{ text: 'Ping' },
{ text: '\n', diff: true, diffOperation: { type: 'insert' } },
{ text: 'Code' },
],
},
],
},

removeWithoutLineBreakChar: {
input1: [
{
type: 'paragraph',
children: [{ text: 'Ping\nCode' }],
},
],
input2: [
{
type: 'paragraph',
children: [{ text: 'PingCode' }],
},
],
expected: [
{
type: 'paragraph',
children: [
{ text: 'Ping' },
{ text: '\n', diff: true, diffOperation: { type: 'delete' } },
{ text: 'Code' },
],
},
],
},

insertWithLineBreakChar: {
lineBreakChar: '¶',
input1: [
{
type: 'paragraph',
children: [{ text: 'PingCode' }],
},
],
input2: [
{
type: 'paragraph',
children: [
{ text: 'Ping\nCo' },
{ text: 'd', bold: true },
{ text: 'e' },
],
},
],
expected: [
{
type: 'paragraph',
children: [
{ text: 'Ping' },
{ text: '¶\n', diff: true, diffOperation: { type: 'insert' } },
{ text: 'Co' },
{
text: 'd',
bold: true,
diff: true,
diffOperation: {
type: 'update',
properties: { bold: undefined },
newProperties: { bold: true },
},
},
{ text: 'e', bold: undefined },
],
},
],
},

removeWithLineBreakChar: {
lineBreakChar: '¶',
input1: [
{
type: 'paragraph',
children: [{ text: 'Ping\nCode' }],
},
],
input2: [
{
type: 'paragraph',
children: [{ text: 'PingCode' }],
},
],
expected: [
{
type: 'paragraph',
children: [
{ text: 'Ping' },
{ text: '¶', diff: true, diffOperation: { type: 'delete' } },
{ text: 'Code' },
],
},
],
},
};

describe('computeDiff', () => {
Object.entries(fixtures).forEach(
([name, { it: itFn = it, input1, input2, expected }]) => {
([name, { it: itFn = it, input1, input2, expected, lineBreakChar }]) => {
itFn(name, () => {
const output = computeDiff(input1, input2, {
isInline: (node) => node.type === ELEMENT_INLINE_VOID,
ignoreProps: ['id'],
lineBreakChar,
});

expect(output).toEqual(expected);
Expand Down
3 changes: 3 additions & 0 deletions packages/diff/src/computeDiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DiffProps } from './types';
export interface ComputeDiffOptions {
isInline: PlateEditor['isInline'];
ignoreProps?: string[];
lineBreakChar?: string;
getInsertProps: (node: TDescendant) => any;
getDeleteProps: (node: TDescendant) => any;
getUpdateProps: (
Expand All @@ -31,6 +32,7 @@ export const computeDiff = (
getInsertProps = defaultGetInsertProps,
getDeleteProps = defaultGetDeleteProps,
getUpdateProps = defaultGetUpdateProps,
...options
}: Partial<ComputeDiffOptions> = {}
): TDescendant[] => {
const stringCharMapping = new StringCharMapping();
Expand All @@ -56,6 +58,7 @@ export const computeDiff = (
return getUpdateProps(node, properties, newProperties);
},
stringCharMapping,
...options,
});
};

Expand Down
78 changes: 69 additions & 9 deletions packages/diff/src/internal/transforms/transformDiffTexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ComputeDiffOptions } from '../../computeDiff';
import { dmp } from '../utils/dmp';
import { getProperties } from '../utils/get-properties';
import { InlineNodeCharMap } from '../utils/inline-node-char-map';
import { unusedCharGenerator } from '../utils/unused-char-generator';
import { withChangeTracking } from '../utils/with-change-tracking';

// Main function to transform an array of text nodes into another array of text nodes
Expand All @@ -23,15 +24,34 @@ export function transformDiffTexts(
if (nextNodes.length === 0)
throw new Error('must have at least one nextNodes');

const inlineNodeCharMap = new InlineNodeCharMap({
const { lineBreakChar } = options;
const hasLineBreakChar = lineBreakChar !== undefined;

const charGenerator = unusedCharGenerator({
// Do not use any char that is present in the text
unavailableChars: nodes
skipChars: nodes
.concat(nextNodes)
.filter(isText)
.map((n) => n.text)
.join(''),
});

/**
* Chars to represent inserted and deleted line breaks in the diff. These
* must have a length of 1 to keep the offsets consistent. `lineBreakChar`
* itself may have any length.
*/
const insertedLineBreakProxyChar = hasLineBreakChar
? charGenerator.next().value
: undefined;
const deletedLineBreakProxyChar = hasLineBreakChar
? charGenerator.next().value
: undefined;

const inlineNodeCharMap = new InlineNodeCharMap({
charGenerator,
});

// Map inlines nodes to unique text nodes
const texts = nodes.map((n) => inlineNodeCharMap.nodeToText(n));
const nextTexts = nextNodes.map((n) => inlineNodeCharMap.nodeToText(n));
Expand All @@ -58,21 +78,43 @@ export function transformDiffTexts(
}

// After merging, apply split operations based on the target state (`nextTexts`)
for (const op of splitTextNodes(node, nextTexts)) {
for (const op of splitTextNodes(node, nextTexts, {
insertedLineBreakChar: insertedLineBreakProxyChar,
deletedLineBreakChar: deletedLineBreakProxyChar,
})) {
nodesEditor.apply(op);
}

nodesEditor.commitChangesToDiffs();
});

const diffTexts: TText[] = (nodesEditor.children[0] as any).children;
let diffTexts: TText[] = (nodesEditor.children[0] as any).children;

// Replace line break proxy chars with the actual line break char
if (hasLineBreakChar) {
diffTexts = diffTexts.map((n) => ({
...n,
text: n.text
.replaceAll(insertedLineBreakProxyChar, lineBreakChar + '\n')
.replaceAll(deletedLineBreakProxyChar, lineBreakChar),
}));
}

// Restore the original inline nodes
return diffTexts.flatMap((t) => inlineNodeCharMap.textToNode(t));
}

interface LineBreakCharsOptions {
insertedLineBreakChar?: string;
deletedLineBreakChar?: string;
}

// Function to compute the text operations needed to transform string `a` into string `b`
function slateTextDiff(a: string, b: string): Op[] {
function slateTextDiff(
a: string,
b: string,
{ insertedLineBreakChar, deletedLineBreakChar }: LineBreakCharsOptions
): Op[] {
// Compute the diff between two strings
const diff = dmp.diff_main(a, b);
dmp.diff_cleanupSemantic(diff);
Expand All @@ -98,13 +140,27 @@ function slateTextDiff(a: string, b: string): Op[] {
}
case -1: {
// For deletions, add a remove_text operation
operations.push({ type: 'remove_text', offset, text });
operations.push({
type: 'remove_text',
offset,
text:
deletedLineBreakChar === undefined
? text
: text.replaceAll('\n', deletedLineBreakChar),
});

break;
}
case 1: {
// For insertions, add an insert_text operation
operations.push({ type: 'insert_text', offset, text });
operations.push({
type: 'insert_text',
offset,
text:
insertedLineBreakChar === undefined
? text
: text.replaceAll('\n', insertedLineBreakChar),
});
// Move the offset forward by the length of the inserted text
offset += text.length;

Expand All @@ -128,7 +184,11 @@ via a combination of remove_text/insert_text as above and split_node
operations.
*/
// Function to split a single text node into multiple nodes based on the desired target state
function splitTextNodes(node: TText, split: TText[]): TOperation[] {
function splitTextNodes(
node: TText,
split: TText[],
options: LineBreakCharsOptions
): TOperation[] {
if (split.length === 0) {
// If there are no target nodes, simply remove the original node
return [
Expand All @@ -153,7 +213,7 @@ function splitTextNodes(node: TText, split: TText[]): TOperation[] {
// Use diff-match-pach to transform the text in the source node to equal
// the text in the sequence of target nodes. Once we do this transform,
// we can then worry about splitting up the resulting source node.
for (const op of slateTextDiff(nodeText, splitText)) {
for (const op of slateTextDiff(nodeText, splitText, options)) {
// TODO: maybe path has to be changed if there are multiple OPS?
operations.push({ path: [0, 0], ...op });
}
Expand Down
8 changes: 6 additions & 2 deletions packages/diff/src/internal/utils/inline-node-char-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ describe('InlineNodeCharMap', () => {
let map: InlineNodeCharMap;

beforeEach(() => {
map = new InlineNodeCharMap({ unavailableChars: 'ABCDEFG' });
const charGenerator: Generator<string> = (function* () {
yield* 'HI';
})();

map = new InlineNodeCharMap({ charGenerator });
});

describe('nodeToText', () => {
it('should replace inline nodes with unused chars', () => {
it('should replace inline nodes with generated chars', () => {
const inline1 = { type: 'inline1', children: [{ text: '' }] };
const inline2 = { type: 'inline2', children: [{ text: '' }] };
const text1 = map.nodeToText(inline1);
Expand Down
Loading

0 comments on commit 07a8375

Please sign in to comment.