realtime.ts 13 KB

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