Răsfoiți Sursa

Merge branch 'master' into sk-end-subform-test

Shane Kilkelly 10 ani în urmă
părinte
comite
3ec0e65ae1

+ 1 - 0
services/web/Gruntfile.coffee

@@ -161,6 +161,7 @@ module.exports = (grunt) ->
 						"moment": "libs/#{PackageVersions.lib('moment')}"
 						"mathjax": "/js/libs/mathjax/MathJax.js?config=TeX-AMS_HTML"
 						"libs/pdf": "libs/#{PackageVersions.lib('pdfjs')}/pdf"
+						"ace": "#{PackageVersions.lib('ace')}"
 					shim:
 						"libs/pdf":
 							deps: ["libs/#{PackageVersions.lib('pdfjs')}/compatibility"]

+ 15 - 19
services/web/app/views/layout.jade

@@ -52,6 +52,7 @@ html(itemscope, itemtype='http://schema.org/Product')
 			window.csrfToken = "#{csrfToken}";
 			
 		block scripts
+
 		script(src=buildJsPath("libs/jquery-1.11.1.min.js", {fingerprint:false}))
 		script(type="text/javascript").
 			var noCdnKey = "nocdn=true"
@@ -109,41 +110,36 @@ html(itemscope, itemtype='http://schema.org/Product')
 		- if(typeof(suppressNavbar) == "undefined")
 			include layout/navbar
 
-		script(type='text/javascript').
-			// minimal requirejs configuration (can be extended/overridden)
-			window.requirejs = {
-				"paths" : {
-					"moment": "libs/#{lib('moment')}"
-				},
-				"config":{
-					"moment":{
-						"noGlobal": true
-					}
-				}
-			};
-
 		block content
+
 		div(ng-controller="AbTestController")
 		- if(typeof(suppressFooter) == "undefined")
 			include layout/footer
 
-
-
 		- if (typeof(lookingForScribtex) != "undefined" && lookingForScribtex)
 			span(ng-controller="ScribtexPopupController")
 			include scribtex-modal
 
-		- if(typeof(suppressDefaultJs) == "undefined")
+		block requirejs
 			script(type='text/javascript').
-				// extend requirejs config
-				window.requirejs.urlArgs = "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}"
+				// minimal requirejs configuration (can be extended/overridden)
+				window.requirejs = {
+					"paths" : {
+						"moment": "libs/#{lib('moment')}"
+					},
+					"urlArgs": "fingerprint=#{fingerprint(jsPath + 'main.js')}-#{fingerprint(jsPath + 'libs.js')}",
+					"config":{
+						"moment":{
+							"noGlobal": true
+						}
+					}
+				};
 			script(
 				data-main=buildJsPath('main.js', {fingerprint:false}),
 				baseurl=fullJsPath,
 				src=buildJsPath('libs/require.js')
 			)
 
-
 		include contact-us-modal
 		include sentry
 		

+ 4 - 8
services/web/app/views/project/editor.jade

@@ -3,7 +3,6 @@ extends ../layout
 block vars
 	- var suppressNavbar = true
 	- var suppressFooter = true
-	- var suppressDefaultJs = true
 	- var suppressSystemMessages = true
 
 block content
@@ -66,7 +65,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
@@ -86,8 +85,8 @@ block content
 		.modal-footer
 			button.btn.btn-info(ng-click="done()") #{translate("ok")}
 
-	script(src='/socket.io/socket.io.js')
-
+block requirejs
+	script(type="text/javascript" src='/socket.io/socket.io.js')
 	//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
 	//- and doesn't prematurely end the script tag.
 	script(type='text/javascript').
@@ -126,8 +125,6 @@ block content
 		};
 		window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
 
-	- locals.suppressDefaultJs = true
-
 	- var pdfPath = "libs/" + lib('pdfjs') + "/pdf.worker.js"
 	- var fingerprintedPath = fingerprint(jsPath+pdfPath)
 	- var pdfJsWorkerPath = buildJsPath(pdfPath, {cdn:false,qs:{fingerprint:fingerprintedPath}}) // don't use worker for cdn
@@ -144,5 +141,4 @@ block content
 		src=buildJsPath('libs/require.js')
 	)
 
-		
-
+	

+ 2 - 2
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(

+ 6 - 6
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

+ 27 - 27
services/web/app/views/project/editor/track-changes.jade → 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
 				| &nbsp;&nbsp; #{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
-					| <strong>{{trackChanges.diff.highlights.length}} </strong>
+					| <strong>{{history.diff.highlights.length}} </strong>
 					ng-pluralize(
-						count="trackChanges.diff.highlights.length",
+						count="history.diff.highlights.length",
 						when="{\
 							'one': 'change',\
 							'other': 'changes'\
 						}"
 					)
-					|  in <strong>{{trackChanges.diff.doc.name}}</strong>
+					|  in <strong>{{history.diff.doc.name}}</strong>
 				.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
 			| &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")}
 
-script(type="text/ng-template", id="trackChangesRestoreDiffModalTemplate")
+script(type="text/ng-template", id="historyRestoreDiffModalTemplate")
 	.modal-header
 		button.close(
 			type="button"

+ 2 - 3
services/web/app/views/subscriptions/edit-billing-details.jade

@@ -1,10 +1,9 @@
 extends ../layout
 
-block content
-	- locals.suppressDefaultJs = true
-	script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
+block scripts
 	script(src=buildJsPath('libs/recurly.min.js', {fingerprint:false}))
 
+block content
 	.content.content-alt
 		.container
 			.row

+ 1 - 5
services/web/app/views/subscriptions/new.jade

@@ -1,6 +1,6 @@
 extends ../layout
-block scripts
 
+block scripts
 	script(src="https://js.recurly.com/v3/recurly.js")
 
 	script(type='text/javascript').
@@ -11,10 +11,6 @@ block scripts
 		window.couponCode = "#{couponCode}"
 
 block content
-	- locals.suppressDefaultJs = true
-	script(data-main=jsPath+'main.js', src=jsPath+'libs/require.js', baseurl=jsPath)
-
-
 	.content.content-alt
 		.container(ng-controller="NewSubscriptionController" ng-cloak)
 			.row.card-group

+ 1 - 1
services/web/app/views/user/settings.jade

@@ -151,7 +151,7 @@ block content
 		.modal-header
 			h3 #{translate("delete_account")}
 		.modal-body
-			p !{translate("delete_account_warning_message")}
+			p !{translate("delete_account_warning_message_2")}
 			form(novalidate, name="deleteAccountForm")
 				input.form-control(
 					type="text",

+ 3 - 3
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
@@ -116,7 +116,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)

+ 7 - 1
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

+ 410 - 0
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

+ 33 - 33
services/web/public/coffee/ide/track-changes/TrackChangesManager.coffee → 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) ->

+ 46 - 0
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()

+ 22 - 22
services/web/public/coffee/ide/track-changes/controllers/TrackChangesListController.coffee → 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) ->

+ 0 - 0
services/web/public/coffee/ide/track-changes/directives/infiniteScroll.coffee → services/web/public/coffee/ide/history/directives/infiniteScroll.coffee


+ 0 - 46
services/web/public/coffee/ide/track-changes/controllers/TrackChangesDiffController.coffee

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

+ 3 - 1
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...)
+				callback.callback(args...)
+
+		emit: (args...) -> @trigger(args...)

+ 3 - 0
services/web/public/js/ace-1.2.5/ace.js

@@ -2283,6 +2283,9 @@ var TextInput = function(parentNode, host) {
         if (e.type == "compositionend" && c.range) {
             host.selection.setRange(c.range);
         }
+        // WORKAROUND: Accent keys and Korean keys don't work in Chrome >53.
+        // https://github.com/ajaxorg/ace/issues/3045
+        if (useragent.isChrome >= 53) onInput();
     };
     
     

+ 13 - 2
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";
@@ -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;
@@ -422,4 +433,4 @@
 .dropbox-teaser-video {
 	width: 100%;
 	height: auto;
-}
+}

+ 2 - 2
services/web/public/stylesheets/app/editor/track-changes.less → 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;