RangesTracker.js 28 KB

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