realtime.ts 6.8 KB

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