소스 검색

Merge pull request #25910 from overleaf/em-track-changes-sharejs

Track changes in the history OT sharejs doc

GitOrigin-RevId: 17365219f24a25790eac611dbde9681eb73d0961
Eric Mc Sween 1 년 전
부모
커밋
f11ea06c1a

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

@@ -18,6 +18,7 @@ import { useConnectionContext } from '@/features/ide-react/context/connection-co
 import { debugConsole } from '@/utils/debugging'
 import { DocumentContainer } from '@/features/ide-react/editor/document-container'
 import { useLayoutContext } from '@/shared/context/layout-context'
+import { useUserContext } from '@/shared/context/user-context'
 import { GotoLineOptions } from '@/features/ide-react/types/goto-line-options'
 import { Doc } from '../../../../../types/doc'
 import { useFileTreeData } from '@/shared/context/file-tree-data-context'
@@ -99,6 +100,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
   const { view, setView } = useLayoutContext()
   const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
     useModalsContext()
+  const { id: userId } = useUserContext()
 
   const [showSymbolPalette, setShowSymbolPalette] = useScopeValue<boolean>(
     'editor.showSymbolPalette'
@@ -309,7 +311,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
       const tryToggle = () => {
         const saved = doc.getInflightOp() == null && doc.getPendingOp() == null
         if (saved) {
-          doc.setTrackingChanges(want)
+          doc.setTrackChangesUserId(want ? userId : null)
           setTrackChanges(want)
         } else {
           syncTimeoutRef.current = window.setTimeout(tryToggle, 100)
@@ -318,7 +320,7 @@ export const EditorManagerProvider: FC<React.PropsWithChildren> = ({
 
       tryToggle()
     },
-    [setTrackChanges]
+    [setTrackChanges, userId]
   )
 
   const doOpenNewDocument = useCallback(

+ 6 - 2
services/web/frontend/js/features/ide-react/editor/document-container.ts

@@ -196,9 +196,13 @@ export class DocumentContainer extends EventEmitter {
     return this.doc?.hasBufferedOps()
   }
 
-  setTrackingChanges(track_changes: boolean) {
+  setTrackChangesUserId(userId: string | null) {
+    this.track_changes_as = userId
     if (this.doc) {
-      this.doc.track_changes = track_changes
+      this.doc.setTrackChangesUserId(userId)
+    }
+    if (this.cm6) {
+      this.cm6.setTrackChangesUserId(userId)
     }
   }
 

+ 19 - 13
services/web/frontend/js/features/ide-react/editor/share-js-doc.ts

@@ -12,18 +12,14 @@ import {
   Message,
   ShareJsConnectionState,
   ShareJsOperation,
-  ShareJsTextType,
   TrackChangesIdSeeds,
 } from '@/features/ide-react/editor/types/document'
 import { EditorFacade } from '@/features/source-editor/extensions/realtime'
 import { recordDocumentFirstChangeEvent } from '@/features/event-tracking/document-first-change-event'
 import getMeta from '@/utils/meta'
-import { HistoryOTType } from './share-js-history-ot-type'
-import { StringFileData } from 'overleaf-editor-core/index'
-import {
-  RawEditOperation,
-  StringFileRawData,
-} from 'overleaf-editor-core/lib/types'
+import { historyOTType } from './share-js-history-ot-type'
+import { StringFileData, TrackedChangeList } from 'overleaf-editor-core/index'
+import { StringFileRawData } from 'overleaf-editor-core/lib/types'
 
 // All times below are in milliseconds
 const SINGLE_USER_FLUSH_DELAY = 2000
@@ -68,19 +64,17 @@ export class ShareJsDoc extends EventEmitter {
     readonly type: OTType = 'sharejs-text-ot'
   ) {
     super()
-    let sharejsType: ShareJsTextType = sharejs.types.text
+    let sharejsType
     // Decode any binary bits of data
     let snapshot: string | StringFileData
     if (this.type === 'history-ot') {
       snapshot = StringFileData.fromRaw(
         docLines as unknown as StringFileRawData
       )
-      sharejsType = new HistoryOTType(snapshot) as ShareJsTextType<
-        StringFileData,
-        RawEditOperation[]
-      >
+      sharejsType = historyOTType
     } else {
       snapshot = docLines.map(line => decodeUtf8(line)).join('\n')
+      sharejsType = sharejs.types.text
     }
 
     this.connection = {
@@ -159,6 +153,18 @@ export class ShareJsDoc extends EventEmitter {
     this.removeCarriageReturnCharFromShareJsDoc()
   }
 
+  setTrackChangesUserId(userId: string | null) {
+    this.track_changes = userId != null
+  }
+
+  getTrackedChanges() {
+    if (this._doc.otType === 'history-ot') {
+      return this._doc.snapshot.getTrackedChanges() as TrackedChangeList
+    } else {
+      return null
+    }
+  }
+
   private removeCarriageReturnCharFromShareJsDoc() {
     const doc = this._doc
     let nextPos
@@ -365,7 +371,7 @@ export class ShareJsDoc extends EventEmitter {
 
   attachToCM6(cm6: EditorFacade) {
     this.attachToEditor(cm6, () => {
-      cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'), this.type)
+      cm6.attachShareJs(this._doc, getMeta('ol-maxDocLength'))
     })
   }
 

+ 61 - 67
services/web/frontend/js/features/ide-react/editor/share-js-history-ot-type.ts

@@ -1,4 +1,3 @@
-import EventEmitter from '@/utils/EventEmitter'
 import {
   EditOperationBuilder,
   EditOperationTransformer,
@@ -9,75 +8,28 @@ import {
   TextOperation,
 } from 'overleaf-editor-core'
 import { RawEditOperation } from 'overleaf-editor-core/lib/types'
+import { ShareDoc } from '../../../../../types/share-doc'
 
-export class HistoryOTType extends EventEmitter {
-  // stub interface, these are actually on the Doc
-  api: HistoryOTType
-  snapshot: StringFileData
+type Api = {
+  otType: 'history-ot'
+  trackChangesUserId: string | null
 
-  constructor(snapshot: StringFileData) {
-    super()
-    this.api = this
-    this.snapshot = snapshot
-  }
-
-  transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) {
-    const [a, b] = EditOperationTransformer.transform(
-      EditOperationBuilder.fromJSON(raw1[0]),
-      EditOperationBuilder.fromJSON(raw2[0])
-    )
-    return [[a.toJSON()], [b.toJSON()]]
-  }
-
-  apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) {
-    const operation = EditOperationBuilder.fromJSON(rawEditOperation[0])
-    const afterFile = StringFileData.fromRaw(snapshot.toRaw())
-    afterFile.edit(operation)
-    this.snapshot = afterFile
-    return afterFile
-  }
-
-  compose(op1: RawEditOperation[], op2: RawEditOperation[]) {
-    return [
-      EditOperationBuilder.fromJSON(op1[0])
-        .compose(EditOperationBuilder.fromJSON(op2[0]))
-        .toJSON(),
-    ]
-  }
-
-  // Do not provide normalize, used by submitOp to fixup bad input.
-  // normalize(op: TextOperation) {}
-
-  // Do not provide invert, only needed for reverting a rejected update.
-  // We are displaying an out-of-sync modal when an op is rejected.
-  // invert(op: TextOperation) {}
-
-  // API
-  insert(pos: number, text: string, fromUndo: boolean) {
-    const old = this.getText()
-    const op = new TextOperation()
-    op.retain(pos)
-    op.insert(text)
-    op.retain(old.length - pos)
-    this.submitOp([op.toJSON()])
-  }
+  getText(): string
+  getLength(): number
+  _register(): void
+}
 
-  del(pos: number, length: number, fromUndo: boolean) {
-    const old = this.getText()
-    const op = new TextOperation()
-    op.retain(pos)
-    op.remove(length)
-    op.retain(old.length - pos - length)
-    this.submitOp([op.toJSON()])
-  }
+const api: Api & ThisType<Api & ShareDoc & { snapshot: StringFileData }> = {
+  otType: 'history-ot',
+  trackChangesUserId: null,
 
   getText() {
-    return this.snapshot.getContent({ filterTrackedDeletes: true })
-  }
+    return this.snapshot.getContent()
+  },
 
   getLength() {
-    return this.getText().length
-  }
+    return this.snapshot.getStringLength()
+  },
 
   _register() {
     this.on(
@@ -95,10 +47,14 @@ export class HistoryOTType extends EventEmitter {
 
           let outputCursor = 0
           let inputCursor = 0
+          let trackedChangesInvalidated = false
           for (const op of operation.ops) {
             if (op instanceof RetainOp) {
               inputCursor += op.length
               outputCursor += op.length
+              if (op.tracking != null) {
+                trackedChangesInvalidated = true
+              }
             } else if (op instanceof InsertOp) {
               this.emit(
                 'insert',
@@ -107,6 +63,7 @@ export class HistoryOTType extends EventEmitter {
                 op.insertion.length
               )
               outputCursor += op.insertion.length
+              trackedChangesInvalidated = true
             } else if (op instanceof RemoveOp) {
               this.emit(
                 'delete',
@@ -114,20 +71,57 @@ export class HistoryOTType extends EventEmitter {
                 str.slice(inputCursor, inputCursor + op.length)
               )
               inputCursor += op.length
+              trackedChangesInvalidated = true
             }
           }
 
-          if (inputCursor !== str.length)
+          if (inputCursor !== str.length) {
             throw new TextOperation.ApplyError(
               "The operation didn't operate on the whole string.",
               operation,
               str
             )
+          }
+
+          if (trackedChangesInvalidated) {
+            this.emit('tracked-changes-invalidated')
+          }
         }
       }
     )
-  }
+  },
+}
+
+export const historyOTType = {
+  api,
+
+  transformX(raw1: RawEditOperation[], raw2: RawEditOperation[]) {
+    const [a, b] = EditOperationTransformer.transform(
+      EditOperationBuilder.fromJSON(raw1[0]),
+      EditOperationBuilder.fromJSON(raw2[0])
+    )
+    return [[a.toJSON()], [b.toJSON()]]
+  },
+
+  apply(snapshot: StringFileData, rawEditOperation: RawEditOperation[]) {
+    const operation = EditOperationBuilder.fromJSON(rawEditOperation[0])
+    const afterFile = StringFileData.fromRaw(snapshot.toRaw())
+    afterFile.edit(operation)
+    return afterFile
+  },
 
-  // stub-interface, provided by sharejs.Doc
-  submitOp(op: RawEditOperation[]) {}
+  compose(op1: RawEditOperation[], op2: RawEditOperation[]) {
+    return [
+      EditOperationBuilder.fromJSON(op1[0])
+        .compose(EditOperationBuilder.fromJSON(op2[0]))
+        .toJSON(),
+    ]
+  },
+
+  // Do not provide normalize, used by submitOp to fixup bad input.
+  // normalize(op: TextOperation) {}
+
+  // Do not provide invert, only needed for reverting a rejected update.
+  // We are displaying an out-of-sync modal when an op is rejected.
+  // invert(op: TextOperation) {}
 }

+ 290 - 0
services/web/frontend/js/features/source-editor/extensions/history-ot.ts

@@ -0,0 +1,290 @@
+import { Decoration, EditorView } from '@codemirror/view'
+import {
+  ChangeSpec,
+  EditorState,
+  StateEffect,
+  StateField,
+  Transaction,
+} from '@codemirror/state'
+import {
+  CommentList,
+  EditOperation,
+  TextOperation,
+  TrackingProps,
+  TrackedChangeList,
+} from 'overleaf-editor-core'
+import { DocumentContainer } from '@/features/ide-react/editor/document-container'
+
+export const historyOT = (currentDoc: DocumentContainer) => {
+  const trackedChanges = currentDoc.doc?.getTrackedChanges()
+  return [
+    trackChangesUserIdState,
+    commentsState,
+    trackedChanges != null
+      ? trackedChangesState.init(() =>
+          buildTrackedChangesDecorations(trackedChanges)
+        )
+      : trackedChangesState,
+    trackedChangesFilter,
+    rangesTheme,
+  ]
+}
+
+const rangesTheme = EditorView.theme({
+  '.tracked-change-insertion': {
+    backgroundColor: 'rgba(0, 255, 0, 0.2)',
+  },
+  '.tracked-change-deletion': {
+    backgroundColor: 'rgba(255, 0, 0, 0.2)',
+  },
+  '.comment': {
+    backgroundColor: 'rgba(255, 255, 0, 0.2)',
+  },
+})
+
+const updateTrackedChangesEffect = StateEffect.define<TrackedChangeList>()
+
+export const updateTrackedChanges = (trackedChanges: TrackedChangeList) => {
+  return {
+    effects: updateTrackedChangesEffect.of(trackedChanges),
+  }
+}
+
+const buildTrackedChangesDecorations = (trackedChanges: TrackedChangeList) =>
+  Decoration.set(
+    trackedChanges.asSorted().map(change =>
+      Decoration.mark({
+        class:
+          change.tracking.type === 'insert'
+            ? 'tracked-change-insertion'
+            : 'tracked-change-deletion',
+        tracking: change.tracking,
+      }).range(change.range.pos, change.range.end)
+    ),
+    true
+  )
+
+const trackedChangesState = StateField.define({
+  create() {
+    return Decoration.none
+  },
+
+  update(value, transaction) {
+    if (transaction.docChanged) {
+      value = value.map(transaction.changes)
+    }
+
+    for (const effect of transaction.effects) {
+      if (effect.is(updateTrackedChangesEffect)) {
+        value = buildTrackedChangesDecorations(effect.value)
+      }
+    }
+
+    return value
+  },
+
+  provide(field) {
+    return EditorView.decorations.from(field)
+  },
+})
+
+const setTrackChangesUserIdEffect = StateEffect.define<string | null>()
+
+export const setTrackChangesUserId = (userId: string | null) => {
+  return {
+    effects: setTrackChangesUserIdEffect.of(userId),
+  }
+}
+
+const trackChangesUserIdState = StateField.define<string | null>({
+  create() {
+    return null
+  },
+
+  update(value, transaction) {
+    for (const effect of transaction.effects) {
+      if (effect.is(setTrackChangesUserIdEffect)) {
+        value = effect.value
+      }
+    }
+    return value
+  },
+})
+
+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 trackedChangesFilter = EditorState.transactionFilter.of(tr => {
+  if (!tr.docChanged || tr.annotation(Transaction.remote)) {
+    return tr
+  }
+
+  const trackingUserId = tr.startState.field(trackChangesUserIdState)
+  const startDoc = tr.startState.doc
+  const changes: ChangeSpec[] = []
+  const opBuilder = new OperationBuilder(startDoc.length)
+
+  if (trackingUserId == null) {
+    // Not tracking changes
+    tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
+      // insert
+      if (inserted.length > 0) {
+        opBuilder.insert(fromA, inserted.toString())
+      }
+
+      // deletion
+      if (toA > fromA) {
+        opBuilder.delete(fromA, toA - fromA)
+      }
+    })
+  } else {
+    // Tracking changes
+    const timestamp = new Date()
+    tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
+      // insertion
+      if (inserted.length > 0) {
+        opBuilder.trackedInsert(
+          fromA,
+          inserted.toString(),
+          trackingUserId,
+          timestamp
+        )
+      }
+
+      // deletion
+      if (toA > fromA) {
+        const deleted = startDoc.sliceString(fromA, toA)
+        // re-insert the deleted text after the inserted text
+        changes.push({
+          from: fromB + inserted.length,
+          insert: deleted,
+        })
+
+        opBuilder.trackedDelete(fromA, toA - fromA, trackingUserId, timestamp)
+      }
+    })
+  }
+
+  const op = opBuilder.finish()
+  return [
+    tr,
+    { changes, effects: historyOTOperationEffect.of([op]), sequential: true },
+  ]
+})
+
+/**
+ * Incrementally builds a TextOperation from a series of inserts and deletes.
+ *
+ * This relies on inserts and deletes being ordered by document position. This
+ * is not clear in the documentation, but has been confirmed by Marijn in
+ * https://discuss.codemirror.net/t/iterators-can-be-hard-to-work-with-for-beginners/3533/10
+ */
+class OperationBuilder {
+  /**
+   * Source document length
+   */
+  private docLength: number
+
+  /**
+   * Position in the source document
+   */
+  private pos: number
+
+  /**
+   * Operation built
+   */
+  private op: TextOperation
+
+  constructor(docLength: number) {
+    this.docLength = docLength
+    this.op = new TextOperation()
+    this.pos = 0
+  }
+
+  insert(pos: number, text: string) {
+    this.retainUntil(pos)
+    this.op.insert(text)
+  }
+
+  delete(pos: number, length: number) {
+    this.retainUntil(pos)
+    this.op.remove(length)
+    this.pos += length
+  }
+
+  trackedInsert(pos: number, text: string, userId: string, timestamp: Date) {
+    this.retainUntil(pos)
+    this.op.insert(text, {
+      tracking: new TrackingProps('insert', userId, timestamp),
+    })
+  }
+
+  trackedDelete(pos: number, length: number, userId: string, timestamp: Date) {
+    this.retainUntil(pos)
+    this.op.retain(length, {
+      tracking: new TrackingProps('delete', userId, timestamp),
+    })
+    this.pos += length
+  }
+
+  retainUntil(pos: number) {
+    if (pos > this.pos) {
+      this.op.retain(pos - this.pos)
+      this.pos = pos
+    } else if (pos < this.pos) {
+      throw Error(
+        `Out of order: position ${pos} comes before current position: ${this.pos}`
+      )
+    }
+  }
+
+  finish() {
+    this.retainUntil(this.docLength)
+    return this.op
+  }
+}

+ 4 - 1
services/web/frontend/js/features/source-editor/extensions/index.ts

@@ -50,6 +50,7 @@ import { docName } from './doc-name'
 import { fileTreeItemDrop } from './file-tree-item-drop'
 import { mathPreview } from './math-preview'
 import { ranges } from './ranges'
+import { historyOT } from './history-ot'
 import { trackDetachedComments } from './track-detached-comments'
 import { reviewTooltip } from './review-tooltip'
 
@@ -142,7 +143,9 @@ export const createExtensions = (options: Record<string, any>): Extension[] => [
   // NOTE: `emptyLineFiller` needs to be before `trackChanges`,
   // so the decorations are added in the correct order.
   emptyLineFiller(),
-  ranges(),
+  options.currentDoc.currentDocument.getType() === 'history-ot'
+    ? historyOT(options.currentDoc.currentDocument)
+    : ranges(),
   trackDetachedComments(options.currentDoc),
   visual(options.visual),
   mathPreview(options.settings.mathPreview),

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

@@ -1,11 +1,26 @@
-import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state'
+import {
+  Prec,
+  Transaction,
+  Annotation,
+  ChangeSpec,
+  Text,
+} from '@codemirror/state'
 import { EditorView, ViewPlugin } from '@codemirror/view'
 import { EventEmitter } from 'events'
 import RangesTracker from '@overleaf/ranges-tracker'
-import { ShareDoc } from '../../../../../types/share-doc'
+import {
+  ShareDoc,
+  ShareLatexOTShareDoc,
+  HistoryOTShareDoc,
+} from '../../../../../types/share-doc'
 import { debugConsole } from '@/utils/debugging'
 import { DocumentContainer } from '@/features/ide-react/editor/document-container'
-import { OTType } from '@/features/ide-react/editor/share-js-doc'
+import { TrackedChangeList } from 'overleaf-editor-core'
+import {
+  updateTrackedChanges,
+  setTrackChangesUserId,
+  historyOTOperationEffect,
+} from './history-ot'
 
 /*
  * Integrate CodeMirror 6 with the real-time system, via ShareJS.
@@ -26,8 +41,10 @@ import { OTType } from '@/features/ide-react/editor/share-js-doc'
  *   - frontend/js/features/ide-react/connection/editor-watchdog-manager.js
  */
 
+type Origin = 'remote' | 'undo' | 'reject' | undefined
+
 export type ChangeDescription = {
-  origin: 'remote' | 'undo' | 'reject' | undefined
+  origin: Origin
   inserted: boolean
   removed: boolean
 }
@@ -126,9 +143,13 @@ export class EditorFacade extends EventEmitter {
     this.cmChange({ from: position, to: position + text.length }, origin)
   }
 
-  attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) {
+  cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) {
+    this.view.dispatch(updateTrackedChanges(trackedChanges))
+  }
+
+  attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
     this.otAdapter =
-      type === 'history-ot'
+      shareDoc.otType === 'history-ot'
         ? new HistoryOTAdapter(this, shareDoc, maxDocLength)
         : new ShareLatexOTAdapter(this, shareDoc, maxDocLength)
     this.otAdapter.attachShareJs()
@@ -148,12 +169,18 @@ export class EditorFacade extends EventEmitter {
 
     this.otAdapter.handleUpdateFromCM(transactions, ranges)
   }
+
+  setTrackChangesUserId(userId: string | null) {
+    if (this.otAdapter instanceof HistoryOTAdapter) {
+      this.view.dispatch(setTrackChangesUserId(userId))
+    }
+  }
 }
 
 class ShareLatexOTAdapter {
   constructor(
     public editor: EditorFacade,
-    private shareDoc: ShareDoc,
+    private shareDoc: ShareLatexOTShareDoc,
     private maxDocLength?: number
   ) {
     this.editor = editor
@@ -279,7 +306,133 @@ class ShareLatexOTAdapter {
   }
 }
 
-class HistoryOTAdapter extends ShareLatexOTAdapter {}
+class HistoryOTAdapter {
+  constructor(
+    public editor: EditorFacade,
+    private shareDoc: HistoryOTShareDoc,
+    private maxDocLength?: number
+  ) {
+    this.editor = editor
+    this.shareDoc = shareDoc
+    this.maxDocLength = maxDocLength
+  }
+
+  attachShareJs() {
+    this.checkContent()
+
+    const onInsert = this.onShareJsInsert.bind(this)
+    const onDelete = this.onShareJsDelete.bind(this)
+    const onTrackedChangesInvalidated =
+      this.onShareJsTrackedChangesInvalidated.bind(this)
+
+    this.shareDoc.on('insert', onInsert)
+    this.shareDoc.on('delete', onDelete)
+    this.shareDoc.on('tracked-changes-invalidated', onTrackedChangesInvalidated)
+
+    this.shareDoc.detach_cm6 = () => {
+      this.shareDoc.removeListener('insert', onInsert)
+      this.shareDoc.removeListener('delete', onDelete)
+      this.shareDoc.removeListener(
+        'tracked-changes-invalidated',
+        onTrackedChangesInvalidated
+      )
+      delete this.shareDoc.detach_cm6
+      this.editor.detachShareJs()
+    }
+  }
+
+  handleUpdateFromCM(
+    transactions: readonly Transaction[],
+    ranges?: RangesTracker
+  ) {
+    for (const transaction of transactions) {
+      if (
+        this.maxDocLength &&
+        transaction.changes.newLength >= this.maxDocLength
+      ) {
+        this.shareDoc.emit(
+          'error',
+          new Error('document length is greater than maxDocLength')
+        )
+        return
+      }
+
+      let snapshotUpdated = false
+      for (const effect of transaction.effects) {
+        if (effect.is(historyOTOperationEffect)) {
+          this.shareDoc.submitOp(effect.value.map(op => op.toJSON()))
+          snapshotUpdated = true
+        }
+      }
+
+      if (snapshotUpdated || transaction.annotation(Transaction.remote)) {
+        window.setTimeout(() => {
+          this.editor.cmUpdateTrackedChanges(
+            this.shareDoc.snapshot.getTrackedChanges()
+          )
+        }, 0)
+      }
+
+      const origin = chooseOrigin(transaction)
+      transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
+        this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin)
+      })
+    }
+  }
+
+  onShareJsInsert(pos: number, text: string) {
+    this.editor.cmInsert(pos, text, 'remote')
+    this.checkContent()
+  }
+
+  onShareJsDelete(pos: number, text: string) {
+    this.editor.cmDelete(pos, text, 'remote')
+    this.checkContent()
+  }
+
+  onShareJsTrackedChangesInvalidated() {
+    this.editor.cmUpdateTrackedChanges(
+      this.shareDoc.snapshot.getTrackedChanges()
+    )
+  }
+
+  onCodeMirrorChange(
+    fromA: number,
+    toA: number,
+    fromB: number,
+    toB: number,
+    insertedText: Text,
+    origin: Origin
+  ) {
+    const insertedLength = insertedText.length
+    const removedLength = toA - fromA
+    const inserted = insertedLength > 0
+    const removed = removedLength > 0
+
+    const changeDescription: ChangeDescription = {
+      origin,
+      inserted,
+      removed,
+    }
+
+    this.editor.emit('change', this.editor, changeDescription)
+  }
+
+  checkContent() {
+    // run in a timeout so it checks the editor content once this update has been applied
+    window.setTimeout(() => {
+      const editorText = this.editor.getValue()
+      const otText = this.shareDoc.getText()
+
+      if (editorText !== otText) {
+        this.shareDoc.emit('error', 'Text does not match in CodeMirror 6')
+        debugConsole.error('Text does not match!')
+        debugConsole.error('editor: ' + editorText)
+        debugConsole.error('ot:     ' + otText)
+      }
+    }, 0)
+  }
+}
 
 export const trackChangesAnnotation = Annotation.define()
 

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

@@ -185,9 +185,9 @@ function useCodeMirrorScope(view: EditorView) {
 
     if (currentDocument) {
       if (trackChanges) {
-        currentDocument.track_changes_as = userId || 'anonymous'
+        currentDocument.setTrackChangesUserId(userId ?? 'anonymous')
       } else {
-        currentDocument.track_changes_as = null
+        currentDocument.setTrackChangesUserId(null)
       }
     }
   }, [userId, currentDocument, trackChanges])

+ 3 - 2
services/web/frontend/js/vendor/libs/sharejs.js

@@ -680,6 +680,7 @@ export const { Doc } = (() => {
   // Text document API for text
 
   text.api = {
+    otType: "sharejs-text-ot",
     provides: { text: true },
 
     // The number of characters in the string
@@ -1008,8 +1009,8 @@ export const { Doc } = (() => {
 
         this.type = type;
         if (type.api) {
-          for (const k of ['insert', 'del', 'getText', 'getLength', '_register']) {
-            this[k] = type.api[k]
+          for (var k in type.api) {
+            var v = type.api[k];this[k] = v;
           }
           return typeof this._register === 'function' ? this._register() : undefined;
         } else {

+ 1 - 1
services/web/frontend/stories/source-editor/source-editor.stories.tsx

@@ -198,7 +198,7 @@ const mockDoc = (content: string, changes: Array<Record<string, any>> = []) => {
     setTrackChangesIdSeeds: () => {
       // Do nothing
     },
-    setTrackingChanges: () => {
+    setTrackChangesUserId: () => {
       // Do nothing
     },
     getTrackingChanges: () => {

+ 11 - 3
services/web/test/frontend/features/source-editor/helpers/mock-doc.ts

@@ -1,4 +1,4 @@
-import { ShareDoc } from '../../../../../types/share-doc'
+import { ShareLatexOTShareDoc } from '../../../../../types/share-doc'
 import { EventEmitter } from 'events'
 
 export const docId = 'test-doc'
@@ -36,6 +36,9 @@ const defaultContent = mockDocContent(contentLines.join('\n'))
 const MAX_DOC_LENGTH = 2 * 1024 * 1024 // ol-maxDocLength
 
 class MockShareDoc extends EventEmitter {
+  otType = 'sharejs-text-ot' as const
+  snapshot = ''
+
   constructor(public text: string) {
     super()
   }
@@ -51,16 +54,21 @@ class MockShareDoc extends EventEmitter {
   del() {
     // do nothing
   }
+
+  submitOp() {
+    // do nothing
+  }
 }
 
 export const mockDoc = (
   content = defaultContent,
   { rangesOptions = {} } = {}
 ) => {
-  const mockShareJSDoc: ShareDoc = new MockShareDoc(content)
+  const mockShareJSDoc: ShareLatexOTShareDoc = new MockShareDoc(content)
 
   return {
     doc_id: docId,
+    getType: () => 'sharejs-text-ot',
     getSnapshot: () => {
       return content
     },
@@ -101,7 +109,7 @@ export const mockDoc = (
     submitOp: (op: any) => {},
     setTrackChangesIdSeeds: () => {},
     getTrackingChanges: () => true,
-    setTrackingChanges: () => {},
+    setTrackChangesUserId: () => {},
     getInflightOp: () => null,
     getPendingOp: () => null,
     hasBufferedOps: () => false,

+ 15 - 1
services/web/types/share-doc.ts

@@ -1,9 +1,23 @@
 import EventEmitter from 'events'
+import { StringFileData } from 'overleaf-editor-core'
 
 // type for the Doc class in vendor/libs/sharejs.js
-export interface ShareDoc extends EventEmitter {
+export interface ShareLatexOTShareDoc extends EventEmitter {
+  otType: 'sharejs-text-ot'
+  snapshot: string
   detach_cm6?: () => void
   getText: () => string
   insert: (pos: number, insert: string, fromUndo: boolean) => void
   del: (pos: number, length: number, fromUndo: boolean) => void
+  submitOp(op: any[]): void
 }
+
+export interface HistoryOTShareDoc extends EventEmitter {
+  otType: 'history-ot'
+  snapshot: StringFileData
+  detach_cm6?: () => void
+  getText: () => string
+  submitOp(op: any[]): void
+}
+
+export type ShareDoc = ShareLatexOTShareDoc | HistoryOTShareDoc