|
@@ -1,38 +1,230 @@
|
|
|
define [
|
|
define [
|
|
|
"ace/ace"
|
|
"ace/ace"
|
|
|
"utils/EventEmitter"
|
|
"utils/EventEmitter"
|
|
|
-], (_, EventEmitter) ->
|
|
|
|
|
|
|
+ "ide/colors/ColorManager"
|
|
|
|
|
+ "ide/editor/AceShareJsCodec"
|
|
|
|
|
+], (_, EventEmitter, ColorManager, AceShareJsCodec) ->
|
|
|
class TrackChangesManager
|
|
class TrackChangesManager
|
|
|
Range = ace.require("ace/range").Range
|
|
Range = ace.require("ace/range").Range
|
|
|
|
|
|
|
|
constructor: (@$scope, @editor, @element) ->
|
|
constructor: (@$scope, @editor, @element) ->
|
|
|
- @changesTracker = new ChangesTracker()
|
|
|
|
|
|
|
+ window.trackChangesManager ?= @
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$watch "changesTracker", (changesTracker) =>
|
|
|
|
|
+ return if !changesTracker?
|
|
|
|
|
+ @disconnectFromChangesTracker()
|
|
|
|
|
+ @changesTracker = changesTracker
|
|
|
|
|
+ @connectToChangesTracker()
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$watch "trackNewChanges", (track_new_changes) =>
|
|
|
|
|
+ return if !track_new_changes?
|
|
|
|
|
+ @changesTracker?.track_changes = track_new_changes
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "comment:add", (e, comment) =>
|
|
|
|
|
+ @addCommentToSelection(comment)
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "comment:select_line", (e) =>
|
|
|
|
|
+ @selectLineIfNoSelection()
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "change:accept", (e, change_id) =>
|
|
|
|
|
+ @acceptChangeId(change_id)
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "change:reject", (e, change_id) =>
|
|
|
|
|
+ @rejectChangeId(change_id)
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "comment:remove", (e, comment_id) =>
|
|
|
|
|
+ @removeCommentId(comment_id)
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "comment:resolve", (e, comment_id, user_id) =>
|
|
|
|
|
+ @resolveCommentId(comment_id, user_id)
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "comment:unresolve", (e, comment_id) =>
|
|
|
|
|
+ @unresolveCommentId(comment_id)
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$on "review-panel:recalculate-screen-positions", () =>
|
|
|
|
|
+ @recalculateReviewEntriesScreenPositions()
|
|
|
|
|
+
|
|
|
|
|
+ changingSelection = false
|
|
|
|
|
+ onChangeSelection = (args...) =>
|
|
|
|
|
+ # Deletes can send about 5 changeSelection events, so
|
|
|
|
|
+ # just act on the last one.
|
|
|
|
|
+ if !changingSelection
|
|
|
|
|
+ changingSelection = true
|
|
|
|
|
+ @$scope.$evalAsync () =>
|
|
|
|
|
+ changingSelection = false
|
|
|
|
|
+ @updateFocus()
|
|
|
|
|
+ @recalculateReviewEntriesScreenPositions()
|
|
|
|
|
+
|
|
|
|
|
+ onResize = () =>
|
|
|
|
|
+ @recalculateReviewEntriesScreenPositions()
|
|
|
|
|
+
|
|
|
|
|
+ onChange = (e) =>
|
|
|
|
|
+ if !@editor.initing
|
|
|
|
|
+ # This change is trigger by a sharejs 'change' event, which is before the
|
|
|
|
|
+ # sharejs 'remoteop' event. So wait until the next event loop when the 'remoteop'
|
|
|
|
|
+ # 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
|
|
|
|
|
+
|
|
|
|
|
+ was_tracking = @changesTracker.track_changes
|
|
|
|
|
+ if @dont_track_next_update
|
|
|
|
|
+ @changesTracker.track_changes = false
|
|
|
|
|
+ @dont_track_next_update = false
|
|
|
|
|
+ @applyChange(e, { user_id })
|
|
|
|
|
+ @changesTracker.track_changes = was_tracking
|
|
|
|
|
+
|
|
|
|
|
+ # TODO: Just for debugging, remove before going live.
|
|
|
|
|
+ setTimeout () =>
|
|
|
|
|
+ @checkMapping()
|
|
|
|
|
+ , 100
|
|
|
|
|
+
|
|
|
|
|
+ onChangeSession = (e) =>
|
|
|
|
|
+ e.oldSession?.getDocument().off "change", onChange
|
|
|
|
|
+ e.session.getDocument().on "change", onChange
|
|
|
|
|
+ @redrawAnnotations()
|
|
|
|
|
+
|
|
|
|
|
+ bindToAce = () =>
|
|
|
|
|
+ @editor.getSession().getDocument().on "change", onChange
|
|
|
|
|
+ @editor.on "changeSelection", onChangeSelection
|
|
|
|
|
+ @editor.on "changeSession", onChangeSession
|
|
|
|
|
+ @editor.renderer.on "resize", onResize
|
|
|
|
|
+
|
|
|
|
|
+ unbindFromAce = () =>
|
|
|
|
|
+ @editor.getSession().getDocument().off "change", onChange
|
|
|
|
|
+ @editor.off "changeSelection", onChangeSelection
|
|
|
|
|
+ @editor.off "changeSession", onChangeSession
|
|
|
|
|
+ @editor.renderer.off "resize", onResize
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$watch "trackChangesEnabled", (enabled) =>
|
|
|
|
|
+ return if !enabled?
|
|
|
|
|
+ if enabled
|
|
|
|
|
+ bindToAce()
|
|
|
|
|
+ else
|
|
|
|
|
+ unbindFromAce()
|
|
|
|
|
+
|
|
|
|
|
+ disconnectFromChangesTracker: () ->
|
|
|
@changeIdToMarkerIdMap = {}
|
|
@changeIdToMarkerIdMap = {}
|
|
|
- @enabled = false
|
|
|
|
|
|
|
|
|
|
|
|
+ if @changesTracker?
|
|
|
|
|
+ @changesTracker.off "insert:added"
|
|
|
|
|
+ @changesTracker.off "insert:removed"
|
|
|
|
|
+ @changesTracker.off "delete:added"
|
|
|
|
|
+ @changesTracker.off "delete:removed"
|
|
|
|
|
+ @changesTracker.off "changes:moved"
|
|
|
|
|
+ @changesTracker.off "comment:added"
|
|
|
|
|
+ @changesTracker.off "comment:moved"
|
|
|
|
|
+ @changesTracker.off "comment:removed"
|
|
|
|
|
+ @changesTracker.off "comment:resolved"
|
|
|
|
|
+ @changesTracker.off "comment:unresolved"
|
|
|
|
|
+
|
|
|
|
|
+ connectToChangesTracker: () ->
|
|
|
|
|
+ @changesTracker.track_changes = @$scope.trackNewChanges
|
|
|
|
|
+
|
|
|
@changesTracker.on "insert:added", (change) =>
|
|
@changesTracker.on "insert:added", (change) =>
|
|
|
|
|
+ sl_console.log "[insert:added]", change
|
|
|
@_onInsertAdded(change)
|
|
@_onInsertAdded(change)
|
|
|
@changesTracker.on "insert:removed", (change) =>
|
|
@changesTracker.on "insert:removed", (change) =>
|
|
|
|
|
+ sl_console.log "[insert:removed]", change
|
|
|
@_onInsertRemoved(change)
|
|
@_onInsertRemoved(change)
|
|
|
@changesTracker.on "delete:added", (change) =>
|
|
@changesTracker.on "delete:added", (change) =>
|
|
|
|
|
+ sl_console.log "[delete:added]", change
|
|
|
@_onDeleteAdded(change)
|
|
@_onDeleteAdded(change)
|
|
|
@changesTracker.on "delete:removed", (change) =>
|
|
@changesTracker.on "delete:removed", (change) =>
|
|
|
|
|
+ sl_console.log "[delete:removed]", change
|
|
|
@_onDeleteRemoved(change)
|
|
@_onDeleteRemoved(change)
|
|
|
@changesTracker.on "changes:moved", (changes) =>
|
|
@changesTracker.on "changes:moved", (changes) =>
|
|
|
|
|
+ sl_console.log "[changes:moved]", changes
|
|
|
@_onChangesMoved(changes)
|
|
@_onChangesMoved(changes)
|
|
|
|
|
|
|
|
- onChange = (e) =>
|
|
|
|
|
- if !@editor.initing and @enabled
|
|
|
|
|
- @applyChange(e)
|
|
|
|
|
- setTimeout () =>
|
|
|
|
|
- @checkMapping()
|
|
|
|
|
- , 100
|
|
|
|
|
|
|
+ @changesTracker.on "comment:added", (comment) =>
|
|
|
|
|
+ sl_console.log "[comment:added]", comment
|
|
|
|
|
+ @_onCommentAdded(comment)
|
|
|
|
|
+ @changesTracker.on "comment:moved", (comment) =>
|
|
|
|
|
+ sl_console.log "[comment:moved]", comment
|
|
|
|
|
+ @_onCommentMoved(comment)
|
|
|
|
|
+ @changesTracker.on "comment:removed", (comment) =>
|
|
|
|
|
+ sl_console.log "[comment:removed]", comment
|
|
|
|
|
+ @_onCommentRemoved(comment)
|
|
|
|
|
+ @changesTracker.on "comment:resolved", (comment) =>
|
|
|
|
|
+ sl_console.log "[comment:resolved]", comment
|
|
|
|
|
+ @_onCommentRemoved(comment)
|
|
|
|
|
+ @changesTracker.on "comment:unresolved", (comment) =>
|
|
|
|
|
+ sl_console.log "[comment:unresolved]", comment
|
|
|
|
|
+ @_onCommentAdded(comment)
|
|
|
|
|
+
|
|
|
|
|
+ redrawAnnotations: () ->
|
|
|
|
|
+ for change in @changesTracker.changes
|
|
|
|
|
+ if change.op.i?
|
|
|
|
|
+ @_onInsertAdded(change)
|
|
|
|
|
+ else if change.op.d?
|
|
|
|
|
+ @_onDeleteAdded(change)
|
|
|
|
|
|
|
|
- @editor.on "changeSession", (e) =>
|
|
|
|
|
- e.oldSession?.getDocument().off "change", onChange
|
|
|
|
|
- e.session.getDocument().on "change", onChange
|
|
|
|
|
- @editor.getSession().getDocument().on "change", onChange
|
|
|
|
|
|
|
+ for comment in @changesTracker.comments
|
|
|
|
|
+ @_onCommentAdded(comment)
|
|
|
|
|
+
|
|
|
|
|
+ addComment: (offset, length, content) ->
|
|
|
|
|
+ @changesTracker.addComment offset, length, {
|
|
|
|
|
+ thread: [{
|
|
|
|
|
+ content: content
|
|
|
|
|
+ user_id: window.user_id
|
|
|
|
|
+ ts: new Date()
|
|
|
|
|
+ }]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ addCommentToSelection: (content) ->
|
|
|
|
|
+ range = @editor.getSelectionRange()
|
|
|
|
|
+ offset = @_aceRangeToShareJs(range.start)
|
|
|
|
|
+ end = @_aceRangeToShareJs(range.end)
|
|
|
|
|
+ length = end - offset
|
|
|
|
|
+ @addComment(offset, length, content)
|
|
|
|
|
+
|
|
|
|
|
+ selectLineIfNoSelection: () ->
|
|
|
|
|
+ if @editor.selection.isEmpty()
|
|
|
|
|
+ @editor.selection.selectLine()
|
|
|
|
|
|
|
|
|
|
+ acceptChangeId: (change_id) ->
|
|
|
|
|
+ @changesTracker.removeChangeId(change_id)
|
|
|
|
|
+
|
|
|
|
|
+ rejectChangeId: (change_id) ->
|
|
|
|
|
+ change = @changesTracker.getChange(change_id)
|
|
|
|
|
+ return if !change?
|
|
|
|
|
+ @changesTracker.removeChangeId(change_id)
|
|
|
|
|
+ @dont_track_next_update = true
|
|
|
|
|
+ session = @editor.getSession()
|
|
|
|
|
+ if change.op.d?
|
|
|
|
|
+ content = change.op.d
|
|
|
|
|
+ position = @_shareJsOffsetToAcePosition(change.op.p)
|
|
|
|
|
+ session.insert(position, content)
|
|
|
|
|
+ else if change.op.i?
|
|
|
|
|
+ start = @_shareJsOffsetToAcePosition(change.op.p)
|
|
|
|
|
+ end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
|
|
|
|
|
+ editor_text = session.getDocument().getTextRange({start, end})
|
|
|
|
|
+ if editor_text != change.op.i
|
|
|
|
|
+ throw new Error("Op to be removed (#{JSON.stringify(change.op)}), does not match editor text, '#{editor_text}'")
|
|
|
|
|
+ session.remove({start, end})
|
|
|
|
|
+ else
|
|
|
|
|
+ throw new Error("unknown change: #{JSON.stringify(change)}")
|
|
|
|
|
+
|
|
|
|
|
+ removeCommentId: (comment_id) ->
|
|
|
|
|
+ @changesTracker.removeCommentId(comment_id)
|
|
|
|
|
+
|
|
|
|
|
+ resolveCommentId: (comment_id, user_id) ->
|
|
|
|
|
+ @changesTracker.resolveCommentId(comment_id, {
|
|
|
|
|
+ user_id, ts: new Date()
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ unresolveCommentId: (comment_id) ->
|
|
|
|
|
+ @changesTracker.unresolveCommentId(comment_id)
|
|
|
|
|
+
|
|
|
checkMapping: () ->
|
|
checkMapping: () ->
|
|
|
session = @editor.getSession()
|
|
session = @editor.getSession()
|
|
|
|
|
|
|
@@ -41,16 +233,28 @@ define [
|
|
|
for marker_id, marker of session.getMarkers()
|
|
for marker_id, marker of session.getMarkers()
|
|
|
markers[marker_id] = marker
|
|
markers[marker_id] = marker
|
|
|
|
|
|
|
|
|
|
+ expected_markers = []
|
|
|
for change in @changesTracker.changes
|
|
for change in @changesTracker.changes
|
|
|
- op = change.op
|
|
|
|
|
- marker_id = @changeIdToMarkerIdMap[change.id]
|
|
|
|
|
-
|
|
|
|
|
- start = @_shareJsOffsetToAcePosition(op.p)
|
|
|
|
|
- if op.i?
|
|
|
|
|
- end = @_shareJsOffsetToAcePosition(op.p + op.i.length)
|
|
|
|
|
- else if op.d?
|
|
|
|
|
- end = start
|
|
|
|
|
-
|
|
|
|
|
|
|
+ if @changeIdToMarkerIdMap[change.id]?
|
|
|
|
|
+ op = change.op
|
|
|
|
|
+ {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
|
|
|
|
|
+ start = @_shareJsOffsetToAcePosition(op.p)
|
|
|
|
|
+ if op.i?
|
|
|
|
|
+ end = @_shareJsOffsetToAcePosition(op.p + op.i.length)
|
|
|
|
|
+ else if op.d?
|
|
|
|
|
+ end = start
|
|
|
|
|
+ expected_markers.push { marker_id: background_marker_id, start, end }
|
|
|
|
|
+ expected_markers.push { marker_id: callout_marker_id, start, end: start }
|
|
|
|
|
+
|
|
|
|
|
+ for comment in @changesTracker.comments
|
|
|
|
|
+ if @changeIdToMarkerIdMap[comment.id]?
|
|
|
|
|
+ {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
|
|
|
|
|
+ start = @_shareJsOffsetToAcePosition(comment.offset)
|
|
|
|
|
+ end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
|
|
|
|
|
+ expected_markers.push { marker_id: background_marker_id, start, end }
|
|
|
|
|
+ expected_markers.push { marker_id: callout_marker_id, start, end: start }
|
|
|
|
|
+
|
|
|
|
|
+ for {marker_id, start, end} in expected_markers
|
|
|
marker = markers[marker_id]
|
|
marker = markers[marker_id]
|
|
|
delete markers[marker_id]
|
|
delete markers[marker_id]
|
|
|
if marker.range.start.row != start.row or
|
|
if marker.range.start.row != start.row or
|
|
@@ -63,348 +267,168 @@ 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)
|
|
|
- console.log "Applying change", delta, op
|
|
|
|
|
- @changesTracker.applyOp(op)
|
|
|
|
|
|
|
+ @changesTracker.applyOp(op, metadata)
|
|
|
|
|
+
|
|
|
|
|
+ updateFocus: () ->
|
|
|
|
|
+ selection = @editor.getSelectionRange()
|
|
|
|
|
+ cursor_offset = @_aceRangeToShareJs(selection.start)
|
|
|
|
|
+ entries = @_getCurrentDocEntries()
|
|
|
|
|
+ selection = !(selection.start.column == selection.end.column and selection.start.row == selection.end.row)
|
|
|
|
|
+ @$scope.$emit "editor:focus:changed", cursor_offset, selection
|
|
|
|
|
+
|
|
|
|
|
+ broadcastChange: () ->
|
|
|
|
|
+ @$scope.$emit "editor:track-changes:changed", @$scope.docId
|
|
|
|
|
|
|
|
|
|
+ recalculateReviewEntriesScreenPositions: () ->
|
|
|
|
|
+ session = @editor.getSession()
|
|
|
|
|
+ renderer = @editor.renderer
|
|
|
|
|
+ entries = @_getCurrentDocEntries()
|
|
|
|
|
+ for entry_id, entry of entries or {}
|
|
|
|
|
+ doc_position = @_shareJsOffsetToAcePosition(entry.offset)
|
|
|
|
|
+ screen_position = session.documentToScreenPosition(doc_position.row, doc_position.column)
|
|
|
|
|
+ y = screen_position.row * renderer.lineHeight
|
|
|
|
|
+ entry.screenPos ?= {}
|
|
|
|
|
+ entry.screenPos.y = y
|
|
|
|
|
+ entry.docPos = doc_position
|
|
|
|
|
+
|
|
|
|
|
+ @$scope.$apply()
|
|
|
|
|
+
|
|
|
|
|
+ _getCurrentDocEntries: () ->
|
|
|
|
|
+ doc_id = @$scope.docId
|
|
|
|
|
+ entries = @$scope.reviewPanel.entries[doc_id] ?= {}
|
|
|
|
|
+ return entries
|
|
|
|
|
+
|
|
|
|
|
+ _makeZeroWidthRange: (position) ->
|
|
|
|
|
+ ace_range = new Range(position.row, position.column, position.row, position.column)
|
|
|
|
|
+ # Our delete marker is zero characters wide, but Ace doesn't draw ranges
|
|
|
|
|
+ # that are empty. So we monkey patch the range to tell Ace it's not empty.
|
|
|
|
|
+ # We do want to claim to be empty if we're off screen after clipping rows though.
|
|
|
|
|
+ # This is the code we need to trick:
|
|
|
|
|
+ # var range = marker.range.clipRows(config.firstRow, config.lastRow);
|
|
|
|
|
+ # if (range.isEmpty()) continue;
|
|
|
|
|
+ ace_range.clipRows = (first_row, last_row) ->
|
|
|
|
|
+ @isEmpty = () ->
|
|
|
|
|
+ first_row > @end.row or last_row < @start.row
|
|
|
|
|
+ return @
|
|
|
|
|
+ return ace_range
|
|
|
|
|
+
|
|
|
|
|
+ _createCalloutMarker: (position, klass) ->
|
|
|
|
|
+ session = @editor.getSession()
|
|
|
|
|
+ callout_range = @_makeZeroWidthRange(position)
|
|
|
|
|
+ markerLayer = @editor.renderer.$markerBack
|
|
|
|
|
+ callout_marker_id = session.addMarker callout_range, klass, (html, range, left, top, config) ->
|
|
|
|
|
+ markerLayer.drawSingleLineMarker(html, range, "track-changes-marker-callout #{klass} ace_start", config, 0, "width: auto; right: 0;")
|
|
|
|
|
+
|
|
|
_onInsertAdded: (change) ->
|
|
_onInsertAdded: (change) ->
|
|
|
start = @_shareJsOffsetToAcePosition(change.op.p)
|
|
start = @_shareJsOffsetToAcePosition(change.op.p)
|
|
|
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
|
|
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
|
|
|
session = @editor.getSession()
|
|
session = @editor.getSession()
|
|
|
doc = session.getDocument()
|
|
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")
|
|
|
|
|
- @changeIdToMarkerIdMap[change.id] = marker_id
|
|
|
|
|
-
|
|
|
|
|
|
|
+ background_range = new Range(start.row, start.column, end.row, end.column)
|
|
|
|
|
+ background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-added-marker", "text"
|
|
|
|
|
+ callout_marker_id = @_createCalloutMarker(start, "track-changes-added-marker-callout")
|
|
|
|
|
+ @changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
+
|
|
|
_onDeleteAdded: (change) ->
|
|
_onDeleteAdded: (change) ->
|
|
|
position = @_shareJsOffsetToAcePosition(change.op.p)
|
|
position = @_shareJsOffsetToAcePosition(change.op.p)
|
|
|
session = @editor.getSession()
|
|
session = @editor.getSession()
|
|
|
doc = session.getDocument()
|
|
doc = session.getDocument()
|
|
|
- ace_range = new Range(position.row, position.column, position.row, position.column)
|
|
|
|
|
-
|
|
|
|
|
- # Our delete marker is zero characters wide, but Ace doesn't draw ranges
|
|
|
|
|
- # that are empty. So we monkey patch the range to tell Ace it's not empty.
|
|
|
|
|
- # This is the code we need to trick:
|
|
|
|
|
- # var range = marker.range.clipRows(config.firstRow, config.lastRow);
|
|
|
|
|
- # if (range.isEmpty()) continue;
|
|
|
|
|
- _clipRows = ace_range.clipRows
|
|
|
|
|
- ace_range.clipRows = (args...) ->
|
|
|
|
|
- range = _clipRows.apply(ace_range, args)
|
|
|
|
|
- range.isEmpty = () ->
|
|
|
|
|
- false
|
|
|
|
|
- return range
|
|
|
|
|
|
|
|
|
|
- marker_id = session.addMarker(ace_range, "track-changes-deleted-marker", "text")
|
|
|
|
|
- @changeIdToMarkerIdMap[change.id] = marker_id
|
|
|
|
|
|
|
+ markerLayer = @editor.renderer.$markerBack
|
|
|
|
|
+ klass = "track-changes-marker track-changes-deleted-marker"
|
|
|
|
|
+ background_range = @_makeZeroWidthRange(position)
|
|
|
|
|
+ background_marker_id = session.addMarker background_range, klass, (html, range, left, top, config) ->
|
|
|
|
|
+ markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, "")
|
|
|
|
|
+
|
|
|
|
|
+ callout_marker_id = @_createCalloutMarker(position, "track-changes-deleted-marker-callout")
|
|
|
|
|
+ @changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
|
|
|
_onInsertRemoved: (change) ->
|
|
_onInsertRemoved: (change) ->
|
|
|
- marker_id = @changeIdToMarkerIdMap[change.id]
|
|
|
|
|
|
|
+ {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
|
|
|
|
|
+ delete @changeIdToMarkerIdMap[change.id]
|
|
|
session = @editor.getSession()
|
|
session = @editor.getSession()
|
|
|
- session.removeMarker marker_id
|
|
|
|
|
|
|
+ session.removeMarker background_marker_id
|
|
|
|
|
+ session.removeMarker callout_marker_id
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
|
|
|
_onDeleteRemoved: (change) ->
|
|
_onDeleteRemoved: (change) ->
|
|
|
- marker_id = @changeIdToMarkerIdMap[change.id]
|
|
|
|
|
|
|
+ {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
|
|
|
|
|
+ delete @changeIdToMarkerIdMap[change.id]
|
|
|
session = @editor.getSession()
|
|
session = @editor.getSession()
|
|
|
- session.removeMarker marker_id
|
|
|
|
|
|
|
+ session.removeMarker background_marker_id
|
|
|
|
|
+ session.removeMarker callout_marker_id
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
|
|
|
- _aceChangeToShareJs: (delta) ->
|
|
|
|
|
- start = delta.start
|
|
|
|
|
- lines = @editor.getSession().getDocument().getLines 0, start.row
|
|
|
|
|
- offset = 0
|
|
|
|
|
- for line, i in lines
|
|
|
|
|
- offset += if i < start.row
|
|
|
|
|
- line.length
|
|
|
|
|
- else
|
|
|
|
|
- start.column
|
|
|
|
|
- offset += start.row # Include newlines
|
|
|
|
|
|
|
+ _onCommentAdded: (comment) ->
|
|
|
|
|
+ if !@changeIdToMarkerIdMap[comment.id]?
|
|
|
|
|
+ # Only create new markers if they don't already exist
|
|
|
|
|
+ start = @_shareJsOffsetToAcePosition(comment.offset)
|
|
|
|
|
+ end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
|
|
|
|
|
+ session = @editor.getSession()
|
|
|
|
|
+ doc = session.getDocument()
|
|
|
|
|
+ background_range = new Range(start.row, start.column, end.row, end.column)
|
|
|
|
|
+ background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-comment-marker", "text"
|
|
|
|
|
+ callout_marker_id = @_createCalloutMarker(start, "track-changes-comment-marker-callout")
|
|
|
|
|
+ @changeIdToMarkerIdMap[comment.id] = { background_marker_id, callout_marker_id }
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
+
|
|
|
|
|
+ _onCommentRemoved: (comment) ->
|
|
|
|
|
+ if @changeIdToMarkerIdMap[comment.id]?
|
|
|
|
|
+ # Resolved comments may not have marker ids
|
|
|
|
|
+ {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
|
|
|
|
|
+ delete @changeIdToMarkerIdMap[comment.id]
|
|
|
|
|
+ session = @editor.getSession()
|
|
|
|
|
+ session.removeMarker background_marker_id
|
|
|
|
|
+ session.removeMarker callout_marker_id
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
|
|
|
- text = delta.lines.join('\n')
|
|
|
|
|
- switch delta.action
|
|
|
|
|
- when 'insert'
|
|
|
|
|
- return { i: text, p: offset }
|
|
|
|
|
- when 'remove'
|
|
|
|
|
- return { d: text, p: offset }
|
|
|
|
|
- else throw new Error "unknown action: #{delta.action}"
|
|
|
|
|
|
|
+ _aceRangeToShareJs: (range) ->
|
|
|
|
|
+ lines = @editor.getSession().getDocument().getLines 0, range.row
|
|
|
|
|
+ return AceShareJsCodec.aceRangeToShareJs(range, lines)
|
|
|
|
|
+
|
|
|
|
|
+ _aceChangeToShareJs: (delta) ->
|
|
|
|
|
+ lines = @editor.getSession().getDocument().getLines 0, delta.start.row
|
|
|
|
|
+ return AceShareJsCodec.aceChangeToShareJs(delta, lines)
|
|
|
|
|
|
|
|
_shareJsOffsetToAcePosition: (offset) ->
|
|
_shareJsOffsetToAcePosition: (offset) ->
|
|
|
lines = @editor.getSession().getDocument().getAllLines()
|
|
lines = @editor.getSession().getDocument().getAllLines()
|
|
|
- row = 0
|
|
|
|
|
- for line, row in lines
|
|
|
|
|
- break if offset <= line.length
|
|
|
|
|
- offset -= lines[row].length + 1 # + 1 for newline char
|
|
|
|
|
- return {row:row, column:offset}
|
|
|
|
|
|
|
+ return AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
|
|
|
|
|
|
|
|
_onChangesMoved: (changes) ->
|
|
_onChangesMoved: (changes) ->
|
|
|
- session = @editor.getSession()
|
|
|
|
|
- markers = session.getMarkers()
|
|
|
|
|
|
|
+ # TODO: PERFORMANCE: Only run through the Ace lines once, and calculate all
|
|
|
|
|
+ # change positions as we go.
|
|
|
for change in changes
|
|
for change in changes
|
|
|
start = @_shareJsOffsetToAcePosition(change.op.p)
|
|
start = @_shareJsOffsetToAcePosition(change.op.p)
|
|
|
if change.op.i?
|
|
if change.op.i?
|
|
|
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
|
|
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
|
|
|
else
|
|
else
|
|
|
end = start
|
|
end = start
|
|
|
- marker_id = @changeIdToMarkerIdMap[change.id]
|
|
|
|
|
- marker = markers[marker_id]
|
|
|
|
|
- console.log "moving marker", {marker, start, end, change}
|
|
|
|
|
- marker.range.start = start
|
|
|
|
|
- marker.range.end = end
|
|
|
|
|
-
|
|
|
|
|
- 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
|
|
|
|
|
|
|
+ @_updateMarker(change.id, start, end)
|
|
|
|
|
+ @editor.renderer.updateBackMarkers()
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
|
|
|
- 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
|
|
|
|
|
|
|
+ _onCommentMoved: (comment) ->
|
|
|
|
|
+ start = @_shareJsOffsetToAcePosition(comment.offset)
|
|
|
|
|
+ end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
|
|
|
|
|
+ @_updateMarker(comment.id, start, end)
|
|
|
|
|
+ @editor.renderer.updateBackMarkers()
|
|
|
|
|
+ @broadcastChange()
|
|
|
|
|
+
|
|
|
|
|
+ _updateMarker: (change_id, start, end) ->
|
|
|
|
|
+ return if !@changeIdToMarkerIdMap[change_id]?
|
|
|
|
|
+ session = @editor.getSession()
|
|
|
|
|
+ markers = session.getMarkers()
|
|
|
|
|
+ {background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change_id]
|
|
|
|
|
+ if background_marker_id?
|
|
|
|
|
+ background_marker = markers[background_marker_id]
|
|
|
|
|
+ background_marker.range.start = start
|
|
|
|
|
+ background_marker.range.end = end
|
|
|
|
|
+ if callout_marker_id?
|
|
|
|
|
+ callout_marker = markers[callout_marker_id]
|
|
|
|
|
+ callout_marker.range.start = start
|
|
|
|
|
+ callout_marker.range.end = start
|
|
|
|
|
|
|
|
- return TrackChangesManager
|
|
|