ranges-context.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  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. export const RangesActionsContext = createContext<RangesActions | undefined>(
  95. undefined
  96. )
  97. export const RangesProvider: FC<React.PropsWithChildren> = ({ children }) => {
  98. const view = useCodeMirrorViewContext()
  99. const { projectId } = useIdeReactContext()
  100. const { currentDocument } = useEditorOpenDocContext()
  101. const { socket } = useConnectionContext()
  102. const { sendEvent } = useEditorAnalytics()
  103. const [ranges, setRanges] = useState<Ranges | undefined>(() =>
  104. buildRanges(currentDocument)
  105. )
  106. const reviewPanelView = useReviewPanelViewContext()
  107. // rebuild the ranges when the current doc changes
  108. useEffect(() => {
  109. if (currentDocument) {
  110. if (currentDocument.isHistoryOT()) {
  111. setRanges(buildRangesFromHistoryOT(currentDocument))
  112. } else {
  113. setRanges(buildRanges(currentDocument))
  114. }
  115. }
  116. }, [currentDocument])
  117. useEffect(() => {
  118. if (currentDocument && currentDocument.isHistoryOT()) {
  119. const listener = throttle(
  120. () => {
  121. window.setTimeout(() => {
  122. setRanges(buildRangesFromHistoryOT(currentDocument))
  123. })
  124. },
  125. 500,
  126. { leading: true, trailing: true }
  127. )
  128. currentDocument.on('ranges:dirty.ot', listener)
  129. return () => {
  130. currentDocument.off('ranges:dirty.ot')
  131. }
  132. }
  133. }, [currentDocument])
  134. useEffect(() => {
  135. if (currentDocument && !currentDocument.isHistoryOT()) {
  136. const listener = throttle(
  137. () => {
  138. window.setTimeout(() => {
  139. setRanges(buildRanges(currentDocument))
  140. })
  141. },
  142. 500,
  143. { leading: true, trailing: true }
  144. )
  145. // currentDocument.on('ranges:clear.cm6', listener)
  146. currentDocument.on('ranges:redraw.cm6', listener)
  147. currentDocument.on('ranges:dirty.cm6', listener)
  148. return () => {
  149. // currentDocument.off('ranges:clear.cm6')
  150. currentDocument.off('ranges:redraw.cm6')
  151. currentDocument.off('ranges:dirty.cm6')
  152. }
  153. }
  154. }, [currentDocument])
  155. // TODO: move this into DocumentContainer?
  156. useEffect(() => {
  157. if (currentDocument) {
  158. const regenerateTrackChangesId = (doc: DocumentContainer) => {
  159. if (doc.ranges) {
  160. const inflight = doc.ranges.getIdSeed()
  161. const pending = RangesTracker.generateIdSeed()
  162. doc.ranges.setIdSeed(pending)
  163. doc.setTrackChangesIdSeeds({ pending, inflight })
  164. }
  165. }
  166. currentDocument.on('flipped_pending_to_inflight', () =>
  167. regenerateTrackChangesId(currentDocument)
  168. )
  169. regenerateTrackChangesId(currentDocument)
  170. return () => {
  171. currentDocument.off('flipped_pending_to_inflight')
  172. }
  173. }
  174. }, [currentDocument])
  175. useSocketListener(
  176. socket,
  177. 'accept-changes',
  178. useCallback(
  179. (docId: string, entryIds: string[]) => {
  180. if (currentDocument?.ranges) {
  181. if (docId === currentDocument.doc_id) {
  182. currentDocument.ranges.removeChangeIds(entryIds)
  183. setRanges(buildRanges(currentDocument))
  184. }
  185. }
  186. },
  187. [currentDocument]
  188. )
  189. )
  190. const actions = useMemo(() => {
  191. if (!currentDocument) {
  192. return
  193. }
  194. if (currentDocument.isHistoryOT()) {
  195. return {
  196. async acceptChanges(...changes) {
  197. const op = new TextOperation()
  198. let currentSnapshotPos = 0
  199. for (const change of changes) {
  200. const { start, end, length } = change.snapshotRange!
  201. if (start > currentSnapshotPos) {
  202. op.retain(start - currentSnapshotPos)
  203. }
  204. currentSnapshotPos = end
  205. if (isInsertOperation(change.op)) {
  206. // accept tracked insertion
  207. // clear tracking from snapshot
  208. op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
  209. } else {
  210. // accept tracked deletion
  211. // remove text from snapshot
  212. op.remove(length) // NOTE: tracking is removed automatically
  213. }
  214. }
  215. const shareDoc = currentDocument.historyOTShareDoc
  216. const length = shareDoc.snapshot.getStringLength()
  217. if (currentSnapshotPos < length) {
  218. op.retain(length - currentSnapshotPos)
  219. }
  220. shareDoc.submitOp([op])
  221. sendEvent('rp-changes-accepted', {
  222. count: changes.length,
  223. view: reviewPanelView,
  224. })
  225. // dispatch an effect as the editor's doc doesn't change when tracked changes are accepted
  226. view.dispatch({
  227. effects: rangesUpdatedEffect.of(null),
  228. })
  229. },
  230. async rejectChanges(...changes) {
  231. const shareDoc = currentDocument.historyOTShareDoc
  232. const originalLength = shareDoc.snapshot.getStringLength()
  233. const op = new TextOperation()
  234. let currentSnapshotPos = 0
  235. const specs: TransactionSpec[] = []
  236. for (const change of changes) {
  237. const { start, end, length } = change.snapshotRange!
  238. if (start > currentSnapshotPos) {
  239. op.retain(start - currentSnapshotPos)
  240. }
  241. currentSnapshotPos = end
  242. if (isInsertOperation(change.op)) {
  243. // reject tracked insertion
  244. // remove text from snapshot
  245. op.remove(length) // NOTE: tracking is removed automatically
  246. // remove text from editor
  247. specs.push({
  248. changes: {
  249. from: change.op.p,
  250. to: change.op.p + change.op.i.length,
  251. insert: '',
  252. },
  253. annotations: [
  254. Transaction.remote.of(true),
  255. // Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
  256. ],
  257. })
  258. } else {
  259. // reject tracked deletion
  260. // remove tracking from snapshot
  261. op.retain({ r: length }, { tracking: new ClearTrackingProps() }) // TODO: { type: 'none' })
  262. // re-add text to editor
  263. specs.push({
  264. changes: {
  265. from: change.op.p,
  266. insert: change.op.d,
  267. },
  268. selection: EditorSelection.cursor(
  269. change.op.p + change.op.d.length
  270. ), // TODO: map selection through changes?
  271. annotations: [
  272. Transaction.remote.of(true),
  273. // Transaction.addToHistory.of(false), // TODO: is this needed for the undo stack?
  274. ],
  275. })
  276. }
  277. }
  278. if (currentSnapshotPos < originalLength) {
  279. op.retain(originalLength - currentSnapshotPos)
  280. }
  281. shareDoc.submitOp([op])
  282. sendEvent('rp-changes-rejected', {
  283. count: changes.length,
  284. view: reviewPanelView,
  285. })
  286. // in case the doc didn't change
  287. view.dispatch(...specs, {
  288. effects: rangesUpdatedEffect.of(null),
  289. })
  290. },
  291. } satisfies RangesActions
  292. } else {
  293. return {
  294. async acceptChanges(...changes) {
  295. if (currentDocument.ranges) {
  296. const ids = changes.map(change => change.id)
  297. const url = `/project/${projectId}/doc/${currentDocument.doc_id}/changes/accept`
  298. await postJSON(url, { body: { change_ids: ids } })
  299. currentDocument.ranges.removeChangeIds(ids)
  300. setRanges(buildRanges(currentDocument))
  301. sendEvent('rp-changes-accepted', {
  302. count: ids.length,
  303. view: reviewPanelView,
  304. })
  305. }
  306. },
  307. async rejectChanges(...changes) {
  308. if (currentDocument.ranges) {
  309. const ids = changes.map(change => change.id)
  310. view.dispatch(
  311. rejectChanges(view.state, currentDocument.ranges, ids)
  312. )
  313. sendEvent('rp-changes-rejected', {
  314. count: ids.length,
  315. view: reviewPanelView,
  316. })
  317. }
  318. },
  319. } satisfies RangesActions
  320. }
  321. }, [currentDocument, projectId, view, sendEvent, reviewPanelView])
  322. if (!actions) {
  323. return null
  324. }
  325. return (
  326. <RangesActionsContext.Provider value={actions}>
  327. <RangesContext.Provider value={ranges}>{children}</RangesContext.Provider>
  328. </RangesActionsContext.Provider>
  329. )
  330. }
  331. export const useRangesContext = () => {
  332. return useContext(RangesContext)
  333. }
  334. export const useRangesActionsContext = () => {
  335. const context = useContext(RangesActionsContext)
  336. if (!context) {
  337. throw new Error(
  338. 'useRangesActionsContext is only available inside RangesProvider'
  339. )
  340. }
  341. return context
  342. }