diff --git a/libraries/overleaf-editor-core/lib/file_data/range.js b/libraries/overleaf-editor-core/lib/file_data/range.js index 7b59f6b4a7..c7db354110 100644 --- a/libraries/overleaf-editor-core/lib/file_data/range.js +++ b/libraries/overleaf-editor-core/lib/file_data/range.js @@ -170,6 +170,23 @@ class Range { static fromRaw(raw) { return new Range(raw.pos, raw.length) } + + /** + * Splits a range into two ranges, at a given cursor + * @param {number} cursor + * @returns {[Range, Range]} + */ + splitAt(cursor) { + if (!this.containsCursor(cursor)) { + throw new Error('The cursor must be contained in the range') + } + const rangeUpToCursor = new Range(this.pos, cursor - this.pos) + const rangeAfterCursor = new Range( + cursor, + this.length - rangeUpToCursor.length + ) + return [rangeUpToCursor, rangeAfterCursor] + } } module.exports = Range diff --git a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js index f0badada2c..e96d2b9687 100644 --- a/libraries/overleaf-editor-core/lib/file_data/string_file_data.js +++ b/libraries/overleaf-editor-core/lib/file_data/string_file_data.js @@ -5,24 +5,28 @@ const assert = require('check-types').assert const FileData = require('./') const CommentList = require('./comment_list') +const TrackedChangeList = require('./tracked_change_list') /** * @typedef {import("../types").StringFileRawData} StringFileRawData * @typedef {import("../operation/edit_operation")} EditOperation * @typedef {import("../types").BlobStore} BlobStore * @typedef {import("../types").CommentRawData} CommentRawData + * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData */ class StringFileData extends FileData { /** * @param {string} content - * @param {CommentRawData[]} [rawComments] + * @param {CommentRawData[] | undefined} [rawComments] + * @param {TrackedChangeRawData[] | undefined} [rawTrackedChanges] */ - constructor(content, rawComments = []) { + constructor(content, rawComments = [], rawTrackedChanges = []) { super() assert.string(content) this.content = content this.comments = CommentList.fromRaw(rawComments) + this.trackedChanges = TrackedChangeList.fromRaw(rawTrackedChanges) } /** @@ -45,6 +49,10 @@ class StringFileData extends FileData { raw.comments = comments } + if (this.trackedChanges.length) { + raw.trackedChanges = this.trackedChanges.toRaw() + } + return raw } diff --git a/libraries/overleaf-editor-core/lib/file_data/tracked_change.js b/libraries/overleaf-editor-core/lib/file_data/tracked_change.js new file mode 100644 index 0000000000..a9b44d0754 --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/tracked_change.js @@ -0,0 +1,77 @@ +// @ts-check +const Range = require('./range') +const TrackingProps = require('./tracking_props') + +/** + * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData + */ + +class TrackedChange { + /** + * + * @param {Range} range + * @param {TrackingProps} tracking + */ + constructor(range, tracking) { + this.range = range + this.tracking = tracking + } + + /** + * + * @param {TrackedChangeRawData} raw + * @returns {TrackedChange} + */ + static fromRaw(raw) { + return new TrackedChange( + Range.fromRaw(raw.range), + TrackingProps.fromRaw(raw.tracking) + ) + } + + /** + * @returns {TrackedChangeRawData} + */ + toRaw() { + return { + range: this.range.toRaw(), + tracking: this.tracking.toRaw(), + } + } + + /** + * Checks whether the tracked change can be merged with another + * @param {TrackedChange} other + * @returns {boolean} + */ + canMerge(other) { + if (!(other instanceof TrackedChange)) { + return false + } + return ( + this.tracking.type === other.tracking.type && + this.tracking.userId === other.tracking.userId && + this.range.touches(other.range) && + this.range.canMerge(other.range) + ) + } + + /** + * Merges another tracked change into this, updating the range and tracking + * timestamp + * @param {TrackedChange} other + * @returns {void} + */ + merge(other) { + if (!this.canMerge(other)) { + throw new Error('Cannot merge tracked changes') + } + this.range.merge(other.range) + this.tracking.ts = + this.tracking.ts.getTime() > other.tracking.ts.getTime() + ? this.tracking.ts + : other.tracking.ts + } +} + +module.exports = TrackedChange diff --git a/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js b/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js new file mode 100644 index 0000000000..042cb75def --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js @@ -0,0 +1,224 @@ +// @ts-check +const Range = require('./range') +const TrackedChange = require('./tracked_change') + +/** + * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData + * @typedef {import("../file_data/tracking_props")} TrackingProps + */ + +class TrackedChangeList { + /** + * + * @param {TrackedChange[]} trackedChanges + */ + constructor(trackedChanges) { + this.trackedChanges = trackedChanges + } + + /** + * + * @param {TrackedChangeRawData[]} raw + * @returns {TrackedChangeList} + */ + static fromRaw(raw) { + return new TrackedChangeList(raw.map(TrackedChange.fromRaw)) + } + + /** + * Converts the tracked changes to a raw object + * @returns {TrackedChangeRawData[]} + */ + toRaw() { + return this.trackedChanges.map(change => change.toRaw()) + } + + get length() { + return this.trackedChanges.length + } + + /** + * Returns the tracked changes that are fully included in the range + * @param {Range} range + * @returns {TrackedChange[]} + */ + inRange(range) { + return this.trackedChanges.filter(change => range.contains(change.range)) + } + + /** + * Removes the tracked changes that are fully included in the range + * @param {Range} range + */ + removeInRange(range) { + this.trackedChanges = this.trackedChanges.filter( + change => !range.contains(change.range) + ) + } + + /** + * Adds a tracked change to the list + * @param {TrackedChange} trackedChange + */ + add(trackedChange) { + this.trackedChanges.push(trackedChange) + this._mergeRanges() + } + + /** + * Collapses consecutive (and compatible) ranges + * @returns {void} + */ + _mergeRanges() { + if (this.trackedChanges.length < 2) { + return + } + // ranges are non-overlapping so we can sort based on their first indices + this.trackedChanges.sort((a, b) => a.range.start - b.range.start) + const newTrackedChanges = [this.trackedChanges[0]] + for (let i = 1; i < this.trackedChanges.length; i++) { + const last = newTrackedChanges[newTrackedChanges.length - 1] + const current = this.trackedChanges[i] + if (last.canMerge(current)) { + last.merge(current) + } else { + newTrackedChanges.push(current) + } + } + this.trackedChanges = newTrackedChanges + } + + /** + * + * @param {number} cursor + * @param {string} insertedText + * @param {{tracking?: TrackingProps}} opts + */ + applyInsert(cursor, insertedText, opts = {}) { + const newTrackedChanges = [] + for (const trackedChange of this.trackedChanges) { + if ( + // If the cursor is before or at the insertion point, we need to move + // the tracked change + trackedChange.range.startIsAfter(cursor) || + cursor === trackedChange.range.start + ) { + trackedChange.range.moveBy(insertedText.length) + newTrackedChanges.push(trackedChange) + } else if (cursor === trackedChange.range.end) { + // The insertion is at the end of the tracked change. So we don't need + // to move it. + newTrackedChanges.push(trackedChange) + } else if (trackedChange.range.containsCursor(cursor)) { + // If the tracked change is in the inserted text, we need to expand it + // split in three chunks. The middle one is added if it is a tracked insertion + const [firstRange, , thirdRange] = trackedChange.range.insertAt( + cursor, + insertedText.length + ) + const firstPart = new TrackedChange( + firstRange, + trackedChange.tracking.clone() + ) + newTrackedChanges.push(firstPart) + // second part will be added at the end if it is a tracked insertion + const thirdPart = new TrackedChange( + thirdRange, + trackedChange.tracking.clone() + ) + newTrackedChanges.push(thirdPart) + } else { + newTrackedChanges.push(trackedChange) + } + } + + if (opts.tracking) { + // This is a new tracked change + const newTrackedChange = new TrackedChange( + new Range(cursor, insertedText.length), + opts.tracking + ) + newTrackedChanges.push(newTrackedChange) + } + this.trackedChanges = newTrackedChanges + this._mergeRanges() + } + + /** + * + * @param {number} cursor + * @param {number} length + */ + applyDelete(cursor, length) { + const newTrackedChanges = [] + for (const trackedChange of this.trackedChanges) { + const deletedRange = new Range(cursor, length) + // If the tracked change is after the deletion, we need to move it + if (deletedRange.contains(trackedChange.range)) { + continue + } else if (deletedRange.overlaps(trackedChange.range)) { + trackedChange.range.subtract(deletedRange) + newTrackedChanges.push(trackedChange) + } else if (trackedChange.range.startIsAfter(cursor)) { + trackedChange.range.pos -= length + newTrackedChanges.push(trackedChange) + } else { + newTrackedChanges.push(trackedChange) + } + } + this.trackedChanges = newTrackedChanges + this._mergeRanges() + } + + /** + * @param {number} cursor + * @param {number} length + * @param {{tracking?: TrackingProps}} opts + */ + applyRetain(cursor, length, opts = {}) { + // If there's no tracking info, leave everything as-is + if (!opts.tracking) { + return + } + const newTrackedChanges = [] + const retainedRange = new Range(cursor, length) + for (const trackedChange of this.trackedChanges) { + if (retainedRange.contains(trackedChange.range)) { + // Remove the range + } else if (retainedRange.overlaps(trackedChange.range)) { + if (trackedChange.range.contains(retainedRange)) { + const [leftRange, rightRange] = trackedChange.range.splitAt(cursor) + rightRange.pos += length + rightRange.length -= length + newTrackedChanges.push( + new TrackedChange(leftRange, trackedChange.tracking.clone()) + ) + newTrackedChanges.push( + new TrackedChange(rightRange, trackedChange.tracking.clone()) + ) + } else if (retainedRange.start <= trackedChange.range.start) { + // overlaps to the left + const [, reducedRange] = trackedChange.range.splitAt( + retainedRange.end + ) + trackedChange.range = reducedRange + newTrackedChanges.push(trackedChange) + } else { + // overlaps to the right + const [reducedRange] = trackedChange.range.splitAt(cursor) + trackedChange.range = reducedRange + newTrackedChanges.push(trackedChange) + } + } + } + if (opts.tracking?.type === 'delete' || opts.tracking?.type === 'insert') { + // This is a new tracked change + const newTrackedChange = new TrackedChange(retainedRange, opts.tracking) + newTrackedChanges.push(newTrackedChange) + } + this.trackedChanges = newTrackedChanges + this._mergeRanges() + } +} + +module.exports = TrackedChangeList diff --git a/libraries/overleaf-editor-core/lib/file_data/tracking_props.js b/libraries/overleaf-editor-core/lib/file_data/tracking_props.js new file mode 100644 index 0000000000..f79c018c3c --- /dev/null +++ b/libraries/overleaf-editor-core/lib/file_data/tracking_props.js @@ -0,0 +1,52 @@ +// @ts-check +/** + * @typedef {import("../types").TrackedChangeRawData} TrackedChangeRawData + */ + +class TrackingProps { + /** + * + * @param {'insert' | 'delete' | 'none'} type + * @param {string} userId + * @param {Date} ts + */ + constructor(type, userId, ts) { + /** + * @readonly + * @type {'insert' | 'delete' | 'none'} + */ + this.type = type + /** + * @readonly + * @type {string} + */ + this.userId = userId + /** + * @type {Date} + */ + this.ts = ts + } + + /** + * + * @param {TrackedChangeRawData['tracking']} raw + * @returns + */ + static fromRaw(raw) { + return new TrackingProps(raw.type, raw.userId, new Date(raw.ts)) + } + + toRaw() { + return { + type: this.type, + userId: this.userId, + ts: this.ts.toISOString(), + } + } + + clone() { + return new TrackingProps(this.type, this.userId, this.ts) + } +} + +module.exports = TrackingProps diff --git a/libraries/overleaf-editor-core/lib/types.ts b/libraries/overleaf-editor-core/lib/types.ts index 110f9b6eec..ba6d52a491 100644 --- a/libraries/overleaf-editor-core/lib/types.ts +++ b/libraries/overleaf-editor-core/lib/types.ts @@ -5,15 +5,28 @@ export type BlobStore = { putString(content: string): Promise } +type Range = { + pos: number + length: number +} + export type CommentRawData = { id: string - ranges: { - pos: number - length: number - }[] + ranges: Range[] resolved?: boolean } +export type TrackedChangeRawData = { + range: Range + tracking: TrackingPropsRawData +} + +export type TrackingPropsRawData = { + type: 'insert' | 'delete' | 'none' + userId: string + ts: string +} + export type StringFileRawData = { content: string comments?: CommentRawData[] diff --git a/libraries/overleaf-editor-core/test/range.test.js b/libraries/overleaf-editor-core/test/range.test.js index 287a92258e..02865d16cc 100644 --- a/libraries/overleaf-editor-core/test/range.test.js +++ b/libraries/overleaf-editor-core/test/range.test.js @@ -342,4 +342,90 @@ describe('Range', function () { expect(from5to14.start).to.eql(8) expect(from5to14.end).to.eql(18) }) + + describe('splitAt', function () { + it('should split at the start', function () { + const range = new Range(5, 10) + const [left, right] = range.splitAt(5) + expect(left.isEmpty()).to.be.true + expect(right.start).to.eql(5) + expect(right.end).to.eql(15) + }) + + it('should not split before the start', function () { + const range = new Range(5, 10) + expect(() => range.splitAt(4)).to.throw() + }) + + it('should split at last cursor in range', function () { + const range = new Range(5, 10) + const [left, right] = range.splitAt(14) + expect(left.start).to.equal(5) + expect(left.end).to.equal(14) + expect(right.start).to.equal(14) + expect(right.end).to.equal(15) + }) + + it('should not split after the end', function () { + const range = new Range(5, 10) + expect(() => range.splitAt(16)).to.throw() + }) + + it('should split at end', function () { + const range = new Range(5, 10) + const [left, right] = range.splitAt(15) + expect(left.start).to.equal(5) + expect(left.end).to.equal(15) + expect(right.start).to.equal(15) + expect(right.end).to.equal(15) + }) + + it('should split in the middle', function () { + const range = new Range(5, 10) + const [left, right] = range.splitAt(10) + expect(left.start).to.equal(5) + expect(left.end).to.equal(10) + expect(right.start).to.equal(10) + expect(right.end).to.equal(15) + }) + }) + + describe('insertAt', function () { + it('should insert at the start', function () { + const range = new Range(5, 10) + const [left, inserted, right] = range.insertAt(5, 3) + expect(left.isEmpty()).to.be.true + expect(inserted.start).to.eql(5) + expect(inserted.end).to.eql(8) + expect(right.start).to.eql(8) + expect(right.end).to.eql(18) + }) + + it('should insert at the end', function () { + const range = new Range(5, 10) + const [left, inserted, right] = range.insertAt(15, 3) + expect(left.start).to.eql(5) + expect(left.end).to.eql(15) + expect(inserted.start).to.eql(15) + expect(inserted.end).to.eql(18) + expect(right.isEmpty()).to.be.true + }) + + it('should insert in the middle', function () { + const range = new Range(5, 10) + const [left, inserted, right] = range.insertAt(10, 3) + expect(left.start).to.eql(5) + expect(left.end).to.eql(10) + expect(inserted.start).to.eql(10) + expect(inserted.end).to.eql(13) + expect(right.start).to.eql(13) + expect(right.end).to.eql(18) + }) + + it('should throw if cursor is out of range', function () { + const range = new Range(5, 10) + expect(() => range.insertAt(4, 3)).to.throw() + expect(() => range.insertAt(16, 3)).to.throw() + }) + }) }) diff --git a/libraries/overleaf-editor-core/test/tracked_change.test.js b/libraries/overleaf-editor-core/test/tracked_change.test.js new file mode 100644 index 0000000000..953998143b --- /dev/null +++ b/libraries/overleaf-editor-core/test/tracked_change.test.js @@ -0,0 +1,55 @@ +// @ts-check +const TrackedChange = require('../lib/file_data/tracked_change') +const Range = require('../lib/file_data/range') +const TrackingProps = require('../lib/file_data/tracking_props') +const { expect } = require('chai') + +describe('TrackedChange', function () { + it('should survive serialization', function () { + const trackedChange = new TrackedChange( + new Range(1, 2), + new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z')) + ) + const newTrackedChange = TrackedChange.fromRaw(trackedChange.toRaw()) + expect(newTrackedChange).to.be.instanceOf(TrackedChange) + expect(newTrackedChange).to.deep.equal(trackedChange) + }) + + it('can be created from a raw object', function () { + const trackedChange = TrackedChange.fromRaw({ + range: { pos: 1, length: 2 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }) + expect(trackedChange).to.be.instanceOf(TrackedChange) + expect(trackedChange).to.deep.equal( + new TrackedChange( + new Range(1, 2), + new TrackingProps( + 'insert', + 'user1', + new Date('2024-01-01T00:00:00.000Z') + ) + ) + ) + }) + + it('can be serialized to a raw object', function () { + const change = new TrackedChange( + new Range(1, 2), + new TrackingProps('insert', 'user1', new Date('2024-01-01T00:00:00.000Z')) + ) + expect(change).to.be.instanceOf(TrackedChange) + expect(change.toRaw()).to.deep.equal({ + range: { pos: 1, length: 2 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }) + }) +}) diff --git a/libraries/overleaf-editor-core/test/tracked_change_list.test.js b/libraries/overleaf-editor-core/test/tracked_change_list.test.js new file mode 100644 index 0000000000..5a93d8400d --- /dev/null +++ b/libraries/overleaf-editor-core/test/tracked_change_list.test.js @@ -0,0 +1,845 @@ +// @ts-check +const TrackedChangeList = require('../lib/file_data/tracked_change_list') +const TrackingProps = require('../lib/file_data/tracking_props') +const { expect } = require('chai') +/** @typedef {import('../lib/types').TrackedChangeRawData} TrackedChangeRawData */ + +describe('TrackedChangeList', function () { + describe('applyInsert', function () { + describe('with same author', function () { + it('should merge consecutive tracked changes and use the latest timestamp', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(3, 'foo', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 6 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should extend tracked changes when inserting in the middle', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(5, 'foobar', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 16 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should merge two tracked changes starting at the same position', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(0, 'foo', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 6 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should not extend range when there is a gap between the ranges', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(4, 'foobar', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(2) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 4, length: 6 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should not merge tracked changes if there is a space between them', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 5, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(4, 'foo', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(2) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 4, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 8, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + }) + + describe('with different authors', function () { + it('should not merge consecutive tracked changes', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(3, 'foo', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(2) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 3, length: 3 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should not merge tracked changes at same position', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(0, 'foo', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(2) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 3 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 3, length: 3 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should insert tracked changes in the middle of a tracked range', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(5, 'foobar', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(3) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 5, length: 6 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 11, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should insert tracked changes at the end of a tracked range', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(5, 'foobar', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(2) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 5, length: 6 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should split a track range when inserting at last contained cursor', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(4, 'foobar', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(3) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 4 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 4, length: 6 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 10, length: 1 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should insert a new range if inserted just before the first cursor of a tracked range', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 5, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyInsert(5, 'foobar', { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(2) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 5, length: 6 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 11, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + }) + }) + + describe('applyDelete', function () { + it('should shrink tracked changes', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyDelete(5, 2) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 8 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should delete tracked changes when the whole range is deleted', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyDelete(0, 10) + expect(trackedChanges.trackedChanges.length).to.equal(0) + expect(trackedChanges.toRaw()).to.deep.equal([]) + }) + + it('should delete tracked changes when more than the whole range is deleted', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 5, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyDelete(0, 25) + expect(trackedChanges.trackedChanges.length).to.equal(0) + expect(trackedChanges.toRaw()).to.deep.equal([]) + }) + + it('should shrink the tracked change from start with overlap', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyDelete(1, 9) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 1 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should shrink the tracked change from end with overlap', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyDelete(0, 9) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 1 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + }) + + describe('fromRaw & toRaw', function () { + it('should survive serialization', function () { + /** @type {TrackedChangeRawData[]} */ + const initialRaw = [ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ] + + const trackedChanges = TrackedChangeList.fromRaw(initialRaw) + const raw = trackedChanges.toRaw() + const newTrackedChanges = TrackedChangeList.fromRaw(raw) + + expect(newTrackedChanges).to.deep.equal(trackedChanges) + expect(raw).to.deep.equal(initialRaw) + }) + }) + + describe('applyRetain', function () { + it('should add tracking information to an untracked range', function () { + const trackedChanges = TrackedChangeList.fromRaw([]) + trackedChanges.applyRetain(0, 10, { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should shrink a tracked range to make room for retained operation', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 3, length: 7 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(0, 5, { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(2) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 5 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 5, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should break up a tracked range to make room for retained operation', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(5, 1, { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(3) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 5 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 5, length: 1 }, + tracking: { + type: 'insert', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + { + range: { pos: 6, length: 4 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should update the timestamp of a tracked range', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(1, 12, { + tracking: TrackingProps.fromRaw({ + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 13 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should leave ignore a retain operation with no tracking info', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(0, 10) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should leave not break up a tracked change for a retain with no tracking info', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(4, 1) + expect(trackedChanges.trackedChanges.length).to.equal(1) + expect(trackedChanges.toRaw()).to.deep.equal([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + }) + + it('should delete a tracked change which is being resolved', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(0, 10, { + tracking: TrackingProps.fromRaw({ + type: 'none', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(0) + expect(trackedChanges.toRaw()).to.deep.equal([]) + }) + + it('should delete a tracked change which is being resolved by other user', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'insert', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(0, 10, { + tracking: TrackingProps.fromRaw({ + type: 'none', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(0) + expect(trackedChanges.toRaw()).to.deep.equal([]) + }) + + it('should delete a tracked change which is being rejected', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'delete', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(0, 10, { + tracking: TrackingProps.fromRaw({ + type: 'none', + userId: 'user1', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(0) + expect(trackedChanges.toRaw()).to.deep.equal([]) + }) + + it('should delete a tracked change which is being rejected by other user', function () { + const trackedChanges = TrackedChangeList.fromRaw([ + { + range: { pos: 0, length: 10 }, + tracking: { + type: 'delete', + userId: 'user1', + ts: '2023-01-01T00:00:00.000Z', + }, + }, + ]) + trackedChanges.applyRetain(0, 10, { + tracking: TrackingProps.fromRaw({ + type: 'none', + userId: 'user2', + ts: '2024-01-01T00:00:00.000Z', + }), + }) + expect(trackedChanges.trackedChanges.length).to.equal(0) + expect(trackedChanges.toRaw()).to.deep.equal([]) + }) + }) +})