diff --git a/services/web/app/views/project/editor.jade b/services/web/app/views/project/editor.jade
index 86088ded50..2dfec5694c 100644
--- a/services/web/app/views/project/editor.jade
+++ b/services/web/app/views/project/editor.jade
@@ -66,7 +66,7 @@ block content
.ui-layout-center
include ./editor/editor
include ./editor/binary-file
- include ./editor/track-changes
+ include ./editor/history
include ./editor/publish-template
.ui-layout-east
diff --git a/services/web/app/views/project/editor/file-tree.jade b/services/web/app/views/project/editor/file-tree.jade
index b32bdbed23..92af8b627d 100644
--- a/services/web/app/views/project/editor/file-tree.jade
+++ b/services/web/app/views/project/editor/file-tree.jade
@@ -70,13 +70,13 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']"
)
- li(ng-show="deletedDocs.length > 0 && ui.view == 'track-changes'")
+ li(ng-show="deletedDocs.length > 0 && ui.view == 'history'")
h3 #{translate("deleted_files")}
li(
ng-class="{ 'selected': entity.selected }",
ng-repeat="entity in deletedDocs | orderBy:'name'",
ng-controller="FileTreeEntityController",
- ng-show="ui.view == 'track-changes'"
+ ng-show="ui.view == 'history'"
)
.entity
.entity-name(
diff --git a/services/web/app/views/project/editor/header.jade b/services/web/app/views/project/editor/header.jade
index db691265b6..02cec9d2ca 100644
--- a/services/web/app/views/project/editor/header.jade
+++ b/services/web/app/views/project/editor/header.jade
@@ -102,8 +102,8 @@ div(ng-if="!shouldABTestHeaderLabels")
i.fa.fa-fw.fa-group
a.btn.btn-full-height(
href,
- ng-click="toggleTrackChanges()",
- ng-class="{ active: (ui.view == 'track-changes') }"
+ ng-click="toggleHistory()",
+ ng-class="{ active: (ui.view == 'history') }"
tooltip="#{translate('recent_changes')}",
tooltip-placement="bottom",
)
@@ -232,8 +232,8 @@ div(ng-if="shouldABTestHeaderLabels")
i.fa.fa-fw.fa-group
a.btn.btn-full-height(
href,
- ng-click="toggleTrackChanges(); trackABTestConversion('history');",
- ng-class="{ active: (ui.view == 'track-changes') }"
+ ng-click="toggleHistory(); trackABTestConversion('history');",
+ ng-class="{ active: (ui.view == 'history') }"
tooltip="#{translate('recent_changes')}",
tooltip-placement="bottom",
sixpack-convert="editor-header"
@@ -357,8 +357,8 @@ div(ng-if="shouldABTestHeaderLabels")
p.toolbar-label #{translate("share")}
a.btn.btn-full-height(
href,
- ng-click="toggleTrackChanges(); trackABTestConversion('history');",
- ng-class="{ active: (ui.view == 'track-changes') }",
+ ng-click="toggleHistory(); trackABTestConversion('history');",
+ ng-class="{ active: (ui.view == 'history') }",
sixpack-convert="editor-header"
)
i.fa.fa-fw.fa-history
diff --git a/services/web/app/views/project/editor/track-changes.jade b/services/web/app/views/project/editor/history.jade
similarity index 76%
rename from services/web/app/views/project/editor/track-changes.jade
rename to services/web/app/views/project/editor/history.jade
index de3c0e1730..9cf756c344 100644
--- a/services/web/app/views/project/editor/track-changes.jade
+++ b/services/web/app/views/project/editor/history.jade
@@ -1,5 +1,5 @@
-div#trackChanges(ng-show="ui.view == 'track-changes'")
- span(ng-controller="TrackChangesPremiumPopup")
+div#history(ng-show="ui.view == 'history'")
+ span(ng-controller="HistoryPremiumPopup")
.upgrade-prompt(ng-show="!project.features.versioning")
.message(ng-show="project.owner._id == user.id")
p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
@@ -33,29 +33,29 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
a.btn.btn-success(
href
ng-class="buttonClass"
- ng-click="startFreeTrial('track-changes')"
+ ng-click="startFreeTrial('history')"
) #{translate("start_free_trial")}
.message(ng-show="project.owner._id != user.id")
p #{translate("ask_proj_owner_to_upgrade_for_history")}
p
- a.small(href, ng-click="toggleTrackChanges()") #{translate("cancel")}
+ a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
aside.change-list(
- ng-controller="TrackChangesListController"
+ ng-controller="HistoryListController"
infinite-scroll="loadMore()"
- infinite-scroll-disabled="trackChanges.loading || trackChanges.atEnd"
- infinite-scroll-initialize="ui.view == 'track-changes'"
+ infinite-scroll-disabled="history.loading || history.atEnd"
+ infinite-scroll-initialize="ui.view == 'history'"
)
.infinite-scroll-inner
ul.list-unstyled(
ng-class="{\
- 'hover-state': trackChanges.hoveringOverListSelectors\
+ 'hover-state': history.hoveringOverListSelectors\
}"
)
li.change(
- ng-repeat="update in trackChanges.updates"
+ ng-repeat="update in history.updates"
ng-class="{\
'first-in-day': update.meta.first_in_day,\
'selected': update.inSelection,\
@@ -65,7 +65,7 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
'hover-selected-to': update.hoverSelectedTo,\
'hover-selected-from': update.hoverSelectedFrom,\
}"
- ng-controller="TrackChangesListItemController"
+ ng-controller="HistoryListItemController"
)
div.day(ng-show="update.meta.first_in_day") {{ update.meta.end_ts | relativeDate }}
@@ -108,55 +108,55 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
.color-square(style="background-color: hsl(100, 100%, 50%)")
span #{translate("anonymous")}
- .loading(ng-show="trackChanges.loading")
+ .loading(ng-show="history.loading")
i.fa.fa-spin.fa-refresh
| #{translate("loading")}...
- .diff-panel.full-size(ng-controller="TrackChangesDiffController")
+ .diff-panel.full-size(ng-controller="HistoryDiffController")
.diff(
- ng-show="!!trackChanges.diff && !trackChanges.diff.loading && !trackChanges.diff.deleted && !trackChanges.diff.error"
+ ng-show="!!history.diff && !history.diff.loading && !history.diff.deleted && !history.diff.error"
)
.toolbar.toolbar-alt
span.name
- | {{trackChanges.diff.highlights.length}}
+ | {{history.diff.highlights.length}}
ng-pluralize(
- count="trackChanges.diff.highlights.length",
+ count="history.diff.highlights.length",
when="{\
'one': 'change',\
'other': 'changes'\
}"
)
- | in {{trackChanges.diff.doc.name}}
+ | in {{history.diff.doc.name}}
.toolbar-right
a.btn.btn-danger.btn-sm(
href,
ng-click="openRestoreDiffModal()"
) #{translate("restore_to_before_these_changes")}
.diff-editor.hide-ace-cursor(
- ace-editor="track-changes",
+ ace-editor="history",
theme="settings.theme",
font-size="settings.fontSize",
- text="trackChanges.diff.text",
- highlights="trackChanges.diff.highlights",
+ text="history.diff.text",
+ highlights="history.diff.highlights",
read-only="true",
resize-on="layout:main:resize",
navigate-highlights="true"
)
.diff-deleted.text-centered(
- ng-show="trackChanges.diff.deleted && !trackChanges.diff.restoreDeletedSuccess"
+ ng-show="history.diff.deleted && !history.diff.restoreDeletedSuccess"
)
- p.text-serif #{translate("file_has_been_deleted", {filename:"{{ trackChanges.diff.doc.name }} "})}
+ p.text-serif #{translate("file_has_been_deleted", {filename:"{{ history.diff.doc.name }} "})}
p
a.btn.btn-primary.btn-lg(
href,
ng-click="restoreDeletedDoc()",
- ng-disabled="trackChanges.diff.restoreInProgress"
+ ng-disabled="history.diff.restoreInProgress"
) #{translate("restore")}
.diff-deleted.text-centered(
- ng-show="trackChanges.diff.deleted && trackChanges.diff.restoreDeletedSuccess"
+ ng-show="history.diff.deleted && history.diff.restoreDeletedSuccess"
)
- p.text-serif #{translate("file_restored", {filename:"{{ trackChanges.diff.doc.name }} "})}
+ p.text-serif #{translate("file_restored", {filename:"{{ history.diff.doc.name }} "})}
p.text-serif #{translate("file_restored_back_to_editor")}
p
a.btn.btn-default(
@@ -164,13 +164,13 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
ng-click="backToEditorAfterRestore()",
) #{translate("file_restored_back_to_editor_btn")}
- .loading-panel(ng-show="trackChanges.diff.loading")
+ .loading-panel(ng-show="history.diff.loading")
i.fa.fa-spin.fa-refresh
| #{translate("loading")}...
- .error-panel(ng-show="trackChanges.diff.error")
+ .error-panel(ng-show="history.diff.error")
.alert.alert-danger #{translate("generic_something_went_wrong")}
-script(type="text/ng-template", id="trackChangesRestoreDiffModalTemplate")
+script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
.modal-header
button.close(
type="button"
diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee
index 201d324821..c8c0da5b59 100644
--- a/services/web/public/coffee/ide.coffee
+++ b/services/web/public/coffee/ide.coffee
@@ -4,7 +4,7 @@ define [
"ide/connection/ConnectionManager"
"ide/editor/EditorManager"
"ide/online-users/OnlineUsersManager"
- "ide/track-changes/TrackChangesManager"
+ "ide/history/HistoryManager"
"ide/permissions/PermissionsManager"
"ide/pdf/PdfManager"
"ide/binary-files/BinaryFilesManager"
@@ -36,7 +36,7 @@ define [
ConnectionManager
EditorManager
OnlineUsersManager
- TrackChangesManager
+ HistoryManager
PermissionsManager
PdfManager
BinaryFilesManager
@@ -120,7 +120,7 @@ define [
ide.fileTreeManager = new FileTreeManager(ide, $scope)
ide.editorManager = new EditorManager(ide, $scope)
ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
- ide.trackChangesManager = new TrackChangesManager(ide, $scope)
+ ide.historyManager = new HistoryManager(ide, $scope)
ide.pdfManager = new PdfManager(ide, $scope)
ide.permissionsManager = new PermissionsManager(ide, $scope)
ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
index 0d5bffbf24..2dc9440ffa 100644
--- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee
@@ -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)
@@ -69,6 +70,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=true/ # track changes on
+ trackChangesManager.enabled = true
# Prevert Ctrl|Cmd-S from triggering save dialog
editor.commands.addCommand
@@ -222,7 +226,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
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
new file mode 100644
index 0000000000..1c092ad190
--- /dev/null
+++ b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee
@@ -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
\ No newline at end of file
diff --git a/services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee b/services/web/public/coffee/ide/history/HistoryManager.coffee
similarity index 76%
rename from services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee
rename to services/web/public/coffee/ide/history/HistoryManager.coffee
index 10b6bb9765..12de2149d8 100644
--- a/services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee
+++ b/services/web/public/coffee/ide/history/HistoryManager.coffee
@@ -1,31 +1,31 @@
define [
"moment"
- "ide/track-changes/controllers/TrackChangesListController"
- "ide/track-changes/controllers/TrackChangesDiffController"
- "ide/track-changes/directives/infiniteScroll"
+ "ide/history/controllers/HistoryListController"
+ "ide/history/controllers/HistoryDiffController"
+ "ide/history/directives/infiniteScroll"
], (moment) ->
- class TrackChangesManager
+ class HistoryManager
constructor: (@ide, @$scope) ->
@reset()
- @$scope.toggleTrackChanges = () =>
- if @$scope.ui.view == "track-changes"
+ @$scope.toggleHistory = () =>
+ if @$scope.ui.view == "history"
@hide()
else
@show()
- @$scope.$watch "trackChanges.selection.updates", (updates) =>
+ @$scope.$watch "history.selection.updates", (updates) =>
if updates? and updates.length > 0
@_selectDocFromUpdates()
@reloadDiff()
@$scope.$on "entity:selected", (event, entity) =>
- if (@$scope.ui.view == "track-changes") and (entity.type == "doc")
- @$scope.trackChanges.selection.doc = entity
+ if (@$scope.ui.view == "history") and (entity.type == "doc")
+ @$scope.history.selection.doc = entity
@reloadDiff()
show: () ->
- @$scope.ui.view = "track-changes"
+ @$scope.ui.view = "history"
@reset()
hide: () ->
@@ -34,7 +34,7 @@ define [
@$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity()
reset: () ->
- @$scope.trackChanges = {
+ @$scope.history = {
updates: []
nextBeforeTimestamp: null
atEnd: false
@@ -52,36 +52,36 @@ define [
}
autoSelectRecentUpdates: () ->
- return if @$scope.trackChanges.updates.length == 0
+ return if @$scope.history.updates.length == 0
- @$scope.trackChanges.updates[0].selectedTo = true
+ @$scope.history.updates[0].selectedTo = true
indexOfLastUpdateNotByMe = 0
- for update, i in @$scope.trackChanges.updates
+ for update, i in @$scope.history.updates
if @_updateContainsUserId(update, @$scope.user.id)
break
indexOfLastUpdateNotByMe = i
- @$scope.trackChanges.updates[indexOfLastUpdateNotByMe].selectedFrom = true
+ @$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
BATCH_SIZE: 10
fetchNextBatchOfUpdates: () ->
url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
- if @$scope.trackChanges.nextBeforeTimestamp?
- url += "&before=#{@$scope.trackChanges.nextBeforeTimestamp}"
- @$scope.trackChanges.loading = true
+ if @$scope.history.nextBeforeTimestamp?
+ url += "&before=#{@$scope.history.nextBeforeTimestamp}"
+ @$scope.history.loading = true
@ide.$http
.get(url)
.success (data) =>
@_loadUpdates(data.updates)
- @$scope.trackChanges.nextBeforeTimestamp = data.nextBeforeTimestamp
+ @$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp
if !data.nextBeforeTimestamp?
- @$scope.trackChanges.atEnd = true
- @$scope.trackChanges.loading = false
+ @$scope.history.atEnd = true
+ @$scope.history.loading = false
reloadDiff: () ->
- diff = @$scope.trackChanges.diff
- {updates, doc} = @$scope.trackChanges.selection
+ diff = @$scope.history.diff
+ {updates, doc} = @$scope.history.selection
{fromV, toV, start_ts, end_ts} = @_calculateRangeFromSelection()
return if !doc?
@@ -91,7 +91,7 @@ define [
diff.fromV == fromV and
diff.toV == toV
- @$scope.trackChanges.diff = diff = {
+ @$scope.history.diff = diff = {
fromV: fromV
toV: toV
start_ts: start_ts
@@ -184,7 +184,7 @@ define [
return {text, highlights}
_loadUpdates: (updates = []) ->
- previousUpdate = @$scope.trackChanges.updates[@$scope.trackChanges.updates.length - 1]
+ previousUpdate = @$scope.history.updates[@$scope.history.updates.length - 1]
for update in updates
for doc_id, doc of update.docs or {}
@@ -203,19 +203,19 @@ define [
previousUpdate = update
- firstLoad = @$scope.trackChanges.updates.length == 0
+ firstLoad = @$scope.history.updates.length == 0
- @$scope.trackChanges.updates =
- @$scope.trackChanges.updates.concat(updates)
+ @$scope.history.updates =
+ @$scope.history.updates.concat(updates)
@autoSelectRecentUpdates() if firstLoad
_calculateRangeFromSelection: () ->
fromV = toV = start_ts = end_ts = null
- selected_doc_id = @$scope.trackChanges.selection.doc?.id
+ selected_doc_id = @$scope.history.selection.doc?.id
- for update in @$scope.trackChanges.selection.updates or []
+ for update in @$scope.history.selection.updates or []
for doc_id, doc of update.docs
if doc_id == selected_doc_id
if fromV? and toV?
@@ -237,11 +237,11 @@ define [
# then prefer this one if present.
_selectDocFromUpdates: () ->
affected_docs = {}
- for update in @$scope.trackChanges.selection.updates
+ for update in @$scope.history.selection.updates
for doc_id, doc of update.docs
affected_docs[doc_id] = doc.entity
- selected_doc = @$scope.trackChanges.selection.doc
+ selected_doc = @$scope.history.selection.doc
if selected_doc? and affected_docs[selected_doc.id]?
# Selected doc is already open
else
@@ -249,7 +249,7 @@ define [
selected_doc = doc
break
- @$scope.trackChanges.selection.doc = selected_doc
+ @$scope.history.selection.doc = selected_doc
@ide.fileTreeManager.selectEntity(selected_doc)
_updateContainsUserId: (update, user_id) ->
diff --git a/services/web/public/coffee/ide/history/controllers/HistoryDiffController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryDiffController.coffee
new file mode 100644
index 0000000000..d32155395e
--- /dev/null
+++ b/services/web/public/coffee/ide/history/controllers/HistoryDiffController.coffee
@@ -0,0 +1,46 @@
+define [
+ "base"
+], (App) ->
+ App.controller "HistoryDiffController", ($scope, $modal, ide, event_tracking) ->
+ $scope.restoreDeletedDoc = () ->
+ event_tracking.sendMB "history-restore-deleted"
+ $scope.history.diff.restoreInProgress = true
+ ide.historyManager
+ .restoreDeletedDoc(
+ $scope.history.diff.doc
+ )
+ .success (response) ->
+ $scope.history.diff.restoredDocNewId = response.doc_id
+ $scope.history.diff.restoreInProgress = false
+ $scope.history.diff.restoreDeletedSuccess = true
+
+ $scope.openRestoreDiffModal = () ->
+ event_tracking.sendMB "history-restore-modal"
+ $modal.open {
+ templateUrl: "historyRestoreDiffModalTemplate"
+ controller: "HistoryRestoreDiffModalController"
+ resolve:
+ diff: () -> $scope.history.diff
+ }
+
+ $scope.backToEditorAfterRestore = () ->
+ ide.editorManager.openDoc({ id: $scope.history.diff.restoredDocNewId })
+
+ App.controller "HistoryRestoreDiffModalController", ($scope, $modalInstance, diff, ide, event_tracking) ->
+ $scope.state =
+ inflight: false
+
+ $scope.diff = diff
+
+ $scope.restore = () ->
+ event_tracking.sendMB "history-restored"
+ $scope.state.inflight = true
+ ide.historyManager
+ .restoreDiff(diff)
+ .success () ->
+ $scope.state.inflight = false
+ $modalInstance.close()
+ ide.editorManager.openDoc(diff.doc)
+
+ $scope.cancel = () ->
+ $modalInstance.dismiss()
diff --git a/services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
similarity index 69%
rename from services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee
rename to services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
index 3511ddf6c1..d3ca0c50f2 100644
--- a/services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee
+++ b/services/web/public/coffee/ide/history/controllers/HistoryListController.coffee
@@ -2,26 +2,26 @@ define [
"base"
], (App) ->
- App.controller "TrackChangesPremiumPopup", ($scope, ide, sixpack)->
+ App.controller "HistoryPremiumPopup", ($scope, ide, sixpack)->
$scope.$watch "ui.view", ->
- if $scope.ui.view == "track-changes"
+ if $scope.ui.view == "history"
if $scope.project?.features?.versioning
$scope.versioningPopupType = "default"
- else if $scope.ui.view == "track-changes"
- sixpack.participate 'track-changes-discount', ['default', 'discount'], (chosenVariation, rawResponse)->
+ else if $scope.ui.view == "history"
+ sixpack.participate 'history-discount', ['default', 'discount'], (chosenVariation, rawResponse)->
$scope.versioningPopupType = chosenVariation
- App.controller "TrackChangesListController", ["$scope", "ide", ($scope, ide) ->
+ App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
$scope.hoveringOverListSelectors = false
$scope.loadMore = () =>
- ide.trackChangesManager.fetchNextBatchOfUpdates()
+ ide.historyManager.fetchNextBatchOfUpdates()
$scope.recalculateSelectedUpdates = () ->
beforeSelection = true
afterSelection = false
- $scope.trackChanges.selection.updates = []
- for update in $scope.trackChanges.updates
+ $scope.history.selection.updates = []
+ for update in $scope.history.updates
if update.selectedTo
inSelection = true
beforeSelection = false
@@ -31,7 +31,7 @@ define [
update.afterSelection = afterSelection
if inSelection
- $scope.trackChanges.selection.updates.push update
+ $scope.history.selection.updates.push update
if update.selectedFrom
inSelection = false
@@ -40,7 +40,7 @@ define [
$scope.recalculateHoveredUpdates = () ->
hoverSelectedFrom = false
hoverSelectedTo = false
- for update in $scope.trackChanges.updates
+ for update in $scope.history.updates
# Figure out whether the to or from selector is hovered over
if update.hoverSelectedFrom
hoverSelectedFrom = true
@@ -50,7 +50,7 @@ define [
if hoverSelectedFrom
# We want to 'hover select' everything between hoverSelectedFrom and selectedTo
inHoverSelection = false
- for update in $scope.trackChanges.updates
+ for update in $scope.history.updates
if update.selectedTo
update.hoverSelectedTo = true
inHoverSelection = true
@@ -60,7 +60,7 @@ define [
if hoverSelectedTo
# We want to 'hover select' everything between hoverSelectedTo and selectedFrom
inHoverSelection = false
- for update in $scope.trackChanges.updates
+ for update in $scope.history.updates
if update.hoverSelectedTo
inHoverSelection = true
update.inHoverSelection = inHoverSelection
@@ -69,49 +69,49 @@ define [
inHoverSelection = false
$scope.resetHoverState = () ->
- for update in $scope.trackChanges.updates
+ for update in $scope.history.updates
delete update.hoverSelectedFrom
delete update.hoverSelectedTo
delete update.inHoverSelection
- $scope.$watch "trackChanges.updates.length", () ->
+ $scope.$watch "history.updates.length", () ->
$scope.recalculateSelectedUpdates()
]
- App.controller "TrackChangesListItemController", ["$scope", "event_tracking", ($scope, event_tracking) ->
+ App.controller "HistoryListItemController", ["$scope", "event_tracking", ($scope, event_tracking) ->
$scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) ->
if selectedFrom
- for update in $scope.trackChanges.updates
+ for update in $scope.history.updates
update.selectedFrom = false unless update == $scope.update
$scope.recalculateSelectedUpdates()
$scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) ->
if selectedTo
- for update in $scope.trackChanges.updates
+ for update in $scope.history.updates
update.selectedTo = false unless update == $scope.update
$scope.recalculateSelectedUpdates()
$scope.select = () ->
- event_tracking.sendMB "track-changes-view-change"
+ event_tracking.sendMB "history-view-change"
$scope.update.selectedTo = true
$scope.update.selectedFrom = true
$scope.mouseOverSelectedFrom = () ->
- $scope.trackChanges.hoveringOverListSelectors = true
+ $scope.history.hoveringOverListSelectors = true
$scope.update.hoverSelectedFrom = true
$scope.recalculateHoveredUpdates()
$scope.mouseOutSelectedFrom = () ->
- $scope.trackChanges.hoveringOverListSelectors = false
+ $scope.history.hoveringOverListSelectors = false
$scope.resetHoverState()
$scope.mouseOverSelectedTo = () ->
- $scope.trackChanges.hoveringOverListSelectors = true
+ $scope.history.hoveringOverListSelectors = true
$scope.update.hoverSelectedTo = true
$scope.recalculateHoveredUpdates()
$scope.mouseOutSelectedTo = () ->
- $scope.trackChanges.hoveringOverListSelectors = false
+ $scope.history.hoveringOverListSelectors = false
$scope.resetHoverState()
$scope.displayName = (user) ->
diff --git a/services/web/public/coffee/ide/track-changes/directives/infiniteScroll.coffee b/services/web/public/coffee/ide/history/directives/infiniteScroll.coffee
similarity index 100%
rename from services/web/public/coffee/ide/track-changes/directives/infiniteScroll.coffee
rename to services/web/public/coffee/ide/history/directives/infiniteScroll.coffee
diff --git a/services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee b/services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee
deleted file mode 100644
index a32acfb2b3..0000000000
--- a/services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee
+++ /dev/null
@@ -1,46 +0,0 @@
-define [
- "base"
-], (App) ->
- App.controller "TrackChangesDiffController", ($scope, $modal, ide, event_tracking) ->
- $scope.restoreDeletedDoc = () ->
- event_tracking.sendMB "track-changes-restore-deleted"
- $scope.trackChanges.diff.restoreInProgress = true
- ide.trackChangesManager
- .restoreDeletedDoc(
- $scope.trackChanges.diff.doc
- )
- .success (response) ->
- $scope.trackChanges.diff.restoredDocNewId = response.doc_id
- $scope.trackChanges.diff.restoreInProgress = false
- $scope.trackChanges.diff.restoreDeletedSuccess = true
-
- $scope.openRestoreDiffModal = () ->
- event_tracking.sendMB "track-changes-restore-modal"
- $modal.open {
- templateUrl: "trackChangesRestoreDiffModalTemplate"
- controller: "TrackChangesRestoreDiffModalController"
- resolve:
- diff: () -> $scope.trackChanges.diff
- }
-
- $scope.backToEditorAfterRestore = () ->
- ide.editorManager.openDoc({ id: $scope.trackChanges.diff.restoredDocNewId })
-
- App.controller "TrackChangesRestoreDiffModalController", ($scope, $modalInstance, diff, ide, event_tracking) ->
- $scope.state =
- inflight: false
-
- $scope.diff = diff
-
- $scope.restore = () ->
- event_tracking.sendMB "track-changes-restored"
- $scope.state.inflight = true
- ide.trackChangesManager
- .restoreDiff(diff)
- .success () ->
- $scope.state.inflight = false
- $modalInstance.close()
- ide.editorManager.openDoc(diff.doc)
-
- $scope.cancel = () ->
- $modalInstance.dismiss()
diff --git a/services/web/public/coffee/utils/EventEmitter.coffee b/services/web/public/coffee/utils/EventEmitter.coffee
index 12c34e73f0..b0fe5f1a36 100644
--- a/services/web/public/coffee/utils/EventEmitter.coffee
+++ b/services/web/public/coffee/utils/EventEmitter.coffee
@@ -30,4 +30,6 @@ define [], () ->
trigger: (event, args...) ->
@events ||= {}
for callback in @events[event] or []
- callback.callback(args...)
\ No newline at end of file
+ callback.callback(args...)
+
+ emit: (args...) -> @trigger(args...)
diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less
index 4b38201aff..c8773b07d8 100644
--- a/services/web/public/stylesheets/app/editor.less
+++ b/services/web/public/stylesheets/app/editor.less
@@ -1,5 +1,5 @@
@import "./editor/file-tree.less";
-@import "./editor/track-changes.less";
+@import "./editor/history.less";
@import "./editor/toolbar.less";
@import "./editor/left-menu.less";
@import "./editor/pdf.less";
@@ -140,6 +140,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;
@@ -416,4 +427,4 @@
.dropbox-teaser-video {
width: 100%;
height: auto;
-}
\ No newline at end of file
+}
diff --git a/services/web/public/stylesheets/app/editor/track-changes.less b/services/web/public/stylesheets/app/editor/history.less
similarity index 99%
rename from services/web/public/stylesheets/app/editor/track-changes.less
rename to services/web/public/stylesheets/app/editor/history.less
index 6cf98e53eb..cb88be62c1 100644
--- a/services/web/public/stylesheets/app/editor/track-changes.less
+++ b/services/web/public/stylesheets/app/editor/history.less
@@ -8,7 +8,7 @@
@range-bar-color: @link-color;
@range-bar-selected-offset: 14px;
-#trackChanges {
+#history {
.upgrade-prompt {
position: absolute;
top: 0;
@@ -272,7 +272,7 @@
}
.editor-dark {
- #trackChanges {
+ #history {
aside.change-list {
border-color: @editor-dark-toolbar-border-color;