Files
overleaf-cep/services/web/frontend/js/features/source-editor/extensions/realtime.ts
T
Eric Mc Sween 266f2c47c0 Merge pull request #26041 from overleaf/em-history-ot-type-serialize
History OT type: operate on parsed EditOperations

GitOrigin-RevId: dbb35789736958d4ef398e566400d6e9a0e49e8b
2025-06-04 08:07:54 +00:00

450 lines
12 KiB
TypeScript

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,
ShareLatexOTShareDoc,
HistoryOTShareDoc,
} from '../../../../../types/share-doc'
import { debugConsole } from '@/utils/debugging'
import { DocumentContainer } from '@/features/ide-react/editor/document-container'
import { TrackedChangeList } from 'overleaf-editor-core'
import {
updateTrackedChanges,
setTrackChangesUserId,
historyOTOperationEffect,
} from './history-ot'
/*
* Integrate CodeMirror 6 with the real-time system, via ShareJS.
*
* Changes from CodeMirror are passed to the shareDoc
* via `handleTransaction`, while changes arriving from
* real-time are passed to CodeMirror via the EditorFacade.
*
* We use an `EditorFacade` to integrate with the rest of
* the IDE, providing an interface the other systems can work with.
*
* Related files:
* - frontend/js/ide/editor/Document.js
* - frontend/js/ide/editor/ShareJsDoc.js
* - frontend/js/ide/connection/EditorWatchdogManager.js
* - frontend/js/features/ide-react/editor/document.ts
* - frontend/js/features/ide-react/editor/share-js-doc.ts
* - frontend/js/features/ide-react/connection/editor-watchdog-manager.js
*/
type Origin = 'remote' | 'undo' | 'reject' | undefined
export type ChangeDescription = {
origin: Origin
inserted: boolean
removed: boolean
}
/**
* A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document.
*/
export const realtime = (
{ currentDoc }: { currentDoc: DocumentContainer },
handleError: (error: Error) => void
) => {
const realtimePlugin = ViewPlugin.define(view => {
const editor = new EditorFacade(view)
currentDoc.attachToCM6(editor)
return {
update(update) {
if (update.docChanged) {
editor.handleUpdateFromCM(update.transactions, currentDoc.ranges)
}
},
destroy() {
// TODO: wrap in a timeout so processing can finish?
// window.setTimeout(() => {
currentDoc.detachFromCM6()
// }, 0)
},
}
})
// NOTE: not a view plugin, so shouldn't get removed
const ensureRealtimePlugin = EditorView.updateListener.of(update => {
if (!update.view.plugin(realtimePlugin)) {
const message = 'The realtime extension has been destroyed!!'
debugConsole.warn(message)
if (currentDoc.doc) {
// display the "out of sync" modal
currentDoc.doc.emit('error', message)
} else {
// display the error boundary
handleError(new Error(message))
}
}
})
return Prec.highest([realtimePlugin, ensureRealtimePlugin])
}
type OTAdapter = {
handleUpdateFromCM(
transactions: readonly Transaction[],
ranges?: RangesTracker
): void
attachShareJs(): void
}
export class EditorFacade extends EventEmitter {
private otAdapter: OTAdapter | null
public events: EventEmitter
constructor(public view: EditorView) {
super()
this.view = view
this.otAdapter = null
this.events = new EventEmitter()
}
getValue() {
return this.view.state.doc.toString()
}
// Dispatch changes to CodeMirror view
cmChange(changes: ChangeSpec, origin?: string) {
const isRemote = origin === 'remote'
this.view.dispatch({
changes,
annotations: [
Transaction.remote.of(isRemote),
Transaction.addToHistory.of(!isRemote),
],
effects:
// if this is a remote change, restore a snapshot of the current scroll position after the change has been applied
isRemote
? this.view.scrollSnapshot().map(this.view.state.changes(changes))
: undefined,
})
}
cmInsert(position: number, text: string, origin?: string) {
this.cmChange({ from: position, insert: text }, origin)
}
cmDelete(position: number, text: string, origin?: string) {
this.cmChange({ from: position, to: position + text.length }, origin)
}
cmUpdateTrackedChanges(trackedChanges: TrackedChangeList) {
this.view.dispatch(updateTrackedChanges(trackedChanges))
}
attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
this.otAdapter =
shareDoc.otType === 'history-ot'
? new HistoryOTAdapter(this, shareDoc, maxDocLength)
: new ShareLatexOTAdapter(this, shareDoc, maxDocLength)
this.otAdapter.attachShareJs()
}
detachShareJs() {
this.otAdapter = null
}
handleUpdateFromCM(
transactions: readonly Transaction[],
ranges?: RangesTracker
) {
if (this.otAdapter == null) {
throw new Error('Trying to process updates with no otAdapter')
}
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: ShareLatexOTShareDoc,
private maxDocLength?: number
) {
this.editor = editor
this.shareDoc = shareDoc
this.maxDocLength = maxDocLength
}
// Connect to ShareJS, passing changes to the CodeMirror view
// as new transactions.
// This is a broad immitation of helper functions supplied in
// the sharejs library. (See vendor/libs/sharejs, in particular
// the 'attach_ace' helper)
attachShareJs() {
const shareDoc = this.shareDoc
const check = () => {
// 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 = 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)
}
const onInsert = (pos: number, text: string) => {
this.editor.cmInsert(pos, text, 'remote')
check()
}
const onDelete = (pos: number, text: string) => {
this.editor.cmDelete(pos, text, 'remote')
check()
}
check()
shareDoc.on('insert', onInsert)
shareDoc.on('delete', onDelete)
shareDoc.detach_cm6 = () => {
shareDoc.removeListener('insert', onInsert)
shareDoc.removeListener('delete', onDelete)
delete shareDoc.detach_cm6
this.editor.detachShareJs()
}
}
// Process an update from CodeMirror, applying changes to the
// ShareJs doc if appropriate
handleUpdateFromCM(
transactions: readonly Transaction[],
ranges?: RangesTracker
) {
const shareDoc = this.shareDoc
const trackedDeletesLength =
ranges != null ? ranges.getTrackedDeletesLength() : 0
for (const transaction of transactions) {
if (transaction.docChanged) {
const origin = chooseOrigin(transaction)
if (origin === 'remote') {
return
}
// This is an approximation. Some deletes could have generated new
// tracked deletes since we measured trackedDeletesLength at the top of
// the function. Unfortunately, the ranges tracker is only updated
// after all transactions are processed, so it's not easy to get an
// exact number.
const fullDocLength =
transaction.changes.desc.newLength + trackedDeletesLength
if (this.maxDocLength && fullDocLength >= this.maxDocLength) {
shareDoc.emit(
'error',
new Error('document length is greater than maxDocLength')
)
return
}
let positionShift = 0
transaction.changes.iterChanges(
(fromA, toA, fromB, toB, insertedText) => {
const fromUndo = origin === 'undo' || origin === 'reject'
const insertedLength = insertedText.length
const removedLength = toA - fromA
const inserted = insertedLength > 0
const removed = removedLength > 0
const pos = fromA + positionShift
if (removed) {
shareDoc.del(pos, removedLength, fromUndo)
}
if (inserted) {
shareDoc.insert(pos, insertedText.toString(), fromUndo)
}
// TODO: mapPos instead?
positionShift = positionShift - removedLength + insertedLength
const changeDescription: ChangeDescription = {
origin,
inserted,
removed,
}
this.editor.emit('change', this.editor, changeDescription)
}
)
}
}
}
}
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)
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()
const chooseOrigin = (transaction: Transaction) => {
if (transaction.annotation(Transaction.remote)) {
return 'remote'
}
if (transaction.annotation(Transaction.userEvent) === 'undo') {
return 'undo'
}
if (transaction.annotation(trackChangesAnnotation) === 'reject') {
return 'reject'
}
}