change-manager.ts 16 KB

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