فهرست منبع

Merge pull request #19 from sharelatex/ja-track-changes

Don't consume partial parts of delete when inserting a change
James Allen 9 سال پیش
والد
کامیت
9cfe651930

+ 49 - 29
services/document-updater/app/coffee/RangesTracker.coffee

@@ -1,5 +1,5 @@
-load = (EventEmitter) ->
-	class RangesTracker extends EventEmitter
+load = () ->
+	class RangesTracker
 		# The purpose of this class is to track a set of inserts and deletes to a document, like
 		# The purpose of this class is to track a set of inserts and deletes to a document, like
 		# track changes in Word. We store these as a set of ShareJs style ranges:
 		# track changes in Word. We store these as a set of ShareJs style ranges:
 		#   {i: "foo", p: 42} # Insert 'foo' at offset 42
 		#   {i: "foo", p: 42} # Insert 'foo' at offset 42
@@ -36,6 +36,7 @@ load = (EventEmitter) ->
 		#     middle of a previous insert by the first user, the original insert will be split into two.
 		#     middle of a previous insert by the first user, the original insert will be split into two.
 		constructor: (@changes = [], @comments = []) ->
 		constructor: (@changes = [], @comments = []) ->
 			@setIdSeed(RangesTracker.generateIdSeed())
 			@setIdSeed(RangesTracker.generateIdSeed())
+			@resetDirtyState()
 
 
 		getIdSeed: () ->
 		getIdSeed: () ->
 			return @id_seed
 			return @id_seed
@@ -75,7 +76,7 @@ load = (EventEmitter) ->
 			comment = @getComment(comment_id)
 			comment = @getComment(comment_id)
 			return if !comment?
 			return if !comment?
 			@comments = @comments.filter (c) -> c.id != comment_id
 			@comments = @comments.filter (c) -> c.id != comment_id
-			@emit "comment:removed", comment
+			@_markAsDirty comment, "comment", "removed"
 		
 		
 		getChange: (change_id) ->
 		getChange: (change_id) ->
 			change = null
 			change = null
@@ -103,7 +104,11 @@ load = (EventEmitter) ->
 				@addComment(op, metadata)
 				@addComment(op, metadata)
 			else
 			else
 				throw new Error("unknown op type")
 				throw new Error("unknown op type")
-			
+
+		applyOps: (ops, metadata = {}) ->
+			for op in ops
+				@applyOp(op, metadata)
+
 		addComment: (op, metadata) ->
 		addComment: (op, metadata) ->
 			# TODO: Don't allow overlapping comments?
 			# TODO: Don't allow overlapping comments?
 			@comments.push comment = {
 			@comments.push comment = {
@@ -114,18 +119,18 @@ load = (EventEmitter) ->
 					t: op.t
 					t: op.t
 				metadata
 				metadata
 			}
 			}
-			@emit "comment:added", comment
+			@_markAsDirty comment, "comment", "added"
 			return comment
 			return comment
 		
 		
 		applyInsertToComments: (op) ->
 		applyInsertToComments: (op) ->
 			for comment in @comments
 			for comment in @comments
 				if op.p <= comment.op.p
 				if op.p <= comment.op.p
 					comment.op.p += op.i.length
 					comment.op.p += op.i.length
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 				else if op.p < comment.op.p + comment.op.c.length
 				else if op.p < comment.op.p + comment.op.c.length
 					offset = op.p - comment.op.p
 					offset = op.p - comment.op.p
 					comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
 					comment.op.c = comment.op.c[0..(offset-1)] + op.i + comment.op.c[offset...]
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 
 
 		applyDeleteToComments: (op) ->
 		applyDeleteToComments: (op) ->
 			op_start = op.p
 			op_start = op.p
@@ -138,7 +143,7 @@ load = (EventEmitter) ->
 				if op_end <= comment_start
 				if op_end <= comment_start
 					# delete is fully before comment
 					# delete is fully before comment
 					comment.op.p -= op_length
 					comment.op.p -= op_length
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 				else if op_start >= comment_end
 				else if op_start >= comment_end
 					# delete is fully after comment, nothing to do
 					# delete is fully after comment, nothing to do
 				else
 				else
@@ -161,12 +166,13 @@ load = (EventEmitter) ->
 					
 					
 					comment.op.p = Math.min(comment_start, op_start)
 					comment.op.p = Math.min(comment_start, op_start)
 					comment.op.c = remaining_before + remaining_after
 					comment.op.c = remaining_before + remaining_after
-					@emit "comment:moved", comment
+					@_markAsDirty comment, "comment", "moved"
 
 
 		applyInsertToChanges: (op, metadata) ->
 		applyInsertToChanges: (op, metadata) ->
 			op_start = op.p
 			op_start = op.p
 			op_length = op.i.length
 			op_length = op.i.length
 			op_end = op.p + op_length
 			op_end = op.p + op_length
+			undoing = !!op.u
 
 
 
 
 			already_merged = false
 			already_merged = false
@@ -184,8 +190,9 @@ load = (EventEmitter) ->
 						change.op.p += op_length
 						change.op.p += op_length
 						moved_changes.push change
 						moved_changes.push change
 					else if op_start == change_start
 					else if op_start == change_start
-						# If the insert matches the start of the delete, just remove it from the delete instead
-						if change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
+						# If we are undoing, then we want to cancel any existing delete ranges if we can.
+						# Check if the insert matches the start of the delete, and just remove it from the delete instead if so.
+						if undoing and change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
 							change.op.d = change.op.d.slice(op.i.length)
 							change.op.d = change.op.d.slice(op.i.length)
 							change.op.p += op.i.length
 							change.op.p += op.i.length
 							if change.op.d == ""
 							if change.op.d == ""
@@ -203,15 +210,15 @@ load = (EventEmitter) ->
 					# Only merge inserts if they are from the same user
 					# Only merge inserts if they are from the same user
 					is_same_user = metadata.user_id == change.metadata.user_id
 					is_same_user = metadata.user_id == change.metadata.user_id
 					
 					
-					# If this is an insert op at the end of an existing insert with a delete following, and it cancels out the following
-					# delete then we shouldn't append it to this insert, but instead only cancel the following delete.
+					# If we are undoing, then our changes will be removed from any delete ops just after. In that case, if there is also
+					# an insert op just before, then we shouldn't append it to this insert, but instead only cancel the following delete.
 					# E.g.
 					# E.g.
 					#                   foo|<--- about to insert 'b' here
 					#                   foo|<--- about to insert 'b' here
 					#  inserted 'foo'  --^ ^-- deleted 'bar'
 					#  inserted 'foo'  --^ ^-- deleted 'bar'
 					# should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
 					# should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), .
 					next_change = @changes[i+1]
 					next_change = @changes[i+1]
 					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
 					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
-					will_op_cancel_next_delete = is_op_adjacent_to_next_delete and next_change.op.d.slice(0, op.i.length) == op.i
+					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
 					
 					
 					# If there is a delete at the start of the insert, and we're inserting
 					# If there is a delete at the start of the insert, and we're inserting
 					# at the start, we SHOULDN'T merge since the delete acts as a partition.
 					# at the start, we SHOULDN'T merge since the delete acts as a partition.
@@ -281,8 +288,8 @@ load = (EventEmitter) ->
 			for change in remove_changes
 			for change in remove_changes
 				@_removeChange change
 				@_removeChange change
 			
 			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
+			for change in moved_changes
+				@_markAsDirty change, "change", "moved"
 		
 		
 		applyDeleteToChanges: (op, metadata) ->
 		applyDeleteToChanges: (op, metadata) ->
 			op_start = op.p
 			op_start = op.p
@@ -406,8 +413,8 @@ load = (EventEmitter) ->
 					@_removeChange change
 					@_removeChange change
 					moved_changes = moved_changes.filter (c) -> c != change
 					moved_changes = moved_changes.filter (c) -> c != change
 			
 			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
+			for change in moved_changes
+				@_markAsDirty change, "change", "moved"
 
 
 		_addOp: (op, metadata) ->
 		_addOp: (op, metadata) ->
 			change = {
 			change = {
@@ -427,17 +434,11 @@ load = (EventEmitter) ->
 				else
 				else
 					return -1
 					return -1
 
 
-			if op.d?
-				@emit "delete:added", change
-			else if op.i?
-				@emit "insert:added", change
+			@_markAsDirty(change, "change", "added")
 		
 		
 		_removeChange: (change) ->
 		_removeChange: (change) ->
 			@changes = @changes.filter (c) -> c.id != change.id
 			@changes = @changes.filter (c) -> c.id != change.id
-			if change.op.d?
-				@emit "delete:removed", change
-			else if change.op.i?
-				@emit "insert:removed", change
+			@_markAsDirty change, "change", "removed"
 			
 			
 		_applyOpModifications: (content, op_modifications) ->
 		_applyOpModifications: (content, op_modifications) ->
 			# Put in descending position order, with deleting first if at the same offset 
 			# Put in descending position order, with deleting first if at the same offset 
@@ -486,13 +487,32 @@ load = (EventEmitter) ->
 					previous_change = change
 					previous_change = change
 			return { moved_changes, remove_changes }
 			return { moved_changes, remove_changes }
 		
 		
+		resetDirtyState: () ->
+			@_dirtyState = {
+				comment: {
+					moved: {}
+					removed: {}
+					added: {}
+				}
+				change: {
+					moved: {}
+					removed: {}
+					added: {}
+				}
+			}
+		
+		getDirtyState: () ->
+			return @_dirtyState
+		
+		_markAsDirty: (object, type, action) ->
+			@_dirtyState[type][action][object.id] = object
+		
 		_clone: (object) ->
 		_clone: (object) ->
 			clone = {}
 			clone = {}
 			(clone[k] = v for k,v of object)
 			(clone[k] = v for k,v of object)
 			return clone
 			return clone
 
 
 if define?
 if define?
-	define ["utils/EventEmitter"], load
+	define [], load
 else
 else
-	EventEmitter = require("events").EventEmitter
-	module.exports = load(EventEmitter)
+	module.exports = load()

+ 11 - 5
services/document-updater/app/coffee/sharejs/types/text-api.coffee

@@ -11,14 +11,20 @@ text.api =
   # Get the text contents of a document
   # Get the text contents of a document
   getText: -> @snapshot
   getText: -> @snapshot
 
 
-  insert: (pos, text, callback) ->
-    op = [{p:pos, i:text}]
+  insert: (pos, text, fromUndo, callback) ->
+    op = {p:pos, i:text}
+    if fromUndo
+      op.u = true
+    op = [op]
     
     
     @submitOp op, callback
     @submitOp op, callback
     op
     op
   
   
-  del: (pos, length, callback) ->
-    op = [{p:pos, d:@snapshot[pos...(pos + length)]}]
+  del: (pos, length, fromUndo, callback) ->
+    op = {p:pos, d:@snapshot[pos...(pos + length)]}
+    if fromUndo
+      op.u = true
+    op = [op]
 
 
     @submitOp op, callback
     @submitOp op, callback
     op
     op
@@ -28,5 +34,5 @@ text.api =
       for component in op
       for component in op
         if component.i != undefined
         if component.i != undefined
           @emit 'insert', component.p, component.i
           @emit 'insert', component.p, component.i
-        else
+        else if component.d != undefined
           @emit 'delete', component.p, component.d
           @emit 'delete', component.p, component.d

+ 20 - 13
services/document-updater/app/coffee/sharejs/types/text.coffee

@@ -56,6 +56,13 @@ text.apply = (snapshot, op) ->
       throw new Error "Unknown op type"
       throw new Error "Unknown op type"
   snapshot
   snapshot
 
 
+cloneAndModify = (op, modifications) ->
+  newOp = {}
+  for k,v of op
+    newOp[k] = v
+  for k,v of modifications
+    newOp[k] = v
+  return newOp
 
 
 # Exported for use by the random op generator.
 # Exported for use by the random op generator.
 #
 #
@@ -69,10 +76,10 @@ text._append = append = (newOp, c) ->
     last = newOp[newOp.length - 1]
     last = newOp[newOp.length - 1]
 
 
     # Compose the insert into the previous insert if possible
     # Compose the insert into the previous insert if possible
-    if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length)
-      newOp[newOp.length - 1] = {i:strInject(last.i, c.p - last.p, c.i), p:last.p}
-    else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length)
-      newOp[newOp.length - 1] = {d:strInject(c.d, last.p - c.p, last.d), p:c.p}
+    if last.i? && c.i? and last.p <= c.p <= (last.p + last.i.length) and last.u == c.u
+      newOp[newOp.length - 1] = cloneAndModify(last, {i:strInject(last.i, c.p - last.p, c.i)})
+    else if last.d? && c.d? and c.p <= last.p <= (c.p + c.d.length) and last.u == c.u
+      newOp[newOp.length - 1] = cloneAndModify(last, {d:strInject(c.d, last.p - c.p, last.d), p: c.p})
     else
     else
       newOp.push c
       newOp.push c
 
 
@@ -150,25 +157,25 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
   checkValidOp [otherC]
   checkValidOp [otherC]
 
 
   if c.i?
   if c.i?
-    append dest, {i:c.i, p:transformPosition(c.p, otherC, side == 'right')}
+    append dest, cloneAndModify(c, {p:transformPosition(c.p, otherC, side == 'right')})
 
 
   else if c.d? # Delete
   else if c.d? # Delete
     if otherC.i? # delete vs insert
     if otherC.i? # delete vs insert
       s = c.d
       s = c.d
       if c.p < otherC.p
       if c.p < otherC.p
-        append dest, {d:s[...otherC.p - c.p], p:c.p}
+        append dest, cloneAndModify(c, {d:s[...otherC.p - c.p]})
         s = s[(otherC.p - c.p)..]
         s = s[(otherC.p - c.p)..]
       if s != ''
       if s != ''
-        append dest, {d:s, p:c.p + otherC.i.length}
+        append dest, cloneAndModify(c, {d:s, p:c.p + otherC.i.length})
 
 
     else if otherC.d? # Delete vs delete
     else if otherC.d? # Delete vs delete
       if c.p >= otherC.p + otherC.d.length
       if c.p >= otherC.p + otherC.d.length
-        append dest, {d:c.d, p:c.p - otherC.d.length}
+        append dest, cloneAndModify(c, {p:c.p - otherC.d.length})
       else if c.p + c.d.length <= otherC.p
       else if c.p + c.d.length <= otherC.p
         append dest, c
         append dest, c
       else
       else
         # They overlap somewhere.
         # They overlap somewhere.
-        newC = {d:'', p:c.p}
+        newC = cloneAndModify(c, {d:''})
         if c.p < otherC.p
         if c.p < otherC.p
           newC.d = c.d[...(otherC.p - c.p)]
           newC.d = c.d[...(otherC.p - c.p)]
         if c.p + c.d.length > otherC.p + otherC.d.length
         if c.p + c.d.length > otherC.p + otherC.d.length
@@ -198,18 +205,18 @@ text._tc = transformComponent = (dest, c, otherC, side) ->
       if c.p < otherC.p < c.p + c.c.length
       if c.p < otherC.p < c.p + c.c.length
         offset = otherC.p - c.p
         offset = otherC.p - c.p
         new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...])
         new_c = (c.c[0..(offset-1)] + otherC.i + c.c[offset...])
-        append dest, {c:new_c, p:c.p, t: c.t}
+        append dest, cloneAndModify(c, {c:new_c})
       else
       else
-        append dest, {c:c.c, p:transformPosition(c.p, otherC, true), t: c.t}
+        append dest, cloneAndModify(c, {p:transformPosition(c.p, otherC, true)})
     
     
     else if otherC.d?
     else if otherC.d?
       if c.p >= otherC.p + otherC.d.length
       if c.p >= otherC.p + otherC.d.length
-        append dest, {c:c.c, p:c.p - otherC.d.length, t: c.t}
+        append dest, cloneAndModify(c, {p:c.p - otherC.d.length})
       else if c.p + c.c.length <= otherC.p
       else if c.p + c.c.length <= otherC.p
         append dest, c
         append dest, c
       else # Delete overlaps comment
       else # Delete overlaps comment
         # They overlap somewhere.
         # They overlap somewhere.
-        newC = {c:'', p:c.p, t: c.t}
+        newC = cloneAndModify(c, {c:''})
         if c.p < otherC.p
         if c.p < otherC.p
           newC.c = c.c[...(otherC.p - c.p)]
           newC.c = c.c[...(otherC.p - c.p)]
         if c.p + c.c.length > otherC.p + otherC.d.length
         if c.p + c.c.length > otherC.p + otherC.d.length

+ 51 - 2
services/document-updater/test/unit/coffee/ShareJS/TextTransformTests.coffee

@@ -27,6 +27,11 @@ describe "ShareJS text type", ->
 				dest = []
 				dest = []
 				text._tc(dest, { i: "foo", p: 3 }, { i: "bar", p: 3 }, 'left')
 				text._tc(dest, { i: "foo", p: 3 }, { i: "bar", p: 3 }, 'left')
 				dest.should.deep.equal [{ i: "foo", p: 3 }]
 				dest.should.deep.equal [{ i: "foo", p: 3 }]
+			
+			it "should preserve the undo flag", ->
+				dest = []
+				text._tc(dest, { i: "foo", p: 9, u: true }, { i: "bar", p: 3 })
+				dest.should.deep.equal [{ i: "foo", p: 12, u: true }]
 
 
 		describe "insert / delete", ->
 		describe "insert / delete", ->
 			it "with a delete before", ->
 			it "with a delete before", ->
@@ -46,9 +51,13 @@ describe "ShareJS text type", ->
 
 
 			it "with a delete at the same place with side == 'left'", ->
 			it "with a delete at the same place with side == 'left'", ->
 				dest = []
 				dest = []
-				
 				text._tc(dest, { i: "foo", p: 3 }, { d: "bar", p: 3 }, 'left')
 				text._tc(dest, { i: "foo", p: 3 }, { d: "bar", p: 3 }, 'left')
 				dest.should.deep.equal [{ i: "foo", p: 3 }]
 				dest.should.deep.equal [{ i: "foo", p: 3 }]
+			
+			it "should preserve the undo flag", ->
+				dest = []
+				text._tc(dest, { i: "foo", p: 9, u: true }, { d: "bar", p: 3 })
+				dest.should.deep.equal [{ i: "foo", p: 6, u: true }]
 
 
 		describe "delete / insert", ->
 		describe "delete / insert", ->
 			it "with an insert before", ->
 			it "with an insert before", ->
@@ -75,7 +84,11 @@ describe "ShareJS text type", ->
 				dest = []
 				dest = []
 				text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 4 })
 				text._tc(dest, { d: "foo", p: 3 }, { i: "bar", p: 4 })
 				dest.should.deep.equal [{ d: "f", p: 3 }, { d: "oo", p: 6 }]
 				dest.should.deep.equal [{ d: "f", p: 3 }, { d: "oo", p: 6 }]
-				
+			
+			it "should preserve the undo flag", ->
+				dest = []
+				text._tc(dest, { d: "foo", p: 9, u: true }, { i: "bar", p: 3 })
+				dest.should.deep.equal [{ d: "foo", p: 12, u: true }]
 
 
 		describe "delete / delete", ->
 		describe "delete / delete", ->
 			it "with a delete before", ->
 			it "with a delete before", ->
@@ -112,6 +125,11 @@ describe "ShareJS text type", ->
 				dest = []
 				dest = []
 				text._tc(dest, { d: "foo", p: 6 }, { d: "abcfoo123", p: 3 })
 				text._tc(dest, { d: "foo", p: 6 }, { d: "abcfoo123", p: 3 })
 				dest.should.deep.equal []
 				dest.should.deep.equal []
+			
+			it "should preserve the undo flag", ->
+				dest = []
+				text._tc(dest, { d: "foo", p: 9, u: true }, { d: "bar", p: 3 })
+				dest.should.deep.equal [{ d: "foo", p: 6, u: true }]
 	
 	
 		describe "comment / insert", ->
 		describe "comment / insert", ->
 			it "with an insert before", ->
 			it "with an insert before", ->
@@ -210,6 +228,37 @@ describe "ShareJS text type", ->
 				text.apply("foo123bar", [{ c: "456", p: 3 }])
 				text.apply("foo123bar", [{ c: "456", p: 3 }])
 			).should.throw(Error)
 			).should.throw(Error)
 	
 	
+	describe "_append", ->
+		it "should combine adjacent inserts", ->
+			dest = [{ i: "foo", p: 3 }]
+			text._append dest, { i: "bar", p: 6 }
+			dest.should.deep.equal [{ i: "foobar", p: 3 }]
+
+		it "should combine adjacent undo inserts", ->
+			dest = [{ i: "foo", p: 3, u: true }]
+			text._append dest, { i: "bar", p: 6, u: true }
+			dest.should.deep.equal [{ i: "foobar", p: 3, u: true }]
+
+		it "should not combine an undo and a normal insert", ->
+			dest = [{ i: "foo", p: 3, u: true }]
+			text._append dest, { i: "bar", p: 6 }
+			dest.should.deep.equal [{ i: "foo", p: 3, u: true }, { i: "bar", p: 6 }]
+	
+		it "should combine adjacent deletes", ->
+			dest = [{ d: "bar", p: 6 }]
+			text._append dest, { d: "foobaz", p: 3 }
+			dest.should.deep.equal [{ d: "foobarbaz", p: 3 }]
+
+		it "should combine adjacent undo deletes", ->
+			dest = [{ d: "foo", p: 3, u: true }]
+			text._append dest, { d: "bar", p: 3, u: true }
+			dest.should.deep.equal [{ d: "foobar", p: 3, u: true }]
+
+		it "should not combine an undo and a normal insert", ->
+			dest = [{ d: "foo", p: 3, u: true }]
+			text._append dest, { d: "bar", p: 3 }
+			dest.should.deep.equal [{ d: "foo", p: 3, u: true }, { d: "bar", p: 3 }]
+	
 	describe "applying ops and comments in different orders", ->
 	describe "applying ops and comments in different orders", ->
 		it "should not matter which op or comment is applied first", ->
 		it "should not matter which op or comment is applied first", ->
 			transform = (op1, op2, side) ->
 			transform = (op1, op2, side) ->