change-manager.ts 18 KB

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