ranges-context.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import {
  2. createContext,
  3. FC,
  4. useCallback,
  5. useContext,
  6. useEffect,
  7. useMemo,
  8. useState,
  9. } from 'react'
  10. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  11. import {
  12. Change,
  13. CommentOperation,
  14. EditOperation,
  15. } from '../../../../../types/change'
  16. import RangesTracker from '@overleaf/ranges-tracker'
  17. import { rejectChanges } from '@/features/source-editor/extensions/changes/reject-changes'
  18. import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
  19. import { postJSON } from '@/infrastructure/fetch-json'
  20. import { useIdeReactContext } from '@/features/ide-react/context/ide-react-context'
  21. import { useConnectionContext } from '@/features/ide-react/context/connection-context'
  22. import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
  23. import { throttle } from 'lodash'
  24. import { useEditorOpenDocContext } from '@/features/ide-react/context/editor-open-doc-context'
  25. import { TextOperation, Range } from 'overleaf-editor-core'
  26. import { rangesUpdatedEffect } from '@/features/source-editor/extensions/history-ot'
  27. import ClearTrackingProps from 'overleaf-editor-core/lib/file_data/clear_tracking_props'
  28. import { isInsertOperation } from '@/utils/operations'
  29. import {
  30. EditorSelection,
  31. Transaction,
  32. TransactionSpec,
  33. } from '@codemirror/state'
  34. import { buildRangesFromSnapshot } from '@/features/review-panel/utils/snapshot-ranges'
  35. import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
  36. import { useReviewPanelViewContext } from './review-panel-view-context'
  37. export type Ranges = {
  38. docId: string
  39. changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
  40. comments: Array<
  41. Change<CommentOperation> & { snapshotRange?: Range; resolved?: boolean }
  42. >
  43. }
  44. export const RangesContext = createContext<Ranges | undefined>(undefined)
  45. type RangesActions = {
  46. acceptChanges: (
  47. ...changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
  48. ) => Promise<void>
  49. rejectChanges: (
  50. ...changes: Array<Change<EditOperation> & { snapshotRange?: Range }>
  51. ) => Promise<void>
  52. }
  53. const buildRanges = (currentDocument: DocumentContainer | null) => {
  54. const ranges = currentDocument?.ranges
  55. if (!ranges) {
  56. return undefined
  57. }
  58. const dirtyState = ranges.getDirtyState()
  59. ranges.resetDirtyState()
  60. const changed = {
  61. changes: new Set([
  62. ...Object.keys(dirtyState.change.added),
  63. ...Object.keys(dirtyState.change.moved),
  64. ...Object.keys(dirtyState.change.removed),
  65. ]),
  66. comments: new Set([
  67. ...Object.keys(dirtyState.comment.added),
  68. ...Object.keys(dirtyState.comment.moved),
  69. ...Object.keys(dirtyState.comment.removed),
  70. ]),
  71. }
  72. return {
  73. changes:
  74. changed.changes.size > 0
  75. ? ranges.changes.map(change =>
  76. changed.changes.has(change.id) ? { ...change } : change
  77. )
  78. : ranges.changes,
  79. comments:
  80. changed.comments.size > 0
  81. ? ranges.comments.map(comment =>
  82. changed.comments.has(comment.id) ? { ...comment } : comment
  83. )
  84. : ranges.comments,
  85. docId: currentDocument.doc_id,
  86. }
  87. }
  88. const buildRangesFromHistoryOT = (currentDocument: DocumentContainer) => {
  89. return buildRangesFromSnapshot(
  90. currentDocument.historyOTShareDoc.snapshot,
  91. currentDocument.doc_id
  92. )
  93. }
  94. const RangesActionsContext = createContext<RangesActions | undefined>(undefined)
  95. export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
  96. const view = useCodeMirrorViewContext()
  97. const { projectId } = useIdeReactContext()
  98. const { currentDocument } = useEditorOpenDocContext()
  99. const { socket } = useConnectionContext()
  100. const { sendEvent } = useEditorAnalytics()
  101. const [ranges, setRanges] = useState<Ranges | undefined>(() =>
  102. buildRanges(currentDocument)
  103. )
  104. const reviewPanelView = useReviewPanelViewContext()
  105. // rebuild the ranges when the current doc changes
  106. useEffect(() => {
  107. if (currentDocument) {
  108. if (currentDocument.isHistoryOT()) {
  109. setRanges(buildRangesFromHistoryOT(currentDocument))
  110. } else {
  111. setRanges(buildRanges(currentDocument))
  112. }
  113. }
  114. }, [currentDocument])
  115. useEffect(() => {
  116. if (currentDocument && currentDocument.isHistoryOT()) {
  117. const listener = throttle(
  118. () => {
  119. window.setTimeout(() => {
  120. setRanges(buildRangesFromHistoryOT(currentDocument))
  121. })
  122. },
  123. 500,
  124. { leading: true, trailing: true }
  125. )
  126. currentDocument.on('ranges:dirty.ot', listener)
  127. return () => {
  128. currentDocument.off('ranges:dirty.ot')
  129. }
  130. }
  131. }, [currentDocument])
  132. useEffect(() => {
  133. if (currentDocument && !currentDocument.isHistoryOT()) {
  134. const listener = throttle(
  135. () => {
  136. window.setTimeout(() => {
  137. setRanges(buildRanges(currentDocument))
  138. })
  139. },
  140. 500,
  141. { leading: true, trailing: true }
  142. )
  143. // currentDocument.on('ranges:clear.cm6', listener)
  144. currentDocument.on('ranges:redraw.cm6', listener)
  145. currentDocument.on('ranges:dirty.cm6', listener)
  146. return () => {
  147. // currentDocument.off('ranges:clear.cm6')
  148. currentDocument.off('ranges:redraw.cm6')
  149. currentDocument.off('ranges:dirty.cm6')
  150. }
  151. }
  152. }, [currentDocument])
  153. // TODO: move this into DocumentContainer?
  154. useEffect(() => {
  155. if (currentDocument) {
  156. const regenerateTrackChangesId = (doc: DocumentContainer) => {
  157. if (doc.ranges) {
  158. const inflight = doc.ranges.getIdSeed()
  159. const pending = RangesTracker.generateIdSeed()
  160. doc.ranges.setIdSeed(pending)
  161. doc.setTrackChangesIdSeeds({ pending, inflight })
  162. }
  163. }
  164. currentDocument.on('flipped_pending_to_inflight', () =>
  165. regenerateTrackChangesId(currentDocument)
  166. )
  167. regenerateTrackChangesId(currentDocument)
  168. return () => {
  169. currentDocument.off('flipped_pending_to_inflight')
  170. }
  171. }
  172. }, [currentDocument])
  173. useSocketListener(
  174. socket,
  175. 'accept-changes',
  176. useCallback(
  177. (docId: string, entryIds: string[]) => {
  178. if (currentDocument?.ranges) {
  179. if (docId === currentDocument.doc_id) {
  180. currentDocument.ranges.removeChangeIds(entryIds)
  181. setRanges(buildRanges(currentDocument))
  182. }
  183. }
  184. },
  185. [currentDocument]
  186. )
  187. )
  188. const actions = useMemo(() => {
  189. if (!currentDocument) {
  190. return
  191. }
  192. if (currentDocument.isHistoryOT()) {
  193. return {
  194. async acceptChanges(...changes) {
  195. const op = new TextOperation()
  196. let currentSnapshotPos = 0
  197. for (const change of changes) {
  198. const { start, end, length } = change.snapshotRange!
  199. if (start > currentSnapshotPos) {
  200. op.retain(start - currentSnapshotPos)
  201. }
  202. currentSnapshotPos = end
  203. if (isInsertOperation(change.op)) {
  204. // accept tracked insertion
  205. // clear tracking from snapshot
  206. op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
  207. } else {
  208. // accept tracked deletion
  209. // remove text from snapshot
  210. op.remove(length) // NOTE: tracking is removed automatically
  211. }
  212. }
  213. const shareDoc = currentDocument.historyOTShareDoc
  214. const length = shareDoc.snapshot.getStringLength()
  215. if (currentSnapshotPos < length) {
  216. op.retain(length - currentSnapshotPos)
  217. }
  218. shareDoc.submitOp([op])
  219. sendEvent('rp-changes-accepted', {
  220. count: changes.length,
  221. view: reviewPanelView,
  222. })
  223. // dispatch an effect as the editor's doc doesn't change when tracked changes are accepted
  224. view.dispatch({
  225. effects: rangesUpdatedEffect.of(null),
  226. })
  227. },
  228. async rejectChanges(...changes) {
  229. const shareDoc = currentDocument.historyOTShareDoc
  230. const originalLength = shareDoc.snapshot.getStringLength()
  231. const op = new TextOperation()
  232. let currentSnapshotPos = 0
  233. const specs: TransactionSpec[] = []
  234. for (const change of changes) {
  235. const { start, end, length } = change.snapshotRange!
  236. if (start > currentSnapshotPos) {
  237. op.retain(start - currentSnapshotPos)
  238. }
  239. currentSnapshotPos = end
  240. if (isInsertOperation(change.op)) {
  241. // reject tracked insertion
  242. // remove text from snapshot
  243. op.remove(length) // NOTE: tracking is removed automatically
  244. // remove text from editor
  245. specs.push({
  246. changes: {
  247. from: change.op.p,
  248. to: change.op.p + change.op.i.length,
  249. insert: '',
  250. },
  251. annotations: [
  252. Transaction.remote.of(true),
  253. // Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
  254. ],
  255. })
  256. } else {
  257. // reject tracked deletion
  258. // remove tracking from snapshot
  259. op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
  260. // re-add text to editor
  261. specs.push({
  262. changes: {
  263. from: change.op.p,
  264. insert: change.op.d,
  265. },
  266. selection: EditorSelection.cursor(
  267. change.op.p + change.op.d.length
  268. ), // TODO: map selection through changes?
  269. annotations: [
  270. Transaction.remote.of(true),
  271. // Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
  272. ],
  273. })
  274. }
  275. }
  276. if (currentSnapshotPos < originalLength) {
  277. op.retain(originalLength - currentSnapshotPos)
  278. }
  279. shareDoc.submitOp([op])
  280. sendEvent('rp-changes-rejected', {
  281. count: changes.length,
  282. view: reviewPanelView,
  283. })
  284. // in case the doc didn't change
  285. view.dispatch(...specs, {
  286. effects: rangesUpdatedEffect.of(null),
  287. })
  288. },
  289. } satisfies RangesActions
  290. } else {
  291. return {
  292. async acceptChanges(...changes) {
  293. if (currentDocument.ranges) {
  294. const ids = changes.map(change => change.id)
  295. const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
  296. await postJSON(url, { body: { change_ids: ids } })
  297. currentDocument.ranges.removeChangeIds(ids)
  298. setRanges(buildRanges(currentDocument))
  299. sendEvent('rp-changes-accepted', {
  300. count: ids.length,
  301. view: reviewPanelView,
  302. })
  303. }
  304. },
  305. async rejectChanges(...changes) {
  306. if (currentDocument.ranges) {
  307. const ids = changes.map(change => change.id)
  308. view.dispatch(
  309. rejectChanges(view.state, currentDocument.ranges, ids)
  310. )
  311. sendEvent('rp-changes-rejected', {
  312. count: ids.length,
  313. view: reviewPanelView,
  314. })
  315. }
  316. },
  317. } satisfies RangesActions
  318. }
  319. }, [currentDocument, projectId, view, sendEvent, reviewPanelView])
  320. if (!actions) {
  321. return null
  322. }
  323. return (
  324. <RangesActionsContext.Provider value={actions}>
  325. <RangesContext.Provider value={ranges}>{children}</RangesContext.Provider>
  326. </RangesActionsContext.Provider>
  327. )
  328. }
  329. export const useRangesContext = () => {
  330. return useContext(RangesContext)
  331. }
  332. export const useRangesActionsContext = () => {
  333. const context = useContext(RangesActionsContext)
  334. if (!context) {
  335. throw new Error(
  336. 'useRangesActionsContext is only available inside RangesProvider'
  337. )
  338. }
  339. return context
  340. }