ranges.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import { StateEffect, TransactionSpec } from '@codemirror/state'
  2. import {
  3. Decoration,
  4. type DecorationSet,
  5. EditorView,
  6. type PluginValue,
  7. ViewPlugin,
  8. WidgetType,
  9. } from '@codemirror/view'
  10. import { Change, DeleteOperation } from '../../../../../types/change'
  11. import { debugConsole } from '@/utils/debugging'
  12. import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
  13. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  14. import { Ranges } from '@/features/review-panel-new/context/ranges-context'
  15. import { Threads } from '@/features/review-panel-new/context/threads-context'
  16. type RangesData = {
  17. ranges: Ranges
  18. threads: Threads
  19. }
  20. const updateRangesEffect = StateEffect.define<RangesData>()
  21. export const updateRanges = (data: RangesData): TransactionSpec => {
  22. return {
  23. effects: updateRangesEffect.of(data),
  24. }
  25. }
  26. type Options = {
  27. currentDoc: DocumentContainer
  28. loadingThreads?: boolean
  29. ranges?: Ranges
  30. threads?: Threads
  31. }
  32. /**
  33. * A custom extension that initialises the change manager, passes any updates to it,
  34. * and produces decorations for tracked changes and comments.
  35. */
  36. export const ranges = ({ ranges, threads }: Options) => {
  37. return [
  38. // handle viewportChanged updates
  39. ViewPlugin.define(view => {
  40. let timer: number
  41. return {
  42. update(update) {
  43. if (update.viewportChanged) {
  44. if (timer) {
  45. window.clearTimeout(timer)
  46. }
  47. timer = window.setTimeout(() => {
  48. dispatchEvent(new Event('editor:viewport-changed'))
  49. }, 25)
  50. }
  51. },
  52. }
  53. }),
  54. // draw change decorations
  55. ViewPlugin.define<
  56. PluginValue & {
  57. decorations: DecorationSet
  58. }
  59. >(
  60. () => {
  61. return {
  62. decorations:
  63. ranges && threads
  64. ? buildChangeDecorations({ ranges, threads })
  65. : Decoration.none,
  66. update(update) {
  67. for (const transaction of update.transactions) {
  68. this.decorations = this.decorations.map(transaction.changes)
  69. for (const effect of transaction.effects) {
  70. if (effect.is(updateRangesEffect)) {
  71. this.decorations = buildChangeDecorations(effect.value)
  72. }
  73. }
  74. }
  75. },
  76. }
  77. },
  78. {
  79. decorations: value => value.decorations,
  80. }
  81. ),
  82. // styles for change decorations
  83. trackChangesTheme,
  84. ]
  85. }
  86. const buildChangeDecorations = (data: RangesData) => {
  87. if (!data.ranges) {
  88. return Decoration.none
  89. }
  90. const changes = [...data.ranges.changes, ...data.ranges.comments]
  91. const decorations = []
  92. for (const change of changes) {
  93. try {
  94. decorations.push(...createChangeRange(change, data))
  95. } catch (error) {
  96. // ignore invalid changes
  97. debugConsole.debug('invalid change position', error)
  98. }
  99. }
  100. return Decoration.set(decorations, true)
  101. }
  102. class ChangeDeletedWidget extends WidgetType {
  103. constructor(public change: Change<DeleteOperation>) {
  104. super()
  105. }
  106. toDOM() {
  107. const widget = document.createElement('span')
  108. widget.classList.add('ol-cm-change')
  109. widget.classList.add('ol-cm-change-d')
  110. return widget
  111. }
  112. eq() {
  113. return true
  114. }
  115. }
  116. const createChangeRange = (change: Change, data: RangesData) => {
  117. const { id, metadata, op } = change
  118. const from = op.p
  119. // TODO: find valid positions?
  120. if (isDeleteOperation(op)) {
  121. const opType = 'd'
  122. const changeWidget = Decoration.widget({
  123. widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
  124. side: 1,
  125. opType,
  126. id,
  127. metadata,
  128. })
  129. return [changeWidget.range(from, from)]
  130. }
  131. const _isCommentOperation = isCommentOperation(op)
  132. if (_isCommentOperation) {
  133. const thread = data.threads[op.t]
  134. if (!thread || thread.resolved) {
  135. return []
  136. }
  137. }
  138. const opType = _isCommentOperation ? 'c' : 'i'
  139. const changedText = _isCommentOperation ? op.c : op.i
  140. const to = from + changedText.length
  141. // Mark decorations must not be empty
  142. if (from === to) {
  143. return []
  144. }
  145. const changeMark = Decoration.mark({
  146. tagName: 'span',
  147. class: `ol-cm-change ol-cm-change-${opType}`,
  148. opType,
  149. id,
  150. metadata,
  151. })
  152. return [changeMark.range(from, to)]
  153. }
  154. const trackChangesTheme = EditorView.baseTheme({
  155. '&light .ol-cm-change-i': {
  156. backgroundColor: '#2c8e304d',
  157. },
  158. '&dark .ol-cm-change-i': {
  159. backgroundColor: 'rgba(37, 107, 41, 0.15)',
  160. },
  161. '&light .ol-cm-change-c': {
  162. backgroundColor: '#f3b1114d',
  163. },
  164. '&dark .ol-cm-change-c': {
  165. backgroundColor: 'rgba(194, 93, 11, 0.15)',
  166. },
  167. '.ol-cm-change': {
  168. padding: 'var(--half-leading, 0) 0',
  169. },
  170. '.ol-cm-change-d': {
  171. borderLeft: '2px dotted #c5060b',
  172. marginLeft: '-1px',
  173. },
  174. })