ChangesTracker.coffee 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. load = (EventEmitter) ->
  2. class ChangesTracker extends EventEmitter
  3. # The purpose of this class is to track a set of inserts and deletes to a document, like
  4. # track changes in Word. We store these as a set of ShareJs style ranges:
  5. # {i: "foo", p: 42} # Insert 'foo' at offset 42
  6. # {d: "bar", p: 37} # Delete 'bar' at offset 37
  7. # We only track the inserts and deletes, not the whole document, but by being given all
  8. # updates that are applied to a document, we can update these appropriately.
  9. #
  10. # Note that the set of inserts and deletes we store applies to the document as-is at the moment.
  11. # So inserts correspond to text which is in the document, while deletes correspond to text which
  12. # is no longer there, so their lengths do not affect the position of later offsets.
  13. # E.g.
  14. # this is the current text of the document
  15. # |-----| |
  16. # {i: "current ", p:12} -^ ^- {d: "old ", p: 31}
  17. #
  18. # Track changes rules (should be consistent with Word):
  19. # * When text is inserted at a delete, the text goes to the left of the delete
  20. # I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
  21. # * Deleting content flagged as 'inserted' does not create a new delete marker, it only
  22. # removes the insert marker. E.g.
  23. # * "abdefghijkl" -> "abfghijkl" when 'de' is deleted. No delete marker added
  24. # |---| <- inserted |-| <- inserted
  25. # * Deletes overlapping regular text and inserted text will insert a delete marker for the
  26. # regular text:
  27. # "abcdefghijkl" -> "abcdejkl" when 'fghi' is deleted
  28. # |----| |--||
  29. # ^- inserted 'bcdefg' \ ^- deleted 'hi'
  30. # \--inserted 'bcde'
  31. # * Deletes overlapping other deletes are merged. E.g.
  32. # "abcghijkl" -> "ahijkl" when 'bcg is deleted'
  33. # | <- delete 'def' | <- delete 'bcdefg'
  34. # * Deletes by another user will consume deletes by the first user
  35. # * Inserts by another user will not combine with inserts by the first user. If they are in the
  36. # middle of a previous insert by the first user, the original insert will be split into two.
  37. constructor: (@changes = [], @comments = []) ->
  38. # Change objects have the following structure:
  39. # {
  40. # id: ... # Uniquely generated by us
  41. # op: { # ShareJs style op tracking the offset (p) and content inserted (i) or deleted (d)
  42. # i: "..."
  43. # p: 42
  44. # }
  45. # }
  46. #
  47. # Ids are used to uniquely identify a change, e.g. for updating it in the database, or keeping in
  48. # sync with Ace ranges.
  49. @id = 0
  50. addComment: (offset, length, metadata) ->
  51. # TODO: Don't allow overlapping comments?
  52. @comments.push comment = {
  53. id: @_newId()
  54. offset, length, metadata
  55. }
  56. @emit "comment:added", comment
  57. return comment
  58. getComment: (comment_id) ->
  59. comment = null
  60. for c in @comments
  61. if c.id == comment_id
  62. comment = c
  63. break
  64. return comment
  65. resolveCommentId: (comment_id, resolved_data) ->
  66. comment = @getComment(comment_id)
  67. return if !comment?
  68. comment.metadata.resolved = true
  69. comment.metadata.resolved_data = resolved_data
  70. @emit "comment:resolved", comment
  71. unresolveCommentId: (comment_id) ->
  72. comment = @getComment(comment_id)
  73. return if !comment?
  74. comment.metadata.resolved = false
  75. @emit "comment:unresolved", comment
  76. removeCommentId: (comment_id) ->
  77. comment = @getComment(comment_id)
  78. return if !comment?
  79. @comments = @comments.filter (c) -> c.id != comment_id
  80. @emit "comment:removed", comment
  81. getChange: (change_id) ->
  82. change = null
  83. for c in @changes
  84. if c.id == change_id
  85. change = c
  86. break
  87. return change
  88. removeChangeId: (change_id) ->
  89. change = @getChange(change_id)
  90. return if !change?
  91. @_removeChange(change)
  92. applyOp: (op, metadata = {}) ->
  93. metadata.ts ?= new Date()
  94. # Apply an op that has been applied to the document to our changes to keep them up to date
  95. if op.i?
  96. @applyInsertToChanges(op, metadata)
  97. @applyInsertToComments(op)
  98. else if op.d?
  99. @applyDeleteToChanges(op, metadata)
  100. @applyDeleteToComments(op)
  101. applyInsertToComments: (op) ->
  102. for comment in @comments
  103. if op.p <= comment.offset
  104. comment.offset += op.i.length
  105. @emit "comment:moved", comment
  106. else if op.p < comment.offset + comment.length
  107. comment.length += op.i.length
  108. @emit "comment:moved", comment
  109. applyDeleteToComments: (op) ->
  110. op_start = op.p
  111. op_length = op.d.length
  112. op_end = op.p + op_length
  113. for comment in @comments
  114. comment_end = comment.offset + comment.length
  115. if op_end <= comment.offset
  116. # delete is fully before comment
  117. comment.offset -= op_length
  118. @emit "comment:moved", comment
  119. else if op_start >= comment_end
  120. # delete is fully after comment, nothing to do
  121. else
  122. # delete and comment overlap
  123. delete_length_before = Math.max(0, comment.offset - op_start)
  124. delete_length_after = Math.max(0, op_end - comment_end)
  125. delete_length_overlapping = op_length - delete_length_before - delete_length_after
  126. comment.offset = Math.min(comment.offset, op_start)
  127. comment.length -= delete_length_overlapping
  128. @emit "comment:moved", comment
  129. applyInsertToChanges: (op, metadata) ->
  130. op_start = op.p
  131. op_length = op.i.length
  132. op_end = op.p + op_length
  133. already_merged = false
  134. previous_change = null
  135. moved_changes = []
  136. remove_changes = []
  137. new_changes = []
  138. for change in @changes
  139. change_start = change.op.p
  140. if change.op.d?
  141. # Shift any deletes after this along by the length of this insert
  142. if op_start < change_start
  143. change.op.p += op_length
  144. moved_changes.push change
  145. else if op_start == change_start
  146. # If the insert matches the start of the delete, just remove it from the delete instead
  147. if change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
  148. change.op.d = change.op.d.slice(op.i.length)
  149. change.op.p += op.i.length
  150. if change.op.d == ""
  151. remove_changes.push change
  152. else
  153. moved_changes.push change
  154. already_merged = true
  155. else
  156. change.op.p += op_length
  157. moved_changes.push change
  158. else if change.op.i?
  159. change_end = change_start + change.op.i.length
  160. is_change_overlapping = (op_start >= change_start and op_start <= change_end)
  161. # Only merge inserts if they are from the same user
  162. is_same_user = metadata.user_id == change.metadata.user_id
  163. # If there is a delete at the start of the insert, and we're inserting
  164. # at the start, we SHOULDN'T merge since the delete acts as a partition.
  165. # The previous op will be the delete, but it's already been shifted by this insert
  166. #
  167. # I.e.
  168. # Originally: |-- existing insert --|
  169. # | <- existing delete at same offset
  170. #
  171. # Now: |-- existing insert --| <- not shifted yet
  172. # |-- this insert --|| <- existing delete shifted along to end of this op
  173. #
  174. # After: |-- existing insert --|
  175. # |-- this insert --|| <- existing delete
  176. #
  177. # Without the delete, the inserts would be merged.
  178. is_insert_blocked_by_delete = (previous_change? and previous_change.op.d? and previous_change.op.p == op_end)
  179. # If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
  180. # then we merge them into one.
  181. if @track_changes and
  182. is_change_overlapping and
  183. !is_insert_blocked_by_delete and
  184. !already_merged and
  185. is_same_user
  186. offset = op_start - change_start
  187. change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
  188. change.metadata.ts = metadata.ts
  189. already_merged = true
  190. moved_changes.push change
  191. else if op_start <= change_start
  192. # If we're fully before the other insert we can just shift the other insert by our length.
  193. # If they are touching, and should have been merged, they will have been above.
  194. # 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
  195. change.op.p += op_length
  196. moved_changes.push change
  197. else if (!is_same_user or !@track_changes) and change_start < op_start < change_end
  198. # This user is inserting inside a change by another user, so we need to split the
  199. # other user's change into one before and after this one.
  200. offset = op_start - change_start
  201. before_content = change.op.i.slice(0, offset)
  202. after_content = change.op.i.slice(offset)
  203. # The existing change can become the 'before' change
  204. change.op.i = before_content
  205. moved_changes.push change
  206. # Create a new op afterwards
  207. after_change = {
  208. op: {
  209. i: after_content
  210. p: change_start + offset + op_length
  211. }
  212. metadata: {}
  213. }
  214. after_change.metadata[key] = value for key, value of change.metadata
  215. new_changes.push after_change
  216. previous_change = change
  217. if @track_changes and !already_merged
  218. @_addOp op, metadata
  219. for {op, metadata} in new_changes
  220. @_addOp op, metadata
  221. for change in remove_changes
  222. @_removeChange change
  223. if moved_changes.length > 0
  224. @emit "changes:moved", moved_changes
  225. applyDeleteToChanges: (op, metadata) ->
  226. op_start = op.p
  227. op_length = op.d.length
  228. op_end = op.p + op_length
  229. remove_changes = []
  230. moved_changes = []
  231. # We might end up modifying our delete op if it merges with existing deletes, or cancels out
  232. # with an existing insert. Since we might do multiple modifications, we record them and do
  233. # all the modifications after looping through the existing changes, so as not to mess up the
  234. # offset indexes as we go.
  235. op_modifications = []
  236. for change in @changes
  237. if change.op.i?
  238. change_start = change.op.p
  239. change_end = change_start + change.op.i.length
  240. if op_end <= change_start
  241. # Shift ops after us back by our length
  242. change.op.p -= op_length
  243. moved_changes.push change
  244. else if op_start >= change_end
  245. # Delete is after insert, nothing to do
  246. else
  247. # When the new delete overlaps an insert, we should remove the part of the insert that
  248. # is now deleted, and also remove the part of the new delete that overlapped. I.e.
  249. # the two cancel out where they overlap.
  250. if op_start >= change_start
  251. # |-- existing insert --|
  252. # insert_remaining_before -> |.....||-- new delete --|
  253. delete_remaining_before = ""
  254. insert_remaining_before = change.op.i.slice(0, op_start - change_start)
  255. else
  256. # delete_remaining_before -> |.....||-- existing insert --|
  257. # |-- new delete --|
  258. delete_remaining_before = op.d.slice(0, change_start - op_start)
  259. insert_remaining_before = ""
  260. if op_end <= change_end
  261. # |-- existing insert --|
  262. # |-- new delete --||.....| <- insert_remaining_after
  263. delete_remaining_after = ""
  264. insert_remaining_after = change.op.i.slice(op_end - change_start)
  265. else
  266. # |-- existing insert --||.....| <- delete_remaining_after
  267. # |-- new delete --|
  268. delete_remaining_after = op.d.slice(change_end - op_start)
  269. insert_remaining_after = ""
  270. insert_remaining = insert_remaining_before + insert_remaining_after
  271. if insert_remaining.length > 0
  272. change.op.i = insert_remaining
  273. change.op.p = Math.min(change_start, op_start)
  274. change.metadata.ts = metadata.ts
  275. moved_changes.push change
  276. else
  277. remove_changes.push change
  278. # We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
  279. # afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the
  280. # chunk in the middle not covered by these.
  281. delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length
  282. delete_removed_start = delete_remaining_before.length
  283. modification = {
  284. d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length)
  285. p: delete_removed_start
  286. }
  287. if modification.d.length > 0
  288. op_modifications.push modification
  289. else if change.op.d?
  290. change_start = change.op.p
  291. if op_end < change_start or (!@track_changes and op_end == change_start)
  292. # Shift ops after us back by our length.
  293. # If we're tracking changes, it must be strictly before, since we'll merge
  294. # below if they are touching. Otherwise, touching is fine.
  295. change.op.p -= op_length
  296. moved_changes.push change
  297. else if op_start <= change_start <= op_end
  298. if @track_changes
  299. # If we overlap a delete, add it in our content, and delete the existing change.
  300. # It's easier to do it this way, rather than modifying the existing delete in case
  301. # we overlap many deletes and we'd need to track that. We have a workaround to
  302. # update the delete in place if possible below.
  303. offset = change_start - op_start
  304. op_modifications.push { i: change.op.d, p: offset }
  305. remove_changes.push change
  306. else
  307. change.op.p = op_start
  308. moved_changes.push change
  309. # Copy rather than modify because we still need to apply it to comments
  310. op = {
  311. p: op.p
  312. d: @_applyOpModifications(op.d, op_modifications)
  313. }
  314. for change in remove_changes
  315. # This is a bit of hack to avoid removing one delete and replacing it with another.
  316. # If we don't do this, it causes the UI to flicker
  317. if op.d.length > 0 and change.op.d? and op.p <= change.op.p <= op.p + op.d.length
  318. change.op.p = op.p
  319. change.op.d = op.d
  320. change.metadata = metadata
  321. moved_changes.push change
  322. op.d = "" # stop it being added
  323. else
  324. @_removeChange change
  325. if @track_changes and op.d.length > 0
  326. @_addOp op, metadata
  327. else
  328. # It's possible that we deleted an insert between two other inserts. I.e.
  329. # If we delete 'user_2 insert' in:
  330. # |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
  331. # it becomes:
  332. # |-- user_1 insert --||-- user_1 insert --|
  333. # We need to merge these together again
  334. results = @_scanAndMergeAdjacentUpdates()
  335. moved_changes = moved_changes.concat(results.moved_changes)
  336. for change in results.remove_changes
  337. @_removeChange change
  338. moved_changes = moved_changes.filter (c) -> c != change
  339. if moved_changes.length > 0
  340. @emit "changes:moved", moved_changes
  341. _newId: () ->
  342. (@id++).toString()
  343. _addOp: (op, metadata) ->
  344. change = {
  345. id: @_newId()
  346. op: op
  347. metadata: metadata
  348. }
  349. @changes.push change
  350. # Keep ops in order of offset, with deletes before inserts
  351. @changes.sort (c1, c2) ->
  352. result = c1.op.p - c2.op.p
  353. if result != 0
  354. return result
  355. else if c1.op.i? and c2.op.d?
  356. return 1
  357. else
  358. return -1
  359. if op.d?
  360. @emit "delete:added", change
  361. else if op.i?
  362. @emit "insert:added", change
  363. _removeChange: (change) ->
  364. @changes = @changes.filter (c) -> c.id != change.id
  365. if change.op.d?
  366. @emit "delete:removed", change
  367. else if change.op.i?
  368. @emit "insert:removed", change
  369. _applyOpModifications: (content, op_modifications) ->
  370. # Put in descending position order, with deleting first if at the same offset
  371. # (Inserting first would modify the content that the delete will delete)
  372. op_modifications.sort (a, b) ->
  373. result = b.p - a.p
  374. if result != 0
  375. return result
  376. else if a.i? and b.d?
  377. return 1
  378. else
  379. return -1
  380. for modification in op_modifications
  381. if modification.i?
  382. content = content.slice(0, modification.p) + modification.i + content.slice(modification.p)
  383. else if modification.d?
  384. if content.slice(modification.p, modification.p + modification.d.length) != modification.d
  385. throw new Error("deleted content does not match. content: #{JSON.stringify(content)}; modification: #{JSON.stringify(modification)}")
  386. content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length)
  387. return content
  388. _scanAndMergeAdjacentUpdates: () ->
  389. # This should only need calling when deleting an update between two
  390. # other updates. There's no other way to get two adjacent updates from the
  391. # same user, since they would be merged on insert.
  392. previous_change = null
  393. remove_changes = []
  394. moved_changes = []
  395. for change in @changes
  396. if previous_change?.op.i? and change.op.i?
  397. previous_change_end = previous_change.op.p + previous_change.op.i.length
  398. previous_change_user_id = previous_change.metadata.user_id
  399. change_start = change.op.p
  400. change_user_id = change.metadata.user_id
  401. if previous_change_end == change_start and previous_change_user_id == change_user_id
  402. remove_changes.push change
  403. previous_change.op.i += change.op.i
  404. moved_changes.push previous_change
  405. else if previous_change?.op.d? and change.op.d? and previous_change.op.p == change.op.p
  406. # Merge adjacent deletes
  407. previous_change.op.d += change.op.d
  408. remove_changes.push change
  409. moved_changes.push previous_change
  410. else # Only update to the current change if we haven't removed it.
  411. previous_change = change
  412. return { moved_changes, remove_changes }
  413. if define?
  414. define ["utils/EventEmitter"], load
  415. else
  416. EventEmitter = require("events").EventEmitter
  417. module.exports = load(EventEmitter)