Przeglądaj źródła

Use tracked changes and comments from the snapshot (#26267)

GitOrigin-RevId: c2bf0c9c9a5ab4f8837b8712ca549119a31cf067
Alf Eaton 11 miesięcy temu
rodzic
commit
2c1baa717d
21 zmienionych plików z 737 dodań i 267 usunięć
  1. 7 0
      libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js
  2. 1 0
      services/web/app/src/Features/Project/ProjectController.js
  3. 1 0
      services/web/app/views/project/editor/_meta.pug
  4. 28 0
      services/web/frontend/js/features/ide-react/editor/document-container.ts
  5. 67 0
      services/web/frontend/js/features/ide-react/editor/share-js-doc.ts
  6. 5 5
      services/web/frontend/js/features/review-panel/components/review-panel-change.tsx
  7. 5 1
      services/web/frontend/js/features/review-panel/components/review-panel-current-file.tsx
  8. 3 0
      services/web/frontend/js/features/review-panel/components/review-panel-overview-file.tsx
  9. 3 1
      services/web/frontend/js/features/review-panel/components/review-panel-overview.tsx
  10. 15 3
      services/web/frontend/js/features/review-panel/components/review-panel-resolved-threads-menu.tsx
  11. 13 15
      services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx
  12. 200 24
      services/web/frontend/js/features/review-panel/context/ranges-context.tsx
  13. 74 10
      services/web/frontend/js/features/review-panel/context/threads-context.tsx
  14. 42 18
      services/web/frontend/js/features/review-panel/hooks/use-project-ranges.ts
  15. 74 0
      services/web/frontend/js/features/review-panel/utils/snapshot-ranges.ts
  16. 99 171
      services/web/frontend/js/features/source-editor/extensions/history-ot.ts
  17. 9 18
      services/web/frontend/js/features/source-editor/extensions/realtime.ts
  18. 77 0
      services/web/frontend/js/features/source-editor/utils/tracked-deletes.ts
  19. 12 1
      services/web/frontend/js/infrastructure/project-snapshot.ts
  20. 1 0
      services/web/frontend/js/utils/meta.ts
  21. 1 0
      services/web/test/frontend/features/source-editor/helpers/mock-doc.ts

+ 7 - 0
libraries/overleaf-editor-core/lib/file_data/tracked_change_list.js

@@ -21,6 +21,13 @@ class TrackedChangeList {
     this._trackedChanges = trackedChanges
   }
 
+  /**
+   * @returns {IterableIterator<TrackedChange>}
+   */
+  [Symbol.iterator]() {
+    return this._trackedChanges.values()
+  }
+
   /**
    *
    * @param {TrackedChangeRawData[]} raw

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -871,6 +871,7 @@ const _ProjectController = {
         showUpgradePrompt,
         fixedSizeDocument: true,
         hasTrackChangesFeature: Features.hasFeature('track-changes'),
+        otMigrationStage: project.overleaf?.history?.otMigrationStage ?? 0,
         projectTags,
         isSaas: Features.hasFeature('saas'),
         shouldLoadHotjar: splitTestAssignments.hotjar?.variant === 'enabled',

+ 1 - 0
services/web/app/views/project/editor/_meta.pug

@@ -34,6 +34,7 @@ meta(name="ol-showUpgradePrompt" data-type="boolean" content=showUpgradePrompt)
 meta(name="ol-showSupport", data-type="boolean" content=showSupport)
 meta(name="ol-showTemplatesServerPro", data-type="boolean" content=showTemplatesServerPro)
 meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChangesFeature)
+meta(name="ol-otMigrationStage", data-type="number" content=otMigrationStage)
 meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials)
 meta(name="ol-projectTags" data-type="json" content=projectTags)
 meta(name="ol-ro-mirror-on-client-no-local-storage" data-type="boolean" content=roMirrorOnClientNoLocalStorage)

+ 28 - 0
services/web/frontend/js/features/ide-react/editor/document-container.ts

@@ -29,6 +29,10 @@ import {
 import { ThreadId } from '../../../../../types/review-panel/review-panel'
 import getMeta from '@/utils/meta'
 import OError from '@overleaf/o-error'
+import {
+  HistoryOTShareDoc,
+  ShareLatexOTShareDoc,
+} from '../../../../../types/share-doc'
 
 const MAX_PENDING_OP_SIZE = 64
 
@@ -121,6 +125,27 @@ export class DocumentContainer extends EventEmitter {
     this.bindToSocketEvents()
   }
 
+  get shareDoc() {
+    if (!this.doc) {
+      throw new Error('Missing ShareJSDoc')
+    }
+    if (!this.doc._doc) {
+      throw new Error('Missing ShareJS Doc')
+    }
+    return this.doc._doc as HistoryOTShareDoc | ShareLatexOTShareDoc
+  }
+
+  isHistoryOT() {
+    return this.shareDoc.otType === 'history-ot'
+  }
+
+  get historyOTShareDoc() {
+    if (!this.isHistoryOT()) {
+      throw new Error('shareDoc is not historyOT')
+    }
+    return this.shareDoc as HistoryOTShareDoc
+  }
+
   attachToCM6(cm6: EditorFacade) {
     this.cm6 = cm6
     if (this.doc) {
@@ -613,6 +638,9 @@ export class DocumentContainer extends EventEmitter {
       this.trigger('op:timeout')
       return this.onError(new Error('op timed out'))
     })
+    this.doc.on('ranges:dirty', (...args) => {
+      return this.trigger('ranges:dirty', ...args)
+    })
 
     let docChangedTimeout: number | null = null
     this.doc.on(

+ 67 - 0
services/web/frontend/js/features/ide-react/editor/share-js-doc.ts

@@ -22,11 +22,13 @@ import {
   StringFileData,
   TrackedChangeList,
   EditOperationBuilder,
+  CommentList,
 } from 'overleaf-editor-core'
 import {
   StringFileRawData,
   RawEditOperation,
 } from 'overleaf-editor-core/lib/types'
+import { HistoryOTShareDoc } from '../../../../../types/share-doc'
 
 // All times below are in milliseconds
 const SINGLE_USER_FLUSH_DELAY = 2000
@@ -267,11 +269,26 @@ export class ShareJsDoc extends EventEmitter {
   processUpdateFromServer(message: Message) {
     try {
       if (this.type === 'history-ot' && message.op != null) {
+        const shareDoc = this._doc as HistoryOTShareDoc
+        const trackedChangesBefore = shareDoc.snapshot.getTrackedChanges()
+        const commentsBefore = shareDoc.snapshot.getComments()
+
         const ops = message.op as RawEditOperation[]
         this._doc._onMessage({
           ...message,
           op: ops.map(EditOperationBuilder.fromJSON),
         })
+
+        if (
+          this.rangesUpdated(
+            trackedChangesBefore,
+            commentsBefore,
+            shareDoc.snapshot.getTrackedChanges(),
+            shareDoc.snapshot.getComments()
+          )
+        ) {
+          this.trigger('ranges:dirty')
+        }
       } else {
         this._doc._onMessage(message)
       }
@@ -471,6 +488,29 @@ export class ShareJsDoc extends EventEmitter {
       doc.pendingCallbacks.push(() => {
         return this.trigger('op:acknowledged', op)
       })
+
+      // history-ot: submit the op and detect whether tracked changes or comments have updated
+      if (this.type === 'history-ot') {
+        const shareDoc = doc as HistoryOTShareDoc
+        const trackedChangesBefore = shareDoc.snapshot.getTrackedChanges()
+        const commentsBefore = shareDoc.snapshot.getComments()
+        const result = submitOp.call(doc, op, callback)
+
+        if (
+          this.rangesUpdated(
+            trackedChangesBefore,
+            commentsBefore,
+            shareDoc.snapshot.getTrackedChanges(),
+            shareDoc.snapshot.getComments()
+          )
+        ) {
+          this.trigger('ranges:dirty')
+        }
+
+        return result
+      }
+
+      // non-history-ot: just submit the op
       return submitOp.call(doc, op, callback)
     }
 
@@ -480,4 +520,31 @@ export class ShareJsDoc extends EventEmitter {
       return flush.call(doc)
     }
   }
+
+  private rangesUpdated(
+    trackedChangesBefore: TrackedChangeList,
+    commentsBefore: CommentList,
+    trackedChangesAfter: TrackedChangeList,
+    commentsAfter: CommentList
+  ) {
+    return (
+      // quick length comparison first
+      trackedChangesBefore.length !== trackedChangesAfter.length ||
+      commentsBefore.length !== commentsAfter.length ||
+      // then compare each item by identity
+      this.itemsChanged(
+        trackedChangesBefore.asSorted(),
+        trackedChangesAfter.asSorted()
+      ) ||
+      this.itemsChanged(commentsBefore.toArray(), commentsAfter.toArray())
+    )
+  }
+
+  private itemsChanged(before: readonly any[], after: readonly any[]) {
+    for (let i = 0; i < before.length; i++) {
+      if (before[i] !== after[i]) {
+        return true
+      }
+    }
+  }
 }

+ 5 - 5
services/web/frontend/js/features/review-panel/components/review-panel-change.tsx

@@ -57,9 +57,9 @@ export const ReviewPanelChange = memo<{
       setAccepting(true)
       try {
         if (aggregate) {
-          await acceptChanges(change.id, aggregate.id)
+          await acceptChanges(change, aggregate)
         } else {
-          await acceptChanges(change.id)
+          await acceptChanges(change)
         }
       } catch (err) {
         showGenericMessageModal(
@@ -69,13 +69,13 @@ export const ReviewPanelChange = memo<{
       } finally {
         setAccepting(false)
       }
-    }, [acceptChanges, aggregate, change.id, showGenericMessageModal, t])
+    }, [acceptChanges, aggregate, change, showGenericMessageModal, t])
 
     const rejectHandler = useCallback(async () => {
       if (aggregate) {
-        await rejectChanges(change.id, aggregate.id)
+        await rejectChanges(change, aggregate)
       } else {
-        await rejectChanges(change.id)
+        await rejectChanges(change)
       }
     }, [aggregate, change, rejectChanges])
 

+ 5 - 1
services/web/frontend/js/features/review-panel/components/review-panel-current-file.tsx

@@ -94,7 +94,11 @@ const ReviewPanelCurrentFile: FC = () => {
 
       if (threads) {
         for (const comment of ranges.comments) {
-          if (threads[comment.op.t] && !threads[comment.op.t]?.resolved) {
+          if (
+            !comment.resolved &&
+            threads[comment.op.t] &&
+            !threads[comment.op.t]?.resolved
+          ) {
             output.comments.push(comment)
           }
         }

+ 3 - 0
services/web/frontend/js/features/review-panel/components/review-panel-overview-file.tsx

@@ -51,6 +51,9 @@ export const ReviewPanelOverviewFile: FC<{
 
   const entries = useMemo(() => {
     const unresolvedComments = ranges.comments.filter(comment => {
+      if (comment.resolved) {
+        return false
+      }
       const thread = threads?.[comment.op.t]
       return thread && thread.messages.length > 0 && !thread.resolved
     })

+ 3 - 1
services/web/frontend/js/features/review-panel/components/review-panel-overview.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
 import { ReviewPanelOverviewFile } from './review-panel-overview-file'
 import ReviewPanelEmptyState from './review-panel-empty-state'
 import useProjectRanges from '../hooks/use-project-ranges'
+import getMeta from '@/utils/meta'
 
 export const ReviewPanelOverview: FC = () => {
   const { t } = useTranslation()
@@ -16,12 +17,13 @@ export const ReviewPanelOverview: FC = () => {
   const rangesForDocs = useMemo(() => {
     if (docs && docRanges && projectRanges) {
       const rangesForDocs = new Map<string, Ranges>()
+      const otMigrationStage = getMeta('ol-otMigrationStage')
 
       for (const doc of docs) {
         const ranges =
           doc.doc.id === docRanges.docId
             ? docRanges
-            : projectRanges.get(doc.doc.id)
+            : projectRanges.get(otMigrationStage === 1 ? doc.path : doc.doc.id)
 
         if (ranges) {
           rangesForDocs.set(doc.doc.id, ranges)

+ 15 - 3
services/web/frontend/js/features/review-panel/components/review-panel-resolved-threads-menu.tsx

@@ -8,6 +8,7 @@ import { Change, CommentOperation } from '../../../../../types/change'
 import { ThreadId } from '../../../../../types/review-panel/review-panel'
 import LoadingSpinner from '@/shared/components/loading-spinner'
 import OLBadge from '@/shared/components/ol/ol-badge'
+import getMeta from '@/utils/meta'
 
 export const ReviewPanelResolvedThreadsMenu: FC = () => {
   const { t } = useTranslation()
@@ -18,9 +19,12 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
 
   const docNameForThread = useMemo(() => {
     const docNameForThread = new Map<string, string>()
+    const otMigrationStage = getMeta('ol-otMigrationStage')
 
     for (const [docId, ranges] of projectRanges?.entries() ?? []) {
-      const docName = docs?.find(doc => doc.doc.id === docId)?.doc.name
+      const docName = docs?.find(
+        doc => (otMigrationStage === 1 ? doc.path : doc.doc.id) === docId
+      )?.doc.name
       if (docName !== undefined) {
         for (const comment of ranges.comments) {
           const threadId = comment.op.t
@@ -33,7 +37,10 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
   }, [docs, projectRanges])
 
   const allComments = useMemo(() => {
-    const allComments = new Map<string, Change<CommentOperation>>()
+    const allComments = new Map<
+      string,
+      Change<CommentOperation> & { resolved?: boolean }
+    >()
 
     // eslint-disable-next-line no-unused-vars
     for (const [_, ranges] of projectRanges?.entries() ?? []) {
@@ -52,11 +59,16 @@ export const ReviewPanelResolvedThreadsMenu: FC = () => {
 
     const allResolvedThreads = []
     for (const [id, thread] of Object.entries(threads)) {
-      if (thread.resolved) {
+      // sharejs-text-ot has "resolved" on the thread; history-ot has "resolved" on the comment
+      if (thread.resolved || allComments.get(id)?.resolved) {
         allResolvedThreads.push({ thread, id })
       }
     }
     allResolvedThreads.sort((a, b) => {
+      // TODO: add "resolved_at"/"resolved_by" to history-ot comments?
+      if (!a.thread.resolved_at || !b.thread.resolved_at) {
+        return 0
+      }
       return Date.parse(b.thread.resolved_at) - Date.parse(a.thread.resolved_at)
     })
 

+ 13 - 15
services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx

@@ -108,16 +108,14 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
   const [tooltipStyle, setTooltipStyle] = useState<CSSProperties | undefined>()
   const [visible, setVisible] = useState(false)
 
-  const changeIdsInSelection = useMemo(() => {
-    return (ranges?.changes ?? [])
-      .filter(({ op }) => {
-        const opFrom = op.p
-        const opLength = isInsertOperation(op) ? op.i.length : 0
-        const opTo = opFrom + opLength
-        const selection = state.selection.main
-        return opFrom >= selection.from && opTo <= selection.to
-      })
-      .map(({ id }) => id)
+  const changesInSelection = useMemo(() => {
+    return (ranges?.changes ?? []).filter(({ op }) => {
+      const opFrom = op.p
+      const opLength = isInsertOperation(op) ? op.i.length : 0
+      const opTo = opFrom + opLength
+      const selection = state.selection.main
+      return opFrom >= selection.from && opTo <= selection.to
+    })
   }, [ranges, state.selection.main])
 
   const acceptChangesHandler = useCallback(() => {
@@ -129,13 +127,13 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
       message: t('confirm_accept_selected_changes', { count: nChanges }),
       title: t('accept_selected_changes'),
       onConfirm: async () => {
-        await acceptChanges(...changeIdsInSelection)
+        await acceptChanges(...changesInSelection)
       },
       primaryVariant: 'danger',
     })
   }, [
     acceptChanges,
-    changeIdsInSelection,
+    changesInSelection,
     ranges,
     showGenericConfirmModal,
     view,
@@ -151,7 +149,7 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
       message: t('confirm_reject_selected_changes', { count: nChanges }),
       title: t('reject_selected_changes'),
       onConfirm: async () => {
-        await rejectChanges(...changeIdsInSelection)
+        await rejectChanges(...changesInSelection)
       },
       primaryVariant: 'danger',
     })
@@ -161,10 +159,10 @@ const ReviewTooltipMenuContent: FC<{ onAddComment: () => void }> = ({
     ranges,
     view,
     rejectChanges,
-    changeIdsInSelection,
+    changesInSelection,
   ])
 
-  const showChangesButtons = changeIdsInSelection.length > 0
+  const showChangesButtons = changesInSelection.length > 0
 
   useEffect(() => {
     view.requestMeasure({

+ 200 - 24
services/web/frontend/js/features/review-panel/context/ranges-context.tsx

@@ -22,18 +22,34 @@ import { useConnectionContext } from '@/features/ide-react/context/connection-co
 import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
 import { throttle } from 'lodash'
 import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
+import { TextOperation, Range } from 'overleaf-editor-core'
+import { rangesUpdatedEffect } from '@/features/source-editor/extensions/history-ot'
+import ClearTrackingProps from 'overleaf-editor-core/lib/file_data/clear_tracking_props'
+import { isInsertOperation } from '@/utils/operations'
+import {
+  EditorSelection,
+  Transaction,
+  TransactionSpec,
+} from '@codemirror/state'
+import { buildRangesFromSnapshot } from '@/features/review-panel/utils/snapshot-ranges'
 
 export type Ranges = {
   docId: string
-  changes: Change<EditOperation>[]
-  comments: Change<CommentOperation>[]
+  changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
+  comments: Array<
+    Change<CommentOperation> & { snapshotRange?: Range; resolved?: boolean }
+  >
 }
 
 export const RangesContext = createContext<Ranges | undefined>(undefined)
 
 type RangesActions = {
-  acceptChanges: (...ids: string[]) => Promise<void>
-  rejectChanges: (...ids: string[]) => Promise<void>
+  acceptChanges: (
+    ...changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
+  ) => Promise<void>
+  rejectChanges: (
+    ...changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
+  ) => Promise<void>
 }
 
 const buildRanges = (currentDocument: DocumentContainer | null) => {
@@ -76,6 +92,13 @@ const buildRanges = (currentDocument: DocumentContainer | null) => {
   }
 }
 
+const buildRangesFromHistoryOT = (currentDocument: DocumentContainer) => {
+  return buildRangesFromSnapshot(
+    currentDocument.historyOTShareDoc.snapshot,
+    currentDocument.doc_id
+  )
+}
+
 const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
 
 export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
@@ -89,11 +112,37 @@ export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
 
   // rebuild the ranges when the current doc changes
   useEffect(() => {
-    setRanges(buildRanges(currentDocument))
+    if (currentDocument) {
+      if (currentDocument.isHistoryOT()) {
+        setRanges(buildRangesFromHistoryOT(currentDocument))
+      } else {
+        setRanges(buildRanges(currentDocument))
+      }
+    }
   }, [currentDocument])
 
   useEffect(() => {
-    if (currentDocument) {
+    if (currentDocument && currentDocument.isHistoryOT()) {
+      const listener = throttle(
+        () => {
+          window.setTimeout(() => {
+            setRanges(buildRangesFromHistoryOT(currentDocument))
+          })
+        },
+        500,
+        { leading: true, trailing: true }
+      )
+
+      currentDocument.on('ranges:dirty.ot', listener)
+
+      return () => {
+        currentDocument.off('ranges:dirty.ot')
+      }
+    }
+  }, [currentDocument])
+
+  useEffect(() => {
+    if (currentDocument && !currentDocument.isHistoryOT()) {
       const listener = throttle(
         () => {
           window.setTimeout(() => {
@@ -156,24 +205,151 @@ export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
     )
   )
 
-  const actions = useMemo(
-    () => ({
-      async acceptChanges(...ids: string[]) {
-        if (currentDocument?.ranges) {
-          const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
-          await postJSON(url, { body: { change_ids: ids } })
-          currentDocument.ranges.removeChangeIds(ids)
-          setRanges(buildRanges(currentDocument))
-        }
-      },
-      async rejectChanges(...ids: string[]) {
-        if (currentDocument?.ranges) {
-          view.dispatch(rejectChanges(view.state, currentDocument.ranges, ids))
-        }
-      },
-    }),
-    [currentDocument, projectId, view]
-  )
+  const actions = useMemo(() => {
+    if (!currentDocument) {
+      return
+    }
+
+    if (currentDocument.isHistoryOT()) {
+      return {
+        async acceptChanges(...changes) {
+          const op = new TextOperation()
+
+          let currentSnapshotPos = 0
+          for (const change of changes) {
+            const { start, end, length } = change.snapshotRange!
+
+            if (start > currentSnapshotPos) {
+              op.retain(start - currentSnapshotPos)
+            }
+
+            currentSnapshotPos = end
+
+            if (isInsertOperation(change.op)) {
+              // accept tracked insertion
+
+              // clear tracking from snapshot
+              op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
+            } else {
+              // accept tracked deletion
+
+              // remove text from snapshot
+              op.remove(length) // NOTE: tracking is removed automatically
+            }
+          }
+
+          const shareDoc = currentDocument.historyOTShareDoc
+
+          const length = shareDoc.snapshot.getStringLength()
+
+          if (currentSnapshotPos < length) {
+            op.retain(length - currentSnapshotPos)
+          }
+
+          shareDoc.submitOp([op])
+
+          // dispatch an effect as the editor's doc doesn't change when tracked changes are accepted
+          view.dispatch({
+            effects: rangesUpdatedEffect.of(null),
+          })
+        },
+        async rejectChanges(...changes) {
+          const shareDoc = currentDocument.historyOTShareDoc
+
+          const originalLength = shareDoc.snapshot.getStringLength()
+
+          const op = new TextOperation()
+
+          let currentSnapshotPos = 0
+          const specs: TransactionSpec[] = []
+          for (const change of changes) {
+            const { start, end, length } = change.snapshotRange!
+
+            if (start > currentSnapshotPos) {
+              op.retain(start - currentSnapshotPos)
+            }
+
+            currentSnapshotPos = end
+
+            if (isInsertOperation(change.op)) {
+              // reject tracked insertion
+
+              // remove text from snapshot
+              op.remove(length) // NOTE: tracking is removed automatically
+
+              // remove text from editor
+              specs.push({
+                changes: {
+                  from: change.op.p,
+                  to: change.op.p + change.op.i.length,
+                  insert: '',
+                },
+                annotations: [
+                  Transaction.remote.of(true),
+                  // Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
+                ],
+              })
+            } else {
+              // reject tracked deletion
+
+              // remove tracking from snapshot
+              op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
+
+              // re-add text to editor
+              specs.push({
+                changes: {
+                  from: change.op.p,
+                  insert: change.op.d,
+                },
+                selection: EditorSelection.cursor(
+                  change.op.p + change.op.d.length
+                ), // TODO: map selection through changes?
+                annotations: [
+                  Transaction.remote.of(true),
+                  // Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
+                ],
+              })
+            }
+          }
+
+          if (currentSnapshotPos < originalLength) {
+            op.retain(originalLength - currentSnapshotPos)
+          }
+
+          shareDoc.submitOp([op])
+
+          // in case the doc didn't change
+          view.dispatch(...specs, {
+            effects: rangesUpdatedEffect.of(null),
+          })
+        },
+      } satisfies RangesActions
+    } else {
+      return {
+        async acceptChanges(...changes) {
+          if (currentDocument.ranges) {
+            const ids = changes.map(change => change.id)
+            const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
+            await postJSON(url, { body: { change_ids: ids } })
+            currentDocument.ranges.removeChangeIds(ids)
+            setRanges(buildRanges(currentDocument))
+          }
+        },
+        async rejectChanges(...changes) {
+          if (currentDocument.ranges) {
+            const ids = changes.map(change => change.id)
+            view.dispatch(
+              rejectChanges(view.state, currentDocument.ranges, ids)
+            )
+          }
+        },
+      } satisfies RangesActions
+    }
+  }, [currentDocument, projectId, view])
+
+  if (!actions) {
+    return null
+  }
 
   return (
     <RangesActionsContext.Provider value={actions}>

+ 74 - 10
services/web/frontend/js/features/review-panel/context/threads-context.tsx

@@ -24,6 +24,15 @@ import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-ope
 import { useEditorContext } from '@/shared/context/editor-context'
 import { debugConsole } from '@/utils/debugging'
 import { captureException } from '@/infrastructure/error-reporter'
+import {
+  AddCommentOperation,
+  DeleteCommentOperation,
+  SetCommentStateOperation,
+} from 'overleaf-editor-core'
+import Range from 'overleaf-editor-core/lib/range'
+import { trackedDeletesFromState } from '@/features/source-editor/utils/tracked-deletes'
+import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
+import { rangesUpdatedEffect } from '@/features/source-editor/extensions/history-ot'
 
 export type Threads = Record<ThreadId, ReviewPanelCommentThread>
 
@@ -52,10 +61,13 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
   const { projectId } = useProjectContext()
   const { currentDocument } = useEditorOpenDocContext()
   const { isRestrictedTokenMember } = useEditorContext()
+  const view = useCodeMirrorViewContext()
 
   // const [error, setError] = useState<Error>()
   const [data, setData] = useState<Threads>()
 
+  const isHistoryOT = currentDocument?.isHistoryOT()
+
   // load the initial threads data
   useEffect(() => {
     if (isRestrictedTokenMember) {
@@ -250,8 +262,12 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
     }, [])
   )
 
-  const actions = useMemo(
-    () => ({
+  const actions = useMemo(() => {
+    if (!currentDocument) {
+      return
+    }
+
+    const actions = {
       async addComment(pos: number, text: string, content: string) {
         const threadId = RangesTracker.generateId() as ThreadId
 
@@ -265,23 +281,23 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
           t: threadId,
         }
 
-        currentDocument?.submitOp(op)
+        currentDocument.submitOp(op)
       },
       async resolveThread(threadId: string) {
         await postJSON(
-          `/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/resolve`
+          `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/resolve`
         )
       },
       async reopenThread(threadId: string) {
         await postJSON(
-          `/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}/reopen`
+          `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}/reopen`
         )
       },
       async deleteThread(threadId: string) {
         await deleteJSON(
-          `/project/${projectId}/doc/${currentDocument?.doc_id}/thread/${threadId}`
+          `/project/${projectId}/doc/${currentDocument.doc_id}/thread/${threadId}`
         )
-        currentDocument?.ranges?.removeCommentId(threadId)
+        currentDocument.ranges?.removeCommentId(threadId)
       },
       async addMessage(threadId: ThreadId, content: string) {
         await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
@@ -308,9 +324,57 @@ export const ThreadsProvider: FC<React.PropsWithChildren> = ({ children }) => {
           `/project/${projectId}/thread/${threadId}/own-messages/${commentId}`
         )
       },
-    }),
-    [currentDocument, projectId]
-  )
+    }
+
+    if (isHistoryOT) {
+      // TODO: dispatch on view instead?
+      Object.assign(actions, {
+        async addComment(pos: number, text: string, content: string) {
+          const threadId = RangesTracker.generateId() as ThreadId // TODO
+
+          await postJSON(`/project/${projectId}/thread/${threadId}/messages`, {
+            body: { content },
+          })
+
+          const trackedDeletes = trackedDeletesFromState(view.state)
+          pos = trackedDeletes.toSnapshot(pos)
+          const ranges = [new Range(pos, text.length)]
+          const op = new AddCommentOperation(threadId, ranges)
+          currentDocument.historyOTShareDoc.submitOp([op])
+          view.dispatch({
+            effects: rangesUpdatedEffect.of(null),
+          })
+        },
+        async resolveThread(threadId: string) {
+          const op = new SetCommentStateOperation(threadId, true)
+          currentDocument.historyOTShareDoc.submitOp([op])
+          view.dispatch({
+            effects: rangesUpdatedEffect.of(null),
+          })
+        },
+        async reopenThread(threadId: string) {
+          const op = new SetCommentStateOperation(threadId, false)
+          currentDocument.historyOTShareDoc.submitOp([op])
+          view.dispatch({
+            effects: rangesUpdatedEffect.of(null),
+          })
+        },
+        async deleteThread(threadId: string) {
+          const op = new DeleteCommentOperation(threadId)
+          currentDocument.historyOTShareDoc.submitOp([op])
+          view.dispatch({
+            effects: rangesUpdatedEffect.of(null),
+          })
+        },
+      })
+    }
+
+    return actions
+  }, [view, currentDocument, projectId, isHistoryOT])
+
+  if (!actions) {
+    return null
+  }
 
   return (
     <ThreadsActionsContext.Provider value={actions}>

+ 42 - 18
services/web/frontend/js/features/review-panel/hooks/use-project-ranges.ts

@@ -4,6 +4,8 @@ import { useProjectContext } from '@/shared/context/project-context'
 import { getJSON } from '@/infrastructure/fetch-json'
 import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
 import { useConnectionContext } from '@/features/ide-react/context/connection-context'
+import getMeta from '@/utils/meta'
+import { buildProjectRangesFromSnapshot } from '@/features/review-panel/utils/snapshot-ranges'
 
 export default function useProjectRanges() {
   const { projectId } = useProjectContext()
@@ -11,27 +13,36 @@ export default function useProjectRanges() {
   const [projectRanges, setProjectRanges] = useState<Map<string, Ranges>>()
   const [loading, setLoading] = useState(true)
   const { socket } = useConnectionContext()
+  const otMigrationStage = getMeta('ol-otMigrationStage')
+  const { projectSnapshot } = useProjectContext()
 
   useEffect(() => {
-    setLoading(true)
-    getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
-      .then(data => {
-        setProjectRanges(
-          new Map(
-            data.map(item => [
-              item.id,
-              {
-                docId: item.id,
-                changes: item.ranges.changes ?? [],
-                comments: item.ranges.comments ?? [],
-              },
-            ])
-          )
-        )
+    if (otMigrationStage === 1) {
+      projectSnapshot.refresh().then(() => {
+        setProjectRanges(buildProjectRangesFromSnapshot(projectSnapshot))
+        setLoading(false)
       })
-      .catch(error => setError(error))
-      .finally(() => setLoading(false))
-  }, [projectId])
+    } else {
+      setLoading(true)
+      getJSON<{ id: string; ranges: Ranges }[]>(`/project/${projectId}/ranges`)
+        .then(data => {
+          setProjectRanges(
+            new Map(
+              data.map(item => [
+                item.id,
+                {
+                  docId: item.id,
+                  changes: item.ranges.changes ?? [],
+                  comments: item.ranges.comments ?? [],
+                },
+              ])
+            )
+          )
+        })
+        .catch(error => setError(error))
+        .finally(() => setLoading(false))
+    }
+  }, [projectId, otMigrationStage, projectSnapshot])
 
   useSocketListener(
     socket,
@@ -60,5 +71,18 @@ export default function useProjectRanges() {
     }, [])
   )
 
+  useSocketListener(
+    socket,
+    'new-comment',
+    useCallback(() => {
+      if (otMigrationStage === 1) {
+        projectSnapshot.refresh().then(() => {
+          setProjectRanges(buildProjectRangesFromSnapshot(projectSnapshot))
+          setLoading(false)
+        })
+      }
+    }, [otMigrationStage, projectSnapshot])
+  )
+
   return { projectRanges, error, loading }
 }

+ 74 - 0
services/web/frontend/js/features/review-panel/utils/snapshot-ranges.ts

@@ -0,0 +1,74 @@
+import { TrackedDeletes } from '@/features/source-editor/utils/tracked-deletes'
+import { UserId } from '../../../../../types/user'
+import { ThreadId } from '../../../../../types/review-panel/review-panel'
+import { Ranges } from '@/features/review-panel/context/ranges-context'
+import { StringFileData } from 'overleaf-editor-core'
+import { ProjectSnapshot } from '@/infrastructure/project-snapshot'
+
+export const buildProjectRangesFromSnapshot = (
+  projectSnapshot: ProjectSnapshot
+) => {
+  const projectRanges = new Map()
+  for (const [path, file] of projectSnapshot.getDocs().entries()) {
+    const ranges = buildRangesFromSnapshot(file.data as StringFileData, path)
+    projectRanges.set(path, ranges)
+  }
+  return projectRanges
+}
+
+export const buildRangesFromSnapshot = (
+  snapshot: StringFileData,
+  docId: string
+) => {
+  const comments = snapshot.getComments()
+  const trackedChanges = snapshot.getTrackedChanges()
+  const snapshotContent = snapshot.getContent()
+
+  const trackedDeletes = new TrackedDeletes(trackedChanges)
+
+  const ranges: Ranges = {
+    docId,
+    changes: [], // TODO: trackedChanges, once React components are updated
+    comments: [], // TODO: comments, once React components are updated
+  }
+
+  for (const trackedChange of trackedChanges) {
+    const { range, tracking } = trackedChange
+    const text = snapshotContent.substring(range.pos, range.end)
+    const pos = trackedDeletes.toCodeMirror(range.pos)
+    const id = `change-${tracking.type}-${pos}`
+
+    const metadata = {
+      user_id: tracking.userId as UserId,
+      ts: tracking.ts,
+    }
+
+    const op =
+      tracking.type === 'insert' ? { p: pos, i: text } : { p: pos, d: text }
+    ranges.changes.push({ id, metadata, op, snapshotRange: range })
+  }
+
+  const seenComments = new Set<string>()
+  for (const comment of comments) {
+    if (!seenComments.has(comment.id)) {
+      seenComments.add(comment.id)
+
+      const range = comment.ranges[0] // show the comment next to the first range
+      const pos = trackedDeletes.toCodeMirror(range.pos)
+      const text = snapshotContent.substring(pos, range.end)
+
+      ranges.comments.push({
+        id: comment.id,
+        op: {
+          p: pos,
+          c: text,
+          t: comment.id as ThreadId,
+        },
+        snapshotRange: range,
+        resolved: comment.resolved,
+      })
+    }
+  }
+
+  return ranges
+}

+ 99 - 171
services/web/frontend/js/features/source-editor/extensions/history-ot.ts

@@ -1,4 +1,9 @@
-import { Decoration, EditorView, WidgetType } from '@codemirror/view'
+import {
+  Decoration,
+  DecorationSet,
+  EditorView,
+  WidgetType,
+} from '@codemirror/view'
 import {
   EditorState,
   StateEffect,
@@ -7,29 +12,31 @@ import {
 } from '@codemirror/state'
 import {
   CommentList,
-  EditOperation,
   TextOperation,
   TrackingProps,
   TrackedChangeList,
 } from 'overleaf-editor-core'
 import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 import { HistoryOTShareDoc } from '../../../../../types/share-doc'
+import {
+  TrackedDeletes,
+  trackedDeletesFromState,
+} from '@/features/source-editor/utils/tracked-deletes'
 
 export const historyOT = (currentDoc: DocumentContainer) => {
   const trackedChanges =
-    currentDoc.doc?.getTrackedChanges() ?? new TrackedChangeList([])
-  const positionMapper = new PositionMapper(trackedChanges)
+    currentDoc.historyOTShareDoc.snapshot.getTrackedChanges() ??
+    new TrackedChangeList([])
+  const comments =
+    currentDoc.historyOTShareDoc.snapshot.getComments() ?? new CommentList([])
   return [
     updateSender,
     trackChangesUserIdState,
     shareDocState.init(() => currentDoc?.doc?._doc ?? null),
-    commentsState,
-    trackedChangesState.init(() => ({
-      decorations: buildTrackedChangesDecorations(
-        trackedChanges,
-        positionMapper
-      ),
-      positionMapper,
+    rangesState.init(() => ({
+      trackedChanges,
+      comments,
+      decorations: buildRangesDecorations({ trackedChanges, comments }),
     })),
     trackedChangesTheme,
   ]
@@ -93,35 +100,66 @@ const trackedChangesTheme = EditorView.baseTheme({
   },
 })
 
-export const updateTrackedChangesEffect =
-  StateEffect.define<TrackedChangeList>()
+export const rangesUpdatedEffect = StateEffect.define()
+
+const buildRangesDecorations = ({
+  trackedChanges,
+  comments,
+}: {
+  trackedChanges: TrackedChangeList
+  comments: CommentList
+}) => {
+  if (trackedChanges.length === 0 && comments.length === 0) {
+    return Decoration.none
+  }
+
+  const trackedDeletes = new TrackedDeletes(trackedChanges)
 
-const buildTrackedChangesDecorations = (
-  trackedChanges: TrackedChangeList,
-  positionMapper: PositionMapper
-) => {
   const decorations = []
   for (const change of trackedChanges.asSorted()) {
+    const from = trackedDeletes.toCodeMirror(change.range.pos)
     if (change.tracking.type === 'insert') {
-      decorations.push(
-        Decoration.mark({
-          class: 'ol-cm-change ol-cm-change-i',
-          tracking: change.tracking,
-        }).range(
-          positionMapper.toCM6(change.range.pos),
-          positionMapper.toCM6(change.range.end)
+      const to = trackedDeletes.toCodeMirror(change.range.end)
+      if (from < to) {
+        decorations.push(
+          Decoration.mark({
+            class: 'ol-cm-change ol-cm-change-i',
+            tracking: change.tracking,
+            rangeType: 'trackedChange',
+            change,
+          }).range(from, to)
         )
-      )
+      }
     } else {
       decorations.push(
         Decoration.widget({
           widget: new ChangeDeletedWidget(),
           side: 1,
-        }).range(positionMapper.toCM6(change.range.pos))
+          rangeType: 'trackedChange',
+          change,
+        }).range(from)
       )
     }
   }
 
+  for (const comment of comments) {
+    if (!comment.resolved) {
+      for (const range of comment.ranges) {
+        decorations.push(
+          Decoration.mark({
+            class: 'ol-cm-change ol-cm-change-c',
+            id: comment.id,
+            rangeType: 'comment',
+            comment,
+          }).range(
+            trackedDeletes.toCodeMirror(range.pos),
+            trackedDeletes.toCodeMirror(range.end)
+          )
+        )
+      }
+    }
+  }
+
   return Decoration.set(decorations, true)
 }
 
@@ -138,29 +176,38 @@ class ChangeDeletedWidget extends WidgetType {
   }
 }
 
-export const trackedChangesState = StateField.define({
+export const rangesState = StateField.define<{
+  comments: CommentList
+  trackedChanges: TrackedChangeList
+  decorations: DecorationSet
+}>({
   create() {
-    return {
-      decorations: Decoration.none,
-      positionMapper: new PositionMapper(new TrackedChangeList([])),
-    }
+    const trackedChanges = new TrackedChangeList([])
+    const comments = new CommentList([])
+    const decorations = buildRangesDecorations({ trackedChanges, comments })
+    return { trackedChanges, comments, decorations }
   },
 
   update(value, transaction) {
-    if (
-      (transaction.docChanged && !transaction.annotation(Transaction.remote)) ||
-      transaction.effects.some(effect => effect.is(updateTrackedChangesEffect))
-    ) {
-      const shareDoc = transaction.startState.field(shareDocState)
-      if (shareDoc != null) {
-        const trackedChanges = shareDoc.snapshot.getTrackedChanges()
-        const positionMapper = new PositionMapper(trackedChanges)
-        value = {
-          decorations: buildTrackedChangesDecorations(
+    const shareDoc = transaction.state.field(shareDocState)!
+    const { snapshot } = shareDoc
+
+    if (transaction.docChanged) {
+      const trackedChanges = snapshot.getTrackedChanges()
+      const comments = snapshot.getComments()
+      const decorations = buildRangesDecorations({ trackedChanges, comments })
+      value = { trackedChanges, comments, decorations }
+    } else {
+      for (const effect of transaction.effects) {
+        if (effect.is(rangesUpdatedEffect)) {
+          const trackedChanges = snapshot.getTrackedChanges()
+          const comments = snapshot.getComments()
+          const decorations = buildRangesDecorations({
             trackedChanges,
-            positionMapper
-          ),
-          positionMapper,
+            comments,
+          })
+          value = { trackedChanges, comments, decorations }
+          shareDoc.emit('ranges:dirty')
         }
       }
     }
@@ -196,64 +243,16 @@ const trackChangesUserIdState = StateField.define<string | null>({
   },
 })
 
-const updateCommentsEffect = StateEffect.define<CommentList>()
-
-export const updateComments = (comments: CommentList) => {
-  return {
-    effects: updateCommentsEffect.of(comments),
-  }
-}
-
-const buildCommentsDecorations = (comments: CommentList) =>
-  Decoration.set(
-    comments.toArray().flatMap(comment =>
-      comment.ranges.map(range =>
-        Decoration.mark({
-          class: 'tracked-change-comment',
-          id: comment.id,
-          resolved: comment.resolved,
-        }).range(range.pos, range.end)
-      )
-    ),
-    true
-  )
-
-const commentsState = StateField.define({
-  create() {
-    return Decoration.none // TODO: init from snapshot
-  },
-
-  update(value, transaction) {
-    if (transaction.docChanged) {
-      value = value.map(transaction.changes)
-    }
-
-    for (const effect of transaction.effects) {
-      if (effect.is(updateCommentsEffect)) {
-        value = buildCommentsDecorations(effect.value)
-      }
-    }
-
-    return value
-  },
-
-  provide(field) {
-    return EditorView.decorations.from(field)
-  },
-})
-
-export const historyOTOperationEffect = StateEffect.define<EditOperation[]>()
-
 const updateSender = EditorState.transactionExtender.of(tr => {
   if (!tr.docChanged || tr.annotation(Transaction.remote)) {
     return {}
   }
 
   const trackingUserId = tr.startState.field(trackChangesUserIdState)
-  const positionMapper = tr.startState.field(trackedChangesState).positionMapper
+  const trackedDeletes = trackedDeletesFromState(tr.startState)
   const startDoc = tr.startState.doc
   const opBuilder = new OperationBuilder(
-    positionMapper.toSnapshot(startDoc.length)
+    trackedDeletes.toSnapshot(startDoc.length)
   )
 
   if (trackingUserId == null) {
@@ -261,14 +260,14 @@ const updateSender = EditorState.transactionExtender.of(tr => {
     tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
       // insert
       if (inserted.length > 0) {
-        const pos = positionMapper.toSnapshot(fromA)
+        const pos = trackedDeletes.toSnapshot(fromA)
         opBuilder.insert(pos, inserted.toString())
       }
 
       // deletion
       if (toA > fromA) {
-        const start = positionMapper.toSnapshot(fromA)
-        const end = positionMapper.toSnapshot(toA)
+        const start = trackedDeletes.toSnapshot(fromA)
+        const end = trackedDeletes.toSnapshot(toA)
         opBuilder.delete(start, end - start)
       }
     })
@@ -278,7 +277,7 @@ const updateSender = EditorState.transactionExtender.of(tr => {
     tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
       // insertion
       if (inserted.length > 0) {
-        const pos = positionMapper.toSnapshot(fromA)
+        const pos = trackedDeletes.toSnapshot(fromA)
         opBuilder.trackedInsert(
           pos,
           inserted.toString(),
@@ -289,8 +288,8 @@ const updateSender = EditorState.transactionExtender.of(tr => {
 
       // deletion
       if (toA > fromA) {
-        const start = positionMapper.toSnapshot(fromA)
-        const end = positionMapper.toSnapshot(toA)
+        const start = trackedDeletes.toSnapshot(fromA)
+        const end = trackedDeletes.toSnapshot(toA)
         opBuilder.trackedDelete(start, end - start, trackingUserId, timestamp)
       }
     })
@@ -376,74 +375,3 @@ class OperationBuilder {
     return this.op
   }
 }
-
-type OffsetTable = { pos: number; map: (pos: number) => number }[]
-
-class PositionMapper {
-  private offsets: {
-    toCM6: OffsetTable
-    toSnapshot: OffsetTable
-  }
-
-  constructor(trackedChanges: TrackedChangeList) {
-    this.offsets = {
-      toCM6: [{ pos: 0, map: pos => pos }],
-      toSnapshot: [{ pos: 0, map: pos => pos }],
-    }
-
-    // Offset of the snapshot pos relative to the CM6 pos
-    let offset = 0
-    for (const change of trackedChanges.asSorted()) {
-      if (change.tracking.type === 'delete') {
-        const deleteLength = change.range.length
-        const deletePos = change.range.pos
-        const oldOffset = offset
-        const newOffset = offset + deleteLength
-        this.offsets.toSnapshot.push({
-          pos: change.range.pos - offset + 1,
-          map: pos => pos + newOffset,
-        })
-        this.offsets.toCM6.push({
-          pos: change.range.pos,
-          map: () => deletePos - oldOffset,
-        })
-        this.offsets.toCM6.push({
-          pos: change.range.pos + deleteLength,
-          map: pos => pos - newOffset,
-        })
-        offset = newOffset
-      }
-    }
-  }
-
-  toCM6(snapshotPos: number) {
-    return this.mapPos(snapshotPos, this.offsets.toCM6)
-  }
-
-  toSnapshot(cm6Pos: number) {
-    return this.mapPos(cm6Pos, this.offsets.toSnapshot)
-  }
-
-  mapPos(pos: number, offsets: OffsetTable) {
-    // Binary search for the offset at the last position before pos
-    let low = 0
-    let high = offsets.length - 1
-    while (low < high) {
-      const middle = Math.ceil((low + high) / 2)
-      const entry = offsets[middle]
-      if (entry.pos < pos) {
-        // This entry could be the right offset, but lower entries are too low
-        // Because we used Math.ceil(), middle is higher than low and the
-        // algorithm progresses.
-        low = middle
-      } else if (entry.pos > pos) {
-        // This entry is too high
-        high = middle - 1
-      } else {
-        // This is the right entry
-        return entry.map(pos)
-      }
-    }
-    return offsets[low].map(pos)
-  }
-}

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

@@ -23,12 +23,8 @@ import {
   RemoveOp,
   RetainOp,
 } from 'overleaf-editor-core'
-import {
-  updateTrackedChangesEffect,
-  setTrackChangesUserId,
-  trackedChangesState,
-  shareDocState,
-} from './history-ot'
+import { rangesUpdatedEffect, setTrackChangesUserId } from './history-ot'
+import { trackedDeletesFromState } from '@/features/source-editor/utils/tracked-deletes'
 
 /*
  * Integrate CodeMirror 6 with the real-time system, via ShareJS.
@@ -355,8 +351,7 @@ class HistoryOTAdapter {
   }
 
   onRemoteOp(operations: EditOperation[]) {
-    const positionMapper =
-      this.editor.view.state.field(trackedChangesState).positionMapper
+    const trackedDeletes = trackedDeletesFromState(this.editor.view.state)
     const changes: ChangeSpec[] = []
     let trackedChangesUpdated = false
     for (const operation of operations) {
@@ -366,15 +361,15 @@ class HistoryOTAdapter {
           if (op instanceof InsertOp) {
             if (op.tracking?.type !== 'delete') {
               changes.push({
-                from: positionMapper.toCM6(cursor),
+                from: trackedDeletes.toCodeMirror(cursor),
                 insert: op.insertion,
               })
             }
             trackedChangesUpdated = true
           } else if (op instanceof RemoveOp) {
             changes.push({
-              from: positionMapper.toCM6(cursor),
-              to: positionMapper.toCM6(cursor + op.length),
+              from: trackedDeletes.toCodeMirror(cursor),
+              to: trackedDeletes.toCodeMirror(cursor + op.length),
             })
             cursor += op.length
             trackedChangesUpdated = true
@@ -382,8 +377,8 @@ class HistoryOTAdapter {
             if (op.tracking != null) {
               if (op.tracking.type === 'delete') {
                 changes.push({
-                  from: positionMapper.toCM6(cursor),
-                  to: positionMapper.toCM6(cursor + op.length),
+                  from: trackedDeletes.toCodeMirror(cursor),
+                  to: trackedDeletes.toCodeMirror(cursor + op.length),
                 })
               }
               trackedChangesUpdated = true
@@ -402,11 +397,7 @@ class HistoryOTAdapter {
         effects.push(scrollEffect)
       }
       if (trackedChangesUpdated) {
-        const shareDoc = this.editor.view.state.field(shareDocState)
-        if (shareDoc != null) {
-          const trackedChanges = shareDoc.snapshot.getTrackedChanges()
-          effects.push(updateTrackedChangesEffect.of(trackedChanges))
-        }
+        effects.push(rangesUpdatedEffect.of(null))
       }
 
       view.dispatch({

+ 77 - 0
services/web/frontend/js/features/source-editor/utils/tracked-deletes.ts

@@ -0,0 +1,77 @@
+import { TrackedChangeList } from 'overleaf-editor-core'
+import { EditorState } from '@codemirror/state'
+import { rangesState } from '@/features/source-editor/extensions/history-ot'
+
+export const trackedDeletesFromState = (state: EditorState) =>
+  new TrackedDeletes(state.field(rangesState).trackedChanges)
+
+type OffsetTable = { pos: number; map: (pos: number) => number }[]
+
+export class TrackedDeletes {
+  private offsets: {
+    toCM6: OffsetTable
+    toSnapshot: OffsetTable
+  }
+
+  constructor(trackedChanges: TrackedChangeList) {
+    this.offsets = {
+      toCM6: [{ pos: 0, map: pos => pos }],
+      toSnapshot: [{ pos: 0, map: pos => pos }],
+    }
+
+    // Offset of the snapshot pos relative to the CM6 pos
+    let offset = 0
+    for (const change of trackedChanges.asSorted()) {
+      if (change.tracking.type === 'delete') {
+        const deleteLength = change.range.length
+        const deletePos = change.range.pos
+        const oldOffset = offset
+        const newOffset = offset + deleteLength
+        this.offsets.toSnapshot.push({
+          pos: change.range.pos - offset + 1,
+          map: pos => pos + newOffset,
+        })
+        this.offsets.toCM6.push({
+          pos: change.range.pos,
+          map: () => deletePos - oldOffset,
+        })
+        this.offsets.toCM6.push({
+          pos: change.range.pos + deleteLength,
+          map: pos => pos - newOffset,
+        })
+        offset = newOffset
+      }
+    }
+  }
+
+  toCodeMirror(snapshotPos: number) {
+    return this.mapPos(snapshotPos, this.offsets.toCM6)
+  }
+
+  toSnapshot(cm6Pos: number) {
+    return this.mapPos(cm6Pos, this.offsets.toSnapshot)
+  }
+
+  mapPos(pos: number, offsets: OffsetTable) {
+    // Binary search for the offset at the last position before pos
+    let low = 0
+    let high = offsets.length - 1
+    while (low < high) {
+      const middle = Math.ceil((low + high) / 2)
+      const entry = offsets[middle]
+      if (entry.pos < pos) {
+        // This entry could be the right offset, but lower entries are too low
+        // Because we used Math.ceil(), middle is higher than low and the
+        // algorithm progresses.
+        low = middle
+      } else if (entry.pos > pos) {
+        // This entry is too high
+        high = middle - 1
+      } else {
+        // This is the right entry
+        return entry.map(pos)
+      }
+    }
+    return offsets[low].map(pos)
+  }
+}

+ 12 - 1
services/web/frontend/js/infrastructure/project-snapshot.ts

@@ -1,5 +1,5 @@
 import pLimit from 'p-limit'
-import { Change, Chunk, Snapshot } from 'overleaf-editor-core'
+import { Change, Chunk, Snapshot, File } from 'overleaf-editor-core'
 import { RawChange, RawChunk } from 'overleaf-editor-core/lib/types'
 import { FetchError, getJSON, postJSON } from '@/infrastructure/fetch-json'
 
@@ -108,6 +108,17 @@ export class ProjectSnapshot {
     return await this.blobStore.getString(hash, options)
   }
 
+  getDocs(): Map<string, File> {
+    const files = new Map()
+    for (const path of this.snapshot.getFilePathnames()) {
+      const file = this.snapshot.getFile(path)
+      if (file?.isEditable()) {
+        files.set(path, file)
+      }
+    }
+    return files
+  }
+
   /**
    * Immediately start a refresh
    */

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -194,6 +194,7 @@ export interface Meta {
   'ol-notificationsInstitution': InstitutionType[]
   'ol-oauthProviders': OAuthProviders
   'ol-odcData': OnboardingFormData
+  'ol-otMigrationStage': number
   'ol-overallThemes': OverallThemeMeta[]
   'ol-pages': number
   'ol-passwordStrengthOptions': PasswordStrengthOptions

+ 1 - 0
services/web/test/frontend/features/source-editor/helpers/mock-doc.ts

@@ -115,5 +115,6 @@ export const mockDoc = (
     getPendingOp: () => null,
     hasBufferedOps: () => false,
     leaveAndCleanUpPromise: () => false,
+    isHistoryOT: () => false,
   }
 }