index.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785
  1. /**
  2. * The purpose of this class is to track a set of inserts and deletes to a document, like
  3. * track changes in Word. We store these as a set of ShareJs style ranges:
  4. * {i: "foo", p: 42} # Insert 'foo' at offset 42
  5. * {d: "bar", p: 37} # Delete 'bar' at offset 37
  6. * We only track the inserts and deletes, not the whole document, but by being given all
  7. * updates that are applied to a document, we can update these appropriately.
  8. *
  9. * Note that the set of inserts and deletes we store applies to the document as-is at the moment.
  10. * So inserts correspond to text which is in the document, while deletes correspond to text which
  11. * is no longer there, so their lengths do not affect the position of later offsets.
  12. * E.g.
  13. * this is the current text of the document
  14. * |-----| |
  15. * {i: "current ", p:12} -^ ^- {d: "old ", p: 31}
  16. *
  17. * Track changes rules (should be consistent with Word):
  18. * * When text is inserted at a delete, the text goes to the left of the delete
  19. * I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
  20. * * Deleting content flagged as 'inserted' does not create a new delete marker, it only
  21. * removes the insert marker. E.g.
  22. * * "abdefghijkl" -> "abfghijkl" when 'de' is deleted. No delete marker added
  23. * |---| <- inserted |-| <- inserted
  24. * * Deletes overlapping regular text and inserted text will insert a delete marker for the
  25. * regular text:
  26. * "abcdefghijkl" -> "abcdejkl" when 'fghi' is deleted
  27. * |----| |--||
  28. * ^- inserted 'bcdefg' \ ^- deleted 'hi'
  29. * \--inserted 'bcde'
  30. * * Deletes overlapping other deletes are merged. E.g.
  31. * "abcghijkl" -> "ahijkl" when 'bcg is deleted'
  32. * | <- delete 'def' | <- delete 'bcdefg'
  33. * * Deletes by another user will consume deletes by the first user
  34. * * Inserts by another user will not combine with inserts by the first user. If they are in the
  35. * middle of a previous insert by the first user, the original insert will be split into two.
  36. */
  37. class RangesTracker {
  38. constructor(changes, comments) {
  39. if (changes == null) {
  40. changes = []
  41. }
  42. this.changes = changes
  43. if (comments == null) {
  44. comments = []
  45. }
  46. this.comments = comments
  47. this.setIdSeed(RangesTracker.generateIdSeed())
  48. this.resetDirtyState()
  49. }
  50. getIdSeed() {
  51. return this.id_seed
  52. }
  53. setIdSeed(seed) {
  54. this.id_seed = seed
  55. this.id_increment = 0
  56. }
  57. static generateIdSeed() {
  58. // Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part
  59. // Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js
  60. const pid = Math.floor(Math.random() * 32767).toString(16)
  61. const machine = Math.floor(Math.random() * 16777216).toString(16)
  62. const timestamp = Math.floor(new Date().valueOf() / 1000).toString(16)
  63. return (
  64. '00000000'.substr(0, 8 - timestamp.length) +
  65. timestamp +
  66. '000000'.substr(0, 6 - machine.length) +
  67. machine +
  68. '0000'.substr(0, 4 - pid.length) +
  69. pid
  70. )
  71. }
  72. static generateId() {
  73. return this.generateIdSeed() + '000001'
  74. }
  75. newId() {
  76. this.id_increment++
  77. const increment = this.id_increment.toString(16)
  78. const id =
  79. this.id_seed + '000000'.substr(0, 6 - increment.length) + increment
  80. return id
  81. }
  82. getComment(commentId) {
  83. let comment = null
  84. for (const c of this.comments) {
  85. if (c.id === commentId) {
  86. comment = c
  87. break
  88. }
  89. }
  90. return comment
  91. }
  92. removeCommentId(commentId) {
  93. const comment = this.getComment(commentId)
  94. if (comment == null) {
  95. return
  96. }
  97. this.comments = this.comments.filter(c => c.id !== commentId)
  98. this._markAsDirty(comment, 'comment', 'removed')
  99. }
  100. moveCommentId(commentId, position, text) {
  101. for (const comment of this.comments) {
  102. if (comment.id === commentId) {
  103. comment.op.p = position
  104. comment.op.c = text
  105. this._markAsDirty(comment, 'comment', 'moved')
  106. }
  107. }
  108. }
  109. getChange(changeId) {
  110. let change = null
  111. for (const c of this.changes) {
  112. if (c.id === changeId) {
  113. change = c
  114. break
  115. }
  116. }
  117. return change
  118. }
  119. getChanges(changeIds) {
  120. const changesResponse = []
  121. const idsMap = {}
  122. for (const changeId of changeIds) {
  123. idsMap[changeId] = true
  124. }
  125. for (const change of this.changes) {
  126. if (idsMap[change.id]) {
  127. delete idsMap[change.id]
  128. changesResponse.push(change)
  129. }
  130. }
  131. return changesResponse
  132. }
  133. removeChangeId(changeId) {
  134. const change = this.getChange(changeId)
  135. if (change == null) {
  136. return
  137. }
  138. this._removeChange(change)
  139. }
  140. removeChangeIds(changeToRemoveIds) {
  141. if (changeToRemoveIds == null || changeToRemoveIds.length === 0) {
  142. return
  143. }
  144. const removeChangeId = {}
  145. for (const changeId of changeToRemoveIds) {
  146. removeChangeId[changeId] = true
  147. }
  148. const remainingChanges = []
  149. for (const change of this.changes) {
  150. if (removeChangeId[change.id]) {
  151. delete removeChangeId[change.id]
  152. this._markAsDirty(change, 'change', 'removed')
  153. } else {
  154. remainingChanges.push(change)
  155. }
  156. }
  157. this.changes = remainingChanges
  158. }
  159. validate(text) {
  160. let content
  161. for (const change of this.changes) {
  162. if (change.op.i != null) {
  163. content = text.slice(change.op.p, change.op.p + change.op.i.length)
  164. if (content !== change.op.i) {
  165. throw new Error(
  166. `Change (${JSON.stringify(
  167. change
  168. )}) doesn't match text (${JSON.stringify(content)})`
  169. )
  170. }
  171. }
  172. }
  173. for (const comment of this.comments) {
  174. content = text.slice(comment.op.p, comment.op.p + comment.op.c.length)
  175. if (content !== comment.op.c) {
  176. throw new Error(
  177. `Comment (${JSON.stringify(
  178. comment
  179. )}) doesn't match text (${JSON.stringify(content)})`
  180. )
  181. }
  182. }
  183. }
  184. applyOp(op, metadata) {
  185. if (metadata == null) {
  186. metadata = {}
  187. }
  188. if (metadata.ts == null) {
  189. metadata.ts = new Date()
  190. }
  191. // Apply an op that has been applied to the document to our changes to keep them up to date
  192. if (op.i != null) {
  193. this.applyInsertToChanges(op, metadata)
  194. this.applyInsertToComments(op)
  195. } else if (op.d != null) {
  196. this.applyDeleteToChanges(op, metadata)
  197. this.applyDeleteToComments(op)
  198. } else if (op.c != null) {
  199. this.addComment(op, metadata)
  200. } else {
  201. throw new Error('unknown op type')
  202. }
  203. }
  204. applyOps(ops, metadata) {
  205. if (metadata == null) {
  206. metadata = {}
  207. }
  208. for (const op of ops) {
  209. this.applyOp(op, metadata)
  210. }
  211. }
  212. addComment(op, metadata) {
  213. const existing = this.getComment(op.t)
  214. if (existing != null) {
  215. this.moveCommentId(op.t, op.p, op.c)
  216. } else {
  217. let comment
  218. this.comments.push(
  219. (comment = {
  220. id: op.t || this.newId(),
  221. op: {
  222. // Copy because we'll modify in place
  223. c: op.c,
  224. p: op.p,
  225. t: op.t,
  226. },
  227. metadata,
  228. })
  229. )
  230. this._markAsDirty(comment, 'comment', 'added')
  231. }
  232. }
  233. applyInsertToComments(op) {
  234. for (const comment of this.comments) {
  235. if (op.p <= comment.op.p) {
  236. comment.op.p += op.i.length
  237. this._markAsDirty(comment, 'comment', 'moved')
  238. } else if (op.p < comment.op.p + comment.op.c.length) {
  239. const offset = op.p - comment.op.p
  240. comment.op.c =
  241. comment.op.c.slice(0, +(offset - 1) + 1 || undefined) +
  242. op.i +
  243. comment.op.c.slice(offset)
  244. this._markAsDirty(comment, 'comment', 'moved')
  245. }
  246. }
  247. }
  248. applyDeleteToComments(op) {
  249. const opStart = op.p
  250. const opLength = op.d.length
  251. const opEnd = op.p + opLength
  252. for (const comment of this.comments) {
  253. const commentStart = comment.op.p
  254. const commentEnd = comment.op.p + comment.op.c.length
  255. const commentLength = commentEnd - commentStart
  256. if (opEnd <= commentStart) {
  257. // delete is fully before comment
  258. comment.op.p -= opLength
  259. this._markAsDirty(comment, 'comment', 'moved')
  260. } else if (opStart >= commentEnd) {
  261. // delete is fully after comment, nothing to do
  262. } else {
  263. // delete and comment overlap
  264. let remainingAfter, remainingBefore
  265. if (opStart <= commentStart) {
  266. remainingBefore = ''
  267. } else {
  268. remainingBefore = comment.op.c.slice(0, opStart - commentStart)
  269. }
  270. if (opEnd >= commentEnd) {
  271. remainingAfter = ''
  272. } else {
  273. remainingAfter = comment.op.c.slice(opEnd - commentStart)
  274. }
  275. // Check deleted content matches delete op
  276. const deletedComment = comment.op.c.slice(
  277. remainingBefore.length,
  278. commentLength - remainingAfter.length
  279. )
  280. const offset = Math.max(0, commentStart - opStart)
  281. const deletedOpContent = op.d
  282. .slice(offset)
  283. .slice(0, deletedComment.length)
  284. if (deletedComment !== deletedOpContent) {
  285. throw new Error('deleted content does not match comment content')
  286. }
  287. comment.op.p = Math.min(commentStart, opStart)
  288. comment.op.c = remainingBefore + remainingAfter
  289. this._markAsDirty(comment, 'comment', 'moved')
  290. }
  291. }
  292. }
  293. applyInsertToChanges(op, metadata) {
  294. let change
  295. const opStart = op.p
  296. const opLength = op.i.length
  297. const opEnd = op.p + opLength
  298. const undoing = !!op.u
  299. let alreadyMerged = false
  300. let previousChange = null
  301. const movedChanges = []
  302. const removeChanges = []
  303. const newChanges = []
  304. for (let i = 0; i < this.changes.length; i++) {
  305. change = this.changes[i]
  306. const changeStart = change.op.p
  307. if (change.op.d != null) {
  308. // Shift any deletes after this along by the length of this insert
  309. if (opStart < changeStart) {
  310. change.op.p += opLength
  311. movedChanges.push(change)
  312. } else if (opStart === changeStart) {
  313. // If we are undoing, then we want to cancel any existing delete ranges if we can.
  314. // Check if the insert matches the start of the delete, and just remove it from the delete instead if so.
  315. if (
  316. undoing &&
  317. change.op.d.length >= op.i.length &&
  318. change.op.d.slice(0, op.i.length) === op.i
  319. ) {
  320. change.op.d = change.op.d.slice(op.i.length)
  321. change.op.p += op.i.length
  322. if (change.op.d === '') {
  323. removeChanges.push(change)
  324. } else {
  325. movedChanges.push(change)
  326. }
  327. alreadyMerged = true
  328. } else {
  329. change.op.p += opLength
  330. movedChanges.push(change)
  331. }
  332. }
  333. } else if (change.op.i != null) {
  334. let offset
  335. const changeEnd = changeStart + change.op.i.length
  336. const isChangeOverlapping =
  337. opStart >= changeStart && opStart <= changeEnd
  338. // Only merge inserts if they are from the same user
  339. const isSameUser = metadata.user_id === change.metadata.user_id
  340. // If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also
  341. // an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete.
  342. // E.g.
  343. // foo|<--- about to insert 'b' here
  344. // inserted 'foo' --^ ^-- deleted 'bar'
  345. // should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
  346. const nextChange = this.changes[i + 1]
  347. const isOpAdjacentToNextDelete =
  348. nextChange != null &&
  349. nextChange.op.d != null &&
  350. op.p === changeEnd &&
  351. nextChange.op.p === op.p
  352. const willOpCancelNextDelete =
  353. undoing &&
  354. isOpAdjacentToNextDelete &&
  355. nextChange.op.d.slice(0, op.i.length) === op.i
  356. // If there is a delete at the start of the insert, and we're inserting
  357. // at the start, we SHOULDN'T merge since the delete acts as a partition.
  358. // The previous op will be the delete, but it's already been shifted by this insert
  359. //
  360. // I.e.
  361. // Originally: |-- existing insert --|
  362. // | <- existing delete at same offset
  363. //
  364. // Now: |-- existing insert --| <- not shifted yet
  365. // |-- this insert --|| <- existing delete shifted along to end of this op
  366. //
  367. // After: |-- existing insert --|
  368. // |-- this insert --|| <- existing delete
  369. //
  370. // Without the delete, the inserts would be merged.
  371. const isInsertBlockedByDelete =
  372. previousChange != null &&
  373. previousChange.op.d != null &&
  374. previousChange.op.p === opEnd
  375. // If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
  376. // then we merge them into one.
  377. if (
  378. this.track_changes &&
  379. isChangeOverlapping &&
  380. !isInsertBlockedByDelete &&
  381. !alreadyMerged &&
  382. !willOpCancelNextDelete &&
  383. isSameUser
  384. ) {
  385. offset = opStart - changeStart
  386. change.op.i =
  387. change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
  388. change.metadata.ts = metadata.ts
  389. alreadyMerged = true
  390. movedChanges.push(change)
  391. } else if (opStart <= changeStart) {
  392. // If we're fully before the other insert we can just shift the other insert by our length.
  393. // If they are touching, and should have been merged, they will have been above.
  394. // If not merged above, then it must be blocked by a delete, and will be after this insert, so we shift it along as well
  395. change.op.p += opLength
  396. movedChanges.push(change)
  397. } else if (
  398. (!isSameUser || !this.track_changes) &&
  399. changeStart < opStart &&
  400. opStart < changeEnd
  401. ) {
  402. // This user is inserting inside a change by another user, so we need to split the
  403. // other user's change into one before and after this one.
  404. offset = opStart - changeStart
  405. const beforeContent = change.op.i.slice(0, offset)
  406. const afterContent = change.op.i.slice(offset)
  407. // The existing change can become the 'before' change
  408. change.op.i = beforeContent
  409. movedChanges.push(change)
  410. // Create a new op afterwards
  411. const afterChange = {
  412. op: {
  413. i: afterContent,
  414. p: changeStart + offset + opLength,
  415. },
  416. metadata: {},
  417. }
  418. for (const key in change.metadata) {
  419. const value = change.metadata[key]
  420. afterChange.metadata[key] = value
  421. }
  422. newChanges.push(afterChange)
  423. }
  424. }
  425. previousChange = change
  426. }
  427. if (this.track_changes && !alreadyMerged) {
  428. this._addOp(op, metadata)
  429. }
  430. for ({ op, metadata } of newChanges) {
  431. this._addOp(op, metadata)
  432. }
  433. for (change of removeChanges) {
  434. this._removeChange(change)
  435. }
  436. for (change of movedChanges) {
  437. this._markAsDirty(change, 'change', 'moved')
  438. }
  439. }
  440. applyDeleteToChanges(op, metadata) {
  441. const opStart = op.p
  442. const opLength = op.d.length
  443. const opEnd = op.p + opLength
  444. const removeChanges = []
  445. let movedChanges = []
  446. // We might end up modifying our delete op if it merges with existing deletes, or cancels out
  447. // with an existing insert. Since we might do multiple modifications, we record them and do
  448. // all the modifications after looping through the existing changes, so as not to mess up the
  449. // offset indexes as we go.
  450. const opModifications = []
  451. for (const change of this.changes) {
  452. let changeStart
  453. if (change.op.i != null) {
  454. changeStart = change.op.p
  455. const changeEnd = changeStart + change.op.i.length
  456. if (opEnd <= changeStart) {
  457. // Shift ops after us back by our length
  458. change.op.p -= opLength
  459. movedChanges.push(change)
  460. } else if (opStart >= changeEnd) {
  461. // Delete is after insert, nothing to do
  462. } else {
  463. // When the new delete overlaps an insert, we should remove the part of the insert that
  464. // is now deleted, and also remove the part of the new delete that overlapped. I.e.
  465. // the two cancel out where they overlap.
  466. let deleteRemainingAfter,
  467. deleteRemainingBefore,
  468. insertRemainingAfter,
  469. insertRemainingBefore
  470. if (opStart >= changeStart) {
  471. // |-- existing insert --|
  472. // insertRemainingBefore -> |.....||-- new delete --|
  473. deleteRemainingBefore = ''
  474. insertRemainingBefore = change.op.i.slice(0, opStart - changeStart)
  475. } else {
  476. // deleteRemainingBefore -> |.....||-- existing insert --|
  477. // |-- new delete --|
  478. deleteRemainingBefore = op.d.slice(0, changeStart - opStart)
  479. insertRemainingBefore = ''
  480. }
  481. if (opEnd <= changeEnd) {
  482. // |-- existing insert --|
  483. // |-- new delete --||.....| <- insertRemainingAfter
  484. deleteRemainingAfter = ''
  485. insertRemainingAfter = change.op.i.slice(opEnd - changeStart)
  486. } else {
  487. // |-- existing insert --||.....| <- deleteRemainingAfter
  488. // |-- new delete --|
  489. deleteRemainingAfter = op.d.slice(changeEnd - opStart)
  490. insertRemainingAfter = ''
  491. }
  492. const insertRemaining = insertRemainingBefore + insertRemainingAfter
  493. if (insertRemaining.length > 0) {
  494. change.op.i = insertRemaining
  495. change.op.p = Math.min(changeStart, opStart)
  496. change.metadata.ts = metadata.ts
  497. movedChanges.push(change)
  498. } else {
  499. removeChanges.push(change)
  500. }
  501. // We know what we want to preserve of our delete op before (deleteRemainingBefore) and what we want to preserve
  502. // afterwards (deleteRemainingBefore). Now we need to turn that into a modification which deletes the
  503. // chunk in the middle not covered by these.
  504. const deleteRemovedLength =
  505. op.d.length -
  506. deleteRemainingBefore.length -
  507. deleteRemainingAfter.length
  508. const deleteRemovedStart = deleteRemainingBefore.length
  509. const modification = {
  510. d: op.d.slice(
  511. deleteRemovedStart,
  512. deleteRemovedStart + deleteRemovedLength
  513. ),
  514. p: deleteRemovedStart,
  515. }
  516. if (modification.d.length > 0) {
  517. opModifications.push(modification)
  518. }
  519. }
  520. } else if (change.op.d != null) {
  521. changeStart = change.op.p
  522. if (
  523. opEnd < changeStart ||
  524. (!this.track_changes && opEnd === changeStart)
  525. ) {
  526. // Shift ops after us back by our length.
  527. // If we're tracking changes, it must be strictly before, since we'll merge
  528. // below if they are touching. Otherwise, touching is fine.
  529. change.op.p -= opLength
  530. movedChanges.push(change)
  531. } else if (opStart <= changeStart && changeStart <= opEnd) {
  532. if (this.track_changes) {
  533. // If we overlap a delete, add it in our content, and delete the existing change.
  534. // It's easier to do it this way, rather than modifying the existing delete in case
  535. // we overlap many deletes and we'd need to track that. We have a workaround to
  536. // update the delete in place if possible below.
  537. const offset = changeStart - opStart
  538. opModifications.push({ i: change.op.d, p: offset })
  539. removeChanges.push(change)
  540. } else {
  541. change.op.p = opStart
  542. movedChanges.push(change)
  543. }
  544. }
  545. }
  546. }
  547. // Copy rather than modify because we still need to apply it to comments
  548. op = {
  549. p: op.p,
  550. d: this._applyOpModifications(op.d, opModifications),
  551. }
  552. for (const change of removeChanges) {
  553. // This is a bit of hack to avoid removing one delete and replacing it with another.
  554. // If we don't do this, it causes the UI to flicker
  555. if (
  556. op.d.length > 0 &&
  557. change.op.d != null &&
  558. op.p <= change.op.p &&
  559. change.op.p <= op.p + op.d.length
  560. ) {
  561. change.op.p = op.p
  562. change.op.d = op.d
  563. change.metadata = metadata
  564. movedChanges.push(change)
  565. op.d = '' // stop it being added
  566. } else {
  567. this._removeChange(change)
  568. }
  569. }
  570. if (this.track_changes && op.d.length > 0) {
  571. this._addOp(op, metadata)
  572. } else {
  573. // It's possible that we deleted an insert between two other inserts. I.e.
  574. // If we delete 'user_2 insert' in:
  575. // |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
  576. // it becomes:
  577. // |-- user_1 insert --||-- user_1 insert --|
  578. // We need to merge these together again
  579. const results = this._scanAndMergeAdjacentUpdates()
  580. movedChanges = movedChanges.concat(results.movedChanges)
  581. for (const change of results.removeChanges) {
  582. this._removeChange(change)
  583. movedChanges = movedChanges.filter(c => c !== change)
  584. }
  585. }
  586. for (const change of movedChanges) {
  587. this._markAsDirty(change, 'change', 'moved')
  588. }
  589. }
  590. _addOp(op, metadata) {
  591. const change = {
  592. id: this.newId(),
  593. op: this._clone(op), // Don't take a reference to the existing op since we'll modify this in place with future changes
  594. metadata: this._clone(metadata),
  595. }
  596. this.changes.push(change)
  597. // Keep ops in order of offset, with deletes before inserts
  598. this.changes.sort(function (c1, c2) {
  599. const result = c1.op.p - c2.op.p
  600. if (result !== 0) {
  601. return result
  602. } else if (c1.op.i != null && c2.op.d != null) {
  603. return 1
  604. } else if (c1.op.d != null && c2.op.i != null) {
  605. return -1
  606. } else {
  607. return 0
  608. }
  609. })
  610. this._markAsDirty(change, 'change', 'added')
  611. }
  612. _removeChange(change) {
  613. this.changes = this.changes.filter(c => c.id !== change.id)
  614. this._markAsDirty(change, 'change', 'removed')
  615. }
  616. _applyOpModifications(content, opModifications) {
  617. // Put in descending position order, with deleting first if at the same offset
  618. // (Inserting first would modify the content that the delete will delete)
  619. opModifications.sort(function (a, b) {
  620. const result = b.p - a.p
  621. if (result !== 0) {
  622. return result
  623. } else if (a.i != null && b.d != null) {
  624. return 1
  625. } else if (a.d != null && b.i != null) {
  626. return -1
  627. } else {
  628. return 0
  629. }
  630. })
  631. for (const modification of opModifications) {
  632. if (modification.i != null) {
  633. content =
  634. content.slice(0, modification.p) +
  635. modification.i +
  636. content.slice(modification.p)
  637. } else if (modification.d != null) {
  638. if (
  639. content.slice(
  640. modification.p,
  641. modification.p + modification.d.length
  642. ) !== modification.d
  643. ) {
  644. throw new Error(
  645. `deleted content does not match. content: ${JSON.stringify(
  646. content
  647. )}; modification: ${JSON.stringify(modification)}`
  648. )
  649. }
  650. content =
  651. content.slice(0, modification.p) +
  652. content.slice(modification.p + modification.d.length)
  653. }
  654. }
  655. return content
  656. }
  657. _scanAndMergeAdjacentUpdates() {
  658. // This should only need calling when deleting an update between two
  659. // other updates. There's no other way to get two adjacent updates from the
  660. // same user, since they would be merged on insert.
  661. let previousChange = null
  662. const removeChanges = []
  663. const movedChanges = []
  664. for (const change of this.changes) {
  665. if (previousChange?.op.i != null && change.op.i != null) {
  666. const previousChangeEnd =
  667. previousChange.op.p + previousChange.op.i.length
  668. const previousChangeUserId = previousChange.metadata.user_id
  669. const changeStart = change.op.p
  670. const changeUserId = change.metadata.user_id
  671. if (
  672. previousChangeEnd === changeStart &&
  673. previousChangeUserId === changeUserId
  674. ) {
  675. removeChanges.push(change)
  676. previousChange.op.i += change.op.i
  677. movedChanges.push(previousChange)
  678. }
  679. } else if (
  680. previousChange?.op.d != null &&
  681. change.op.d != null &&
  682. previousChange?.op.p === change.op.p
  683. ) {
  684. // Merge adjacent deletes
  685. previousChange.op.d += change.op.d
  686. removeChanges.push(change)
  687. movedChanges.push(previousChange)
  688. } else {
  689. // Only update to the current change if we haven't removed it.
  690. previousChange = change
  691. }
  692. }
  693. return { movedChanges, removeChanges }
  694. }
  695. resetDirtyState() {
  696. this._dirtyState = {
  697. comment: {
  698. moved: {},
  699. removed: {},
  700. added: {},
  701. },
  702. change: {
  703. moved: {},
  704. removed: {},
  705. added: {},
  706. },
  707. }
  708. }
  709. getDirtyState() {
  710. return this._dirtyState
  711. }
  712. _markAsDirty(object, type, action) {
  713. this._dirtyState[type][action][object.id] = object
  714. }
  715. _clone(object) {
  716. const clone = {}
  717. for (const k in object) {
  718. const v = object[k]
  719. clone[k] = v
  720. }
  721. return clone
  722. }
  723. }
  724. module.exports = RangesTracker