diff --git a/services/web/public/coffee/ide/editor/Document.coffee b/services/web/public/coffee/ide/editor/Document.coffee index 1de01b5467..91c05e6e39 100644 --- a/services/web/public/coffee/ide/editor/Document.coffee +++ b/services/web/public/coffee/ide/editor/Document.coffee @@ -353,11 +353,12 @@ define [ @ranges.applyOp op, { user_id: track_changes_as } if old_id_seed? @ranges.setIdSeed(old_id_seed) + @emit "ranges:dirty" _catchUpRanges: (changes = [], comments = []) -> # We've just been given the current server's ranges, but need to apply any local ops we have. # Reset to the server state then apply our local ops again. - @ranges.emit "clear" + @emit "ranges:clear" @ranges.changes = changes @ranges.comments = comments @ranges.track_changes = @doc.track_changes @@ -367,4 +368,4 @@ define [ for op in @doc.getPendingOp() or [] @ranges.setIdSeed(@doc.track_changes_id_seeds.pending) @ranges.applyOp(op, { user_id: @track_changes_as }) - @ranges.emit "redraw" + @emit "ranges:redraw" 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 38b3e5678c..17c10e7f71 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 @@ -14,11 +14,11 @@ define [ return if !track_changes? @setTrackChanges(track_changes) - @$scope.$watch "sharejsDoc", (doc) => + @$scope.$watch "sharejsDoc", (doc, oldDoc) => return if !doc? - @disconnectFromRangesTracker() - @rangesTracker = doc.ranges - @connectToRangesTracker() + if oldDoc? + @disconnectFromDoc(oldDoc) + @connectToDoc(doc) @$scope.$on "comment:add", (e, thread_id, offset, length) => @addCommentToSelection(thread_id, offset, length) @@ -36,10 +36,10 @@ define [ @removeCommentId(comment_id) @$scope.$on "comment:resolve_threads", (e, thread_ids) => - @resolveCommentByThreadIds(thread_ids) + @hideCommentsByThreadIds(thread_ids) @$scope.$on "comment:unresolve_thread", (e, thread_id) => - @unresolveCommentByThreadId(thread_id) + @showCommentByThreadId(thread_id) @$scope.$on "review-panel:recalculate-screen-positions", () => @recalculateReviewEntriesScreenPositions() @@ -92,18 +92,11 @@ define [ else unbindFromAce() - disconnectFromRangesTracker: () -> + disconnectFromDoc: (doc) -> @changeIdToMarkerIdMap = {} - - if @rangesTracker? - @rangesTracker.off "insert:added" - @rangesTracker.off "insert:removed" - @rangesTracker.off "delete:added" - @rangesTracker.off "delete:removed" - @rangesTracker.off "changes:moved" - @rangesTracker.off "comment:added" - @rangesTracker.off "comment:moved" - @rangesTracker.off "comment:removed" + doc.off "ranges:clear" + doc.off "ranges:redraw" + doc.off "ranges:dirty" setTrackChanges: (value) -> if value @@ -111,56 +104,15 @@ define [ else @$scope.sharejsDoc?.track_changes_as = null - connectToRangesTracker: () -> + connectToDoc: (doc) -> + @rangesTracker = doc.ranges @setTrackChanges(@$scope.trackChanges) - # Add a timeout because on remote ops, we get these notifications before - # ace has updated - @rangesTracker.on "insert:added", (change) => - sl_console.log "[insert:added]", change - setTimeout () => - @_onInsertAdded(change) - @broadcastChange() - @rangesTracker.on "insert:removed", (change) => - sl_console.log "[insert:removed]", change - setTimeout () => - @_onInsertRemoved(change) - @broadcastChange() - @rangesTracker.on "delete:added", (change) => - sl_console.log "[delete:added]", change - setTimeout () => - @_onDeleteAdded(change) - @broadcastChange() - @rangesTracker.on "delete:removed", (change) => - sl_console.log "[delete:removed]", change - setTimeout () => - @_onDeleteRemoved(change) - @broadcastChange() - @rangesTracker.on "changes:moved", (changes) => - sl_console.log "[changes:moved]", changes - setTimeout () => - @_onChangesMoved(changes) - @broadcastChange() - - @rangesTracker.on "comment:added", (comment) => - sl_console.log "[comment:added]", comment - setTimeout () => - @_onCommentAdded(comment) - @broadcastChange() - @rangesTracker.on "comment:moved", (comment) => - sl_console.log "[comment:moved]", comment - setTimeout () => - @_onCommentMoved(comment) - @broadcastChange() - @rangesTracker.on "comment:removed", (comment) => - sl_console.log "[comment:removed]", comment - setTimeout () => - @_onCommentRemoved(comment) - @broadcastChange() - - @rangesTracker.on "clear", () => + doc.on "ranges:dirty", () => + @updateAnnotations() + doc.on "ranges:clear", () => @clearAnnotations() - @rangesTracker.on "redraw", () => + doc.on "ranges:redraw", () => @redrawAnnotations() clearAnnotations: () -> @@ -181,6 +133,55 @@ define [ @_onCommentAdded(comment) @broadcastChange() + + _doneUpdateThisLoop: false + _pendingUpdates: false + updateAnnotations: () -> + # Doc updates with multiple ops, like search/replace or block comments + # will call this with every individual op in a single event loop. So only + # do the first this loop, then schedule an update for the next loop for the rest. + if !@_doneUpdateThisLoop + @_doUpdateAnnotations() + @_doneUpdateThisLoop = true + setTimeout () => + if @_pendingUpdates + @_doUpdateAnnotations() + @_doneUpdateThisLoop = false + @_pendingUpdates = false + else + @_pendingUpdates = true + + _doUpdateAnnotations: () -> + dirty = @rangesTracker.getDirtyState() + + updateMarkers = false + + for id, change of dirty.change.added + if change.op.i? + @_onInsertAdded(change) + else if change.op.d? + @_onDeleteAdded(change) + for id, change of dirty.change.removed + if change.op.i? + @_onInsertRemoved(change) + else if change.op.d? + @_onDeleteRemoved(change) + for id, change of dirty.change.moved + updateMarkers = true + @_onChangeMoved(change) + + for id, comment of dirty.comment.added + @_onCommentAdded(comment) + for id, comment of dirty.comment.removed + @_onCommentRemoved(comment) + for id, comment of dirty.comment.moved + updateMarkers = true + @_onCommentMoved(comment) + + @rangesTracker.resetDirtyState() + if updateMarkers + @editor.renderer.updateBackMarkers() + @broadcastChange() addComment: (offset, content, thread_id) -> op = { c: content, p: offset, t: thread_id } @@ -200,6 +201,7 @@ define [ acceptChangeId: (change_id) -> @rangesTracker.removeChangeId(change_id) + @updateAnnotations() rejectChangeId: (change_id) -> change = @rangesTracker.getChange(change_id) @@ -221,8 +223,9 @@ define [ removeCommentId: (comment_id) -> @rangesTracker.removeCommentId(comment_id) + @updateAnnotations() - resolveCommentByThreadIds: (thread_ids) -> + hideCommentsByThreadIds: (thread_ids) -> resolve_ids = {} for id in thread_ids resolve_ids[id] = true @@ -231,7 +234,7 @@ define [ @_onCommentRemoved(comment) @broadcastChange() - unresolveCommentByThreadId: (thread_id) -> + showCommentByThreadId: (thread_id) -> for comment in @rangesTracker?.comments or [] if comment.op.t == thread_id @_onCommentAdded(comment) @@ -421,23 +424,18 @@ define [ lines = @editor.getSession().getDocument().getAllLines() return AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines) - _onChangesMoved: (changes) -> - # TODO: PERFORMANCE: Only run through the Ace lines once, and calculate all - # change positions as we go. - for change in changes - start = @_shareJsOffsetToAcePosition(change.op.p) - if change.op.i? - end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length) - else - end = start - @_updateMarker(change.id, start, end) - @editor.renderer.updateBackMarkers() + _onChangeMoved: (change) -> + start = @_shareJsOffsetToAcePosition(change.op.p) + if change.op.i? + end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length) + else + end = start + @_updateMarker(change.id, start, end) _onCommentMoved: (comment) -> start = @_shareJsOffsetToAcePosition(comment.op.p) end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length) @_updateMarker(comment.id, start, end) - @editor.renderer.updateBackMarkers() _updateMarker: (change_id, start, end) -> return if !@changeIdToMarkerIdMap[change_id]? diff --git a/services/web/public/coffee/ide/review-panel/RangesTracker.coffee b/services/web/public/coffee/ide/review-panel/RangesTracker.coffee index 865ecf4ef6..1f32f19d3a 100644 --- a/services/web/public/coffee/ide/review-panel/RangesTracker.coffee +++ b/services/web/public/coffee/ide/review-panel/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 # track changes in Word. We store these as a set of ShareJs style ranges: # {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. constructor: (@changes = [], @comments = []) -> @setIdSeed(RangesTracker.generateIdSeed()) + @resetDirtyState() getIdSeed: () -> return @id_seed @@ -75,7 +76,7 @@ load = (EventEmitter) -> comment = @getComment(comment_id) return if !comment? @comments = @comments.filter (c) -> c.id != comment_id - @emit "comment:removed", comment + @_markAsDirty comment, "comment", "removed" getChange: (change_id) -> change = null @@ -103,7 +104,11 @@ load = (EventEmitter) -> @addComment(op, metadata) else throw new Error("unknown op type") - + + applyOps: (ops, metadata = {}) -> + for op in ops + @applyOp(op, metadata) + addComment: (op, metadata) -> # TODO: Don't allow overlapping comments? @comments.push comment = { @@ -114,18 +119,18 @@ load = (EventEmitter) -> t: op.t metadata } - @emit "comment:added", comment + @_markAsDirty comment, "comment", "added" return comment applyInsertToComments: (op) -> for comment in @comments if op.p <= comment.op.p 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 offset = op.p - comment.op.p 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) -> op_start = op.p @@ -138,7 +143,7 @@ load = (EventEmitter) -> if op_end <= comment_start # delete is fully before comment comment.op.p -= op_length - @emit "comment:moved", comment + @_markAsDirty comment, "comment", "moved" else if op_start >= comment_end # delete is fully after comment, nothing to do else @@ -161,7 +166,7 @@ load = (EventEmitter) -> comment.op.p = Math.min(comment_start, op_start) comment.op.c = remaining_before + remaining_after - @emit "comment:moved", comment + @_markAsDirty comment, "comment", "moved" applyInsertToChanges: (op, metadata) -> op_start = op.p @@ -206,12 +211,12 @@ load = (EventEmitter) -> # 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. # E.g. - # foo|<--- about to insert 'b' here + # foo|<--- about to insert 'bar' here # inserted 'foo' --^ ^-- deleted 'bar' - # should become just 'foo' not 'foob' (with the delete marker becoming just 'ar'), . + # should become just 'foo' not 'foobar' (with the delete marker disappearing), . 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 - 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 = is_op_adjacent_to_next_delete and next_change.op.d == op.i # 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. @@ -281,8 +286,8 @@ load = (EventEmitter) -> for change in remove_changes @_removeChange change - if moved_changes.length > 0 - @emit "changes:moved", moved_changes + for change in moved_changes + @_markAsDirty change, "change", "moved" applyDeleteToChanges: (op, metadata) -> op_start = op.p @@ -406,8 +411,8 @@ load = (EventEmitter) -> @_removeChange 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) -> change = { @@ -427,17 +432,11 @@ load = (EventEmitter) -> else return -1 - if op.d? - @emit "delete:added", change - else if op.i? - @emit "insert:added", change + @_markAsDirty(change, "change", "added") _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 + @_markAsDirty change, "change", "removed" _applyOpModifications: (content, op_modifications) -> # Put in descending position order, with deleting first if at the same offset @@ -486,13 +485,32 @@ load = (EventEmitter) -> previous_change = change 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 = {} (clone[k] = v for k,v of object) return clone if define? - define ["utils/EventEmitter"], load + define [], load else - EventEmitter = require("events").EventEmitter - module.exports = load(EventEmitter) \ No newline at end of file + module.exports = load() \ No newline at end of file