diff --git a/docs/text-diff/Wu_1990_6334.pdf b/docs/text-diff/Wu_1990_6334.pdf new file mode 100644 index 0000000..1cf512b Binary files /dev/null and b/docs/text-diff/Wu_1990_6334.pdf differ diff --git a/docs/text-diff/myers.pdf b/docs/text-diff/myers.pdf new file mode 100644 index 0000000..439cae8 Binary files /dev/null and b/docs/text-diff/myers.pdf differ diff --git a/package.json b/package.json index 95d0f32..9030417 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ }, "scripts": { "test": "jest", + "test:watch": "jest --watch", "test:debug": "node --inspect-brk ./node_modules/jest/bin/jest.js --runInBand", "build": "rollup --config rollup.config.ts --configPlugin typescript", "prepare": "husky install" diff --git a/readme.md b/readme.md index 70688c0..a0f3406 100644 --- a/readme.md +++ b/readme.md @@ -76,11 +76,12 @@ render( ## Caveats - 1. Currently the Y Text shared type is not supported. This means that strings - in the store do not benefit from the conflict-resolution performed by Yjs. 1. The Yjs awareness protocol is not supported. At the moment, it is unclear if the library is able to support Yjs protocols. This means that, for now, support for the awareness protocol is not planned. + * This does not mean you cannot use awareness in your projects - see the + sister project [y-react](joebobmiles/y-react) for an example of using + awareness without the middleware. # License diff --git a/src/diff.spec.ts b/src/diff.spec.ts index 1a0347d..0178eb2 100644 --- a/src/diff.spec.ts +++ b/src/diff.spec.ts @@ -1,276 +1,447 @@ -import { diff, } from "./diff"; +import { ChangeType, } from "./types"; +import { getChanges, } from "./diff"; -describe("diff", () => +describe.only("getChanges", () => { - describe("When passed scalar values", () => + describe("When given objects", () => { - it("Returns undefined for two identical numbers.", () => - { - expect(diff(1, 1)).toBeUndefined(); - }); - - it("Returns undefined for two identical strings.", () => - { - expect(diff("hello", "hello")).toBeUndefined(); - }); - - // See GitHub Issue #32 it.each([ - null, - undefined - ])("Returns undefined for two null/undefined values.", (nonValue) => + [ {} ], + [ { "foo": 1, } ], + [ { "foo": null, } ], // See GitHub Issue #32 + [ { "foo": undefined, } ], // See GitHub Issue #32 + [ { "foo": { "bar": 1, }, } ] + ])("Returns an empty list for two identical objects.", (a) => { - expect(diff(nonValue, nonValue)).toBeUndefined(); + expect(getChanges(a, a)).toStrictEqual([]); }); - it( - "Returns { __old: , __new: } for different values.", - () => - { - expect(diff(1, 2)).toEqual({ - "__old": 1, - "__new": 2, - }); - } - ); - }); - - describe("When passed objects", () => - { it.each([ - 1, - // See GitHub Issue #32 - null, - undefined - ])("Returns undefined for two objects with identical contents.", (value) => - { - expect(diff({ "foo": value, }, { "foo": value, })).toBeUndefined(); - }); - - it("Returns undefined for two objects with identical hierarchies.", () => - { - expect(diff( - { "foo": { "bar": 1, }, }, - { "foo": { "bar": 1, }, } - )).toBeUndefined(); - }); - - it("Returns { __deleted: } when b is missing .", () => - { - expect(diff( + [ + {}, + { "foo": 1, }, + [ + [ ChangeType.INSERT, "foo", 1 ] + ] + ], + [ + { "foo": 1, }, + {}, + [ + [ ChangeType.DELETE, "foo", undefined ] + ] + ], + [ { "foo": 1, }, - { } - )).toEqual({ - "foo__deleted": 1, - }); + { "foo": 2, }, + [ + [ ChangeType.UPDATE, "foo", 2 ] + ] + ], + [ + { "foo": 1, }, + { "bar": 1, }, + [ + [ ChangeType.DELETE, "foo", undefined ], + [ ChangeType.INSERT, "bar", 1 ] + ] + ], + [ + { "foo": 1, "bar": 3, }, + { "foo": 1, "bar": 2, }, + [ + [ ChangeType.UPDATE, "bar", 2 ] + ] + ], + [ + { "foo": 1, }, + { "foo": "a", }, + [ + [ ChangeType.UPDATE, "foo", "a" ] + ] + ], + [ + { "foo": "a", }, + { "foo": "", }, + [ + [ + ChangeType.PENDING, + "foo", + [ + [ ChangeType.DELETE, 0, undefined ] + ] + ] + ] + ], + [ + { "foo": "a", }, + { "foo": "b", }, + [ + [ + ChangeType.PENDING, + "foo", + [ + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.INSERT, 0, "b" ] + ] + ] + ] + ], + [ + { "foo": "ab", }, + { "foo": "bc", }, + [ + [ + ChangeType.PENDING, + "foo", + [ + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.INSERT, 1, "c" ] + ] + ] + ] + ], + [ + { "foo": [ 1 ], }, + { "foo": [ 2 ], }, + [ + [ + ChangeType.PENDING, + "foo", + [ + [ ChangeType.UPDATE, 0, 2 ] + ] + ] + ] + ], + [ + { "foo": [ 1, 2 ], }, + { "foo": [ 2, 2 ], }, + [ + [ + ChangeType.PENDING, + "foo", + [ + [ ChangeType.UPDATE, 0, 2 ] + ] + ] + ] + ], + [ + { "foo": [ 1, 2, 2 ], }, + { "foo": [ 1, 2, 3 ], }, + [ + [ + ChangeType.PENDING, + "foo", + [ + [ ChangeType.UPDATE, 2, 3 ] + ] + ] + ] + ], + [ + { "foo": [ 1, 2, 2 ], }, + { "foo": [ 1, 2 ], }, + [ + [ + ChangeType.PENDING, + "foo", + [ + [ ChangeType.DELETE, 2, undefined ] + ] + ] + ] + ] + ])("Generates a change list for objects", (a, b, changes) => + { + expect(getChanges(a, b)).toStrictEqual(changes); }); - it("Returns { __added: } when a is missing .", () => + it("Ignores properties whose values are functions", () => { - expect(diff( - { }, - { "foo": 1, } - )).toEqual({ - "foo__added": 1, - }); - }); + const a = { + "foo": () => + 1, + }; - it( - "Returns { : { __old: , __new: } } when a and b have " - +"different values.", - () => - { - expect(diff( - { "foo": 1, }, - { "foo": 2, } - )).toEqual({ - "foo": { - "__old": 1, - "__new": 2, - }, - }); - } - ); + const b = {}; - it("Only returns fields that have changed.", () => - { - expect(diff( - { "foo": 1, "bar": 2, }, - { "foo": 1, "bar": 3, } - )).toEqual({ - "bar": { - "__old": 2, - "__new": 3, - }, - }); + expect(getChanges(a, b)).toStrictEqual([]); }); }); - describe("When passed arrays of scalar values", () => + describe("When given arrays", () => { it.each([ [ [ 1, 2, 3 ] ], - // See GitHub Issue #32 - [ [ null, null, null ] ], - [ [ undefined, undefined, undefined ] ] - ])("Returns undefined for arrays with identical contents.", (array) => - { - expect(diff(array, array)).toBeUndefined(); - }); - - it( - "Returns [ ..., [ '-', ], ... ] when b is missing a value.", - () => - { - expect(diff([ 1, 2, 3 ], [ 1, 2 ])).toEqual([ - [ " ", 1 ], - [ " ", 2 ], - [ "-", 3 ] - ]); - } - ); - - it( - "Returns [ ..., [ '+', ], ... ] when a is missing a value.", - () => - { - expect(diff([ 1, 3 ], [ 1, 2, 3 ])).toEqual([ - [ " ", 1 ], - [ "+", 2 ], - [ " ", 3 ] - ]); - } - ); - - it( - "Returns [ ..., [ '-', ], [ '+', ], ... ] for replaced " - +"values.", - () => - { - expect(diff([ 1 ], [ 2 ])).toEqual([ [ "-", 1 ], [ "+", 2 ] ]); - } - ); - - it("Does not forget additions at the end of b.", () => + [ [ null, null, null ] ], // See GitHub Issue #32 + [ [ { "foo": 1, } ] ] + ])("Returns an empty list for identical arrays", (a) => { - expect(diff([ 1 ], [ 2, 3 ])).toEqual([ - [ "-", 1 ], - [ "+", 2 ], - [ "+", 3 ] - ]); + expect(getChanges(a, a)).toStrictEqual([]); }); - it( - "Returns [ ..., [ '+', ] ] when a is missing a value.", - () => - { - expect(diff([ 1, 2 ], [ 1, 2, 3 ])).toEqual([ - [ " ", 1 ], - [ " ", 2 ], - [ "+", 3 ] - ]); - } - ); - - it("Does not duplicate unchanged value if it is last in the array.", () => + it.each([ + [ + [ 1, 2, 3 ], + [ 1, 2 ], + [ + [ ChangeType.DELETE, 2, undefined ] + ] + ], + [ + [ 1, 2 ], + [ 1, 2, 3 ], + [ + [ ChangeType.INSERT, 2, 3 ] + ] + ], + [ + [ 1, 3 ], + [ 1, 2, 3 ], + [ + [ ChangeType.INSERT, 1, 2 ] + ] + ], + [ + [ 0, 2, 3 ], + [ 1, 2, 3 ], + [ + [ ChangeType.UPDATE, 0, 1 ] + ] + ], + /* + * This is an edge case in how we perform change detection. + * + * In this case, A contains a repeated sequence of digits that is not in + * B. This confuses the look ahead, which, in order to detect an update, + * looks to the next value in B to see if the value found in A has just + * moved. + * + * When it sees that A's position 1, with a value of 3, is not the same as + * B's position 1 (with a value of 2), the look ahead checks to see if + * B's position 2 is the same as A's position 1. It is, so the algorithm + * assumes that an insertion took place in B. + * + * This insertion causes an increase in the indexing offset for B. When + * that happens, the next iteration is looking at B position 3 (does not + * exist) instead of position 3. Because B position 3 does not exist, it + * is assumed that the duplicate value was deleted in B. + * + * As far as I know, there's no way around this. One option is that we + * could increase the look ahead. But by doing that, we change the minimum + * length of the sequence this happens with. If we added, say, a look + * ahead of two positions, we'd eliminate the issue with values repeated + * twice, but not for values repeated three times. + * + * Another option is to retroactively recognize a repeated sequence and + * then correct the previous insertion to an update when we try to delete + * the end of the sequence. However, this has other issues, such as the + * ambiguity about what to do when an update happens at the beginning of + * a repeated sequence and a delete happens at the end. That could be + * construed as an insert at the beginning and two deletes at the end. + * + * At the end of the day, a correct transformation is better than a + * 'correct' change list. + */ + [ + [ 1, 3, 3 ], + [ 1, 2, 3 ], + [ + [ ChangeType.INSERT, 1, 2 ], + [ ChangeType.DELETE, 2, undefined ] + ] + ], + [ + [ { "foo": 1, } ], + [ { "foo": 1, }, { "bar": 2, } ], + [ [ ChangeType.INSERT, 1, { "bar": 2, } ] ] + ], + [ + [ { "foo": 1, } ], + [ { "foo": 2, }, { "foo": 1, } ], + [ + [ ChangeType.INSERT, 0, { "foo": 2, } ] + ] + ], + [ + [ { "foo": 1, }, { "foo": 2, } ], + [ { "foo": 0, }, { "foo": 1, }, { "foo": 2, } ], + [ + [ ChangeType.INSERT, 0, { "foo": 0, } ] + ] + ], + [ + [ { "foo": 1, }, { "foo": 2, } ], + [ { "foo": 1, }, { "foo": 1, }, { "foo": 2, } ], + [ + [ ChangeType.INSERT, 0, { "foo": 1, } ] + ] + ], + [ + [ { "foo": 1, }, { "foo": 2, }, { "foo": 3, } ], + [ { "foo": 1, }, { "foo": 2, }, { "foo": 2, }, { "foo": 3, } ], + [ + [ ChangeType.INSERT, 1, { "foo": 2, } ] + ] + ], + [ + [ { "foo": 1, } ], + [ { "foo": 0, } ], + [ + [ + ChangeType.PENDING, + 0, + [ + [ ChangeType.UPDATE, "foo", 0 ] + ] + ] + ] + ], + [ + [ "a" ], + [ "" ], + [ + [ + ChangeType.PENDING, + 0, + [ + [ ChangeType.DELETE, 0, undefined ] + ] + ] + ] + ], + [ + [ "ab" ], + [ "bc" ], + [ + [ + ChangeType.PENDING, + 0, + [ + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.INSERT, 1, "c" ] + ] + ] + ] + ] + ])("Returns a change list for arrays", (a, b, changes) => { - expect(diff( - [ 2 ], - [ 1, 2 ] - )) - .toEqual([ - [ "+", 1 ], - [ " ", 2 ] - ]); + expect(getChanges(a, b)).toStrictEqual(changes); }); - - it( - "Does not duplicate an unchanged element when a new element is inserted " - +"before it.", - () => - { - expect(diff( - [ 1, 2 ], - [ 0, 1, 2 ] - )) - .toEqual([ - [ "+", 0 ], - [ " ", 1 ], - [ " ", 2 ] - ]); - } - ); }); - describe("When passed arrays with nested objects", () => + describe("When given strings", () => { - it("Returns undefined for arrays of identical contents.", () => + it.each([ + [ "", "" ], + [ "a", "a" ], + [ "hello, world!", "hello, world!" ] + ])("Returns undefined for identical sequences", (a, b) => { - expect(diff([ { "foo": 1, } ], [ { "foo": 1, } ])).toBeUndefined(); + expect(getChanges(a, b)).toStrictEqual([]); }); - it( - "Returns [ ..., [ '-', ], ... ] when b is missing an item.", - () => - { - expect(diff([ { "foo": 1, }, { "bar": 2, } ], [ { "foo": 1, } ])) - .toEqual([ - [ " ", { "foo": 1, } ], - [ "-", { "bar": 2, } ] - ]); - } - ); - - it( - "Returns [ ..., [ '+', ], ... ] when a is missing an item.", - () => - { - expect(diff([ { "foo": 1, } ], [ { "foo": 1, }, { "bar": 2, } ])) - .toEqual([ - [ " ", { "foo": 1, } ], - [ "+", { "bar": 2, } ] - ]); - } - ); - - it( - "Returns [ ..., [ '~', ], ... ] when the item is modified.", - () => - { - expect(diff([ { "foo": 1, } ], [ { "foo": 2, } ])) - .toEqual([ - [ "~", { "foo": { "__old": 1, "__new": 2, }, } ] - ]); - } - ); - - it("Does not duplicate existing object if it is last in the array.", () => + it.each([ + [ "a", "", [ [ ChangeType.DELETE, 0, undefined ] ] ], + [ "", "a", [ [ ChangeType.INSERT, 0, "a" ] ] ], + [ "a", "ab", [ [ ChangeType.INSERT, 1, "b" ] ] ], + [ "ab", "a", [ [ ChangeType.DELETE, 1, undefined ] ] ], + [ + "ab", + "ac", + [ + [ ChangeType.DELETE, 1, undefined ], + [ ChangeType.INSERT, 1, "c" ] + ] + ], + [ + "ac", + "bc", + [ + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.INSERT, 0, "b" ] + ] + ], + [ + "ab", + "", + [ + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.DELETE, 0, undefined ] + ] + ], + [ + "", + "ab", + [ + [ ChangeType.INSERT, 0, "a" ], + [ ChangeType.INSERT, 1, "b" ] + ] + ], + // No common subsequence test cases. + [ + "a", + "b", + [ + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.INSERT, 0, "b" ] + ] + ], + [ + "ab", + "cd", + [ + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.DELETE, 0, undefined ], + [ ChangeType.INSERT, 0, "c" ], + [ ChangeType.INSERT, 1, "d" ] + ] + ] + ])("Returns a change tuple for sequences that are different", (a, b, diff) => { - expect(diff( - [ { "foo": 1, } ], - [ { "foo": 2, }, { "foo": 1, } ] - )) - .toEqual([ - [ "+", { "foo": 2, } ], - [ " ", { "foo": 1, } ] - ]); + expect(getChanges(a, b)).toStrictEqual(diff); }); - it( - "Does not duplicate an unchanged element when a new element is inserted " - +"before it.", - () => - { - expect(diff( - [ { "foo": 1, }, { "foo": 2, } ], - [ { "foo": 0, }, { "foo": 1, }, { "foo": 2, } ] - )) - .toEqual([ - [ "+", { "foo": 0, } ], - [ " ", { "foo": 1, } ], - [ " ", { "foo": 2, } ] - ]); - } - ); + it.each([ + [ + "hello", + "goodbye", + [ + [ ChangeType.INSERT, 0, "g" ], + [ ChangeType.INSERT, 1, "o" ], + [ ChangeType.INSERT, 2, "o" ], + [ ChangeType.INSERT, 3, "d" ], + [ ChangeType.INSERT, 4, "b" ], + [ ChangeType.INSERT, 5, "y" ], + [ ChangeType.DELETE, 6, undefined ], + [ ChangeType.DELETE, 7, undefined ], + [ ChangeType.DELETE, 7, undefined ], + [ ChangeType.DELETE, 7, undefined ] + ] + ], + [ + "hello, world!", + "goodbye, world.", + [ + [ ChangeType.INSERT, 0, "g" ], + [ ChangeType.INSERT, 1, "o" ], + [ ChangeType.INSERT, 2, "o" ], + [ ChangeType.INSERT, 3, "d" ], + [ ChangeType.INSERT, 4, "b" ], + [ ChangeType.INSERT, 5, "y" ], + [ ChangeType.DELETE, 6, undefined ], + [ ChangeType.DELETE, 7, undefined ], + [ ChangeType.DELETE, 7, undefined ], + [ ChangeType.DELETE, 7, undefined ], + [ ChangeType.INSERT, 14, "." ], + [ ChangeType.DELETE, 15, undefined ] + ] + ] + ])("Adjusts indices to account for previous changes.", (a, b, diff) => + { + expect(getChanges(a, b)).toStrictEqual(diff); + }); }); }); \ No newline at end of file diff --git a/src/diff.ts b/src/diff.ts index 003c60a..d7b8e9f 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -1,128 +1,324 @@ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export const diff = (a: any, b: any): any => +import { ChangeType, Change, } from "./types"; + +type Diffable = Record | Array | string; + +const isDiffable = (v: any): v is Diffable => + isArray(v) || isString(v) || v instanceof Object; + +const isArray = (d: Diffable): d is Array => + d instanceof Array; + +const isString = (d: Diffable): d is string => + typeof d === "string"; + +const isRecord = (d: Diffable): d is Record => + !isArray(d) && !isString(d); + +export const getChanges = (a: Diffable, b: Diffable): Change[] => +{ + if (isString(a) && isString(b)) + return getStringChanges(a, b); + else if (isArray(a) && isArray(b)) + return getArrayChanges(a, b); + else if (isRecord(a) && isRecord(b)) + return getRecordChanges(a, b); + else + return []; +}; + +const getStringChanges = (a: string, b: string): Change[] => { - if (!Object.is(a, b)) + if (a === b) + return []; + else if (a.length === 0) { - if (a instanceof Array && b instanceof Array) - { + return b.split("").map((character, index) => + [ ChangeType.INSERT, index, character ]); + } + else if (b.length === 0) + { + return a.split("").map(() => + [ ChangeType.DELETE, 0, undefined ]); + } + else if (!hasCommonSubsequence(a, b)) + { + const deletes = a.split("").map(() => + [ ChangeType.DELETE, 0, undefined ]); - const result: any[] = []; + const inserts = b.split("").map((character, index) => + [ ChangeType.INSERT, index, character ]); - if (a.length === b.length) - { - if (a.every((value, index) => - diff(value, b[index]) === undefined)) - return undefined; - } + return deletes.concat(inserts); + } + else + { + const m = a.length, n = b.length; + const reverse = m >= n; - let finalIndices = 0; - let bOffset = 0; + return reverse + ? _diffText(b, a, reverse) + : _diffText(a, b, reverse); + } +}; - for (let index = 0; index < a.length; index++) - { - const value = a[index]; +const getArrayChanges = (a: Array, b: Array): Change[] => +{ + const changeList: Change[] = []; - const bIndex = index + bOffset; + let finalIndices = 0; + let bOffset = 0; - if (b[bIndex] === undefined) - result.push([ "-", value ]); + for (let index = 0; index < a.length; index++) + { + const value = a[index]; - else if (value instanceof Object && b[bIndex] instanceof Object) - { - const currentDiff = diff(value, b[bIndex]); - const nextDiff = diff(value, b[bIndex+1]); - - if (currentDiff !== undefined && nextDiff === undefined) - { - result.push([ "+", b[bIndex] ], [ " ", value ]); - finalIndices += 2; - bOffset++; - } - - else if (currentDiff !== undefined) - { - result.push([ "~", currentDiff ]); - finalIndices++; - } - - else - { - result.push([ " ", value ]); - finalIndices++; - } - } + const bIndex = index + bOffset; - else if (value !== b[bIndex] && value === b[bIndex+1]) - { - result.push([ "+", b[bIndex] ], [ " ", value ]); - finalIndices += 2; - bOffset++; - } + if (b[bIndex] === undefined) + changeList.push([ ChangeType.DELETE, index, undefined ]); - else if (value !== b[bIndex] && value !== b[bIndex+1]) - { - result.push([ "-", value ], [ "+", b[bIndex] ]); - finalIndices++; - } + else if (isDiffable(value) && isDiffable(b[bIndex])) + { + const currentDiff = getChanges(value, b[bIndex]); + const nextDiff = typeof b[bIndex + 1] === "undefined" + ? [] + : getChanges(value, b[bIndex+1]); - else - { - result.push([ " ", value ]); - finalIndices++; - } + if (typeof b[bIndex+1] !== "undefined" && nextDiff.length === 0) + { + changeList.push([ ChangeType.INSERT, index, b[bIndex] ]); + finalIndices += 2; + bOffset++; } - if (finalIndices < b.length) + else if (currentDiff.length !== 0) { - b.slice(a.length).forEach((value) => - result.push([ "+", value ])); + changeList.push([ ChangeType.PENDING, index, currentDiff ]); + finalIndices++; } - return result; + else + finalIndices++; } - else if (a instanceof Object && b instanceof Object) + + else if (value !== b[bIndex] && value === b[bIndex+1]) { - const result: any = {}; + changeList.push([ ChangeType.INSERT, bIndex, b[bIndex] ]); + finalIndices += 2; + bOffset++; + } - Object.entries(b).forEach(([ property, value ]) => - { - if (!(property in a)) - result[`${property}__added`] = value; + else if (value !== b[bIndex] && value !== b[bIndex+1]) + { + changeList.push([ ChangeType.UPDATE, bIndex, b[bIndex] ]); + finalIndices++; + } + + else + finalIndices++; + } - else if (a[property] instanceof Object && value instanceof Object) + if (finalIndices < b.length) + { + b.slice(a.length).forEach((value, index) => + changeList.push([ ChangeType.INSERT, finalIndices + index, value ])); + } + + return changeList; +}; + +const getRecordChanges = ( + a: Record, + b: Record +): Change[] => +{ + const changeList: Change[] = []; + + Object.entries(a).forEach(([ property, value ]) => + { + if (!(property in b) && !(value instanceof Function)) + changeList.push([ ChangeType.DELETE, property, undefined ]); + }); + + Object.entries(b).forEach(([ property, value ]) => + { + if (!(property in a)) + changeList.push([ ChangeType.INSERT, property, value ]); + + else if (isDiffable(a[property]) && isDiffable(value)) + { + const d = getChanges(a[property], value); + + if (d.length !== 0) + changeList.push([ ChangeType.PENDING, property, d ]); + } + + else if (a[property] !== value) + changeList.push([ ChangeType.UPDATE, property, value ]); + }); + + return changeList; +}; + +const hasCommonSubsequence = (a: string, b: string) => +{ + const alphabetOfA = a.split(""); + const alphabetOfB = b.split(""); + + let hasCommonSubsequence = false; + for (const c of alphabetOfA) + hasCommonSubsequence = hasCommonSubsequence || alphabetOfB.includes(c); + + return hasCommonSubsequence; +}; + +/** + * An adaptation of Wu et al. O(NP) text diff. (See docs/text-diff) + * + * Credit to [this JavaScript implementation](https://github.com/cubicdaiya/onp/blob/master/javascript/onp.js). + * + * @param a The old string to transform. + * @param b The new string to transform to. + * @param isReversed Whether or not a or b have been swapped. + * @returns A list of changes that that turn a into b. + */ +const _diffText = (a: string, b: string, isReversed: boolean): Change[] => +{ + const m = a.length, n = b.length; + const offset = m; + const delta = n - m; + const size = m + n + 1; + + const frontierPoints: number[] = []; + for (let i = 0; i < size; i++) frontierPoints[i] = -1; + + const path: number[] = []; + for (let i = 0; i < size; i++) path[i] = -1; + + const pathPositions: { x: number, y: number, k: number }[] = []; + + const snake = (k: number, p: number, q: number) => + { + let y = Math.max(p, q); + let x = y - k; + + while (x < m && y < n && a[x] === b[y]) + { + x++; y++; + } + + path[k + offset] = pathPositions.length; + pathPositions[pathPositions.length] = { + "x": x, + "y": y, + "k": p > q ? path[k + offset - 1] : path[k + offset + 1], + }; + + return y; + }; + + let p = -1; + do + { + p++; + + for (let k = -p; k < delta; k++) + { + frontierPoints[k + offset] = snake( + k, + frontierPoints[k + offset - 1] + 1, + frontierPoints[k + offset + 1] + ); + } + + for (let k = delta + p; k > delta; k--) + { + frontierPoints[k + offset] = snake( + k, + frontierPoints[k + offset - 1] + 1, + frontierPoints[k + offset + 1] + ); + } + + frontierPoints[delta + offset] = snake( + delta, + frontierPoints[delta + offset - 1] + 1, + frontierPoints[delta + offset + 1] + ); + } while (frontierPoints[delta + offset] !== n); + + let k = path[delta + offset]; + + const editPath: { x: number, y: number }[] = []; + while (k !== -1) + { + editPath[editPath.length] = { + "x": pathPositions[k].x, + "y": pathPositions[k].y, + }; + + k = pathPositions[k].k; // eslint-disable-line prefer-destructuring + } + + const changeList: Change[] = []; + let x = 0, y = 0, index = -1; + + for (let i = editPath.length - 1; i >= 0; i--) + { + while (x <= editPath[i].x || y <= editPath[i].y) + { + if (editPath[i].y - editPath[i].x > y - x) + { + if (isReversed) + { + changeList[changeList.length] = [ + ChangeType.DELETE, + index, + undefined + ]; + } + else { - const d = diff(a[property], value); + changeList[changeList.length] = [ + ChangeType.INSERT, + index, + b[y - 1] + ]; - if (d !== undefined) - result[property] = d; + index++; } - else if (a[property] !== value) + y++; + } + else if (editPath[i].y - editPath[i].x < y - x) + { + if (isReversed) { - result[property] = - { - "__old": a[property], - "__new": value, - }; + changeList[changeList.length] = [ + ChangeType.INSERT, + index, + a[x - 1] + ]; + + index++; + } + else + { + changeList[changeList.length] = [ + ChangeType.DELETE, + index, + undefined + ]; } - }); - Object.entries(a).forEach(([ property, value ]) => + x++; + } + else { - if (!(property in b)) - result[`${property}__deleted`] = value; - }); - - return Object.entries(result).length === 0 ? undefined : result; - } - else if (a !== b) - { - return { - "__old": a, - "__new": b, - }; + x++; y++; index++; + } } } - else - return undefined; + + return changeList; }; \ No newline at end of file diff --git a/src/mapping.spec.ts b/src/mapping.spec.ts index 226c182..9b091ba 100644 --- a/src/mapping.spec.ts +++ b/src/mapping.spec.ts @@ -2,8 +2,10 @@ import * as Y from "yjs"; import { arrayToYArray, objectToYMap, + stringToYText, yArrayToArray, yMapToObject, + yTextToString, } from "./mapping"; describe("arrayToYArray", () => @@ -222,4 +224,39 @@ describe("objectToYMap and yMapToObject are inverses", () => expect(yMapToObject(ymap.get("map"))).toEqual(object); }); +}); + +describe("yTextToString", () => +{ + it.each([ + "hello", + "rawr", + "goodbye" + ])("Returns a string with the same content as the YText.", (string) => + { + const ydoc = new Y.Doc(); + const ytext = ydoc.getText("tmp"); + + ytext.insert(0, string); + + expect(yTextToString(ytext)).toEqual(string); + }); +}); + +describe("stringToYText", () => +{ + it.each([ + "hello", + "goodbye", + "wrong", + "running out of things to type" + ])("Returns a YText with the same content as the string", (string) => + { + const ydoc = new Y.Doc(); + const ymap = ydoc.getMap("tmp"); + + ymap.set("text", stringToYText(string)); + + expect(ymap.get("text").toString()).toEqual(string); + }); }); \ No newline at end of file diff --git a/src/mapping.ts b/src/mapping.ts index 632d294..a194646 100644 --- a/src/mapping.ts +++ b/src/mapping.ts @@ -10,8 +10,7 @@ import * as Y from "yjs"; * @example Nested arrays to nested YArrays. * arrayToYArray([ 1, [ 2, 3 ] ]).get(1).get(0) // => 2 * - * @example Object nested inside array to YMap nested in YArray. - * + * @example Object nested inside array to YMap nested in YArray. * arrayToYArray([ { foo: 1 } ]).get(0).get("foo") // => 1 * * @param array The array to transform into a YArray @@ -58,8 +57,7 @@ export const arrayToYArray = (array: any[]): Y.Array => * * yArrayToArray(yarray1) // => [ 1, [ 2, 3 ], 4 ] * - * @example Nested YMaps in YArrays are converted to objects nested - * in arrays. + * @example Nested YMaps in YArrays are converted to objects nested in arrays. * const ydoc = new Y.Doc(); * * const yarray = ydoc.getArray("array"); @@ -86,8 +84,7 @@ export const yArrayToArray = (yarray: Y.Array): any[] => * @example Nested objects to nested YMaps. * objectToYMap({ foo: { bar: 1 } }).get("foo").get("bar") // => 1 * - * @example Nested arrays in objects to nested YArrays in YMaps. - * + * @example Nested arrays in objects to nested YArrays in YMaps. * objectToYMap({ foo: [ 1, 2 ] }).get("foo").get(1) // => 2 * * @param object The object to turn into a YMap shared type. @@ -134,8 +131,7 @@ export const objectToYMap = (object: any): Y.Map => * * yMapToObject(ymap1) // => { foo: { bar: 1 } } * - * @example Nested arrays in objects are converted to YArrays nested in - * YMaps. + * @example Nested arrays in objects are converted to YArrays nested in YMaps. * const ydoc = new Y.Doc(); * * const ymap = ydoc.getMap("map"); @@ -151,3 +147,9 @@ export const objectToYMap = (object: any): Y.Map => */ export const yMapToObject = (ymap: Y.Map): any => ymap.toJSON(); + +export const yTextToString = (ytext: Y.Text): string => + ytext.toString(); + +export const stringToYText = (string: string): Y.Text => + new Y.Text(string); \ No newline at end of file diff --git a/src/patching.spec.ts b/src/patching.spec.ts index 976203a..d3c9961 100644 --- a/src/patching.spec.ts +++ b/src/patching.spec.ts @@ -2,129 +2,10 @@ import * as Y from "yjs"; import create from "zustand/vanilla"; import { arrayToYArray, objectToYMap, } from "./mapping"; import { - getChangeList, patchSharedType, patchStore, } from "./patching"; -describe("getChangeList", () => -{ - it.each([ - [ {} ], - [ { "foo": 1, } ], - [ [ 1 ] ], - // See GitHub Issue #32 - [ { "foo": null, } ], - [ { "foo": undefined, } ], - [ [ null ] ], - [ [ undefined ] ] - ])( - "Should create an empty array for two values that are identical.", - (value) => - { - expect(getChangeList(value, value)).toEqual([]); - } - ); - - it.each([ - [ - {}, - { "foo": 1, }, - [ "add", "foo", 1 ] - ], - [ - [], - [ 1 ], - [ "add", 0, 1 ] - ] - ])( - "Should create an add entry when b contains a new item. (#%#)", - (a, b, change) => - { - expect(getChangeList(a, b)).toContainEqual(change); - } - ); - - it("Should create an update entry when b contains a new value.", () => - { - expect(getChangeList( { "foo": 1, }, { "foo": 2, })) - .toEqual([ [ "update", "foo", 2 ] ]); - }); - - it("Should create an add and delete entry when an array changes.", () => - { - expect(getChangeList([ 1 ], [ 2 ])) - .toContainEqual([ "delete", 0, undefined ]); - - expect(getChangeList([ 1 ], [ 2 ])) - .toContainEqual([ "add", 0, 2 ]); - }); - - it( - "Should create a delete entry when b is missing a value.", - () => - { - expect(getChangeList({ "foo": 1, }, {})) - .toContainEqual([ "delete", "foo", undefined ]); - } - ); - - it.each([ - [ - { "foo": { "bar": 1, }, }, - { "foo": { "bar": 2, }, }, - [ "pending", "foo", undefined ] - ], - [ - { "foo": [ 1 ], }, - { "foo": [ 1, 2 ], }, - [ "pending", "foo", undefined ] - ], - [ - [ { "foo": 1, "bar": 3, } ], - [ { "foo": 2, "bar": 3, } ], - [ "pending", 0, undefined ] - ] - ])( - "Should create a pending entry when a and b have nested data. (#%#)", - (a, b, change) => - { - expect(getChangeList(a, b)) - .toContainEqual(change); - } - ); - - it.each([ - [ - { "foo": 1, "bar": 2, }, - { "foo": 1, "bar": 3, }, - [ "none", "foo", 1 ] - ], - [ - [ 1, 3 ], - [ 1, 2 ], - [ "none", 0, 1 ] - ], - // See GitHub Issue #32 - [ - { "foo": null, "bar": 2, }, - { "foo": null, "bar": 3, }, - [ "none", "foo", null ] - ], - [ - { "foo": undefined, "bar": 2, }, - { "foo": undefined, "bar": 3, }, - [ "none", "foo", undefined ] - ] - ])( - "Should create a 'none' change when a field does not change. (#%#)", - (a, b, change) => - { - expect(getChangeList(a, b)).toContainEqual(change); - } - ); -}); - describe("patchSharedType", () => { let ydoc: Y.Doc = new Y.Doc(); @@ -390,6 +271,58 @@ describe("patchSharedType", () => expect(ymap.get("state").get("foo")).toBe(1); }); + + it("Applies additions to text", () => + { + ymap.set("text", new Y.Text("a")); + patchSharedType(ymap.get("text"), "ab"); + + expect(ymap.get("text").toString()).toBe("ab"); + }); + + it("Applies deletions to text", () => + { + ymap.set("text", new Y.Text("ab")); + patchSharedType(ymap.get("text"), "a"); + + expect(ymap.get("text").toString()).toBe("a"); + }); + + it("Combines additions and deletions to text", () => + { + ymap.set("text", new Y.Text("ab")); + patchSharedType(ymap.get("text"), "bc"); + + expect(ymap.get("text").toString()).toBe("bc"); + }); + + it("Converts strings to YText in objects", () => + { + ymap.set("state", objectToYMap({ "foo": null, })); + patchSharedType( + ymap.get("state"), + { + "foo": "bar", + } + ); + + expect(ymap.get("state").get("foo")).toBeInstanceOf(Y.Text); + expect(ymap.get("state").get("foo") + .toString()).toBe("bar"); + }); + + it("Converts strings to YText in arrays", () => + { + ymap.set("state", arrayToYArray([])); + patchSharedType( + ymap.get("state"), + [ "bar" ] + ); + + expect(ymap.get("state").get(0)).toBeInstanceOf(Y.Text); + expect(ymap.get("state").get(0) + .toString()).toBe("bar"); + }); }); describe("patchStore", () => @@ -727,4 +660,28 @@ describe("patchStore", () => expect(store.getState()).toEqual({ "count": 0, }); }); + + it.each([ + [ "a", "b" ], + [ "a", "" ], + [ "ab", "cd" ], + [ "ab", "bc" ] + ])("Applies changes to strings", (a, b) => + { + type State = { + "string": string, + }; + + const store = create(() => + ({ + "string": a, + })); + + patchStore( + store, + { "string": b, } + ); + + expect(store.getState()).toEqual({ "string": b, }); + }); }); \ No newline at end of file diff --git a/src/patching.ts b/src/patching.ts index a973ec0..54a1fdc 100644 --- a/src/patching.ts +++ b/src/patching.ts @@ -1,102 +1,9 @@ import * as Y from "yjs"; -import { diff, } from "./diff"; -import { arrayToYArray, objectToYMap, } from "./mapping"; +import { ChangeType, Change, } from "./types"; +import { getChanges, } from "./diff"; +import { arrayToYArray, objectToYMap, stringToYText, } from "./mapping"; import { State, StoreApi, } from "zustand/vanilla"; -/** - * A record that documents a change to an entry in an array or object. - */ -export type Change = [ - "add" | "update" | "delete" | "pending" | "none", - string | number, - any -]; - -/** - * Computes a diff between a and b and creates a list of changes that transform - * a into b. This list of changes are only for the top level of a. Nested - * changes are denoted by a 'pending' entry, indicating that a change resolver - * will need to recurse in order to fully transform a into b. - * - * @param a The 'old' object to compare to the 'new' object. - * @param b The 'new' object to compare to the 'old' object. - * @returns A list of Changes that inform what is different between a and b. - */ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export const getChangeList = (a: any, b: any): Change[] => -{ - const delta = diff(a, b); - const changes: Change[] = []; - - if (delta instanceof Array) - { - let offset = 0; - - delta.forEach(([ type, value ], index) => - { - switch (type) - { - case "+": - if (0 < changes.length && changes[changes.length-1][0] === "delete") - offset--; - - changes.push([ "add", index + offset, value ]); - - break; - - case "-": - changes.push([ "delete", index + offset, undefined ]); - break; - - case "~": - changes.push([ "pending", index + offset, undefined ]); - break; - - case " ": - default: - changes.push([ "none", index + offset, value ]); - break; - } - }); - } - else if (delta instanceof Object) - { - Object.entries(a).forEach(([ property, value ]) => - { - const deltaDeletesFromA = Object.keys(delta).some((p) => - p === `${property}__deleted`); - - const deltaUpdatesA = Object.keys(delta).some((p) => - p === property); - - if (!deltaDeletesFromA && !deltaUpdatesA) - delta[property] = value; - }); - - (Object.entries({ ...delta, }) as [ string, any ]) - .forEach(([ property, value ]) => - { - if (property.match(/__added$/)) - changes.push([ "add", property.replace(/__added$/, ""), value ]); - - else if (property.match(/__deleted$/)) - changes.push([ "delete", property.replace(/__deleted$/, ""), undefined ]); - - // eslint-disable-next-line max-len - else if (value && value.__old !== undefined && value.__new !== undefined) - changes.push([ "update", property, value.__new ]); - - else if (value instanceof Object) - changes.push([ "pending", property, undefined ]); - - else - changes.push([ "none", property, value ]); - }); - } - - return changes; -}; - /** * Diffs sharedType and newState to create a list of changes for transforming * the contents of sharedType into that of newState. For every nested, 'pending' @@ -107,29 +14,29 @@ export const getChangeList = (a: any, b: any): Change[] => * @param newState The new state to patch the shared type into. */ export const patchSharedType = ( - sharedType: Y.Map | Y.Array, + sharedType: Y.Map | Y.Array | Y.Text, // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types newState: any ): void => { - const changes = getChangeList(sharedType.toJSON(), newState); + const changes = getChanges(sharedType.toJSON(), newState); changes.forEach(([ type, property, value ]) => { switch (type) { - case "add": - case "update": + case ChangeType.INSERT: + case ChangeType.UPDATE: if ((value instanceof Function) === false) { if (sharedType instanceof Y.Map) { - if (value instanceof Array) + if (typeof value === "string") + sharedType.set(property as string, stringToYText(value)); + else if (value instanceof Array) sharedType.set(property as string, arrayToYArray(value)); - else if (value instanceof Object) sharedType.set(property as string, objectToYMap(value)); - else sharedType.set(property as string, value); } @@ -138,20 +45,25 @@ export const patchSharedType = ( { const index = property as number; - if (type === "update") + if (type === ChangeType.UPDATE) sharedType.delete(index); - if (value instanceof Array) + if (typeof value === "string") + sharedType.insert(index, [ stringToYText(value) ]); + else if (value instanceof Array) sharedType.insert(index, [ arrayToYArray(value) ]); else if (value instanceof Object) sharedType.insert(index, [ objectToYMap(value) ]); else sharedType.insert(index, [ value ]); } + + else if (sharedType instanceof Y.Text) + sharedType.insert(property as number, value); } break; - case "delete": + case ChangeType.DELETE: if (sharedType instanceof Y.Map) sharedType.delete(property as string); @@ -163,9 +75,13 @@ export const patchSharedType = ( : index); } + else if (sharedType instanceof Y.Text) + // A delete operation for text is only ever for a single character. + sharedType.delete(property as number, 1); + break; - case "pending": + case ChangeType.PENDING: if (sharedType instanceof Y.Map) { patchSharedType( @@ -199,104 +115,137 @@ export const patchSharedType = ( * @returns The patched oldState, identical to newState. */ // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export const patchObject = (oldState: any, newState: any): any => +export const patchState = (oldState: any, newState: any): any => { - const changes = getChangeList(oldState, newState); - - if (changes.length === 0) - return oldState; + const changes = getChanges(oldState, newState); - else if (oldState instanceof Array) + const applyChanges = ( + state: (string | any[] | Record), + changes: Change[] + ): any => { - const p: any = changes + if (typeof state === "string") + return applyChangesToString(state as string, changes); + else if (state instanceof Array) + return applyChangesToArray(state as any[], changes); + else if (state instanceof Object) + return applyChangesToObject(state as Record, changes); + }; + + const applyChangesToArray = (array: any[], changes: Change[]): any => + changes .sort(([ , indexA ], [ , indexB ]) => Math.sign((indexA as number) - (indexB as number))) .reduce( - (state, [ type, index, value ]) => + (revisedArray, [ type, index, value ]) => { switch (type) { - case "add": - case "update": - case "none": + case ChangeType.INSERT: { - return [ - ...state, - value - ]; + revisedArray.splice(index as number, 0, value); + return revisedArray; } - case "pending": + case ChangeType.UPDATE: { - return [ - ...state, - patchObject( - oldState[index as number], - newState[index as number] - ) - ]; + revisedArray[index as number] = value; + return revisedArray; } - case "delete": + case ChangeType.PENDING: + { + revisedArray[index as number] = + applyChanges(array[index as number], value); + return revisedArray; + } + + case ChangeType.DELETE: + { + revisedArray.splice(index as number, 1); + return revisedArray; + } + + case ChangeType.NONE: default: - return state; + return revisedArray; } }, - [] as any[] + array ); - return p; - } - - else if (oldState instanceof Object) - { - const p: any = changes.reduce( - (state, [ type, property, value ]) => - { - switch (type) - { - case "add": - case "update": - case "none": + const applyChangesToObject = ( + object: Record, + changes: Change[] + ): any => + changes + .reduce( + (revisedObject, [ type, property, value ]) => { - return { - ...state, - [property]: value, - }; - } + switch (type) + { + case ChangeType.INSERT: + case ChangeType.UPDATE: + { + revisedObject[property] = value; + return revisedObject; + } + + case ChangeType.PENDING: + { + revisedObject[property] = applyChanges(object[property], value); + return revisedObject; + } + + case ChangeType.DELETE: + { + delete revisedObject[property]; + return revisedObject; + } + + case ChangeType.NONE: + default: + return revisedObject; + } + }, + object as Record + ); - case "pending": + const applyChangesToString = (string: string, changes: Change[]): any => + changes + .reduce( + (revisedString, [ type, index, value ]) => { - return { - ...state, - [property]: patchObject( - oldState[property as string], - newState[property as string] - ), - }; - } + switch (type) + { + case ChangeType.INSERT: + { + const left = revisedString.slice(0, index as number); + const right = revisedString.slice(index as number); + return left + value + right; + } - case "delete": - default: - return state; - } - }, - {} - ); + case ChangeType.DELETE: + { + const left = revisedString.slice(0, index as number); + const right = revisedString.slice((index as number) + 1); + return left + right; + } + + default: + { + return revisedString; + } + } + }, + string + ); + + if (changes.length === 0) + return oldState; - return { - ...Object.entries(oldState).reduce( - (o, [ property, value ]) => - ( - value instanceof Function - ? { ...o, [property]: value, } - : o - ), - {} - ), - ...p, - }; - } + else + return applyChanges(oldState, changes); }; @@ -314,7 +263,7 @@ export const patchStore = ( ): void => { store.setState( - patchObject(store.getState() || {}, newState), + patchState(store.getState() || {}, newState), true // Replace with the patched state. ); }; \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..1c467da --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,25 @@ +/** + * Describes the change that needs to be made. + */ +export enum ChangeType +{ // eslint-disable-line @typescript-eslint/indent + /** No change. */ + NONE = "none", + /** A value was inserted. */ + INSERT = "insert", + /** A value was replaced. */ + UPDATE = "update", + /** A value was deleted. */ + DELETE = "delete", + /** The value requires a recursive diff to identify further changes. */ + PENDING = "pending" +} + +/** + * A record that documents a change to an entry in an array or object. + */ +export type Change = [ + ChangeType, + string | number, + any +]; \ No newline at end of file