realtime.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  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(
  289. transactions: readonly Transaction[],
  290. ranges?: RangesTracker
  291. ) {
  292. for (const transaction of transactions) {
  293. if (
  294. this.maxDocLength &&
  295. transaction.changes.newLength >= this.maxDocLength
  296. ) {
  297. this.shareDoc.emit(
  298. 'error',
  299. new Error('document length is greater than maxDocLength')
  300. )
  301. return
  302. }
  303. const origin = chooseOrigin(transaction)
  304. transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
  305. this.onCodeMirrorChange(fromA, toA, fromB, toB, inserted, origin)
  306. })
  307. }
  308. }
  309. onRemoteOp(operations: EditOperation[]) {
  310. const positionMapper =
  311. this.editor.view.state.field(trackedChangesState).positionMapper
  312. const changes: ChangeSpec[] = []
  313. let trackedChangesUpdated = false
  314. for (const operation of operations) {
  315. if (operation instanceof TextOperation) {
  316. let cursor = 0
  317. for (const op of operation.ops) {
  318. if (op instanceof InsertOp) {
  319. if (op.tracking?.type !== 'delete') {
  320. changes.push({
  321. from: positionMapper.toCM6(cursor),
  322. insert: op.insertion,
  323. })
  324. }
  325. trackedChangesUpdated = true
  326. } else if (op instanceof RemoveOp) {
  327. changes.push({
  328. from: positionMapper.toCM6(cursor),
  329. to: positionMapper.toCM6(cursor + op.length),
  330. })
  331. cursor += op.length
  332. trackedChangesUpdated = true
  333. } else if (op instanceof RetainOp) {
  334. if (op.tracking != null) {
  335. if (op.tracking.type === 'delete') {
  336. changes.push({
  337. from: positionMapper.toCM6(cursor),
  338. to: positionMapper.toCM6(cursor + op.length),
  339. })
  340. }
  341. trackedChangesUpdated = true
  342. }
  343. cursor += op.length
  344. }
  345. }
  346. }
  347. const view = this.editor.view
  348. const effects: StateEffect<any>[] = []
  349. const scrollEffect = view
  350. .scrollSnapshot()
  351. .map(view.state.changes(changes))
  352. if (scrollEffect != null) {
  353. effects.push(scrollEffect)
  354. }
  355. if (trackedChangesUpdated) {
  356. const shareDoc = this.editor.view.state.field(shareDocState)
  357. if (shareDoc != null) {
  358. const trackedChanges = shareDoc.snapshot.getTrackedChanges()
  359. effects.push(updateTrackedChangesEffect.of(trackedChanges))
  360. }
  361. }
  362. view.dispatch({
  363. changes,
  364. effects,
  365. annotations: [
  366. Transaction.remote.of(true),
  367. Transaction.addToHistory.of(false),
  368. ],
  369. })
  370. }
  371. }
  372. onCodeMirrorChange(
  373. fromA: number,
  374. toA: number,
  375. fromB: number,
  376. toB: number,
  377. insertedText: Text,
  378. origin: Origin
  379. ) {
  380. const insertedLength = insertedText.length
  381. const removedLength = toA - fromA
  382. const inserted = insertedLength > 0
  383. const removed = removedLength > 0
  384. const changeDescription: ChangeDescription = {
  385. origin,
  386. inserted,
  387. removed,
  388. }
  389. this.editor.emit('change', this.editor, changeDescription)
  390. }
  391. checkContent() {
  392. // run in a timeout so it checks the editor content once this update has been applied
  393. window.setTimeout(() => {
  394. const editorText = this.editor.getValue()
  395. const otText = this.shareDoc.getText()
  396. if (editorText !== otText) {
  397. this.shareDoc.emit('error', 'Text does not match in CodeMirror 6')
  398. debugConsole.error('Text does not match!')
  399. debugConsole.error('editor: ' + editorText)
  400. debugConsole.error('ot: ' + otText)
  401. }
  402. }, 0)
  403. }
  404. }
  405. export const trackChangesAnnotation = Annotation.define()
  406. const chooseOrigin = (transaction: Transaction) => {
  407. if (transaction.annotation(Transaction.remote)) {
  408. return 'remote'
  409. }
  410. if (transaction.annotation(Transaction.userEvent) === 'undo') {
  411. return 'undo'
  412. }
  413. if (transaction.annotation(trackChangesAnnotation) === 'reject') {
  414. return 'reject'
  415. }
  416. }