change-manager.ts 18 KB

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