ranges.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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 {
  11. AnyOperation,
  12. Change,
  13. DeleteOperation,
  14. } from '../../../../../types/change'
  15. import { debugConsole } from '@/utils/debugging'
  16. import {
  17. isCommentOperation,
  18. isDeleteOperation,
  19. isInsertOperation,
  20. } from '@/utils/operations'
  21. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  22. import { Ranges } from '@/features/review-panel-new/context/ranges-context'
  23. import { Threads } from '@/features/review-panel-new/context/threads-context'
  24. import { isSelectionWithinOp } from '@/features/review-panel-new/utils/is-selection-within-op'
  25. type RangesData = {
  26. ranges: Ranges
  27. threads: Threads
  28. }
  29. const updateRangesEffect = StateEffect.define<RangesData>()
  30. const highlightRangesEffect = StateEffect.define<AnyOperation | undefined>()
  31. export const updateRanges = (data: RangesData): TransactionSpec => {
  32. return {
  33. effects: updateRangesEffect.of(data),
  34. }
  35. }
  36. export const highlightRanges = (op?: AnyOperation): TransactionSpec => {
  37. return {
  38. effects: highlightRangesEffect.of(op),
  39. }
  40. }
  41. type Options = {
  42. currentDoc: DocumentContainer
  43. loadingThreads?: boolean
  44. ranges?: Ranges
  45. threads?: Threads
  46. }
  47. /**
  48. * A custom extension that initialises the change manager, passes any updates to it,
  49. * and produces decorations for tracked changes and comments.
  50. */
  51. export const ranges = ({ ranges, threads }: Options) => {
  52. return [
  53. // handle viewportChanged updates
  54. ViewPlugin.define(view => {
  55. let timer: number
  56. return {
  57. update(update) {
  58. if (update.viewportChanged) {
  59. if (timer) {
  60. window.clearTimeout(timer)
  61. }
  62. timer = window.setTimeout(() => {
  63. dispatchEvent(new Event('editor:viewport-changed'))
  64. }, 25)
  65. }
  66. },
  67. }
  68. }),
  69. // draw change decorations
  70. ViewPlugin.define<
  71. PluginValue & {
  72. decorations: DecorationSet
  73. }
  74. >(
  75. () => {
  76. return {
  77. decorations:
  78. ranges && threads
  79. ? buildChangeDecorations({ ranges, threads })
  80. : Decoration.none,
  81. update(update) {
  82. for (const transaction of update.transactions) {
  83. this.decorations = this.decorations.map(transaction.changes)
  84. for (const effect of transaction.effects) {
  85. if (effect.is(updateRangesEffect)) {
  86. this.decorations = buildChangeDecorations(effect.value)
  87. }
  88. }
  89. }
  90. },
  91. }
  92. },
  93. {
  94. decorations: value => value.decorations,
  95. }
  96. ),
  97. // draw highlight decorations
  98. ViewPlugin.define<
  99. PluginValue & {
  100. decorations: DecorationSet
  101. }
  102. >(
  103. () => {
  104. return {
  105. decorations: Decoration.none,
  106. update(update) {
  107. for (const transaction of update.transactions) {
  108. this.decorations = this.decorations.map(transaction.changes)
  109. for (const effect of transaction.effects) {
  110. if (effect.is(highlightRangesEffect)) {
  111. this.decorations = buildHighlightDecorations(
  112. 'ol-cm-change-highlight',
  113. effect.value
  114. )
  115. }
  116. }
  117. }
  118. },
  119. }
  120. },
  121. {
  122. decorations: value => value.decorations,
  123. }
  124. ),
  125. // draw focus decorations
  126. ViewPlugin.define<
  127. PluginValue & {
  128. decorations: DecorationSet
  129. }
  130. >(
  131. () => {
  132. return {
  133. decorations: Decoration.none,
  134. update(update) {
  135. this.decorations = Decoration.none
  136. if (!ranges) {
  137. return
  138. }
  139. for (const range of [...ranges.changes, ...ranges.comments]) {
  140. if (isSelectionWithinOp(range.op, update.state.selection.main)) {
  141. this.decorations = buildHighlightDecorations(
  142. 'ol-cm-change-focus',
  143. range.op
  144. )
  145. }
  146. }
  147. },
  148. }
  149. },
  150. {
  151. decorations: value => value.decorations,
  152. }
  153. ),
  154. // styles for change decorations
  155. trackChangesTheme,
  156. ]
  157. }
  158. const buildChangeDecorations = (data: RangesData) => {
  159. if (!data.ranges) {
  160. return Decoration.none
  161. }
  162. const changes = [...data.ranges.changes, ...data.ranges.comments]
  163. const decorations = []
  164. for (const change of changes) {
  165. try {
  166. decorations.push(...createChangeRange(change, data))
  167. } catch (error) {
  168. // ignore invalid changes
  169. debugConsole.debug('invalid change position', error)
  170. }
  171. }
  172. return Decoration.set(decorations, true)
  173. }
  174. const buildHighlightDecorations = (className: string, op?: AnyOperation) => {
  175. if (!op) {
  176. return Decoration.none
  177. }
  178. if (isDeleteOperation(op)) {
  179. // nothing to highlight for deletions (for now)
  180. // TODO: add highlight when delete indicator is done
  181. return Decoration.none
  182. }
  183. const opFrom = op.p
  184. const opLength = isInsertOperation(op) ? op.i.length : op.c.length
  185. const opType = isInsertOperation(op) ? 'i' : 'c'
  186. return Decoration.set(
  187. Decoration.mark({
  188. class: `${className} ${className}-${opType}`,
  189. }).range(opFrom, opFrom + opLength),
  190. true
  191. )
  192. }
  193. class ChangeDeletedWidget extends WidgetType {
  194. constructor(public change: Change<DeleteOperation>) {
  195. super()
  196. }
  197. toDOM() {
  198. const widget = document.createElement('span')
  199. widget.classList.add('ol-cm-change')
  200. widget.classList.add('ol-cm-change-d')
  201. return widget
  202. }
  203. eq() {
  204. return true
  205. }
  206. }
  207. const createChangeRange = (change: Change, data: RangesData) => {
  208. const { id, metadata, op } = change
  209. const from = op.p
  210. // TODO: find valid positions?
  211. if (isDeleteOperation(op)) {
  212. const opType = 'd'
  213. const changeWidget = Decoration.widget({
  214. widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
  215. side: 1,
  216. opType,
  217. id,
  218. metadata,
  219. })
  220. return [changeWidget.range(from, from)]
  221. }
  222. const _isCommentOperation = isCommentOperation(op)
  223. if (_isCommentOperation) {
  224. const thread = data.threads[op.t]
  225. if (!thread || thread.resolved) {
  226. return []
  227. }
  228. }
  229. const opType = _isCommentOperation ? 'c' : 'i'
  230. const changedText = _isCommentOperation ? op.c : op.i
  231. const to = from + changedText.length
  232. // Mark decorations must not be empty
  233. if (from === to) {
  234. return []
  235. }
  236. const changeMark = Decoration.mark({
  237. tagName: 'span',
  238. class: `ol-cm-change ol-cm-change-${opType}`,
  239. opType,
  240. id,
  241. metadata,
  242. })
  243. return [changeMark.range(from, to)]
  244. }
  245. const trackChangesTheme = EditorView.baseTheme({
  246. '&light .ol-cm-change-i': {
  247. backgroundColor: '#2c8e304d',
  248. },
  249. '&dark .ol-cm-change-i': {
  250. backgroundColor: 'rgba(37, 107, 41, 0.15)',
  251. },
  252. '&light .ol-cm-change-c': {
  253. backgroundColor: '#f3b1114d',
  254. },
  255. '&dark .ol-cm-change-c': {
  256. backgroundColor: 'rgba(194, 93, 11, 0.15)',
  257. },
  258. '.ol-cm-change': {
  259. padding: 'var(--half-leading, 0) 0',
  260. },
  261. '.ol-cm-change-d': {
  262. borderLeft: '2px dotted #c5060b',
  263. marginLeft: '-1px',
  264. },
  265. '&light .ol-cm-change-highlight-i': {
  266. backgroundColor: '#b8dbc899',
  267. },
  268. '&dark .ol-cm-change-highlight-i': {
  269. backgroundColor: '#b8dbc899',
  270. },
  271. '&light .ol-cm-change-highlight-c': {
  272. backgroundColor: '#fcc4837d',
  273. },
  274. '&dark .ol-cm-change-highlight-c': {
  275. backgroundColor: '#fcc4837d',
  276. },
  277. '&light .ol-cm-change-focus-i': {
  278. backgroundColor: '#B8DBC8',
  279. },
  280. '&dark .ol-cm-change-focus-i': {
  281. backgroundColor: '#B8DBC8',
  282. },
  283. '&light .ol-cm-change-focus-c': {
  284. backgroundColor: '#FCC483',
  285. },
  286. '&dark .ol-cm-change-focus-c': {
  287. backgroundColor: '#FCC483',
  288. },
  289. })