RangesTracker.coffee 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. # This file is shared between document-updater and web, so that the server and client share
  2. # an identical track changes implementation. Do not edit it directly in web or document-updater,
  3. # instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests
  4. load = () ->
  5. class RangesTracker
  6. # The purpose of this class is to track a set of inserts and deletes to a document, like
  7. # track changes in Word. We store these as a set of ShareJs style ranges:
  8. # {i: "foo", p: 42} # Insert 'foo' at offset 42
  9. # {d: "bar", p: 37} # Delete 'bar' at offset 37
  10. # We only track the inserts and deletes, not the whole document, but by being given all
  11. # updates that are applied to a document, we can update these appropriately.
  12. #
  13. # Note that the set of inserts and deletes we store applies to the document as-is at the moment.
  14. # So inserts correspond to text which is in the document, while deletes correspond to text which
  15. # is no longer there, so their lengths do not affect the position of later offsets.
  16. # E.g.
  17. # this is the current text of the document
  18. # |-----| |
  19. # {i: "current ", p:12} -^ ^- {d: "old ", p: 31}
  20. #
  21. # Track changes rules (should be consistent with Word):
  22. # * When text is inserted at a delete, the text goes to the left of the delete
  23. # I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
  24. # * Deleting content flagged as 'inserted' does not create a new delete marker, it only
  25. # removes the insert marker. E.g.
  26. # * "abdefghijkl" -> "abfghijkl" when 'de' is deleted. No delete marker added
  27. # |---| <- inserted |-| <- inserted
  28. # * Deletes overlapping regular text and inserted text will insert a delete marker for the
  29. # regular text:
  30. # "abcdefghijkl" -> "abcdejkl" when 'fghi' is deleted
  31. # |----| |--||
  32. # ^- inserted 'bcdefg' \ ^- deleted 'hi'
  33. # \--inserted 'bcde'
  34. # * Deletes overlapping other deletes are merged. E.g.
  35. # "abcghijkl" -> "ahijkl" when 'bcg is deleted'
  36. # | <- delete 'def' | <- delete 'bcdefg'
  37. # * Deletes by another user will consume deletes by the first user
  38. # * Inserts by another user will not combine with inserts by the first user. If they are in the
  39. # middle of a previous insert by the first user, the original insert will be split into two.
  40. constructor: (@changes = [], @comments = []) ->
  41. @setIdSeed(RangesTracker.generateIdSeed())
  42. @resetDirtyState()
  43. getIdSeed: () ->
  44. return @id_seed
  45. setIdSeed: (seed) ->
  46. @id_seed = seed
  47. @id_increment = 0
  48. @generateIdSeed: () ->
  49. # Generate a the first 18 characters of Mongo ObjectId, leaving 6 for the increment part
  50. # Reference: https://github.com/dreampulse/ObjectId.js/blob/master/src/main/javascript/Objectid.js
  51. pid = Math.floor(Math.random() * (32767)).toString(16)
  52. machine = Math.floor(Math.random() * (16777216)).toString(16)
  53. timestamp = Math.floor(new Date().valueOf() / 1000).toString(16)
  54. return '00000000'.substr(0, 8 - timestamp.length) + timestamp +
  55. '000000'.substr(0, 6 - machine.length) + machine +
  56. '0000'.substr(0, 4 - pid.length) + pid
  57. @generateId: () ->
  58. @generateIdSeed() + "000001"
  59. newId: () ->
  60. @id_increment++
  61. increment = @id_increment.toString(16)
  62. id = @id_seed + '000000'.substr(0, 6 - increment.length) + increment;
  63. return id
  64. getComment: (comment_id) ->
  65. comment = null
  66. for c in @comments
  67. if c.id == comment_id
  68. comment = c
  69. break
  70. return comment
  71. removeCommentId: (comment_id) ->
  72. comment = @getComment(comment_id)
  73. return if !comment?
  74. @comments = @comments.filter (c) -> c.id != comment_id
  75. @_markAsDirty comment, "comment", "removed"
  76. moveCommentId: (comment_id, position, text) ->
  77. for comment in @comments
  78. if comment.id == comment_id
  79. comment.op.p = position
  80. comment.op.c = text
  81. @_markAsDirty comment, "comment", "moved"
  82. getChange: (change_id) ->
  83. change = null
  84. for c in @changes
  85. if c.id == change_id
  86. change = c
  87. break
  88. return change
  89. getChanges: (change_ids) ->
  90. changes_response = []
  91. ids_map = {}
  92. for change_id in change_ids
  93. ids_map[change_id] = true
  94. for change in @changes
  95. if ids_map[change.id]
  96. delete ids_map[change.id]
  97. changes_response.push change
  98. return changes_response
  99. removeChangeId: (change_id) ->
  100. change = @getChange(change_id)
  101. return if !change?
  102. @_removeChange(change)
  103. removeChangeIds: (change_to_remove_ids) ->
  104. return if !change_to_remove_ids?.length > 0
  105. i = @changes.length
  106. remove_change_id = {}
  107. for change_id in change_to_remove_ids
  108. remove_change_id[change_id] = true
  109. remaining_changes = []
  110. for change in @changes
  111. if remove_change_id[change.id]
  112. delete remove_change_id[change.id]
  113. @_markAsDirty change, "change", "removed"
  114. else
  115. remaining_changes.push change
  116. @changes = remaining_changes
  117. validate: (text) ->
  118. for change in @changes
  119. if change.op.i?
  120. content = text.slice(change.op.p, change.op.p + change.op.i.length)
  121. if content != change.op.i
  122. throw new Error("Change (#{JSON.stringify(change)}) doesn't match text (#{JSON.stringify(content)})")
  123. for comment in @comments
  124. content = text.slice(comment.op.p, comment.op.p + comment.op.c.length)
  125. if content != comment.op.c
  126. throw new Error("Comment (#{JSON.stringify(comment)}) doesn't match text (#{JSON.stringify(content)})")
  127. return true
  128. applyOp: (op, metadata = {}) ->
  129. metadata.ts ?= new Date()
  130. # Apply an op that has been applied to the document to our changes to keep them up to date
  131. if op.i?
  132. @applyInsertToChanges(op, metadata)
  133. @applyInsertToComments(op)
  134. else if op.d?
  135. @applyDeleteToChanges(op, metadata)
  136. @applyDeleteToComments(op)
  137. else if op.c?
  138. @addComment(op, metadata)
  139. else
  140. throw new Error("unknown op type")
  141. applyOps: (ops, metadata = {}) ->
  142. for op in ops
  143. @applyOp(op, metadata)
  144. addComment: (op, metadata) ->
  145. existing = @getComment(op.t)
  146. if existing?
  147. @moveCommentId(op.t, op.p, op.c)
  148. return existing
  149. else
  150. @comments.push comment = {
  151. id: op.t or @newId()
  152. op: # Copy because we'll modify in place
  153. c: op.c
  154. p: op.p
  155. t: op.t
  156. metadata
  157. }
  158. @_markAsDirty comment, "comment", "added"
  159. return comment
  160. applyInsertToComments: (op) ->
  161. for comment in @comments
  162. if op.p <= comment.op.p
  163. comment.op.p += op.i.length
  164. @_markAsDirty comment, "comment", "moved"
  165. else if op.p < comment.op.p + comment.op.c.length
  166. offset = op.p - comment.op.p
  167. comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
  168. @_markAsDirty comment, "comment", "moved"
  169. applyDeleteToComments: (op) ->
  170. op_start = op.p
  171. op_length = op.d.length
  172. op_end = op.p + op_length
  173. for comment in @comments
  174. comment_start = comment.op.p
  175. comment_end = comment.op.p + comment.op.c.length
  176. comment_length = comment_end - comment_start
  177. if op_end <= comment_start
  178. # delete is fully before comment
  179. comment.op.p -= op_length
  180. @_markAsDirty comment, "comment", "moved"
  181. else if op_start >= comment_end
  182. # delete is fully after comment, nothing to do
  183. else
  184. # delete and comment overlap
  185. if op_start <= comment_start
  186. remaining_before = ""
  187. else
  188. remaining_before = comment.op.c.slice(0, op_start - comment_start)
  189. if op_end >= comment_end
  190. remaining_after = ""
  191. else
  192. remaining_after = comment.op.c.slice(op_end - comment_start)
  193. # Check deleted content matches delete op
  194. deleted_comment = comment.op.c.slice(remaining_before.length, comment_length - remaining_after.length)
  195. offset = Math.max(0, comment_start - op_start)
  196. deleted_op_content = op.d.slice(offset).slice(0, deleted_comment.length)
  197. if deleted_comment != deleted_op_content
  198. throw new Error("deleted content does not match comment content")
  199. comment.op.p = Math.min(comment_start, op_start)
  200. comment.op.c = remaining_before + remaining_after
  201. @_markAsDirty comment, "comment", "moved"
  202. applyInsertToChanges: (op, metadata) ->
  203. op_start = op.p
  204. op_length = op.i.length
  205. op_end = op.p + op_length
  206. undoing = !!op.u
  207. already_merged = false
  208. previous_change = null
  209. moved_changes = []
  210. remove_changes = []
  211. new_changes = []
  212. for change, i in @changes
  213. change_start = change.op.p
  214. if change.op.d?
  215. # Shift any deletes after this along by the length of this insert
  216. if op_start < change_start
  217. change.op.p += op_length
  218. moved_changes.push change
  219. else if op_start == change_start
  220. # If we are undoing, then we want to cancel any existing delete ranges if we can.
  221. # Check if the insert matches the start of the delete, and just remove it from the delete instead if so.
  222. if undoing and change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
  223. change.op.d = change.op.d.slice(op.i.length)
  224. change.op.p += op.i.length
  225. if change.op.d == ""
  226. remove_changes.push change
  227. else
  228. moved_changes.push change
  229. already_merged = true
  230. else
  231. change.op.p += op_length
  232. moved_changes.push change
  233. else if change.op.i?
  234. change_end = change_start + change.op.i.length
  235. is_change_overlapping = (op_start >= change_start and op_start <= change_end)
  236. # Only merge inserts if they are from the same user
  237. is_same_user = metadata.user_id == change.metadata.user_id
  238. # If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also
  239. # an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete.
  240. # E.g.
  241. # foo|<--- about to insert 'b' here
  242. # inserted 'foo' --^ ^-- deleted 'bar'
  243. # should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
  244. next_change = @changes[i+1]
  245. is_op_adjacent_to_next_delete = next_change? and next_change.op.d? and op.p == change_end and next_change.op.p == op.p
  246. will_op_cancel_next_delete = undoing and is_op_adjacent_to_next_delete and next_change.op.d.slice(0, op.i.length) == op.i
  247. # If there is a delete at the start of the insert, and we're inserting
  248. # at the start, we SHOULDN'T merge since the delete acts as a partition.
  249. # The previous op will be the delete, but it's already been shifted by this insert
  250. #
  251. # I.e.
  252. # Originally: |-- existing insert --|
  253. # | <- existing delete at same offset
  254. #
  255. # Now: |-- existing insert --| <- not shifted yet
  256. # |-- this insert --|| <- existing delete shifted along to end of this op
  257. #
  258. # After: |-- existing insert --|
  259. # |-- this insert --|| <- existing delete
  260. #
  261. # Without the delete, the inserts would be merged.
  262. is_insert_blocked_by_delete = (previous_change? and previous_change.op.d? and previous_change.op.p == op_end)
  263. # If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
  264. # then we merge them into one.
  265. if @track_changes and
  266. is_change_overlapping and
  267. !is_insert_blocked_by_delete and
  268. !already_merged and
  269. !will_op_cancel_next_delete and
  270. is_same_user
  271. offset = op_start - change_start
  272. change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
  273. change.metadata.ts = metadata.ts
  274. already_merged = true
  275. moved_changes.push change
  276. else if op_start <= change_start
  277. # If we're fully before the other insert we can just shift the other insert by our length.
  278. # If they are touching, and should have been merged, they will have been above.
  279. # 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
  280. change.op.p += op_length
  281. moved_changes.push change
  282. else if (!is_same_user or !@track_changes) and change_start < op_start < change_end
  283. # This user is inserting inside a change by another user, so we need to split the
  284. # other user's change into one before and after this one.
  285. offset = op_start - change_start
  286. before_content = change.op.i.slice(0, offset)
  287. after_content = change.op.i.slice(offset)
  288. # The existing change can become the 'before' change
  289. change.op.i = before_content
  290. moved_changes.push change
  291. # Create a new op afterwards
  292. after_change = {
  293. op: {
  294. i: after_content
  295. p: change_start + offset + op_length
  296. }
  297. metadata: {}
  298. }
  299. after_change.metadata[key] = value for key, value of change.metadata
  300. new_changes.push after_change
  301. previous_change = change
  302. if @track_changes and !already_merged
  303. @_addOp op, metadata
  304. for {op, metadata} in new_changes
  305. @_addOp op, metadata
  306. for change in remove_changes
  307. @_removeChange change
  308. for change in moved_changes
  309. @_markAsDirty change, "change", "moved"
  310. applyDeleteToChanges: (op, metadata) ->
  311. op_start = op.p
  312. op_length = op.d.length
  313. op_end = op.p + op_length
  314. remove_changes = []
  315. moved_changes = []
  316. # We might end up modifying our delete op if it merges with existing deletes, or cancels out
  317. # with an existing insert. Since we might do multiple modifications, we record them and do
  318. # all the modifications after looping through the existing changes, so as not to mess up the
  319. # offset indexes as we go.
  320. op_modifications = []
  321. for change in @changes
  322. if change.op.i?
  323. change_start = change.op.p
  324. change_end = change_start + change.op.i.length
  325. if op_end <= change_start
  326. # Shift ops after us back by our length
  327. change.op.p -= op_length
  328. moved_changes.push change
  329. else if op_start >= change_end
  330. # Delete is after insert, nothing to do
  331. else
  332. # When the new delete overlaps an insert, we should remove the part of the insert that
  333. # is now deleted, and also remove the part of the new delete that overlapped. I.e.
  334. # the two cancel out where they overlap.
  335. if op_start >= change_start
  336. # |-- existing insert --|
  337. # insert_remaining_before -> |.....||-- new delete --|
  338. delete_remaining_before = ""
  339. insert_remaining_before = change.op.i.slice(0, op_start - change_start)
  340. else
  341. # delete_remaining_before -> |.....||-- existing insert --|
  342. # |-- new delete --|
  343. delete_remaining_before = op.d.slice(0, change_start - op_start)
  344. insert_remaining_before = ""
  345. if op_end <= change_end
  346. # |-- existing insert --|
  347. # |-- new delete --||.....| <- insert_remaining_after
  348. delete_remaining_after = ""
  349. insert_remaining_after = change.op.i.slice(op_end - change_start)
  350. else
  351. # |-- existing insert --||.....| <- delete_remaining_after
  352. # |-- new delete --|
  353. delete_remaining_after = op.d.slice(change_end - op_start)
  354. insert_remaining_after = ""
  355. insert_remaining = insert_remaining_before + insert_remaining_after
  356. if insert_remaining.length > 0
  357. change.op.i = insert_remaining
  358. change.op.p = Math.min(change_start, op_start)
  359. change.metadata.ts = metadata.ts
  360. moved_changes.push change
  361. else
  362. remove_changes.push change
  363. # We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
  364. # afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the
  365. # chunk in the middle not covered by these.
  366. delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length
  367. delete_removed_start = delete_remaining_before.length
  368. modification = {
  369. d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length)
  370. p: delete_removed_start
  371. }
  372. if modification.d.length > 0
  373. op_modifications.push modification
  374. else if change.op.d?
  375. change_start = change.op.p
  376. if op_end < change_start or (!@track_changes and op_end == change_start)
  377. # Shift ops after us back by our length.
  378. # If we're tracking changes, it must be strictly before, since we'll merge
  379. # below if they are touching. Otherwise, touching is fine.
  380. change.op.p -= op_length
  381. moved_changes.push change
  382. else if op_start <= change_start <= op_end
  383. if @track_changes
  384. # If we overlap a delete, add it in our content, and delete the existing change.
  385. # It's easier to do it this way, rather than modifying the existing delete in case
  386. # we overlap many deletes and we'd need to track that. We have a workaround to
  387. # update the delete in place if possible below.
  388. offset = change_start - op_start
  389. op_modifications.push { i: change.op.d, p: offset }
  390. remove_changes.push change
  391. else
  392. change.op.p = op_start
  393. moved_changes.push change
  394. # Copy rather than modify because we still need to apply it to comments
  395. op = {
  396. p: op.p
  397. d: @_applyOpModifications(op.d, op_modifications)
  398. }
  399. for change in remove_changes
  400. # This is a bit of hack to avoid removing one delete and replacing it with another.
  401. # If we don't do this, it causes the UI to flicker
  402. if op.d.length > 0 and change.op.d? and op.p <= change.op.p <= op.p + op.d.length
  403. change.op.p = op.p
  404. change.op.d = op.d
  405. change.metadata = metadata
  406. moved_changes.push change
  407. op.d = "" # stop it being added
  408. else
  409. @_removeChange change
  410. if @track_changes and op.d.length > 0
  411. @_addOp op, metadata
  412. else
  413. # It's possible that we deleted an insert between two other inserts. I.e.
  414. # If we delete 'user_2 insert' in:
  415. # |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
  416. # it becomes:
  417. # |-- user_1 insert --||-- user_1 insert --|
  418. # We need to merge these together again
  419. results = @_scanAndMergeAdjacentUpdates()
  420. moved_changes = moved_changes.concat(results.moved_changes)
  421. for change in results.remove_changes
  422. @_removeChange change
  423. moved_changes = moved_changes.filter (c) -> c != change
  424. for change in moved_changes
  425. @_markAsDirty change, "change", "moved"
  426. _addOp: (op, metadata) ->
  427. change = {
  428. id: @newId()
  429. op: @_clone(op) # Don't take a reference to the existing op since we'll modify this in place with future changes
  430. metadata: @_clone(metadata)
  431. }
  432. @changes.push change
  433. # Keep ops in order of offset, with deletes before inserts
  434. @changes.sort (c1, c2) ->
  435. result = c1.op.p - c2.op.p
  436. if result != 0
  437. return result
  438. else if c1.op.i? and c2.op.d?
  439. return 1
  440. else
  441. return -1
  442. @_markAsDirty(change, "change", "added")
  443. _removeChange: (change) ->
  444. @changes = @changes.filter (c) -> c.id != change.id
  445. @_markAsDirty change, "change", "removed"
  446. _applyOpModifications: (content, op_modifications) ->
  447. # Put in descending position order, with deleting first if at the same offset
  448. # (Inserting first would modify the content that the delete will delete)
  449. op_modifications.sort (a, b) ->
  450. result = b.p - a.p
  451. if result != 0
  452. return result
  453. else if a.i? and b.d?
  454. return 1
  455. else
  456. return -1
  457. for modification in op_modifications
  458. if modification.i?
  459. content = content.slice(0, modification.p) + modification.i + content.slice(modification.p)
  460. else if modification.d?
  461. if content.slice(modification.p, modification.p + modification.d.length) != modification.d
  462. throw new Error("deleted content does not match. content: #{JSON.stringify(content)}; modification: #{JSON.stringify(modification)}")
  463. content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length)
  464. return content
  465. _scanAndMergeAdjacentUpdates: () ->
  466. # This should only need calling when deleting an update between two
  467. # other updates. There's no other way to get two adjacent updates from the
  468. # same user, since they would be merged on insert.
  469. previous_change = null
  470. remove_changes = []
  471. moved_changes = []
  472. for change in @changes
  473. if previous_change?.op.i? and change.op.i?
  474. previous_change_end = previous_change.op.p + previous_change.op.i.length
  475. previous_change_user_id = previous_change.metadata.user_id
  476. change_start = change.op.p
  477. change_user_id = change.metadata.user_id
  478. if previous_change_end == change_start and previous_change_user_id == change_user_id
  479. remove_changes.push change
  480. previous_change.op.i += change.op.i
  481. moved_changes.push previous_change
  482. else if previous_change?.op.d? and change.op.d? and previous_change.op.p == change.op.p
  483. # Merge adjacent deletes
  484. previous_change.op.d += change.op.d
  485. remove_changes.push change
  486. moved_changes.push previous_change
  487. else # Only update to the current change if we haven't removed it.
  488. previous_change = change
  489. return { moved_changes, remove_changes }
  490. resetDirtyState: () ->
  491. @_dirtyState = {
  492. comment: {
  493. moved: {}
  494. removed: {}
  495. added: {}
  496. }
  497. change: {
  498. moved: {}
  499. removed: {}
  500. added: {}
  501. }
  502. }
  503. getDirtyState: () ->
  504. return @_dirtyState
  505. _markAsDirty: (object, type, action) ->
  506. @_dirtyState[type][action][object.id] = object
  507. _clone: (object) ->
  508. clone = {}
  509. (clone[k] = v for k,v of object)
  510. return clone
  511. if define?
  512. define [], load
  513. else
  514. module.exports = load()