Add basic change tracking into editor behind a feature flag

This commit is contained in:
James Allen 2016-10-10 17:06:46 +01:00
parent 1c9abd35f8
commit 0995ba5ee6
4 changed files with 431 additions and 2 deletions

View file

@ -7,7 +7,8 @@ define [
"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
"ide/editor/directives/aceEditor/highlights/HighlightsManager"
"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
], (App, Ace, SearchBox, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager) ->
"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
], (App, Ace, SearchBox, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager) ->
EditSession = ace.require('ace/edit_session').EditSession
# set the path for ace workers if using a CDN (from editor.jade)
@ -68,6 +69,9 @@ define [
undoManager = new UndoManager(scope, editor, element)
highlightsManager = new HighlightsManager(scope, editor, element)
cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage)
trackChangesManager = new TrackChangesManager(scope, editor, element)
if window.location.search.match /tcon/ # track changes on
trackChangesManager.enabled = true
# Prevert Ctrl|Cmd-S from triggering save dialog
editor.commands.addCommand
@ -217,7 +221,9 @@ define [
sharejs_doc.on "remoteop.recordForUndo", () =>
undoManager.nextUpdateIsRemote = true
editor.initing = true
sharejs_doc.attachToAce(editor)
editor.initing = false
# need to set annotations after attaching because attaching
# deletes and then inserts document content
session.setAnnotations scope.annotations

View file

@ -0,0 +1,410 @@
define [
"ace/ace"
"utils/EventEmitter"
], (_, EventEmitter) ->
class TrackChangesManager
Range = ace.require("ace/range").Range
constructor: (@$scope, @editor, @element) ->
@changesTracker = new ChangesTracker()
@changeIdToMarkerIdMap = {}
@enabled = false
@changesTracker.on "insert:added", (change) =>
@_onInsertAdded(change)
@changesTracker.on "insert:removed", (change) =>
@_onInsertRemoved(change)
@changesTracker.on "delete:added", (change) =>
@_onDeleteAdded(change)
@changesTracker.on "delete:removed", (change) =>
@_onDeleteRemoved(change)
@changesTracker.on "changes:moved", (changes) =>
@_onChangesMoved(changes)
onChange = (e) =>
if !@editor.initing and @enabled
@applyChange(e)
setTimeout () =>
@checkMapping()
, 100
@editor.on "changeSession", (e) =>
e.oldSession?.getDocument().off "change", onChange
e.session.getDocument().on "change", onChange
@editor.getSession().getDocument().on "change", onChange
checkMapping: () ->
session = @editor.getSession()
# Make a copy of session.getMarkers() so we can modify it
markers = {}
for marker_id, marker of session.getMarkers()
markers[marker_id] = marker
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
marker = markers[marker_id]
delete markers[marker_id]
if marker.range.start.row != start.row or
marker.range.start.column != start.column or
marker.range.end.row != end.row or
marker.range.end.column != end.column
console.error "Change doesn't match marker anymore", {change, marker, start, end}
for marker_id, marker of markers
if marker.clazz.match("track-changes")
console.error "Orphaned ace marker", marker
applyChange: (delta) ->
op = @_aceChangeToShareJs(delta)
console.log "Applying change", delta, op
@changesTracker.applyOp(op)
_onInsertAdded: (change) ->
start = @_shareJsOffsetToAcePosition(change.op.p)
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
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")
@changeIdToMarkerIdMap[change.id] = marker_id
_onDeleteAdded: (change) ->
position = @_shareJsOffsetToAcePosition(change.op.p)
session = @editor.getSession()
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
_onInsertRemoved: (change) ->
marker_id = @changeIdToMarkerIdMap[change.id]
session = @editor.getSession()
session.removeMarker marker_id
_onDeleteRemoved: (change) ->
marker_id = @changeIdToMarkerIdMap[change.id]
session = @editor.getSession()
session.removeMarker marker_id
_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
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}"
_shareJsOffsetToAcePosition: (offset) ->
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}
_onChangesMoved: (changes) ->
session = @editor.getSession()
markers = session.getMarkers()
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
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
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

View file

@ -30,4 +30,6 @@ define [], () ->
trigger: (event, args...) ->
@events ||= {}
for callback in @events[event] or []
callback.callback(args...)
callback.callback(args...)
emit: (args...) -> @trigger(args...)

View file

@ -146,6 +146,17 @@
background-repeat: repeat-x;
background-position: bottom left;
}
.track-changes-added-marker {
border-radius: 0;
position: absolute;
background-color: hsl(100, 70%, 70%);
}
.track-changes-deleted-marker {
border-radius: 0;
position: absolute;
border-left: 2px dotted red;
margin-left: -1px;
}
.remote-cursor {
position: absolute;
border-left: 2px solid transparent;