diff --git a/packages/diff/src/computeDiff.spec.ts b/packages/diff/src/computeDiff.spec.ts index 54449e5ab7..69902b37a8 100644 --- a/packages/diff/src/computeDiff.spec.ts +++ b/packages/diff/src/computeDiff.spec.ts @@ -1393,7 +1393,11 @@ const fixtures: Record = { input2: [ { type: 'paragraph', - children: [{ text: 'Ping\nCode' }], + children: [ + { text: 'Ping\nCo' }, + { text: 'd', bold: true }, + { text: 'e' }, + ], }, ], expected: [ @@ -1402,7 +1406,18 @@ const fixtures: Record = { children: [ { text: 'Ping' }, { text: 'ΒΆ\n', diff: true, diffOperation: { type: 'insert' } }, - { text: 'Code' }, + { text: 'Co' }, + { + text: 'd', + bold: true, + diff: true, + diffOperation: { + type: 'update', + properties: { bold: undefined }, + newProperties: { bold: true }, + }, + }, + { text: 'e', bold: undefined }, ], }, ], diff --git a/packages/diff/src/internal/transforms/transformDiffTexts.ts b/packages/diff/src/internal/transforms/transformDiffTexts.ts index 8f69b2790d..ad4ee3d6b8 100644 --- a/packages/diff/src/internal/transforms/transformDiffTexts.ts +++ b/packages/diff/src/internal/transforms/transformDiffTexts.ts @@ -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 @@ -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)); @@ -58,24 +78,42 @@ export function transformDiffTexts( } // After merging, apply split operations based on the target state (`nextTexts`) - for (const op of splitTextNodes(node, nextTexts, options)) { + 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, - { lineBreakChar }: ComputeDiffOptions + { insertedLineBreakChar, deletedLineBreakChar }: LineBreakCharsOptions ): Op[] { // Compute the diff between two strings const diff = dmp.diff_main(a, b); @@ -106,9 +144,9 @@ function slateTextDiff( type: 'remove_text', offset, text: - lineBreakChar === undefined + deletedLineBreakChar === undefined ? text - : text.replaceAll('\n', lineBreakChar), + : text.replaceAll('\n', deletedLineBreakChar), }); break; @@ -119,9 +157,9 @@ function slateTextDiff( type: 'insert_text', offset, text: - lineBreakChar === undefined + insertedLineBreakChar === undefined ? text - : text.replaceAll('\n', lineBreakChar + '\n'), + : text.replaceAll('\n', insertedLineBreakChar), }); // Move the offset forward by the length of the inserted text offset += text.length; @@ -149,7 +187,7 @@ operations. function splitTextNodes( node: TText, split: TText[], - options: ComputeDiffOptions + options: LineBreakCharsOptions ): TOperation[] { if (split.length === 0) { // If there are no target nodes, simply remove the original node diff --git a/packages/diff/src/internal/utils/inline-node-char-map.spec.ts b/packages/diff/src/internal/utils/inline-node-char-map.spec.ts index db6f910325..8baeed230b 100644 --- a/packages/diff/src/internal/utils/inline-node-char-map.spec.ts +++ b/packages/diff/src/internal/utils/inline-node-char-map.spec.ts @@ -4,11 +4,15 @@ describe('InlineNodeCharMap', () => { let map: InlineNodeCharMap; beforeEach(() => { - map = new InlineNodeCharMap({ unavailableChars: 'ABCDEFG' }); + const charGenerator: Generator = (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); diff --git a/packages/diff/src/internal/utils/inline-node-char-map.ts b/packages/diff/src/internal/utils/inline-node-char-map.ts index 3dca86b8de..0785db4225 100644 --- a/packages/diff/src/internal/utils/inline-node-char-map.ts +++ b/packages/diff/src/internal/utils/inline-node-char-map.ts @@ -6,22 +6,17 @@ import { } from '@udecode/plate-common'; export class InlineNodeCharMap { - private _nextChar: string = 'A'; + private _charGenerator: Generator; private _charToNode: Map = new Map(); - private _unavailableChars: string; - constructor({ - unavailableChars = '', - }: { - unavailableChars?: string; - } = {}) { - this._unavailableChars = unavailableChars; + constructor({ charGenerator }: { charGenerator: Generator }) { + this._charGenerator = charGenerator; } // Replace non-text nodes with a text node containing a unique char public nodeToText(node: TDescendant): TText { if (isText(node)) return node; - const c = this.getUnusedChar(); + const c = this._charGenerator.next().value; this._charToNode.set(c, node); return { text: c }; } @@ -37,18 +32,6 @@ export class InlineNodeCharMap { return outputNodes; } - private getUnusedChar() { - while (true) { - this._nextChar = String.fromCodePoint(this._nextChar.codePointAt(0)! + 1); - - if (!this._unavailableChars.includes(this._nextChar)) { - break; - } - } - - return this._nextChar; - } - private replaceCharWithNode( haystack: TDescendant[], needle: string, diff --git a/packages/diff/src/internal/utils/unused-char-generator.spec.ts b/packages/diff/src/internal/utils/unused-char-generator.spec.ts new file mode 100644 index 0000000000..12d26f95a2 --- /dev/null +++ b/packages/diff/src/internal/utils/unused-char-generator.spec.ts @@ -0,0 +1,12 @@ +import { unusedCharGenerator } from './unused-char-generator'; + +describe('unusedCharGenerator', () => { + it('should generate unique chars that are not skipped', () => { + const generator = unusedCharGenerator({ + skipChars: 'DO NOT USE ANY OF THESE CHARS', + }); + + const chars = Array.from({ length: 10 }, () => generator.next().value); + expect(chars).toEqual(['B', 'G', 'I', 'J', 'K', 'L', 'M', 'P', 'Q', 'V']); + }); +}); diff --git a/packages/diff/src/internal/utils/unused-char-generator.ts b/packages/diff/src/internal/utils/unused-char-generator.ts new file mode 100644 index 0000000000..ccd929bb14 --- /dev/null +++ b/packages/diff/src/internal/utils/unused-char-generator.ts @@ -0,0 +1,15 @@ +export interface UnusedCharGeneratorOptions { + skipChars?: string; +} + +export function* unusedCharGenerator({ + skipChars = '', +}: UnusedCharGeneratorOptions = {}): Generator { + const skipSet = new Set(skipChars); + + for (let code = 'A'.codePointAt(0)!; ; code++) { + const c = String.fromCodePoint(code); + if (skipSet.has(c)) continue; + yield c; + } +}