track-changes.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import {
  2. EditorState,
  3. RangeSet,
  4. StateEffect,
  5. StateField,
  6. Transaction,
  7. } from '@codemirror/state'
  8. import {
  9. Decoration,
  10. type DecorationSet,
  11. EditorView,
  12. type PluginValue,
  13. ViewPlugin,
  14. WidgetType,
  15. } from '@codemirror/view'
  16. import {
  17. findCommentsInCut,
  18. findDetachedCommentsInChanges,
  19. restoreCommentsOnPaste,
  20. restoreDetachedComments,
  21. StoredComment,
  22. } from './changes/comments'
  23. import { invertedEffects } from '@codemirror/commands'
  24. import { Change, DeleteOperation } from '../../../../../types/change'
  25. import { ChangeManager } from './changes/change-manager'
  26. import { debugConsole } from '@/utils/debugging'
  27. import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
  28. import {
  29. DocumentContainer,
  30. RangesTrackerWithResolvedThreadIds,
  31. } from '@/features/ide-react/editor/document-container'
  32. import { Ranges } from '@/features/review-panel-new/context/ranges-context'
  33. import { Threads } from '@/features/review-panel-new/context/threads-context'
  34. import { isSplitTestEnabled } from '@/utils/splitTestUtils'
  35. type RangesData = {
  36. ranges: Ranges
  37. threads: Threads
  38. }
  39. const clearChangesEffect = StateEffect.define()
  40. const buildChangesEffect = StateEffect.define()
  41. const restoreDetachedCommentsEffect = StateEffect.define<RangeSet<any>>({
  42. map: (value, mapping) => {
  43. return value
  44. .update({
  45. filter: (from, to) => {
  46. return from <= mapping.length && to <= mapping.length
  47. },
  48. })
  49. .map(mapping)
  50. },
  51. })
  52. type Options = {
  53. currentDoc: DocumentContainer
  54. loadingThreads?: boolean
  55. ranges?: Ranges
  56. threads?: Threads
  57. }
  58. /**
  59. * A custom extension that initialises the change manager, passes any updates to it,
  60. * and produces decorations for tracked changes and comments.
  61. */
  62. export const trackChanges = (
  63. { currentDoc, loadingThreads, ranges, threads }: Options,
  64. changeManager?: ChangeManager
  65. ) => {
  66. // A state field that stored any comments found within the ranges of a "cut" transaction,
  67. // to be restored when pasting matching text.
  68. const cutCommentsState = StateField.define<StoredComment[]>({
  69. create: () => {
  70. return []
  71. },
  72. update: (value, transaction) => {
  73. if (transaction.annotation(Transaction.remote)) {
  74. return value
  75. }
  76. if (!transaction.docChanged) {
  77. return value
  78. }
  79. if (transaction.isUserEvent('delete.cut')) {
  80. return findCommentsInCut(currentDoc, transaction)
  81. }
  82. if (transaction.isUserEvent('input.paste')) {
  83. restoreCommentsOnPaste(currentDoc, transaction, value)
  84. return []
  85. }
  86. return value
  87. },
  88. })
  89. return [
  90. // attach any comments detached by the transaction as an inverted effect, to be applied on undo
  91. invertedEffects.of(transaction => {
  92. if (
  93. transaction.docChanged &&
  94. !transaction.annotation(Transaction.remote)
  95. ) {
  96. const detachedComments = findDetachedCommentsInChanges(
  97. currentDoc,
  98. transaction
  99. )
  100. if (detachedComments.size) {
  101. return [restoreDetachedCommentsEffect.of(detachedComments)]
  102. }
  103. }
  104. return []
  105. }),
  106. // restore any detached comments on undo
  107. EditorState.transactionExtender.of(transaction => {
  108. for (const effect of transaction.effects) {
  109. if (effect.is(restoreDetachedCommentsEffect)) {
  110. // send the comments to the ShareJS doc
  111. restoreDetachedComments(currentDoc, transaction, effect.value)
  112. // return a transaction spec to rebuild the change markers
  113. return buildChangeMarkers()
  114. }
  115. }
  116. return null
  117. }),
  118. cutCommentsState,
  119. // initialize/destroy the change manager, and handle any updates
  120. changeManager
  121. ? ViewPlugin.define(() => {
  122. changeManager.initialize()
  123. return {
  124. update: update => {
  125. changeManager.handleUpdate(update)
  126. },
  127. destroy: () => {
  128. changeManager.destroy()
  129. },
  130. }
  131. })
  132. : ViewPlugin.define(view => {
  133. let timer: number
  134. return {
  135. update(update) {
  136. if (update.viewportChanged) {
  137. if (timer) {
  138. window.clearTimeout(timer)
  139. }
  140. timer = window.setTimeout(() => {
  141. dispatchEvent(new Event('editor:viewport-changed'))
  142. }, 25)
  143. }
  144. },
  145. }
  146. }),
  147. // draw change decorations
  148. ViewPlugin.define<
  149. PluginValue & {
  150. decorations: DecorationSet
  151. }
  152. >(
  153. () => {
  154. let decorations = Decoration.none
  155. if (isSplitTestEnabled('review-panel-redesign')) {
  156. if (ranges && threads) {
  157. decorations = buildChangeDecorations(currentDoc, {
  158. ranges,
  159. threads,
  160. })
  161. }
  162. } else if (!loadingThreads) {
  163. decorations = buildChangeDecorations(currentDoc)
  164. }
  165. return {
  166. decorations,
  167. update(update) {
  168. for (const transaction of update.transactions) {
  169. this.decorations = this.decorations.map(transaction.changes)
  170. for (const effect of transaction.effects) {
  171. if (effect.is(clearChangesEffect)) {
  172. this.decorations = Decoration.none
  173. } else if (effect.is(buildChangesEffect)) {
  174. this.decorations = buildChangeDecorations(currentDoc)
  175. }
  176. }
  177. }
  178. },
  179. }
  180. },
  181. {
  182. decorations: value => value.decorations,
  183. }
  184. ),
  185. // styles for change decorations
  186. trackChangesTheme,
  187. ]
  188. }
  189. export const clearChangeMarkers = () => {
  190. return {
  191. effects: clearChangesEffect.of(null),
  192. }
  193. }
  194. export const buildChangeMarkers = () => {
  195. return {
  196. effects: buildChangesEffect.of(null),
  197. }
  198. }
  199. const buildChangeDecorations = (
  200. currentDoc: DocumentContainer,
  201. data?: RangesData
  202. ) => {
  203. const ranges = data ? data.ranges : currentDoc.ranges
  204. if (!ranges) {
  205. return Decoration.none
  206. }
  207. const changes = [...ranges.changes, ...ranges.comments]
  208. const decorations = []
  209. for (const change of changes) {
  210. try {
  211. decorations.push(...createChangeRange(change, currentDoc, data))
  212. } catch (error) {
  213. // ignore invalid changes
  214. debugConsole.debug('invalid change position', error)
  215. }
  216. }
  217. return Decoration.set(decorations, true)
  218. }
  219. class ChangeDeletedWidget extends WidgetType {
  220. constructor(public change: Change<DeleteOperation>) {
  221. super()
  222. }
  223. toDOM() {
  224. const widget = document.createElement('span')
  225. widget.classList.add('ol-cm-change')
  226. widget.classList.add('ol-cm-change-d')
  227. return widget
  228. }
  229. eq() {
  230. return true
  231. }
  232. }
  233. class ChangeCalloutWidget extends WidgetType {
  234. constructor(
  235. public change: Change,
  236. public opType: string
  237. ) {
  238. super()
  239. }
  240. toDOM() {
  241. const widget = document.createElement('span')
  242. widget.className = 'ol-cm-change-callout'
  243. widget.classList.add(`ol-cm-change-callout-${this.opType}`)
  244. const inner = document.createElement('span')
  245. inner.classList.add('ol-cm-change-callout-inner')
  246. widget.appendChild(inner)
  247. return widget
  248. }
  249. eq(widget: ChangeCalloutWidget) {
  250. return widget.opType === this.opType
  251. }
  252. updateDOM(element: HTMLElement) {
  253. element.className = 'ol-cm-change-callout'
  254. element.classList.add(`ol-cm-change-callout-${this.opType}`)
  255. return true
  256. }
  257. }
  258. const createChangeRange = (
  259. change: Change,
  260. currentDoc: DocumentContainer,
  261. data?: RangesData
  262. ) => {
  263. const { id, metadata, op } = change
  264. const from = op.p
  265. // TODO: find valid positions?
  266. if (isDeleteOperation(op)) {
  267. const opType = 'd'
  268. const changeWidget = Decoration.widget({
  269. widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
  270. side: 1,
  271. opType,
  272. id,
  273. metadata,
  274. })
  275. const calloutWidget = Decoration.widget({
  276. widget: new ChangeCalloutWidget(change, opType),
  277. side: 1,
  278. opType,
  279. id,
  280. metadata,
  281. })
  282. return [calloutWidget.range(from, from), changeWidget.range(from, from)]
  283. }
  284. const _isCommentOperation = isCommentOperation(op)
  285. if (
  286. _isCommentOperation &&
  287. (currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
  288. .resolvedThreadIds![op.t]
  289. ) {
  290. return []
  291. }
  292. if (_isCommentOperation) {
  293. if (data) {
  294. const thread = data.threads[op.t]
  295. if (!thread || thread.resolved) {
  296. return []
  297. }
  298. } else if (
  299. (currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
  300. .resolvedThreadIds![op.t]
  301. ) {
  302. return []
  303. }
  304. }
  305. const opType = _isCommentOperation ? 'c' : 'i'
  306. const changedText = _isCommentOperation ? op.c : op.i
  307. const to = from + changedText.length
  308. // Mark decorations must not be empty
  309. if (from === to) {
  310. return []
  311. }
  312. const changeMark = Decoration.mark({
  313. tagName: 'span',
  314. class: `ol-cm-change ol-cm-change-${opType}`,
  315. opType,
  316. id,
  317. metadata,
  318. })
  319. const calloutWidget = Decoration.widget({
  320. widget: new ChangeCalloutWidget(change, opType),
  321. opType,
  322. id,
  323. metadata,
  324. })
  325. return [calloutWidget.range(from, from), changeMark.range(from, to)]
  326. }
  327. const trackChangesTheme = EditorView.baseTheme({
  328. '.cm-line': {
  329. overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
  330. },
  331. '&light .ol-cm-change-i': {
  332. backgroundColor: '#2c8e304d',
  333. },
  334. '&dark .ol-cm-change-i': {
  335. backgroundColor: 'rgba(37, 107, 41, 0.15)',
  336. },
  337. '&light .ol-cm-change-c': {
  338. backgroundColor: '#f3b1114d',
  339. },
  340. '&dark .ol-cm-change-c': {
  341. backgroundColor: 'rgba(194, 93, 11, 0.15)',
  342. },
  343. '.ol-cm-change': {
  344. padding: 'var(--half-leading, 0) 0',
  345. },
  346. '.ol-cm-change-d': {
  347. borderLeft: '2px dotted #c5060b',
  348. marginLeft: '-1px',
  349. },
  350. '.ol-cm-change-callout': {
  351. position: 'relative',
  352. pointerEvents: 'none',
  353. padding: 'var(--half-leading, 0) 0',
  354. },
  355. '.ol-cm-change-callout-inner': {
  356. display: 'inline-block',
  357. position: 'absolute',
  358. left: 0,
  359. bottom: 0,
  360. width: '100vw',
  361. borderBottom: '1px dashed black',
  362. },
  363. // disable callout line in Firefox
  364. '@supports (-moz-appearance:none)': {
  365. '.ol-cm-change-callout-inner': {
  366. display: 'none',
  367. },
  368. },
  369. '.ol-cm-change-callout-i .ol-cm-change-callout-inner': {
  370. borderColor: '#2c8e30',
  371. },
  372. '.ol-cm-change-callout-c .ol-cm-change-callout-inner': {
  373. borderColor: '#f3b111',
  374. },
  375. '.ol-cm-change-callout-d .ol-cm-change-callout-inner': {
  376. borderColor: '#c5060b',
  377. },
  378. })