Merge pull request #16807 from overleaf/mj-tracked-changes-history

[overleaf-editor-core] Add TrackedChangeList to StringFileData

GitOrigin-RevId: b2abce4860c5be6189beafca575de04e880b08f5
This commit is contained in:
Mathias Jakobsen 2024-02-05 13:20:11 +00:00 committed by Copybot
parent 09ff9526b8
commit ad0a8be0af
9 changed files with 1383 additions and 6 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -5,15 +5,28 @@ export type BlobStore = {
putString(content: string): Promise<Blob>
}
export type CommentRawData = {
id: string
ranges: {
type Range = {
pos: number
length: number
}[]
}
export type CommentRawData = {
id: string
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[]

View file

@ -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()
})
})
})

View file

@ -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',
},
})
})
})

View file

@ -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([])
})
})
})