diff --git a/services/web/Gruntfile.coffee b/services/web/Gruntfile.coffee index 41d5ed8161..e66a0217ce 100644 --- a/services/web/Gruntfile.coffee +++ b/services/web/Gruntfile.coffee @@ -84,6 +84,7 @@ module.exports = (grunt) -> paths: "underscore": "libs/underscore" "jquery": "libs/jquery" + "moment": "libs/moment" shim: "libs/backbone": deps: ["libs/underscore"] diff --git a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee index 19675c70cf..0469e832ac 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityHandler.coffee @@ -217,7 +217,7 @@ module.exports = ProjectEntityHandler = logger.err "error putting doc #{doc_id} in project #{project_id} #{err}" callback err else if docComparitor.areSame docLines, doc.lines - logger.log sl_req_id: sl_req_id, docLines:docLines, project_id:project_id, doc_id:doc_id, rev:doc.rev, "old doc lines are same as the new doc lines, not updating them" + logger.log sl_req_id: sl_req_id, project_id:project_id, doc_id:doc_id, rev:doc.rev, "old doc lines are same as the new doc lines, not updating them" callback() else logger.log sl_req_id: sl_req_id, project_id:project_id, doc_id:doc_id, docLines: docLines, oldDocLines: doc.lines, rev:doc.rev, "updating doc lines" diff --git a/services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee b/services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee new file mode 100644 index 0000000000..bb7193b5a7 --- /dev/null +++ b/services/web/app/coffee/Features/TrackChanges/TrackChangesController.coffee @@ -0,0 +1,13 @@ +logger = require "logger-sharelatex" +request = require "request" +settings = require "settings-sharelatex" + +module.exports = TrackChangesController = + proxyToTrackChangesApi: (req, res, next = (error) ->) -> + url = settings.apis.trackchanges.url + req.url + logger.log url: url, "proxying to track-changes api" + getReq = request.get(url) + getReq.pipe(res) + getReq.on "error", (error) -> + logger.error err: error, "track-changes API error" + next(error) \ No newline at end of file diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index c724173e3d..4c9f88b31a 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -32,6 +32,7 @@ CompileController = require("./Features/Compile/CompileController") HealthCheckController = require("./Features/HealthCheck/HealthCheckController") ProjectDownloadsController = require "./Features/Downloads/ProjectDownloadsController" FileStoreController = require("./Features/FileStore/FileStoreController") +TrackChangesController = require("./Features/TrackChanges/TrackChangesController") logger = require("logger-sharelatex") httpAuth = require('express').basicAuth (user, pass)-> @@ -122,6 +123,9 @@ module.exports = class Router app.get '/Project/:Project_id/version', SecutiryManager.requestCanAccessProject, versioningController.listVersions app.get '/Project/:Project_id/version/:Version_id', SecutiryManager.requestCanAccessProject, versioningController.getVersion + app.get "/project/:Project_id/doc/:doc_id/updates", SecutiryManager.requestCanAccessProject, TrackChangesController.proxyToTrackChangesApi + app.get "/project/:Project_id/doc/:doc_id/diff", SecutiryManager.requestCanAccessProject, TrackChangesController.proxyToTrackChangesApi + app.post '/project/:project_id/leave', AuthenticationController.requireLogin(), CollaboratorsController.removeSelfFromProject app.get '/project/:Project_id/collaborators', SecutiryManager.requestCanAccessProject(allow_auth_token: true), CollaboratorsController.getCollaborators diff --git a/services/web/app/views/about/about.jade b/services/web/app/views/about/about.jade index 18c543e076..dc66ecec0c 100644 --- a/services/web/app/views/about/about.jade +++ b/services/web/app/views/about/about.jade @@ -24,7 +24,7 @@ block content p(style="clear: both") h3 Motivation - p Our first priority with ShareLaTeX is to build a tool which makes life easier for all the LaTeX users our there. + p Our first priority with ShareLaTeX is to build a tool which makes life easier for all the LaTeX users out there. | The "thank you"s and success stories are a strong motivator for us, and we hope to always be able to offer a fully-functional | LaTeX editor which anyone can use for free. p We also believe that charging money for tools like ShareLaTeX is important since it helps to guarantee the future of the site and diff --git a/services/web/app/views/project/editor.jade b/services/web/app/views/project/editor.jade index f8e74fbe82..c018162f0f 100644 --- a/services/web/app/views/project/editor.jade +++ b/services/web/app/views/project/editor.jade @@ -38,7 +38,8 @@ block content window.requirejs = { "paths" : { "underscore": "libs/underscore", - "mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML" + "mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML", + "moment": "libs/moment" }, "urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}", "waitSeconds": 0, diff --git a/services/web/app/views/project/list.jade b/services/web/app/views/project/list.jade index 4fffc3fab9..90eaf857db 100644 --- a/services/web/app/views/project/list.jade +++ b/services/web/app/views/project/list.jade @@ -144,6 +144,12 @@ block content mixin tag('{{ project_id }}', '{{ tagName }}', true) - locals.supressDefaultJs = true + script + window.requirejs = { + "paths" : { + "moment": "libs/moment" + } + }; script( data-main=jsPath+'list.js?fingerprint='+fingerprint(jsPath + 'list.js'), baseurl=jsPath, diff --git a/services/web/app/views/templates.jade b/services/web/app/views/templates.jade index 18c0712731..5f4e7574ef 100644 --- a/services/web/app/views/templates.jade +++ b/services/web/app/views/templates.jade @@ -147,10 +147,11 @@ #editorArea(style='display: none;') #editorSplitter #leftEditorPanel.ui-layout-center - #editor - #undoConflictWarning(style="display: none") - | Watch out! We had to undo some of your collaborators changes before we could undo yours. - a(href="#").js-hide Hide + #editorWrapper + #editor + #undoConflictWarning(style="display: none") + | Watch out! We had to undo some of your collaborators changes before we could undo yours. + a(href="#").js-hide Hide #rightEditorPanel.ui-layout-east script(type="text/template")#loadingIndicatorTemplate @@ -403,11 +404,6 @@ div(class='version-message') {{message}} div(class='version-date') {{date}} - script(type='text/template')#autoCompleteSuggestionTemplate - li - strong {{base}} - | {{completion}} - script(type='text/template')#versionListTemplate ul#version-list.nav.nav-pills.nav-stacked li.loading Loading... @@ -427,6 +423,32 @@ div a(href="#", title='Show Hot Keys List') Hot keys + script(type='text/template')#trackChangesPanelTemplate + #trackChangesPanel + .track-changes-side-bar + .track-changes-header + h3 Recent changes + a(href="#").track-changes-close + i.icon-remove + .change-list-area + .track-changes-diff + + script(type='text/template')#changeListItemTemplate + div(class='change-selectors') + input(type="radio",name="fromVersion").change-selector-from + input(type="radio",name="toVersion").change-selector-to + + div(class='change-description') + div(class='change-date') {{date}} + div(class='change-name') + div.color-square(style="background-color: hsl({{hue}}, 100%, 70%);") + span {{name}} + + script(type='text/template')#changeListTemplate + ul.change-list.nav.nav-pills.nav-stacked + li.loading-changes Loading... + li.empty-message You haven't made any changes yet! + script(type='text/template')#hotKeysListTemplate .hotkeys h3 Common diff --git a/services/web/public/coffee/editor/Editor.coffee b/services/web/public/coffee/editor/Editor.coffee index 7c2da966c9..7c86598ad6 100644 --- a/services/web/public/coffee/editor/Editor.coffee +++ b/services/web/public/coffee/editor/Editor.coffee @@ -73,7 +73,7 @@ define [ if @currentViewState != @viewOptions.splitView @currentViewState = @viewOptions.splitView @leftPanel.prepend( - @editorPanel.find("#editor") + @editorPanel.find("#editorWrapper") ) splitter = @editorPanel.find("#editorSplitter") splitter.show() @@ -84,7 +84,7 @@ define [ @_saveSplitterState() @currentViewState = @viewOptions.flatView @editorPanel.prepend( - @editorPanel.find("#editor") + @editorPanel.find("#editorWrapper") ) @editorPanel.find("#editorSplitter").hide() @aceEditor.resize(true) @@ -352,3 +352,9 @@ define [ getCurrentDocId: () -> @current_doc_id + show: () -> + $("#editor").show() + + hide: () -> + $("#editor").hide() + diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index c3d758c978..871ac98d1e 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -25,6 +25,7 @@ define [ "file-view/FileViewManager" "tour/IdeTour" "analytics/AnalyticsManager" + "track-changes/TrackChangesManager" "ace/ace" "libs/jquery.color" "libs/jquery-layout" @@ -56,7 +57,8 @@ define [ BackspaceHighjack, FileViewManager, IdeTour, - AnalyticsManager + AnalyticsManager, + TrackChangesManager ) -> @@ -117,6 +119,7 @@ define [ @cursorManager = new CursorManager(@) @fileViewManager = new FileViewManager(@) @analyticsManager = new AnalyticsManager(@) + @trackChangesManager = new TrackChangesManager(@) @setLoadingMessage("Connecting") firstConnect = true diff --git a/services/web/public/coffee/list.coffee b/services/web/public/coffee/list.coffee index 5f6330d0ab..30f1812dfd 100644 --- a/services/web/public/coffee/list.coffee +++ b/services/web/public/coffee/list.coffee @@ -1,16 +1,16 @@ require [ "tags" + "moment" "gui" - "libs/moment" "libs/underscore" "libs/fineuploader" "libs/jquery.storage" -], (tagsManager)-> +], (tagsManager, moment)-> $('.isoDate').each (i, d)-> html = $(d) - unparsedDate = html.text() - formatedDate = moment(unparsedDate).format('LLL') + unparsedDate = html.text().trim() + formatedDate = moment(unparsedDate).format("Do MMM YYYY, h:mm:ss a") html.text(formatedDate) refreshProjectFilter = -> diff --git a/services/web/public/coffee/models/User.coffee b/services/web/public/coffee/models/User.coffee index 3443d2eb3a..1e101bee34 100644 --- a/services/web/public/coffee/models/User.coffee +++ b/services/web/public/coffee/models/User.coffee @@ -1,7 +1,38 @@ define [ + "libs/md5" "libs/backbone" ], () -> - User = Backbone.Model.extend {}, { + User = Backbone.Model.extend { + gravatarUrl: (size = 32) -> + email = @get("email").trim().toLowerCase() + hash = CryptoJS.MD5(email) + return "//www.gravatar.com/avatar/#{hash}.jpg?size=#{size}&d=mm" + + OWNER_HUE: 200 + hue: () -> + if window.user.id == @get("id") + hue = @OWNER_HUE + else + hash = CryptoJS.MD5(@get("id")) + hue = parseInt(hash.toString().slice(0,8), 16) % 320 + # Avoid 20 degrees either side of the owner + if hue > @OWNER_HUE - 20 + hue = hue + 40 + return hue + + name: () -> + if window.user.id == @get("id") + return "you" + parts = [] + first_name = @get("first_name") + if first_name? and first_name.length > 0 + parts.push first_name + last_name = @get("last_name") + if last_name? and last_name.length > 0 + parts.push last_name + return parts.join(" ") + + }, { findOrBuild : (id, attributes) -> model = @find id if !model? diff --git a/services/web/public/coffee/track-changes/ChangeListView.coffee b/services/web/public/coffee/track-changes/ChangeListView.coffee new file mode 100644 index 0000000000..b4cebd15bd --- /dev/null +++ b/services/web/public/coffee/track-changes/ChangeListView.coffee @@ -0,0 +1,198 @@ +define [ + "moment" + "libs/mustache" + "libs/backbone" +], (moment)-> + ChangeListView = Backbone.View.extend + template: $("#changeListTemplate").html() + + events: + "scroll" : "loadUntilFull" + + initialize: () -> + @itemViews = [] + @atEndOfCollection = false + + self = this + @collection.on "add", (model) -> + self.addItem model + @collection.on "reset", (collection) -> + self.addItem model for model in collection.models + + @selectedFromIndex = 0 + @selectedToIndex = 0 + + @render() + @hideLoading() + + render: -> + @$el.html Mustache.to_html @template + @$el.css + overflow: "scroll" + this + + addItem: (model) -> + index = @collection.indexOf(model) + view = new ChangeListItemView(model : model) + @itemViews.push view + elementAtIndex = @$(".change-list").children()[index] + view.$el.insertBefore(elementAtIndex) + + view.on "click", (e, v) => + @selectedToIndex = index + @selectedFromIndex = index + @resetAllSelectors() + @triggerChangeDiff() + + view.on "selected:to", (e, v) => + @selectedToIndex = index + @resetAllSelectors() + @triggerChangeDiff() + + view.on "selected:from", (e, v) => + @selectedFromIndex = index + @resetAllSelectors() + @triggerChangeDiff() + + view.resetSelector(index, @selectedFromIndex, @selectedToIndex) + + resetAllSelectors: () -> + for view, i in @itemViews + view.resetSelector(i, @selectedFromIndex, @selectedToIndex) + + triggerChangeDiff: () -> + @trigger "change_diff", @collection.models[@selectedFromIndex], @collection.models[@selectedToIndex] + + listShorterThanContainer: -> + @$el.height() > @$(".change-list").height() + + atEndOfListView: -> + @$el.scrollTop() + @$el.height() >= @$(".change-list").height() - 30 + + loadUntilFull: (e, callback) -> + if (@listShorterThanContainer() or @atEndOfListView()) and not @atEndOfCollection and not @loading + @showLoading() + @hideEmptyMessage() + @collection.fetchNextBatch + error: => + @hideLoading() + @showEmptyMessageIfCollectionEmpty() + callback() if callback? + success: (collection, response) => + @hideLoading() + if response.updates.length == @collection.batchSize + @loadUntilFull(e, callback) + else + @atEndOfCollection = true + @showEmptyMessageIfCollectionEmpty() + callback() if callback? + + else + callback() if callback? + + showEmptyMessageIfCollectionEmpty: ()-> + if @collection.isEmpty() + @$(".empty-message").show() + else + @$(".empty-message").hide() + + hideEmptyMessage: () -> + @$(".empty-message").hide() + + showLoading: -> + @loading = true + @$(".loading-changes").show() + + hideLoading: -> + @loading = false + @$(".loading-changes").hide() + + ChangeListItemView = Backbone.View.extend + tagName: "li" + + events: + "click .change-description" : "onClick" + "click .change-selector-from" : "onFromSelectorClick" + "click .change-selector-to" : "onToSelectorClick" + + template : $("#changeListItemTemplate").html() + + initialize: -> + @render() + + render: -> + @$el.html Mustache.to_html(@template, @modelView()) + return this + + modelView: -> + modelView = { + hue: @model.get("user").hue() + date: moment(parseInt(@model.get("end_ts"), 10)).calendar() + name: @model.get("user").name() + } + # modelView.start_ts = util.formatDate(modelView.start_ts) + # modelView.end_ts = util.formatDate(modelView.end_ts) + return modelView + + onClick: (e) -> + e.preventDefault() + @trigger "click", e, @ + + onToSelectorClick: (e) -> + @trigger "selected:to", e, @ + + onFromSelectorClick: (e) -> + @trigger "selected:from", e, @ + + isSelectedFrom: () -> + @$(".change-selector-from").is(":checked") + + isSelectedTo: () -> + @$(".change-selector-to").is(":checked") + + hideFromSelector: () -> + @$(".change-selector-from").hide() + + showFromSelector: () -> + @$(".change-selector-from").show() + + hideToSelector: () -> + @$(".change-selector-to").hide() + + showToSelector: () -> + @$(".change-selector-to").show() + + setFromChecked: (checked) -> + @$(".change-selector-from").prop("checked", checked) + + setToChecked: (checked) -> + @$(".change-selector-to").prop("checked", checked) + + setSelected: () -> + @$el.addClass("selected") + + setUnselected: () -> + @$el.removeClass("selected") + + resetSelector: (myIndex, selectedFromIndex, selectedToIndex) -> + if myIndex >= selectedToIndex + @showFromSelector() + else + @hideFromSelector() + + if myIndex <= selectedFromIndex + @showToSelector() + else + @hideToSelector() + + if selectedToIndex <= myIndex <= selectedFromIndex + @setSelected() + else + @setUnselected() + + @setFromChecked(myIndex == selectedFromIndex) + @setToChecked(myIndex == selectedToIndex) + + + return ChangeListView + diff --git a/services/web/public/coffee/track-changes/DiffView.coffee b/services/web/public/coffee/track-changes/DiffView.coffee new file mode 100644 index 0000000000..70a67dd2e6 --- /dev/null +++ b/services/web/public/coffee/track-changes/DiffView.coffee @@ -0,0 +1,140 @@ +define [ + "ace/ace" + "ace/mode/latex" + "ace/range" + "libs/backbone" +], (Ace, LatexMode, Range)-> + DiffView = Backbone.View.extend + initialize: () -> + @model.on "change:diff", () => @render() + @render() + + render: -> + diff = @model.get("diff") + return unless diff? + @createAceEditor() + @aceEditor.setValue(@getPlainDiffContent()) + @aceEditor.clearSelection() + session = @aceEditor.getSession() + session.setMode(new LatexMode.Mode()) + session.setUseWrapMode(true) + @insertMarkers() + return @ + + createAceEditor: () -> + @$el.empty() + $editor = $("
") + @$el.append($editor) + @aceEditor = Ace.edit($editor[0]) + @aceEditor.setTheme("ace/theme/#{window.userSettings.theme}") + @aceEditor.setReadOnly true + @aceEditor.setShowPrintMargin(false) + + @aceEditor.on "mousemove", (e) => + position = @aceEditor.renderer.screenToTextCoordinates(e.clientX, e.clientY) + e.position = position + @updateVisibleNames(e) + + getPlainDiffContent: () -> + content = "" + for entry in @model.get("diff") or [] + content += entry.u or entry.i or entry.d or "" + return content + + insertMarkers: () -> + row = 0 + column = 0 + for entry, i in @model.get("diff") or [] + content = entry.u or entry.i or entry.d + lines = content.split("\n") + startRow = row + startColumn = column + if lines.length > 1 + endRow = startRow + lines.length - 1 + endColumn = lines[lines.length - 1].length + else + endRow = startRow + endColumn = startColumn + lines[0].length + row = endRow + column = endColumn + + range = new Range.Range( + startRow, startColumn, endRow, endColumn + ) + @addMarker(range, "change-marker-#{i}", entry) + + addMarker: (range, id, entry) -> + session = @aceEditor.getSession() + markerBackLayer = @aceEditor.renderer.$markerBack + markerFrontLayer = @aceEditor.renderer.$markerFront + lineHeight = @aceEditor.renderer.lineHeight + if entry.i? or entry.d? + hue = entry.meta.user.hue() + if entry.i? + @_addMarkerWithCustomStyle session, markerBackLayer, range, "deleted-change-background", false, """ + background-color : hsl(#{hue}, 70%, 85%); + """ + tag = "Added by #{entry.meta.user.name()}" + if entry.d? + @_addMarkerWithCustomStyle session, markerBackLayer, range, "deleted-change-background", false, """ + background-color : hsl(#{hue}, 70%, 95%); + """ + @_addMarkerWithCustomStyle session, markerBackLayer, range, "deleted-change-foreground", true, """ + height: #{Math.round(lineHeight/2) - 1}px; + border-bottom: 2px solid hsl(#{hue}, 70%, 40%); + """ + tag = "Deleted by #{entry.meta.user.name()}" + + date = moment(parseInt(entry.meta.end_ts, 10)).format("Do MMM YYYY, h:mm:ss a") + tag += " on #{date}" + @_addNameTag session, id, range, tag, """ + background-color : hsl(#{hue}, 70%, 95%); + """ + + _addMarkerWithCustomStyle: (session, markerLayer, range, klass, foreground, style) -> + session.addMarker range, klass, (html, range, left, top, config) -> + if range.isMultiLine() + markerLayer.drawTextMarker(html, range, klass, config, style) + else + markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style) + , foreground + + _addNameTag: (session, id, range, content, style) -> + @nameMarkers ||= [] + @nameMarkers.push + range: range + id: id + startRange = new Range.Range( + range.start.row, range.start.column + range.start.row, range.start.column + 1 + ) + session.addMarker startRange, "change-name-marker", (html, range, left, top, config) -> + html.push """ +