Merge pull request #338 from sharelatex/ja-track-changes

Ja track changes
This commit is contained in:
James Allen 2016-10-12 09:21:46 +01:00 committed by GitHub
commit c689937297
15 changed files with 575 additions and 146 deletions

View file

@ -65,7 +65,7 @@ block content
.ui-layout-center .ui-layout-center
include ./editor/editor include ./editor/editor
include ./editor/binary-file include ./editor/binary-file
include ./editor/track-changes include ./editor/history
include ./editor/publish-template include ./editor/publish-template
.ui-layout-east .ui-layout-east

View file

@ -70,13 +70,13 @@ aside#file-tree(ng-controller="FileTreeController", ng-class="{ 'multi-selected'
ng-repeat="entity in rootFolder.children | orderBy:[orderByFoldersFirst, 'name']" 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")} h3 #{translate("deleted_files")}
li( li(
ng-class="{ 'selected': entity.selected }", ng-class="{ 'selected': entity.selected }",
ng-repeat="entity in deletedDocs | orderBy:'name'", ng-repeat="entity in deletedDocs | orderBy:'name'",
ng-controller="FileTreeEntityController", ng-controller="FileTreeEntityController",
ng-show="ui.view == 'track-changes'" ng-show="ui.view == 'history'"
) )
.entity .entity
.entity-name( .entity-name(

View file

@ -102,8 +102,8 @@ div(ng-if="!shouldABTestHeaderLabels")
i.fa.fa-fw.fa-group i.fa.fa-fw.fa-group
a.btn.btn-full-height( a.btn.btn-full-height(
href, href,
ng-click="toggleTrackChanges()", ng-click="toggleHistory()",
ng-class="{ active: (ui.view == 'track-changes') }" ng-class="{ active: (ui.view == 'history') }"
tooltip="#{translate('recent_changes')}", tooltip="#{translate('recent_changes')}",
tooltip-placement="bottom", tooltip-placement="bottom",
) )
@ -232,8 +232,8 @@ div(ng-if="shouldABTestHeaderLabels")
i.fa.fa-fw.fa-group i.fa.fa-fw.fa-group
a.btn.btn-full-height( a.btn.btn-full-height(
href, href,
ng-click="toggleTrackChanges(); trackABTestConversion('history');", ng-click="toggleHistory(); trackABTestConversion('history');",
ng-class="{ active: (ui.view == 'track-changes') }" ng-class="{ active: (ui.view == 'history') }"
tooltip="#{translate('recent_changes')}", tooltip="#{translate('recent_changes')}",
tooltip-placement="bottom", tooltip-placement="bottom",
sixpack-convert="editor-header" sixpack-convert="editor-header"
@ -357,8 +357,8 @@ div(ng-if="shouldABTestHeaderLabels")
p.toolbar-label #{translate("share")} p.toolbar-label #{translate("share")}
a.btn.btn-full-height( a.btn.btn-full-height(
href, href,
ng-click="toggleTrackChanges(); trackABTestConversion('history');", ng-click="toggleHistory(); trackABTestConversion('history');",
ng-class="{ active: (ui.view == 'track-changes') }", ng-class="{ active: (ui.view == 'history') }",
sixpack-convert="editor-header" sixpack-convert="editor-header"
) )
i.fa.fa-fw.fa-history i.fa.fa-fw.fa-history

View file

@ -1,5 +1,5 @@
div#trackChanges(ng-show="ui.view == 'track-changes'") div#history(ng-show="ui.view == 'history'")
span(ng-controller="TrackChangesPremiumPopup") span(ng-controller="HistoryPremiumPopup")
.upgrade-prompt(ng-show="!project.features.versioning") .upgrade-prompt(ng-show="!project.features.versioning")
.message(ng-show="project.owner._id == user.id") .message(ng-show="project.owner._id == user.id")
p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})} 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( a.btn.btn-success(
href href
ng-class="buttonClass" ng-class="buttonClass"
ng-click="startFreeTrial('track-changes')" ng-click="startFreeTrial('history')"
) #{translate("start_free_trial")} ) #{translate("start_free_trial")}
.message(ng-show="project.owner._id != user.id") .message(ng-show="project.owner._id != user.id")
p #{translate("ask_proj_owner_to_upgrade_for_history")} p #{translate("ask_proj_owner_to_upgrade_for_history")}
p p
a.small(href, ng-click="toggleTrackChanges()") #{translate("cancel")} a.small(href, ng-click="toggleHistory()") #{translate("cancel")}
aside.change-list( aside.change-list(
ng-controller="TrackChangesListController" ng-controller="HistoryListController"
infinite-scroll="loadMore()" infinite-scroll="loadMore()"
infinite-scroll-disabled="trackChanges.loading || trackChanges.atEnd" infinite-scroll-disabled="history.loading || history.atEnd"
infinite-scroll-initialize="ui.view == 'track-changes'" infinite-scroll-initialize="ui.view == 'history'"
) )
.infinite-scroll-inner .infinite-scroll-inner
ul.list-unstyled( ul.list-unstyled(
ng-class="{\ ng-class="{\
'hover-state': trackChanges.hoveringOverListSelectors\ 'hover-state': history.hoveringOverListSelectors\
}" }"
) )
li.change( li.change(
ng-repeat="update in trackChanges.updates" ng-repeat="update in history.updates"
ng-class="{\ ng-class="{\
'first-in-day': update.meta.first_in_day,\ 'first-in-day': update.meta.first_in_day,\
'selected': update.inSelection,\ 'selected': update.inSelection,\
@ -65,7 +65,7 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
'hover-selected-to': update.hoverSelectedTo,\ 'hover-selected-to': update.hoverSelectedTo,\
'hover-selected-from': update.hoverSelectedFrom,\ '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 }} 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%)") .color-square(style="background-color: hsl(100, 100%, 50%)")
span #{translate("anonymous")} span #{translate("anonymous")}
.loading(ng-show="trackChanges.loading") .loading(ng-show="history.loading")
i.fa.fa-spin.fa-refresh i.fa.fa-spin.fa-refresh
|    #{translate("loading")}... |    #{translate("loading")}...
.diff-panel.full-size(ng-controller="TrackChangesDiffController") .diff-panel.full-size(ng-controller="HistoryDiffController")
.diff( .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 .toolbar.toolbar-alt
span.name span.name
| <strong>{{trackChanges.diff.highlights.length}} </strong> | <strong>{{history.diff.highlights.length}} </strong>
ng-pluralize( ng-pluralize(
count="trackChanges.diff.highlights.length", count="history.diff.highlights.length",
when="{\ when="{\
'one': 'change',\ 'one': 'change',\
'other': 'changes'\ 'other': 'changes'\
}" }"
) )
| in <strong>{{trackChanges.diff.doc.name}}</strong> | in <strong>{{history.diff.doc.name}}</strong>
.toolbar-right .toolbar-right
a.btn.btn-danger.btn-sm( a.btn.btn-danger.btn-sm(
href, href,
ng-click="openRestoreDiffModal()" ng-click="openRestoreDiffModal()"
) #{translate("restore_to_before_these_changes")} ) #{translate("restore_to_before_these_changes")}
.diff-editor.hide-ace-cursor( .diff-editor.hide-ace-cursor(
ace-editor="track-changes", ace-editor="history",
theme="settings.theme", theme="settings.theme",
font-size="settings.fontSize", font-size="settings.fontSize",
text="trackChanges.diff.text", text="history.diff.text",
highlights="trackChanges.diff.highlights", highlights="history.diff.highlights",
read-only="true", read-only="true",
resize-on="layout:main:resize", resize-on="layout:main:resize",
navigate-highlights="true" navigate-highlights="true"
) )
.diff-deleted.text-centered( .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 p
a.btn.btn-primary.btn-lg( a.btn.btn-primary.btn-lg(
href, href,
ng-click="restoreDeletedDoc()", ng-click="restoreDeletedDoc()",
ng-disabled="trackChanges.diff.restoreInProgress" ng-disabled="history.diff.restoreInProgress"
) #{translate("restore")} ) #{translate("restore")}
.diff-deleted.text-centered( .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.text-serif #{translate("file_restored_back_to_editor")}
p p
a.btn.btn-default( a.btn.btn-default(
@ -164,13 +164,13 @@ div#trackChanges(ng-show="ui.view == 'track-changes'")
ng-click="backToEditorAfterRestore()", ng-click="backToEditorAfterRestore()",
) #{translate("file_restored_back_to_editor_btn")} ) #{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 i.fa.fa-spin.fa-refresh
| &nbsp;&nbsp;#{translate("loading")}... | &nbsp;&nbsp;#{translate("loading")}...
.error-panel(ng-show="trackChanges.diff.error") .error-panel(ng-show="history.diff.error")
.alert.alert-danger #{translate("generic_something_went_wrong")} .alert.alert-danger #{translate("generic_something_went_wrong")}
script(type="text/ng-template", id="trackChangesRestoreDiffModalTemplate") script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
.modal-header .modal-header
button.close( button.close(
type="button" type="button"

View file

@ -4,7 +4,7 @@ define [
"ide/connection/ConnectionManager" "ide/connection/ConnectionManager"
"ide/editor/EditorManager" "ide/editor/EditorManager"
"ide/online-users/OnlineUsersManager" "ide/online-users/OnlineUsersManager"
"ide/track-changes/TrackChangesManager" "ide/history/HistoryManager"
"ide/permissions/PermissionsManager" "ide/permissions/PermissionsManager"
"ide/pdf/PdfManager" "ide/pdf/PdfManager"
"ide/binary-files/BinaryFilesManager" "ide/binary-files/BinaryFilesManager"
@ -36,7 +36,7 @@ define [
ConnectionManager ConnectionManager
EditorManager EditorManager
OnlineUsersManager OnlineUsersManager
TrackChangesManager HistoryManager
PermissionsManager PermissionsManager
PdfManager PdfManager
BinaryFilesManager BinaryFilesManager
@ -116,7 +116,7 @@ define [
ide.fileTreeManager = new FileTreeManager(ide, $scope) ide.fileTreeManager = new FileTreeManager(ide, $scope)
ide.editorManager = new EditorManager(ide, $scope) ide.editorManager = new EditorManager(ide, $scope)
ide.onlineUsersManager = new OnlineUsersManager(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.pdfManager = new PdfManager(ide, $scope)
ide.permissionsManager = new PermissionsManager(ide, $scope) ide.permissionsManager = new PermissionsManager(ide, $scope)
ide.binaryFilesManager = new BinaryFilesManager(ide, $scope) ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)

View file

@ -7,7 +7,8 @@ define [
"ide/editor/directives/aceEditor/spell-check/SpellCheckManager" "ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
"ide/editor/directives/aceEditor/highlights/HighlightsManager" "ide/editor/directives/aceEditor/highlights/HighlightsManager"
"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager" "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 EditSession = ace.require('ace/edit_session').EditSession
# set the path for ace workers if using a CDN (from editor.jade) # set the path for ace workers if using a CDN (from editor.jade)
@ -69,6 +70,9 @@ define [
undoManager = new UndoManager(scope, editor, element) undoManager = new UndoManager(scope, editor, element)
highlightsManager = new HighlightsManager(scope, editor, element) highlightsManager = new HighlightsManager(scope, editor, element)
cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage) 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 # Prevert Ctrl|Cmd-S from triggering save dialog
editor.commands.addCommand editor.commands.addCommand
@ -222,7 +226,9 @@ define [
sharejs_doc.on "remoteop.recordForUndo", () => sharejs_doc.on "remoteop.recordForUndo", () =>
undoManager.nextUpdateIsRemote = true undoManager.nextUpdateIsRemote = true
editor.initing = true
sharejs_doc.attachToAce(editor) sharejs_doc.attachToAce(editor)
editor.initing = false
# need to set annotations after attaching because attaching # need to set annotations after attaching because attaching
# deletes and then inserts document content # deletes and then inserts document content
session.setAnnotations scope.annotations 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

@ -1,31 +1,31 @@
define [ define [
"moment" "moment"
"ide/track-changes/controllers/TrackChangesListController" "ide/history/controllers/HistoryListController"
"ide/track-changes/controllers/TrackChangesDiffController" "ide/history/controllers/HistoryDiffController"
"ide/track-changes/directives/infiniteScroll" "ide/history/directives/infiniteScroll"
], (moment) -> ], (moment) ->
class TrackChangesManager class HistoryManager
constructor: (@ide, @$scope) -> constructor: (@ide, @$scope) ->
@reset() @reset()
@$scope.toggleTrackChanges = () => @$scope.toggleHistory = () =>
if @$scope.ui.view == "track-changes" if @$scope.ui.view == "history"
@hide() @hide()
else else
@show() @show()
@$scope.$watch "trackChanges.selection.updates", (updates) => @$scope.$watch "history.selection.updates", (updates) =>
if updates? and updates.length > 0 if updates? and updates.length > 0
@_selectDocFromUpdates() @_selectDocFromUpdates()
@reloadDiff() @reloadDiff()
@$scope.$on "entity:selected", (event, entity) => @$scope.$on "entity:selected", (event, entity) =>
if (@$scope.ui.view == "track-changes") and (entity.type == "doc") if (@$scope.ui.view == "history") and (entity.type == "doc")
@$scope.trackChanges.selection.doc = entity @$scope.history.selection.doc = entity
@reloadDiff() @reloadDiff()
show: () -> show: () ->
@$scope.ui.view = "track-changes" @$scope.ui.view = "history"
@reset() @reset()
hide: () -> hide: () ->
@ -34,7 +34,7 @@ define [
@$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity() @$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity()
reset: () -> reset: () ->
@$scope.trackChanges = { @$scope.history = {
updates: [] updates: []
nextBeforeTimestamp: null nextBeforeTimestamp: null
atEnd: false atEnd: false
@ -52,36 +52,36 @@ define [
} }
autoSelectRecentUpdates: () -> 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 indexOfLastUpdateNotByMe = 0
for update, i in @$scope.trackChanges.updates for update, i in @$scope.history.updates
if @_updateContainsUserId(update, @$scope.user.id) if @_updateContainsUserId(update, @$scope.user.id)
break break
indexOfLastUpdateNotByMe = i indexOfLastUpdateNotByMe = i
@$scope.trackChanges.updates[indexOfLastUpdateNotByMe].selectedFrom = true @$scope.history.updates[indexOfLastUpdateNotByMe].selectedFrom = true
BATCH_SIZE: 10 BATCH_SIZE: 10
fetchNextBatchOfUpdates: () -> fetchNextBatchOfUpdates: () ->
url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}" url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
if @$scope.trackChanges.nextBeforeTimestamp? if @$scope.history.nextBeforeTimestamp?
url += "&before=#{@$scope.trackChanges.nextBeforeTimestamp}" url += "&before=#{@$scope.history.nextBeforeTimestamp}"
@$scope.trackChanges.loading = true @$scope.history.loading = true
@ide.$http @ide.$http
.get(url) .get(url)
.success (data) => .success (data) =>
@_loadUpdates(data.updates) @_loadUpdates(data.updates)
@$scope.trackChanges.nextBeforeTimestamp = data.nextBeforeTimestamp @$scope.history.nextBeforeTimestamp = data.nextBeforeTimestamp
if !data.nextBeforeTimestamp? if !data.nextBeforeTimestamp?
@$scope.trackChanges.atEnd = true @$scope.history.atEnd = true
@$scope.trackChanges.loading = false @$scope.history.loading = false
reloadDiff: () -> reloadDiff: () ->
diff = @$scope.trackChanges.diff diff = @$scope.history.diff
{updates, doc} = @$scope.trackChanges.selection {updates, doc} = @$scope.history.selection
{fromV, toV, start_ts, end_ts} = @_calculateRangeFromSelection() {fromV, toV, start_ts, end_ts} = @_calculateRangeFromSelection()
return if !doc? return if !doc?
@ -91,7 +91,7 @@ define [
diff.fromV == fromV and diff.fromV == fromV and
diff.toV == toV diff.toV == toV
@$scope.trackChanges.diff = diff = { @$scope.history.diff = diff = {
fromV: fromV fromV: fromV
toV: toV toV: toV
start_ts: start_ts start_ts: start_ts
@ -184,7 +184,7 @@ define [
return {text, highlights} return {text, highlights}
_loadUpdates: (updates = []) -> _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 update in updates
for doc_id, doc of update.docs or {} for doc_id, doc of update.docs or {}
@ -203,19 +203,19 @@ define [
previousUpdate = update previousUpdate = update
firstLoad = @$scope.trackChanges.updates.length == 0 firstLoad = @$scope.history.updates.length == 0
@$scope.trackChanges.updates = @$scope.history.updates =
@$scope.trackChanges.updates.concat(updates) @$scope.history.updates.concat(updates)
@autoSelectRecentUpdates() if firstLoad @autoSelectRecentUpdates() if firstLoad
_calculateRangeFromSelection: () -> _calculateRangeFromSelection: () ->
fromV = toV = start_ts = end_ts = null 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 for doc_id, doc of update.docs
if doc_id == selected_doc_id if doc_id == selected_doc_id
if fromV? and toV? if fromV? and toV?
@ -237,11 +237,11 @@ define [
# then prefer this one if present. # then prefer this one if present.
_selectDocFromUpdates: () -> _selectDocFromUpdates: () ->
affected_docs = {} affected_docs = {}
for update in @$scope.trackChanges.selection.updates for update in @$scope.history.selection.updates
for doc_id, doc of update.docs for doc_id, doc of update.docs
affected_docs[doc_id] = doc.entity 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]? if selected_doc? and affected_docs[selected_doc.id]?
# Selected doc is already open # Selected doc is already open
else else
@ -249,7 +249,7 @@ define [
selected_doc = doc selected_doc = doc
break break
@$scope.trackChanges.selection.doc = selected_doc @$scope.history.selection.doc = selected_doc
@ide.fileTreeManager.selectEntity(selected_doc) @ide.fileTreeManager.selectEntity(selected_doc)
_updateContainsUserId: (update, user_id) -> _updateContainsUserId: (update, user_id) ->

View file

@ -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()

View file

@ -2,26 +2,26 @@ define [
"base" "base"
], (App) -> ], (App) ->
App.controller "TrackChangesPremiumPopup", ($scope, ide, sixpack)-> App.controller "HistoryPremiumPopup", ($scope, ide, sixpack)->
$scope.$watch "ui.view", -> $scope.$watch "ui.view", ->
if $scope.ui.view == "track-changes" if $scope.ui.view == "history"
if $scope.project?.features?.versioning if $scope.project?.features?.versioning
$scope.versioningPopupType = "default" $scope.versioningPopupType = "default"
else if $scope.ui.view == "track-changes" else if $scope.ui.view == "history"
sixpack.participate 'track-changes-discount', ['default', 'discount'], (chosenVariation, rawResponse)-> sixpack.participate 'history-discount', ['default', 'discount'], (chosenVariation, rawResponse)->
$scope.versioningPopupType = chosenVariation $scope.versioningPopupType = chosenVariation
App.controller "TrackChangesListController", ["$scope", "ide", ($scope, ide) -> App.controller "HistoryListController", ["$scope", "ide", ($scope, ide) ->
$scope.hoveringOverListSelectors = false $scope.hoveringOverListSelectors = false
$scope.loadMore = () => $scope.loadMore = () =>
ide.trackChangesManager.fetchNextBatchOfUpdates() ide.historyManager.fetchNextBatchOfUpdates()
$scope.recalculateSelectedUpdates = () -> $scope.recalculateSelectedUpdates = () ->
beforeSelection = true beforeSelection = true
afterSelection = false afterSelection = false
$scope.trackChanges.selection.updates = [] $scope.history.selection.updates = []
for update in $scope.trackChanges.updates for update in $scope.history.updates
if update.selectedTo if update.selectedTo
inSelection = true inSelection = true
beforeSelection = false beforeSelection = false
@ -31,7 +31,7 @@ define [
update.afterSelection = afterSelection update.afterSelection = afterSelection
if inSelection if inSelection
$scope.trackChanges.selection.updates.push update $scope.history.selection.updates.push update
if update.selectedFrom if update.selectedFrom
inSelection = false inSelection = false
@ -40,7 +40,7 @@ define [
$scope.recalculateHoveredUpdates = () -> $scope.recalculateHoveredUpdates = () ->
hoverSelectedFrom = false hoverSelectedFrom = false
hoverSelectedTo = 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 # Figure out whether the to or from selector is hovered over
if update.hoverSelectedFrom if update.hoverSelectedFrom
hoverSelectedFrom = true hoverSelectedFrom = true
@ -50,7 +50,7 @@ define [
if hoverSelectedFrom if hoverSelectedFrom
# We want to 'hover select' everything between hoverSelectedFrom and selectedTo # We want to 'hover select' everything between hoverSelectedFrom and selectedTo
inHoverSelection = false inHoverSelection = false
for update in $scope.trackChanges.updates for update in $scope.history.updates
if update.selectedTo if update.selectedTo
update.hoverSelectedTo = true update.hoverSelectedTo = true
inHoverSelection = true inHoverSelection = true
@ -60,7 +60,7 @@ define [
if hoverSelectedTo if hoverSelectedTo
# We want to 'hover select' everything between hoverSelectedTo and selectedFrom # We want to 'hover select' everything between hoverSelectedTo and selectedFrom
inHoverSelection = false inHoverSelection = false
for update in $scope.trackChanges.updates for update in $scope.history.updates
if update.hoverSelectedTo if update.hoverSelectedTo
inHoverSelection = true inHoverSelection = true
update.inHoverSelection = inHoverSelection update.inHoverSelection = inHoverSelection
@ -69,49 +69,49 @@ define [
inHoverSelection = false inHoverSelection = false
$scope.resetHoverState = () -> $scope.resetHoverState = () ->
for update in $scope.trackChanges.updates for update in $scope.history.updates
delete update.hoverSelectedFrom delete update.hoverSelectedFrom
delete update.hoverSelectedTo delete update.hoverSelectedTo
delete update.inHoverSelection delete update.inHoverSelection
$scope.$watch "trackChanges.updates.length", () -> $scope.$watch "history.updates.length", () ->
$scope.recalculateSelectedUpdates() $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) -> $scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) ->
if selectedFrom if selectedFrom
for update in $scope.trackChanges.updates for update in $scope.history.updates
update.selectedFrom = false unless update == $scope.update update.selectedFrom = false unless update == $scope.update
$scope.recalculateSelectedUpdates() $scope.recalculateSelectedUpdates()
$scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) -> $scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) ->
if selectedTo if selectedTo
for update in $scope.trackChanges.updates for update in $scope.history.updates
update.selectedTo = false unless update == $scope.update update.selectedTo = false unless update == $scope.update
$scope.recalculateSelectedUpdates() $scope.recalculateSelectedUpdates()
$scope.select = () -> $scope.select = () ->
event_tracking.sendMB "track-changes-view-change" event_tracking.sendMB "history-view-change"
$scope.update.selectedTo = true $scope.update.selectedTo = true
$scope.update.selectedFrom = true $scope.update.selectedFrom = true
$scope.mouseOverSelectedFrom = () -> $scope.mouseOverSelectedFrom = () ->
$scope.trackChanges.hoveringOverListSelectors = true $scope.history.hoveringOverListSelectors = true
$scope.update.hoverSelectedFrom = true $scope.update.hoverSelectedFrom = true
$scope.recalculateHoveredUpdates() $scope.recalculateHoveredUpdates()
$scope.mouseOutSelectedFrom = () -> $scope.mouseOutSelectedFrom = () ->
$scope.trackChanges.hoveringOverListSelectors = false $scope.history.hoveringOverListSelectors = false
$scope.resetHoverState() $scope.resetHoverState()
$scope.mouseOverSelectedTo = () -> $scope.mouseOverSelectedTo = () ->
$scope.trackChanges.hoveringOverListSelectors = true $scope.history.hoveringOverListSelectors = true
$scope.update.hoverSelectedTo = true $scope.update.hoverSelectedTo = true
$scope.recalculateHoveredUpdates() $scope.recalculateHoveredUpdates()
$scope.mouseOutSelectedTo = () -> $scope.mouseOutSelectedTo = () ->
$scope.trackChanges.hoveringOverListSelectors = false $scope.history.hoveringOverListSelectors = false
$scope.resetHoverState() $scope.resetHoverState()
$scope.displayName = (user) -> $scope.displayName = (user) ->

View file

@ -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()

View file

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

View file

@ -1,5 +1,5 @@
@import "./editor/file-tree.less"; @import "./editor/file-tree.less";
@import "./editor/track-changes.less"; @import "./editor/history.less";
@import "./editor/toolbar.less"; @import "./editor/toolbar.less";
@import "./editor/left-menu.less"; @import "./editor/left-menu.less";
@import "./editor/pdf.less"; @import "./editor/pdf.less";
@ -146,6 +146,17 @@
background-repeat: repeat-x; background-repeat: repeat-x;
background-position: bottom left; 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 { .remote-cursor {
position: absolute; position: absolute;
border-left: 2px solid transparent; border-left: 2px solid transparent;
@ -422,4 +433,4 @@
.dropbox-teaser-video { .dropbox-teaser-video {
width: 100%; width: 100%;
height: auto; height: auto;
} }

View file

@ -8,7 +8,7 @@
@range-bar-color: @link-color; @range-bar-color: @link-color;
@range-bar-selected-offset: 14px; @range-bar-selected-offset: 14px;
#trackChanges { #history {
.upgrade-prompt { .upgrade-prompt {
position: absolute; position: absolute;
top: 0; top: 0;
@ -272,7 +272,7 @@
} }
.editor-dark { .editor-dark {
#trackChanges { #history {
aside.change-list { aside.change-list {
border-color: @editor-dark-toolbar-border-color; border-color: @editor-dark-toolbar-border-color;