Просмотр исходного кода

Show different users changes in different colours

James Allen 10 лет назад
Родитель
Сommit
52f3fe7303

+ 3 - 2
services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee

@@ -1,7 +1,8 @@
 define [
 define [
 	"base"
 	"base"
-], (App) ->
+	"ide/colors/ColorManager"
+], (App, ColorManager) ->
 	App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
 	App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
 		$scope.hue = (user) ->
 		$scope.hue = (user) ->
-			ide.onlineUsersManager.getHueForUserId(user.id)
+			ColorManager.getHueForUserId(user.id)
 	]
 	]

+ 44 - 0
services/web/public/coffee/ide/colors/ColorManager.coffee

@@ -0,0 +1,44 @@
+define [], () ->
+	ColorManager =
+		getColorScheme: (hue, element) ->
+			if @isDarkTheme(element)
+				return {
+					cursor: "hsl(#{hue}, 70%, 50%)"
+					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
+					highlightBackgroundColor: "hsl(#{hue}, 100%, 28%);"
+					strikeThroughBackgroundColor: "hsl(#{hue}, 100%, 20%);"
+					strikeThroughForegroundColor: "hsl(#{hue}, 100%, 60%);"
+				}
+			else
+				return {
+					cursor: "hsl(#{hue}, 70%, 50%)"
+					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
+					highlightBackgroundColor: "hsl(#{hue}, 70%, 85%);"
+					strikeThroughBackgroundColor: "hsl(#{hue}, 70%, 95%);"
+					strikeThroughForegroundColor: "hsl(#{hue}, 70%, 40%);"
+				}
+
+		isDarkTheme: (element) ->
+			rgb = element.find(".ace_editor").css("background-color");
+			[m, r, g, b] = rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
+			r = parseInt(r, 10)
+			g = parseInt(g, 10)
+			b = parseInt(b, 10)
+			return r + g + b < 3 * 128
+		
+		OWN_HUE: 200 # We will always appear as this color to ourselves
+		ANONYMOUS_HUE: 100
+		getHueForUserId: (user_id) ->
+			if !user_id? or user_id == "anonymous-user"
+				return @ANONYMOUS_HUE
+
+			if window.user.id == user_id
+				return @OWN_HUE
+
+			hash = CryptoJS.MD5(user_id)
+			hue = parseInt(hash.toString().slice(0,8), 16) % 320
+			# Avoid 20 degrees either side of the personal hue
+			if hue > @OWNER_HUE - 20
+				hue = hue + 40
+			return hue
+		

+ 3 - 28
services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee

@@ -1,6 +1,7 @@
 define [
 define [
 	"ace/ace"
 	"ace/ace"
-], () ->
+	"ide/colors/ColorManager"
+], (_, ColorManager) ->
 	Range = ace.require("ace/range").Range
 	Range = ace.require("ace/range").Range
 
 
 	class HighlightsManager
 	class HighlightsManager
@@ -64,7 +65,7 @@ define [
 
 
 			for annotation in @$scope.highlights or []
 			for annotation in @$scope.highlights or []
 				do (annotation) =>
 				do (annotation) =>
-					colorScheme = @_getColorScheme(annotation.hue)
+					colorScheme = ColorManager.getColorScheme(annotation.hue, @element)
 					if annotation.cursor?
 					if annotation.cursor?
 						@labels.push {
 						@labels.push {
 							text: annotation.label
 							text: annotation.label
@@ -262,29 +263,3 @@ define [
 				else
 				else
 					markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
 					markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
 			, foreground
 			, foreground
-
-		_getColorScheme: (hue) ->
-			if @_isDarkTheme()
-				return {
-					cursor: "hsl(#{hue}, 70%, 50%)"
-					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
-					highlightBackgroundColor: "hsl(#{hue}, 100%, 28%);"
-					strikeThroughBackgroundColor: "hsl(#{hue}, 100%, 20%);"
-					strikeThroughForegroundColor: "hsl(#{hue}, 100%, 60%);"
-				}
-			else
-				return {
-					cursor: "hsl(#{hue}, 70%, 50%)"
-					labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
-					highlightBackgroundColor: "hsl(#{hue}, 70%, 85%);"
-					strikeThroughBackgroundColor: "hsl(#{hue}, 70%, 95%);"
-					strikeThroughForegroundColor: "hsl(#{hue}, 70%, 40%);"
-				}
-
-		_isDarkTheme: () ->
-			rgb = @element.find(".ace_editor").css("background-color");
-			[m, r, g, b] = rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
-			r = parseInt(r, 10)
-			g = parseInt(g, 10)
-			b = parseInt(b, 10)
-			return r + g + b < 3 * 128

+ 322 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/ChangesTracker.coffee

@@ -0,0 +1,322 @@
+define [
+	"utils/EventEmitter"
+], (EventEmitter) ->
+	class ChangesTracker extends EventEmitter
+		# 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:
+		#   {i: "foo", p: 42} # Insert 'foo' at offset 42
+		#   {d: "bar", p: 37} # Delete 'bar' at offset 37
+		# We only track the inserts and deletes, not the whole document, but by being given all
+		# updates that are applied to a document, we can update these appropriately.
+		# 
+		# Note that the set of inserts and deletes we store applies to the document as-is at the moment.
+		# So inserts correspond to text which is in the document, while deletes correspond to text which
+		# is no longer there, so their lengths do not affect the position of later offsets.
+		# E.g.
+		#             this is the current text of the document
+		#                         |-----|            |
+		#  {i: "current ", p:12} -^                   ^- {d: "old ", p: 31}
+		#
+		# Track changes rules (should be consistent with Word):
+		#   * When text is inserted at a delete, the text goes to the left of the delete
+		#       I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
+		#   * Deleting content flagged as 'inserted' does not create a new delete marker, it only
+		#     removes the insert marker. E.g.
+		#       * "abdefghijkl"        -> "abfghijkl"        when 'de' is deleted. No delete marker added
+		#           |---| <- inserted       |-| <- inserted
+		#       * Deletes overlapping regular text and inserted text will insert a delete marker for the
+		#         regular text:
+		#         "abcdefghijkl"    ->    "abcdejkl"   when 'fghi' is deleted
+		#           |----|                  |--||
+		#           ^- inserted 'bcdefg'      \ ^- deleted 'hi'
+		#                                      \--inserted 'bcde'
+		#   * Deletes overlapping other deletes are merged. E.g.
+		#      "abcghijkl"        ->   "ahijkl"     when 'bcg is deleted'
+		#          | <- delete 'def'     | <- delete 'bcdefg'
+		#   * Deletes by another user will consume deletes by the first user
+		#   * Inserts by another user will not combine with inserts by the first user. If they are in the
+		#     middle of a previous insert by the first user, the original insert will be split into two.
+		constructor: () ->
+			# Change objects have the following structure:
+			# {
+			#   id: ... # Uniquely generated by us
+			#   op: { # ShareJs style op tracking the offset (p) and content inserted (i) or deleted (d)
+			#     i: "..."
+			#     p: 42
+			#   }
+			# }
+			#
+			# Ids are used to uniquely identify a change, e.g. for updating it in the database, or keeping in
+			# sync with Ace ranges.
+			@changes = []
+			@id = 0
+		
+		applyOp: (op, metadata) ->
+			# Apply an op that has been applied to the document to our changes to keep them up to date
+			if op.i?
+				@applyInsert(op, metadata)
+			else if op.d?
+				@applyDelete(op, metadata)
+			
+		applyInsert: (op, metadata) ->
+			op_start = op.p
+			op_length = op.i.length
+			op_end = op.p + op_length
+
+			already_merged = false
+			previous_change = null
+			moved_changes = []
+			new_changes = []
+			for change in @changes
+				change_start = change.op.p
+				
+				if change.op.d?
+					# Shift any deletes after this along by the length of this insert
+					if op_start <= change_start
+						change.op.p += op_length
+						moved_changes.push change
+				else if change.op.i?
+					change_end = change_start + change.op.i.length
+					is_change_overlapping = (op_start >= change_start and op_start <= change_end)
+					
+					# Only merge inserts if they are from the same user
+					is_same_user = metadata.user_id == change.metadata.user_id
+					
+					# 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.
+					# The previous op will be the delete, but it's already been shifted by this insert
+					#
+					# I.e.
+					# Originally: |-- existing insert --|
+					#             | <- existing delete at same offset
+					#
+					# Now:        |-- existing insert --| <- not shifted yet
+					#             |-- this insert --|| <- existing delete shifted along to end of this op
+					# 
+					# After:                         |-- existing insert --|  
+					#             |-- this insert --|| <- existing delete
+					# 
+					# Without the delete, the inserts would be merged.
+					is_insert_blocked_by_delete = (previous_change? and previous_change.op.d? and previous_change.op.p == op_end)
+
+					# If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
+					# then we merge them into one.
+					if is_change_overlapping and
+							!is_insert_blocked_by_delete and
+							!already_merged and 
+							is_same_user
+						offset = op_start - change_start
+						change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
+						already_merged = true
+						moved_changes.push change
+					else if op_start <= change_start
+						# If we're fully before the other insert we can just shift the other insert by our length.
+						# If they are touching, and should have been merged, they will have been above.
+						# 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
+						change.op.p += op_length
+						moved_changes.push change
+					else if !is_same_user and change_start < op_start < change_end
+						# This user is inserting inside a change by another user, so we need to split the
+						# other user's change into one before and after this one.
+						offset = op_start - change_start
+						before_content = change.op.i.slice(0, offset)
+						after_content = change.op.i.slice(offset)
+						
+						# The existing change can become the 'before' change
+						change.op.i = before_content
+						moved_changes.push change
+						
+						# Create a new op afterwards
+						after_change = {
+							op: {
+								i: after_content
+								p: change_start + offset + op_length
+							}
+							metadata: {}
+						}
+						after_change.metadata[key] = value for key, value of change.metadata
+						new_changes.push after_change
+						
+				previous_change = change
+
+			if !already_merged
+				@_addOp op, metadata
+			for {op, metadata} in new_changes
+				@_addOp op, metadata
+			
+			if moved_changes.length > 0
+				@emit "changes:moved", moved_changes
+		
+		applyDelete: (op, metadata) ->
+			op_start = op.p
+			op_length = op.d.length
+			op_end = op.p + op_length
+			remove_changes = []
+			moved_changes = []
+			
+			# We might end up modifying our delete op if it merges with existing deletes, or cancels out
+			# with an existing insert. Since we might do multiple modifications, we record them and do
+			# all the modifications after looping through the existing changes, so as not to mess up the
+			# offset indexes as we go.
+			op_modifications = []
+			for change in @changes
+				if change.op.i?
+					change_start = change.op.p
+					change_end = change_start + change.op.i.length
+					if op_end <= change_start
+						# Shift ops after us back by our length
+						change.op.p -= op_length
+						moved_changes.push change
+					else if op_start >= change_end
+						# Delete is after insert, nothing to do
+					else
+						# When the new delete overlaps an insert, we should remove the part of the insert that
+						# is now deleted, and also remove the part of the new delete that overlapped. I.e.
+						# the two cancel out where they overlap.
+						if op_start >= change_start
+							#                            |-- existing insert --|
+							# insert_remaining_before -> |.....||--   new delete   --|
+							delete_remaining_before = ""
+							insert_remaining_before = change.op.i.slice(0, op_start - change_start)
+						else
+							# delete_remaining_before -> |.....||-- existing insert --|
+							#                            |-- new delete   --|
+							delete_remaining_before = op.d.slice(0, change_start - op_start)
+							insert_remaining_before = ""
+
+						if op_end <= change_end
+							#    |--  existing insert  --|
+							# |--  new delete   --||.....| <- insert_remaining_after
+							delete_remaining_after = ""
+							insert_remaining_after = change.op.i.slice(op_end - change_start)
+						else
+							# |--  existing insert  --||.....| <- delete_remaining_after
+							#            |--  new delete   --|
+							delete_remaining_after = op.d.slice(change_end - op_start)
+							insert_remaining_after = ""
+
+						insert_remaining = insert_remaining_before + insert_remaining_after
+						if insert_remaining.length > 0
+							change.op.i = insert_remaining
+							change.op.p = Math.min(change_start, op_start)
+							moved_changes.push change
+						else
+							remove_changes.push change
+
+						# We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
+						# afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the 
+						# chunk in the middle not covered by these.
+						delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length
+						delete_removed_start = delete_remaining_before.length
+						modification = {
+							d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length)
+							p: delete_removed_start
+						}
+						if modification.d.length > 0
+							op_modifications.push modification
+				else if change.op.d?
+					change_start = change.op.p
+					if op_end < change_start
+						# Shift ops after us (but not touching) back by our length
+						change.op.p -= op_length
+						moved_changes.push change
+					else if op_start <= change_start <= op_end
+						# If we overlap a delete, add it in our content, and delete the existing change
+						offset = change_start - op_start
+						op_modifications.push { i: change.op.d, p: offset }
+						remove_changes.push change
+
+			for change in remove_changes
+				@_removeChange change
+			
+			op.d = @_applyOpModifications(op.d, op_modifications)
+			if op.d.length > 0
+				@_addOp op, metadata
+			else
+				# It's possible that we deleted an insert between two other inserts. I.e.
+				# If we delete 'user_2 insert' in:
+				#   |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
+				# it becomes:
+				#   |-- user_1 insert --||-- user_1 insert --|
+				# We need to merge these together again
+				results = @_scanAndMergeAdjacentUpdates()
+				moved_changed = moved_changes.concat(results.moved_changes)
+				for change in results.remove_changes
+					@_removeChange change
+			
+			if moved_changes.length > 0
+				@emit "changes:moved", moved_changes
+
+		_newId: () ->
+			@id++
+
+		_addOp: (op, metadata) ->
+			change = {
+				id: @_newId()
+				op: op
+				metadata: metadata
+			}
+			@changes.push change
+
+			# Keep ops in order of offset, with deletes before inserts
+			@changes.sort (c1, c2) ->
+				result = c1.op.p - c2.op.p
+				if result != 0
+					return result
+				else if c1.op.i? and c2.op.d?
+					return 1
+				else
+					return -1
+
+			if op.d?
+				@emit "delete:added", change
+			else if op.i?
+				@emit "insert:added", change
+		
+		_removeChange: (change) ->
+			@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
+			
+		_applyOpModifications: (content, op_modifications) ->
+			# Put in descending position order, with deleting first if at the same offset 
+			# (Inserting first would modify the content that the delete will delete)
+			op_modifications.sort (a, b) ->
+				result = b.p - a.p 
+				if result != 0
+					return result
+				else if a.i? and b.d?
+					return 1
+				else
+					return -1
+
+			for modification in op_modifications
+				if modification.i?
+					content = content.slice(0, modification.p) + modification.i + content.slice(modification.p)
+				else if modification.d?
+					if content.slice(modification.p, modification.p + modification.d.length) != modification.d
+						throw new Error("deleted content does not match. content: #{JSON.stringify(content)}; modification: #{JSON.stringify(modification)}")
+					content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length)
+			return content
+		
+		_scanAndMergeAdjacentUpdates: () ->
+			# This should only need calling when deleting an update between two
+			# other updates. There's no other way to get two adjacent updates from the
+			# same user, since they would be merged on insert.
+			previous_change = null
+			remove_changes = []
+			moved_changes = []
+			for change in @changes
+				if previous_change?.op.i? and change.op.i?
+					previous_change_end = previous_change.op.p + previous_change.op.i.length
+					previous_change_user_id = previous_change.metadata.user_id
+					change_start = change.op.p
+					change_user_id = change.metadata.user_id
+					if previous_change_end == change_start and previous_change_user_id == change_user_id
+						remove_changes.push change
+						previous_change.op.i += change.op.i
+						moved_changes.push previous_change
+				previous_change = change
+			return { moved_changes, remove_changes }

+ 31 - 265
services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee

@@ -1,7 +1,9 @@
 define [
 define [
 	"ace/ace"
 	"ace/ace"
 	"utils/EventEmitter"
 	"utils/EventEmitter"
-], (_, EventEmitter) ->
+	"ide/editor/directives/aceEditor/track-changes/ChangesTracker"
+	"ide/colors/ColorManager"
+], (_, EventEmitter, ChangesTracker, ColorManager) ->
 	class TrackChangesManager
 	class TrackChangesManager
 		Range = ace.require("ace/range").Range
 		Range = ace.require("ace/range").Range
 		
 		
@@ -9,6 +11,7 @@ define [
 			@changesTracker = new ChangesTracker()
 			@changesTracker = new ChangesTracker()
 			@changeIdToMarkerIdMap = {}
 			@changeIdToMarkerIdMap = {}
 			@enabled = false
 			@enabled = false
+			window.changesTracker ?= @changesTracker
 
 
 			@changesTracker.on "insert:added", (change) =>
 			@changesTracker.on "insert:added", (change) =>
 				@_onInsertAdded(change)
 				@_onInsertAdded(change)
@@ -28,14 +31,17 @@ define [
 					# will have fired, before we decide if it was a remote op.
 					# will have fired, before we decide if it was a remote op.
 					setTimeout () =>
 					setTimeout () =>
 						if @nextUpdateMetaData?
 						if @nextUpdateMetaData?
+							user_id = @nextUpdateMetaData.user_id
 							# The remote op may have contained multiple atomic ops, each of which is an Ace
 							# The remote op may have contained multiple atomic ops, each of which is an Ace
 							# 'change' event (i.e. bulk commenting out of lines is a single remote op
 							# 'change' event (i.e. bulk commenting out of lines is a single remote op
 							# but gives us one event for each % inserted). These all come in a single event loop
 							# but gives us one event for each % inserted). These all come in a single event loop
 							# though, so wait until the next one before clearing the metadata.
 							# though, so wait until the next one before clearing the metadata.
 							setTimeout () =>
 							setTimeout () =>
 								@nextUpdateMetaData = null
 								@nextUpdateMetaData = null
+						else
+							user_id = window.user.id
 
 
-						@applyChange(e)
+						@applyChange(e, { user_id })
 						
 						
 						# TODO: Just for debugging, remove before going live.
 						# TODO: Just for debugging, remove before going live.
 						setTimeout () =>
 						setTimeout () =>
@@ -80,9 +86,9 @@ define [
 				if marker.clazz.match("track-changes")
 				if marker.clazz.match("track-changes")
 					console.error "Orphaned ace marker", marker
 					console.error "Orphaned ace marker", marker
 		
 		
-		applyChange: (delta) ->
+		applyChange: (delta, metadata) ->
 			op = @_aceChangeToShareJs(delta)
 			op = @_aceChangeToShareJs(delta)
-			@changesTracker.applyOp(op)
+			@changesTracker.applyOp(op, metadata)
 		
 		
 		updateReviewEntriesScope: () ->
 		updateReviewEntriesScope: () ->
 			# TODO: Update in place so Angular doesn't have to redo EVERYTHING
 			# TODO: Update in place so Angular doesn't have to redo EVERYTHING
@@ -110,7 +116,19 @@ define [
 			session = @editor.getSession()
 			session = @editor.getSession()
 			doc = session.getDocument()
 			doc = session.getDocument()
 			ace_range = new Range(start.row, start.column, end.row, end.column)
 			ace_range = new Range(start.row, start.column, end.row, end.column)
-			marker_id = session.addMarker(ace_range, "track-changes-added-marker", "text")
+
+			hue = ColorManager.getHueForUserId(change.metadata.user_id)
+			colorScheme = ColorManager.getColorScheme(hue, @element)
+			markerLayer = @editor.renderer.$markerBack
+			klass = "track-changes-added-marker"
+			style = "background-color: #{colorScheme.highlightBackgroundColor}"
+			marker_id = session.addMarker ace_range, klass, (html, range, left, top, config) ->
+				if range.isMultiLine()
+					markerLayer.drawTextMarker(html, range, klass, config, style)
+				else
+					markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
+			
+			# marker_id = session.addMarker(ace_range, "track-changes-added-marker", "text")
 			@changeIdToMarkerIdMap[change.id] = marker_id
 			@changeIdToMarkerIdMap[change.id] = marker_id
 			@updateReviewEntriesScope()
 			@updateReviewEntriesScope()
 		
 		
@@ -132,7 +150,14 @@ define [
 					false
 					false
 				return range
 				return range
 
 
-			marker_id = session.addMarker(ace_range, "track-changes-deleted-marker", "text")
+			hue = ColorManager.getHueForUserId(change.metadata.user_id)
+			colorScheme = ColorManager.getColorScheme(hue, @element)
+			markerLayer = @editor.renderer.$markerBack
+			klass = "track-changes-deleted-marker"
+			style = "border-color: #{colorScheme.cursor}"
+			marker_id = session.addMarker ace_range, klass, (html, range, left, top, config) ->
+				markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
+
 			@changeIdToMarkerIdMap[change.id] = marker_id
 			@changeIdToMarkerIdMap[change.id] = marker_id
 			@updateReviewEntriesScope()
 			@updateReviewEntriesScope()
 		
 		
@@ -192,262 +217,3 @@ define [
 				marker.range.end = end
 				marker.range.end = end
 			@editor.renderer.updateBackMarkers()
 			@editor.renderer.updateBackMarkers()
 			@updateReviewEntriesScope()
 			@updateReviewEntriesScope()
-	
-	class ChangesTracker extends EventEmitter
-		# 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:
-		#   {i: "foo", p: 42} # Insert 'foo' at offset 42
-		#   {d: "bar", p: 37} # Delete 'bar' at offset 37
-		# We only track the inserts and deletes, not the whole document, but by being given all
-		# updates that are applied to a document, we can update these appropriately.
-		# 
-		# Note that the set of inserts and deletes we store applies to the document as-is at the moment.
-		# So inserts correspond to text which is in the document, while deletes correspond to text which
-		# is no longer there, so their lengths do not affect the position of later offsets.
-		# E.g.
-		#             this is the current text of the document
-		#                         |-----|            |
-		#  {i: "current ", p:12} -^                   ^- {d: "old ", p: 31}
-		#
-		# Track changes rules (should be consistent with Word):
-		#   * When text is inserted at a delete, the text goes to the left of the delete
-		#       I.e. "foo|bar" -> "foobaz|bar", where | is the delete, and 'baz' is inserted
-		#   * Deleting content flagged as 'inserted' does not create a new delete marker, it only
-		#     removes the insert marker. E.g.
-		#       * "abdefghijkl"        -> "abfghijkl"        when 'de' is deleted. No delete marker added
-		#           |---| <- inserted       |-| <- inserted
-		#       * Deletes overlapping regular text and inserted text will insert a delete marker for the
-		#         regular text:
-		#         "abcdefghijkl"    ->    "abcdejkl"   when 'fghi' is deleted
-		#           |----|                  |--||
-		#           ^- inserted 'bcdefg'      \ ^- deleted 'hi'
-		#                                      \--inserted 'bcde'
-		#   * Deletes overlapping other deletes are merged. E.g.
-		#      "abcghijkl"        ->   "ahijkl"     when 'bcg is deleted'
-		#          | <- delete 'def'     | <- delete 'bcdefg'
-		constructor: () ->
-			# Change objects have the following structure:
-			# {
-			#   id: ... # Uniquely generated by us
-			#   op: { # ShareJs style op tracking the offset (p) and content inserted (i) or deleted (d)
-			#     i: "..."
-			#     p: 42
-			#   }
-			# }
-			#
-			# Ids are used to uniquely identify a change, e.g. for updating it in the database, or keeping in
-			# sync with Ace ranges.
-			@changes = []
-			@id = 0
-		
-		applyOp: (op) ->
-			# Apply an op that has been applied to the document to our changes to keep them up to date
-			if op.i?
-				@applyInsert(op)
-			else if op.d?
-				@applyDelete(op)
-			
-		applyInsert: (op) ->
-			op_start = op.p
-			op_length = op.i.length
-			op_end = op.p + op_length
-
-			already_merged = false
-			previous_change = null
-			moved_changes = []
-			for change in @changes
-				change_start = change.op.p
-				
-				if change.op.d?
-					# Shift any deletes after this along by the length of this insert
-					if op_start <= change_start
-						change.op.p += op_length
-						moved_changes.push change
-				else if change.op.i?
-					change_end = change_start + change.op.i.length
-					is_change_overlapping = (op_start >= change_start and op_start <= change_end)
-					
-					# 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.
-					# The previous op will be the delete, but it's already been shifted by this insert
-					#
-					# I.e.
-					# Originally: |-- existing insert --|
-					#             | <- existing delete at same offset
-					#
-					# Now:        |-- existing insert --| <- not shifted yet
-					#             |-- this insert --|| <- existing delete shifted along to end of this op
-					# 
-					# After:                         |-- existing insert --|  
-					#             |-- this insert --|| <- existing delete
-					# 
-					# Without the delete, the inserts would be merged.
-					is_insert_blocked_by_delete = (previous_change? and previous_change.op.d? and previous_change.op.p == op_end)
-
-					# If the insert is overlapping another insert, either at the beginning in the middle or touching the end,
-					# then we merge them into one.
-					if is_change_overlapping and
-							!is_insert_blocked_by_delete and
-							!already_merged # With the way we order our changes, there should only ever be one candidate to merge
-							                # with since changes don't overlap. However, this flag just adds a little bit of protection
-						offset = op_start - change_start
-						change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
-						already_merged = true
-						moved_changes.push change
-					else if op_start <= change_start
-						# If we're fully before the other insert we can just shift the other insert by our length.
-						# If they are touching, and should have been merged, they will have been above.
-						# 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
-						change.op.p += op_length
-						moved_changes.push change
-				previous_change = change
-
-			if !already_merged
-				@_addOp op
-			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
-		
-		applyDelete: (op) ->
-			op_start = op.p
-			op_length = op.d.length
-			op_end = op.p + op_length
-			remove_changes = []
-			moved_changes = []
-			
-			# We might end up modifying our delete op if it merges with existing deletes, or cancels out
-			# with an existing insert. Since we might do multiple modifications, we record them and do
-			# all the modifications after looping through the existing changes, so as not to mess up the
-			# offset indexes as we go.
-			op_modifications = []
-			for change in @changes
-				if change.op.i?
-					change_start = change.op.p
-					change_end = change_start + change.op.i.length
-					if op_end <= change_start
-						# Shift ops after us back by our length
-						change.op.p -= op_length
-						moved_changes.push change
-					else if op_start >= change_end
-						# Delete is after insert, nothing to do
-					else
-						# When the new delete overlaps an insert, we should remove the part of the insert that
-						# is now deleted, and also remove the part of the new delete that overlapped. I.e.
-						# the two cancel out where they overlap.
-						if op_start >= change_start
-							#                            |-- existing insert --|
-							# insert_remaining_before -> |.....||--   new delete   --|
-							delete_remaining_before = ""
-							insert_remaining_before = change.op.i.slice(0, op_start - change_start)
-						else
-							# delete_remaining_before -> |.....||-- existing insert --|
-							#                            |-- new delete   --|
-							delete_remaining_before = op.d.slice(0, change_start - op_start)
-							insert_remaining_before = ""
-
-						if op_end <= change_end
-							#    |--  existing insert  --|
-							# |--  new delete   --||.....| <- insert_remaining_after
-							delete_remaining_after = ""
-							insert_remaining_after = change.op.i.slice(op_end - change_start)
-						else
-							# |--  existing insert  --||.....| <- delete_remaining_after
-							#            |--  new delete   --|
-							delete_remaining_after = op.d.slice(change_end - op_start)
-							insert_remaining_after = ""
-
-						insert_remaining = insert_remaining_before + insert_remaining_after
-						if insert_remaining.length > 0
-							change.op.i = insert_remaining
-							change.op.p = Math.min(change_start, op_start)
-							moved_changes.push change
-						else
-							remove_changes.push change
-
-						# We know what we want to preserve of our delete op before (delete_remaining_before) and what we want to preserve
-						# afterwards (delete_remaining_before). Now we need to turn that into a modification which deletes the 
-						# chunk in the middle not covered by these.
-						delete_removed_length = op.d.length - delete_remaining_before.length - delete_remaining_after.length
-						delete_removed_start = delete_remaining_before.length
-						modification = {
-							d: op.d.slice(delete_removed_start, delete_removed_start + delete_removed_length)
-							p: delete_removed_start
-						}
-						if modification.d.length > 0
-							op_modifications.push modification
-				else if change.op.d?
-					change_start = change.op.p
-					if op_end < change_start
-						# Shift ops after us (but not touching) back by our length
-						change.op.p -= op_length
-						moved_changes.push change
-					else if op_start <= change_start <= op_end
-						# If we overlap a delete, add it in our content, and delete the existing change
-						offset = change_start - op_start
-						op_modifications.push { i: change.op.d, p: offset }
-						remove_changes.push change
-			
-			op.d = @_applyOpModifications(op.d, op_modifications)
-			if op.d.length > 0
-				@_addOp op
-
-			for change in remove_changes
-				@_removeChange change
-			
-			if moved_changes.length > 0
-				@emit "changes:moved", moved_changes
-
-		_newId: () ->
-			@id++
-
-		_addOp: (op) ->
-			change = {
-				id: @_newId()
-				op: op
-			}
-			@changes.push change
-
-			# Keep ops in order of offset, with deletes before inserts
-			@changes.sort (c1, c2) ->
-				result = c1.op.p - c2.op.p
-				if result != 0
-					return result
-				else if c1.op.i? and c2.op.d?
-					return 1
-				else
-					return -1
-
-			if op.d?
-				@emit "delete:added", change
-			else if op.i?
-				@emit "insert:added", change
-		
-		_removeChange: (change) ->
-			@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
-			
-		_applyOpModifications: (content, op_modifications) ->
-			# Put in descending position order, with deleting first if at the same offset 
-			# (Inserting first would modify the content that the delete will delete)
-			op_modifications.sort (a, b) ->
-				result = b.p - a.p 
-				if result != 0
-					return result
-				else if a.i? and b.d?
-					return 1
-				else
-					return -1
-
-			for modification in op_modifications
-				if modification.i?
-					content = content.slice(0, modification.p) + modification.i + content.slice(modification.p)
-				else if modification.d?
-					if content.slice(modification.p, modification.p + modification.d.length) != modification.d
-						throw new Error("deleted content does not match. content: #{JSON.stringify(content)}; modification: #{JSON.stringify(modification)}")
-					content = content.slice(0, modification.p) + content.slice(modification.p + modification.d.length)
-			return content
-
-	return TrackChangesManager

+ 5 - 4
services/web/public/coffee/ide/history/HistoryManager.coffee

@@ -1,9 +1,10 @@
 define [
 define [
 	"moment"
 	"moment"
+	"ide/colors/ColorManager"
 	"ide/history/controllers/HistoryListController"
 	"ide/history/controllers/HistoryListController"
 	"ide/history/controllers/HistoryDiffController"
 	"ide/history/controllers/HistoryDiffController"
 	"ide/history/directives/infiniteScroll"
 	"ide/history/directives/infiniteScroll"
-], (moment) ->
+], (moment, ColorManager) ->
 	class HistoryManager
 	class HistoryManager
 		constructor: (@ide, @$scope) ->
 		constructor: (@ide, @$scope) ->
 			@reset()
 			@reset()
@@ -172,13 +173,13 @@ define [
 						highlights.push {
 						highlights.push {
 							label: "Added by #{name} on #{date}"
 							label: "Added by #{name} on #{date}"
 							highlight: range
 							highlight: range
-							hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
+							hue: ColorManager.getHueForUserId(entry.meta.user?.id)
 						}
 						}
 					else if entry.d?
 					else if entry.d?
 						highlights.push {
 						highlights.push {
 							label: "Deleted by #{name} on #{date}"
 							label: "Deleted by #{name} on #{date}"
 							strikeThrough: range
 							strikeThrough: range
-							hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
+							hue: ColorManager.getHueForUserId(entry.meta.user?.id)
 						}
 						}
 
 
 			return {text, highlights}
 			return {text, highlights}
@@ -192,7 +193,7 @@ define [
 
 
 				for user in update.meta.users or []
 				for user in update.meta.users or []
 					if user?
 					if user?
-						user.hue = @ide.onlineUsersManager.getHueForUserId(user.id)
+						user.hue = ColorManager.getHueForUserId(user.id)
 
 
 				if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
 				if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
 					update.meta.first_in_day = true
 					update.meta.first_in_day = true

+ 4 - 19
services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee

@@ -1,7 +1,8 @@
 define [
 define [
+	"ide/colors/ColorManager"
 	"libs/md5"
 	"libs/md5"
 	"ide/online-users/controllers/OnlineUsersController"
 	"ide/online-users/controllers/OnlineUsersController"
-], () ->
+], (ColorManager) ->
 	class OnlineUsersManager
 	class OnlineUsersManager
 
 
 		cursorUpdateInterval:500
 		cursorUpdateInterval:500
@@ -46,7 +47,7 @@ define [
 					@refreshOnlineUsers()
 					@refreshOnlineUsers()
 					
 					
 			@$scope.getHueForUserId = (user_id) =>
 			@$scope.getHueForUserId = (user_id) =>
-				@getHueForUserId(user_id)
+				ColorManager.getHueForUserId(user_id)
 
 
 		refreshOnlineUsers: () ->
 		refreshOnlineUsers: () ->
 			@$scope.onlineUsersArray = []
 			@$scope.onlineUsersArray = []
@@ -74,7 +75,7 @@ define [
 					cursor:
 					cursor:
 						row: client.row
 						row: client.row
 						column: client.column
 						column: client.column
-					hue: @getHueForUserId(client.user_id)
+					hue: ColorManager.getHueForUserId(client.user_id)
 				}
 				}
 
 
 			if @$scope.onlineUsersArray.length > 0
 			if @$scope.onlineUsersArray.length > 0
@@ -101,19 +102,3 @@ define [
 					delete @cursorUpdateTimeout
 					delete @cursorUpdateTimeout
 				, @cursorUpdateInterval
 				, @cursorUpdateInterval
 
 
-		OWN_HUE: 200 # We will always appear as this color to ourselves
-		ANONYMOUS_HUE: 100
-		getHueForUserId: (user_id) ->
-			if !user_id? or user_id == "anonymous-user"
-				return @ANONYMOUS_HUE
-
-			if window.user.id == user_id
-				return @OWN_HUE
-
-			hash = CryptoJS.MD5(user_id)
-			hue = parseInt(hash.toString().slice(0,8), 16) % 320
-			# Avoid 20 degrees either side of the personal hue
-			if hue > @OWNER_HUE - 20
-				hue = hue + 40
-			return hue
-

+ 0 - 1
services/web/public/stylesheets/app/editor.less

@@ -143,7 +143,6 @@
 	.track-changes-added-marker {
 	.track-changes-added-marker {
 		border-radius: 0;
 		border-radius: 0;
 		position: absolute;
 		position: absolute;
-		background-color: hsl(100, 70%, 70%);
 	}
 	}
 	.track-changes-deleted-marker {
 	.track-changes-deleted-marker {
 		border-radius: 0;
 		border-radius: 0;