From 52f3fe73038afd5c916340f7bdd94dfe31da3c08 Mon Sep 17 00:00:00 2001 From: James Allen Date: Thu, 20 Oct 2016 12:15:22 +0100 Subject: [PATCH] Show different users changes in different colours --- .../controllers/ChatMessageController.coffee | 5 +- .../coffee/ide/colors/ColorManager.coffee | 44 +++ .../highlights/HighlightsManager.coffee | 31 +- .../track-changes/ChangesTracker.coffee | 322 ++++++++++++++++++ .../track-changes/TrackChangesManager.coffee | 296 ++-------------- .../coffee/ide/history/HistoryManager.coffee | 9 +- .../online-users/OnlineUsersManager.coffee | 23 +- .../web/public/stylesheets/app/editor.less | 1 - 8 files changed, 412 insertions(+), 319 deletions(-) create mode 100644 services/web/public/coffee/ide/colors/ColorManager.coffee create mode 100644 services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/ChangesTracker.coffee diff --git a/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee b/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee index f8ed988d62..6c79efd0ad 100644 --- a/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee +++ b/services/web/public/coffee/ide/chat/controllers/ChatMessageController.coffee @@ -1,7 +1,8 @@ define [ "base" -], (App) -> + "ide/colors/ColorManager" +], (App, ColorManager) -> App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) -> $scope.hue = (user) -> - ide.onlineUsersManager.getHueForUserId(user.id) + ColorManager.getHueForUserId(user.id) ] \ No newline at end of file diff --git a/services/web/public/coffee/ide/colors/ColorManager.coffee b/services/web/public/coffee/ide/colors/ColorManager.coffee new file mode 100644 index 0000000000..c55539bed0 --- /dev/null +++ b/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 + \ No newline at end of file diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee index d1f520d8f1..92f3ac599c 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/highlights/HighlightsManager.coffee @@ -1,6 +1,7 @@ define [ "ace/ace" -], () -> + "ide/colors/ColorManager" +], (_, ColorManager) -> Range = ace.require("ace/range").Range class HighlightsManager @@ -64,7 +65,7 @@ define [ for annotation in @$scope.highlights or [] do (annotation) => - colorScheme = @_getColorScheme(annotation.hue) + colorScheme = ColorManager.getColorScheme(annotation.hue, @element) if annotation.cursor? @labels.push { text: annotation.label @@ -262,29 +263,3 @@ define [ else markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style) , 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 diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/ChangesTracker.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/ChangesTracker.coffee new file mode 100644 index 0000000000..b6c953d030 --- /dev/null +++ b/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 } diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee index abcb8817c1..bceb4b7d9a 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee @@ -1,7 +1,9 @@ define [ "ace/ace" "utils/EventEmitter" -], (_, EventEmitter) -> + "ide/editor/directives/aceEditor/track-changes/ChangesTracker" + "ide/colors/ColorManager" +], (_, EventEmitter, ChangesTracker, ColorManager) -> class TrackChangesManager Range = ace.require("ace/range").Range @@ -9,6 +11,7 @@ define [ @changesTracker = new ChangesTracker() @changeIdToMarkerIdMap = {} @enabled = false + window.changesTracker ?= @changesTracker @changesTracker.on "insert:added", (change) => @_onInsertAdded(change) @@ -28,14 +31,17 @@ define [ # will have fired, before we decide if it was a remote op. setTimeout () => if @nextUpdateMetaData? + user_id = @nextUpdateMetaData.user_id # 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 # 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. setTimeout () => @nextUpdateMetaData = null + else + user_id = window.user.id - @applyChange(e) + @applyChange(e, { user_id }) # TODO: Just for debugging, remove before going live. setTimeout () => @@ -80,9 +86,9 @@ define [ if marker.clazz.match("track-changes") console.error "Orphaned ace marker", marker - applyChange: (delta) -> + applyChange: (delta, metadata) -> op = @_aceChangeToShareJs(delta) - @changesTracker.applyOp(op) + @changesTracker.applyOp(op, metadata) updateReviewEntriesScope: () -> # TODO: Update in place so Angular doesn't have to redo EVERYTHING @@ -110,7 +116,19 @@ define [ session = @editor.getSession() doc = session.getDocument() 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 @updateReviewEntriesScope() @@ -132,7 +150,14 @@ define [ false 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 @updateReviewEntriesScope() @@ -192,262 +217,3 @@ define [ marker.range.end = end @editor.renderer.updateBackMarkers() @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 \ No newline at end of file diff --git a/services/web/public/coffee/ide/history/HistoryManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee index 12de2149d8..66c855ebc6 100644 --- a/services/web/public/coffee/ide/history/HistoryManager.coffee +++ b/services/web/public/coffee/ide/history/HistoryManager.coffee @@ -1,9 +1,10 @@ define [ "moment" + "ide/colors/ColorManager" "ide/history/controllers/HistoryListController" "ide/history/controllers/HistoryDiffController" "ide/history/directives/infiniteScroll" -], (moment) -> +], (moment, ColorManager) -> class HistoryManager constructor: (@ide, @$scope) -> @reset() @@ -172,13 +173,13 @@ define [ highlights.push { label: "Added by #{name} on #{date}" highlight: range - hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id) + hue: ColorManager.getHueForUserId(entry.meta.user?.id) } else if entry.d? highlights.push { label: "Deleted by #{name} on #{date}" strikeThrough: range - hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id) + hue: ColorManager.getHueForUserId(entry.meta.user?.id) } return {text, highlights} @@ -192,7 +193,7 @@ define [ for user in update.meta.users or [] 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") update.meta.first_in_day = true diff --git a/services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee b/services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee index f6633267c9..90fd5ed6b5 100644 --- a/services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee +++ b/services/web/public/coffee/ide/online-users/OnlineUsersManager.coffee @@ -1,7 +1,8 @@ define [ + "ide/colors/ColorManager" "libs/md5" "ide/online-users/controllers/OnlineUsersController" -], () -> +], (ColorManager) -> class OnlineUsersManager cursorUpdateInterval:500 @@ -46,7 +47,7 @@ define [ @refreshOnlineUsers() @$scope.getHueForUserId = (user_id) => - @getHueForUserId(user_id) + ColorManager.getHueForUserId(user_id) refreshOnlineUsers: () -> @$scope.onlineUsersArray = [] @@ -74,7 +75,7 @@ define [ cursor: row: client.row column: client.column - hue: @getHueForUserId(client.user_id) + hue: ColorManager.getHueForUserId(client.user_id) } if @$scope.onlineUsersArray.length > 0 @@ -101,19 +102,3 @@ define [ delete @cursorUpdateTimeout , @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 - diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index c8773b07d8..a7f4a80062 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -143,7 +143,6 @@ .track-changes-added-marker { border-radius: 0; position: absolute; - background-color: hsl(100, 70%, 70%); } .track-changes-deleted-marker { border-radius: 0;