realtime.ts 7.6 KB

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