change-manager.ts 19 KB

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