mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Merge pull request #35985 from overleaf/mg-history-ot-comments-preserve
HistoryOT - Preserve comments on cut/paste/undo GitOrigin-RevId: 3c53da9abe599ab80ccc20960a46c6380d597831
This commit is contained in:
@@ -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<CommentOperation>
|
||||
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<CommentRangeValue>
|
||||
>({
|
||||
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<CommentOperation>
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string | null>({
|
||||
},
|
||||
})
|
||||
|
||||
type CutComment = { offset: number; text: string; id: string }
|
||||
type CutSpan = { text: string; comments: CutComment[] }
|
||||
|
||||
const cutCommentsEffect = StateEffect.define<CutSpan[]>()
|
||||
|
||||
// Comments captured by the latest cut, to restore onto matching pasted text.
|
||||
const cutCommentsState = StateField.define<CutSpan[]>({
|
||||
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<CommentRangeValue>[] = []
|
||||
|
||||
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<string, OtRange[]>()
|
||||
|
||||
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) {
|
||||
|
||||
+16
-24
@@ -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<RangeSet<any>>({
|
||||
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
|
||||
|
||||
+490
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user