track-changes.ts 9.0 KB

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