diff --git a/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts b/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts index 28a0f251ff..f902afad6c 100644 --- a/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts +++ b/services/web/frontend/js/features/source-editor/extensions/changes/comments.ts @@ -1,9 +1,11 @@ -import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state' import { - AnyOperation, - Change, - CommentOperation, -} from '../../../../../../types/change' + Range, + RangeSet, + RangeValue, + StateEffect, + Transaction, +} from '@codemirror/state' +import { AnyOperation, CommentOperation } from '../../../../../../types/change' import { ThreadId } from '../../../../../../types/review-panel/review-panel' import { DocumentContainer } from '@/features/ide-react/editor/document-container' @@ -12,10 +14,28 @@ export type StoredComment = { comments: { offset: number text: string - comment: Change + comment: { id: string } }[] } +/** + * Comments to re-attach when a deletion is undone, keyed by CM position so the + * undo can confirm the text came back to the same place. + */ +export const restoreDetachedCommentsEffect = StateEffect.define< + RangeSet +>({ + map: (value, mapping) => { + return value + .update({ + filter: (from, to) => { + return from <= mapping.length && to <= mapping.length + }, + }) + .map(mapping) + }, +}) + /** * Find tracked comments within the range of the current transaction's changes */ @@ -80,10 +100,10 @@ export const findCommentsInPaste = ( return ops } -class CommentRangeValue extends RangeValue { +export class CommentRangeValue extends RangeValue { constructor( public content: string, - public comment: Change + public comment: { id: string } ) { super() } @@ -165,17 +185,14 @@ export const restoreCommentsOnPaste = ( transaction: Transaction, storedComments: StoredComment[] ) => { - if (storedComments.length) { - const ops = findCommentsInPaste(storedComments, transaction) + if (!storedComments.length) { + return + } - if (ops.length) { - submitOpsAfterEvent( - currentDoc, - 'ranges:dirty.paste-cm6', - ops, - transaction - ) - } + const ops = findCommentsInPaste(storedComments, transaction) + + if (ops.length) { + submitOpsAfterEvent(currentDoc, 'ranges:dirty.paste-cm6', ops, transaction) } } diff --git a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts index b910f30aca..891dbd6061 100644 --- a/services/web/frontend/js/features/source-editor/extensions/history-ot.ts +++ b/services/web/frontend/js/features/source-editor/extensions/history-ot.ts @@ -6,12 +6,17 @@ import { } from '@codemirror/view' import { EditorState, + Range as CmRange, + RangeSet, StateEffect, StateField, Transaction, } from '@codemirror/state' import { + AddCommentOperation, CommentList, + EditOperation, + Range as OtRange, TextOperation, TrackingProps, TrackedChangeList, @@ -22,6 +27,10 @@ import { TrackedDeletes, trackedDeletesFromState, } from '@/features/source-editor/utils/tracked-deletes' +import { + CommentRangeValue, + restoreDetachedCommentsEffect, +} from './changes/comments' export const historyOT = (currentDoc: DocumentContainer) => { const trackedChanges = @@ -32,6 +41,7 @@ export const historyOT = (currentDoc: DocumentContainer) => { return [ updateSender, trackChangesUserIdState, + cutCommentsState, shareDocState.init(() => currentDoc?.doc?._doc ?? null), rangesState.init(() => ({ trackedChanges, @@ -245,6 +255,165 @@ const trackChangesUserIdState = StateField.define({ }, }) +type CutComment = { offset: number; text: string; id: string } +type CutSpan = { text: string; comments: CutComment[] } + +const cutCommentsEffect = StateEffect.define() + +// Comments captured by the latest cut, to restore onto matching pasted text. +const cutCommentsState = StateField.define({ + create() { + return [] + }, + update(value, tr) { + for (const effect of tr.effects) { + if (effect.is(cutCommentsEffect)) { + return effect.value + } + } + if (tr.isUserEvent('input.paste')) { + return [] + } + return value + }, +}) + +const findCutComments = (tr: Transaction): CutSpan[] => { + // Read the comments as they were before the cut. Snapshots are copied on + // each edit, so startState's rangesState still holds the pre-cut ranges. + const { comments, trackedChanges } = tr.startState.field(rangesState) + const trackedDeletes = new TrackedDeletes(trackedChanges) + const spans: CutSpan[] = [] + + tr.changes.iterChanges((fromA, toA) => { + const captured: CutComment[] = [] + for (const comment of comments) { + for (const range of comment.ranges) { + const from = trackedDeletes.toCodeMirror(range.pos) + const to = trackedDeletes.toCodeMirror(range.end) + // Capture only a comment wholly inside the cut span; from < to skips + // already-detached comments, and a straddling comment stays detached. + if (from < to && fromA <= from && to <= toA) { + captured.push({ + offset: from - fromA, + text: tr.startState.sliceDoc(from, to), + id: comment.id, + }) + } + } + } + if (captured.length) { + spans.push({ + text: tr.startState.sliceDoc(fromA, toA), + comments: captured, + }) + } + }) + + return spans +} + +export const findDetachedHistoryOTCommentsInChanges = (tr: Transaction) => { + const { comments, trackedChanges } = tr.startState.field(rangesState) + const trackedDeletes = new TrackedDeletes(trackedChanges) + const items: CmRange[] = [] + + tr.changes.iterChanges((fromA, toA) => { + for (const comment of comments) { + for (const range of comment.ranges) { + const from = trackedDeletes.toCodeMirror(range.pos) + const to = trackedDeletes.toCodeMirror(range.end) + // Same wholly-inside-the-change capture rule as findCutComments. + if (from < to && fromA <= from && to <= toA) { + const content = tr.startState.sliceDoc(from, to) + items.push( + new CommentRangeValue(content, { id: comment.id }).range(from, to) + ) + } + } + } + }) + + return RangeSet.of(items, true) +} + +// Re-attach comments to text this transaction restores: cut comments onto +// matching pasted text, and comments recorded on deletion onto undone text. +// Ranges are expressed in the target document of the transaction's text op, +// so the AddCommentOperations must follow it in the same update. +const buildCommentRestoreOps = ( + tr: Transaction, + shareDoc: HistoryOTShareDoc, + insertions: { fromB: number; text: string; targetPos: number }[] +) => { + const rangesByCommentId = new Map() + + if (tr.isUserEvent('input.paste')) { + const cutSpans = tr.startState.field(cutCommentsState) + for (const insertion of insertions) { + const matched = cutSpans.find(span => span.text === insertion.text) + if (!matched) { + continue + } + // Re-adding by the same id moves the (now detached) comment here. + for (const { offset, text, id } of matched.comments) { + const ranges = rangesByCommentId.get(id) ?? [] + ranges.push(new OtRange(insertion.targetPos + offset, text.length)) + rangesByCommentId.set(id, ranges) + } + } + } + + const snapshotComments = shareDoc.snapshot.getComments() + for (const effect of tr.effects) { + if (!effect.is(restoreDetachedCommentsEffect)) { + continue + } + const cursor = effect.value.iter() + while (cursor.value) { + const { content, comment } = cursor.value + const existing = snapshotComments.getComment(comment.id) + // Only restore while the comment is still detached and the undo brought + // the same text back. + if ( + existing?.isEmpty() && + tr.newDoc.sliceString(cursor.from, cursor.from + content.length) === + content + ) { + const insertion = insertions.find( + item => + item.fromB <= cursor.from && + cursor.from + content.length <= item.fromB + item.text.length + ) + if (insertion) { + const ranges = rangesByCommentId.get(comment.id) ?? [] + ranges.push( + new OtRange( + insertion.targetPos + (cursor.from - insertion.fromB), + content.length + ) + ) + rangesByCommentId.set(comment.id, ranges) + } + } + cursor.next() + } + } + + // One op per id: AddCommentOperation replaces the comment entry, so a second + // op for the same id would drop the first op's ranges, and the replacement + // must carry the current resolved state. + return Array.from( + rangesByCommentId, + ([id, ranges]) => + new AddCommentOperation( + id, + ranges, + snapshotComments.getComment(id)?.resolved + ) + ) +} + const updateSender = EditorState.transactionExtender.of(tr => { if (!tr.docChanged || tr.annotation(Transaction.remote)) { return {} @@ -260,13 +429,34 @@ const updateSender = EditorState.transactionExtender.of(tr => { startDoc.length + trackedDeletes.totalLength ) + // An insert strictly inside a comment extends it, matching ShareLaTeX + // (inserts at either edge are excluded). `pos` is in snapshot coordinates. + // Keep the rule in step with getHistoryOpForInsert in document-updater's + // RangesManager. + const comments = tr.startState.field(rangesState).comments + const commentIdsAt = (pos: number) => { + const ids = [] + for (const comment of comments) { + if (comment.ranges.some(range => range.pos < pos && pos < range.end)) { + ids.push(comment.id) + } + } + return ids.length > 0 ? ids : undefined + } + + // Where each insertion lands in the built op's target document, for placing + // restored comments in the same update. + const insertions: { fromB: number; text: string; targetPos: number }[] = [] + if (trackingUserId == null) { // Not tracking changes tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => { // insert if (inserted.length > 0) { const pos = trackedDeletes.toSnapshot(fromA) - opBuilder.insert(pos, inserted.toString()) + const text = inserted.toString() + const targetPos = opBuilder.insert(pos, text, commentIdsAt(pos)) + insertions.push({ fromB, text, targetPos }) } // deletion @@ -283,12 +473,15 @@ const updateSender = EditorState.transactionExtender.of(tr => { // insertion if (inserted.length > 0) { const pos = trackedDeletes.toSnapshot(fromA) - opBuilder.trackedInsert( + const text = inserted.toString() + const targetPos = opBuilder.trackedInsert( pos, - inserted.toString(), + text, trackingUserId, - timestamp + timestamp, + commentIdsAt(pos) ) + insertions.push({ fromB, text, targetPos }) } // deletion @@ -300,10 +493,17 @@ const updateSender = EditorState.transactionExtender.of(tr => { }) } - const op = opBuilder.finish() const shareDoc = tr.startState.field(shareDocState) if (shareDoc != null) { - shareDoc.submitOp([op]) + const ops: EditOperation[] = [opBuilder.finish()] + for (const op of buildCommentRestoreOps(tr, shareDoc, insertions)) { + ops.push(op) + } + shareDoc.submitOp(ops) + } + + if (tr.isUserEvent('delete.cut')) { + return { effects: cutCommentsEffect.of(findCutComments(tr)) } } return {} @@ -327,6 +527,12 @@ class OperationBuilder { */ private pos: number + /** + * Length difference between the target and source documents for the + * operations built so far + */ + private delta: number + /** * Operation built */ @@ -336,24 +542,45 @@ class OperationBuilder { this.docLength = docLength this.op = new TextOperation() this.pos = 0 + this.delta = 0 } - insert(pos: number, text: string) { + /** + * Returns the position of the inserted text in the target document. + */ + insert(pos: number, text: string, commentIds?: string[]) { this.retainUntil(pos) - this.op.insert(text) + this.op.insert(text, { commentIds }) + const targetPos = pos + this.delta + this.delta += text.length + return targetPos } delete(pos: number, length: number) { this.retainUntil(pos) this.op.remove(length) this.pos += length + this.delta -= length } - trackedInsert(pos: number, text: string, userId: string, timestamp: Date) { + /** + * Returns the position of the inserted text in the target document. + */ + trackedInsert( + pos: number, + text: string, + userId: string, + timestamp: Date, + commentIds?: string[] + ) { this.retainUntil(pos) this.op.insert(text, { tracking: new TrackingProps('insert', userId, timestamp), + commentIds, }) + const targetPos = pos + this.delta + this.delta += text.length + return targetPos } trackedDelete(pos: number, length: number, userId: string, timestamp: Date) { diff --git a/services/web/frontend/js/features/source-editor/extensions/track-detached-comments.ts b/services/web/frontend/js/features/source-editor/extensions/track-detached-comments.ts index 680b025c4c..34f658af2e 100644 --- a/services/web/frontend/js/features/source-editor/extensions/track-detached-comments.ts +++ b/services/web/frontend/js/features/source-editor/extensions/track-detached-comments.ts @@ -1,32 +1,16 @@ -import { - EditorState, - RangeSet, - StateEffect, - StateField, - Transaction, -} from '@codemirror/state' +import { EditorState, StateField, Transaction } from '@codemirror/state' import { findCommentsInCut, findDetachedCommentsInChanges, restoreCommentsOnPaste, restoreDetachedComments, + restoreDetachedCommentsEffect, StoredComment, } from './changes/comments' +import { findDetachedHistoryOTCommentsInChanges } from './history-ot' import { invertedEffects } from '@codemirror/commands' import { DocumentContainer } from '@/features/ide-react/editor/document-container' -const restoreDetachedCommentsEffect = StateEffect.define>({ - map: (value, mapping) => { - return value - .update({ - filter: (from, to) => { - return from <= mapping.length && to <= mapping.length - }, - }) - .map(mapping) - }, -}) - /** * A custom extension that detects detached comments when a comment is cut and pasted, * or when a deleted comment is undone @@ -43,6 +27,12 @@ export const trackDetachedComments = ({ return [] }, update: (value, transaction) => { + // history-OT cut/paste restoration is handled atomically by the + // updateSender in the historyOT extension + if (currentDoc.isHistoryOT()) { + return value + } + if (transaction.annotation(Transaction.remote)) { return value } @@ -71,10 +61,9 @@ export const trackDetachedComments = ({ transaction.docChanged && !transaction.annotation(Transaction.remote) ) { - const detachedComments = findDetachedCommentsInChanges( - currentDoc, - transaction - ) + const detachedComments = currentDoc.isHistoryOT() + ? findDetachedHistoryOTCommentsInChanges(transaction) + : findDetachedCommentsInChanges(currentDoc, transaction) if (detachedComments.size) { return [restoreDetachedCommentsEffect.of(detachedComments)] } @@ -82,8 +71,11 @@ export const trackDetachedComments = ({ return [] }), - // restore any detached comments on undo + // restore any detached comments on undo (history-OT does this in updateSender) EditorState.transactionExtender.of(transaction => { + if (currentDoc.isHistoryOT()) { + return null + } for (const effect of transaction.effects) { if (effect.is(restoreDetachedCommentsEffect)) { // send the comments to the ShareJS doc diff --git a/services/web/test/frontend/features/source-editor/extensions/changes/comments.test.ts b/services/web/test/frontend/features/source-editor/extensions/changes/comments.test.ts new file mode 100644 index 0000000000..7390fd15cd --- /dev/null +++ b/services/web/test/frontend/features/source-editor/extensions/changes/comments.test.ts @@ -0,0 +1,490 @@ +import { expect } from 'chai' +import { EventEmitter } from 'events' +import { EditorState } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { history, undo } from '@codemirror/commands' +import { + AddCommentOperation, + Range, + StringFileData, + TextOperation, + TrackingProps, +} from 'overleaf-editor-core' +import { + historyOT, + setTrackChangesUserId, +} from '../../../../../../frontend/js/features/source-editor/extensions/history-ot' +import { trackDetachedComments } from '../../../../../../frontend/js/features/source-editor/extensions/track-detached-comments' +import { historyOTType } from '../../../../../../frontend/js/features/ide-react/editor/share-js-history-ot-type' + +const setup = ( + content: string, + comments: { + id: string + ranges: { pos: number; length: number }[] + resolved?: boolean + }[] = [], + trackedDeletes: { pos: number; length: number }[] = [] +) => { + let snapshot = new StringFileData(content) + if (trackedDeletes.length > 0) { + const op = new TextOperation() + let cursor = 0 + for (const { pos, length } of trackedDeletes) { + if (pos > cursor) { + op.retain(pos - cursor) + } + op.retain(length, { + tracking: new TrackingProps( + 'delete', + 'user-1', + new Date('2025-01-01T00:00:00.000Z') + ), + }) + cursor = pos + length + } + if (cursor < content.length) { + op.retain(content.length - cursor) + } + snapshot = historyOTType.apply(snapshot, [op]) + } + for (const comment of comments) { + snapshot = historyOTType.apply(snapshot, [ + new AddCommentOperation( + comment.id, + comment.ranges.map(range => new Range(range.pos, range.length)), + comment.resolved + ), + ]) + } + + const shareDoc: any = Object.assign(new EventEmitter(), { + otType: 'history-ot', + snapshot, + getText: () => shareDoc.snapshot.getContent({ filterTrackedDeletes: true }), + submitOp: (ops: any[]) => { + shareDoc.snapshot = historyOTType.apply(shareDoc.snapshot, ops) + }, + }) + + const currentDoc: any = { + isHistoryOT: () => true, + historyOTShareDoc: shareDoc, + doc: { _doc: shareDoc }, + } + + const state = EditorState.create({ + doc: snapshot.getContent({ filterTrackedDeletes: true }), + extensions: [ + history(), + historyOT(currentDoc), + trackDetachedComments({ currentDoc }), + ], + }) + const view = new EditorView({ state }) + + return { view, shareDoc } +} + +describe('historyOT comments', function () { + it('moves a comment to matching pasted text', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + expect(shareDoc.snapshot.getComments().length).to.equal(1) + + // cut "hello" + view.dispatch({ changes: { from: 0, to: 5 }, userEvent: 'delete.cut' }) + + // paste "hello" at the end of " world" + const end = view.state.doc.length + view.dispatch({ + changes: { from: end, insert: 'hello' }, + userEvent: 'input.paste', + }) + + const comments = shareDoc.snapshot.getComments().toArray() + expect(comments).to.have.length(1) + expect(comments[0].id).to.equal('c1') + expect(comments[0].ranges).to.have.length(1) + expect(comments[0].ranges[0].pos).to.equal(end) + expect(comments[0].ranges[0].length).to.equal(5) + }) + + it('restores a comment when its deletion is undone', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + + // delete "hello" — the comment detaches + view.dispatch({ changes: { from: 0, to: 5 } }) + expect(shareDoc.snapshot.getComments().toArray()[0].isEmpty()).to.be.true + + undo(view) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.isEmpty()).to.be.false + expect(comment.ranges[0].pos).to.equal(0) + expect(comment.ranges[0].length).to.equal(5) + }) + + it('moves multiple comments cut together onto matching pasted text', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + { id: 'c2', ranges: [{ pos: 6, length: 5 }] }, + ]) + + // cut the whole "hello world" — both comments detach + view.dispatch({ changes: { from: 0, to: 11 }, userEvent: 'delete.cut' }) + + // paste it back into the now-empty document + view.dispatch({ + changes: { from: 0, insert: 'hello world' }, + userEvent: 'input.paste', + }) + + const comments = shareDoc.snapshot.getComments().toArray() + const c1 = comments.find((c: any) => c.id === 'c1') + const c2 = comments.find((c: any) => c.id === 'c2') + expect(c1.ranges[0].pos).to.equal(0) + expect(c1.ranges[0].length).to.equal(5) + expect(c2.ranges[0].pos).to.equal(6) + expect(c2.ranges[0].length).to.equal(5) + }) + + it('restores multiple cut segments pasted in a single transaction', function () { + const { view, shareDoc } = setup('foo bar', [ + { id: 'c1', ranges: [{ pos: 0, length: 3 }] }, + { id: 'c2', ranges: [{ pos: 4, length: 3 }] }, + ]) + + // cut "foo" and "bar" in one transaction (two change regions) + view.dispatch({ + changes: [ + { from: 0, to: 3 }, + { from: 4, to: 7 }, + ], + userEvent: 'delete.cut', + }) + + // paste both back in one transaction (two inserted regions) + view.dispatch({ + changes: [ + { from: 0, insert: 'foo' }, + { from: 1, insert: 'bar' }, + ], + userEvent: 'input.paste', + }) + + expect(view.state.doc.toString()).to.equal('foo bar') + const comments = shareDoc.snapshot.getComments().toArray() + const c1 = comments.find((c: any) => c.id === 'c1') + const c2 = comments.find((c: any) => c.id === 'c2') + expect(c1.ranges[0].pos).to.equal(0) + expect(c1.ranges[0].length).to.equal(3) + expect(c2.ranges[0].pos).to.equal(4) + expect(c2.ranges[0].length).to.equal(3) + }) + + it('moves a resolved comment to matching pasted text', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }], resolved: true }, + ]) + + view.dispatch({ changes: { from: 0, to: 5 }, userEvent: 'delete.cut' }) + + const end = view.state.doc.length + view.dispatch({ + changes: { from: end, insert: 'hello' }, + userEvent: 'input.paste', + }) + + // resolution lives on the thread, not the range: the range moves with its + // text exactly like an unresolved comment's, as in ShareLaTeX + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.resolved).to.be.true + expect(comment.ranges).to.have.length(1) + expect(comment.ranges[0].pos).to.equal(end) + expect(comment.ranges[0].length).to.equal(5) + }) + + it('does not restore when pasted text does not match a cut comment', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + + view.dispatch({ changes: { from: 0, to: 5 }, userEvent: 'delete.cut' }) + + const end = view.state.doc.length + view.dispatch({ + changes: { from: end, insert: 'xyz' }, + userEvent: 'input.paste', + }) + + expect(shareDoc.snapshot.getComments().toArray()[0].isEmpty()).to.be.true + }) + + it('moves a comment across tracked deletes on cut and paste', function () { + // 'hello ' is tracked-deleted: hidden in the editor but still in the + // snapshot, so editor and snapshot coordinates diverge by 6 + const { view, shareDoc } = setup( + 'aa hello bb cc', + [{ id: 'c1', ranges: [{ pos: 12, length: 2 }] }], + [{ pos: 3, length: 6 }] + ) + expect(view.state.doc.toString()).to.equal('aa bb cc') + + // cut the commented "cc" and paste it at the start of the visible doc + view.dispatch({ changes: { from: 6, to: 8 }, userEvent: 'delete.cut' }) + view.dispatch({ + changes: { from: 0, insert: 'cc' }, + userEvent: 'input.paste', + }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(1) + const range = comment.ranges[0] + expect( + shareDoc.snapshot.getContent().slice(range.pos, range.pos + range.length) + ).to.equal('cc') + }) + + it('restores a comment at every paste site with track changes enabled', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + view.dispatch(setTrackChangesUserId('user-1')) + + // with track changes on, the cut is a tracked delete (the text stays in + // the snapshot, so the comment never detaches) and each paste is a tracked + // insert; the comment must still move onto the pasted text + view.dispatch({ changes: { from: 0, to: 5 }, userEvent: 'delete.cut' }) + view.dispatch({ + changes: [ + { from: 0, insert: 'hello' }, + { from: 6, insert: 'hello' }, + ], + userEvent: 'input.paste', + }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(2) + expect(comment.ranges[0].pos).to.equal(0) + expect(comment.ranges[1].pos).to.equal(16) + const content = shareDoc.snapshot.getContent() + for (const range of comment.ranges) { + expect(content.slice(range.pos, range.pos + range.length)).to.equal( + 'hello' + ) + } + }) + + it('restores a comment at every paste site in a multi-cursor paste', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + + view.dispatch({ changes: { from: 0, to: 5 }, userEvent: 'delete.cut' }) + // paste "hello" at two cursors in one transaction + view.dispatch({ + changes: [ + { from: 0, insert: 'hello' }, + { from: 6, insert: 'hello' }, + ], + userEvent: 'input.paste', + }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(2) + const content = shareDoc.snapshot.getContent() + for (const range of comment.ranges) { + expect(content.slice(range.pos, range.pos + range.length)).to.equal( + 'hello' + ) + } + }) + + it('restores a comment correctly when typing immediately after a paste', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + + view.dispatch({ changes: { from: 0, to: 5 }, userEvent: 'delete.cut' }) + const end = view.state.doc.length + view.dispatch({ + changes: { from: end, insert: 'hello' }, + userEvent: 'input.paste', + }) + // an edit before the restore has settled must not shift the comment + view.dispatch({ changes: { from: 0, insert: 'X' } }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(1) + const range = comment.ranges[0] + expect( + shareDoc.snapshot.getContent().slice(range.pos, range.pos + range.length) + ).to.equal('hello') + }) + + it('restores a comment correctly when typing immediately after an undo', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + + view.dispatch({ changes: { from: 0, to: 5 } }) + undo(view) + // an edit before the restore has settled must not shift the comment + view.dispatch({ changes: { from: 0, insert: 'X' } }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(1) + const range = comment.ranges[0] + expect( + shareDoc.snapshot.getContent().slice(range.pos, range.pos + range.length) + ).to.equal('hello') + }) + + describe('typing in and around a comment', function () { + it('extends a comment when text is typed inside it', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 11 }] }, + ]) + + view.dispatch({ changes: { from: 5, insert: 'X' } }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(1) + expect(comment.ranges[0].pos).to.equal(0) + expect(comment.ranges[0].length).to.equal(12) + }) + + it('shifts a comment right when text is typed at its start', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 6, length: 5 }] }, + ]) + + view.dispatch({ changes: { from: 6, insert: 'X' } }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(1) + expect(comment.ranges[0].pos).to.equal(7) + expect(comment.ranges[0].length).to.equal(5) + }) + + it('excludes text typed at the end of a comment', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + + view.dispatch({ changes: { from: 5, insert: 'X' } }) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(1) + expect(comment.ranges[0].pos).to.equal(0) + expect(comment.ranges[0].length).to.equal(5) + }) + + it('does not re-attach a detached comment', function () { + const { view, shareDoc } = setup('hello world', [ + { id: 'c1', ranges: [{ pos: 0, length: 5 }] }, + ]) + + // delete "hello" without a cut: the comment detaches + view.dispatch({ changes: { from: 0, to: 5 } }) + expect(shareDoc.snapshot.getComments().toArray()[0].isEmpty()).to.be.true + + view.dispatch({ changes: { from: 0, insert: 'X' } }) + + expect(shareDoc.snapshot.getComments().toArray()[0].isEmpty()).to.be.true + }) + }) + + it('moves every range of a multi-range comment onto matching pasted text', function () { + const { view, shareDoc } = setup('helloX world', [ + { + id: 'c1', + ranges: [ + { pos: 0, length: 5 }, + { pos: 6, length: 6 }, + ], + }, + ]) + + view.dispatch({ changes: { from: 0, to: 12 }, userEvent: 'delete.cut' }) + view.dispatch({ + changes: { from: 0, insert: 'helloX world' }, + userEvent: 'input.paste', + }) + + const comments = shareDoc.snapshot.getComments().toArray() + expect(comments).to.have.length(1) + expect(comments[0].ranges).to.have.length(2) + expect(comments[0].ranges[0].pos).to.equal(0) + expect(comments[0].ranges[0].length).to.equal(5) + expect(comments[0].ranges[1].pos).to.equal(6) + expect(comments[0].ranges[1].length).to.equal(6) + }) + + it('restores every range of a multi-range comment when its deletion is undone', function () { + const { view, shareDoc } = setup('helloX world', [ + { + id: 'c1', + ranges: [ + { pos: 0, length: 5 }, + { pos: 6, length: 6 }, + ], + }, + ]) + + view.dispatch({ changes: { from: 0, to: 12 } }) + expect(shareDoc.snapshot.getComments().toArray()[0].isEmpty()).to.be.true + + undo(view) + + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(2) + expect(comment.ranges[0].pos).to.equal(0) + expect(comment.ranges[0].length).to.equal(5) + expect(comment.ranges[1].pos).to.equal(6) + expect(comment.ranges[1].length).to.equal(6) + }) + + it('restores a multi-range comment cut as two separate regions', function () { + const { view, shareDoc } = setup('fooX bar', [ + { + id: 'c1', + ranges: [ + { pos: 0, length: 3 }, + { pos: 4, length: 4 }, + ], + }, + ]) + + // cut both commented regions in one transaction, leaving "X" + view.dispatch({ + changes: [ + { from: 0, to: 3 }, + { from: 4, to: 8 }, + ], + userEvent: 'delete.cut', + }) + expect(view.state.doc.toString()).to.equal('X') + + // paste both back in one transaction + view.dispatch({ + changes: [ + { from: 0, insert: 'foo' }, + { from: 1, insert: ' bar' }, + ], + userEvent: 'input.paste', + }) + + expect(view.state.doc.toString()).to.equal('fooX bar') + const comment = shareDoc.snapshot.getComments().toArray()[0] + expect(comment.ranges).to.have.length(2) + expect(comment.ranges[0].pos).to.equal(0) + expect(comment.ranges[0].length).to.equal(3) + expect(comment.ranges[1].pos).to.equal(4) + expect(comment.ranges[1].length).to.equal(4) + }) +})