Kaynağa Gözat

Standardise types for `ranges` (#16927)

GitOrigin-RevId: 28dd0eb67e1684e6bd0e452d15315ce1f9e3481a
Alf Eaton 2 yıl önce
ebeveyn
işleme
7b3ffb9fae

+ 3 - 1
libraries/ranges-tracker/package.json

@@ -2,8 +2,10 @@
   "name": "@overleaf/ranges-tracker",
   "description": "Shared logic for syncing comments and tracked changes with operational transforms",
   "main": "index.cjs",
+  "types": "types/index.d.cts",
   "files": [
-    "index.cjs"
+    "index.cjs",
+    "types"
   ],
   "author": "Overleaf (https://www.overleaf.com)",
   "private": true,

+ 11 - 11
services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx

@@ -16,7 +16,7 @@ import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchd
 import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
 import { debugConsole } from '@/utils/debugging'
-import { Document } from '@/features/ide-react/editor/document'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 import { useLayoutContext } from '@/shared/context/layout-context'
 import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
 import { Doc } from '../../../../../types/doc'
@@ -45,7 +45,7 @@ interface OpenDocOptions
 export type EditorManager = {
   getEditorType: () => EditorType | null
   showSymbolPalette: boolean
-  currentDocument: Document
+  currentDocument: DocumentContainer
   currentDocumentId: DocId | null
   getCurrentDocValue: () => string | null
   getCurrentDocId: () => DocId | null
@@ -73,7 +73,7 @@ function hasGotoOffset(options: OpenDocOptions): options is GotoOffsetOptions {
 export type EditorScopeValue = {
   showSymbolPalette: false
   toggleSymbolPalette: () => void
-  sharejs_doc: Document | null
+  sharejs_doc: DocumentContainer | null
   open_doc_id: string | null
   open_doc_name: string | null
   opening: boolean
@@ -101,7 +101,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
   )
   const [showVisual] = useScopeValue<boolean>('editor.showVisual')
   const [currentDocument, setCurrentDocument] =
-    useScopeValue<Document>('editor.sharejs_doc')
+    useScopeValue<DocumentContainer>('editor.sharejs_doc')
   const [openDocId, setOpenDocId] = useScopeValue<DocId | null>(
     'editor.open_doc_id'
   )
@@ -140,7 +140,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
   // prevents circular dependencies in useCallbacks
   const [docError, setDocError] = useState<{
     doc: Doc
-    document: Document
+    document: DocumentContainer
     error: Error | string
     meta?: Record<string, any>
     editorContent?: string
@@ -225,12 +225,12 @@ export const EditorManagerProvider: FC = ({ children }) => {
     [goToLineEmitter]
   )
 
-  const unbindFromDocumentEvents = (document: Document) => {
+  const unbindFromDocumentEvents = (document: DocumentContainer) => {
     document.off()
   }
 
   const attachErrorHandlerToDocument = useCallback(
-    (doc: Doc, document: Document) => {
+    (doc: Doc, document: DocumentContainer) => {
       document.on(
         'error',
         (
@@ -246,7 +246,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
   )
 
   const bindToDocumentEvents = useCallback(
-    (doc: Doc, document: Document) => {
+    (doc: Doc, document: DocumentContainer) => {
       attachErrorHandlerToDocument(doc, document)
 
       document.on('externalUpdate', (update: Update) => {
@@ -276,7 +276,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
   const syncTimeoutRef = useRef<number | null>(null)
 
   const syncTrackChangesState = useCallback(
-    (doc: Document) => {
+    (doc: DocumentContainer) => {
       if (!doc) {
         return
       }
@@ -310,7 +310,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
 
   const doOpenNewDocument = useCallback(
     (doc: Doc) =>
-      new Promise<Document>((resolve, reject) => {
+      new Promise<DocumentContainer>((resolve, reject) => {
         debugConsole.log('[doOpenNewDocument] Opening...')
         const newDocument = openDocs.getDocument(doc._id)
         if (!newDocument) {
@@ -344,7 +344,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
   )
 
   const openNewDocument = useCallback(
-    async (doc: Doc): Promise<Document> => {
+    async (doc: Doc): Promise<DocumentContainer> => {
       // Leave the current document
       //  - when we are opening a different new one, to avoid race conditions
       //     between leaving and joining the same document

+ 32 - 20
services/web/frontend/js/features/ide-react/context/review-panel/hooks/use-review-panel-state.ts

@@ -27,7 +27,6 @@ import { debugConsole } from '@/utils/debugging'
 import { useEditorContext } from '@/shared/context/editor-context'
 import { deleteJSON, getJSON, postJSON } from '@/infrastructure/fetch-json'
 import ColorManager from '@/ide/colors/ColorManager'
-// @ts-ignore
 import RangesTracker from '@overleaf/ranges-tracker'
 import * as ReviewPanel from '../types/review-panel-state'
 import {
@@ -61,6 +60,12 @@ import {
   ReviewPanelCommentThreadsApi,
 } from '../../../../../../../types/review-panel/api'
 import { DateString } from '../../../../../../../types/helpers/date'
+import {
+  Change,
+  CommentOperation,
+  EditOperation,
+} from '../../../../../../../types/change'
+import { RangesTrackerWithResolvedThreadIds } from '@/features/ide-react/editor/document-container'
 
 const dispatchReviewPanelEvent = (type: string, payload?: any) => {
   window.dispatchEvent(
@@ -251,7 +256,9 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
       })
   }, [loadThreadsController.signal, projectId, setLoadingThreads])
 
-  const rangesTrackers = useRef<Record<DocId, RangesTracker>>({})
+  const rangesTrackers = useRef<
+    Record<DocId, RangesTrackerWithResolvedThreadIds>
+  >({})
   const refreshingRangeUsers = useRef(false)
   const refreshedForUserIds = useRef(new Set<UserId>())
   const refreshChangeUsers = useCallback(
@@ -299,12 +306,14 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
   const getChangeTracker = useCallback(
     (docId: DocId) => {
       if (!rangesTrackers.current[docId]) {
-        rangesTrackers.current[docId] = new RangesTracker()
-        rangesTrackers.current[docId].resolvedThreadIds = {
-          ...resolvedThreadIds,
-        }
+        const rangesTracker = new RangesTracker([], [])
+        ;(
+          rangesTracker as RangesTrackerWithResolvedThreadIds
+        ).resolvedThreadIds = { ...resolvedThreadIds }
+        rangesTrackers.current[docId] =
+          rangesTracker as RangesTrackerWithResolvedThreadIds
       }
-      return rangesTrackers.current[docId]
+      return rangesTrackers.current[docId]!
     },
     [resolvedThreadIds]
   )
@@ -435,17 +444,18 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
 
       if (!loadingThreadsInProgressRef.current) {
         for (const comment of rangesTracker.comments) {
-          deleteChanges.delete(comment.id)
+          const commentId = comment.id as ThreadId
+          deleteChanges.delete(commentId)
 
           let newComment: any
           if (localResolvedThreadIds[comment.op.t]) {
-            docResolvedComments[comment.id] ??= {} as ReviewPanelCommentEntry
-            newComment = docResolvedComments[comment.id]
-            delete docEntries[comment.id]
+            docResolvedComments[commentId] ??= {} as ReviewPanelCommentEntry
+            newComment = docResolvedComments[commentId]
+            delete docEntries[commentId]
           } else {
-            docEntries[comment.id] ??= {} as ReviewPanelEntry
-            newComment = docEntries[comment.id]
-            delete docResolvedComments[comment.id]
+            docEntries[commentId] ??= {} as ReviewPanelEntry
+            newComment = docEntries[commentId]
+            delete docResolvedComments[commentId]
           }
 
           newComment.type = 'comment'
@@ -505,10 +515,12 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
     }
     // The open doc range tracker is kept up to date in real-time so
     // replace any outdated info with this
+    const rangesTracker = currentDocument.ranges!
+    ;(rangesTracker as RangesTrackerWithResolvedThreadIds).resolvedThreadIds = {
+      ...resolvedThreadIds,
+    }
     rangesTrackers.current[currentDocument.doc_id as DocId] =
-      currentDocument.ranges
-    rangesTrackers.current[currentDocument.doc_id as DocId].resolvedThreadIds =
-      { ...resolvedThreadIds }
+      rangesTracker as RangesTrackerWithResolvedThreadIds
     currentDocument.on('flipped_pending_to_inflight', () =>
       regenerateTrackChangesId(currentDocument)
     )
@@ -1034,8 +1046,8 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
     type Doc = {
       id: DocId
       ranges: {
-        comments?: unknown[]
-        changes?: unknown[]
+        comments?: Change<CommentOperation>[]
+        changes?: Change<EditOperation>[]
       }
     }
 
@@ -1119,7 +1131,7 @@ function useReviewPanelState(): ReviewPanelStateReactIde {
       }
 
       const { offset, length } = addCommentEntry
-      const threadId = RangesTracker.generateId()
+      const threadId = RangesTracker.generateId() as ThreadId
       setCommentThreads(prevState => ({
         ...prevState,
         [threadId]: { ...getThread(threadId), submitting: true },

+ 36 - 23
services/web/frontend/js/features/ide-react/editor/document.ts → services/web/frontend/js/features/ide-react/editor/document-container.ts

@@ -1,11 +1,6 @@
-/* eslint-disable
-    camelcase,
-    n/handle-callback-err,
-    max-len,
-*/
+/* eslint-disable camelcase */
 // Migrated from services/web/frontend/js/ide/editor/Document.js
 
-// @ts-ignore
 import RangesTracker from '@overleaf/ranges-tracker'
 import { ShareJsDoc } from './share-js-doc'
 import { debugConsole } from '@/utils/debugging'
@@ -19,6 +14,7 @@ import {
   AnyOperation,
   Change,
   CommentOperation,
+  EditOperation,
 } from '../../../../../types/change'
 import {
   isCommentOperation,
@@ -31,6 +27,7 @@ import {
   TrackChangesIdSeeds,
   Version,
 } from '@/features/ide-react/editor/types/document'
+import { ThreadId } from '../../../../../types/review-panel/review-panel'
 
 const MAX_PENDING_OP_SIZE = 64
 
@@ -79,11 +76,22 @@ function getShareJsOpSize(shareJsOp: ShareJsOperation) {
   return shareJsOp.reduce((total, op) => total + getOpSize(op), 0)
 }
 
-export class Document extends EventEmitter {
+// TODO: define these in RangesTracker
+type _RangesTracker = Omit<RangesTracker, 'changes' | 'comments'> & {
+  changes: Change<EditOperation>[]
+  comments: Change<CommentOperation>[]
+  track_changes?: boolean
+}
+
+export type RangesTrackerWithResolvedThreadIds = _RangesTracker & {
+  resolvedThreadIds: Record<ThreadId, boolean>
+}
+
+export class DocumentContainer extends EventEmitter {
   private connected: boolean
   private wantToBeJoined = false
   private chaosMonkeyTimer: number | null = null
-  private track_changes_as: string | null = null
+  public track_changes_as: string | null = null
 
   private joinCallbacks: JoinCallback[] = []
   private leaveCallbacks: LeaveCallback[] = []
@@ -91,7 +99,9 @@ export class Document extends EventEmitter {
   doc?: ShareJsDoc
   cm6?: EditorFacade
   oldInflightOp?: ShareJsOperation
-  ranges: RangesTracker
+
+  ranges?: _RangesTracker | RangesTrackerWithResolvedThreadIds
+
   joined = false
 
   // This is set and read in useCodeMirrorScope
@@ -103,7 +113,7 @@ export class Document extends EventEmitter {
     private readonly globalEditorWatchdogManager: EditorWatchdogManager,
     private readonly ideEventEmitter: IdeEventEmitter,
     private readonly eventLog: EventLog,
-    private readonly detachDoc: (docId: string, doc: Document) => void
+    private readonly detachDoc: (docId: string, doc: DocumentContainer) => void
   ) {
     super()
     this.connected = this.socket.socket.connected
@@ -675,18 +685,18 @@ export class Document extends EventEmitter {
     let track_changes_as = null
     const remote_op = msg != null
     if (remote_op && msg?.meta.tc) {
-      old_id_seed = this.ranges.getIdSeed()
-      this.ranges.setIdSeed(msg.meta.tc)
+      old_id_seed = this.ranges!.getIdSeed()
+      this.ranges!.setIdSeed(msg.meta.tc)
       track_changes_as = msg.meta.user_id
     } else if (!remote_op && this.track_changes_as != null) {
       track_changes_as = this.track_changes_as
     }
-    this.ranges.track_changes = track_changes_as != null
+    this.ranges!.track_changes = track_changes_as != null
     for (const op of this.filterOps(ops)) {
-      this.ranges.applyOp(op, { user_id: track_changes_as })
+      this.ranges!.applyOp(op, { user_id: track_changes_as })
     }
     if (old_id_seed != null) {
-      this.ranges.setIdSeed(old_id_seed)
+      this.ranges!.setIdSeed(old_id_seed)
     }
     if (remote_op) {
       // With remote ops, the editor hasn't been updated when we receive this
@@ -697,7 +707,10 @@ export class Document extends EventEmitter {
     }
   }
 
-  private catchUpRanges(changes: Change[], comments: CommentOperation[]) {
+  private catchUpRanges(
+    changes: Change<EditOperation>[],
+    comments: Change<CommentOperation>[]
+  ) {
     // We've just been given the current server's ranges, but need to apply any local ops we have.
     // Reset to the server state then apply our local ops again.
     if (changes == null) {
@@ -707,16 +720,16 @@ export class Document extends EventEmitter {
       comments = []
     }
     this.emit('ranges:clear')
-    this.ranges.changes = changes
-    this.ranges.comments = comments
-    this.ranges.track_changes = this.doc?.track_changes
+    this.ranges!.changes = changes
+    this.ranges!.comments = comments
+    this.ranges!.track_changes = this.doc?.track_changes
     for (const op of this.filterOps(this.doc?.getInflightOp() || [])) {
-      this.ranges.setIdSeed(this.doc?.track_changes_id_seeds?.inflight)
-      this.ranges.applyOp(op, { user_id: this.track_changes_as })
+      this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.inflight)
+      this.ranges!.applyOp(op, { user_id: this.track_changes_as })
     }
     for (const op of this.filterOps(this.doc?.getPendingOp() || [])) {
-      this.ranges.setIdSeed(this.doc?.track_changes_id_seeds?.pending)
-      this.ranges.applyOp(op, { user_id: this.track_changes_as })
+      this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.pending)
+      this.ranges!.applyOp(op, { user_id: this.track_changes_as })
     }
     return this.emit('ranges:redraw')
   }

+ 4 - 4
services/web/frontend/js/features/ide-react/editor/open-documents.ts

@@ -1,6 +1,6 @@
 // Migrated from static methods of Document in Document.js
 
-import { Document } from '@/features/ide-react/editor/document'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 import { debugConsole } from '@/utils/debugging'
 import { Socket } from '@/features/ide-react/connection/types/socket'
 import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
@@ -8,7 +8,7 @@ import { EventLog } from '@/features/ide-react/editor/event-log'
 import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
 
 export class OpenDocuments {
-  private openDocs = new Map<string, Document>()
+  private openDocs = new Map<string, DocumentContainer>()
 
   // eslint-disable-next-line no-useless-constructor
   constructor(
@@ -44,7 +44,7 @@ export class OpenDocuments {
   }
 
   private createDoc(docId: string) {
-    const doc = new Document(
+    const doc = new DocumentContainer(
       docId,
       this.socket,
       this.globalEditorWatchdogManager,
@@ -55,7 +55,7 @@ export class OpenDocuments {
     this.openDocs.set(docId, doc)
   }
 
-  detachDoc(docId: string, doc: Document) {
+  detachDoc(docId: string, doc: DocumentContainer) {
     if (this.openDocs.get(docId) === doc) {
       debugConsole.log(
         `[detach] Removing document with ID (${docId}) from openDocs`

+ 30 - 33
services/web/frontend/js/features/source-editor/extensions/changes/change-manager.ts

@@ -8,9 +8,12 @@ import {
 } from '../vertical-overflow'
 import { EditorSelection, EditorState } from '@codemirror/state'
 import { EditorView, ViewUpdate } from '@codemirror/view'
-import { CurrentDoc } from '../../../../../../types/current-doc'
 import { fullHeightCoordsAtPos } from '../../utils/layer'
 import { debounce } from 'lodash'
+import { Change, EditOperation } from '../../../../../../types/change'
+import { ThreadId } from '../../../../../../types/review-panel/review-panel'
+import { isDeleteOperation, isInsertOperation } from '@/utils/operations'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 
 // With less than this number of entries, don't bother culling to avoid
 // little UI jumps when scrolling.
@@ -75,7 +78,7 @@ export type UpdateType =
 
 export const createChangeManager = (
   view: EditorView,
-  currentDoc: CurrentDoc
+  currentDoc: DocumentContainer
 ): ChangeManager => {
   /**
    * Calculate the screen coordinates of each entry (change or comment),
@@ -152,7 +155,7 @@ export const createChangeManager = (
   /**
    * Add a comment (thread) to the ShareJS doc when it's created
    */
-  const addComment = (offset: number, length: number, threadId: string) => {
+  const addComment = (offset: number, length: number, threadId: ThreadId) => {
     currentDoc.submitOp({
       c: view.state.doc.sliceString(offset, offset + length),
       p: offset,
@@ -164,14 +167,14 @@ export const createChangeManager = (
    * Remove a comment (thread) from the range tracker when it's deleted
    */
   const removeComment = (commentId: string) => {
-    currentDoc.ranges.removeCommentId(commentId)
+    currentDoc.ranges!.removeCommentId(commentId)
   }
 
   /**
    * Remove tracked changes from the range tracker when they're accepted
    */
   const acceptChanges = (changeIds: string[]) => {
-    currentDoc.ranges.removeChangeIds(changeIds)
+    currentDoc.ranges!.removeChangeIds(changeIds)
   }
 
   /**
@@ -179,7 +182,9 @@ export const createChangeManager = (
    * and restore the original content
    */
   const rejectChanges = (changeIds: string[]) => {
-    const changes: any[] = currentDoc.ranges.getChanges(changeIds)
+    const changes = currentDoc.ranges!.getChanges(
+      changeIds
+    ) as Change<EditOperation>[]
 
     if (changes.length === 0) {
       return {}
@@ -242,38 +247,30 @@ export const createChangeManager = (
     const changesToDispatch = changes.map(change => {
       const { op } = change
 
-      const opType = 'i' in op ? 'i' : 'c' in op ? 'c' : 'd'
+      if (isInsertOperation(op)) {
+        const from = op.p
+        const content = op.i
+        const to = from + content.length
 
-      switch (opType) {
-        case 'd': {
-          return {
-            from: op.p,
-            to: op.p,
-            insert: op.d,
-          }
-        }
-
-        case 'i': {
-          const from = op.p
-          const content = op.i
-          const to = from + content.length
-
-          const text = view.state.doc.sliceString(from, to)
+        const text = view.state.doc.sliceString(from, to)
 
-          if (text !== content) {
-            throw new Error(
-              `Op to be removed (${JSON.stringify(
-                change.op
-              )}) does not match editor text '${text}'`
-            )
-          }
-
-          return { from, to, insert: '' }
+        if (text !== content) {
+          throw new Error(
+            `Op to be removed (${JSON.stringify(
+              change.op
+            )}) does not match editor text '${text}'`
+          )
         }
 
-        default: {
-          throw new Error(`unknown change: ${JSON.stringify(change)}`)
+        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)}`)
       }
     })
 

+ 16 - 16
services/web/frontend/js/features/source-editor/extensions/changes/comments.ts

@@ -1,11 +1,11 @@
 import { Range, RangeSet, RangeValue, Transaction } from '@codemirror/state'
-import { CurrentDoc } from '../../../../../../types/current-doc'
 import {
   AnyOperation,
   Change,
-  ChangeOperation,
   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
@@ -20,14 +20,14 @@ export type StoredComment = {
  * Find tracked comments within the range of the current transaction's changes
  */
 export const findCommentsInCut = (
-  currentDoc: CurrentDoc,
+  currentDoc: DocumentContainer,
   transaction: Transaction
 ) => {
   const items: StoredComment[] = []
 
   transaction.changes.iterChanges((fromA, toA) => {
-    const comments = currentDoc.ranges.comments
-      .filter(
+    const comments = currentDoc
+      .ranges!.comments.filter(
         comment =>
           fromA <= comment.op.p && comment.op.p + comment.op.c.length <= toA
       )
@@ -55,7 +55,7 @@ export const findCommentsInPaste = (
   storedComments: StoredComment[],
   transaction: Transaction
 ) => {
-  const ops: ChangeOperation[] = []
+  const ops: CommentOperation[] = []
 
   transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
     const insertedText = inserted.toString()
@@ -71,7 +71,7 @@ export const findCommentsInPaste = (
         ops.push({
           c: text,
           p: fromB + offset,
-          t: comment.id,
+          t: comment.id as ThreadId,
         })
       }
     }
@@ -93,13 +93,13 @@ class CommentRangeValue extends RangeValue {
  * Find tracked comments with no content with the ranges of a transaction's changes
  */
 export const findDetachedCommentsInChanges = (
-  currentDoc: CurrentDoc,
+  currentDoc: DocumentContainer,
   transaction: Transaction
 ) => {
   const items: Range<CommentRangeValue>[] = []
 
   transaction.changes.iterChanges((fromA, toA) => {
-    for (const comment of currentDoc.ranges.comments) {
+    for (const comment of currentDoc.ranges!.comments) {
       const content = comment.op.c
 
       // TODO: handle comments that were never attached
@@ -124,7 +124,7 @@ export const findDetachedCommentsInChanges = (
  * (used when restoring comments on paste)
  */
 const submitOps = (
-  currentDoc: CurrentDoc,
+  currentDoc: DocumentContainer,
   ops: AnyOperation[],
   transaction: Transaction
 ) => {
@@ -133,14 +133,14 @@ const submitOps = (
   }
 
   // Check that comments still match text. Will throw error if not.
-  currentDoc.ranges.validate(transaction.state.doc.toString())
+  currentDoc.ranges!.validate(transaction.state.doc.toString())
 }
 
 /**
  * Wait for the ShareJS doc to fire an event, then submit the operations.
  */
 const submitOpsAfterEvent = (
-  currentDoc: CurrentDoc,
+  currentDoc: DocumentContainer,
   eventName: string,
   ops: AnyOperation[],
   transaction: Transaction
@@ -161,7 +161,7 @@ const submitOpsAfterEvent = (
  * Look through the comments stored on cut, and restore those in text that matches the pasted text.
  */
 export const restoreCommentsOnPaste = (
-  currentDoc: CurrentDoc,
+  currentDoc: DocumentContainer,
   transaction: Transaction,
   storedComments: StoredComment[]
 ) => {
@@ -183,18 +183,18 @@ export const restoreCommentsOnPaste = (
  * When undoing a change, find comments from the original content and restore them.
  */
 export const restoreDetachedComments = (
-  currentDoc: CurrentDoc,
+  currentDoc: DocumentContainer,
   transaction: Transaction,
   storedComments: RangeSet<any>
 ) => {
-  const ops: ChangeOperation[] = []
+  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)
+    const comment = currentDoc.ranges!.comments.find(item => item.id === id)
 
     // check that the comment still exists and is detached
     if (comment && comment.op.c === '') {

+ 2 - 2
services/web/frontend/js/features/source-editor/extensions/realtime.ts

@@ -1,9 +1,9 @@
 import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state'
 import { EditorView, ViewPlugin } from '@codemirror/view'
 import { EventEmitter } from 'events'
-import { CurrentDoc } from '../../../../../types/current-doc'
 import { ShareDoc } from '../../../../../types/share-doc'
 import { debugConsole } from '@/utils/debugging'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 
 /*
  * Integrate CodeMirror 6 with the real-time system, via ShareJS.
@@ -34,7 +34,7 @@ export type ChangeDescription = {
  * A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document.
  */
 export const realtime = (
-  { currentDoc }: { currentDoc: CurrentDoc },
+  { currentDoc }: { currentDoc: DocumentContainer },
   handleError: (error: Error) => void
 ) => {
   const realtimePlugin = ViewPlugin.define(view => {

+ 20 - 13
services/web/frontend/js/features/source-editor/extensions/track-changes.ts

@@ -21,15 +21,14 @@ import {
   StoredComment,
 } from './changes/comments'
 import { invertedEffects } from '@codemirror/commands'
-import { CurrentDoc } from '../../../../../types/current-doc'
 import { Change, DeleteOperation } from '../../../../../types/change'
 import { ChangeManager } from './changes/change-manager'
 import { debugConsole } from '@/utils/debugging'
+import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
 import {
-  isChangeOperation,
-  isCommentOperation,
-  isDeleteOperation,
-} from '@/utils/operations'
+  DocumentContainer,
+  RangesTrackerWithResolvedThreadIds,
+} from '@/features/ide-react/editor/document-container'
 
 const clearChangesEffect = StateEffect.define()
 const buildChangesEffect = StateEffect.define()
@@ -46,7 +45,7 @@ const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
 })
 
 type Options = {
-  currentDoc: CurrentDoc
+  currentDoc: DocumentContainer
   loadingThreads: boolean
 }
 
@@ -182,7 +181,11 @@ export const buildChangeMarkers = () => {
   }
 }
 
-const buildChangeDecorations = (currentDoc: CurrentDoc) => {
+const buildChangeDecorations = (currentDoc: DocumentContainer) => {
+  if (!currentDoc.ranges) {
+    return Decoration.none
+  }
+
   const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
 
   const decorations = []
@@ -245,7 +248,7 @@ class ChangeCalloutWidget extends WidgetType {
   }
 }
 
-const createChangeRange = (change: Change, currentDoc: CurrentDoc) => {
+const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
   const { id, metadata, op } = change
 
   const from = op.p
@@ -273,14 +276,18 @@ const createChangeRange = (change: Change, currentDoc: CurrentDoc) => {
     return [calloutWidget.range(from, from), changeWidget.range(from, from)]
   }
 
-  if (isChangeOperation(op) && currentDoc.ranges.resolvedThreadIds[op.t]) {
+  const _isCommentOperation = isCommentOperation(op)
+
+  if (
+    _isCommentOperation &&
+    (currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
+      .resolvedThreadIds![op.t]
+  ) {
     return []
   }
 
-  const isChangeOrCommentOperation =
-    isChangeOperation(op) || isCommentOperation(op)
-  const opType = isChangeOrCommentOperation ? 'c' : 'i'
-  const changedText = isChangeOrCommentOperation ? op.c : op.i
+  const opType = _isCommentOperation ? 'c' : 'i'
+  const changedText = _isCommentOperation ? op.c : op.i
   const to = from + changedText.length
 
   // Mark decorations must not be empty

+ 4 - 2
services/web/frontend/js/features/source-editor/hooks/use-codemirror-scope.ts

@@ -47,7 +47,6 @@ import {
 import { setKeybindings } from '../extensions/keybindings'
 import { Highlight } from '../../../../../types/highlight'
 import { EditorView } from '@codemirror/view'
-import { CurrentDoc } from '../../../../../types/current-doc'
 import { useErrorHandler } from 'react-error-boundary'
 import { setVisual } from '../extensions/visual/visual'
 import { useFileTreePathContext } from '@/features/file-tree/contexts/file-tree-path'
@@ -56,6 +55,7 @@ import { setDocName } from '@/features/source-editor/extensions/doc-name'
 import isValidTexFile from '@/main/is-valid-tex-file'
 import { captureException } from '@/infrastructure/error-reporter'
 import grammarlyExtensionPresent from '@/shared/utils/grammarly'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 
 function useCodeMirrorScope(view: EditorView) {
   const ide = useIdeContext()
@@ -71,7 +71,9 @@ function useCodeMirrorScope(view: EditorView) {
 
   const [loadingThreads] = useScopeValue<boolean>('loadingThreads')
 
-  const [currentDoc] = useScopeValue<CurrentDoc | null>('editor.sharejs_doc')
+  const [currentDoc] = useScopeValue<DocumentContainer | null>(
+    'editor.sharejs_doc'
+  )
   const [docName] = useScopeValue<string>('editor.open_doc_name')
   const [trackChanges] = useScopeValue<boolean>('editor.trackChanges')
 

+ 1 - 4
services/web/frontend/js/utils/operations.ts

@@ -1,5 +1,4 @@
 import {
-  ChangeOperation,
   CommentOperation,
   DeleteOperation,
   InsertOperation,
@@ -8,9 +7,7 @@ import {
 
 export const isInsertOperation = (op: Operation): op is InsertOperation =>
   'i' in op
-export const isChangeOperation = (op: Operation): op is ChangeOperation =>
-  'c' in op && 't' in op
 export const isCommentOperation = (op: Operation): op is CommentOperation =>
-  'c' in op && !('t' in op)
+  'c' in op
 export const isDeleteOperation = (op: Operation): op is DeleteOperation =>
   'd' in op

+ 11 - 16
services/web/types/change.ts

@@ -1,36 +1,31 @@
+import { ThreadId } from './review-panel/review-panel'
+import { UserId } from './user'
+
 export interface Operation {
-  p: number
+  p: number // position
 }
 
 export interface InsertOperation extends Operation {
-  i: string
-  t: string
-}
-
-export interface ChangeOperation extends Operation {
-  c: string
-  t: string
+  i: string // inserted text
 }
 
 export interface DeleteOperation extends Operation {
-  d: string
+  d: string // deleted text
 }
 
 export interface CommentOperation extends Operation {
-  c: string
+  c: string // comment text
+  t: ThreadId // thread/comment id
 }
 
-export type NonCommentOperation =
-  | InsertOperation
-  | ChangeOperation
-  | DeleteOperation
+export type EditOperation = InsertOperation | DeleteOperation
 
-export type AnyOperation = NonCommentOperation | CommentOperation
+export type AnyOperation = EditOperation | CommentOperation
 
 export type Change<T extends AnyOperation = AnyOperation> = {
   id: string
   metadata?: {
-    user_id: string
+    user_id: UserId | null
     ts: Date
   }
   op: T

+ 0 - 39
services/web/types/current-doc.ts

@@ -1,39 +0,0 @@
-import EventEmitter from '../frontend/js/utils/EventEmitter'
-import { ShareDoc } from './share-doc'
-import { EditorFacade } from '../frontend/js/features/source-editor/extensions/realtime'
-import {
-  AnyOperation,
-  Change,
-  ChangeOperation,
-  CommentOperation,
-  DeleteOperation,
-  InsertOperation,
-} from './change'
-
-// type for the Document class in ide/editor/Document.js
-// note: this is a custom EventEmitter class
-
-// TODO: MIGRATION: This doesn't match the type for
-// ide-react/editor/document.ts, which has a nullable `ranges` property and some
-// other quirks. They should match.
-export interface CurrentDoc extends EventEmitter {
-  doc_id: string
-  docName: string
-  doc: ShareDoc | null
-  track_changes_as: string | null
-  ranges: {
-    changes: Change<InsertOperation | ChangeOperation | DeleteOperation>[]
-    comments: Change<CommentOperation>[]
-    resolvedThreadIds: Record<string, any>
-    removeCommentId: (id: string) => void
-    removeChangeIds: (ids: string[]) => void
-    getChanges: (
-      ids: string[]
-    ) => Change<InsertOperation | ChangeOperation | DeleteOperation>[]
-    validate: (text: string) => void
-  }
-  attachToCM6: (editor: EditorFacade) => void
-  detachFromCM6: () => void
-  submitOp: (op: AnyOperation) => void
-  getSnapshot: () => string
-}