| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449 |
- 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.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()
- 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'
- }
- }
|