first commit
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state'
|
||||
import {
|
||||
AnyOperation,
|
||||
Change,
|
||||
CommentOperation,
|
||||
} from '../../../../../../types/change'
|
||||
import { ThreadId } from '../../../../../../types/review-panel/review-panel'
|
||||
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
|
||||
export type StoredComment = {
|
||||
text: string
|
||||
comments: {
|
||||
offset: number
|
||||
text: string
|
||||
comment: Change<CommentOperation>
|
||||
}[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tracked comments within the range of the current transaction's changes
|
||||
*/
|
||||
export const findCommentsInCut = (
|
||||
currentDoc: DocumentContainer,
|
||||
transaction: Transaction
|
||||
) => {
|
||||
const items: StoredComment[] = []
|
||||
|
||||
transaction.changes.iterChanges((fromA, toA) => {
|
||||
const comments = currentDoc
|
||||
.ranges!.comments.filter(
|
||||
comment =>
|
||||
fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA
|
||||
)
|
||||
.map(comment => ({
|
||||
offset: comment.op.p - fromA,
|
||||
text: comment.op.c,
|
||||
comment,
|
||||
}))
|
||||
|
||||
if (comments.length) {
|
||||
items.push({
|
||||
text: transaction.startState.sliceDoc(fromA, toA),
|
||||
comments,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
/**
|
||||
* Find stored comments matching the text of the current transaction's changes
|
||||
*/
|
||||
export const findCommentsInPaste = (
|
||||
storedComments: StoredComment[],
|
||||
transaction: Transaction
|
||||
) => {
|
||||
const ops: CommentOperation[] = []
|
||||
|
||||
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
const insertedText = inserted.toString()
|
||||
|
||||
// note: only using the first match
|
||||
const matchedComment = storedComments.find(
|
||||
item => item.text === insertedText
|
||||
)
|
||||
|
||||
if (matchedComment) {
|
||||
for (const { offset, text, comment } of matchedComment.comments) {
|
||||
// Resubmitting an existing comment op (by thread id) will move it
|
||||
ops.push({
|
||||
c: text,
|
||||
p: fromB + offset,
|
||||
t: comment.id as ThreadId,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ops
|
||||
}
|
||||
|
||||
class CommentRangeValue extends RangeValue {
|
||||
constructor(
|
||||
public content: string,
|
||||
public comment: Change<CommentOperation>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find tracked comments with no content with the ranges of a transaction's changes
|
||||
*/
|
||||
export const findDetachedCommentsInChanges = (
|
||||
currentDoc: DocumentContainer,
|
||||
transaction: Transaction
|
||||
) => {
|
||||
const items: Range<CommentRangeValue>[] = []
|
||||
|
||||
transaction.changes.iterChanges((fromA, toA) => {
|
||||
for (const comment of currentDoc.ranges!.comments) {
|
||||
const content = comment.op.c
|
||||
|
||||
// TODO: handle comments that were never attached
|
||||
if (!content.length) {
|
||||
continue
|
||||
}
|
||||
|
||||
const from = comment.op.p
|
||||
const to = from + content.length
|
||||
|
||||
if (fromA <= from && to <= toA) {
|
||||
items.push(new CommentRangeValue(content, comment).range(from, to))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return RangeSet.of(items, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit operations to the ShareJS doc
|
||||
* (used when restoring comments on paste)
|
||||
*/
|
||||
const submitOps = (
|
||||
currentDoc: DocumentContainer,
|
||||
ops: AnyOperation[],
|
||||
transaction: Transaction
|
||||
) => {
|
||||
for (const op of ops) {
|
||||
currentDoc.submitOp(op)
|
||||
}
|
||||
|
||||
// Check that comments still match text. Will throw error if not.
|
||||
currentDoc.ranges!.validate(transaction.state.doc.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the ShareJS doc to fire an event, then submit the operations.
|
||||
*/
|
||||
const submitOpsAfterEvent = (
|
||||
currentDoc: DocumentContainer,
|
||||
eventName: string,
|
||||
ops: AnyOperation[],
|
||||
transaction: Transaction
|
||||
) => {
|
||||
// We have to wait until the change has been processed by the range
|
||||
// tracker, since if we move the ops into place beforehand, they will be
|
||||
// moved again when the changes are processed by the range tracker. This
|
||||
// ranges:dirty event is fired after the doc has applied the changes to
|
||||
// the range tracker.
|
||||
// TODO: could put this in an update listener instead, if the ShareJS doc has been updated by then?
|
||||
currentDoc.on(eventName, () => {
|
||||
currentDoc.off(eventName)
|
||||
window.setTimeout(() => submitOps(currentDoc, ops, transaction))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Look through the comments stored on cut, and restore those in text that matches the pasted text.
|
||||
*/
|
||||
export const restoreCommentsOnPaste = (
|
||||
currentDoc: DocumentContainer,
|
||||
transaction: Transaction,
|
||||
storedComments: StoredComment[]
|
||||
) => {
|
||||
if (storedComments.length) {
|
||||
const ops = findCommentsInPaste(storedComments, transaction)
|
||||
|
||||
if (ops.length) {
|
||||
submitOpsAfterEvent(
|
||||
currentDoc,
|
||||
'ranges:dirty.paste-cm6',
|
||||
ops,
|
||||
transaction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When undoing a change, find comments from the original content and restore them.
|
||||
*/
|
||||
export const restoreDetachedComments = (
|
||||
currentDoc: DocumentContainer,
|
||||
transaction: Transaction,
|
||||
storedComments: RangeSet<any>
|
||||
) => {
|
||||
const ops: CommentOperation[] = []
|
||||
|
||||
const cursor = storedComments.iter()
|
||||
|
||||
while (cursor.value) {
|
||||
const { id } = cursor.value.comment
|
||||
|
||||
const comment = currentDoc.ranges!.comments.find(item => item.id === id)
|
||||
|
||||
// check that the comment still exists and is detached
|
||||
if (comment && comment.op.c === '') {
|
||||
const content = transaction.state.doc.sliceString(
|
||||
cursor.from,
|
||||
cursor.from + cursor.value.content.length
|
||||
)
|
||||
|
||||
if (cursor.value.content === content) {
|
||||
ops.push({
|
||||
c: cursor.value.content,
|
||||
p: cursor.from,
|
||||
t: id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cursor.next()
|
||||
}
|
||||
|
||||
// FIXME: timing issue with rapid undos
|
||||
if (ops.length) {
|
||||
window.setTimeout(() => {
|
||||
submitOps(currentDoc, ops, transaction)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// submitOpsAfterEvent('ranges:dirty.undo-cm6', ops, transaction)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { Change, EditOperation } from '../../../../../../types/change'
|
||||
import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
|
||||
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
|
||||
import { trackChangesAnnotation } from '@/features/source-editor/extensions/realtime'
|
||||
|
||||
/**
|
||||
* Remove tracked changes from the range tracker when they're rejected,
|
||||
* and restore the original content
|
||||
*/
|
||||
export const rejectChanges = (
|
||||
state: EditorState,
|
||||
ranges: DocumentContainer['ranges'],
|
||||
changeIds: string[]
|
||||
) => {
|
||||
const changes = ranges!.getChanges(changeIds) as Change<EditOperation>[]
|
||||
|
||||
if (changes.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// When doing bulk rejections, adjacent changes might interact with each other.
|
||||
// Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
|
||||
//
|
||||
// "foo bar baz" -> "foo quux baz"
|
||||
//
|
||||
// The change above will be modeled with two ops, with the insertion going first:
|
||||
//
|
||||
// foo quux baz
|
||||
// |--| -> insertion of "quux", op 1, at position 4
|
||||
// | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
|
||||
//
|
||||
// When rejecting these changes at once, if the insertion is rejected first, we get unexpected
|
||||
// results. What happens is:
|
||||
//
|
||||
// 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
|
||||
// starting from position 4;
|
||||
//
|
||||
// "foo quux baz" -> "foo baz"
|
||||
// |--| -> 4 characters to be removed
|
||||
//
|
||||
// 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
|
||||
// the word "quuux" was still present).
|
||||
//
|
||||
// "foo baz" -> "foo bazbar"
|
||||
// | -> deletion of "bar" is reverted by reinserting "bar" at position 8
|
||||
//
|
||||
// While the intended result would be "foo bar baz", what we get is:
|
||||
//
|
||||
// "foo bazbar" (note "bar" readded at position 8)
|
||||
//
|
||||
// The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
|
||||
// from position 4. This includes the position where the deletion exists; when that position is
|
||||
// cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
|
||||
// As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
|
||||
// does so at the outdated position (position 8, which was valid when "quux" was present).
|
||||
//
|
||||
// To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
|
||||
// subsequent operations that come after. Reverse sorting the operations based on position will
|
||||
// achieve it; in the case above, it makes sure that the the deletion is reverted first:
|
||||
//
|
||||
// 1) Rejecting the deletion adds the deleted word "bar" at position 8
|
||||
//
|
||||
// "foo quux baz" -> "foo quuxbar baz"
|
||||
// | -> deletion of "bar" is reverted by
|
||||
// reinserting "bar" at position 8
|
||||
//
|
||||
// 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
|
||||
// starting from position 4 and achieves the expected result:
|
||||
//
|
||||
// "foo quuxbar baz" -> "foo bar baz"
|
||||
// |--| -> 4 characters to be removed
|
||||
|
||||
changes.sort((a, b) => b.op.p - a.op.p)
|
||||
|
||||
const changesToDispatch = changes.map(change => {
|
||||
const { op } = change
|
||||
|
||||
if (isInsertOperation(op)) {
|
||||
const from = op.p
|
||||
const content = op.i
|
||||
const to = from + content.length
|
||||
|
||||
const text = state.doc.sliceString(from, to)
|
||||
|
||||
if (text !== content) {
|
||||
throw new Error(`Op to be removed does not match editor text`)
|
||||
}
|
||||
|
||||
return { from, to, insert: '' }
|
||||
} else if (isDeleteOperation(op)) {
|
||||
return {
|
||||
from: op.p,
|
||||
to: op.p,
|
||||
insert: op.d,
|
||||
}
|
||||
} else {
|
||||
throw new Error(`unknown change type: ${JSON.stringify(change)}`)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
changes: changesToDispatch,
|
||||
annotations: [trackChangesAnnotation.of('reject')],
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user