realtime.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. import {
  2. Prec,
  3. Transaction,
  4. Annotation,
  5. ChangeSpec,
  6. Text,
  7. StateEffect,
  8. } from '@codemirror/state'
  9. import { EditorView, ViewPlugin } from '@codemirror/view'
  10. import { EventEmitter } from 'events'
  11. import RangesTracker from '@overleaf/ranges-tracker'
  12. import {
  13. ShareDoc,
  14. ShareLatexOTShareDoc,
  15. HistoryOTShareDoc,
  16. } from '../../../../../types/share-doc'
  17. import { debugConsole } from '@/utils/debugging'
  18. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  19. import {
  20. EditOperation,
  21. TextOperation,
  22. InsertOp,
  23. RemoveOp,
  24. RetainOp,
  25. } from 'overleaf-editor-core'
  26. import { rangesUpdatedEffect, setTrackChangesUserId } from './history-ot'
  27. import { trackedDeletesFromState } from '@/features/source-editor/utils/tracked-deletes'
  28. /*
  29. * Integrate CodeMirror 6 with the real-time system, via ShareJS.
  30. *
  31. * Changes from CodeMirror are passed to the shareDoc
  32. * via `handleTransaction`, while changes arriving from
  33. * real-time are passed to CodeMirror via the EditorFacade.
  34. *
  35. * We use an `EditorFacade` to integrate with the rest of
  36. * the IDE, providing an interface the other systems can work with.
  37. *
  38. * Related files:
  39. * - frontend/js/ide/editor/Document.js
  40. * - frontend/js/ide/editor/ShareJsDoc.js
  41. * - frontend/js/ide/connection/EditorWatchdogManager.js
  42. * - frontend/js/features/ide-react/editor/document.ts
  43. * - frontend/js/features/ide-react/editor/share-js-doc.ts
  44. * - frontend/js/features/ide-react/connection/editor-watchdog-manager.js
  45. */
  46. type Origin = 'remote' | 'undo' | 'reject' | undefined
  47. export type ChangeDescription = {
  48. origin: Origin
  49. inserted: boolean
  50. removed: boolean
  51. }
  52. /**
  53. * A custom extension that connects the CodeMirror 6 editor to the currently open ShareJS document.
  54. */
  55. export const realtime = (
  56. { currentDoc }: { currentDoc: DocumentContainer },
  57. handleError: (error: Error) => void
  58. ) => {
  59. const realtimePlugin = ViewPlugin.define(view => {
  60. const editor = new EditorFacade(view)
  61. currentDoc.attachToCM6(editor)
  62. return {
  63. update(update) {
  64. if (update.docChanged) {
  65. editor.handleUpdateFromCM(update.transactions, currentDoc.ranges)
  66. }
  67. },
  68. destroy() {
  69. // TODO: wrap in a timeout so processing can finish?
  70. // window.setTimeout(() => {
  71. currentDoc.detachFromCM6()
  72. // }, 0)
  73. },
  74. }
  75. })
  76. // NOTE: not a view plugin, so shouldn't get removed
  77. const ensureRealtimePlugin = EditorView.updateListener.of(update => {
  78. if (!update.view.plugin(realtimePlugin)) {
  79. const message = 'The realtime extension has been destroyed!!'
  80. debugConsole.warn(message)
  81. if (currentDoc.doc) {
  82. // display the "out of sync" modal
  83. currentDoc.doc.emit('error', message)
  84. } else {
  85. // display the error boundary
  86. handleError(new Error(message))
  87. }
  88. }
  89. })
  90. return Prec.highest([realtimePlugin, ensureRealtimePlugin])
  91. }
  92. type OTAdapter = {
  93. handleUpdateFromCM(
  94. transactions: readonly Transaction[],
  95. ranges?: RangesTracker
  96. ): void
  97. attachShareJs(): void
  98. }
  99. export class EditorFacade extends EventEmitter {
  100. private otAdapter: OTAdapter | null
  101. public events: EventEmitter
  102. constructor(public view: EditorView) {
  103. super()
  104. this.view = view
  105. this.otAdapter = null
  106. this.events = new EventEmitter()
  107. }
  108. getValue() {
  109. return this.view.state.doc.toString()
  110. }
  111. // Dispatch changes to CodeMirror view
  112. cmChange(changes: ChangeSpec, origin?: string) {
  113. const isRemote = origin === 'remote'
  114. this.view.dispatch({
  115. changes,
  116. annotations: [
  117. Transaction.remote.of(isRemote),
  118. Transaction.addToHistory.of(!isRemote),
  119. ],
  120. effects:
  121. // if this is a remote change, restore a snapshot of the current scroll position after the change has been applied
  122. isRemote
  123. ? this.view.scrollSnapshot().map(this.view.state.changes(changes))
  124. : undefined,
  125. })
  126. }
  127. cmInsert(position: number, text: string, origin?: string) {
  128. this.cmChange({ from: position, insert: text }, origin)
  129. }
  130. cmDelete(position: number, text: string, origin?: string) {
  131. this.cmChange({ from: position, to: position + text.length }, origin)
  132. }
  133. attachShareJs(shareDoc: ShareDoc, maxDocLength?: number) {
  134. this.otAdapter =
  135. shareDoc.otType === 'history-ot'
  136. ? new HistoryOTAdapter(this, shareDoc, maxDocLength)
  137. : new ShareLatexOTAdapter(this, shareDoc, maxDocLength)
  138. this.otAdapter.attachShareJs()
  139. }
  140. detachShareJs() {
  141. this.otAdapter = null
  142. }
  143. handleUpdateFromCM(
  144. transactions: readonly Transaction[],
  145. ranges?: RangesTracker
  146. ) {
  147. if (this.otAdapter == null) {
  148. throw new Error('Trying to process updates with no otAdapter')
  149. }
  150. this.otAdapter.handleUpdateFromCM(transactions, ranges)
  151. }
  152. setTrackChangesUserId(userId: string | null) {
  153. if (this.otAdapter instanceof HistoryOTAdapter) {
  154. this.view.dispatch(setTrackChangesUserId(userId))
  155. }
  156. }
  157. }
  158. class ShareLatexOTAdapter {
  159. constructor(
  160. public editor: EditorFacade,
  161. private shareDoc: ShareLatexOTShareDoc,
  162. private maxDocLength?: number
  163. ) {
  164. this.editor = editor
  165. this.shareDoc = shareDoc
  166. this.maxDocLength = maxDocLength
  167. }
  168. // Connect to ShareJS, passing changes to the CodeMirror view
  169. // as new transactions.
  170. // This is a broad immitation of helper functions supplied in
  171. // the sharejs library. (See vendor/libs/sharejs, in particular
  172. // the 'attach_ace' helper)
  173. attachShareJs() {
  174. const shareDoc = this.shareDoc
  175. const check = () => {
  176. // run in a timeout so it checks the editor content once this update has been applied
  177. window.setTimeout(() => {
  178. const editorText = this.editor.getValue()
  179. const otText = shareDoc.getText()
  180. if (editorText !== otText) {
  181. this.shareDoc.emit('error', 'Text does not match in CodeMirror 6')
  182. debugConsole.error('Text does not match!')
  183. debugConsole.error('editor: ' + editorText)
  184. debugConsole.error('ot: ' + otText)
  185. }
  186. }, 0)
  187. }
  188. const onInsert = (pos: number, text: string) => {
  189. this.editor.cmInsert(pos, text, 'remote')
  190. check()
  191. }
  192. const onDelete = (pos: number, text: string) => {
  193. this.editor.cmDelete(pos, text, 'remote')
  194. check()
  195. }
  196. check()
  197. shareDoc.on('insert', onInsert)
  198. shareDoc.on('delete', onDelete)
  199. shareDoc.detach_cm6 = () => {
  200. shareDoc.removeListener('insert', onInsert)
  201. shareDoc.removeListener('delete', onDelete)
  202. delete shareDoc.detach_cm6
  203. this.editor.detachShareJs()
  204. }
  205. }
  206. // Process an update from CodeMirror, applying changes to the
  207. // ShareJs doc if appropriate
  208. handleUpdateFromCM(
  209. transactions: readonly Transaction[],
  210. ranges?: RangesTracker
  211. ) {
  212. const shareDoc = this.shareDoc
  213. const trackedDeletesLength =
  214. ranges != null ? ranges.getTrackedDeletesLength() : 0
  215. for (const transaction of transactions) {
  216. if (transaction.docChanged) {
  217. const origin = chooseOrigin(transaction)
  218. if (origin === 'remote') {
  219. return
  220. }
  221. // This is an approximation. Some deletes could have generated new
  222. // tracked deletes since we measured trackedDeletesLength at the top of
  223. // the function. Unfortunately, the ranges tracker is only updated
  224. // after all transactions are processed, so it's not easy to get an
  225. // exact number.
  226. const fullDocLength =
  227. transaction.changes.desc.newLength + trackedDeletesLength
  228. if (this.maxDocLength && fullDocLength >= this.maxDocLength) {
  229. shareDoc.emit(
  230. 'error',
  231. new Error('document length is greater than maxDocLength')
  232. )
  233. return
  234. }
  235. let positionShift = 0
  236. transaction.changes.iterChanges(
  237. (fromA, toA, fromB, toB, insertedText) => {
  238. const fromUndo = origin === 'undo' || origin === 'reject'
  239. const insertedLength = insertedText.length
  240. const removedLength = toA - fromA
  241. const inserted = insertedLength > 0
  242. const removed = removedLength > 0
  243. const pos = fromA + positionShift
  244. if (removed) {
  245. shareDoc.del(pos, removedLength, fromUndo)
  246. }
  247. if (inserted) {
  248. shareDoc.insert(pos, insertedText.toString(), fromUndo)
  249. }
  250. // TODO: mapPos instead?
  251. positionShift = positionShift - removedLength + insertedLength
  252. const changeDescription: ChangeDescription = {
  253. origin,
  254. inserted,
  255. removed,
  256. }
  257. this.editor.emit('change', this.editor, changeDescription)
  258. }
  259. )
  260. }
  261. }
  262. }
  263. }
  264. class HistoryOTAdapter {
  265. constructor(
  266. public editor: EditorFacade,
  267. private shareDoc: HistoryOTShareDoc,
  268. private maxDocLength?: number
  269. ) {
  270. this.editor = editor
  271. this.shareDoc = shareDoc
  272. this.maxDocLength = maxDocLength
  273. }
  274. attachShareJs() {
  275. this.checkContent()
  276. const onRemoteOp = this.onRemoteOp.bind(this)
  277. this.shareDoc.on('remoteop', onRemoteOp)
  278. this.shareDoc.detach_cm6 = () => {
  279. this.shareDoc.removeListener('remoteop', onRemoteOp)
  280. delete this.shareDoc.detach_cm6
  281. this.editor.detachShareJs()
  282. }
  283. }
  284. handleUpdateFromCM(transactions: readonly Transaction[]) {
  285. for (const transaction of transactions) {
  286. if (
  287. this.maxDocLength &&
  288. transaction.changes.newLength >= this.maxDocLength
  289. ) {
  290. this.shareDoc.emit(
  291. 'error',
  292. new Error('document length is greater than maxDocLength')
  293. )
  294. return
  295. }
  296. const origin = chooseOrigin(transaction)
  297. transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
  298. this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin)
  299. })
  300. }
  301. }
  302. onRemoteOp(operations: EditOperation[]) {
  303. const trackedDeletes = trackedDeletesFromState(this.editor.view.state)
  304. const changes: ChangeSpec[] = []
  305. let trackedChangesUpdated = false
  306. for (const operation of operations) {
  307. if (operation instanceof TextOperation) {
  308. let cursor = 0
  309. for (const op of operation.ops) {
  310. if (op instanceof InsertOp) {
  311. if (op.tracking?.type !== 'delete') {
  312. changes.push({
  313. from: trackedDeletes.toCodeMirror(cursor),
  314. insert: op.insertion,
  315. })
  316. }
  317. trackedChangesUpdated = true
  318. } else if (op instanceof RemoveOp) {
  319. changes.push({
  320. from: trackedDeletes.toCodeMirror(cursor),
  321. to: trackedDeletes.toCodeMirror(cursor + op.length),
  322. })
  323. cursor += op.length
  324. trackedChangesUpdated = true
  325. } else if (op instanceof RetainOp) {
  326. if (op.tracking != null) {
  327. if (op.tracking.type === 'delete') {
  328. changes.push({
  329. from: trackedDeletes.toCodeMirror(cursor),
  330. to: trackedDeletes.toCodeMirror(cursor + op.length),
  331. })
  332. }
  333. trackedChangesUpdated = true
  334. }
  335. cursor += op.length
  336. }
  337. }
  338. }
  339. const view = this.editor.view
  340. const effects: StateEffect<any>[] = []
  341. const scrollEffect = view
  342. .scrollSnapshot()
  343. .map(view.state.changes(changes))
  344. if (scrollEffect != null) {
  345. effects.push(scrollEffect)
  346. }
  347. if (trackedChangesUpdated) {
  348. effects.push(rangesUpdatedEffect.of(null))
  349. }
  350. view.dispatch({
  351. changes,
  352. effects,
  353. annotations: [
  354. Transaction.remote.of(true),
  355. Transaction.addToHistory.of(false),
  356. ],
  357. })
  358. }
  359. }
  360. onCodeMirrorChange(
  361. fromA: number,
  362. toA: number,
  363. fromB: number,
  364. toB: number,
  365. insertedText: Text,
  366. origin: Origin
  367. ) {
  368. const insertedLength = insertedText.length
  369. const removedLength = toA - fromA
  370. const inserted = insertedLength > 0
  371. const removed = removedLength > 0
  372. const changeDescription: ChangeDescription = {
  373. origin,
  374. inserted,
  375. removed,
  376. }
  377. this.editor.emit('change', this.editor, changeDescription)
  378. }
  379. checkContent() {
  380. // run in a timeout so it checks the editor content once this update has been applied
  381. window.setTimeout(() => {
  382. const editorText = this.editor.getValue()
  383. const otText = this.shareDoc.getText()
  384. if (editorText !== otText) {
  385. this.shareDoc.emit('error', 'Text does not match in CodeMirror 6')
  386. debugConsole.error('Text does not match!')
  387. debugConsole.error('editor: ' + editorText)
  388. debugConsole.error('ot: ' + otText)
  389. }
  390. }, 0)
  391. }
  392. }
  393. export const trackChangesAnnotation = Annotation.define()
  394. const chooseOrigin = (transaction: Transaction) => {
  395. if (transaction.annotation(Transaction.remote)) {
  396. return 'remote'
  397. }
  398. if (transaction.annotation(Transaction.userEvent) === 'undo') {
  399. return 'undo'
  400. }
  401. if (transaction.annotation(trackChangesAnnotation) === 'reject') {
  402. return 'reject'
  403. }
  404. }