realtime.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { Prec, Transaction, Annotation, ChangeSpec } from '@codemirror/state'
  2. import { EditorView, ViewPlugin } from '@codemirror/view'
  3. import { EventEmitter } from 'events'
  4. import RangesTracker from '@overleaf/ranges-tracker'
  5. import { ShareDoc } from '../../../../../types/share-doc'
  6. import { debugConsole } from '@/utils/debugging'
  7. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  8. import { OTType } from '@/features/ide-react/editor/share-js-doc'
  9. /*
  10. * Integrate CodeMirror 6 with the real-time system, via ShareJS.
  11. *
  12. * Changes from CodeMirror are passed to the shareDoc
  13. * via `handleTransaction`, while changes arriving from
  14. * real-time are passed to CodeMirror via the EditorFacade.
  15. *
  16. * We use an `EditorFacade` to integrate with the rest of
  17. * the IDE, providing an interface the other systems can work with.
  18. *
  19. * Related files:
  20. * - frontend/js/ide/editor/Document.js
  21. * - frontend/js/ide/editor/ShareJsDoc.js
  22. * - frontend/js/ide/connection/EditorWatchdogManager.js
  23. * - frontend/js/features/ide-react/editor/document.ts
  24. * - frontend/js/features/ide-react/editor/share-js-doc.ts
  25. * - frontend/js/features/ide-react/connection/editor-watchdog-manager.js
  26. */
  27. export type ChangeDescription = {
  28. origin: 'remote' | 'undo' | 'reject' | undefined
  29. inserted: boolean
  30. removed: boolean
  31. }
  32. /**
  33. * A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document.
  34. */
  35. export const realtime = (
  36. { currentDoc }: { currentDoc: DocumentContainer },
  37. handleError: (error: Error) => void
  38. ) => {
  39. const realtimePlugin = ViewPlugin.define(view => {
  40. const editor = new EditorFacade(view)
  41. currentDoc.attachToCM6(editor)
  42. return {
  43. update(update) {
  44. if (update.docChanged) {
  45. editor.handleUpdateFromCM(update.transactions, currentDoc.ranges)
  46. }
  47. },
  48. destroy() {
  49. // TODO: wrap in a timeout so processing can finish?
  50. // window.setTimeout(() => {
  51. currentDoc.detachFromCM6()
  52. // }, 0)
  53. },
  54. }
  55. })
  56. // NOTE: not a view plugin, so shouldn't get removed
  57. const ensureRealtimePlugin = EditorView.updateListener.of(update => {
  58. if (!update.view.plugin(realtimePlugin)) {
  59. const message = 'The realtime extension has been destroyed!!'
  60. debugConsole.warn(message)
  61. if (currentDoc.doc) {
  62. // display the "out of sync" modal
  63. currentDoc.doc.emit('error', message)
  64. } else {
  65. // display the error boundary
  66. handleError(new Error(message))
  67. }
  68. }
  69. })
  70. return Prec.highest([realtimePlugin, ensureRealtimePlugin])
  71. }
  72. type OTAdapter = {
  73. handleUpdateFromCM(
  74. transactions: readonly Transaction[],
  75. ranges?: RangesTracker
  76. ): void
  77. attachShareJs(): void
  78. }
  79. export class EditorFacade extends EventEmitter {
  80. private otAdapter: OTAdapter | null
  81. public events: EventEmitter
  82. constructor(public view: EditorView) {
  83. super()
  84. this.view = view
  85. this.otAdapter = null
  86. this.events = new EventEmitter()
  87. }
  88. getValue() {
  89. return this.view.state.doc.toString()
  90. }
  91. // Dispatch changes to CodeMirror view
  92. cmChange(changes: ChangeSpec, origin?: string) {
  93. const isRemote = origin === 'remote'
  94. this.view.dispatch({
  95. changes,
  96. annotations: [
  97. Transaction.remote.of(isRemote),
  98. Transaction.addToHistory.of(!isRemote),
  99. ],
  100. effects:
  101. // if this is a remote change, restore a snapshot of the current scroll position after the change has been applied
  102. isRemote
  103. ? this.view.scrollSnapshot().map(this.view.state.changes(changes))
  104. : undefined,
  105. })
  106. }
  107. cmInsert(position: number, text: string, origin?: string) {
  108. this.cmChange({ from: position, insert: text }, origin)
  109. }
  110. cmDelete(position: number, text: string, origin?: string) {
  111. this.cmChange({ from: position, to: position + text.length }, origin)
  112. }
  113. attachShareJs(shareDoc: ShareDoc, maxDocLength?: number, type?: OTType) {
  114. this.otAdapter =
  115. type === 'history-ot'
  116. ? new HistoryOTAdapter(this, shareDoc, maxDocLength)
  117. : new ShareLatexOTAdapter(this, shareDoc, maxDocLength)
  118. this.otAdapter.attachShareJs()
  119. }
  120. detachShareJs() {
  121. this.otAdapter = null
  122. }
  123. handleUpdateFromCM(
  124. transactions: readonly Transaction[],
  125. ranges?: RangesTracker
  126. ) {
  127. if (this.otAdapter == null) {
  128. throw new Error('Trying to process updates with no otAdapter')
  129. }
  130. this.otAdapter.handleUpdateFromCM(transactions, ranges)
  131. }
  132. }
  133. class ShareLatexOTAdapter {
  134. constructor(
  135. public editor: EditorFacade,
  136. private shareDoc: ShareDoc,
  137. private maxDocLength?: number
  138. ) {
  139. this.editor = editor
  140. this.shareDoc = shareDoc
  141. this.maxDocLength = maxDocLength
  142. }
  143. // Connect to ShareJS, passing changes to the CodeMirror view
  144. // as new transactions.
  145. // This is a broad immitation of helper functions supplied in
  146. // the sharejs library. (See vendor/libs/sharejs, in particular
  147. // the 'attach_ace' helper)
  148. attachShareJs() {
  149. const shareDoc = this.shareDoc
  150. const check = () => {
  151. // run in a timeout so it checks the editor content once this update has been applied
  152. window.setTimeout(() => {
  153. const editorText = this.editor.getValue()
  154. const otText = shareDoc.getText()
  155. if (editorText !== otText) {
  156. this.shareDoc.emit('error', 'Text does not match in CodeMirror 6')
  157. debugConsole.error('Text does not match!')
  158. debugConsole.error('editor: ' + editorText)
  159. debugConsole.error('ot: ' + otText)
  160. }
  161. }, 0)
  162. }
  163. const onInsert = (pos: number, text: string) => {
  164. this.editor.cmInsert(pos, text, 'remote')
  165. check()
  166. }
  167. const onDelete = (pos: number, text: string) => {
  168. this.editor.cmDelete(pos, text, 'remote')
  169. check()
  170. }
  171. check()
  172. shareDoc.on('insert', onInsert)
  173. shareDoc.on('delete', onDelete)
  174. shareDoc.detach_cm6 = () => {
  175. shareDoc.removeListener('insert', onInsert)
  176. shareDoc.removeListener('delete', onDelete)
  177. delete shareDoc.detach_cm6
  178. this.editor.detachShareJs()
  179. }
  180. }
  181. // Process an update from CodeMirror, applying changes to the
  182. // ShareJs doc if appropriate
  183. handleUpdateFromCM(
  184. transactions: readonly Transaction[],
  185. ranges?: RangesTracker
  186. ) {
  187. const shareDoc = this.shareDoc
  188. const trackedDeletesLength =
  189. ranges != null ? ranges.getTrackedDeletesLength() : 0
  190. for (const transaction of transactions) {
  191. if (transaction.docChanged) {
  192. const origin = chooseOrigin(transaction)
  193. if (origin === 'remote') {
  194. return
  195. }
  196. // This is an approximation. Some deletes could have generated new
  197. // tracked deletes since we measured trackedDeletesLength at the top of
  198. // the function. Unfortunately, the ranges tracker is only updated
  199. // after all transactions are processed, so it's not easy to get an
  200. // exact number.
  201. const fullDocLength =
  202. transaction.changes.desc.newLength + trackedDeletesLength
  203. if (this.maxDocLength && fullDocLength >= this.maxDocLength) {
  204. shareDoc.emit(
  205. 'error',
  206. new Error('document length is greater than maxDocLength')
  207. )
  208. return
  209. }
  210. let positionShift = 0
  211. transaction.changes.iterChanges(
  212. (fromA, toA, fromB, toB, insertedText) => {
  213. const fromUndo = origin === 'undo' || origin === 'reject'
  214. const insertedLength = insertedText.length
  215. const removedLength = toA - fromA
  216. const inserted = insertedLength > 0
  217. const removed = removedLength > 0
  218. const pos = fromA + positionShift
  219. if (removed) {
  220. shareDoc.del(pos, removedLength, fromUndo)
  221. }
  222. if (inserted) {
  223. shareDoc.insert(pos, insertedText.toString(), fromUndo)
  224. }
  225. // TODO: mapPos instead?
  226. positionShift = positionShift - removedLength + insertedLength
  227. const changeDescription: ChangeDescription = {
  228. origin,
  229. inserted,
  230. removed,
  231. }
  232. this.editor.emit('change', this.editor, changeDescription)
  233. }
  234. )
  235. }
  236. }
  237. }
  238. }
  239. class HistoryOTAdapter extends ShareLatexOTAdapter {}
  240. export const trackChangesAnnotation = Annotation.define()
  241. const chooseOrigin = (transaction: Transaction) => {
  242. if (transaction.annotation(Transaction.remote)) {
  243. return 'remote'
  244. }
  245. if (transaction.annotation(Transaction.userEvent) === 'undo') {
  246. return 'undo'
  247. }
  248. if (transaction.annotation(trackChangesAnnotation) === 'reject') {
  249. return 'reject'
  250. }
  251. }