history-ot.ts 11 KB

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