document-container.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
  1. /* eslint-disable camelcase */
  2. // Migrated from services/web/frontend/js/ide/editor/Document.js
  3. import RangesTracker from '@overleaf/ranges-tracker'
  4. import { OTType, ShareJsDoc } from './share-js-doc'
  5. import { debugConsole } from '@/utils/debugging'
  6. import { Socket } from '@/features/ide-react/connection/types/socket'
  7. import { IdeEventEmitter } from '@/features/ide-react/create-ide-event-emitter'
  8. import { EditorFacade } from '@/features/source-editor/extensions/realtime'
  9. import EditorWatchdogManager from '@/features/ide-react/connection/editor-watchdog-manager'
  10. import EventEmitter from '@/utils/EventEmitter'
  11. import {
  12. AnyOperation,
  13. Change,
  14. CommentOperation,
  15. EditOperation,
  16. } from '../../../../../types/change'
  17. import {
  18. isCommentOperation,
  19. isDeleteOperation,
  20. isEditOperation,
  21. isInsertOperation,
  22. } from '@/utils/operations'
  23. import { decodeUtf8 } from '@/utils/decode-utf8'
  24. import {
  25. ShareJsOperation,
  26. TrackChangesIdSeeds,
  27. } from '@/features/ide-react/editor/types/document'
  28. import { ThreadId } from '../../../../../types/review-panel/review-panel'
  29. import getMeta from '@/utils/meta'
  30. import OError from '@overleaf/o-error'
  31. const MAX_PENDING_OP_SIZE = 64
  32. type JoinCallback = (error?: Error) => void
  33. type LeaveCallback = JoinCallback
  34. type Update =
  35. | {
  36. v: number
  37. doc: string
  38. }
  39. | {
  40. v: number
  41. doc: string
  42. op: AnyOperation[]
  43. meta: {
  44. type?: string
  45. source: string
  46. user_id: string
  47. ts: number
  48. }
  49. hash?: string
  50. lastV?: number
  51. }
  52. type Message = {
  53. meta: {
  54. tc: string
  55. user_id: string
  56. }
  57. }
  58. type ErrorMetadata = Record<string, any>
  59. function getOpSize(op: AnyOperation) {
  60. if (isInsertOperation(op)) {
  61. return op.i.length
  62. }
  63. if (isDeleteOperation(op)) {
  64. return op.d.length
  65. }
  66. return 0
  67. }
  68. function getShareJsOpSize(shareJsOp: ShareJsOperation) {
  69. return shareJsOp.reduce((total, op) => total + getOpSize(op), 0)
  70. }
  71. // TODO: define these in RangesTracker
  72. type _RangesTracker = Omit<RangesTracker, 'changes' | 'comments'> & {
  73. changes: Change<EditOperation>[]
  74. comments: Change<CommentOperation>[]
  75. track_changes?: boolean
  76. }
  77. export type RangesTrackerWithResolvedThreadIds = _RangesTracker & {
  78. resolvedThreadIds: Record<ThreadId, boolean>
  79. }
  80. export class DocumentContainer extends EventEmitter {
  81. private connected: boolean
  82. private wantToBeJoined = false
  83. private chaosMonkeyTimer: number | null = null
  84. public track_changes_as: string | null = null
  85. private joinCallbacks: JoinCallback[] = []
  86. private leaveCallbacks: LeaveCallback[] = []
  87. doc?: ShareJsDoc
  88. cm6?: EditorFacade
  89. oldInflightOp?: ShareJsOperation
  90. ranges?: _RangesTracker | RangesTrackerWithResolvedThreadIds
  91. joined = false
  92. // This is set and read in useCodeMirrorScope
  93. docName = ''
  94. constructor(
  95. readonly doc_id: string,
  96. readonly socket: Socket,
  97. private readonly globalEditorWatchdogManager: EditorWatchdogManager,
  98. private readonly ideEventEmitter: IdeEventEmitter,
  99. private readonly detachDoc: (docId: string, doc: DocumentContainer) => void
  100. ) {
  101. super()
  102. this.connected = this.socket.socket.connected
  103. this.bindToEditorEvents()
  104. this.bindToSocketEvents()
  105. }
  106. attachToCM6(cm6: EditorFacade) {
  107. this.cm6 = cm6
  108. if (this.doc) {
  109. this.doc.attachToCM6(this.cm6)
  110. }
  111. this.cm6.on('change', this.checkConsistency)
  112. }
  113. detachFromCM6() {
  114. if (this.doc) {
  115. this.doc.detachFromCM6()
  116. }
  117. if (this.cm6) {
  118. this.cm6.off('change', this.checkConsistency)
  119. }
  120. delete this.cm6
  121. this.clearChaosMonkey()
  122. if (this.doc) {
  123. this.ideEventEmitter.emit('document:closed', this.doc)
  124. }
  125. }
  126. submitOp(...ops: AnyOperation[]) {
  127. this.doc?.submitOp(ops)
  128. }
  129. private checkConsistency = (editor: EditorFacade) => {
  130. // We've been seeing a lot of errors when I think there shouldn't be
  131. // any, which may be related to this check happening before the change is
  132. // applied. If we use a timeout, hopefully we can reduce this.
  133. window.setTimeout(() => {
  134. const editorValue = editor?.getValue()
  135. const sharejsValue = this.doc?.getSnapshot()
  136. if (editorValue !== sharejsValue) {
  137. return this.onError(
  138. new Error('Editor text does not match server text'),
  139. {},
  140. editorValue
  141. )
  142. }
  143. }, 0)
  144. }
  145. getSnapshot() {
  146. return this.doc?.getSnapshot()
  147. }
  148. getType() {
  149. return this.doc?.getType()
  150. }
  151. getInflightOp(): ShareJsOperation | undefined {
  152. return this.doc?.getInflightOp()
  153. }
  154. getPendingOp(): ShareJsOperation | undefined {
  155. return this.doc?.getPendingOp()
  156. }
  157. getRecentAck() {
  158. return this.doc?.getRecentAck()
  159. }
  160. getInflightOpCreatedAt() {
  161. return this.doc?.getInflightOpCreatedAt()
  162. }
  163. getPendingOpCreatedAt() {
  164. return this.doc?.getPendingOpCreatedAt()
  165. }
  166. hasBufferedOps() {
  167. return this.doc?.hasBufferedOps()
  168. }
  169. setTrackingChanges(track_changes: boolean) {
  170. if (this.doc) {
  171. this.doc.track_changes = track_changes
  172. }
  173. }
  174. getTrackingChanges() {
  175. return !!this.doc?.track_changes
  176. }
  177. setTrackChangesIdSeeds(id_seeds: TrackChangesIdSeeds) {
  178. if (this.doc) {
  179. this.doc.track_changes_id_seeds = id_seeds
  180. }
  181. }
  182. private onUpdateAppliedHandler = (update: any) => this.onUpdateApplied(update)
  183. private onErrorHandler = (error: Error, message: ErrorMetadata) => {
  184. // 'otUpdateError' are emitted per doc socket.io room, hence we can be
  185. // sure that message.doc_id exists.
  186. if (message.doc_id !== this.doc_id) {
  187. // This error is for another doc. Do not action it. We could open
  188. // a modal that has the wrong context on it.
  189. return
  190. }
  191. this.onError(error, message)
  192. }
  193. private onDisconnectHandler = () => this.onDisconnect()
  194. private bindToSocketEvents() {
  195. this.socket.on('otUpdateApplied', this.onUpdateAppliedHandler)
  196. this.socket.on('otUpdateError', this.onErrorHandler)
  197. return this.socket.on('disconnect', this.onDisconnectHandler)
  198. }
  199. private unBindFromSocketEvents() {
  200. this.socket.removeListener('otUpdateApplied', this.onUpdateAppliedHandler)
  201. this.socket.removeListener('otUpdateError', this.onErrorHandler)
  202. return this.socket.removeListener('disconnect', this.onDisconnectHandler)
  203. }
  204. private bindToEditorEvents() {
  205. this.ideEventEmitter.on('project:joined', this.onReconnect)
  206. }
  207. private unBindFromEditorEvents() {
  208. this.ideEventEmitter.off('project:joined', this.onReconnect)
  209. }
  210. leaveAndCleanUp(cb?: (error?: Error) => void) {
  211. return this.leave((error?: Error) => {
  212. this.cleanUp()
  213. if (cb) cb(error)
  214. })
  215. }
  216. leaveAndCleanUpPromise() {
  217. return new Promise<void>((resolve, reject) => {
  218. this.leaveAndCleanUp((error?: Error) => {
  219. if (error) {
  220. reject(error)
  221. } else {
  222. resolve()
  223. }
  224. })
  225. })
  226. }
  227. join(callback?: JoinCallback) {
  228. this.wantToBeJoined = true
  229. this.cancelLeave()
  230. if (this.connected) {
  231. this.joinDoc(callback)
  232. } else if (callback) {
  233. this.joinCallbacks.push(callback)
  234. }
  235. }
  236. leave(callback?: LeaveCallback) {
  237. this.flush() // force an immediate flush when leaving document
  238. this.wantToBeJoined = false
  239. this.cancelJoin()
  240. if (this.doc?.hasBufferedOps()) {
  241. debugConsole.log(
  242. '[leave] Doc has buffered ops, pushing callback for later'
  243. )
  244. if (callback) {
  245. this.leaveCallbacks.push(callback)
  246. }
  247. } else if (!this.connected) {
  248. debugConsole.log('[leave] Not connected, returning now')
  249. callback?.()
  250. } else {
  251. debugConsole.log('[leave] Leaving now')
  252. this.leaveDoc(callback)
  253. }
  254. }
  255. flush() {
  256. return this.doc?.flushPendingOps()
  257. }
  258. chaosMonkey(line = 0, char = 'a') {
  259. const orig = char
  260. let copy: string | null = null
  261. let pos = 0
  262. const timer = () => {
  263. if (copy == null || !copy.length) {
  264. copy = orig.slice() + ' ' + new Date() + '\n'
  265. line += Math.random() > 0.1 ? 1 : -2
  266. if (line < 0) {
  267. line = 0
  268. }
  269. pos = 0
  270. }
  271. char = copy[0]
  272. copy = copy.slice(1)
  273. if (this.cm6) {
  274. this.cm6.view.dispatch({
  275. changes: {
  276. from: Math.min(pos, this.cm6.view.state.doc.length),
  277. insert: char,
  278. },
  279. })
  280. }
  281. pos += 1
  282. this.chaosMonkeyTimer = window.setTimeout(
  283. timer,
  284. 100 + (Math.random() < 0.1 ? 1000 : 0)
  285. )
  286. }
  287. timer()
  288. }
  289. clearChaosMonkey() {
  290. const timer = this.chaosMonkeyTimer
  291. if (timer) {
  292. this.chaosMonkeyTimer = null
  293. window.clearTimeout(timer)
  294. }
  295. }
  296. pollSavedStatus() {
  297. // returns false if doc has ops waiting to be acknowledged or
  298. // sent that haven't changed since the last time we checked.
  299. // Otherwise returns true.
  300. let saved
  301. const inflightOp = this.getInflightOp()
  302. const pendingOp = this.getPendingOp()
  303. const recentAck = this.getRecentAck()
  304. const pendingOpSize = pendingOp ? getShareJsOpSize(pendingOp) : 0
  305. if (inflightOp == null && pendingOp == null) {
  306. // There's nothing going on, this is OK.
  307. saved = true
  308. debugConsole.log('[pollSavedStatus] no inflight or pending ops')
  309. } else if (inflightOp && inflightOp === this.oldInflightOp) {
  310. // The same inflight op has been sitting unacked since we
  311. // last checked, this is bad.
  312. saved = false
  313. debugConsole.log('[pollSavedStatus] inflight op is same as before')
  314. } else if (
  315. pendingOp != null &&
  316. recentAck &&
  317. pendingOpSize < MAX_PENDING_OP_SIZE
  318. ) {
  319. // There is an op waiting to go to server but it is small and
  320. // within the recent ack limit, this is OK for now.
  321. saved = true
  322. debugConsole.log(
  323. '[pollSavedStatus] pending op (small with recent ack) assume ok',
  324. pendingOp,
  325. pendingOpSize
  326. )
  327. } else {
  328. // In any other situation, assume the document is unsaved.
  329. saved = false
  330. debugConsole.log(
  331. `[pollSavedStatus] assuming not saved (inflightOp?: ${
  332. inflightOp != null
  333. }, pendingOp?: ${pendingOp != null})`
  334. )
  335. }
  336. this.oldInflightOp = inflightOp
  337. return saved
  338. }
  339. private cancelLeave() {
  340. this.leaveCallbacks = []
  341. }
  342. private cancelJoin() {
  343. this.joinCallbacks = []
  344. }
  345. private onUpdateApplied(update: Update) {
  346. if (update?.doc === this.doc_id && this.doc != null) {
  347. // FIXME: change this back to processUpdateFromServer when redis fixed
  348. this.doc.processUpdateFromServerInOrder(update)
  349. if (!this.wantToBeJoined) {
  350. return this.leave()
  351. }
  352. }
  353. }
  354. private onDisconnect() {
  355. debugConsole.log('[onDisconnect] disconnecting')
  356. this.connected = false
  357. this.joined = false
  358. return this.doc != null
  359. ? this.doc.updateConnectionState('disconnected')
  360. : undefined
  361. }
  362. private onReconnect = () => {
  363. debugConsole.log('[onReconnect] reconnected (joined project)')
  364. this.connected = true
  365. if (this.wantToBeJoined || this.doc?.hasBufferedOps()) {
  366. debugConsole.log(
  367. `[onReconnect] Rejoining (wantToBeJoined: ${
  368. this.wantToBeJoined
  369. } OR hasBufferedOps: ${this.doc?.hasBufferedOps()})`
  370. )
  371. this.joinDoc((error?: Error) => {
  372. if (error) {
  373. this.onError(error)
  374. return
  375. }
  376. this.doc?.updateConnectionState('ok')
  377. this.doc?.flushPendingOps()
  378. this.callJoinCallbacks()
  379. })
  380. }
  381. }
  382. private callJoinCallbacks() {
  383. for (const callback of this.joinCallbacks) {
  384. callback()
  385. }
  386. this.joinCallbacks = []
  387. }
  388. private joinDoc(callback?: JoinCallback) {
  389. if (this.doc) {
  390. return this.socket.emit(
  391. 'joinDoc',
  392. this.doc_id,
  393. this.doc.getVersion(),
  394. {
  395. encodeRanges: true,
  396. age: this.doc.getTimeSinceLastServerActivity(),
  397. supportsHistoryOT: true,
  398. },
  399. (
  400. error,
  401. docLines,
  402. version,
  403. updates,
  404. ranges,
  405. type = 'sharejs-text-ot'
  406. ) => {
  407. if (error) {
  408. callback?.(error)
  409. return
  410. }
  411. this.joined = true
  412. this.doc?.catchUp(updates)
  413. if (this.doc?.getType() !== type) {
  414. // TODO(24596): page reload after checking for pending ops?
  415. throw new OError('ot type mismatch', {
  416. got: type,
  417. want: this.doc?.getType(),
  418. })
  419. }
  420. if (type === 'sharejs-text-ot') {
  421. this.decodeRanges(ranges)
  422. this.catchUpRanges(ranges?.changes, ranges?.comments)
  423. }
  424. callback?.()
  425. }
  426. )
  427. } else {
  428. this.socket.emit(
  429. 'joinDoc',
  430. this.doc_id,
  431. {
  432. encodeRanges: true,
  433. supportsHistoryOT: true,
  434. },
  435. (
  436. error,
  437. docLines,
  438. version,
  439. updates,
  440. ranges,
  441. type: OTType = 'sharejs-text-ot'
  442. ) => {
  443. if (error) {
  444. callback?.(error)
  445. return
  446. }
  447. this.joined = true
  448. this.doc = new ShareJsDoc(
  449. this.doc_id,
  450. docLines,
  451. version,
  452. this.socket,
  453. this.globalEditorWatchdogManager,
  454. this.ideEventEmitter,
  455. type
  456. )
  457. if (type === 'sharejs-text-ot') {
  458. this.decodeRanges(ranges)
  459. }
  460. this.ranges = new RangesTracker(ranges?.changes, ranges?.comments)
  461. this.bindToShareJsDocEvents()
  462. callback?.()
  463. }
  464. )
  465. }
  466. }
  467. private decodeRanges(ranges: RangesTracker) {
  468. try {
  469. if (ranges.changes) {
  470. for (const change of ranges.changes) {
  471. if (isInsertOperation(change.op)) {
  472. change.op.i = decodeUtf8(change.op.i)
  473. }
  474. if (isDeleteOperation(change.op)) {
  475. change.op.d = decodeUtf8(change.op.d)
  476. }
  477. }
  478. }
  479. return (() => {
  480. if (!ranges.comments) {
  481. return []
  482. }
  483. return ranges.comments.map((comment: Change<CommentOperation>) =>
  484. comment.op.c != null
  485. ? (comment.op.c = decodeUtf8(comment.op.c))
  486. : undefined
  487. )
  488. })()
  489. } catch (err) {
  490. debugConsole.error(err)
  491. }
  492. }
  493. private leaveDoc(callback?: LeaveCallback) {
  494. debugConsole.log('[leaveDoc] Sending leaveDoc request')
  495. this.socket.emit('leaveDoc', this.doc_id, error => {
  496. if (error) {
  497. callback?.(error)
  498. return
  499. }
  500. this.joined = false
  501. for (const leaveCallback of this.leaveCallbacks) {
  502. debugConsole.log('[_leaveDoc] Calling buffered callback', leaveCallback)
  503. leaveCallback(error)
  504. }
  505. this.leaveCallbacks = []
  506. callback?.()
  507. })
  508. }
  509. cleanUp() {
  510. // if we arrive here from _onError the pending and inflight ops will have been cleared
  511. if (this.hasBufferedOps()) {
  512. debugConsole.log(
  513. `[cleanUp] Document (${this.doc_id}) has buffered ops, refusing to remove from openDocs`
  514. )
  515. return // return immediately, do not unbind from events
  516. }
  517. this.detachDoc(this.doc_id, this)
  518. this.unBindFromEditorEvents()
  519. this.unBindFromSocketEvents()
  520. }
  521. private bindToShareJsDocEvents() {
  522. if (!this.doc) {
  523. return
  524. }
  525. this.doc.on('error', (error: Error, meta: ErrorMetadata) =>
  526. this.onError(error, meta)
  527. )
  528. this.doc.on('externalUpdate', (update: Update) => {
  529. return this.trigger('externalUpdate', update)
  530. })
  531. this.doc.on('remoteop', (...ops: AnyOperation[]) => {
  532. return this.trigger('remoteop', ...ops)
  533. })
  534. this.doc.on('op:sent', (op: AnyOperation) => {
  535. return this.trigger('op:sent')
  536. })
  537. this.doc.on('op:acknowledged', (op: AnyOperation) => {
  538. this.ideEventEmitter.emit('ide:opAcknowledged', {
  539. doc_id: this.doc_id,
  540. op,
  541. })
  542. return this.trigger('op:acknowledged')
  543. })
  544. this.doc.on('op:timeout', (op: AnyOperation) => {
  545. this.trigger('op:timeout')
  546. return this.onError(new Error('op timed out'))
  547. })
  548. let docChangedTimeout: number | null = null
  549. this.doc.on(
  550. 'change',
  551. (ops: AnyOperation[], oldSnapshot: any, msg: Message) => {
  552. if (this.getType() === 'sharejs-text-ot') {
  553. this.applyOpsToRanges(ops, msg)
  554. }
  555. if (docChangedTimeout) {
  556. window.clearTimeout(docChangedTimeout)
  557. }
  558. docChangedTimeout = window.setTimeout(() => {
  559. if (ops.some(isEditOperation)) {
  560. window.dispatchEvent(
  561. new CustomEvent('doc:changed', { detail: { id: this.doc_id } })
  562. )
  563. this.ideEventEmitter.emit('doc:changed', {
  564. doc_id: this.doc_id,
  565. })
  566. }
  567. }, 50)
  568. }
  569. )
  570. this.doc.on('flipped_pending_to_inflight', () => {
  571. return this.trigger('flipped_pending_to_inflight')
  572. })
  573. let docSavedTimeout: number | null
  574. this.doc.on('saved', () => {
  575. if (docSavedTimeout) {
  576. window.clearTimeout(docSavedTimeout)
  577. }
  578. docSavedTimeout = window.setTimeout(() => {
  579. window.dispatchEvent(
  580. new CustomEvent('doc:saved', { detail: { id: this.doc_id } })
  581. )
  582. this.ideEventEmitter.emit('doc:saved', { doc_id: this.doc_id })
  583. }, 50)
  584. })
  585. }
  586. private onError(
  587. error: Error,
  588. meta: ErrorMetadata = {},
  589. editorContent?: string
  590. ) {
  591. meta.doc_id = this.doc_id
  592. debugConsole.log('ShareJS error', error, meta)
  593. if (error.message === 'no project_id found on client') {
  594. debugConsole.log('ignoring error, will wait to join project')
  595. return
  596. }
  597. if (this.doc) {
  598. this.doc.clearInflightAndPendingOps()
  599. }
  600. this.trigger('error', error, meta, editorContent)
  601. // The clean-up should run after the error is triggered because the error triggers a
  602. // disconnect. If we run the clean-up first, we remove our event handlers and miss
  603. // the disconnect event, which means we try to leaveDoc when the connection comes back.
  604. // This could interfere with the new connection of a new instance of this document.
  605. this.cleanUp()
  606. }
  607. private applyOpsToRanges(ops: AnyOperation[], msg?: Message) {
  608. let old_id_seed
  609. let track_changes_as = null
  610. const remote_op = msg != null
  611. if (remote_op && msg?.meta.tc) {
  612. old_id_seed = this.ranges!.getIdSeed()
  613. this.ranges!.setIdSeed(msg.meta.tc)
  614. track_changes_as = msg.meta.user_id
  615. } else if (!remote_op && this.track_changes_as != null) {
  616. track_changes_as = this.track_changes_as
  617. }
  618. this.ranges!.track_changes = track_changes_as != null
  619. for (const op of this.filterOps(ops)) {
  620. this.ranges!.applyOp(op, { user_id: track_changes_as })
  621. }
  622. if (old_id_seed != null) {
  623. this.ranges!.setIdSeed(old_id_seed)
  624. }
  625. if (remote_op) {
  626. // With remote ops, the editor hasn't been updated when we receive this
  627. // op, so defer updating track changes until it has
  628. return window.setTimeout(() => this.emit('ranges:dirty'))
  629. } else {
  630. return this.emit('ranges:dirty')
  631. }
  632. }
  633. private catchUpRanges(
  634. changes: Change<EditOperation>[],
  635. comments: Change<CommentOperation>[]
  636. ) {
  637. // We've just been given the current server's ranges, but need to apply any local ops we have.
  638. // Reset to the server state then apply our local ops again.
  639. if (changes == null) {
  640. changes = []
  641. }
  642. if (comments == null) {
  643. comments = []
  644. }
  645. this.emit('ranges:clear')
  646. this.ranges!.changes = changes
  647. this.ranges!.comments = comments
  648. this.ranges!.track_changes = this.doc?.track_changes ?? false
  649. for (const op of this.filterOps(this.doc?.getInflightOp() || [])) {
  650. this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.inflight)
  651. this.ranges!.applyOp(op, { user_id: this.track_changes_as })
  652. }
  653. for (const op of this.filterOps(this.doc?.getPendingOp() || [])) {
  654. this.ranges!.setIdSeed(this.doc?.track_changes_id_seeds?.pending)
  655. this.ranges!.applyOp(op, { user_id: this.track_changes_as })
  656. }
  657. return this.emit('ranges:redraw')
  658. }
  659. private filterOps(ops: AnyOperation[]) {
  660. // Read-only token users can't see/edit comment, so we filter out comment
  661. // ops to avoid highlighting comment ranges.
  662. if (getMeta('ol-isRestrictedTokenMember')) {
  663. return ops.filter(op => !isCommentOperation(op))
  664. } else {
  665. return ops
  666. }
  667. }
  668. }