history-ot.ts 11 KB

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