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 }[] } /** * 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 ) { 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[] = [] 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 ) => { 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) }