Merge pull request #18659 from overleaf/em-crop-comments-tracked-deletes

Crop comments when processing tracked deletes

GitOrigin-RevId: 662c9ed86a8ed4959d1671ce466548487f334f45
This commit is contained in:
Eric Mc Sween 2024-06-04 07:36:44 -04:00 committed by Copybot
parent 4101f4efeb
commit 55c342134c
5 changed files with 214 additions and 7 deletions

View file

@ -23,6 +23,12 @@ class AddCommentOperation extends EditOperation {
constructor(commentId, ranges, resolved = false) {
super()
for (const range of ranges) {
if (range.isEmpty()) {
throw new Error("AddCommentOperation can't be built with empty ranges")
}
}
/** @readonly */
this.commentId = commentId

View file

@ -56,20 +56,36 @@ const RangesManager = {
RangesManager._emptyRangesCount(rangesTracker)
const historyUpdates = []
for (const update of updates) {
rangesTracker.track_changes = Boolean(update.meta?.tc)
const trackingChanges = Boolean(update.meta?.tc)
rangesTracker.track_changes = trackingChanges
if (update.meta?.tc) {
rangesTracker.setIdSeed(update.meta.tc)
}
const historyOps = []
for (const op of update.op) {
let croppedCommentOps = []
if (opts.historyRangesSupport) {
historyOps.push(
getHistoryOp(op, rangesTracker.comments, rangesTracker.changes)
)
if (isDelete(op) && trackingChanges) {
// If a tracked delete overlaps a comment, the comment must be
// cropped. The extent of the cropping is calculated before the
// delete is applied, but the cropping operations are applied
// later, after the delete is applied.
croppedCommentOps = getCroppedCommentOps(op, rangesTracker.comments)
}
} else if (isInsert(op) || isDelete(op)) {
historyOps.push(op)
}
rangesTracker.applyOp(op, { user_id: update.meta?.user_id })
if (croppedCommentOps.length > 0) {
historyOps.push(
...croppedCommentOps.map(op =>
getHistoryOpForComment(op, rangesTracker.changes)
)
)
}
}
if (historyOps.length > 0) {
historyUpdates.push({ ...update, op: historyOps })
@ -486,4 +502,70 @@ function getHistoryOpForComment(op, changes) {
return historyOp
}
/**
* Return the ops necessary to properly crop comments when a tracked delete is
* received
*
* The editor treats a tracked delete as a proper delete and updates the
* comment range accordingly. The history doesn't do that and remembers the
* extent of the comment in the tracked delete. In order to keep the history
* consistent with the editor, we'll send ops that will crop the comment in
* the history.
*
* @param {DeleteOp} op
* @param {Comment[]} comments
* @returns {CommentOp[]}
*/
function getCroppedCommentOps(op, comments) {
const deleteStart = op.p
const deleteLength = op.d.length
const deleteEnd = deleteStart + deleteLength
/** @type {HistoryCommentOp[]} */
const historyCommentOps = []
for (const comment of comments) {
const commentStart = comment.op.p
const commentLength = comment.op.c.length
const commentEnd = commentStart + commentLength
if (deleteStart <= commentStart && deleteEnd > commentStart) {
// The comment overlaps the start of the comment or all of it.
const overlapLength = Math.min(deleteEnd, commentEnd) - commentStart
/** @type {CommentOp} */
const commentOp = {
p: deleteStart,
c: comment.op.c.slice(overlapLength),
t: comment.op.t,
}
if (comment.op.resolved) {
commentOp.resolved = true
}
historyCommentOps.push(commentOp)
} else if (
deleteStart > commentStart &&
deleteStart < commentEnd &&
deleteEnd >= commentEnd
) {
// The comment overlaps the end of the comment.
const overlapLength = commentEnd - deleteStart
/** @type {CommentOp} */
const commentOp = {
p: commentStart,
c: comment.op.c.slice(0, -overlapLength),
t: comment.op.t,
}
if (comment.op.resolved) {
commentOp.resolved = true
}
historyCommentOps.push(commentOp)
}
}
return historyCommentOps
}
module.exports = RangesManager

View file

@ -518,6 +518,123 @@ describe('RangesManager', function () {
])
})
})
describe('tracked delete that overlaps the start of a comment', function () {
beforeEach(function () {
// original text is "one three four five"
this.ranges = {
comments: makeRanges([{ c: 'three', p: 4, t: 'comment-id-1' }]),
}
this.updates = makeUpdates([{ d: 'ne thr', p: 1 }], {
tc: 'tracking-id',
})
this.newDocLines = ['oee four five']
this.result = this.RangesManager.applyUpdate(
this.project_id,
this.doc_id,
this.ranges,
this.updates,
this.newDocLines,
{ historyRangesSupport: true }
)
})
it('should crop the beginning of the comment', function () {
expect(this.result.historyUpdates.map(x => x.op)).to.deep.equal([
[
{ d: 'ne thr', p: 1 },
{ c: 'ee', p: 1, hpos: 7, t: 'comment-id-1' },
],
])
})
})
describe('tracked delete that overlaps a whole comment', function () {
beforeEach(function () {
// original text is "one three four five"
this.ranges = {
comments: makeRanges([{ c: 'three', p: 4, t: 'comment-id-1' }]),
}
this.updates = makeUpdates([{ d: 'ne three f', p: 1 }], {
tc: 'tracking-id',
})
this.newDocLines = ['oour five']
this.result = this.RangesManager.applyUpdate(
this.project_id,
this.doc_id,
this.ranges,
this.updates,
this.newDocLines,
{ historyRangesSupport: true }
)
})
it('should crop the beginning of the comment', function () {
expect(this.result.historyUpdates.map(x => x.op)).to.deep.equal([
[
{ d: 'ne three f', p: 1 },
{ c: '', p: 1, hpos: 11, t: 'comment-id-1' },
],
])
})
})
describe('tracked delete that overlaps the end of a comment', function () {
beforeEach(function () {
// original text is "one three four five"
this.ranges = {
comments: makeRanges([{ c: 'three', p: 4, t: 'comment-id-1' }]),
}
this.updates = makeUpdates([{ d: 'ee f', p: 7 }], {
tc: 'tracking-id',
})
this.newDocLines = ['one throur five']
this.result = this.RangesManager.applyUpdate(
this.project_id,
this.doc_id,
this.ranges,
this.updates,
this.newDocLines,
{ historyRangesSupport: true }
)
})
it('should crop the end of the comment', function () {
expect(this.result.historyUpdates.map(x => x.op)).to.deep.equal([
[
{ d: 'ee f', p: 7 },
{ c: 'thr', p: 4, t: 'comment-id-1' },
],
])
})
})
describe('tracked delete that overlaps the inside of a comment', function () {
beforeEach(function () {
// original text is "one three four five"
this.ranges = {
comments: makeRanges([{ c: 'three', p: 4, t: 'comment-id-1' }]),
}
this.updates = makeUpdates([{ d: 'hre', p: 5 }], {
tc: 'tracking-id',
})
this.newDocLines = ['one te four five']
this.result = this.RangesManager.applyUpdate(
this.project_id,
this.doc_id,
this.ranges,
this.updates,
this.newDocLines,
{ historyRangesSupport: true }
)
})
it('should not crop the comment', function () {
expect(this.result.historyUpdates.map(x => x.op)).to.deep.equal([
[{ d: 'hre', p: 5 }],
])
})
})
})
})

View file

@ -280,15 +280,11 @@ class OperationsBuilder {
this.pushTextOperation()
// Add a comment operation
const commentLength = op.hlen ?? op.c.length
const commentOp = {
pathname: this.pathname,
commentId: op.t,
ranges: [
{
pos,
length: op.hlen ?? op.c.length,
},
],
ranges: commentLength > 0 ? [{ pos, length: commentLength }] : [],
}
if ('resolved' in op) {
commentOp.resolved = op.resolved

View file

@ -776,6 +776,7 @@ describe('UpdateTranslator', function () {
{ p: 3, d: 'bar' },
{ p: 5, c: 'comment this', t: 'comment-id-1' },
{ p: 7, c: 'another comment', t: 'comment-id-2' },
{ p: 9, c: '', t: 'comment-id-3' },
{ p: 10, i: 'baz' },
],
v: this.version,
@ -812,6 +813,11 @@ describe('UpdateTranslator', function () {
commentId: 'comment-id-2',
ranges: [{ pos: 7, length: 15 }],
},
{
pathname: 'main.tex',
commentId: 'comment-id-3',
ranges: [],
},
{
pathname: 'main.tex',
textOperation: [10, 'baz', 10],