realtime.ts 6.4 KB

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