track-changes.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import { StateEffect } 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 { ChangeManager } from './changes/change-manager'
  12. import { debugConsole } from '@/utils/debugging'
  13. import { isCommentOperation, isDeleteOperation } from '@/utils/operations'
  14. import {
  15. DocumentContainer,
  16. RangesTrackerWithResolvedThreadIds,
  17. } from '@/features/ide-react/editor/document-container'
  18. const clearChangesEffect = StateEffect.define()
  19. const buildChangesEffect = StateEffect.define()
  20. type Options = {
  21. currentDoc: DocumentContainer
  22. loadingThreads: boolean
  23. }
  24. /**
  25. * A custom extension that initialises the change manager, passes any updates to it,
  26. * and produces decorations for tracked changes and comments.
  27. */
  28. export const trackChanges = (
  29. { currentDoc, loadingThreads }: Options,
  30. changeManager: ChangeManager
  31. ) => {
  32. return [
  33. // initialize/destroy the change manager, and handle any updates
  34. ViewPlugin.define(() => {
  35. changeManager.initialize()
  36. return {
  37. update: update => {
  38. changeManager.handleUpdate(update)
  39. },
  40. destroy: () => {
  41. changeManager.destroy()
  42. },
  43. }
  44. }),
  45. // draw change decorations
  46. ViewPlugin.define<
  47. PluginValue & {
  48. decorations: DecorationSet
  49. }
  50. >(
  51. () => {
  52. return {
  53. decorations: loadingThreads
  54. ? Decoration.none
  55. : buildChangeDecorations(currentDoc),
  56. update(update) {
  57. for (const transaction of update.transactions) {
  58. this.decorations = this.decorations.map(transaction.changes)
  59. for (const effect of transaction.effects) {
  60. if (effect.is(clearChangesEffect)) {
  61. this.decorations = Decoration.none
  62. } else if (effect.is(buildChangesEffect)) {
  63. this.decorations = buildChangeDecorations(currentDoc)
  64. }
  65. }
  66. }
  67. },
  68. }
  69. },
  70. {
  71. decorations: value => value.decorations,
  72. }
  73. ),
  74. // styles for change decorations
  75. trackChangesTheme,
  76. ]
  77. }
  78. export const clearChangeMarkers = () => {
  79. return {
  80. effects: clearChangesEffect.of(null),
  81. }
  82. }
  83. export const buildChangeMarkers = () => {
  84. return {
  85. effects: buildChangesEffect.of(null),
  86. }
  87. }
  88. const buildChangeDecorations = (currentDoc: DocumentContainer) => {
  89. if (!currentDoc.ranges) {
  90. return Decoration.none
  91. }
  92. const changes = [...currentDoc.ranges.changes, ...currentDoc.ranges.comments]
  93. const decorations = []
  94. for (const change of changes) {
  95. try {
  96. decorations.push(...createChangeRange(change, currentDoc))
  97. } catch (error) {
  98. // ignore invalid changes
  99. debugConsole.debug('invalid change position', error)
  100. }
  101. }
  102. return Decoration.set(decorations, true)
  103. }
  104. class ChangeDeletedWidget extends WidgetType {
  105. constructor(public change: Change<DeleteOperation>) {
  106. super()
  107. }
  108. toDOM() {
  109. const widget = document.createElement('span')
  110. widget.classList.add('ol-cm-change')
  111. widget.classList.add('ol-cm-change-d')
  112. return widget
  113. }
  114. eq() {
  115. return true
  116. }
  117. }
  118. class ChangeCalloutWidget extends WidgetType {
  119. constructor(
  120. public change: Change,
  121. public opType: string
  122. ) {
  123. super()
  124. }
  125. toDOM() {
  126. const widget = document.createElement('span')
  127. widget.className = 'ol-cm-change-callout'
  128. widget.classList.add(`ol-cm-change-callout-${this.opType}`)
  129. const inner = document.createElement('span')
  130. inner.classList.add('ol-cm-change-callout-inner')
  131. widget.appendChild(inner)
  132. return widget
  133. }
  134. eq(widget: ChangeCalloutWidget) {
  135. return widget.opType === this.opType
  136. }
  137. updateDOM(element: HTMLElement) {
  138. element.className = 'ol-cm-change-callout'
  139. element.classList.add(`ol-cm-change-callout-${this.opType}`)
  140. return true
  141. }
  142. }
  143. const createChangeRange = (change: Change, currentDoc: DocumentContainer) => {
  144. const { id, metadata, op } = change
  145. const from = op.p
  146. // TODO: find valid positions?
  147. if (isDeleteOperation(op)) {
  148. const opType = 'd'
  149. const changeWidget = Decoration.widget({
  150. widget: new ChangeDeletedWidget(change as Change<DeleteOperation>),
  151. side: 1,
  152. opType,
  153. id,
  154. metadata,
  155. })
  156. const calloutWidget = Decoration.widget({
  157. widget: new ChangeCalloutWidget(change, opType),
  158. side: 1,
  159. opType,
  160. id,
  161. metadata,
  162. })
  163. return [calloutWidget.range(from, from), changeWidget.range(from, from)]
  164. }
  165. const _isCommentOperation = isCommentOperation(op)
  166. if (
  167. _isCommentOperation &&
  168. (currentDoc.ranges as RangesTrackerWithResolvedThreadIds)
  169. .resolvedThreadIds![op.t]
  170. ) {
  171. return []
  172. }
  173. const opType = _isCommentOperation ? 'c' : 'i'
  174. const changedText = _isCommentOperation ? op.c : op.i
  175. const to = from + changedText.length
  176. // Mark decorations must not be empty
  177. if (from === to) {
  178. return []
  179. }
  180. const changeMark = Decoration.mark({
  181. tagName: 'span',
  182. class: `ol-cm-change ol-cm-change-${opType}`,
  183. opType,
  184. id,
  185. metadata,
  186. })
  187. const calloutWidget = Decoration.widget({
  188. widget: new ChangeCalloutWidget(change, opType),
  189. opType,
  190. id,
  191. metadata,
  192. })
  193. return [calloutWidget.range(from, from), changeMark.range(from, to)]
  194. }
  195. const trackChangesTheme = EditorView.baseTheme({
  196. '.cm-line': {
  197. overflowX: 'hidden', // needed so the callout elements don't overflow (requires line wrapping to be on)
  198. },
  199. '&light .ol-cm-change-i': {
  200. backgroundColor: '#2c8e304d',
  201. },
  202. '&dark .ol-cm-change-i': {
  203. backgroundColor: 'rgba(37, 107, 41, 0.15)',
  204. },
  205. '&light .ol-cm-change-c': {
  206. backgroundColor: '#f3b1114d',
  207. },
  208. '&dark .ol-cm-change-c': {
  209. backgroundColor: 'rgba(194, 93, 11, 0.15)',
  210. },
  211. '.ol-cm-change': {
  212. padding: 'var(--half-leading, 0) 0',
  213. },
  214. '.ol-cm-change-d': {
  215. borderLeft: '2px dotted #c5060b',
  216. marginLeft: '-1px',
  217. },
  218. '.ol-cm-change-callout': {
  219. position: 'relative',
  220. pointerEvents: 'none',
  221. padding: 'var(--half-leading, 0) 0',
  222. },
  223. '.ol-cm-change-callout-inner': {
  224. display: 'inline-block',
  225. position: 'absolute',
  226. left: 0,
  227. bottom: 0,
  228. width: '100vw',
  229. borderBottom: '1px dashed black',
  230. },
  231. // disable callout line in Firefox
  232. '@supports (-moz-appearance:none)': {
  233. '.ol-cm-change-callout-inner': {
  234. display: 'none',
  235. },
  236. },
  237. '.ol-cm-change-callout-i .ol-cm-change-callout-inner': {
  238. borderColor: '#2c8e30',
  239. },
  240. '.ol-cm-change-callout-c .ol-cm-change-callout-inner': {
  241. borderColor: '#f3b111',
  242. },
  243. '.ol-cm-change-callout-d .ol-cm-change-callout-inner': {
  244. borderColor: '#c5060b',
  245. },
  246. })