change-manager.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import { trackChangesAnnotation } from '../realtime'
  2. import { clearChangeMarkers, buildChangeMarkers } from '../track-changes'
  3. import {
  4. setVerticalOverflow,
  5. updateSetsVerticalOverflow,
  6. editorVerticalTopPadding,
  7. } from '../vertical-overflow'
  8. import { EditorSelection, EditorState } from '@codemirror/state'
  9. import { EditorView, ViewUpdate } from '@codemirror/view'
  10. import { CurrentDoc } from '../../../../../../types/current-doc'
  11. import { fullHeightCoordsAtPos } from '../../utils/layer'
  12. import { debounce } from 'lodash'
  13. // With less than this number of entries, don't bother culling to avoid
  14. // little UI jumps when scrolling.
  15. const CULL_AFTER = Infinity // Note: was 100 but couldn't scroll to see items outside the viewport
  16. export const dispatchEditorEvent = (type: string, payload?: unknown) => {
  17. window.setTimeout(() => {
  18. window.dispatchEvent(
  19. new CustomEvent('editor:event', {
  20. detail: { type, payload },
  21. })
  22. )
  23. }, 0)
  24. }
  25. export const dispatchReviewPanelLayout = () => {
  26. window.dispatchEvent(new CustomEvent('review-panel:layout'))
  27. }
  28. const scheduleDispatchReviewPanelLayout = debounce(
  29. dispatchReviewPanelLayout,
  30. 50
  31. )
  32. export type ChangeManager = {
  33. initialize: () => void
  34. handleUpdate: (update: ViewUpdate) => void
  35. destroy: () => void
  36. }
  37. export const createChangeManager = (
  38. view: EditorView,
  39. currentDoc: CurrentDoc
  40. ): ChangeManager => {
  41. /**
  42. * Calculate the screen coordinates of each entry (change or comment),
  43. * for use in the review panel.
  44. *
  45. * Returns a boolean indicating whether the visibility of any entry has changed
  46. */
  47. const recalculateScreenPositions = (entries?: Record<string, any>) => {
  48. const contentRect = view.contentDOM.getBoundingClientRect()
  49. const { doc } = view.state
  50. const items = Object.values(entries || {})
  51. const allVisible = items.length <= CULL_AFTER
  52. let visibilityChanged = false
  53. const docLength = doc.length
  54. const editorPaddingTop = editorVerticalTopPadding(view)
  55. for (const entry of items) {
  56. // TODO: clamp to max row and column, account for folding?
  57. const coords = fullHeightCoordsAtPos(
  58. view,
  59. Math.min(entry.offset, docLength) // avoid exception for comments at end of document when deleting text
  60. )
  61. if (coords) {
  62. const y = Math.round(coords.top - contentRect.top - editorPaddingTop)
  63. const height = Math.round(coords.bottom - coords.top)
  64. if (!entry.screenPos) {
  65. visibilityChanged = true
  66. }
  67. entry.screenPos = { y, height, editorPaddingTop }
  68. }
  69. if (allVisible) {
  70. if (!entry.visible) {
  71. visibilityChanged = true
  72. }
  73. entry.visible = true
  74. }
  75. }
  76. if (!allVisible) {
  77. const { from, to } = view.viewport
  78. for (const entry of items) {
  79. const previouslyVisible = entry.visible
  80. entry.visible = entry.offset >= from && entry.offset <= to
  81. if (previouslyVisible !== entry.visible) {
  82. visibilityChanged = true
  83. }
  84. }
  85. }
  86. return visibilityChanged
  87. }
  88. /**
  89. * Add a comment (thread) to the ShareJS doc when it's created
  90. */
  91. const addComment = (offset: number, length: number, threadId: string) => {
  92. currentDoc.submitOp({
  93. c: view.state.doc.sliceString(offset, offset + length),
  94. p: offset,
  95. t: threadId,
  96. })
  97. }
  98. /**
  99. * Remove a comment (thread) from the range tracker when it's deleted
  100. */
  101. const removeComment = (commentId: string) => {
  102. currentDoc.ranges.removeCommentId(commentId)
  103. }
  104. /**
  105. * Remove tracked changes from the range tracker when they're accepted
  106. */
  107. const acceptChanges = (changeIds: string[]) => {
  108. currentDoc.ranges.removeChangeIds(changeIds)
  109. }
  110. /**
  111. * Remove tracked changes from the range tracker when they're rejected,
  112. * and restore the original content
  113. */
  114. const rejectChanges = (changeIds: string[]) => {
  115. const changes: any[] = currentDoc.ranges.getChanges(changeIds)
  116. if (changes.length === 0) {
  117. return {}
  118. }
  119. // When doing bulk rejections, adjacent changes might interact with each other.
  120. // Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
  121. //
  122. // "foo bar baz" -> "foo quux baz"
  123. //
  124. // The change above will be modeled with two ops, with the insertion going first:
  125. //
  126. // foo quux baz
  127. // |--| -> insertion of "quux", op 1, at position 4
  128. // | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
  129. //
  130. // When rejecting these changes at once, if the insertion is rejected first, we get unexpected
  131. // results. What happens is:
  132. //
  133. // 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
  134. // starting from position 4;
  135. //
  136. // "foo quux baz" -> "foo baz"
  137. // |--| -> 4 characters to be removed
  138. //
  139. // 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
  140. // the word "quuux" was still present).
  141. //
  142. // "foo baz" -> "foo bazbar"
  143. // | -> deletion of "bar" is reverted by reinserting "bar" at position 8
  144. //
  145. // While the intended result would be "foo bar baz", what we get is:
  146. //
  147. // "foo bazbar" (note "bar" readded at position 8)
  148. //
  149. // The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
  150. // from position 4. This includes the position where the deletion exists; when that position is
  151. // cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
  152. // As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
  153. // does so at the outdated position (position 8, which was valid when "quux" was present).
  154. //
  155. // To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
  156. // subsequent operations that come after. Reverse sorting the operations based on position will
  157. // achieve it; in the case above, it makes sure that the the deletion is reverted first:
  158. //
  159. // 1) Rejecting the deletion adds the deleted word "bar" at position 8
  160. //
  161. // "foo quux baz" -> "foo quuxbar baz"
  162. // | -> deletion of "bar" is reverted by
  163. // reinserting "bar" at position 8
  164. //
  165. // 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
  166. // starting from position 4 and achieves the expected result:
  167. //
  168. // "foo quuxbar baz" -> "foo bar baz"
  169. // |--| -> 4 characters to be removed
  170. changes.sort((a, b) => b.op.p - a.op.p)
  171. const changesToDispatch = changes.map(change => {
  172. const { op } = change
  173. const opType = 'i' in op ? 'i' : 'c' in op ? 'c' : 'd'
  174. switch (opType) {
  175. case 'd': {
  176. return {
  177. from: op.p,
  178. to: op.p,
  179. insert: op.d,
  180. }
  181. }
  182. case 'i': {
  183. const from = op.p
  184. const content = op.i
  185. const to = from + content.length
  186. const text = view.state.doc.sliceString(from, to)
  187. if (text !== content) {
  188. throw new Error(
  189. `Op to be removed (${JSON.stringify(
  190. change.op
  191. )}) does not match editor text '${text}'`
  192. )
  193. }
  194. return { from, to, insert: '' }
  195. }
  196. default: {
  197. throw new Error(`unknown change: ${JSON.stringify(change)}`)
  198. }
  199. }
  200. })
  201. return {
  202. changes: changesToDispatch,
  203. annotations: [trackChangesAnnotation.of('reject')],
  204. }
  205. }
  206. /**
  207. * If the current selection is empty, select the whole line.
  208. *
  209. * Used when adding a comment with no selected range, e.g. with a keyboard shortcut.
  210. */
  211. const selectCurrentLine = () => {
  212. if (view.state.selection.main.empty) {
  213. const line = view.state.doc.lineAt(view.state.selection.main.from)
  214. view.dispatch({
  215. selection: {
  216. anchor: line.from,
  217. head: line.to === view.state.doc.length ? line.to : line.to + 1,
  218. },
  219. })
  220. }
  221. }
  222. /**
  223. * Collapse the current selection to a single point (after inserting a comment)
  224. */
  225. const collapseSelection = () => {
  226. view.dispatch({
  227. selection: EditorSelection.cursor(view.state.selection.main.head),
  228. })
  229. }
  230. /**
  231. * Listen for events dispatched from the (Angular) review panel.
  232. *
  233. * These are combined into a single listener, avoiding the need to add and remove event listeners individually.
  234. */
  235. const reviewPanelEventListener = (event: Event) => {
  236. const { type, payload } = (
  237. event as CustomEvent<{ type: string; payload: any }>
  238. ).detail
  239. switch (type) {
  240. // receive review panel scroll events
  241. case 'scroll': {
  242. view.scrollDOM.scrollBy(0, payload)
  243. break
  244. }
  245. case 'overview-closed': {
  246. window.setTimeout(() => {
  247. dispatchScrollEvent()
  248. }, 0)
  249. break
  250. }
  251. case 'recalculate-screen-positions': {
  252. const changed = recalculateScreenPositions(payload)
  253. if (changed) {
  254. dispatchEditorEvent('track-changes:visibility_changed')
  255. }
  256. dispatchReviewPanelLayout()
  257. // Ensure the layout is updated again once the review panel entries
  258. // have updated in the React review panel. The use of a timeout is bad
  259. // but the timings are a bit of a mess and will be improved when the
  260. // review panel state is migrated away from Angular
  261. scheduleDispatchReviewPanelLayout()
  262. break
  263. }
  264. case 'changes:accept': {
  265. acceptChanges(payload)
  266. view.dispatch(buildChangeMarkers())
  267. broadcastChange()
  268. break
  269. }
  270. case 'changes:reject': {
  271. view.dispatch(rejectChanges(payload))
  272. broadcastChange()
  273. break
  274. }
  275. case 'comment:select_line': {
  276. selectCurrentLine()
  277. broadcastChange()
  278. break
  279. }
  280. case 'comment:add': {
  281. addComment(payload.offset, payload.length, payload.threadId)
  282. collapseSelection()
  283. broadcastChange()
  284. break
  285. }
  286. case 'comment:remove': {
  287. removeComment(payload)
  288. view.dispatch(buildChangeMarkers())
  289. broadcastChange()
  290. break
  291. }
  292. case 'comment:resolve_threads':
  293. case 'comment:unresolve_thread': {
  294. view.dispatch(buildChangeMarkers())
  295. broadcastChange()
  296. break
  297. }
  298. case 'loaded_threads': {
  299. view.dispatch(buildChangeMarkers())
  300. broadcastChange()
  301. break
  302. }
  303. case 'sizes': {
  304. // the content height and top overflow of the review panel
  305. const { height, overflowTop } = payload
  306. // the difference between the review panel height and the editor content height
  307. const heightDiff = height + overflowTop - view.contentDOM.clientHeight
  308. // the height of the block added at the top of the editor to match the review panel
  309. const topPadding = editorVerticalTopPadding(view)
  310. if (overflowTop !== topPadding || heightDiff !== 0) {
  311. view.dispatch(
  312. setVerticalOverflow({
  313. top: overflowTop,
  314. bottom: heightDiff + view.documentPadding.bottom,
  315. })
  316. )
  317. }
  318. break
  319. }
  320. }
  321. }
  322. const broadcastChange = debounce(() => {
  323. dispatchEditorEvent('track-changes:changed')
  324. }, 50)
  325. /**
  326. * When the editor content, focus, size, viewport or selection changes,
  327. * tell the review panel to update.
  328. *
  329. * @param state object
  330. */
  331. const dispatchFocusChangedEvent = debounce((state: EditorState) => {
  332. // TODO: multiple selections?
  333. const { from, to, empty } = state.selection.main
  334. dispatchEditorEvent('focus:changed', { from, to, empty })
  335. }, 50)
  336. /**
  337. * When the editor is scrolled, tell the review panel so it can scroll in sync.
  338. */
  339. const dispatchScrollEvent = () => {
  340. window.dispatchEvent(
  341. new CustomEvent('editor:scroll', {
  342. detail: {
  343. height: view.scrollDOM.scrollHeight,
  344. scrollTop: view.scrollDOM.scrollTop,
  345. paddingTop: editorVerticalTopPadding(view),
  346. },
  347. })
  348. )
  349. }
  350. /**
  351. * Add event listeners to the ShareJS doc so that change markers are rebuilt when the tracked changes are updated.
  352. *
  353. * Also add event listeners to the editor scroll DOM and window.
  354. */
  355. const addListeners = () => {
  356. // NOTE: the namespace "cm6" is needed so the listeners can be removed individually
  357. currentDoc.on('ranges:dirty.cm6', () => {
  358. // TODO: use currentDoc.ranges.getDirtyState and only update those which have changed?
  359. window.setTimeout(() => {
  360. view.dispatch(buildChangeMarkers())
  361. broadcastChange()
  362. }, 0)
  363. })
  364. // called on joinDoc
  365. currentDoc.on('ranges:clear.cm6', () => {
  366. window.setTimeout(() => {
  367. view.dispatch(clearChangeMarkers())
  368. broadcastChange()
  369. }, 0)
  370. })
  371. // called on joinDoc
  372. currentDoc.on('ranges:redraw.cm6', () => {
  373. window.setTimeout(() => {
  374. view.dispatch(buildChangeMarkers())
  375. broadcastChange()
  376. }, 0)
  377. })
  378. // sync review panel scroll with editor scroll
  379. view.scrollDOM.addEventListener('scroll', dispatchScrollEvent)
  380. // listen for events from the review panel controller
  381. window.addEventListener('review-panel:event', reviewPanelEventListener)
  382. }
  383. /**
  384. * Remove event listeners
  385. */
  386. const removeListeners = () => {
  387. currentDoc.off('ranges:clear.cm6')
  388. currentDoc.off('ranges:dirty.cm6')
  389. currentDoc.off('ranges:redraw.cm6')
  390. view.scrollDOM.removeEventListener('scroll', dispatchScrollEvent)
  391. window.removeEventListener('review-panel:event', reviewPanelEventListener)
  392. }
  393. let ignoreGeometryChangesUntil = 0
  394. return {
  395. initialize() {
  396. addListeners()
  397. broadcastChange()
  398. },
  399. handleUpdate(update: ViewUpdate) {
  400. if (update.geometryChanged && !update.docChanged) {
  401. broadcastChange()
  402. }
  403. if (updateSetsVerticalOverflow(update)) {
  404. ignoreGeometryChangesUntil = Date.now() + 50 // ignore changes for 50ms
  405. } else if (
  406. (update.geometryChanged || update.viewportChanged) &&
  407. Date.now() < ignoreGeometryChangesUntil
  408. ) {
  409. // Ignore a change to the editor geometry or viewport that occurs immediately after
  410. // an update to the vertical padding because otherwise it triggers
  411. // another update to the padding and so on ad infinitum. This is not an
  412. // ideal way to handle this but I couldn't see another way.
  413. return
  414. }
  415. if (
  416. update.docChanged ||
  417. update.focusChanged ||
  418. update.viewportChanged ||
  419. update.selectionSet ||
  420. update.geometryChanged
  421. ) {
  422. dispatchFocusChangedEvent(update.state)
  423. }
  424. },
  425. destroy() {
  426. removeListeners()
  427. },
  428. }
  429. }