history-ot.ts 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import {
  2. Decoration,
  3. DecorationSet,
  4. EditorView,
  5. WidgetType,
  6. } from '@codemirror/view'
  7. import {
  8. EditorState,
  9. StateEffect,
  10. StateField,
  11. Transaction,
  12. } from '@codemirror/state'
  13. import {
  14. CommentList,
  15. TextOperation,
  16. TrackingProps,
  17. TrackedChangeList,
  18. } from 'overleaf-editor-core'
  19. import { DocumentContainer } from '@/features/ide-react/editor/document-container'
  20. import { HistoryOTShareDoc } from '../../../../../types/share-doc'
  21. import {
  22. TrackedDeletes,
  23. trackedDeletesFromState,
  24. } from '@/features/source-editor/utils/tracked-deletes'
  25. export const historyOT = (currentDoc: DocumentContainer) => {
  26. const trackedChanges =
  27. currentDoc.historyOTShareDoc.snapshot.getTrackedChanges() ??
  28. new TrackedChangeList([])
  29. const comments =
  30. currentDoc.historyOTShareDoc.snapshot.getComments() ?? new CommentList([])
  31. return [
  32. updateSender,
  33. trackChangesUserIdState,
  34. shareDocState.init(() => currentDoc?.doc?._doc ?? null),
  35. rangesState.init(() => ({
  36. trackedChanges,
  37. comments,
  38. decorations: buildRangesDecorations({ trackedChanges, comments }),
  39. })),
  40. trackedChangesTheme,
  41. ]
  42. }
  43. export const shareDocState = StateField.define<HistoryOTShareDoc | null>({
  44. create() {
  45. return null
  46. },
  47. update(value) {
  48. // this state is constant
  49. return value
  50. },
  51. })
  52. const trackedChangesTheme = EditorView.baseTheme({
  53. '.ol-cm-change-i, .ol-cm-change-highlight-i, .ol-cm-change-focus-i': {
  54. backgroundColor: 'rgba(44, 142, 48, 0.30)',
  55. },
  56. '&light .ol-cm-change-c, &light .ol-cm-change-highlight-c, &light .ol-cm-change-focus-c':
  57. {
  58. backgroundColor: 'rgba(243, 177, 17, 0.30)',
  59. },
  60. '&dark .ol-cm-change-c, &dark .ol-cm-change-highlight-c, &dark .ol-cm-change-focus-c':
  61. {
  62. backgroundColor: 'rgba(194, 93, 11, 0.15)',
  63. },
  64. '.ol-cm-change': {
  65. padding: 'var(--half-leading, 0) 0',
  66. },
  67. '.ol-cm-change-highlight': {
  68. padding: 'var(--half-leading, 0) 0',
  69. },
  70. '.ol-cm-change-focus': {
  71. padding: 'var(--half-leading, 0) 0',
  72. },
  73. '&light .ol-cm-change-d': {
  74. borderLeft: '2px dotted #c5060b',
  75. marginLeft: '-1px',
  76. },
  77. '&dark .ol-cm-change-d': {
  78. borderLeft: '2px dotted #c5060b',
  79. marginLeft: '-1px',
  80. },
  81. '&light .ol-cm-change-d-highlight': {
  82. borderLeft: '3px solid #c5060b',
  83. marginLeft: '-2px',
  84. },
  85. '&dark .ol-cm-change-d-highlight': {
  86. borderLeft: '3px solid #c5060b',
  87. marginLeft: '-2px',
  88. },
  89. '&light .ol-cm-change-d-focus': {
  90. borderLeft: '3px solid #B83A33',
  91. marginLeft: '-2px',
  92. },
  93. '&dark .ol-cm-change-d-focus': {
  94. borderLeft: '3px solid #B83A33',
  95. marginLeft: '-2px',
  96. },
  97. })
  98. export const rangesUpdatedEffect = StateEffect.define()
  99. const buildRangesDecorations = ({
  100. trackedChanges,
  101. comments,
  102. }: {
  103. trackedChanges: TrackedChangeList
  104. comments: CommentList
  105. }) => {
  106. if (trackedChanges.length === 0 && comments.length === 0) {
  107. return Decoration.none
  108. }
  109. const trackedDeletes = new TrackedDeletes(trackedChanges)
  110. const decorations = []
  111. for (const change of trackedChanges.asSorted()) {
  112. const from = trackedDeletes.toCodeMirror(change.range.pos)
  113. if (change.tracking.type === 'insert') {
  114. const to = trackedDeletes.toCodeMirror(change.range.end)
  115. if (from < to) {
  116. decorations.push(
  117. Decoration.mark({
  118. class: 'ol-cm-change ol-cm-change-i',
  119. tracking: change.tracking,
  120. rangeType: 'trackedChange',
  121. change,
  122. }).range(from, to)
  123. )
  124. }
  125. } else {
  126. decorations.push(
  127. Decoration.widget({
  128. widget: new ChangeDeletedWidget(),
  129. side: 1,
  130. rangeType: 'trackedChange',
  131. change,
  132. }).range(from)
  133. )
  134. }
  135. }
  136. for (const comment of comments) {
  137. if (!comment.resolved) {
  138. for (const range of comment.ranges) {
  139. decorations.push(
  140. Decoration.mark({
  141. class: 'ol-cm-change ol-cm-change-c',
  142. id: comment.id,
  143. rangeType: 'comment',
  144. comment,
  145. }).range(
  146. trackedDeletes.toCodeMirror(range.pos),
  147. trackedDeletes.toCodeMirror(range.end)
  148. )
  149. )
  150. }
  151. }
  152. }
  153. return Decoration.set(decorations, true)
  154. }
  155. class ChangeDeletedWidget extends WidgetType {
  156. toDOM() {
  157. const widget = document.createElement('span')
  158. widget.classList.add('ol-cm-change')
  159. widget.classList.add('ol-cm-change-d')
  160. return widget
  161. }
  162. eq() {
  163. return true
  164. }
  165. }
  166. export const rangesState = StateField.define<{
  167. comments: CommentList
  168. trackedChanges: TrackedChangeList
  169. decorations: DecorationSet
  170. }>({
  171. create() {
  172. const trackedChanges = new TrackedChangeList([])
  173. const comments = new CommentList([])
  174. const decorations = buildRangesDecorations({ trackedChanges, comments })
  175. return { trackedChanges, comments, decorations }
  176. },
  177. update(value, transaction) {
  178. const shareDoc = transaction.state.field(shareDocState)!
  179. const { snapshot } = shareDoc
  180. if (transaction.docChanged) {
  181. const trackedChanges = snapshot.getTrackedChanges()
  182. const comments = snapshot.getComments()
  183. const decorations = buildRangesDecorations({ trackedChanges, comments })
  184. value = { trackedChanges, comments, decorations }
  185. } else {
  186. for (const effect of transaction.effects) {
  187. if (effect.is(rangesUpdatedEffect)) {
  188. const trackedChanges = snapshot.getTrackedChanges()
  189. const comments = snapshot.getComments()
  190. const decorations = buildRangesDecorations({
  191. trackedChanges,
  192. comments,
  193. })
  194. value = { trackedChanges, comments, decorations }
  195. shareDoc.emit('ranges:dirty')
  196. }
  197. }
  198. }
  199. return value
  200. },
  201. provide(field) {
  202. return EditorView.decorations.from(field, value => value.decorations)
  203. },
  204. })
  205. const setTrackChangesUserIdEffect = StateEffect.define<string | null>()
  206. export const setTrackChangesUserId = (userId: string | null) => {
  207. return {
  208. effects: setTrackChangesUserIdEffect.of(userId),
  209. }
  210. }
  211. const trackChangesUserIdState = StateField.define<string | null>({
  212. create() {
  213. return null
  214. },
  215. update(value, transaction) {
  216. for (const effect of transaction.effects) {
  217. if (effect.is(setTrackChangesUserIdEffect)) {
  218. value = effect.value
  219. }
  220. }
  221. return value
  222. },
  223. })
  224. const updateSender = EditorState.transactionExtender.of(tr => {
  225. if (!tr.docChanged || tr.annotation(Transaction.remote)) {
  226. return {}
  227. }
  228. const trackingUserId = tr.startState.field(trackChangesUserIdState)
  229. const trackedDeletes = trackedDeletesFromState(tr.startState)
  230. const startDoc = tr.startState.doc
  231. const opBuilder = new OperationBuilder(
  232. trackedDeletes.toSnapshot(startDoc.length)
  233. )
  234. if (trackingUserId == null) {
  235. // Not tracking changes
  236. tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
  237. // insert
  238. if (inserted.length > 0) {
  239. const pos = trackedDeletes.toSnapshot(fromA)
  240. opBuilder.insert(pos, inserted.toString())
  241. }
  242. // deletion
  243. if (toA > fromA) {
  244. const start = trackedDeletes.toSnapshot(fromA)
  245. const end = trackedDeletes.toSnapshot(toA)
  246. opBuilder.delete(start, end - start)
  247. }
  248. })
  249. } else {
  250. // Tracking changes
  251. const timestamp = new Date()
  252. tr.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
  253. // insertion
  254. if (inserted.length > 0) {
  255. const pos = trackedDeletes.toSnapshot(fromA)
  256. opBuilder.trackedInsert(
  257. pos,
  258. inserted.toString(),
  259. trackingUserId,
  260. timestamp
  261. )
  262. }
  263. // deletion
  264. if (toA > fromA) {
  265. const start = trackedDeletes.toSnapshot(fromA)
  266. const end = trackedDeletes.toSnapshot(toA)
  267. opBuilder.trackedDelete(start, end - start, trackingUserId, timestamp)
  268. }
  269. })
  270. }
  271. const op = opBuilder.finish()
  272. const shareDoc = tr.startState.field(shareDocState)
  273. if (shareDoc != null) {
  274. shareDoc.submitOp([op])
  275. }
  276. return {}
  277. })
  278. /**
  279. * Incrementally builds a TextOperation from a series of inserts and deletes.
  280. *
  281. * This relies on inserts and deletes being ordered by document position. This
  282. * is not clear in the documentation, but has been confirmed by Marijn in
  283. * https://discuss.codemirror.net/t/iterators-can-be-hard-to-work-with-for-beginners/3533/10
  284. */
  285. class OperationBuilder {
  286. /**
  287. * Source document length
  288. */
  289. private docLength: number
  290. /**
  291. * Position in the source document
  292. */
  293. private pos: number
  294. /**
  295. * Operation built
  296. */
  297. private op: TextOperation
  298. constructor(docLength: number) {
  299. this.docLength = docLength
  300. this.op = new TextOperation()
  301. this.pos = 0
  302. }
  303. insert(pos: number, text: string) {
  304. this.retainUntil(pos)
  305. this.op.insert(text)
  306. }
  307. delete(pos: number, length: number) {
  308. this.retainUntil(pos)
  309. this.op.remove(length)
  310. this.pos += length
  311. }
  312. trackedInsert(pos: number, text: string, userId: string, timestamp: Date) {
  313. this.retainUntil(pos)
  314. this.op.insert(text, {
  315. tracking: new TrackingProps('insert', userId, timestamp),
  316. })
  317. }
  318. trackedDelete(pos: number, length: number, userId: string, timestamp: Date) {
  319. this.retainUntil(pos)
  320. this.op.retain(length, {
  321. tracking: new TrackingProps('delete', userId, timestamp),
  322. })
  323. this.pos += length
  324. }
  325. retainUntil(pos: number) {
  326. if (pos > this.pos) {
  327. this.op.retain(pos - this.pos)
  328. this.pos = pos
  329. } else if (pos < this.pos) {
  330. throw Error(
  331. `Out of order: position ${pos} comes before current position: ${this.pos}`
  332. )
  333. }
  334. }
  335. finish() {
  336. this.retainUntil(this.docLength)
  337. return this.op
  338. }
  339. }