mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge branch 'master' into node-4.2
This commit is contained in:
commit
bcf9a17fb3
489 changed files with 65479 additions and 2247 deletions
|
@ -1,43 +1,48 @@
|
|||
Settings = require "settings-sharelatex"
|
||||
settings = require "settings-sharelatex"
|
||||
logger = require "logger-sharelatex"
|
||||
_ = require "underscore"
|
||||
request = require "request"
|
||||
|
||||
if !Settings.analytics?.postgres?
|
||||
module.exports =
|
||||
recordEvent: (user_id, event, segmentation, callback = () ->) ->
|
||||
logger.log {user_id, event, segmentation}, "no event tracking configured, logging event"
|
||||
callback()
|
||||
|
||||
makeRequest = (opts, callback)->
|
||||
if settings.apis?.analytics?.url?
|
||||
urlPath = opts.url
|
||||
opts.url = "#{settings.apis.analytics.url}#{urlPath}"
|
||||
request opts, callback
|
||||
else
|
||||
Sequelize = require "sequelize"
|
||||
options = _.extend {logging:false}, Settings.analytics.postgres
|
||||
callback()
|
||||
|
||||
sequelize = new Sequelize(
|
||||
Settings.analytics.postgres.database,
|
||||
Settings.analytics.postgres.username,
|
||||
Settings.analytics.postgres.password,
|
||||
options
|
||||
)
|
||||
|
||||
Event = sequelize.define("Event", {
|
||||
user_id: Sequelize.STRING,
|
||||
event: Sequelize.STRING,
|
||||
segmentation: Sequelize.JSONB
|
||||
})
|
||||
|
||||
module.exports =
|
||||
recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
|
||||
if user_id? and typeof(user_id) != "string"
|
||||
user_id = user_id.toString()
|
||||
if user_id == Settings.smokeTest?.userId
|
||||
# Don't record smoke tests analytics
|
||||
return callback()
|
||||
Event
|
||||
.create({ user_id, event, segmentation })
|
||||
.then(
|
||||
(result) -> callback(),
|
||||
(error) ->
|
||||
logger.err {err: error, user_id, event, segmentation}, "error recording analytics event"
|
||||
callback(error)
|
||||
)
|
||||
|
||||
sync: () -> sequelize.sync()
|
||||
|
||||
recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
|
||||
if user_id+"" == settings.smokeTest?.userId+""
|
||||
return callback()
|
||||
opts =
|
||||
body:
|
||||
event:event
|
||||
segmentation:segmentation
|
||||
json:true
|
||||
method:"POST"
|
||||
timeout:1000
|
||||
url: "/user/#{user_id}/event"
|
||||
makeRequest opts, callback
|
||||
|
||||
|
||||
getLastOccurance: (user_id, event, callback = (error) ->) ->
|
||||
opts =
|
||||
body:
|
||||
event:event
|
||||
json:true
|
||||
method:"POST"
|
||||
timeout:1000
|
||||
url: "/user/#{user_id}/event/last_occurnace"
|
||||
makeRequest opts, (err, response, body)->
|
||||
if err?
|
||||
console.log response, opts
|
||||
logger.err {user_id, err}, "error getting last occurance of event"
|
||||
return callback err
|
||||
else
|
||||
return callback null, body
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
AnnouncementsHandler = require("./AnnouncementsHandler")
|
||||
AuthenticationController = require("../Authentication/AuthenticationController")
|
||||
logger = require("logger-sharelatex")
|
||||
settings = require("settings-sharelatex")
|
||||
|
||||
module.exports =
|
||||
|
||||
getUndreadAnnouncements: (req, res, next)->
|
||||
if !settings?.apis?.analytics?.url? or !settings.apis.blog.url?
|
||||
return res.json []
|
||||
|
||||
user_id = AuthenticationController.getLoggedInUserId(req)
|
||||
logger.log {user_id}, "getting unread announcements"
|
||||
AnnouncementsHandler.getUnreadAnnouncements user_id, (err, announcements)->
|
||||
if err?
|
||||
logger.err {err, user_id}, "unable to get unread announcements"
|
||||
next(err)
|
||||
else
|
||||
res.json announcements
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
AnalyticsManager = require("../Analytics/AnalyticsManager")
|
||||
BlogHandler = require("../Blog/BlogHandler")
|
||||
async = require("async")
|
||||
_ = require("lodash")
|
||||
logger = require("logger-sharelatex")
|
||||
settings = require("settings-sharelatex")
|
||||
|
||||
module.exports =
|
||||
|
||||
getUnreadAnnouncements : (user_id, callback = (err, announcements)->)->
|
||||
async.parallel {
|
||||
lastEvent: (cb)->
|
||||
AnalyticsManager.getLastOccurance user_id, "announcement-alert-dismissed", cb
|
||||
announcements: (cb)->
|
||||
BlogHandler.getLatestAnnouncements cb
|
||||
}, (err, results)->
|
||||
if err?
|
||||
logger.err err:err, user_id:user_id, "error getting unread announcements"
|
||||
return callback(err)
|
||||
|
||||
announcements = _.sortBy(results.announcements, "date").reverse()
|
||||
|
||||
lastSeenBlogId = results?.lastEvent?.segmentation?.blogPostId
|
||||
|
||||
announcementIndex = _.findIndex announcements, (announcement)->
|
||||
announcement.id == lastSeenBlogId
|
||||
|
||||
announcements = _.map announcements, (announcement, index)->
|
||||
if announcementIndex == -1
|
||||
read = false
|
||||
else if index >= announcementIndex
|
||||
read = true
|
||||
else
|
||||
read = false
|
||||
announcement.read = read
|
||||
return announcement
|
||||
|
||||
logger.log announcementsLength:announcements?.length, user_id:user_id, "returning announcements"
|
||||
|
||||
callback null, announcements
|
|
@ -30,19 +30,18 @@ module.exports = AuthenticationController =
|
|||
deserializeUser: (user, cb) ->
|
||||
cb(null, user)
|
||||
|
||||
passportLogin: (req, res, next) ->
|
||||
# This function is middleware which wraps the passport.authenticate middleware,
|
||||
# so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
|
||||
# and send a `{redir: ""}` response on success
|
||||
passport.authenticate('local', (err, user, info) ->
|
||||
if err?
|
||||
return next(err)
|
||||
if user # `user` is either a user object or false
|
||||
afterLoginSessionSetup: (req, user, callback=(err)->) ->
|
||||
req.login user, (err) ->
|
||||
if err?
|
||||
logger.err {user_id: user._id, err}, "error from req.login"
|
||||
return callback(err)
|
||||
# Regenerate the session to get a new sessionID (cookie value) to
|
||||
# protect against session fixation attacks
|
||||
oldSession = req.session
|
||||
req.session.destroy()
|
||||
req.session.destroy (err) ->
|
||||
if err?
|
||||
logger.err {user_id: user._id, err}, "error when trying to destroy old session"
|
||||
return callback(err)
|
||||
req.sessionStore.generate(req)
|
||||
for key, value of oldSession
|
||||
req.session[key] = value
|
||||
|
@ -51,16 +50,30 @@ module.exports = AuthenticationController =
|
|||
req.session.save (err) ->
|
||||
if err?
|
||||
logger.err {user_id: user._id}, "error saving regenerated session after login"
|
||||
return next(err)
|
||||
return callback(err)
|
||||
UserSessionsManager.trackSession(user, req.sessionID, () ->)
|
||||
res.json {redir: req._redir}
|
||||
callback(null)
|
||||
|
||||
passportLogin: (req, res, next) ->
|
||||
# This function is middleware which wraps the passport.authenticate middleware,
|
||||
# so we can send back our custom `{message: {text: "", type: ""}}` responses on failure,
|
||||
# and send a `{redir: ""}` response on success
|
||||
passport.authenticate('local', (err, user, info) ->
|
||||
if err?
|
||||
return next(err)
|
||||
if user # `user` is either a user object or false
|
||||
redir = AuthenticationController._getRedirectFromSession(req) || "/project"
|
||||
AuthenticationController.afterLoginSessionSetup req, user, (err) ->
|
||||
if err?
|
||||
return next(err)
|
||||
AuthenticationController._clearRedirectFromSession(req)
|
||||
res.json {redir: redir}
|
||||
else
|
||||
res.json message: info
|
||||
)(req, res, next)
|
||||
|
||||
doPassportLogin: (req, username, password, done) ->
|
||||
email = username.toLowerCase()
|
||||
redir = Url.parse(req?.body?.redir or "/project").path
|
||||
LoginRateLimiter.processLoginRequest email, (err, isAllowed)->
|
||||
return done(err) if err?
|
||||
if !isAllowed
|
||||
|
@ -73,12 +86,11 @@ module.exports = AuthenticationController =
|
|||
UserHandler.setupLoginData(user, ()->)
|
||||
LoginRateLimiter.recordSuccessfulLogin(email)
|
||||
AuthenticationController._recordSuccessfulLogin(user._id)
|
||||
Analytics.recordEvent(user._id, "user-logged-in")
|
||||
Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
|
||||
logger.log email: email, user_id: user._id.toString(), "successful log in"
|
||||
req.session.justLoggedIn = true
|
||||
# capture the request ip for use when creating the session
|
||||
user._login_req_ip = req.ip
|
||||
req._redir = redir
|
||||
return done(null, user)
|
||||
else
|
||||
AuthenticationController._recordFailedLogin()
|
||||
|
@ -145,21 +157,23 @@ module.exports = AuthenticationController =
|
|||
return isValid
|
||||
|
||||
_redirectToLoginOrRegisterPage: (req, res)->
|
||||
if req.query.zipUrl? or req.query.project_name?
|
||||
if (req.query.zipUrl? or
|
||||
req.query.project_name? or
|
||||
req.path == '/user/subscription/new')
|
||||
return AuthenticationController._redirectToRegisterPage(req, res)
|
||||
else
|
||||
AuthenticationController._redirectToLoginPage(req, res)
|
||||
|
||||
_redirectToLoginPage: (req, res) ->
|
||||
logger.log url: req.url, "user not logged in so redirecting to login page"
|
||||
req.query.redir = req.path
|
||||
AuthenticationController._setRedirectInSession(req)
|
||||
url = "/login?#{querystring.stringify(req.query)}"
|
||||
res.redirect url
|
||||
Metrics.inc "security.login-redirect"
|
||||
|
||||
_redirectToRegisterPage: (req, res) ->
|
||||
logger.log url: req.url, "user not logged in so redirecting to register page"
|
||||
req.query.redir = req.path
|
||||
AuthenticationController._setRedirectInSession(req)
|
||||
url = "/register?#{querystring.stringify(req.query)}"
|
||||
res.redirect url
|
||||
Metrics.inc "security.login-redirect"
|
||||
|
@ -176,3 +190,16 @@ module.exports = AuthenticationController =
|
|||
_recordFailedLogin: (callback = (error) ->) ->
|
||||
Metrics.inc "user.login.failed"
|
||||
callback()
|
||||
|
||||
_setRedirectInSession: (req, value) ->
|
||||
if !value?
|
||||
value = if Object.keys(req.query).length > 0 then "#{req.path}?#{querystring.stringify(req.query)}" else req.path
|
||||
if req.session?
|
||||
req.session.postLoginRedirect = value
|
||||
|
||||
_getRedirectFromSession: (req) ->
|
||||
return req?.session?.postLoginRedirect || null
|
||||
|
||||
_clearRedirectFromSession: (req) ->
|
||||
if req.session?
|
||||
delete req.session.postLoginRedirect
|
||||
|
|
|
@ -108,5 +108,5 @@ module.exports = AuthorizationMiddlewear =
|
|||
logger.log {from: from}, "redirecting to login"
|
||||
redirect_to = "/login"
|
||||
if from?
|
||||
redirect_to += "?redir=#{encodeURIComponent(from)}"
|
||||
AuthenticationController._setRedirectInSession(req, from)
|
||||
res.redirect redirect_to
|
||||
|
|
24
services/web/app/coffee/Features/Blog/BlogHandler.coffee
Normal file
24
services/web/app/coffee/Features/Blog/BlogHandler.coffee
Normal file
|
@ -0,0 +1,24 @@
|
|||
request = require "request"
|
||||
settings = require "settings-sharelatex"
|
||||
_ = require("underscore")
|
||||
logger = require "logger-sharelatex"
|
||||
|
||||
module.exports = BlogHandler =
|
||||
|
||||
getLatestAnnouncements: (callback)->
|
||||
blogUrl = "#{settings.apis.blog.url}/blog/latestannouncements.json"
|
||||
opts =
|
||||
url:blogUrl
|
||||
json:true
|
||||
timeout:500
|
||||
request.get opts, (err, res, announcements)->
|
||||
if err?
|
||||
return callback err
|
||||
if res.statusCode != 200
|
||||
return callback("blog announcement returned non 200")
|
||||
logger.log announcementsLength: announcements?.length, "announcements returned"
|
||||
announcements = _.map announcements, (announcement)->
|
||||
announcement.url = "/blog#{announcement.url}"
|
||||
announcement.date = new Date(announcement.date)
|
||||
return announcement
|
||||
callback(err, announcements)
|
|
@ -11,27 +11,6 @@ module.exports = CollaboratorsEmailHandler =
|
|||
"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
|
||||
].join("&")
|
||||
|
||||
notifyUserOfProjectShare: (project_id, email, callback)->
|
||||
Project
|
||||
.findOne(_id: project_id )
|
||||
.select("name owner_ref")
|
||||
.populate('owner_ref')
|
||||
.exec (err, project)->
|
||||
emailOptions =
|
||||
to: email
|
||||
replyTo: project.owner_ref.email
|
||||
project:
|
||||
name: project.name
|
||||
url: "#{Settings.siteUrl}/project/#{project._id}?" + [
|
||||
"project_name=#{encodeURIComponent(project.name)}"
|
||||
"user_first_name=#{encodeURIComponent(project.owner_ref.first_name)}"
|
||||
"new_email=#{encodeURIComponent(email)}"
|
||||
"r=#{project.owner_ref.referal_id}" # Referal
|
||||
"rs=ci" # referral source = collaborator invite
|
||||
].join("&")
|
||||
owner: project.owner_ref
|
||||
EmailHandler.sendEmail "projectSharedWithYou", emailOptions, callback
|
||||
|
||||
notifyUserOfProjectInvite: (project_id, email, invite, callback)->
|
||||
Project
|
||||
.findOne(_id: project_id )
|
||||
|
|
|
@ -4,6 +4,7 @@ UserGetter = require "../User/UserGetter"
|
|||
CollaboratorsHandler = require('./CollaboratorsHandler')
|
||||
CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler')
|
||||
logger = require('logger-sharelatex')
|
||||
Settings = require('settings-sharelatex')
|
||||
EmailHelper = require "../Helpers/EmailHelper"
|
||||
EditorRealTimeController = require("../Editor/EditorRealTimeController")
|
||||
NotificationsBuilder = require("../Notifications/NotificationsBuilder")
|
||||
|
@ -21,6 +22,16 @@ module.exports = CollaboratorsInviteController =
|
|||
return next(err)
|
||||
res.json({invites: invites})
|
||||
|
||||
_checkShouldInviteEmail: (email, callback=(err, shouldAllowInvite)->) ->
|
||||
if Settings.restrictInvitesToExistingAccounts == true
|
||||
logger.log {email}, "checking if user exists with this email"
|
||||
UserGetter.getUser {email: email}, {_id: 1}, (err, user) ->
|
||||
return callback(err) if err?
|
||||
userExists = user? and user?._id?
|
||||
callback(null, userExists)
|
||||
else
|
||||
callback(null, true)
|
||||
|
||||
inviteToProject: (req, res, next) ->
|
||||
projectId = req.params.Project_id
|
||||
email = req.body.email
|
||||
|
@ -37,6 +48,13 @@ module.exports = CollaboratorsInviteController =
|
|||
if !email? or email == ""
|
||||
logger.log {projectId, email, sendingUserId}, "invalid email address"
|
||||
return res.sendStatus(400)
|
||||
CollaboratorsInviteController._checkShouldInviteEmail email, (err, shouldAllowInvite)->
|
||||
if err?
|
||||
logger.err {err, email, projectId, sendingUserId}, "error checking if we can invite this email address"
|
||||
return next(err)
|
||||
if !shouldAllowInvite
|
||||
logger.log {email, projectId, sendingUserId}, "not allowed to send an invite to this email address"
|
||||
return res.json {invite: null, error: 'cannot_invite_non_user'}
|
||||
CollaboratorsInviteHandler.inviteToProject projectId, sendingUser, email, privileges, (err, invite) ->
|
||||
if err?
|
||||
logger.err {projectId, email, sendingUserId}, "error creating project invite"
|
||||
|
|
|
@ -30,7 +30,7 @@ module.exports = DocstoreManager =
|
|||
logger.error err: error, project_id: project_id, "error getting all docs from docstore"
|
||||
callback(error)
|
||||
|
||||
getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev) ->) ->
|
||||
getDoc: (project_id, doc_id, options = {}, callback = (error, lines, rev, version) ->) ->
|
||||
if typeof(options) == "function"
|
||||
callback = options
|
||||
options = {}
|
||||
|
@ -45,19 +45,20 @@ module.exports = DocstoreManager =
|
|||
return callback(error) if error?
|
||||
if 200 <= res.statusCode < 300
|
||||
logger.log doc_id: doc_id, project_id: project_id, version: doc.version, rev: doc.rev, "got doc from docstore api"
|
||||
callback(null, doc.lines, doc.rev)
|
||||
callback(null, doc.lines, doc.rev, doc.version)
|
||||
else
|
||||
error = new Error("docstore api responded with non-success code: #{res.statusCode}")
|
||||
logger.error err: error, project_id: project_id, doc_id: doc_id, "error getting doc from docstore"
|
||||
callback(error)
|
||||
|
||||
updateDoc: (project_id, doc_id, lines, callback = (error, modified, rev) ->) ->
|
||||
updateDoc: (project_id, doc_id, lines, version, callback = (error, modified, rev) ->) ->
|
||||
logger.log project_id: project_id, doc_id: doc_id, "updating doc in docstore api"
|
||||
url = "#{settings.apis.docstore.url}/project/#{project_id}/doc/#{doc_id}"
|
||||
request.post {
|
||||
url: url
|
||||
json:
|
||||
lines: lines
|
||||
version: version
|
||||
}, (error, res, result) ->
|
||||
return callback(error) if error?
|
||||
if 200 <= res.statusCode < 300
|
||||
|
|
|
@ -7,7 +7,7 @@ module.exports =
|
|||
doc_id = req.params.doc_id
|
||||
plain = req?.query?.plain == 'true'
|
||||
logger.log doc_id:doc_id, project_id:project_id, "receiving get document request from api (docupdater)"
|
||||
ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev) ->
|
||||
ProjectEntityHandler.getDoc project_id, doc_id, (error, lines, rev, version) ->
|
||||
if error?
|
||||
logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
|
||||
return next(error)
|
||||
|
@ -18,14 +18,15 @@ module.exports =
|
|||
res.type "json"
|
||||
res.send JSON.stringify {
|
||||
lines: lines
|
||||
version: version
|
||||
}
|
||||
|
||||
setDocument: (req, res, next = (error) ->) ->
|
||||
project_id = req.params.Project_id
|
||||
doc_id = req.params.doc_id
|
||||
lines = req.body.lines
|
||||
{lines, version} = req.body
|
||||
logger.log doc_id:doc_id, project_id:project_id, "receiving set document request from api (docupdater)"
|
||||
ProjectEntityHandler.updateDocLines project_id, doc_id, lines, (error) ->
|
||||
ProjectEntityHandler.updateDocLines project_id, doc_id, lines, version, (error) ->
|
||||
if error?
|
||||
logger.err err:error, doc_id:doc_id, project_id:project_id, "error finding element for getDocument"
|
||||
return next(error)
|
||||
|
|
|
@ -7,10 +7,18 @@ settings = require("settings-sharelatex")
|
|||
|
||||
templates = {}
|
||||
|
||||
|
||||
templates.registered =
|
||||
subject: _.template "Activate your #{settings.appName} Account"
|
||||
layout: PersonalEmailLayout
|
||||
type: "notification"
|
||||
plainTextTemplate: _.template """
|
||||
Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".
|
||||
|
||||
Click here to set your password and log in: <%= setNewPasswordUrl %>
|
||||
|
||||
If you have any questions or problems, please contact #{settings.adminEmail}
|
||||
"""
|
||||
compiledTemplate: _.template """
|
||||
<p>Congratulations, you've just had an account created for you on #{settings.appName} with the email address "<%= to %>".</p>
|
||||
|
||||
|
@ -19,10 +27,24 @@ templates.registered =
|
|||
<p>If you have any questions or problems, please contact <a href="mailto:#{settings.adminEmail}">#{settings.adminEmail}</a>.</p>
|
||||
"""
|
||||
|
||||
|
||||
templates.canceledSubscription =
|
||||
subject: _.template "ShareLaTeX thoughts"
|
||||
layout: PersonalEmailLayout
|
||||
type:"lifecycle"
|
||||
plainTextTemplate: _.template """
|
||||
Hi <%= first_name %>,
|
||||
|
||||
I'm sorry to see you cancelled your ShareLaTeX premium account. Would you mind giving me some advice on what the site is lacking at the moment via this survey?:
|
||||
|
||||
https://sharelatex.typeform.com/to/f5lBiZ
|
||||
|
||||
Thank you in advance.
|
||||
|
||||
Henry
|
||||
|
||||
ShareLaTeX Co-founder
|
||||
"""
|
||||
compiledTemplate: _.template '''
|
||||
<p>Hi <%= first_name %>,</p>
|
||||
|
||||
|
@ -36,10 +58,26 @@ ShareLaTeX Co-founder
|
|||
</p>
|
||||
'''
|
||||
|
||||
|
||||
templates.passwordResetRequested =
|
||||
subject: _.template "Password Reset - #{settings.appName}"
|
||||
layout: NotificationEmailLayout
|
||||
type:"notification"
|
||||
plainTextTemplate: _.template """
|
||||
Password Reset
|
||||
|
||||
We got a request to reset your #{settings.appName} password.
|
||||
|
||||
Click this link to reset your password: <%= setNewPasswordUrl %>
|
||||
|
||||
If you ignore this message, your password won't be changed.
|
||||
|
||||
If you didn't request a password reset, let us know.
|
||||
|
||||
Thank you
|
||||
|
||||
#{settings.appName} - <%= siteUrl %>
|
||||
"""
|
||||
compiledTemplate: _.template """
|
||||
<h2>Password Reset</h2>
|
||||
<p>
|
||||
|
@ -66,26 +104,6 @@ If you didn't request a password reset, let us know.
|
|||
<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
|
||||
"""
|
||||
|
||||
templates.projectSharedWithYou =
|
||||
subject: _.template "<%= owner.email %> wants to share <%= project.name %> with you"
|
||||
layout: NotificationEmailLayout
|
||||
type:"notification"
|
||||
compiledTemplate: _.template """
|
||||
<p>Hi, <%= owner.email %> wants to share <a href="<%= project.url %>">'<%= project.name %>'</a> with you</p>
|
||||
<center>
|
||||
<div style="width:200px;background-color:#a93629;border:1px solid #e24b3b;border-radius:3px;padding:15px; margin:24px;">
|
||||
<div style="padding-right:10px;padding-left:10px">
|
||||
<a href="<%= project.url %>" style="text-decoration:none" target="_blank">
|
||||
<span style= "font-size:16px;font-family:Helvetica,Arial;font-weight:400;color:#fff;white-space:nowrap;display:block; text-align:center">
|
||||
View Project
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</center>
|
||||
<p> Thank you</p>
|
||||
<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
|
||||
"""
|
||||
|
||||
templates.projectInvite =
|
||||
subject: _.template "<%= project.name %> - shared by <%= owner.email %>"
|
||||
|
@ -113,10 +131,20 @@ Thank you
|
|||
<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
|
||||
"""
|
||||
|
||||
|
||||
templates.completeJoinGroupAccount =
|
||||
subject: _.template "Verify Email to join <%= group_name %> group"
|
||||
layout: NotificationEmailLayout
|
||||
type:"notification"
|
||||
plainTextTemplate: _.template """
|
||||
Hi, please verify your email to join the <%= group_name %> and get your free premium account
|
||||
|
||||
Click this link to verify now: <%= completeJoinUrl %>
|
||||
|
||||
Thank You
|
||||
|
||||
#{settings.appName} - <%= siteUrl %>
|
||||
"""
|
||||
compiledTemplate: _.template """
|
||||
<p>Hi, please verify your email to join the <%= group_name %> and get your free premium account</p>
|
||||
<center>
|
||||
|
@ -134,6 +162,7 @@ templates.completeJoinGroupAccount =
|
|||
<p> <a href="<%= siteUrl %>">#{settings.appName}</a></p>
|
||||
"""
|
||||
|
||||
|
||||
module.exports =
|
||||
templates: templates
|
||||
|
||||
|
|
|
@ -25,7 +25,7 @@ else if Settings?.email?.parameters?.sendgridApiKey?
|
|||
logger.log "using sendgrid for email"
|
||||
nm_client = nodemailer.createTransport(sgTransport({auth:{api_key:Settings?.email?.parameters?.sendgridApiKey}}))
|
||||
else if Settings?.email?.parameters?
|
||||
smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth")
|
||||
smtp = _.pick(Settings?.email?.parameters, "host", "port", "secure", "auth", "ignoreTLS")
|
||||
|
||||
|
||||
logger.log "using smtp for email"
|
||||
|
|
|
@ -63,7 +63,8 @@ Reporter = (res) ->
|
|||
|
||||
res.contentType("application/json")
|
||||
if failures.length > 0
|
||||
res.send 500, JSON.stringify(results, null, 2)
|
||||
logger.err failures:failures, "health check failed"
|
||||
res.status(500).send(JSON.stringify(results, null, 2))
|
||||
else
|
||||
res.send 200, JSON.stringify(results, null, 2)
|
||||
res.status(200).send(JSON.stringify(results, null, 2))
|
||||
|
||||
|
|
|
@ -153,6 +153,8 @@ module.exports = ProjectController =
|
|||
return next(err)
|
||||
logger.log results:results, user_id:user_id, "rendering project list"
|
||||
tags = results.tags[0]
|
||||
|
||||
|
||||
notifications = require("underscore").map results.notifications, (notification)->
|
||||
notification.html = req.i18n.translate(notification.templateKey, notification.messageOpts)
|
||||
return notification
|
||||
|
|
|
@ -126,7 +126,7 @@ module.exports = ProjectEntityHandler =
|
|||
doc = new Doc name: docName
|
||||
# Put doc in docstore first, so that if it errors, we don't have a doc_id in the project
|
||||
# which hasn't been created in docstore.
|
||||
DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, (err, modified, rev) ->
|
||||
DocstoreManager.updateDoc project_id.toString(), doc._id.toString(), docLines, 0, (err, modified, rev) ->
|
||||
return callback(err) if err?
|
||||
|
||||
ProjectEntityHandler._putElement project, folder_id, doc, "doc", (err, result)=>
|
||||
|
@ -292,7 +292,7 @@ module.exports = ProjectEntityHandler =
|
|||
return callback(err)
|
||||
callback(err, folder, parentFolder_id)
|
||||
|
||||
updateDocLines : (project_id, doc_id, lines, callback = (error) ->)->
|
||||
updateDocLines : (project_id, doc_id, lines, version, callback = (error) ->)->
|
||||
ProjectGetter.getProjectWithoutDocLines project_id, (err, project)->
|
||||
return callback(err) if err?
|
||||
return callback(new Errors.NotFoundError("project not found")) if !project?
|
||||
|
@ -307,7 +307,7 @@ module.exports = ProjectEntityHandler =
|
|||
return callback(error)
|
||||
|
||||
logger.log project_id: project_id, doc_id: doc_id, "telling docstore manager to update doc"
|
||||
DocstoreManager.updateDoc project_id, doc_id, lines, (err, modified, rev) ->
|
||||
DocstoreManager.updateDoc project_id, doc_id, lines, version, (err, modified, rev) ->
|
||||
if err?
|
||||
logger.error err: err, doc_id: doc_id, project_id:project_id, lines: lines, "error sending doc to docstore"
|
||||
return callback(err)
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
Settings = require("settings-sharelatex")
|
||||
redis = require("redis-sharelatex")
|
||||
rclient = redis.createClient(Settings.redis.web)
|
||||
crypto = require("crypto")
|
||||
async = require("async")
|
||||
|
||||
|
||||
module.exports =
|
||||
|
||||
_getEmailKey : (email)->
|
||||
hash = crypto.createHash("md5").update(email).digest("hex")
|
||||
return "e_sess:#{hash}"
|
||||
|
||||
tracksession:(sessionId, email, callback = ->)->
|
||||
session_lookup_key = @_getEmailKey(email)
|
||||
rclient.set session_lookup_key, sessionId, callback
|
||||
|
||||
invalidateSession:(email, callback = ->)->
|
||||
session_lookup_key = @_getEmailKey(email)
|
||||
rclient.get session_lookup_key, (err, sessionId)->
|
||||
async.series [
|
||||
(cb)-> rclient.del sessionId, cb
|
||||
(cb)-> rclient.del session_lookup_key, cb
|
||||
], callback
|
||||
|
||||
|
|
@ -418,7 +418,15 @@ module.exports = RecurlyWrapper =
|
|||
url: "subscriptions/#{subscriptionId}/cancel",
|
||||
method: "put"
|
||||
}, (error, response, body) ->
|
||||
if error?
|
||||
RecurlyWrapper._parseXml body, (_err, parsed) ->
|
||||
if parsed?.error?.description == "A canceled subscription can't transition to canceled"
|
||||
logger.log {subscriptionId, error, body}, "subscription already cancelled, not really an error, proceeding"
|
||||
callback(null)
|
||||
else
|
||||
callback(error)
|
||||
else
|
||||
callback(null)
|
||||
)
|
||||
|
||||
reactivateSubscription: (subscriptionId, callback) ->
|
||||
|
|
|
@ -8,27 +8,34 @@ Settings = require 'settings-sharelatex'
|
|||
logger = require('logger-sharelatex')
|
||||
GeoIpLookup = require("../../infrastructure/GeoIpLookup")
|
||||
SubscriptionDomainHandler = require("./SubscriptionDomainHandler")
|
||||
UserGetter = require "../User/UserGetter"
|
||||
|
||||
module.exports = SubscriptionController =
|
||||
|
||||
plansPage: (req, res, next) ->
|
||||
plans = SubscriptionViewModelBuilder.buildViewModel()
|
||||
if AuthenticationController.isUserLoggedIn(req)
|
||||
baseUrl = ""
|
||||
else
|
||||
baseUrl = "/register?redir="
|
||||
viewName = "subscriptions/plans"
|
||||
if req.query.v?
|
||||
viewName = "#{viewName}_#{req.query.v}"
|
||||
logger.log viewName:viewName, "showing plans page"
|
||||
currentUser = null
|
||||
GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency)->
|
||||
return next(err) if err?
|
||||
render = () ->
|
||||
res.render viewName,
|
||||
title: "plans_and_pricing"
|
||||
plans: plans
|
||||
baseUrl: baseUrl
|
||||
gaExperiments: Settings.gaExperiments.plansPage
|
||||
recomendedCurrency:recomendedCurrency
|
||||
shouldABTestPlans: currentUser == null or (currentUser?.signUpDate? and currentUser.signUpDate >= (new Date('2016-10-27')))
|
||||
user_id = AuthenticationController.getLoggedInUserId(req)
|
||||
if user_id?
|
||||
UserGetter.getUser user_id, {signUpDate: 1}, (err, user) ->
|
||||
return next(err) if err?
|
||||
currentUser = user
|
||||
render()
|
||||
else
|
||||
render()
|
||||
|
||||
#get to show the recurly.js page
|
||||
paymentPage: (req, res, next) ->
|
||||
|
|
|
@ -68,4 +68,3 @@ module.exports =
|
|||
!plan.groupPlan and plan.annual and plan.planCode.indexOf("student") == -1
|
||||
|
||||
return result
|
||||
|
||||
|
|
|
@ -15,11 +15,31 @@ settings = require "settings-sharelatex"
|
|||
|
||||
module.exports = UserController =
|
||||
|
||||
deleteUser: (req, res)->
|
||||
tryDeleteUser: (req, res, next) ->
|
||||
user_id = AuthenticationController.getLoggedInUserId(req)
|
||||
password = req.body.password
|
||||
logger.log {user_id}, "trying to delete user account"
|
||||
if !password? or password == ''
|
||||
logger.err {user_id}, 'no password supplied for attempt to delete account'
|
||||
return res.sendStatus(403)
|
||||
AuthenticationManager.authenticate {_id: user_id}, password, (err, user) ->
|
||||
if err?
|
||||
logger.err {user_id}, 'error authenticating during attempt to delete account'
|
||||
return next(err)
|
||||
if !user
|
||||
logger.err {user_id}, 'auth failed during attempt to delete account'
|
||||
return res.sendStatus(403)
|
||||
UserDeleter.deleteUser user_id, (err) ->
|
||||
if !err?
|
||||
req.session?.destroy()
|
||||
if err?
|
||||
logger.err {user_id}, "error while deleting user account"
|
||||
return next(err)
|
||||
sessionId = req.sessionID
|
||||
req.logout?()
|
||||
req.session.destroy (err) ->
|
||||
if err?
|
||||
logger.err err: err, 'error destorying session'
|
||||
return next(err)
|
||||
UserSessionsManager.untrackSession(user, sessionId)
|
||||
res.sendStatus(200)
|
||||
|
||||
unsubscribe: (req, res)->
|
||||
|
@ -30,6 +50,7 @@ module.exports = UserController =
|
|||
|
||||
updateUserSettings : (req, res)->
|
||||
user_id = AuthenticationController.getLoggedInUserId(req)
|
||||
usingExternalAuth = settings.ldap? or settings.saml?
|
||||
logger.log user_id: user_id, "updating account settings"
|
||||
User.findById user_id, (err, user)->
|
||||
if err? or !user?
|
||||
|
@ -60,12 +81,15 @@ module.exports = UserController =
|
|||
user.ace.syntaxValidation = req.body.syntaxValidation
|
||||
user.save (err)->
|
||||
newEmail = req.body.email?.trim().toLowerCase()
|
||||
if !newEmail? or newEmail == user.email
|
||||
if !newEmail? or newEmail == user.email or usingExternalAuth
|
||||
# end here, don't update email
|
||||
AuthenticationController.setInSessionUser(req, {first_name: user.first_name, last_name: user.last_name})
|
||||
return res.sendStatus 200
|
||||
else if newEmail.indexOf("@") == -1
|
||||
# email invalid
|
||||
return res.sendStatus(400)
|
||||
else
|
||||
# update the user email
|
||||
UserUpdater.changeEmailAddress user_id, newEmail, (err)->
|
||||
if err?
|
||||
logger.err err:err, user_id:user_id, newEmail:newEmail, "problem updaing users email address"
|
||||
|
@ -143,7 +167,7 @@ module.exports = UserController =
|
|||
type:'success'
|
||||
text:'Your password has been changed'
|
||||
else
|
||||
logger.log user: user, "current password wrong"
|
||||
logger.log user_id: user_id, "current password wrong"
|
||||
res.send
|
||||
message:
|
||||
type:'error'
|
||||
|
|
|
@ -17,6 +17,7 @@ module.exports =
|
|||
user = new User()
|
||||
user.email = opts.email
|
||||
user.holdingAccount = opts.holdingAccount
|
||||
user.ace.syntaxValidation = true
|
||||
|
||||
username = opts.email.match(/^[^@]*/)
|
||||
if opts.first_name? and opts.first_name.length != 0
|
||||
|
|
|
@ -20,7 +20,6 @@ module.exports =
|
|||
|
||||
res.render 'user/register',
|
||||
title: 'register'
|
||||
redir: req.query.redir
|
||||
sharedProjectData: sharedProjectData
|
||||
newTemplateData: newTemplateData
|
||||
new_email:req.query.new_email || ""
|
||||
|
@ -49,19 +48,25 @@ module.exports =
|
|||
token: req.query.token
|
||||
|
||||
loginPage : (req, res)->
|
||||
# if user is being sent to /login with explicit redirect (redir=/foo),
|
||||
# such as being sent from the editor to /login, then set the redirect explicitly
|
||||
if req.query.redir? and !AuthenticationController._getRedirectFromSession(req)?
|
||||
logger.log {redir: req.query.redir}, "setting explicit redirect from login page"
|
||||
AuthenticationController._setRedirectInSession(req, req.query.redir)
|
||||
res.render 'user/login',
|
||||
title: 'login',
|
||||
redir: req.query.redir,
|
||||
email: req.query.email
|
||||
|
||||
settingsPage : (req, res, next)->
|
||||
user_id = AuthenticationController.getLoggedInUserId(req)
|
||||
logger.log user: user_id, "loading settings page"
|
||||
shouldAllowEditingDetails = !(Settings?.ldap?.updateUserDetailsOnLogin) and !(Settings?.saml?.updateUserDetailsOnLogin)
|
||||
UserLocator.findById user_id, (err, user)->
|
||||
return next(err) if err?
|
||||
res.render 'user/settings',
|
||||
title:'account_settings'
|
||||
user: user,
|
||||
shouldAllowEditingDetails: shouldAllowEditingDetails
|
||||
languages: Settings.languages,
|
||||
accountSettingsTabActive: true
|
||||
|
||||
|
|
|
@ -3,14 +3,11 @@ redis = require('redis-sharelatex')
|
|||
logger = require("logger-sharelatex")
|
||||
Async = require('async')
|
||||
_ = require('underscore')
|
||||
UserSessionsRedis = require('./UserSessionsRedis')
|
||||
|
||||
rclient = redis.createClient(Settings.redis.web)
|
||||
rclient = UserSessionsRedis.client()
|
||||
|
||||
module.exports = UserSessionsManager =
|
||||
|
||||
_sessionSetKey: (user) ->
|
||||
return "UserSessions:#{user._id}"
|
||||
|
||||
# mimic the key used by the express sessions
|
||||
_sessionKey: (sessionId) ->
|
||||
return "sess:#{sessionId}"
|
||||
|
@ -23,7 +20,7 @@ module.exports = UserSessionsManager =
|
|||
logger.log {user_id: user._id}, "no sessionId to track, returning"
|
||||
return callback(null)
|
||||
logger.log {user_id: user._id, sessionId}, "onLogin handler"
|
||||
sessionSetKey = UserSessionsManager._sessionSetKey(user)
|
||||
sessionSetKey = UserSessionsRedis.sessionSetKey(user)
|
||||
value = UserSessionsManager._sessionKey sessionId
|
||||
rclient.multi()
|
||||
.sadd(sessionSetKey, value)
|
||||
|
@ -43,7 +40,7 @@ module.exports = UserSessionsManager =
|
|||
logger.log {user_id: user._id}, "no sessionId to untrack, returning"
|
||||
return callback(null)
|
||||
logger.log {user_id: user._id, sessionId}, "onLogout handler"
|
||||
sessionSetKey = UserSessionsManager._sessionSetKey(user)
|
||||
sessionSetKey = UserSessionsRedis.sessionSetKey(user)
|
||||
value = UserSessionsManager._sessionKey sessionId
|
||||
rclient.multi()
|
||||
.srem(sessionSetKey, value)
|
||||
|
@ -57,7 +54,7 @@ module.exports = UserSessionsManager =
|
|||
|
||||
getAllUserSessions: (user, exclude, callback=(err, sessionKeys)->) ->
|
||||
exclude = _.map(exclude, UserSessionsManager._sessionKey)
|
||||
sessionSetKey = UserSessionsManager._sessionSetKey(user)
|
||||
sessionSetKey = UserSessionsRedis.sessionSetKey(user)
|
||||
rclient.smembers sessionSetKey, (err, sessionKeys) ->
|
||||
if err?
|
||||
logger.err user_id: user._id, "error getting all session keys for user from redis"
|
||||
|
@ -66,7 +63,8 @@ module.exports = UserSessionsManager =
|
|||
if sessionKeys.length == 0
|
||||
logger.log {user_id: user._id}, "no other sessions found, returning"
|
||||
return callback(null, [])
|
||||
rclient.mget sessionKeys, (err, sessions) ->
|
||||
|
||||
Async.mapSeries sessionKeys, ((k, cb) -> rclient.get(k, cb)), (err, sessions) ->
|
||||
if err?
|
||||
logger.err {user_id: user._id}, "error getting all sessions for user from redis"
|
||||
return callback(err)
|
||||
|
@ -92,7 +90,7 @@ module.exports = UserSessionsManager =
|
|||
logger.log {}, "no user to revoke sessions for, returning"
|
||||
return callback(null)
|
||||
logger.log {user_id: user._id}, "revoking all existing sessions for user"
|
||||
sessionSetKey = UserSessionsManager._sessionSetKey(user)
|
||||
sessionSetKey = UserSessionsRedis.sessionSetKey(user)
|
||||
rclient.smembers sessionSetKey, (err, sessionKeys) ->
|
||||
if err?
|
||||
logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
|
||||
|
@ -102,12 +100,18 @@ module.exports = UserSessionsManager =
|
|||
logger.log {user_id: user._id}, "no sessions in UserSessions set to delete, returning"
|
||||
return callback(null)
|
||||
logger.log {user_id: user._id, count: keysToDelete.length}, "deleting sessions for user"
|
||||
rclient.multi()
|
||||
.del(keysToDelete)
|
||||
.srem(sessionSetKey, keysToDelete)
|
||||
.exec (err, result) ->
|
||||
|
||||
deletions = keysToDelete.map (k) ->
|
||||
(cb) ->
|
||||
rclient.del k, cb
|
||||
|
||||
Async.series deletions, (err, _result) ->
|
||||
if err?
|
||||
logger.err {err, user_id: user._id, sessionSetKey}, "error revoking all sessions for user"
|
||||
logger.err {err, user_id: user._id, sessionSetKey}, "errror revoking all sessions for user"
|
||||
return callback(err)
|
||||
rclient.srem sessionSetKey, keysToDelete, (err) ->
|
||||
if err?
|
||||
logger.err {err, user_id: user._id, sessionSetKey}, "error removing session set for user"
|
||||
return callback(err)
|
||||
callback(null)
|
||||
|
||||
|
@ -115,7 +119,7 @@ module.exports = UserSessionsManager =
|
|||
if !user
|
||||
logger.log {}, "no user to touch sessions for, returning"
|
||||
return callback(null)
|
||||
sessionSetKey = UserSessionsManager._sessionSetKey(user)
|
||||
sessionSetKey = UserSessionsRedis.sessionSetKey(user)
|
||||
rclient.expire sessionSetKey, "#{Settings.cookieSessionLength}", (err, response) ->
|
||||
if err?
|
||||
logger.err {err, user_id: user._id}, "error while updating ttl on UserSessions set"
|
||||
|
@ -127,7 +131,7 @@ module.exports = UserSessionsManager =
|
|||
logger.log {}, "no user, returning"
|
||||
return callback(null)
|
||||
logger.log {user_id: user._id}, "checking sessions for user"
|
||||
sessionSetKey = UserSessionsManager._sessionSetKey(user)
|
||||
sessionSetKey = UserSessionsRedis.sessionSetKey(user)
|
||||
rclient.smembers sessionSetKey, (err, sessionKeys) ->
|
||||
if err?
|
||||
logger.err {err, user_id: user._id, sessionSetKey}, "error getting contents of UserSessions set"
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
Settings = require 'settings-sharelatex'
|
||||
redis = require 'redis-sharelatex'
|
||||
ioredis = require 'ioredis'
|
||||
logger = require 'logger-sharelatex'
|
||||
|
||||
redisSessionsSettings = Settings.redis.websessions or Settings.redis.web
|
||||
|
||||
module.exports = Redis =
|
||||
client: () ->
|
||||
if redisSessionsSettings?.cluster?
|
||||
logger.log {}, "using redis cluster for web sessions"
|
||||
rclient = new ioredis.Cluster(redisSessionsSettings.cluster)
|
||||
else
|
||||
rclient = redis.createClient(redisSessionsSettings)
|
||||
return rclient
|
||||
|
||||
sessionSetKey: (user) ->
|
||||
if redisSessionsSettings?.cluster?
|
||||
return "UserSessions:{#{user._id}}"
|
||||
else
|
||||
return "UserSessions:#{user._id}"
|
|
@ -39,7 +39,7 @@ pathList = [
|
|||
["#{jsPath}ide.js"]
|
||||
["#{jsPath}main.js"]
|
||||
["#{jsPath}libs.js"]
|
||||
["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js", "#{jsPath}#{ace}/snippets/latex.js"]
|
||||
["#{jsPath}#{ace}/ace.js","#{jsPath}#{ace}/mode-latex.js","#{jsPath}#{ace}/worker-latex.js","#{jsPath}#{ace}/snippets/latex.js"]
|
||||
["#{jsPath}libs/#{pdfjs}/pdf.js"]
|
||||
["#{jsPath}libs/#{pdfjs}/pdf.worker.js"]
|
||||
["#{jsPath}libs/#{pdfjs}/compatibility.js"]
|
||||
|
@ -210,7 +210,7 @@ module.exports = (app, webRouter, apiRouter)->
|
|||
|
||||
webRouter.use (req, res, next)->
|
||||
res.locals.externalAuthenticationSystemUsed = ->
|
||||
Settings.ldap?
|
||||
Settings.ldap? or Settings.saml?
|
||||
next()
|
||||
|
||||
webRouter.use (req, res, next)->
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
module.exports =
|
||||
user: (user) ->
|
||||
if !user?
|
||||
return null
|
||||
if !user._id?
|
||||
user = {_id : user}
|
||||
return {
|
||||
|
@ -10,6 +12,8 @@ module.exports =
|
|||
}
|
||||
|
||||
project: (project) ->
|
||||
if !project?
|
||||
return null
|
||||
if !project._id?
|
||||
project = {_id: project}
|
||||
return {
|
||||
|
|
|
@ -19,6 +19,10 @@ module.exports = Modules =
|
|||
for module in @modules
|
||||
module.router?.apply(webRouter, apiRouter)
|
||||
|
||||
applyNonCsrfRouter: (webRouter, apiRouter) ->
|
||||
for module in @modules
|
||||
module.nonCsrfRouter?.apply(webRouter, apiRouter)
|
||||
|
||||
viewIncludes: {}
|
||||
loadViewIncludes: (app) ->
|
||||
@viewIncludes = {}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
version = {
|
||||
"pdfjs": "1.6.210p1"
|
||||
"pdfjs": "1.6.210p2"
|
||||
"moment": "2.9.0"
|
||||
"ace": "1.2.5"
|
||||
}
|
||||
|
|
|
@ -8,7 +8,9 @@ expressLocals = require('./ExpressLocals')
|
|||
Router = require('../router')
|
||||
metrics.inc("startup")
|
||||
redis = require("redis-sharelatex")
|
||||
rclient = redis.createClient(Settings.redis.web)
|
||||
UserSessionsRedis = require('../Features/User/UserSessionsRedis')
|
||||
|
||||
sessionsRedisClient = UserSessionsRedis.client()
|
||||
|
||||
session = require("express-session")
|
||||
RedisStore = require('connect-redis')(session)
|
||||
|
@ -19,7 +21,8 @@ csrf = require('csurf')
|
|||
csrfProtection = csrf()
|
||||
cookieParser = require('cookie-parser')
|
||||
|
||||
sessionStore = new RedisStore(client:rclient)
|
||||
# Init the session store
|
||||
sessionStore = new RedisStore(client:sessionsRedisClient)
|
||||
|
||||
passport = require('passport')
|
||||
LocalStrategy = require('passport-local').Strategy
|
||||
|
@ -87,9 +90,7 @@ webRouter.use session
|
|||
secure: Settings.secureCookie
|
||||
store: sessionStore
|
||||
key: Settings.cookieName
|
||||
webRouter.use csrfProtection
|
||||
webRouter.use translations.expressMiddlewear
|
||||
webRouter.use translations.setLangBasedOnDomainMiddlewear
|
||||
rolling: true
|
||||
|
||||
# passport
|
||||
webRouter.use passport.initialize()
|
||||
|
@ -106,6 +107,16 @@ passport.use(new LocalStrategy(
|
|||
passport.serializeUser(AuthenticationController.serializeUser)
|
||||
passport.deserializeUser(AuthenticationController.deserializeUser)
|
||||
|
||||
Modules.hooks.fire 'passportSetup', passport, (err) ->
|
||||
if err?
|
||||
logger.err {err}, "error setting up passport in modules"
|
||||
|
||||
Modules.applyNonCsrfRouter(webRouter, apiRouter)
|
||||
|
||||
webRouter.use csrfProtection
|
||||
webRouter.use translations.expressMiddlewear
|
||||
webRouter.use translations.setLangBasedOnDomainMiddlewear
|
||||
|
||||
# Measure expiry from last request, not last login
|
||||
webRouter.use (req, res, next) ->
|
||||
req.session.touch()
|
||||
|
|
|
@ -26,7 +26,7 @@ UserSchema = new Schema
|
|||
autoComplete: {type : Boolean, default: true}
|
||||
spellCheckLanguage : {type : String, default: "en"}
|
||||
pdfViewer : {type : String, default: "pdfjs"}
|
||||
syntaxValidation : {type : Boolean, default: true}
|
||||
syntaxValidation : {type : Boolean}
|
||||
}
|
||||
features : {
|
||||
collaborators: { type:Number, default: Settings.defaultFeatures.collaborators }
|
||||
|
|
|
@ -39,6 +39,7 @@ ReferencesController = require('./Features/References/ReferencesController')
|
|||
AuthorizationMiddlewear = require('./Features/Authorization/AuthorizationMiddlewear')
|
||||
BetaProgramController = require('./Features/BetaProgram/BetaProgramController')
|
||||
AnalyticsRouter = require('./Features/Analytics/AnalyticsRouter')
|
||||
AnnouncementsController = require("./Features/Announcements/AnnouncementsController")
|
||||
|
||||
logger = require("logger-sharelatex")
|
||||
_ = require("underscore")
|
||||
|
@ -92,7 +93,7 @@ module.exports = class Router
|
|||
webRouter.post '/user/sessions/clear', AuthenticationController.requireLogin(), UserController.clearSessions
|
||||
|
||||
webRouter.delete '/user/newsletter/unsubscribe', AuthenticationController.requireLogin(), UserController.unsubscribe
|
||||
webRouter.delete '/user', AuthenticationController.requireLogin(), UserController.deleteUser
|
||||
webRouter.post '/user/delete', AuthenticationController.requireLogin(), UserController.tryDeleteUser
|
||||
|
||||
webRouter.get '/user/personal_info', AuthenticationController.requireLogin(), UserInfoController.getLoggedInUsersPersonalInfo
|
||||
apiRouter.get '/user/:user_id/personal_info', AuthenticationController.httpAuth, UserInfoController.getPersonalInfo
|
||||
|
@ -187,6 +188,9 @@ module.exports = class Router
|
|||
webRouter.get '/notifications', AuthenticationController.requireLogin(), NotificationsController.getAllUnreadNotifications
|
||||
webRouter.delete '/notifications/:notification_id', AuthenticationController.requireLogin(), NotificationsController.markNotificationAsRead
|
||||
|
||||
webRouter.get '/announcements', AuthenticationController.requireLogin(), AnnouncementsController.getUndreadAnnouncements
|
||||
|
||||
|
||||
# Deprecated in favour of /internal/project/:project_id but still used by versioning
|
||||
apiRouter.get '/project/:project_id/details', AuthenticationController.httpAuth, ProjectApiController.getProjectDetails
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ html(itemscope, itemtype='http://schema.org/Product')
|
|||
head
|
||||
title Something went wrong
|
||||
link(rel="icon", href="/favicon.ico")
|
||||
if buildCssPath
|
||||
link(rel='stylesheet', href=buildCssPath('/style.css'))
|
||||
link(href="//netdna.bootstrapcdn.com/font-awesome/4.2.0/css/font-awesome.min.css",rel="stylesheet")
|
||||
body
|
||||
|
@ -12,7 +13,9 @@ html(itemscope, itemtype='http://schema.org/Product')
|
|||
.col-md-8.col-md-offset-2.text-center
|
||||
.page-header
|
||||
h2 Oh dear, something went wrong.
|
||||
p: img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
|
||||
if buildImgPath
|
||||
p
|
||||
img(src=buildImgPath("lion-sad-128.png"), alt="Sad Lion")
|
||||
p
|
||||
| Something went wrong with your request, sorry. Our staff are probably looking into this, but if it continues, please contact us at #{settings.adminEmail}
|
||||
p
|
||||
|
|
|
@ -51,8 +51,6 @@ html(itemscope, itemtype='http://schema.org/Product')
|
|||
script(type="text/javascript").
|
||||
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"
|
||||
|
@ -61,6 +59,9 @@ html(itemscope, itemtype='http://schema.org/Product')
|
|||
if (cdnBlocked && !noCdnAlreadyInUrl && navigator.userAgent.indexOf("Googlebot") == -1) {
|
||||
window.location.search += '&'+noCdnKey;
|
||||
}
|
||||
|
||||
block scripts
|
||||
|
||||
script(src=buildJsPath("libs/angular-1.3.15.min.js", {fingerprint:false}))
|
||||
|
||||
script.
|
||||
|
|
|
@ -35,6 +35,9 @@ nav.navbar.navbar-default
|
|||
each child in item.dropdown
|
||||
if child.divider
|
||||
li.divider
|
||||
else if child.user_email
|
||||
li
|
||||
div.subdued #{getUserEmail()}
|
||||
else
|
||||
li
|
||||
if child.url
|
||||
|
|
|
@ -15,6 +15,7 @@ block content
|
|||
p.text-center.text-danger(ng-if="state.error").ng-cloak
|
||||
span(ng-bind-html="state.error")
|
||||
|
||||
include ./editor/feature-onboarding
|
||||
|
||||
.global-alerts(ng-cloak)
|
||||
.alert.alert-danger.small(ng-if="connection.forced_disconnect")
|
||||
|
@ -37,7 +38,7 @@ block content
|
|||
|
||||
include ./editor/left-menu
|
||||
|
||||
#chat-wrapper(
|
||||
#chat-wrapper.full-size(
|
||||
layout="chat",
|
||||
spacing-open="12",
|
||||
spacing-closed="0",
|
||||
|
@ -85,10 +86,16 @@ block content
|
|||
.modal-footer
|
||||
button.btn.btn-info(ng-click="done()") #{translate("ok")}
|
||||
|
||||
script(type="text/ng-template", id="lockEditorModalTemplate")
|
||||
.modal-header
|
||||
h3 {{ title }}
|
||||
.modal-body(ng-bind-html="message")
|
||||
|
||||
block requirejs
|
||||
script(type="text/javascript" src='/socket.io/socket.io.js')
|
||||
|
||||
//- don't use cdn for worker
|
||||
//- don't use cdn for workers
|
||||
- var aceWorkerPath = buildJsPath(lib('ace'), {cdn:false,fingerprint:false})
|
||||
- var pdfWorkerPath = buildJsPath('/libs/' + lib('pdfjs') + '/pdf.worker', {cdn:false,fingerprint:false})
|
||||
|
||||
//- We need to do .replace(/\//g, '\\/') do that '</script>' -> '<\/script>'
|
||||
|
@ -118,6 +125,9 @@ block requirejs
|
|||
"ace/ext-searchbox": {
|
||||
"deps": ["ace/ace"]
|
||||
},
|
||||
"ace/ext-modelist": {
|
||||
"deps": ["ace/ace"]
|
||||
},
|
||||
"ace/ext-language_tools": {
|
||||
"deps": ["ace/ace"]
|
||||
}
|
||||
|
@ -129,10 +139,6 @@ block requirejs
|
|||
}
|
||||
};
|
||||
window.aceFingerprint = "#{fingerprint(jsPath + lib('ace') + '/ace.js')}"
|
||||
|
||||
- var aceWorkerPath = user.betaProgram ? buildJsPath(lib('ace'), {cdn:false,fingerprint:false}) : "" // don't use cdn for worker
|
||||
|
||||
script(type='text/javascript').
|
||||
window.aceWorkerPath = "#{aceWorkerPath}";
|
||||
|
||||
script(
|
||||
|
|
|
@ -7,8 +7,19 @@ div.full-size(
|
|||
resize-proportionally="true"
|
||||
initial-size-east="'50%'"
|
||||
minimum-restore-size-east="300"
|
||||
allow-overflow-on="'center'"
|
||||
)
|
||||
.ui-layout-center(
|
||||
ng-controller="ReviewPanelController",
|
||||
ng-class="{\
|
||||
'rp-state-current-file': (reviewPanel.subView === SubViews.CUR_FILE),\
|
||||
'rp-state-current-file-expanded': (reviewPanel.subView === SubViews.CUR_FILE && ui.reviewPanelOpen),\
|
||||
'rp-state-current-file-mini': (reviewPanel.subView === SubViews.CUR_FILE && !ui.reviewPanelOpen),\
|
||||
'rp-state-overview': (reviewPanel.subView === SubViews.OVERVIEW),\
|
||||
'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
|
||||
'rp-size-expanded': ui.reviewPanelOpen\
|
||||
}"
|
||||
)
|
||||
.ui-layout-center
|
||||
.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
|
||||
span(ng-show="editor.open_doc_id")
|
||||
i.fa.fa-spin.fa-refresh
|
||||
|
@ -24,7 +35,7 @@ div.full-size(
|
|||
keybindings="settings.mode",
|
||||
font-size="settings.fontSize",
|
||||
auto-complete="settings.autoComplete",
|
||||
spell-check="true",
|
||||
spell-check="!anonymous",
|
||||
spell-check-language="project.spellCheckLanguage",
|
||||
highlights="onlineUserCursorHighlights[editor.open_doc_id]"
|
||||
show-print-margin="false",
|
||||
|
@ -32,13 +43,22 @@ div.full-size(
|
|||
last-updated="editor.last_updated",
|
||||
cursor-position="editor.cursorPosition",
|
||||
goto-line="editor.gotoLine",
|
||||
resize-on="layout:main:resize,layout:pdf:resize",
|
||||
resize-on="layout:main:resize,layout:pdf:resize,layout:review:resize,review-panel:toggle",
|
||||
annotations="pdf.logEntryAnnotations[editor.open_doc_id]",
|
||||
read-only="!permissions.write",
|
||||
on-ctrl-enter="recompileViaKey"
|
||||
syntax-validation="settings.syntaxValidation"
|
||||
file-name="editor.open_doc_name",
|
||||
on-ctrl-enter="recompileViaKey",
|
||||
syntax-validation="settings.syntaxValidation",
|
||||
review-panel="reviewPanel",
|
||||
events-bridge="reviewPanelEventsBridge"
|
||||
track-changes-enabled="trackChangesFeatureFlag",
|
||||
track-new-changes= "reviewPanel.trackNewChanges",
|
||||
changes-tracker="reviewPanel.changesTracker",
|
||||
doc-id="editor.open_doc_id"
|
||||
)
|
||||
|
||||
include ./review-panel
|
||||
|
||||
.ui-layout-east
|
||||
div(ng-if="ui.pdfLayout == 'sideBySide'")
|
||||
include ./pdf
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
.feat-onboard(
|
||||
ng-controller="FeatureOnboardingController"
|
||||
ng-class="('feat-onboard-step' + innerStep)"
|
||||
ng-if="!state.loading && ui.showCodeCheckerOnboarding"
|
||||
ng-cloak
|
||||
)
|
||||
.feat-onboard-wrapper
|
||||
h1.feat-onboard-title
|
||||
| Introducing
|
||||
span.feat-onboard-title-name Code check
|
||||
div(ng-if="innerStep === 1;")
|
||||
p.feat-onboard-description
|
||||
span.feat-onboard-description-name Code check
|
||||
| will highlight potential problems in your LaTeX code, allowing you to handle errors earlier and become more productive.
|
||||
.row
|
||||
video.feat-onboard-video(autoplay, loop)
|
||||
source(src="/img/teasers/code-checker/code-checker.mp4", type="video/mp4")
|
||||
img(src="/img/teasers/code-checker/code-checker.gif")
|
||||
.row.feat-onboard-adv-wrapper
|
||||
.col-xs-4
|
||||
h2.feat-onboard-adv-title
|
||||
| Missing
|
||||
span.feat-onboard-adv-title-highlight brackets
|
||||
p Forgot to place a closing bracket? We'll warn you.
|
||||
.col-xs-4
|
||||
h2.feat-onboard-adv-title
|
||||
| Unclosed
|
||||
span.feat-onboard-adv-title-highlight environments
|
||||
p
|
||||
| Know when you are missing an
|
||||
code \end{...}
|
||||
| command.
|
||||
.col-xs-4
|
||||
h2.feat-onboard-adv-title
|
||||
| Incorrect
|
||||
span.feat-onboard-adv-title-highlight nesting
|
||||
p
|
||||
| Order matters. Get notified when you use an
|
||||
code \end{...}
|
||||
| too soon.
|
||||
.feat-onboard-btn-wrapper
|
||||
button.btn.btn-primary(ng-click="turnCodeCheckOn();") Yes, turn Code check on
|
||||
.feat-onboard-btn-wrapper
|
||||
button.btn.btn-default(ng-click="turnCodeCheckOff();") No, disable it for now
|
||||
div(ng-if="innerStep === 2;")
|
||||
p.feat-onboard-description
|
||||
| Remember: you can always turn
|
||||
span.feat-onboard-description-name Code check
|
||||
em on
|
||||
| or
|
||||
em off
|
||||
|, in the settings menu.
|
||||
.feat-onboard-btn-wrapper
|
||||
button.btn.btn-primary(ng-click="dismiss();") OK, got it
|
|
@ -1,271 +1,11 @@
|
|||
div(ng-if="!shouldABTestHeaderLabels")
|
||||
header.toolbar.toolbar-header(
|
||||
ng-cloak,
|
||||
ng-hide="state.loading"
|
||||
)
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-click="ui.leftMenuShown = true"
|
||||
tooltip='#{translate("menu")}',
|
||||
tooltip-placement="bottom",
|
||||
tooltip-append-to-body="true",
|
||||
)
|
||||
i.fa.fa-fw.fa-bars
|
||||
a(
|
||||
href="/project"
|
||||
tooltip="#{translate('back_to_projects')}",
|
||||
tooltip-placement="bottom",
|
||||
tooltip-append-to-body="true"
|
||||
)
|
||||
i.fa.fa-fw.fa-level-up
|
||||
|
||||
span(ng-controller="PdfViewToggleController")
|
||||
a(
|
||||
href,
|
||||
ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
|
||||
tooltip="PDF",
|
||||
tooltip-placement="bottom",
|
||||
tooltip-append-to-body="true",
|
||||
ng-click="togglePdfView()",
|
||||
ng-class="{ 'active': ui.view == 'pdf' }"
|
||||
)
|
||||
i.fa.fa-file-pdf-o
|
||||
|
||||
.toolbar-center.project-name(ng-controller="ProjectNameController")
|
||||
span.name(
|
||||
ng-dblclick="!permissions.admin || startRenaming()",
|
||||
ng-show="!state.renaming"
|
||||
) {{ project.name }}
|
||||
|
||||
input.form-control(
|
||||
type="text"
|
||||
ng-model="inputs.name",
|
||||
ng-show="state.renaming",
|
||||
on-enter="finishRenaming()",
|
||||
ng-blur="finishRenaming()",
|
||||
select-name-when="state.renaming"
|
||||
)
|
||||
|
||||
a.rename(
|
||||
ng-if="permissions.admin",
|
||||
href='#',
|
||||
tooltip-placement="bottom",
|
||||
tooltip="#{translate('rename')}",
|
||||
tooltip-append-to-body="true",
|
||||
ng-click="startRenaming()",
|
||||
ng-show="!state.renaming"
|
||||
)
|
||||
i.fa.fa-pencil
|
||||
|
||||
.toolbar-right
|
||||
span.online-users(
|
||||
ng-show="onlineUsersArray.length > 0"
|
||||
ng-controller="OnlineUsersController"
|
||||
)
|
||||
span(ng-if="onlineUsersArray.length < 4")
|
||||
span.online-user(
|
||||
ng-repeat="user in onlineUsersArray",
|
||||
ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
|
||||
popover="{{ user.name }}"
|
||||
popover-placement="bottom"
|
||||
popover-append-to-body="true"
|
||||
popover-trigger="mouseenter"
|
||||
ng-click="gotoUser(user)"
|
||||
) {{ user.name.slice(0,1) }}
|
||||
|
||||
span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
|
||||
span.online-user.online-user-multi(
|
||||
dropdown-toggle,
|
||||
tooltip="#{translate('connected_users')}",
|
||||
tooltip-placement="left"
|
||||
)
|
||||
strong {{ onlineUsersArray.length }}
|
||||
i.fa.fa-fw.fa-user
|
||||
ul.dropdown-menu.pull-right
|
||||
li.dropdown-header #{translate('connected_users')}
|
||||
li(ng-repeat="user in onlineUsersArray")
|
||||
a(href, ng-click="gotoUser(user)")
|
||||
span.online-user(
|
||||
ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
|
||||
) {{ user.name.slice(0,1) }}
|
||||
| {{ user.name }}
|
||||
|
||||
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-if="permissions.admin",
|
||||
tooltip="#{translate('share')}",
|
||||
tooltip-placement="bottom",
|
||||
ng-click="openShareProjectModal()",
|
||||
ng-controller="ShareController",
|
||||
)
|
||||
i.fa.fa-fw.fa-group
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-click="toggleHistory()",
|
||||
ng-class="{ active: (ui.view == 'history') }"
|
||||
tooltip="#{translate('recent_changes')}",
|
||||
tooltip-placement="bottom",
|
||||
)
|
||||
i.fa.fa-fw.fa-history
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
tooltip="#{translate('chat')}",
|
||||
tooltip-placement="bottom",
|
||||
ng-class="{ active: ui.chatOpen }",
|
||||
ng-click="toggleChat()",
|
||||
ng-controller="ChatButtonController",
|
||||
ng-show="!anonymous",
|
||||
)
|
||||
i.fa.fa-fw.fa-comment(
|
||||
ng-class="{ 'bounce': unreadMessages > 0 }"
|
||||
)
|
||||
span.label.label-info(
|
||||
ng-show="unreadMessages > 0"
|
||||
) {{ unreadMessages }}
|
||||
|
||||
div(ng-if="shouldABTestHeaderLabels")
|
||||
div(sixpack-switch="editor-header")
|
||||
header.toolbar.toolbar-header(
|
||||
ng-cloak,
|
||||
ng-hide="state.loading"
|
||||
sixpack-default
|
||||
)
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-click="ui.leftMenuShown = true; trackABTestConversion('menu');"
|
||||
tooltip='#{translate("menu")}',
|
||||
tooltip-placement="bottom",
|
||||
tooltip-append-to-body="true",
|
||||
sixpack-convert="editor-header"
|
||||
)
|
||||
i.fa.fa-fw.fa-bars
|
||||
a(
|
||||
href="/project"
|
||||
tooltip="#{translate('back_to_projects')}",
|
||||
tooltip-placement="bottom",
|
||||
tooltip-append-to-body="true"
|
||||
)
|
||||
i.fa.fa-fw.fa-level-up
|
||||
|
||||
span(ng-controller="PdfViewToggleController")
|
||||
a(
|
||||
href,
|
||||
ng-show="ui.pdfLayout == 'flat' && fileTreeClosed",
|
||||
tooltip="PDF",
|
||||
tooltip-placement="bottom",
|
||||
tooltip-append-to-body="true",
|
||||
ng-click="togglePdfView()",
|
||||
ng-class="{ 'active': ui.view == 'pdf' }"
|
||||
)
|
||||
i.fa.fa-file-pdf-o
|
||||
|
||||
.toolbar-center.project-name(ng-controller="ProjectNameController")
|
||||
span.name(
|
||||
ng-dblclick="!permissions.admin || startRenaming()",
|
||||
ng-show="!state.renaming"
|
||||
) {{ project.name }}
|
||||
|
||||
input.form-control(
|
||||
type="text"
|
||||
ng-model="inputs.name",
|
||||
ng-show="state.renaming",
|
||||
on-enter="finishRenaming()",
|
||||
ng-blur="finishRenaming()",
|
||||
select-name-when="state.renaming"
|
||||
)
|
||||
|
||||
a.rename(
|
||||
ng-if="permissions.admin",
|
||||
href='#',
|
||||
tooltip-placement="bottom",
|
||||
tooltip="#{translate('rename')}",
|
||||
tooltip-append-to-body="true",
|
||||
ng-click="startRenaming()",
|
||||
ng-show="!state.renaming"
|
||||
)
|
||||
i.fa.fa-pencil
|
||||
|
||||
.toolbar-right
|
||||
span.online-users(
|
||||
ng-show="onlineUsersArray.length > 0"
|
||||
ng-controller="OnlineUsersController"
|
||||
)
|
||||
span(ng-if="onlineUsersArray.length < 4")
|
||||
span.online-user(
|
||||
ng-repeat="user in onlineUsersArray",
|
||||
ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }",
|
||||
popover="{{ user.name }}"
|
||||
popover-placement="bottom"
|
||||
popover-append-to-body="true"
|
||||
popover-trigger="mouseenter"
|
||||
ng-click="gotoUser(user)"
|
||||
) {{ user.name.slice(0,1) }}
|
||||
|
||||
span.dropdown(dropdown, ng-if="onlineUsersArray.length >= 4")
|
||||
span.online-user.online-user-multi(
|
||||
dropdown-toggle,
|
||||
tooltip="#{translate('connected_users')}",
|
||||
tooltip-placement="left"
|
||||
)
|
||||
strong {{ onlineUsersArray.length }}
|
||||
i.fa.fa-fw.fa-user
|
||||
ul.dropdown-menu.pull-right
|
||||
li.dropdown-header #{translate('connected_users')}
|
||||
li(ng-repeat="user in onlineUsersArray")
|
||||
a(href, ng-click="gotoUser(user)")
|
||||
span.online-user(
|
||||
ng-style="{ 'background-color': 'hsl({{ getHueForUserId(user.user_id) }}, 70%, 50%)' }"
|
||||
) {{ user.name.slice(0,1) }}
|
||||
| {{ user.name }}
|
||||
|
||||
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-if="permissions.admin",
|
||||
tooltip="#{translate('share')}",
|
||||
tooltip-placement="bottom",
|
||||
ng-click="openShareProjectModal(); trackABTestConversion('share');",
|
||||
ng-controller="ShareController",
|
||||
sixpack-convert="editor-header"
|
||||
)
|
||||
i.fa.fa-fw.fa-group
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-click="toggleHistory(); trackABTestConversion('history');",
|
||||
ng-class="{ active: (ui.view == 'history') }"
|
||||
tooltip="#{translate('recent_changes')}",
|
||||
tooltip-placement="bottom",
|
||||
sixpack-convert="editor-header"
|
||||
)
|
||||
i.fa.fa-fw.fa-history
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
tooltip="#{translate('chat')}",
|
||||
tooltip-placement="bottom",
|
||||
ng-class="{ active: ui.chatOpen }",
|
||||
ng-click="toggleChat(); trackABTestConversion('chat');",
|
||||
ng-controller="ChatButtonController",
|
||||
ng-show="!anonymous",
|
||||
sixpack-convert="editor-header"
|
||||
)
|
||||
i.fa.fa-fw.fa-comment(
|
||||
ng-class="{ 'bounce': unreadMessages > 0 }"
|
||||
)
|
||||
span.label.label-info(
|
||||
ng-show="unreadMessages > 0"
|
||||
) {{ unreadMessages }}
|
||||
|
||||
header.toolbar.toolbar-header.toolbar-with-labels(
|
||||
ng-cloak,
|
||||
ng-hide="state.loading"
|
||||
sixpack-when="labels"
|
||||
)
|
||||
.toolbar-left
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-click="ui.leftMenuShown = true; trackABTestConversion('menu');",
|
||||
sixpack-convert="editor-header"
|
||||
ng-click="ui.leftMenuShown = true;",
|
||||
)
|
||||
i.fa.fa-fw.fa-bars
|
||||
p.toolbar-label #{translate("menu")}
|
||||
|
@ -345,31 +85,35 @@ div(ng-if="shouldABTestHeaderLabels")
|
|||
) {{ user.name.slice(0,1) }}
|
||||
| {{ user.name }}
|
||||
|
||||
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-if="trackChangesFeatureFlag",
|
||||
ng-class="{ active: ui.reviewPanelOpen }"
|
||||
ng-click="toggleReviewPanel()"
|
||||
)
|
||||
i.review-icon
|
||||
p.toolbar-label Review
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-if="permissions.admin",
|
||||
ng-click="openShareProjectModal(); trackABTestConversion('share');",
|
||||
ng-click="openShareProjectModal();",
|
||||
ng-controller="ShareController",
|
||||
sixpack-convert="editor-header"
|
||||
)
|
||||
i.fa.fa-fw.fa-group
|
||||
p.toolbar-label #{translate("share")}
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-click="toggleHistory(); trackABTestConversion('history');",
|
||||
ng-click="toggleHistory();",
|
||||
ng-class="{ active: (ui.view == 'history') }",
|
||||
sixpack-convert="editor-header"
|
||||
)
|
||||
i.fa.fa-fw.fa-history
|
||||
p.toolbar-label #{translate("history")}
|
||||
a.btn.btn-full-height(
|
||||
href,
|
||||
ng-class="{ active: ui.chatOpen }",
|
||||
ng-click="toggleChat(); trackABTestConversion('chat');",
|
||||
ng-click="toggleChat();",
|
||||
ng-controller="ChatButtonController",
|
||||
ng-show="!anonymous",
|
||||
sixpack-convert="editor-header"
|
||||
)
|
||||
i.fa.fa-fw.fa-comment(
|
||||
ng-class="{ 'bounce': unreadMessages > 0 }"
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
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")
|
||||
.upgrade-prompt(ng-if="project.features.versioning === false && ui.view === 'history'")
|
||||
|
||||
div(ng-if="project.owner._id == user.id")
|
||||
div(sixpack-switch="teaser-history")
|
||||
.message(sixpack-default)
|
||||
p.text-center: strong #{translate("upgrade_to_get_feature", {feature:"full Project History"})}
|
||||
p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
|
||||
ul.list-unstyled
|
||||
|
@ -28,14 +31,50 @@ div#history(ng-show="ui.view == 'history'")
|
|||
li
|
||||
i.fa.fa-check
|
||||
|#{translate("compile_larger_projects")}
|
||||
|
||||
p.text-center(ng-controller="FreeTrialModalController")
|
||||
a.btn.btn-success(
|
||||
href
|
||||
ng-class="buttonClass"
|
||||
ng-click="startFreeTrial('history')"
|
||||
sixpack-convert="teaser-history"
|
||||
) #{translate("start_free_trial")}
|
||||
|
||||
.message.message-wider(sixpack-when="focused")
|
||||
header.message-header
|
||||
h3 History
|
||||
|
||||
.message-body
|
||||
h4.teaser-title See who changed what. Go back to previous versions.
|
||||
img.teaser-img(
|
||||
src="/img/teasers/history/teaser-history.png"
|
||||
alt="History"
|
||||
)
|
||||
p.text-center.small(ng-show="startedFreeTrial") #{translate("refresh_page_after_starting_free_trial")}
|
||||
.row
|
||||
.col-md-8.col-md-offset-2
|
||||
ul.list-unstyled
|
||||
li
|
||||
i.fa.fa-check
|
||||
| Catch up with your collaborators changes
|
||||
|
||||
li
|
||||
i.fa.fa-check
|
||||
| See changes over any time period
|
||||
|
||||
li
|
||||
i.fa.fa-check
|
||||
| Revert your documents to previous versions
|
||||
|
||||
li
|
||||
i.fa.fa-check
|
||||
| Restore deleted files
|
||||
p.text-center(ng-controller="FreeTrialModalController")
|
||||
a.btn.btn-success(
|
||||
href
|
||||
ng-class="buttonClass"
|
||||
ng-click="startFreeTrial('history')"
|
||||
sixpack-convert="teaser-history"
|
||||
) Try it for free
|
||||
|
||||
.message(ng-show="project.owner._id != user.id")
|
||||
p #{translate("ask_proj_owner_to_upgrade_for_history")}
|
||||
|
|
|
@ -105,8 +105,7 @@ aside#left-menu.full-size(
|
|||
ng-options="o.v as o.n for o in [{ n: 'On', v: true }, { n: 'Off', v: false }]"
|
||||
)
|
||||
|
||||
if (user.betaProgram)
|
||||
.form-controls
|
||||
.form-controls.code-check-setting
|
||||
label(for="syntaxValidation") #{translate("syntax_validation")}
|
||||
select(
|
||||
name="syntaxValidation"
|
||||
|
|
219
services/web/app/views/project/editor/review-panel.jade
Normal file
219
services/web/app/views/project/editor/review-panel.jade
Normal file
|
@ -0,0 +1,219 @@
|
|||
#review-panel
|
||||
.review-panel-toolbar
|
||||
span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = true;", ng-if="reviewPanel.trackNewChanges === false") Track Changes is
|
||||
strong off
|
||||
span.review-panel-toolbar-label(ng-click="reviewPanel.trackNewChanges = false;", ng-if="reviewPanel.trackNewChanges === true") Track Changes is
|
||||
strong on
|
||||
review-panel-toggle(ng-model="reviewPanel.trackNewChanges")
|
||||
|
||||
.rp-entry-list(
|
||||
review-panel-sorted
|
||||
ng-if="reviewPanel.subView === SubViews.CUR_FILE"
|
||||
)
|
||||
.rp-entry-list-inner
|
||||
.rp-entry-wrapper(
|
||||
ng-repeat="(entry_id, entry) in reviewPanel.entries[editor.open_doc_id]"
|
||||
)
|
||||
div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
|
||||
change-entry(
|
||||
entry="entry"
|
||||
user="users[entry.metadata.user_id]"
|
||||
on-reject="rejectChange(entry_id);"
|
||||
on-accept="acceptChange(entry_id);"
|
||||
on-indicator-click="toggleReviewPanel();"
|
||||
)
|
||||
|
||||
div(ng-if="entry.type === 'comment'")
|
||||
comment-entry(
|
||||
entry="entry"
|
||||
users="users"
|
||||
on-resolve="resolveComment(entry, entry_id)"
|
||||
on-unresolve="unresolveComment(entry_id)"
|
||||
on-show-thread="showThread(entry)"
|
||||
on-hide-thread="hideThread(entry)"
|
||||
on-delete="deleteComment(entry_id)"
|
||||
on-reply="submitReply(entry, entry_id);"
|
||||
on-indicator-click="toggleReviewPanel();"
|
||||
)
|
||||
|
||||
div(ng-if="entry.type === 'add-comment'")
|
||||
add-comment-entry(
|
||||
on-start-new="startNewComment();"
|
||||
on-submit="submitNewComment(content);"
|
||||
on-cancel="cancelNewComment();"
|
||||
on-indicator-click="toggleReviewPanel();"
|
||||
)
|
||||
|
||||
.rp-entry-list(
|
||||
ng-if="reviewPanel.subView === SubViews.OVERVIEW"
|
||||
)
|
||||
.rp-overview-file(
|
||||
ng-repeat="(doc_id, entries) in reviewPanel.entries"
|
||||
)
|
||||
.rp-overview-file-header
|
||||
| {{ getFileName(doc_id) }}
|
||||
.rp-entry-wrapper(
|
||||
ng-repeat="(entry_id, entry) in entries | orderOverviewEntries"
|
||||
)
|
||||
div(ng-if="entry.type === 'insert' || entry.type === 'delete'")
|
||||
change-entry(
|
||||
entry="entry"
|
||||
user="users[entry.metadata.user_id]"
|
||||
on-reject="rejectChange(entry.id);"
|
||||
on-accept="acceptChange(entry.id);"
|
||||
on-indicator-click="toggleReviewPanel();"
|
||||
ng-click="gotoEntry(doc_id, entry)"
|
||||
)
|
||||
|
||||
div(ng-if="entry.type === 'comment'")
|
||||
comment-entry(
|
||||
entry="entry"
|
||||
users="users"
|
||||
on-resolve="resolveComment(entry, entry.id)"
|
||||
on-unresolve="unresolveComment(entry.id)"
|
||||
on-delete="deleteComment(entry.id)"
|
||||
on-reply="submitReply(entry, entry_id);"
|
||||
on-indicator-click="toggleReviewPanel();"
|
||||
ng-click="gotoEntry(doc_id, entry)"
|
||||
)
|
||||
|
||||
.rp-nav
|
||||
a.rp-nav-item(
|
||||
href
|
||||
ng-click="setSubView(SubViews.CUR_FILE);"
|
||||
ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.CUR_FILE }"
|
||||
)
|
||||
i.fa.fa-file-text-o
|
||||
span.rp-nav-label Current file
|
||||
a.rp-nav-item(
|
||||
href
|
||||
ng-click="setSubView(SubViews.OVERVIEW);"
|
||||
ng-class="{ 'rp-nav-item-active' : reviewPanel.subView === SubViews.OVERVIEW }"
|
||||
)
|
||||
i.fa.fa-list
|
||||
span.rp-nav-label Overview
|
||||
|
||||
|
||||
script(type='text/ng-template', id='changeEntryTemplate')
|
||||
div
|
||||
.rp-entry-callout(
|
||||
ng-class="'rp-entry-callout-' + entry.type"
|
||||
)
|
||||
.rp-entry-indicator(
|
||||
ng-switch="entry.type"
|
||||
ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
|
||||
ng-click="onIndicatorClick();"
|
||||
)
|
||||
i.fa.fa-pencil(ng-switch-when="insert")
|
||||
i.rp-icon-delete(ng-switch-when="delete")
|
||||
.rp-entry(
|
||||
ng-class="[ 'rp-entry-' + entry.type, (entry.focused ? 'rp-entry-focused' : '')]"
|
||||
)
|
||||
.rp-entry-header
|
||||
.rp-entry-action-icon(ng-switch="entry.type")
|
||||
i.fa.fa-pencil(ng-switch-when="insert")
|
||||
i.rp-icon-delete(ng-switch-when="delete")
|
||||
.rp-entry-metadata
|
||||
p.rp-entry-metadata-line(style="color: hsl({{ user.hue }}, 70%, 40%);") {{ user.name }}
|
||||
p.rp-entry-metadata-line {{ entry.metadata.ts | date : 'MMM d, y h:mm a' }}
|
||||
.rp-avatar(style="background-color: hsl({{ user.hue }}, 70%, 50%);") {{ user.avatar_text | limitTo : 1 }}
|
||||
.rp-entry-body(ng-switch="entry.type")
|
||||
span(ng-switch-when="insert") Added
|
||||
ins.rp-content-highlight {{ entry.content }}
|
||||
span(ng-switch-when="delete") Deleted
|
||||
del.rp-content-highlight {{ entry.content }}
|
||||
.rp-entry-actions
|
||||
a.rp-entry-button(href, ng-click="onReject();")
|
||||
i.fa.fa-times
|
||||
| Reject
|
||||
a.rp-entry-button(href, ng-click="onAccept();")
|
||||
i.fa.fa-check
|
||||
| Accept
|
||||
|
||||
script(type='text/ng-template', id='commentEntryTemplate')
|
||||
div
|
||||
.rp-entry-callout.rp-entry-callout-comment(ng-if="!entry.resolved")
|
||||
.rp-entry-indicator(
|
||||
ng-class="{ 'rp-entry-indicator-focused': entry.focused }"
|
||||
ng-click="onIndicatorClick();"
|
||||
)
|
||||
i.fa.fa-comment
|
||||
.rp-entry.rp-entry-comment(
|
||||
ng-class="{ 'rp-entry-focused': entry.focused, 'rp-entry-comment-resolved': entry.resolved}"
|
||||
)
|
||||
.rp-comment(
|
||||
ng-if="!entry.resolved || entry.showWhenResolved"
|
||||
ng-repeat="comment in entry.thread"
|
||||
ng-class="users[comment.user_id].isSelf ? 'rp-comment-self' : '';"
|
||||
)
|
||||
.rp-avatar(
|
||||
ng-if="!users[comment.user_id].isSelf;"
|
||||
style="background-color: hsl({{ users[comment.user_id].hue }}, 70%, 50%);"
|
||||
) {{ users[comment.user_id].avatar_text | limitTo : 1 }}
|
||||
.rp-comment-body(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 90%);")
|
||||
p.rp-comment-content {{ comment.content }}
|
||||
p.rp-comment-metadata
|
||||
| {{ comment.ts | date : 'MMM d, y h:mm a' }}
|
||||
| •
|
||||
span(style="color: hsl({{ users[comment.user_id].hue }}, 70%, 40%);") {{ users[comment.user_id].name }}
|
||||
.rp-comment-reply(ng-if="!entry.resolved || entry.showWhenResolved")
|
||||
textarea.rp-comment-input(
|
||||
ng-model="entry.replyContent"
|
||||
ng-keypress="handleCommentReplyKeyPress($event);"
|
||||
stop-propagation="click"
|
||||
placeholder="{{ 'Hit \"Enter\" to reply' + (entry.resolved ? ' and re-open' : '') }}"
|
||||
)
|
||||
.rp-comment-resolved-description(ng-if="entry.resolved && !entry.showWhenResolved")
|
||||
div
|
||||
| Comment resolved by
|
||||
span(style="color: hsl({{ users[entry.resolved_data.user_id].hue }}, 70%, 40%);") {{ users[entry.resolved_data.user_id].name }}
|
||||
div {{ entry.resolved_data.ts | date : 'MMM d, y h:mm a' }}
|
||||
.rp-entry-actions
|
||||
a.rp-entry-button(href, ng-click="onResolve();", ng-if="!entry.resolved")
|
||||
i.fa.fa-check
|
||||
| Mark as resolved
|
||||
a.rp-entry-button(href, ng-click="onShowThread();", ng-if="entry.resolved && !entry.showWhenResolved")
|
||||
| Show
|
||||
a.rp-entry-button(href, ng-click="onHideThread();", ng-if="entry.resolved && entry.showWhenResolved")
|
||||
| Hide
|
||||
a.rp-entry-button(href, ng-click="onUnresolve();", ng-if="entry.resolved")
|
||||
| Re-open
|
||||
a.rp-entry-button(href, ng-click="onDelete();", ng-if="entry.resolved")
|
||||
| Delete
|
||||
|
||||
|
||||
script(type='text/ng-template', id='addCommentEntryTemplate')
|
||||
div
|
||||
.rp-entry-callout.rp-entry-callout-add-comment
|
||||
.rp-entry-indicator(
|
||||
ng-if="!commentState.adding"
|
||||
ng-click="startNewComment(); onIndicatorClick();"
|
||||
tooltip="Add a comment"
|
||||
tooltip-placement="right"
|
||||
tooltip-append-to-body="true"
|
||||
)
|
||||
i.fa.fa-commenting
|
||||
.rp-entry.rp-entry-add-comment(
|
||||
ng-class="[ (state.isAdding ? 'rp-entry-adding-comment' : ''), (entry.focused ? 'rp-entry-focused' : '')]"
|
||||
)
|
||||
a.rp-add-comment-btn(
|
||||
href
|
||||
ng-if="!state.isAdding"
|
||||
ng-click="startNewComment();"
|
||||
)
|
||||
i.fa.fa-comment
|
||||
| Add comment
|
||||
div(ng-if="state.isAdding")
|
||||
.rp-new-comment
|
||||
textarea.rp-comment-input(
|
||||
ng-model="state.content"
|
||||
ng-keypress="handleCommentKeyPress($event);"
|
||||
placeholder="Add your comment here"
|
||||
)
|
||||
.rp-entry-actions
|
||||
a.rp-entry-button(href, ng-click="cancelNewComment();")
|
||||
i.fa.fa-times
|
||||
| Cancel
|
||||
a.rp-entry-button(href, ng-click="submitNewComment()")
|
||||
i.fa.fa-comment
|
||||
| Comment
|
|
@ -137,10 +137,15 @@ script(type='text/ng-template', id='shareProjectModalTemplate')
|
|||
p.small(ng-show="startedFreeTrial")
|
||||
| #{translate("refresh_page_after_starting_free_trial")}.
|
||||
|
||||
.modal-footer
|
||||
.modal-footer.modal-footer-share
|
||||
.modal-footer-left
|
||||
i.fa.fa-refresh.fa-spin(ng-show="state.inflight")
|
||||
span.text-danger.error(ng-show="state.error") #{translate("generic_something_went_wrong")}
|
||||
span.text-danger.error(ng-show="state.error")
|
||||
span(ng-switch="state.errorReason")
|
||||
span(ng-switch-when="cannot_invite_non_user")
|
||||
| #{translate("cannot_invite_non_user")}
|
||||
span(ng-switch-default)
|
||||
| #{translate("generic_something_went_wrong")}
|
||||
button.btn.btn-default(
|
||||
ng-click="done()"
|
||||
) #{translate("close")}
|
||||
|
|
|
@ -19,8 +19,46 @@ block content
|
|||
}
|
||||
};
|
||||
|
||||
.content.content-alt(ng-controller="ProjectPageController")
|
||||
.content.content-alt.project-list-page(ng-controller="ProjectPageController")
|
||||
.container
|
||||
.announcements(
|
||||
ng-controller="AnnouncementsController"
|
||||
ng-class="{ 'announcements-open': ui.isOpen }"
|
||||
ng-cloak
|
||||
)
|
||||
.announcements-backdrop(
|
||||
ng-if="ui.isOpen"
|
||||
ng-click="toggleAnnouncementsUI();"
|
||||
)
|
||||
a.announcements-btn(
|
||||
href
|
||||
ng-if="announcements.length"
|
||||
ng-click="toggleAnnouncementsUI();"
|
||||
ng-class="{ 'announcements-btn-open': ui.isOpen, 'announcements-btn-has-new': ui.newItems }"
|
||||
)
|
||||
span.announcements-badge(ng-if="ui.newItems") {{ ui.newItems }}
|
||||
.announcements-body(
|
||||
ng-if="ui.isOpen"
|
||||
)
|
||||
.announcements-scroller
|
||||
.announcement(
|
||||
ng-repeat="announcement in announcements | filter:(ui.newItems ? { read: false } : '') track by announcement.id"
|
||||
)
|
||||
h2.announcement-header {{ announcement.title }}
|
||||
p.announcement-description(ng-bind-html="announcement.excerpt")
|
||||
.announcement-meta
|
||||
p.announcement-date {{ announcement.date | date:"longDate" }}
|
||||
a.announcement-link(
|
||||
ng-href="{{ announcement.url }}"
|
||||
target="_blank"
|
||||
) Read more
|
||||
div.text-center(
|
||||
ng-if="ui.newItems > 0 && ui.newItems < announcements.length"
|
||||
)
|
||||
a.btn.btn-default.btn-sm(
|
||||
href
|
||||
ng-click="showAll();"
|
||||
) Show all
|
||||
|
||||
.row(ng-cloak)
|
||||
span(ng-if="projects.length > 0")
|
||||
|
|
|
@ -109,8 +109,7 @@
|
|||
) #{translate("complete")}
|
||||
|
||||
|
||||
.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak, sixpack-switch="left-menu-upgraed-rotation").text-centered
|
||||
span(sixpack-default).text-centered
|
||||
.row-spaced(ng-if="hasProjects && userHasNoSubscription", ng-cloak).text-centered
|
||||
hr
|
||||
p.small #{translate("on_free_sl")}
|
||||
p
|
||||
|
@ -119,41 +118,7 @@
|
|||
| #{translate("or_unlock_features_bonus")}
|
||||
a(href="/user/bonus") #{translate("sharing_sl")} .
|
||||
|
||||
span(sixpack-when="random").text-centered
|
||||
span(ng-if="randomView == 'default'")
|
||||
hr
|
||||
p.small #{translate("on_free_sl")}
|
||||
p
|
||||
a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
|
||||
p.small.text-centered
|
||||
| #{translate("or_unlock_features_bonus")}
|
||||
a(href="/user/bonus") #{translate("sharing_sl")} .
|
||||
|
||||
span(ng-if="randomView == 'dropbox'")
|
||||
hr
|
||||
.card.card-thin
|
||||
p
|
||||
span Get Dropbox Sync
|
||||
p
|
||||
img(src=buildImgPath("dropbox/simple_logo.png"))
|
||||
p
|
||||
a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
|
||||
p.small.text-centered
|
||||
| #{translate("or_unlock_features_bonus")}
|
||||
a(href="/user/bonus") #{translate("sharing_sl")} .
|
||||
|
||||
span(ng-if="randomView == 'github'")
|
||||
hr
|
||||
.card.card-thin
|
||||
p
|
||||
span Get Github Sync
|
||||
p
|
||||
img(src=buildImgPath("github/octocat.jpg"))
|
||||
p
|
||||
a(href="/user/subscription/plans", sixpack-convert="left-menu-upgraed-rotation").btn.btn-primary #{translate("upgrade")}
|
||||
p.small.text-centered
|
||||
| #{translate("or_unlock_features_bonus")}
|
||||
a(href="/user/bonus") #{translate("sharing_sl")} .
|
||||
script.
|
||||
window.userHasNoSubscription = #{!!(settings.enableSubscriptions && !hasSubscription)}
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@
|
|||
// whitelistUrls: ['example.com/scripts/']
|
||||
}).install();
|
||||
}
|
||||
- if (typeof(user) != "undefined" && typeof (user.email) != "undefined")
|
||||
- if (user && typeof(user) != "undefined" && typeof (user.email) != "undefined")
|
||||
script(type="text/javascript").
|
||||
if (typeof(Raven) != "undefined" && Raven.setUserContext) {
|
||||
Raven.setUserContext({email: '#{user.email}'});
|
||||
|
|
|
@ -12,8 +12,8 @@ block scripts
|
|||
|
||||
mixin printPlan(plan)
|
||||
-if (!plan.hideFromUsers)
|
||||
tr(ng-controller="ChangePlanFormController")
|
||||
td(ng-init="plan=#{JSON.stringify(plan)}")
|
||||
tr(ng-controller="ChangePlanFormController", ng-init="plan=#{JSON.stringify(plan)}", ng-show="shouldShowPlan(plan.planCode)")
|
||||
td
|
||||
strong #{plan.name}
|
||||
td {{refreshPrice(plan.planCode)}}
|
||||
-if (plan.annual)
|
||||
|
@ -46,7 +46,7 @@ block content
|
|||
|
|
||||
| #{translate("your_billing_details_were_saved")}
|
||||
.card(ng-if="view == 'overview'")
|
||||
.page-header
|
||||
.page-header(x-current-plan="#{subscription.planCode}")
|
||||
h1 #{translate("your_subscription")}
|
||||
|
||||
- if (subscription && user._id+'' == subscription.admin_id+'')
|
||||
|
@ -56,6 +56,7 @@ block content
|
|||
|
||||
when "active"
|
||||
p !{translate("currently_subscribed_to_plan", {planName:"<strong>" + subscription.name + "</strong>"})}
|
||||
span(ng-show="!isNextGenPlan")
|
||||
a(href, ng-click="changePlan = true") !{translate("change_plan")}.
|
||||
p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>" + subscription.price + "</strong>", collectionDate:"<strong>" + subscription.nextPaymentDueAt + "</strong>"})}
|
||||
p.pull-right
|
||||
|
@ -112,14 +113,10 @@ block content
|
|||
div
|
||||
a(href="/subscription/group").btn.btn-primary !{translate("manage_group")}
|
||||
|
||||
|
||||
|
||||
.card(ng-if="view == 'cancelation'")
|
||||
.page-header
|
||||
h1 #{translate("Cancel Subscription")}
|
||||
|
||||
span(ng-if="sixpackOpt == 'downgrade-options'")
|
||||
|
||||
div(ng-show="showExtendFreeTrial", style="text-align: center")
|
||||
p !{translate("have_more_days_to_try", {days:14})}
|
||||
button(type="submit", ng-click="exendTrial()", ng-disabled='inflight').btn.btn-success #{translate("ill_take_it")}
|
||||
|
@ -128,7 +125,6 @@ block content
|
|||
p
|
||||
a(href, ng-click="cancelSubscription()", ng-disabled='inflight') #{translate("no_thanks_cancel_now")}
|
||||
|
||||
|
||||
div(ng-show="showDowngradeToStudent", style="text-align: center")
|
||||
span(ng-controller="ChangePlanFormController")
|
||||
p !{translate("interested_in_cheaper_plan",{price:'{{studentPrice}}'})}
|
||||
|
@ -144,12 +140,6 @@ block content
|
|||
|
|
||||
a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
|
||||
|
||||
span(ng-if="sixpackOpt == 'basic'")
|
||||
p #{translate("sure_you_want_to_cancel")}
|
||||
a(href="/project").btn.btn-info #{translate("i_want_to_stay")}
|
||||
|
|
||||
a(ng-click="cancelSubscription()", ng-disabled='inflight').btn.btn-primary #{translate("cancel_my_account")}
|
||||
|
||||
script(type="text/javascript").
|
||||
$('#cancelSubscription').on("click", function() {
|
||||
ga('send', 'event', 'subscription-funnel', 'cancelation')
|
||||
|
|
|
@ -3,6 +3,7 @@ block scripts
|
|||
script(type='text/javascript').
|
||||
window.recomendedCurrency = '#{recomendedCurrency}'
|
||||
window.abCurrencyFlag = '#{abCurrencyFlag}'
|
||||
window.shouldABTestPlans = #{shouldABTestPlans || false}
|
||||
|
||||
script(type='text/javascript').
|
||||
(function() {var s=document.createElement('script'); s.type='text/javascript';s.async=true;
|
||||
|
@ -56,6 +57,7 @@ block content
|
|||
ng-click="changeCurreny(currency)"
|
||||
) {{currency}} ({{value['symbol']}})
|
||||
|
||||
div(ng-show="showPlans")
|
||||
.row(ng-cloak)
|
||||
.col-md-10.col-md-offset-1
|
||||
.row
|
||||
|
@ -89,15 +91,16 @@ block content
|
|||
span.small /yr
|
||||
ul.list-unstyled
|
||||
li
|
||||
strong #{translate("collabs_per_proj", {collabcount:10})}
|
||||
strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:10})}
|
||||
strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:8})}
|
||||
strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:12})}
|
||||
li #{translate("full_doc_history")}
|
||||
li #{translate("sync_to_dropbox")}
|
||||
li #{translate("sync_to_github")}
|
||||
li
|
||||
br
|
||||
a.btn.btn-info(
|
||||
|
||||
ng-href="#{baseUrl}/user/subscription/new?planCode=collaborator{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
|
||||
ng-href="/user/subscription/new?planCode={{ getCollaboratorPlanCode() }}¤cy={{currencyCode}}", ng-click="signUpNowClicked('collaborator')"
|
||||
)
|
||||
span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
|
||||
span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
|
||||
|
@ -121,7 +124,7 @@ block content
|
|||
li
|
||||
br
|
||||
a.btn.btn-info(
|
||||
ng-href="#{baseUrl}/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}", ng-click="signUpNowClicked('professional')"
|
||||
ng-href="/user/subscription/new?planCode=professional{{ ui.view == 'annual' && '-annual' || planQueryString}}¤cy={{currencyCode}}", ng-click="signUpNowClicked('professional')"
|
||||
)
|
||||
span(ng-show="ui.view != 'annual'") #{translate("start_free_trial")}
|
||||
span(ng-show="ui.view == 'annual'") #{translate("buy_now")}
|
||||
|
@ -154,14 +157,17 @@ block content
|
|||
span.small /mo
|
||||
ul.list-unstyled
|
||||
li
|
||||
strong #{translate("collabs_per_proj", {collabcount:6})}
|
||||
strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:6})}
|
||||
strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:4})}
|
||||
strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:8})}
|
||||
li #{translate("full_doc_history")}
|
||||
li #{translate("sync_to_dropbox")}
|
||||
li #{translate("sync_to_github")}
|
||||
li
|
||||
br
|
||||
a.btn.btn-info(
|
||||
ng-href="#{baseUrl}/user/subscription/new?planCode=student{{planQueryString}}¤cy={{currencyCode}}", ng-click="signUpNowClicked('student')"
|
||||
ng-href="/user/subscription/new?planCode=student{{ plansVariant == 'default' ? planQueryString : '_'+plansVariant }}¤cy={{currencyCode}}",
|
||||
ng-click="signUpNowClicked('student')"
|
||||
) #{translate("start_free_trial")}
|
||||
|
||||
.col-md-4
|
||||
|
@ -174,16 +180,21 @@ block content
|
|||
span.small /yr
|
||||
ul.list-unstyled
|
||||
li
|
||||
strong #{translate("collabs_per_proj", {collabcount:6})}
|
||||
strong(ng-show="plansVariant == 'default'") #{translate("collabs_per_proj", {collabcount:6})}
|
||||
strong(ng-show="plansVariant == 'heron'") #{translate("collabs_per_proj", {collabcount:4})}
|
||||
strong(ng-show="plansVariant == 'ibis'") #{translate("collabs_per_proj", {collabcount:8})}
|
||||
li #{translate("full_doc_history")}
|
||||
li #{translate("sync_to_dropbox")}
|
||||
li #{translate("sync_to_github")}
|
||||
li
|
||||
br
|
||||
a.btn.btn-info(
|
||||
ng-href="#{baseUrl}/user/subscription/new?planCode=student-annual¤cy={{currencyCode}}", ng-click="signUpNowClicked('student')"
|
||||
ng-href="/user/subscription/new?planCode=student-annual{{ plansVariant == 'default' ? '' : '_'+plansVariant }}¤cy={{currencyCode}}",
|
||||
ng-click="signUpNowClicked('student')"
|
||||
) #{translate("buy_now")}
|
||||
|
||||
|
||||
|
||||
.row.row-spaced(ng-cloak)
|
||||
p.text-centered #{translate("choose_plan_works_for_you", {len:'{{trial_len}}'})}
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ extends ../layout
|
|||
|
||||
block content
|
||||
.content.content-alt
|
||||
.container
|
||||
.container(ng-controller="SuccessfulSubscriptionController")
|
||||
.row
|
||||
.col-md-8.col-md-offset-2
|
||||
.card(ng-cloak)
|
||||
|
@ -10,11 +10,6 @@ block content
|
|||
h2 #{translate("thanks_for_subscribing")}
|
||||
.alert.alert-success
|
||||
p !{translate("next_payment_of_x_collectected_on_y", {paymentAmmount:"<strong>"+subscription.price+"</strong>", collectionDate:"<strong>"+subscription.nextPaymentDueAt+"</strong>"})}
|
||||
span(sixpack-switch="upgrade-success-message")
|
||||
span(sixpack-default)
|
||||
p #{translate("if_you_dont_want_to_be_charged")}
|
||||
a(href="/user/subscription") #{translate("click_here_to_cancel")}.
|
||||
span(sixpack-when="manage-subscription")
|
||||
p #{translate("to_modify_your_subscription_go_to")}
|
||||
a(href="/user/subscription") #{translate("manage_subscription")}.
|
||||
p
|
||||
|
|
|
@ -10,7 +10,6 @@ block content
|
|||
h1 #{translate("log_in")}
|
||||
form(async-form="login", name="loginForm", action='/login', method="POST", ng-cloak)
|
||||
input(name='_csrf', type='hidden', value=csrfToken)
|
||||
input(name='redir', type='hidden', value=redir)
|
||||
form-messages(for="loginForm")
|
||||
.form-group
|
||||
input.form-control(
|
||||
|
|
|
@ -12,7 +12,7 @@ block content
|
|||
| #{translate("join_sl_to_view_project")}.
|
||||
div
|
||||
| #{translate("if_you_are_registered")},
|
||||
a(href="/login?redir=#{getReqQueryParam('redir')}") #{translate("login_here")}
|
||||
a(href="/login") #{translate("login_here")}
|
||||
else if newTemplateData.templateName !== undefined
|
||||
h1 #{translate("register_to_edit_template", {templateName:newTemplateData.templateName})}
|
||||
|
||||
|
|
|
@ -33,6 +33,13 @@ block content
|
|||
)
|
||||
span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty")
|
||||
| #{translate("must_be_email_address")}
|
||||
else
|
||||
// show the email, non-editable
|
||||
.form-group
|
||||
label.control-label #{translate("email")}
|
||||
div.form-control(readonly="true") #{user.email}
|
||||
|
||||
if shouldAllowEditingDetails
|
||||
.form-group
|
||||
label(for='firstName').control-label #{translate("first_name")}
|
||||
input.form-control(
|
||||
|
@ -52,6 +59,14 @@ block content
|
|||
type='submit',
|
||||
ng-disabled="settingsForm.$invalid"
|
||||
) #{translate("update")}
|
||||
else
|
||||
.form-group
|
||||
label.control-label #{translate("first_name")}
|
||||
div.form-control(readonly="true") #{user.first_name}
|
||||
.form-group
|
||||
label.control-label #{translate("last_name")}
|
||||
div.form-control(readonly="true") #{user.last_name}
|
||||
|
||||
if !externalAuthenticationSystemUsed()
|
||||
.col-md-5.col-md-offset-1
|
||||
h3 #{translate("change_password")}
|
||||
|
@ -150,16 +165,32 @@ block content
|
|||
script(type='text/ng-template', id='deleteAccountModalTemplate')
|
||||
.modal-header
|
||||
h3 #{translate("delete_account")}
|
||||
.modal-body
|
||||
p !{translate("delete_account_warning_message_2")}
|
||||
div.modal-body#delete-account-modal
|
||||
p !{translate("delete_account_warning_message_3")}
|
||||
form(novalidate, name="deleteAccountForm")
|
||||
label #{translate('email')}
|
||||
input.form-control(
|
||||
type="text",
|
||||
autocomplete="off",
|
||||
placeholder="",
|
||||
ng-model="state.deleteText",
|
||||
focus-on="open",
|
||||
ng-keyup="checkValidation()"
|
||||
)
|
||||
label #{translate('password')}
|
||||
input.form-control(
|
||||
type="password",
|
||||
autocomplete="off",
|
||||
placeholder="",
|
||||
ng-model="state.password",
|
||||
ng-keyup="checkValidation()"
|
||||
)
|
||||
div(ng-if="state.error")
|
||||
div.alert.alert-danger
|
||||
| #{translate('generic_something_went_wrong')}
|
||||
div(ng-if="state.invalidCredentials")
|
||||
div.alert.alert-danger
|
||||
| #{translate('email_or_password_wrong_try_again')}
|
||||
.modal-footer
|
||||
button.btn.btn-default(
|
||||
ng-click="cancel()"
|
||||
|
|
|
@ -38,6 +38,16 @@ module.exports = settings =
|
|||
port: "6379"
|
||||
password: ""
|
||||
|
||||
# websessions:
|
||||
# cluster: [
|
||||
# {host: 'localhost', port: 7000}
|
||||
# {host: 'localhost', port: 7001}
|
||||
# {host: 'localhost', port: 7002}
|
||||
# {host: 'localhost', port: 7003}
|
||||
# {host: 'localhost', port: 7004}
|
||||
# {host: 'localhost', port: 7005}
|
||||
# ]
|
||||
|
||||
api:
|
||||
host: "localhost"
|
||||
port: "6379"
|
||||
|
@ -266,6 +276,10 @@ module.exports = settings =
|
|||
# Cookie max age (in milliseconds). Set to false for a browser session.
|
||||
cookieSessionLength: 5 * 24 * 60 * 60 * 1000 # 5 days
|
||||
|
||||
# When true, only allow invites to be sent to email addresses that
|
||||
# already have user accounts
|
||||
restrictInvitesToExistingAccounts: false
|
||||
|
||||
# Should we allow access to any page without logging in? This includes
|
||||
# public projects, /learn, /templates, about pages, etc.
|
||||
allowPublicAccess: if process.env["SHARELATEX_ALLOW_PUBLIC_ACCESS"] == 'true' then true else false
|
||||
|
@ -337,6 +351,10 @@ module.exports = settings =
|
|||
text: "Account"
|
||||
only_when_logged_in: true
|
||||
dropdown: [{
|
||||
user_email: true
|
||||
},{
|
||||
divider: true
|
||||
}, {
|
||||
text: "Account Settings"
|
||||
url: "/user/settings"
|
||||
}, {
|
||||
|
@ -420,5 +438,3 @@ module.exports = settings =
|
|||
# name : "all projects",
|
||||
# url: "/templates/all"
|
||||
#}]
|
||||
|
||||
|
||||
|
|
276
services/web/npm-shrinkwrap.json
generated
276
services/web/npm-shrinkwrap.json
generated
|
@ -387,26 +387,42 @@
|
|||
"resolved": "https://registry.npmjs.org/bufferedstream/-/bufferedstream-1.6.0.tgz"
|
||||
},
|
||||
"connect-redis": {
|
||||
"version": "2.3.0",
|
||||
"from": "https://registry.npmjs.org/connect-redis/-/connect-redis-2.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-2.3.0.tgz",
|
||||
"version": "3.1.0",
|
||||
"from": "connect-redis@3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/connect-redis/-/connect-redis-3.1.0.tgz",
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "1.0.4",
|
||||
"from": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-1.0.4.tgz",
|
||||
"version": "2.3.0",
|
||||
"from": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
|
||||
"dependencies": {
|
||||
"ms": {
|
||||
"version": "0.6.2",
|
||||
"from": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-0.6.2.tgz"
|
||||
"version": "0.7.2",
|
||||
"from": "ms@0.7.2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"redis": {
|
||||
"version": "0.12.1",
|
||||
"from": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-0.12.1.tgz"
|
||||
"version": "2.6.3",
|
||||
"from": "https://registry.npmjs.org/redis/-/redis-2.6.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-2.6.3.tgz",
|
||||
"dependencies": {
|
||||
"double-ended-queue": {
|
||||
"version": "2.1.0-0",
|
||||
"from": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz"
|
||||
},
|
||||
"redis-commands": {
|
||||
"version": "1.3.0",
|
||||
"from": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz"
|
||||
},
|
||||
"redis-parser": {
|
||||
"version": "2.1.1",
|
||||
"from": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.1.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-2.1.1.tgz"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -504,7 +520,7 @@
|
|||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.3.1",
|
||||
"from": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz",
|
||||
"from": "cookie@0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz"
|
||||
},
|
||||
"cookie-signature": {
|
||||
|
@ -864,68 +880,66 @@
|
|||
}
|
||||
},
|
||||
"express-session": {
|
||||
"version": "1.11.3",
|
||||
"from": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.11.3.tgz",
|
||||
"version": "1.14.2",
|
||||
"from": "express-session@1.14.2",
|
||||
"resolved": "https://registry.npmjs.org/express-session/-/express-session-1.14.2.tgz",
|
||||
"dependencies": {
|
||||
"cookie": {
|
||||
"version": "0.1.3",
|
||||
"from": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.1.3.tgz"
|
||||
"version": "0.3.1",
|
||||
"from": "cookie@0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz"
|
||||
},
|
||||
"cookie-signature": {
|
||||
"version": "1.0.6",
|
||||
"from": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz"
|
||||
"from": "cookie-signature@1.0.6"
|
||||
},
|
||||
"crc": {
|
||||
"version": "3.3.0",
|
||||
"from": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/crc/-/crc-3.3.0.tgz"
|
||||
"version": "3.4.1",
|
||||
"from": "crc@3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/crc/-/crc-3.4.1.tgz"
|
||||
},
|
||||
"debug": {
|
||||
"version": "2.2.0",
|
||||
"from": "debug@~2.2.0",
|
||||
"from": "debug@2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
|
||||
"dependencies": {
|
||||
"ms": {
|
||||
"version": "0.7.1",
|
||||
"from": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz",
|
||||
"from": "ms@0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-0.7.1.tgz"
|
||||
}
|
||||
}
|
||||
},
|
||||
"depd": {
|
||||
"version": "1.0.1",
|
||||
"from": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.0.1.tgz"
|
||||
"version": "1.1.0",
|
||||
"from": "depd@1.1.0"
|
||||
},
|
||||
"on-headers": {
|
||||
"version": "1.0.1",
|
||||
"from": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.1.tgz"
|
||||
"from": "on-headers@1.0.1"
|
||||
},
|
||||
"parseurl": {
|
||||
"version": "1.3.1",
|
||||
"from": "parseurl@~1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.1.tgz"
|
||||
"from": "parseurl@1.3.1"
|
||||
},
|
||||
"uid-safe": {
|
||||
"version": "2.0.0",
|
||||
"from": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.0.0.tgz",
|
||||
"version": "2.1.3",
|
||||
"from": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.3.tgz",
|
||||
"dependencies": {
|
||||
"base64-url": {
|
||||
"version": "1.2.1",
|
||||
"from": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/base64-url/-/base64-url-1.2.1.tgz"
|
||||
"version": "1.3.3",
|
||||
"from": "base64-url@1.3.3"
|
||||
},
|
||||
"random-bytes": {
|
||||
"version": "1.0.0",
|
||||
"from": "random-bytes@1.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"utils-merge": {
|
||||
"version": "1.0.0",
|
||||
"from": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.0.tgz"
|
||||
"from": "utils-merge@1.0.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -1166,6 +1180,53 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"ioredis": {
|
||||
"version": "2.4.0",
|
||||
"from": "https://registry.npmjs.org/ioredis/-/ioredis-2.4.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-2.4.0.tgz",
|
||||
"dependencies": {
|
||||
"bluebird": {
|
||||
"version": "3.4.6",
|
||||
"from": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.6.tgz",
|
||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.6.tgz"
|
||||
},
|
||||
"cluster-key-slot": {
|
||||
"version": "1.0.8",
|
||||
"from": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.0.8.tgz",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.0.8.tgz"
|
||||
},
|
||||
"debug": {
|
||||
"version": "2.3.0",
|
||||
"from": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.3.0.tgz",
|
||||
"dependencies": {
|
||||
"ms": {
|
||||
"version": "0.7.2",
|
||||
"from": "ms@0.7.2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"double-ended-queue": {
|
||||
"version": "2.1.0-0",
|
||||
"from": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz"
|
||||
},
|
||||
"flexbuffer": {
|
||||
"version": "0.0.6",
|
||||
"from": "flexbuffer@0.0.6"
|
||||
},
|
||||
"redis-commands": {
|
||||
"version": "1.3.0",
|
||||
"from": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.3.0.tgz"
|
||||
},
|
||||
"redis-parser": {
|
||||
"version": "1.3.0",
|
||||
"from": "https://registry.npmjs.org/redis-parser/-/redis-parser-1.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-1.3.0.tgz"
|
||||
}
|
||||
}
|
||||
},
|
||||
"jade": {
|
||||
"version": "1.3.1",
|
||||
"from": "https://registry.npmjs.org/jade/-/jade-1.3.1.tgz",
|
||||
|
@ -1783,7 +1844,7 @@
|
|||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "2.2.0",
|
||||
"from": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
|
||||
"from": "debug@2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.2.0.tgz",
|
||||
"dependencies": {
|
||||
"ms": {
|
||||
|
@ -2518,33 +2579,152 @@
|
|||
},
|
||||
"passport": {
|
||||
"version": "0.3.2",
|
||||
"from": "passport@*",
|
||||
"from": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.3.2.tgz",
|
||||
"dependencies": {
|
||||
"passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"from": "passport-strategy@>=1.0.0 <2.0.0",
|
||||
"from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
|
||||
},
|
||||
"pause": {
|
||||
"version": "0.0.1",
|
||||
"from": "pause@0.0.1",
|
||||
"from": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passport-ldapauth": {
|
||||
"version": "0.6.0",
|
||||
"from": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-0.6.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/passport-ldapauth/-/passport-ldapauth-0.6.0.tgz",
|
||||
"dependencies": {
|
||||
"passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
|
||||
},
|
||||
"ldapauth-fork": {
|
||||
"version": "2.5.3",
|
||||
"from": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-2.5.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/ldapauth-fork/-/ldapauth-fork-2.5.3.tgz",
|
||||
"dependencies": {
|
||||
"bcryptjs": {
|
||||
"version": "2.3.0",
|
||||
"from": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.3.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.3.0.tgz"
|
||||
},
|
||||
"lru-cache": {
|
||||
"version": "3.2.0",
|
||||
"from": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-3.2.0.tgz",
|
||||
"dependencies": {
|
||||
"pseudomap": {
|
||||
"version": "1.0.2",
|
||||
"from": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"passport-local": {
|
||||
"version": "1.0.0",
|
||||
"from": "passport-local@*",
|
||||
"from": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
|
||||
"dependencies": {
|
||||
"passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"from": "passport-strategy@>=1.0.0 <2.0.0",
|
||||
"from": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
|
||||
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz"
|
||||
}
|
||||
}
|
||||
},
|
||||
"passport-saml": {
|
||||
"version": "0.15.0",
|
||||
"from": "passport-saml@",
|
||||
"resolved": "https://registry.npmjs.org/passport-saml/-/passport-saml-0.15.0.tgz",
|
||||
"dependencies": {
|
||||
"passport-strategy": {
|
||||
"version": "1.0.0",
|
||||
"from": "passport-strategy@*"
|
||||
},
|
||||
"q": {
|
||||
"version": "1.1.2",
|
||||
"from": "q@1.1.x"
|
||||
},
|
||||
"xml2js": {
|
||||
"version": "0.4.17",
|
||||
"from": "xml2js@0.4.x",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.17.tgz",
|
||||
"dependencies": {
|
||||
"sax": {
|
||||
"version": "1.2.1",
|
||||
"from": "sax@>=0.6.0"
|
||||
},
|
||||
"xmlbuilder": {
|
||||
"version": "4.2.1",
|
||||
"from": "xmlbuilder@^4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-4.2.1.tgz"
|
||||
}
|
||||
}
|
||||
},
|
||||
"xml-crypto": {
|
||||
"version": "0.8.4",
|
||||
"from": "xml-crypto@0.8.x",
|
||||
"dependencies": {
|
||||
"xmldom": {
|
||||
"version": "0.1.19",
|
||||
"from": "xmldom@=0.1.19"
|
||||
},
|
||||
"xpath.js": {
|
||||
"version": "1.0.6",
|
||||
"from": "xpath.js@>=0.0.3"
|
||||
}
|
||||
}
|
||||
},
|
||||
"xmldom": {
|
||||
"version": "0.1.22",
|
||||
"from": "xmldom@0.1.x"
|
||||
},
|
||||
"xmlbuilder": {
|
||||
"version": "2.5.2",
|
||||
"from": "xmlbuilder@2.5.x",
|
||||
"dependencies": {
|
||||
"lodash": {
|
||||
"version": "3.2.0",
|
||||
"from": "lodash@~3.2.0"
|
||||
}
|
||||
}
|
||||
},
|
||||
"xml-encryption": {
|
||||
"version": "0.7.4",
|
||||
"from": "xml-encryption@~0.7",
|
||||
"dependencies": {
|
||||
"ejs": {
|
||||
"version": "0.8.8",
|
||||
"from": "ejs@~0.8.3"
|
||||
},
|
||||
"async": {
|
||||
"version": "0.2.10",
|
||||
"from": "async@~0.2.7"
|
||||
},
|
||||
"xpath": {
|
||||
"version": "0.0.5",
|
||||
"from": "xpath@0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.5.tgz"
|
||||
},
|
||||
"node-forge": {
|
||||
"version": "0.2.24",
|
||||
"from": "node-forge@0.2.24",
|
||||
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.2.24.tgz"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"pg": {
|
||||
"version": "6.0.3",
|
||||
"from": "https://registry.npmjs.org/pg/-/pg-6.0.3.tgz",
|
||||
|
@ -3431,7 +3611,7 @@
|
|||
},
|
||||
"tough-cookie": {
|
||||
"version": "2.3.1",
|
||||
"from": "tough-cookie@>=0.12.0",
|
||||
"from": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.1.tgz",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.1.tgz"
|
||||
},
|
||||
"tunnel-agent": {
|
||||
|
|
|
@ -17,17 +17,18 @@
|
|||
"bcrypt": "0.8.5",
|
||||
"body-parser": "^1.13.1",
|
||||
"bufferedstream": "1.6.0",
|
||||
"connect-redis": "2.3.0",
|
||||
"connect-redis": "^3.1.0",
|
||||
"contentful": "^3.3.14",
|
||||
"cookie": "^0.2.3",
|
||||
"cookie-parser": "1.3.5",
|
||||
"csurf": "^1.8.3",
|
||||
"dateformat": "1.0.4-1.2.3",
|
||||
"express": "4.13.0",
|
||||
"express-session": "1.11.3",
|
||||
"express-session": "^1.14.2",
|
||||
"grunt": "^0.4.5",
|
||||
"heapdump": "^0.3.7",
|
||||
"http-proxy": "^1.8.1",
|
||||
"ioredis": "^2.4.0",
|
||||
"jade": "~1.3.1",
|
||||
"ldapjs": "^0.7.1",
|
||||
"logger-sharelatex": "git+https://github.com/sharelatex/logger-sharelatex.git#master",
|
||||
|
@ -35,7 +36,7 @@
|
|||
"lynx": "0.1.1",
|
||||
"marked": "^0.3.5",
|
||||
"method-override": "^2.3.3",
|
||||
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.3.0",
|
||||
"metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.6.0",
|
||||
"mimelib": "0.2.14",
|
||||
"mocha": "1.17.1",
|
||||
"mongojs": "0.18.2",
|
||||
|
@ -47,9 +48,8 @@
|
|||
"nodemailer-ses-transport": "^1.3.0",
|
||||
"optimist": "0.6.1",
|
||||
"passport": "^0.3.2",
|
||||
"passport-ldapauth": "^0.6.0",
|
||||
"passport-local": "^1.0.0",
|
||||
"pg": "^6.0.3",
|
||||
"pg-hstore": "^2.3.2",
|
||||
"redback": "0.4.0",
|
||||
"redis": "0.10.1",
|
||||
"redis-sharelatex": "0.0.9",
|
||||
|
@ -63,7 +63,8 @@
|
|||
"temp": "^0.8.3",
|
||||
"underscore": "1.6.0",
|
||||
"v8-profiler": "^5.2.3",
|
||||
"xml2js": "0.2.0"
|
||||
"xml2js": "0.2.0",
|
||||
"passport-saml": "^0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bunyan": "0.22.1",
|
||||
|
|
|
@ -24,8 +24,10 @@ define [
|
|||
|
||||
scope[attrs.name].inflight = true
|
||||
|
||||
# for asyncForm prevent automatic redirect to /login if
|
||||
# authentication fails, we will handle it ourselves
|
||||
$http
|
||||
.post(element.attr('action'), formData)
|
||||
.post(element.attr('action'), formData, {disableAutoLoginRedirect: true})
|
||||
.success (data, status, headers, config) ->
|
||||
scope[attrs.name].inflight = false
|
||||
response.success = true
|
||||
|
|
|
@ -9,7 +9,9 @@ define [
|
|||
"ide/pdf/PdfManager"
|
||||
"ide/binary-files/BinaryFilesManager"
|
||||
"ide/references/ReferencesManager"
|
||||
"ide/review-panel/ReviewPanelManager"
|
||||
"ide/SafariScrollPatcher"
|
||||
"ide/FeatureOnboardingController"
|
||||
"ide/settings/index"
|
||||
"ide/share/index"
|
||||
"ide/chat/index"
|
||||
|
@ -41,6 +43,7 @@ define [
|
|||
PdfManager
|
||||
BinaryFilesManager
|
||||
ReferencesManager
|
||||
ReviewPanelManager
|
||||
SafariScrollPatcher
|
||||
) ->
|
||||
|
||||
|
@ -54,6 +57,9 @@ define [
|
|||
else
|
||||
this.$originalApply(fn);
|
||||
|
||||
if window.location.search.match /tcon=true/ # track changes on
|
||||
$scope.trackChangesFeatureFlag = true
|
||||
|
||||
$scope.state = {
|
||||
loading: true
|
||||
load_progress: 40
|
||||
|
@ -64,30 +70,26 @@ define [
|
|||
view: "editor"
|
||||
chatOpen: false
|
||||
pdfLayout: 'sideBySide'
|
||||
reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}") and $scope.trackChangesFeatureFlag
|
||||
showCodeCheckerOnboarding: !window.userSettings.syntaxValidation?
|
||||
}
|
||||
$scope.user = window.user
|
||||
|
||||
$scope.shouldABTestPlans = false
|
||||
if $scope.user.signUpDate >= '2016-10-27'
|
||||
$scope.shouldABTestPlans = true
|
||||
|
||||
$scope.settings = window.userSettings
|
||||
$scope.anonymous = window.anonymous
|
||||
|
||||
$scope.chat = {}
|
||||
|
||||
ide.toggleReviewPanel = $scope.toggleReviewPanel = () ->
|
||||
$scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen
|
||||
|
||||
# Only run the header AB test for newly registered users.
|
||||
_abTestStartDate = new Date(Date.UTC(2016, 8, 28))
|
||||
_userSignUpDate = new Date(window.user.signUpDate)
|
||||
|
||||
$scope.shouldABTestHeaderLabels = _userSignUpDate > _abTestStartDate
|
||||
$scope.headerLabelsABVariant = ""
|
||||
|
||||
if ($scope.shouldABTestHeaderLabels)
|
||||
sixpack.participate "editor-header", [ "default", "labels"], (chosenVariation) ->
|
||||
$scope.headerLabelsABVariant = chosenVariation
|
||||
|
||||
$scope.trackABTestConversion = (headerItem) ->
|
||||
event_tracking.sendMB "header-ab-conversion", {
|
||||
headerItem: headerItem,
|
||||
variant: $scope.headerLabelsABVariant
|
||||
}
|
||||
$scope.$watch "ui.reviewPanelOpen", (value) ->
|
||||
if value?
|
||||
localStorage "ui.reviewPanelOpen.#{window.project_id}", value
|
||||
|
||||
# Tracking code.
|
||||
$scope.$watch "ui.view", (newView, oldView) ->
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.controller "FeatureOnboardingController", ($scope, settings) ->
|
||||
$scope.innerStep = 1
|
||||
|
||||
$scope.turnCodeCheckOn = () ->
|
||||
settings.saveSettings({ syntaxValidation: true })
|
||||
$scope.settings.syntaxValidation = true
|
||||
navToInnerStep2()
|
||||
|
||||
$scope.turnCodeCheckOff = () ->
|
||||
settings.saveSettings({ syntaxValidation: false })
|
||||
$scope.settings.syntaxValidation = false
|
||||
navToInnerStep2()
|
||||
|
||||
$scope.dismiss = () ->
|
||||
$scope.ui.leftMenuShown = false
|
||||
$scope.ui.showCodeCheckerOnboarding = false
|
||||
|
||||
navToInnerStep2 = () ->
|
||||
$scope.innerStep = 2
|
||||
$scope.ui.leftMenuShown = true
|
||||
|
||||
handleKeypress = (e) ->
|
||||
if e.keyCode == 13
|
||||
if $scope.innerStep == 1
|
||||
$scope.turnCodeCheckOn()
|
||||
else
|
||||
$scope.dismiss()
|
||||
|
||||
$(document).on "keypress", handleKeypress
|
||||
|
||||
$scope.$on "$destroy", () ->
|
||||
$(document).off "keypress", handleKeypress
|
|
@ -1,7 +1,11 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
"ide/colors/ColorManager"
|
||||
], (App, ColorManager) ->
|
||||
App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
|
||||
$scope.hue = (user) ->
|
||||
ide.onlineUsersManager.getHueForUserId(user.id)
|
||||
if !user?
|
||||
return 0
|
||||
else
|
||||
return ColorManager.getHueForUserId(user.id)
|
||||
]
|
44
services/web/public/coffee/ide/colors/ColorManager.coffee
Normal file
44
services/web/public/coffee/ide/colors/ColorManager.coffee
Normal file
|
@ -0,0 +1,44 @@
|
|||
define [], () ->
|
||||
ColorManager =
|
||||
getColorScheme: (hue, element) ->
|
||||
if @isDarkTheme(element)
|
||||
return {
|
||||
cursor: "hsl(#{hue}, 70%, 50%)"
|
||||
labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
|
||||
highlightBackgroundColor: "hsl(#{hue}, 100%, 28%);"
|
||||
strikeThroughBackgroundColor: "hsl(#{hue}, 100%, 20%);"
|
||||
strikeThroughForegroundColor: "hsl(#{hue}, 100%, 60%);"
|
||||
}
|
||||
else
|
||||
return {
|
||||
cursor: "hsl(#{hue}, 70%, 50%)"
|
||||
labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
|
||||
highlightBackgroundColor: "hsl(#{hue}, 70%, 85%);"
|
||||
strikeThroughBackgroundColor: "hsl(#{hue}, 70%, 95%);"
|
||||
strikeThroughForegroundColor: "hsl(#{hue}, 70%, 40%);"
|
||||
}
|
||||
|
||||
isDarkTheme: (element) ->
|
||||
rgb = element.find(".ace_editor").css("background-color");
|
||||
[m, r, g, b] = rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
|
||||
r = parseInt(r, 10)
|
||||
g = parseInt(g, 10)
|
||||
b = parseInt(b, 10)
|
||||
return r + g + b < 3 * 128
|
||||
|
||||
OWN_HUE: 200 # We will always appear as this color to ourselves
|
||||
ANONYMOUS_HUE: 100
|
||||
getHueForUserId: (user_id) ->
|
||||
if !user_id? or user_id == "anonymous-user"
|
||||
return @ANONYMOUS_HUE
|
||||
|
||||
if window.user.id == user_id
|
||||
return @OWN_HUE
|
||||
|
||||
hash = CryptoJS.MD5(user_id)
|
||||
hue = parseInt(hash.toString().slice(0,8), 16) % 320
|
||||
# Avoid 20 degrees either side of the personal hue
|
||||
if hue > @OWNER_HUE - 20
|
||||
hue = hue + 40
|
||||
return hue
|
||||
|
|
@ -20,6 +20,12 @@ define [], () ->
|
|||
@disconnectIfInactive()
|
||||
, ONEHOUR)
|
||||
|
||||
# trigger a reconnect immediately if network comes back online
|
||||
window.addEventListener 'online', =>
|
||||
sl_console.log "[online] browser notified online"
|
||||
if !@connected
|
||||
@tryReconnectWithRateLimit({force:true})
|
||||
|
||||
@userIsLeavingPage = false
|
||||
window.addEventListener 'beforeunload', =>
|
||||
@userIsLeavingPage = true
|
||||
|
@ -27,6 +33,7 @@ define [], () ->
|
|||
|
||||
@connected = false
|
||||
@userIsInactive = false
|
||||
@gracefullyReconnecting = false
|
||||
|
||||
@$scope.connection =
|
||||
reconnecting: false
|
||||
|
@ -35,25 +42,40 @@ define [], () ->
|
|||
inactive_disconnect: false
|
||||
|
||||
@$scope.tryReconnectNow = () =>
|
||||
@tryReconnect()
|
||||
# user manually requested reconnection via "Try now" button
|
||||
@tryReconnectWithRateLimit({force:true})
|
||||
|
||||
@$scope.$on 'cursor:editor:update', () =>
|
||||
@lastUserAction = new Date()
|
||||
@lastUserAction = new Date() # time of last edit
|
||||
if !@connected
|
||||
@tryReconnect()
|
||||
# user is editing, try to reconnect
|
||||
@tryReconnectWithRateLimit()
|
||||
|
||||
document.querySelector('body').addEventListener 'click', (e) =>
|
||||
if !@connected and e.target.id != 'try-reconnect-now-button'
|
||||
@tryReconnect()
|
||||
# user is editing, try to reconnect
|
||||
@tryReconnectWithRateLimit()
|
||||
|
||||
@ide.socket = io.connect null,
|
||||
reconnect: false
|
||||
'connect timeout': 30 * 1000
|
||||
"force new connection": true
|
||||
|
||||
# The "connect" event is the first event we get back. It only
|
||||
# indicates that the websocket is connected, we still need to
|
||||
# pass authentication to join a project.
|
||||
|
||||
@ide.socket.on "connect", () =>
|
||||
sl_console.log "[socket.io connect] Connected"
|
||||
|
||||
# The next event we should get is an authentication response
|
||||
# from the server, either "connectionAccepted" or
|
||||
# "connectionRejected".
|
||||
|
||||
@ide.socket.on 'connectionAccepted', (message) =>
|
||||
sl_console.log "[socket.io connectionAccepted] allowed to connect"
|
||||
@connected = true
|
||||
@gracefullyReconnecting = false
|
||||
@ide.pushEvent("connected")
|
||||
|
||||
@$scope.$apply () =>
|
||||
|
@ -62,16 +84,26 @@ define [], () ->
|
|||
if @$scope.state.loading
|
||||
@$scope.state.load_progress = 70
|
||||
|
||||
# we have passed authentication so we can now join the project
|
||||
setTimeout(() =>
|
||||
@joinProject()
|
||||
, 100)
|
||||
|
||||
@ide.socket.on 'connectionRejected', (err) =>
|
||||
sl_console.log "[socket.io connectionRejected] session not valid or other connection error"
|
||||
# we have failed authentication, usually due to an invalid session cookie
|
||||
return @reportConnectionError(err)
|
||||
|
||||
# Alternatively the attempt to connect can fail completely, so
|
||||
# we never get into the "connect" state.
|
||||
|
||||
@ide.socket.on "connect_failed", () =>
|
||||
@connected = false
|
||||
$scope.$apply () =>
|
||||
@$scope.state.error = "Unable to connect, please view the <u><a href='http://sharelatex.tenderapp.com/help/kb/latex-editor/editor-connection-problems'>connection problems guide</a></u> to fix the issue."
|
||||
|
||||
@$scope.state.error = "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue."
|
||||
|
||||
# We can get a "disconnect" event at any point after the
|
||||
# "connect" event.
|
||||
|
||||
@ide.socket.on 'disconnect', () =>
|
||||
sl_console.log "[socket.io disconnect] Disconnected"
|
||||
|
@ -81,9 +113,11 @@ define [], () ->
|
|||
@$scope.$apply () =>
|
||||
@$scope.connection.reconnecting = false
|
||||
|
||||
if !$scope.connection.forced_disconnect and !@userIsInactive
|
||||
if !$scope.connection.forced_disconnect and !@userIsInactive and !@gracefullyReconnecting
|
||||
@startAutoReconnectCountdown()
|
||||
|
||||
# Site administrators can send the forceDisconnect event to all users
|
||||
|
||||
@ide.socket.on 'forceDisconnect', (message) =>
|
||||
@$scope.$apply () =>
|
||||
@$scope.permissions.write = false
|
||||
|
@ -98,20 +132,32 @@ define [], () ->
|
|||
location.reload()
|
||||
, 10 * 1000
|
||||
|
||||
joinProject: () ->
|
||||
sl_console.log "[joinProject] joining..."
|
||||
@ide.socket.emit 'joinProject', {
|
||||
project_id: @ide.project_id
|
||||
}, (err, project, permissionsLevel, protocolVersion) =>
|
||||
if err?
|
||||
if err.message == "not authorized"
|
||||
@ide.socket.on "reconnectGracefully", () =>
|
||||
sl_console.log "Reconnect gracefully"
|
||||
@reconnectGracefully()
|
||||
|
||||
# Error reporting, which can reload the page if appropriate
|
||||
|
||||
reportConnectionError: (err) ->
|
||||
sl_console.log "[socket.io] reporting connection error"
|
||||
if err?.message == "not authorized" or err?.message == "invalid session"
|
||||
window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
|
||||
else
|
||||
@ide.socket.disconnect()
|
||||
@ide.showGenericMessageModal("Something went wrong connecting", """
|
||||
Something went wrong connecting to your project. Please refresh is this continues to happen.
|
||||
""")
|
||||
return
|
||||
|
||||
joinProject: () ->
|
||||
sl_console.log "[joinProject] joining..."
|
||||
# Note: if the "joinProject" message doesn't reach the server
|
||||
# (e.g. if we are in a disconnected state at this point) the
|
||||
# callback will never be executed
|
||||
@ide.socket.emit 'joinProject', {
|
||||
project_id: @ide.project_id
|
||||
}, (err, project, permissionsLevel, protocolVersion) =>
|
||||
if err?
|
||||
return @reportConnectionError(err)
|
||||
|
||||
if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion
|
||||
location.reload(true)
|
||||
|
@ -129,11 +175,13 @@ define [], () ->
|
|||
@tryReconnect()
|
||||
|
||||
disconnect: () ->
|
||||
sl_console.log "[socket.io] disconnecting client"
|
||||
@ide.socket.disconnect()
|
||||
|
||||
startAutoReconnectCountdown: () ->
|
||||
sl_console.log "[ConnectionManager] starting autoreconnect countdown"
|
||||
twoMinutes = 2 * 60 * 1000
|
||||
if @lastUpdated? and new Date() - @lastUpdated > twoMinutes
|
||||
if @lastUserAction? and new Date() - @lastUserAction > twoMinutes
|
||||
# between 1 minute and 3 minutes
|
||||
countdown = 60 + Math.floor(Math.random() * 120)
|
||||
else
|
||||
|
@ -152,10 +200,16 @@ define [], () ->
|
|||
, 200)
|
||||
|
||||
cancelReconnect: () ->
|
||||
clearTimeout @timeoutId if @timeoutId?
|
||||
# clear timeout and set to null so we know there is no countdown running
|
||||
if @timeoutId?
|
||||
sl_console.log "[ConnectionManager] cancelling existing reconnect timer"
|
||||
clearTimeout @timeoutId
|
||||
@timeoutId = null
|
||||
|
||||
decreaseCountdown: () ->
|
||||
@timeoutId = null
|
||||
return if !@$scope.connection.reconnection_countdown?
|
||||
sl_console.log "[ConnectionManager] decreasing countdown", @$scope.connection.reconnection_countdown
|
||||
@$scope.$apply () =>
|
||||
@$scope.connection.reconnection_countdown--
|
||||
|
||||
|
@ -166,13 +220,33 @@ define [], () ->
|
|||
@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
|
||||
|
||||
tryReconnect: () ->
|
||||
sl_console.log "[ConnectionManager] tryReconnect"
|
||||
@cancelReconnect()
|
||||
delete @$scope.connection.reconnection_countdown
|
||||
return if @connected
|
||||
@$scope.connection.reconnecting = true
|
||||
@ide.socket.socket.reconnect()
|
||||
# use socket.io connect() here to make a single attempt, the
|
||||
# reconnect() method makes multiple attempts
|
||||
@ide.socket.socket.connect()
|
||||
# record the time of the last attempt to connect
|
||||
@lastConnectionAttempt = new Date()
|
||||
setTimeout (=> @startAutoReconnectCountdown() if !@connected), 2000
|
||||
|
||||
MIN_RETRY_INTERVAL: 1000 # ms, rate limit on reconnects for user clicking "try now"
|
||||
BACKGROUND_RETRY_INTERVAL : 5 * 1000 # ms, rate limit on reconnects for other user activity (e.g. cursor moves)
|
||||
|
||||
tryReconnectWithRateLimit: (options) ->
|
||||
# bail out if the reconnect is already in progress
|
||||
return if @$scope.connection?.reconnecting
|
||||
# bail out if we are going to reconnect soon anyway
|
||||
reconnectingSoon = @$scope.connection?.reconnection_countdown? and @$scope.connection.reconnection_countdown <= 5
|
||||
clickedTryNow = options?.force # user requested reconnection
|
||||
return if reconnectingSoon and not clickedTryNow
|
||||
# bail out if we tried reconnecting recently
|
||||
allowedInterval = if clickedTryNow then @MIN_RETRY_INTERVAL else @BACKGROUND_RETRY_INTERVAL
|
||||
return if @lastConnectionAttempt? and new Date() - @lastConnectionAttempt < allowedInterval
|
||||
@tryReconnect()
|
||||
|
||||
disconnectIfInactive: ()->
|
||||
@userIsInactive = (new Date() - @lastUserAction) > @disconnectAfterMs
|
||||
if @userIsInactive and @connected
|
||||
|
@ -180,3 +254,24 @@ define [], () ->
|
|||
@$scope.$apply () =>
|
||||
@$scope.connection.inactive_disconnect = true
|
||||
|
||||
RECONNECT_GRACEFULLY_RETRY_INTERVAL: 5000 # ms
|
||||
MAX_RECONNECT_GRACEFULLY_INTERVAL: 60 * 5 * 1000 # 5 minutes
|
||||
reconnectGracefully: () ->
|
||||
@reconnectGracefullyStarted ?= new Date()
|
||||
userIsInactive = (new Date() - @lastUserAction) > @RECONNECT_GRACEFULLY_RETRY_INTERVAL
|
||||
maxIntervalReached = (new Date() - @reconnectGracefullyStarted) > @MAX_RECONNECT_GRACEFULLY_INTERVAL
|
||||
if userIsInactive or maxIntervalReached
|
||||
sl_console.log "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
|
||||
@_reconnectGracefullyNow()
|
||||
else
|
||||
sl_console.log "[reconnectGracefully] User is working, will try again in 5 seconds"
|
||||
setTimeout () =>
|
||||
@reconnectGracefully()
|
||||
, @RECONNECT_GRACEFULLY_RETRY_INTERVAL
|
||||
|
||||
_reconnectGracefullyNow: () ->
|
||||
@gracefullyReconnecting = true
|
||||
@reconnectGracefullyStarted = null
|
||||
# Clear cookie so we don't go to the same backend server
|
||||
$.cookie("SERVERID", "", { expires: -1, path: "/" })
|
||||
@reconnectImmediately()
|
||||
|
|
|
@ -94,8 +94,6 @@ define [
|
|||
$(window).unload () ->
|
||||
ide.localStorage("layout.#{name}", element.layout().readState())
|
||||
|
||||
|
||||
|
||||
if attrs.openEast?
|
||||
scope.$watch attrs.openEast, (value, oldValue) ->
|
||||
if value? and value != oldValue
|
||||
|
@ -107,6 +105,9 @@ define [
|
|||
scope.$digest()
|
||||
, 0
|
||||
|
||||
if attrs.allowOverflowOn?
|
||||
element.layout().allowOverflow(scope.$eval(attrs.allowOverflowOn))
|
||||
|
||||
resetOpenStates()
|
||||
onInternalResize()
|
||||
|
||||
|
|
29
services/web/public/coffee/ide/editor/AceShareJsCodec.coffee
Normal file
29
services/web/public/coffee/ide/editor/AceShareJsCodec.coffee
Normal file
|
@ -0,0 +1,29 @@
|
|||
define [], () ->
|
||||
AceShareJsCodec =
|
||||
aceRangeToShareJs: (range, lines) ->
|
||||
offset = 0
|
||||
for line, i in lines
|
||||
offset += if i < range.row
|
||||
line.length
|
||||
else
|
||||
range.column
|
||||
offset += range.row # Include newlines
|
||||
return offset
|
||||
|
||||
aceChangeToShareJs: (delta, lines) ->
|
||||
offset = AceShareJsCodec.aceRangeToShareJs(delta.start, lines)
|
||||
|
||||
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) ->
|
||||
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}
|
|
@ -69,6 +69,12 @@ define [
|
|||
getPendingOp: () ->
|
||||
@doc?.getPendingOp()
|
||||
|
||||
getRecentAck: () ->
|
||||
@doc?.getRecentAck()
|
||||
|
||||
getOpSize: (op) ->
|
||||
@doc?.getOpSize(op)
|
||||
|
||||
hasBufferedOps: () ->
|
||||
@doc?.hasBufferedOps()
|
||||
|
||||
|
@ -143,24 +149,34 @@ define [
|
|||
clearChaosMonkey: () ->
|
||||
clearTimeout @_cm
|
||||
|
||||
MAX_PENDING_OP_SIZE: 30 # pending ops bigger than this are always considered unsaved
|
||||
|
||||
pollSavedStatus: () ->
|
||||
# returns false if doc has ops waiting to be acknowledged or
|
||||
# sent that haven't changed since the last time we checked.
|
||||
# Otherwise returns true.
|
||||
inflightOp = @getInflightOp()
|
||||
pendingOp = @getPendingOp()
|
||||
recentAck = @getRecentAck()
|
||||
pendingOpSize = pendingOp? && @getOpSize(pendingOp)
|
||||
if !inflightOp? and !pendingOp?
|
||||
# there's nothing going on
|
||||
# there's nothing going on, this is ok.
|
||||
saved = true
|
||||
sl_console.log "[pollSavedStatus] no inflight or pending ops"
|
||||
else if inflightOp? and inflightOp == @oldInflightOp
|
||||
# The same inflight op has been sitting unacked since we
|
||||
# last checked.
|
||||
# last checked, this is bad.
|
||||
saved = false
|
||||
sl_console.log "[pollSavedStatus] inflight op is same as before"
|
||||
else
|
||||
else if pendingOp? and recentAck && pendingOpSize < @MAX_PENDING_OP_SIZE
|
||||
# There is an op waiting to go to server but it is small and
|
||||
# within the flushDelay, this is ok for now.
|
||||
saved = true
|
||||
sl_console.log "[pollSavedStatus] assuming saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
|
||||
sl_console.log "[pollSavedStatus] pending op (small with recent ack) assume ok", pendingOp, pendingOpSize
|
||||
else
|
||||
# In any other situation, assume the document is unsaved.
|
||||
saved = false
|
||||
sl_console.log "[pollSavedStatus] assuming not saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
|
||||
|
||||
@oldInflightOp = inflightOp
|
||||
return saved
|
||||
|
@ -265,10 +281,10 @@ define [
|
|||
@ide.pushEvent "externalUpdate",
|
||||
doc_id: @doc_id
|
||||
@trigger "externalUpdate", update
|
||||
@doc.on "remoteop", () =>
|
||||
@doc.on "remoteop", (args...) =>
|
||||
@ide.pushEvent "remoteop",
|
||||
doc_id: @doc_id
|
||||
@trigger "remoteop"
|
||||
@trigger "remoteop", args...
|
||||
@doc.on "op:sent", (op) =>
|
||||
@ide.pushEvent "op:sent",
|
||||
doc_id: @doc_id
|
||||
|
@ -294,7 +310,7 @@ define [
|
|||
|
||||
_onError: (error, meta = {}) ->
|
||||
meta.doc_id = @doc_id
|
||||
console.error "ShareJS error", error, meta
|
||||
sl_console.log "ShareJS error", error, meta
|
||||
ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
|
||||
@doc?.clearInflightAndPendingOps()
|
||||
@trigger "error", error, meta
|
||||
|
|
|
@ -8,11 +8,12 @@ define [
|
|||
@$scope.editor = {
|
||||
sharejs_doc: null
|
||||
open_doc_id: null
|
||||
open_doc_name: null
|
||||
opening: true
|
||||
}
|
||||
|
||||
@$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")
|
||||
@openDoc(entity)
|
||||
|
||||
@$scope.$on "entity:deleted", (event, entity) =>
|
||||
|
@ -40,6 +41,11 @@ define [
|
|||
return if !doc?
|
||||
@openDoc(doc)
|
||||
|
||||
openDocId: (doc_id, options = {}) ->
|
||||
doc = @ide.fileTreeManager.findEntityById(doc_id)
|
||||
return if !doc?
|
||||
@openDoc(doc, options)
|
||||
|
||||
openDoc: (doc, options = {}) ->
|
||||
sl_console.log "[openDoc] Opening #{doc.id}"
|
||||
@$scope.ui.view = "editor"
|
||||
|
@ -52,6 +58,11 @@ define [
|
|||
setTimeout () =>
|
||||
@$scope.$broadcast "editor:gotoLine", options.gotoLine, options.gotoColumn
|
||||
, 0
|
||||
else if options.gotoOffset?
|
||||
setTimeout () =>
|
||||
@$scope.$broadcast "editor:gotoOffset", options.gotoOffset
|
||||
, 0
|
||||
|
||||
|
||||
if doc.id == @$scope.editor.open_doc_id and !options.forceReopen
|
||||
@$scope.$apply () =>
|
||||
|
@ -59,6 +70,7 @@ define [
|
|||
return
|
||||
|
||||
@$scope.editor.open_doc_id = doc.id
|
||||
@$scope.editor.open_doc_name = doc.name
|
||||
|
||||
@ide.localStorage "doc.open_id.#{@$scope.project_id}", doc.id
|
||||
@ide.fileTreeManager.selectEntity(doc)
|
||||
|
@ -107,7 +119,7 @@ define [
|
|||
@ide.reportError(error, meta)
|
||||
@ide.showGenericMessageModal(
|
||||
"Out of sync"
|
||||
"Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='http://sharelatex.tenderapp.com/help/kb/browsers/editor-out-of-sync-problems'>Please see this help guide for more information</a>"
|
||||
"Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>"
|
||||
)
|
||||
@openDoc(doc, forceReopen: true)
|
||||
|
||||
|
|
|
@ -46,12 +46,13 @@ define [
|
|||
@_doc.on "change", () =>
|
||||
@trigger "change"
|
||||
@_doc.on "acknowledge", () =>
|
||||
@lastAcked = new Date() # note time of last ack from server for an op we sent
|
||||
@trigger "acknowledge"
|
||||
@_doc.on "remoteop", () =>
|
||||
@_doc.on "remoteop", (args...) =>
|
||||
# As soon as we're working with a collaborator, start sending
|
||||
# ops as quickly as possible for low latency.
|
||||
@_doc.setFlushDelay(0)
|
||||
@trigger "remoteop"
|
||||
@trigger "remoteop", args...
|
||||
@_doc.on "error", (e) =>
|
||||
@_handleError(e)
|
||||
|
||||
|
@ -101,12 +102,26 @@ define [
|
|||
@connection.id = @socket.socket.sessionid
|
||||
@_doc.autoOpen = false
|
||||
@_doc._connectionStateChanged(state)
|
||||
@lastAcked = null # reset the last ack time when connection changes
|
||||
|
||||
hasBufferedOps: () ->
|
||||
@_doc.inflightOp? or @_doc.pendingOp?
|
||||
|
||||
getInflightOp: () -> @_doc.inflightOp
|
||||
getPendingOp: () -> @_doc.pendingOp
|
||||
getRecentAck: () ->
|
||||
# check if we have received an ack recently (within the flush delay)
|
||||
@lastAcked? and new Date() - @lastAcked < @_doc._flushDelay
|
||||
getOpSize: (op) ->
|
||||
# compute size of an op from its components
|
||||
# (total number of characters inserted and deleted)
|
||||
size = 0
|
||||
for component in op or []
|
||||
if component?.i?
|
||||
size += component.i.length
|
||||
if component?.d?
|
||||
size += component.d.length
|
||||
return size
|
||||
|
||||
attachToAce: (ace) -> @_doc.attach_ace(ace, false, window.maxDocLength)
|
||||
detachFromAce: () -> @_doc.detach_ace?()
|
||||
|
|
|
@ -10,12 +10,16 @@ define [
|
|||
$(window).bind 'beforeunload', () =>
|
||||
warnAboutUnsavedChanges()
|
||||
|
||||
lockEditorModal = null # modal showing "connection lost"
|
||||
MAX_UNSAVED_SECONDS = 15 # lock the editor after this time if unsaved
|
||||
|
||||
$scope.docSavingStatus = {}
|
||||
pollSavedStatus = () ->
|
||||
oldStatus = $scope.docSavingStatus
|
||||
oldUnsavedCount = $scope.docSavingStatusCount
|
||||
newStatus = {}
|
||||
newUnsavedCount = 0
|
||||
maxUnsavedSeconds = 0
|
||||
|
||||
for doc_id, doc of Document.openDocs
|
||||
saving = doc.pollSavedStatus()
|
||||
|
@ -23,13 +27,26 @@ define [
|
|||
newUnsavedCount++
|
||||
if oldStatus[doc_id]?
|
||||
newStatus[doc_id] = oldStatus[doc_id]
|
||||
newStatus[doc_id].unsavedSeconds += 1
|
||||
t = newStatus[doc_id].unsavedSeconds += 1
|
||||
if t > maxUnsavedSeconds
|
||||
maxUnsavedSeconds = t
|
||||
else
|
||||
newStatus[doc_id] = {
|
||||
unsavedSeconds: 0
|
||||
doc: ide.fileTreeManager.findEntityById(doc_id)
|
||||
}
|
||||
|
||||
if newUnsavedCount > 0 and t > MAX_UNSAVED_SECONDS and not lockEditorModal
|
||||
lockEditorModal = ide.showLockEditorMessageModal(
|
||||
"Connection lost"
|
||||
"Sorry, the connection to the server is down."
|
||||
)
|
||||
lockEditorModal.result.finally () ->
|
||||
lockEditorModal = null # unset the modal if connection comes back
|
||||
|
||||
if lockEditorModal and newUnsavedCount is 0
|
||||
lockEditorModal.dismiss "connection back up"
|
||||
|
||||
# for performance, only update the display if the old or new
|
||||
# counts of unsaved files are nonzeror. If both old and new
|
||||
# unsaved counts are zero then we know we are in a good state
|
||||
|
|
|
@ -2,19 +2,25 @@ define [
|
|||
"base"
|
||||
"ace/ace"
|
||||
"ace/ext-searchbox"
|
||||
"ace/ext-modelist"
|
||||
"ide/editor/directives/aceEditor/undo/UndoManager"
|
||||
"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
|
||||
"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
|
||||
"ide/editor/directives/aceEditor/highlights/HighlightsManager"
|
||||
"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
|
||||
"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
|
||||
], (App, Ace, SearchBox, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager) ->
|
||||
], (App, Ace, SearchBox, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager) ->
|
||||
EditSession = ace.require('ace/edit_session').EditSession
|
||||
ModeList = ace.require('ace/ext/modelist')
|
||||
|
||||
# set the path for ace workers if using a CDN (from editor.jade)
|
||||
if window.aceWorkerPath != ""
|
||||
syntaxValidationEnabled = true
|
||||
ace.config.set('workerPath', "#{window.aceWorkerPath}")
|
||||
else
|
||||
syntaxValidationEnabled = false
|
||||
|
||||
# By default, don't use workers - enable them per-session as required
|
||||
ace.config.setDefaultValue("session", "useWorker", false)
|
||||
|
||||
# Ace loads its script itself, so we need to hook in to be able to clear
|
||||
|
@ -42,9 +48,16 @@ define [
|
|||
text: "="
|
||||
readOnly: "="
|
||||
annotations: "="
|
||||
navigateHighlights: "=",
|
||||
navigateHighlights: "="
|
||||
fileName: "="
|
||||
onCtrlEnter: "="
|
||||
syntaxValidation: "="
|
||||
reviewPanel: "="
|
||||
eventsBridge: "="
|
||||
trackNewChanges: "="
|
||||
trackChangesEnabled: "="
|
||||
changesTracker: "="
|
||||
docId: "="
|
||||
}
|
||||
link: (scope, element, attrs) ->
|
||||
# Don't freak out if we're already in an apply callback
|
||||
|
@ -58,6 +71,11 @@ define [
|
|||
|
||||
editor = ace.edit(element.find(".ace-editor-body")[0])
|
||||
editor.$blockScrolling = Infinity
|
||||
|
||||
# disable auto insertion of brackets and quotes
|
||||
editor.setOption('behavioursEnabled', false)
|
||||
editor.setOption('wrapBehavioursEnabled', false)
|
||||
|
||||
window.editors ||= []
|
||||
window.editors.push editor
|
||||
|
||||
|
@ -71,8 +89,6 @@ define [
|
|||
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
|
||||
|
@ -194,16 +210,15 @@ define [
|
|||
editor.setReadOnly !!value
|
||||
|
||||
scope.$watch "syntaxValidation", (value) ->
|
||||
# ignore undefined settings here
|
||||
# only instances of ace with an explicit value should set useWorker
|
||||
# the history instance will have syntaxValidation undefined
|
||||
if value? and syntaxValidationEnabled
|
||||
session = editor.getSession()
|
||||
session.setOption("useWorker", value);
|
||||
|
||||
editor.setOption("scrollPastEnd", true)
|
||||
|
||||
resetSession = () ->
|
||||
session = editor.getSession()
|
||||
session.setUseWrapMode(true)
|
||||
session.setMode("ace/mode/latex")
|
||||
|
||||
updateCount = 0
|
||||
onChange = () ->
|
||||
updateCount++
|
||||
|
@ -211,35 +226,96 @@ define [
|
|||
event_tracking.send 'editor-interaction', 'multi-doc-update'
|
||||
scope.$emit "#{scope.name}:change"
|
||||
|
||||
onScroll = (scrollTop) ->
|
||||
return if !scope.eventsBridge?
|
||||
height = editor.renderer.layerConfig.maxHeight
|
||||
scope.eventsBridge.emit "aceScroll", scrollTop, height
|
||||
|
||||
onScrollbarVisibilityChanged = (event, vRenderer) ->
|
||||
return if !scope.eventsBridge?
|
||||
scope.eventsBridge.emit "aceScrollbarVisibilityChanged", vRenderer.scrollBarV.isVisible, vRenderer.scrollBarV.width
|
||||
|
||||
if scope.eventsBridge?
|
||||
editor.renderer.on "scrollbarVisibilityChanged", onScrollbarVisibilityChanged
|
||||
|
||||
scope.eventsBridge.on "externalScroll", (position) ->
|
||||
editor.getSession().setScrollTop(position)
|
||||
scope.eventsBridge.on "refreshScrollPosition", () ->
|
||||
session = editor.getSession()
|
||||
session.setScrollTop(session.getScrollTop() + 1)
|
||||
session.setScrollTop(session.getScrollTop() - 1)
|
||||
|
||||
attachToAce = (sharejs_doc) ->
|
||||
lines = sharejs_doc.getSnapshot().split("\n")
|
||||
session = editor.getSession()
|
||||
if session?
|
||||
session.destroy()
|
||||
editor.setSession(new EditSession(lines, "ace/mode/latex"))
|
||||
resetSession()
|
||||
session = editor.getSession()
|
||||
|
||||
# see if we can lookup a suitable mode from ace
|
||||
# but fall back to text by default
|
||||
try
|
||||
if scope.fileName.match(/\.(Rtex|bbl)$/i)
|
||||
# recognise Rtex and bbl as latex
|
||||
mode = "ace/mode/latex"
|
||||
else if scope.fileName.match(/\.(sty|cls|clo)$/)
|
||||
# recognise some common files as tex
|
||||
mode = "ace/mode/tex"
|
||||
else
|
||||
mode = ModeList.getModeForPath(scope.fileName).mode
|
||||
# we prefer plain_text mode over text mode because ace's
|
||||
# text mode is actually for code and has unwanted
|
||||
# indenting (see wrapMethod in ace edit_session.js)
|
||||
if mode is "ace/mode/text"
|
||||
mode = "ace/mode/plain_text"
|
||||
catch
|
||||
mode = "ace/mode/plain_text"
|
||||
|
||||
# Give beta users the next release of the syntax checker
|
||||
if mode is "ace/mode/latex" and window.user?.betaProgram
|
||||
mode = "ace/mode/latex_beta"
|
||||
|
||||
# create our new session
|
||||
session = new EditSession(lines, mode)
|
||||
|
||||
session.setUseWrapMode(true)
|
||||
# use syntax validation only when explicitly set
|
||||
if scope.syntaxValidation? and syntaxValidationEnabled
|
||||
session.setOption("useWorker", scope.syntaxValidation);
|
||||
|
||||
# now attach session to editor
|
||||
editor.setSession(session)
|
||||
|
||||
doc = session.getDocument()
|
||||
doc.on "change", onChange
|
||||
|
||||
sharejs_doc.on "remoteop.recordForUndo", () =>
|
||||
sharejs_doc.on "remoteop.recordRemote", (op, oldSnapshot, msg) ->
|
||||
undoManager.nextUpdateIsRemote = true
|
||||
trackChangesManager.nextUpdateMetaData = msg?.meta
|
||||
|
||||
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
|
||||
|
||||
if scope.eventsBridge?
|
||||
session.on "changeScrollTop", onScroll
|
||||
|
||||
setTimeout () ->
|
||||
# Let any listeners init themselves
|
||||
onScroll(editor.renderer.getScrollTop())
|
||||
|
||||
editor.focus()
|
||||
|
||||
detachFromAce = (sharejs_doc) ->
|
||||
sharejs_doc.detachFromAce()
|
||||
sharejs_doc.off "remoteop.recordForUndo"
|
||||
sharejs_doc.off "remoteop.recordRemote"
|
||||
|
||||
session = editor.getSession()
|
||||
session.off "changeScrollTop"
|
||||
|
||||
doc = session.getDocument()
|
||||
doc.off "change", onChange
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
define [], () ->
|
||||
define [
|
||||
"ide/editor/AceShareJsCodec"
|
||||
], (AceShareJsCodec) ->
|
||||
class CursorPositionManager
|
||||
constructor: (@$scope, @editor, @element, @localStorage) ->
|
||||
|
||||
|
@ -23,12 +25,18 @@ define [], () ->
|
|||
@storeCursorPosition(@editor.getSession())
|
||||
@storeScrollTopPosition(@editor.getSession())
|
||||
|
||||
@$scope.$on "#{@$scope.name}:gotoLine", (editor, line, column) =>
|
||||
@$scope.$on "#{@$scope.name}:gotoLine", (e, line, column) =>
|
||||
if line?
|
||||
setTimeout () =>
|
||||
@gotoLine(line, column)
|
||||
, 10 # Hack: Must happen after @gotoStoredPosition
|
||||
|
||||
@$scope.$on "#{@$scope.name}:gotoOffset", (e, offset) =>
|
||||
if offset?
|
||||
setTimeout () =>
|
||||
@gotoOffset(offset)
|
||||
, 10 # Hack: Must happen after @gotoStoredPosition
|
||||
|
||||
storeScrollTopPosition: (session) ->
|
||||
if @doc_id?
|
||||
docPosition = @localStorage("doc.position.#{@doc_id}") || {}
|
||||
|
@ -57,3 +65,8 @@ define [], () ->
|
|||
@editor.gotoLine(line, column)
|
||||
@editor.scrollToLine(line,true,true) # centre and animate
|
||||
@editor.focus()
|
||||
|
||||
gotoOffset: (offset) ->
|
||||
lines = @editor.getSession().getDocument().getAllLines()
|
||||
position = AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
|
||||
@gotoLine(position.row + 1, position.column)
|
|
@ -1,6 +1,7 @@
|
|||
define [
|
||||
"ace/ace"
|
||||
], () ->
|
||||
"ide/colors/ColorManager"
|
||||
], (_, ColorManager) ->
|
||||
Range = ace.require("ace/range").Range
|
||||
|
||||
class HighlightsManager
|
||||
|
@ -64,7 +65,7 @@ define [
|
|||
|
||||
for annotation in @$scope.highlights or []
|
||||
do (annotation) =>
|
||||
colorScheme = @_getColorScheme(annotation.hue)
|
||||
colorScheme = ColorManager.getColorScheme(annotation.hue, @element)
|
||||
if annotation.cursor?
|
||||
@labels.push {
|
||||
text: annotation.label
|
||||
|
@ -262,29 +263,3 @@ define [
|
|||
else
|
||||
markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
|
||||
, foreground
|
||||
|
||||
_getColorScheme: (hue) ->
|
||||
if @_isDarkTheme()
|
||||
return {
|
||||
cursor: "hsl(#{hue}, 70%, 50%)"
|
||||
labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
|
||||
highlightBackgroundColor: "hsl(#{hue}, 100%, 28%);"
|
||||
strikeThroughBackgroundColor: "hsl(#{hue}, 100%, 20%);"
|
||||
strikeThroughForegroundColor: "hsl(#{hue}, 100%, 60%);"
|
||||
}
|
||||
else
|
||||
return {
|
||||
cursor: "hsl(#{hue}, 70%, 50%)"
|
||||
labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
|
||||
highlightBackgroundColor: "hsl(#{hue}, 70%, 85%);"
|
||||
strikeThroughBackgroundColor: "hsl(#{hue}, 70%, 95%);"
|
||||
strikeThroughForegroundColor: "hsl(#{hue}, 70%, 40%);"
|
||||
}
|
||||
|
||||
_isDarkTheme: () ->
|
||||
rgb = @element.find(".ace_editor").css("background-color");
|
||||
[m, r, g, b] = rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
|
||||
r = parseInt(r, 10)
|
||||
g = parseInt(g, 10)
|
||||
b = parseInt(b, 10)
|
||||
return r + g + b < 3 * 128
|
||||
|
|
|
@ -1,37 +1,229 @@
|
|||
define [
|
||||
"ace/ace"
|
||||
"utils/EventEmitter"
|
||||
], (_, EventEmitter) ->
|
||||
"ide/colors/ColorManager"
|
||||
"ide/editor/AceShareJsCodec"
|
||||
], (_, EventEmitter, ColorManager, AceShareJsCodec) ->
|
||||
class TrackChangesManager
|
||||
Range = ace.require("ace/range").Range
|
||||
|
||||
constructor: (@$scope, @editor, @element) ->
|
||||
@changesTracker = new ChangesTracker()
|
||||
@changeIdToMarkerIdMap = {}
|
||||
@enabled = false
|
||||
window.trackChangesManager ?= @
|
||||
|
||||
@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)
|
||||
@$scope.$watch "changesTracker", (changesTracker) =>
|
||||
return if !changesTracker?
|
||||
@disconnectFromChangesTracker()
|
||||
@changesTracker = changesTracker
|
||||
@connectToChangesTracker()
|
||||
|
||||
@$scope.$watch "trackNewChanges", (track_new_changes) =>
|
||||
return if !track_new_changes?
|
||||
@changesTracker?.track_changes = track_new_changes
|
||||
|
||||
@$scope.$on "comment:add", (e, comment) =>
|
||||
@addCommentToSelection(comment)
|
||||
|
||||
@$scope.$on "comment:select_line", (e) =>
|
||||
@selectLineIfNoSelection()
|
||||
|
||||
@$scope.$on "change:accept", (e, change_id) =>
|
||||
@acceptChangeId(change_id)
|
||||
|
||||
@$scope.$on "change:reject", (e, change_id) =>
|
||||
@rejectChangeId(change_id)
|
||||
|
||||
@$scope.$on "comment:remove", (e, comment_id) =>
|
||||
@removeCommentId(comment_id)
|
||||
|
||||
@$scope.$on "comment:resolve", (e, comment_id, user_id) =>
|
||||
@resolveCommentId(comment_id, user_id)
|
||||
|
||||
@$scope.$on "comment:unresolve", (e, comment_id) =>
|
||||
@unresolveCommentId(comment_id)
|
||||
|
||||
@$scope.$on "review-panel:recalculate-screen-positions", () =>
|
||||
@recalculateReviewEntriesScreenPositions()
|
||||
|
||||
changingSelection = false
|
||||
onChangeSelection = (args...) =>
|
||||
# Deletes can send about 5 changeSelection events, so
|
||||
# just act on the last one.
|
||||
if !changingSelection
|
||||
changingSelection = true
|
||||
@$scope.$evalAsync () =>
|
||||
changingSelection = false
|
||||
@updateFocus()
|
||||
@recalculateReviewEntriesScreenPositions()
|
||||
|
||||
onResize = () =>
|
||||
@recalculateReviewEntriesScreenPositions()
|
||||
|
||||
onChange = (e) =>
|
||||
if !@editor.initing and @enabled
|
||||
@applyChange(e)
|
||||
if !@editor.initing
|
||||
# This change is trigger by a sharejs 'change' event, which is before the
|
||||
# sharejs 'remoteop' event. So wait until the next event loop when the 'remoteop'
|
||||
# will have fired, before we decide if it was a remote op.
|
||||
setTimeout () =>
|
||||
if @nextUpdateMetaData?
|
||||
user_id = @nextUpdateMetaData.user_id
|
||||
# The remote op may have contained multiple atomic ops, each of which is an Ace
|
||||
# 'change' event (i.e. bulk commenting out of lines is a single remote op
|
||||
# but gives us one event for each % inserted). These all come in a single event loop
|
||||
# though, so wait until the next one before clearing the metadata.
|
||||
setTimeout () =>
|
||||
@nextUpdateMetaData = null
|
||||
else
|
||||
user_id = window.user.id
|
||||
|
||||
was_tracking = @changesTracker.track_changes
|
||||
if @dont_track_next_update
|
||||
@changesTracker.track_changes = false
|
||||
@dont_track_next_update = false
|
||||
@applyChange(e, { user_id })
|
||||
@changesTracker.track_changes = was_tracking
|
||||
|
||||
# TODO: Just for debugging, remove before going live.
|
||||
setTimeout () =>
|
||||
@checkMapping()
|
||||
, 100
|
||||
|
||||
@editor.on "changeSession", (e) =>
|
||||
onChangeSession = (e) =>
|
||||
e.oldSession?.getDocument().off "change", onChange
|
||||
e.session.getDocument().on "change", onChange
|
||||
@redrawAnnotations()
|
||||
|
||||
bindToAce = () =>
|
||||
@editor.getSession().getDocument().on "change", onChange
|
||||
@editor.on "changeSelection", onChangeSelection
|
||||
@editor.on "changeSession", onChangeSession
|
||||
@editor.renderer.on "resize", onResize
|
||||
|
||||
unbindFromAce = () =>
|
||||
@editor.getSession().getDocument().off "change", onChange
|
||||
@editor.off "changeSelection", onChangeSelection
|
||||
@editor.off "changeSession", onChangeSession
|
||||
@editor.renderer.off "resize", onResize
|
||||
|
||||
@$scope.$watch "trackChangesEnabled", (enabled) =>
|
||||
return if !enabled?
|
||||
if enabled
|
||||
bindToAce()
|
||||
else
|
||||
unbindFromAce()
|
||||
|
||||
disconnectFromChangesTracker: () ->
|
||||
@changeIdToMarkerIdMap = {}
|
||||
|
||||
if @changesTracker?
|
||||
@changesTracker.off "insert:added"
|
||||
@changesTracker.off "insert:removed"
|
||||
@changesTracker.off "delete:added"
|
||||
@changesTracker.off "delete:removed"
|
||||
@changesTracker.off "changes:moved"
|
||||
@changesTracker.off "comment:added"
|
||||
@changesTracker.off "comment:moved"
|
||||
@changesTracker.off "comment:removed"
|
||||
@changesTracker.off "comment:resolved"
|
||||
@changesTracker.off "comment:unresolved"
|
||||
|
||||
connectToChangesTracker: () ->
|
||||
@changesTracker.track_changes = @$scope.trackNewChanges
|
||||
|
||||
@changesTracker.on "insert:added", (change) =>
|
||||
sl_console.log "[insert:added]", change
|
||||
@_onInsertAdded(change)
|
||||
@changesTracker.on "insert:removed", (change) =>
|
||||
sl_console.log "[insert:removed]", change
|
||||
@_onInsertRemoved(change)
|
||||
@changesTracker.on "delete:added", (change) =>
|
||||
sl_console.log "[delete:added]", change
|
||||
@_onDeleteAdded(change)
|
||||
@changesTracker.on "delete:removed", (change) =>
|
||||
sl_console.log "[delete:removed]", change
|
||||
@_onDeleteRemoved(change)
|
||||
@changesTracker.on "changes:moved", (changes) =>
|
||||
sl_console.log "[changes:moved]", changes
|
||||
@_onChangesMoved(changes)
|
||||
|
||||
@changesTracker.on "comment:added", (comment) =>
|
||||
sl_console.log "[comment:added]", comment
|
||||
@_onCommentAdded(comment)
|
||||
@changesTracker.on "comment:moved", (comment) =>
|
||||
sl_console.log "[comment:moved]", comment
|
||||
@_onCommentMoved(comment)
|
||||
@changesTracker.on "comment:removed", (comment) =>
|
||||
sl_console.log "[comment:removed]", comment
|
||||
@_onCommentRemoved(comment)
|
||||
@changesTracker.on "comment:resolved", (comment) =>
|
||||
sl_console.log "[comment:resolved]", comment
|
||||
@_onCommentRemoved(comment)
|
||||
@changesTracker.on "comment:unresolved", (comment) =>
|
||||
sl_console.log "[comment:unresolved]", comment
|
||||
@_onCommentAdded(comment)
|
||||
|
||||
redrawAnnotations: () ->
|
||||
for change in @changesTracker.changes
|
||||
if change.op.i?
|
||||
@_onInsertAdded(change)
|
||||
else if change.op.d?
|
||||
@_onDeleteAdded(change)
|
||||
|
||||
for comment in @changesTracker.comments
|
||||
@_onCommentAdded(comment)
|
||||
|
||||
addComment: (offset, length, content) ->
|
||||
@changesTracker.addComment offset, length, {
|
||||
thread: [{
|
||||
content: content
|
||||
user_id: window.user_id
|
||||
ts: new Date()
|
||||
}]
|
||||
}
|
||||
|
||||
addCommentToSelection: (content) ->
|
||||
range = @editor.getSelectionRange()
|
||||
offset = @_aceRangeToShareJs(range.start)
|
||||
end = @_aceRangeToShareJs(range.end)
|
||||
length = end - offset
|
||||
@addComment(offset, length, content)
|
||||
|
||||
selectLineIfNoSelection: () ->
|
||||
if @editor.selection.isEmpty()
|
||||
@editor.selection.selectLine()
|
||||
|
||||
acceptChangeId: (change_id) ->
|
||||
@changesTracker.removeChangeId(change_id)
|
||||
|
||||
rejectChangeId: (change_id) ->
|
||||
change = @changesTracker.getChange(change_id)
|
||||
return if !change?
|
||||
@changesTracker.removeChangeId(change_id)
|
||||
@dont_track_next_update = true
|
||||
session = @editor.getSession()
|
||||
if change.op.d?
|
||||
content = change.op.d
|
||||
position = @_shareJsOffsetToAcePosition(change.op.p)
|
||||
session.insert(position, content)
|
||||
else if change.op.i?
|
||||
start = @_shareJsOffsetToAcePosition(change.op.p)
|
||||
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
|
||||
editor_text = session.getDocument().getTextRange({start, end})
|
||||
if editor_text != change.op.i
|
||||
throw new Error("Op to be removed (#{JSON.stringify(change.op)}), does not match editor text, '#{editor_text}'")
|
||||
session.remove({start, end})
|
||||
else
|
||||
throw new Error("unknown change: #{JSON.stringify(change)}")
|
||||
|
||||
removeCommentId: (comment_id) ->
|
||||
@changesTracker.removeCommentId(comment_id)
|
||||
|
||||
resolveCommentId: (comment_id, user_id) ->
|
||||
@changesTracker.resolveCommentId(comment_id, {
|
||||
user_id, ts: new Date()
|
||||
})
|
||||
|
||||
unresolveCommentId: (comment_id) ->
|
||||
@changesTracker.unresolveCommentId(comment_id)
|
||||
|
||||
checkMapping: () ->
|
||||
session = @editor.getSession()
|
||||
|
@ -41,16 +233,28 @@ define [
|
|||
for marker_id, marker of session.getMarkers()
|
||||
markers[marker_id] = marker
|
||||
|
||||
expected_markers = []
|
||||
for change in @changesTracker.changes
|
||||
if @changeIdToMarkerIdMap[change.id]?
|
||||
op = change.op
|
||||
marker_id = @changeIdToMarkerIdMap[change.id]
|
||||
|
||||
{background_marker_id, callout_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
|
||||
expected_markers.push { marker_id: background_marker_id, start, end }
|
||||
expected_markers.push { marker_id: callout_marker_id, start, end: start }
|
||||
|
||||
for comment in @changesTracker.comments
|
||||
if @changeIdToMarkerIdMap[comment.id]?
|
||||
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
|
||||
start = @_shareJsOffsetToAcePosition(comment.offset)
|
||||
end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
|
||||
expected_markers.push { marker_id: background_marker_id, start, end }
|
||||
expected_markers.push { marker_id: callout_marker_id, start, end: start }
|
||||
|
||||
for {marker_id, start, end} in expected_markers
|
||||
marker = markers[marker_id]
|
||||
delete markers[marker_id]
|
||||
if marker.range.start.row != start.row or
|
||||
|
@ -63,348 +267,168 @@ define [
|
|||
if marker.clazz.match("track-changes")
|
||||
console.error "Orphaned ace marker", marker
|
||||
|
||||
applyChange: (delta) ->
|
||||
applyChange: (delta, metadata) ->
|
||||
op = @_aceChangeToShareJs(delta)
|
||||
console.log "Applying change", delta, op
|
||||
@changesTracker.applyOp(op)
|
||||
@changesTracker.applyOp(op, metadata)
|
||||
|
||||
updateFocus: () ->
|
||||
selection = @editor.getSelectionRange()
|
||||
cursor_offset = @_aceRangeToShareJs(selection.start)
|
||||
entries = @_getCurrentDocEntries()
|
||||
selection = !(selection.start.column == selection.end.column and selection.start.row == selection.end.row)
|
||||
@$scope.$emit "editor:focus:changed", cursor_offset, selection
|
||||
|
||||
broadcastChange: () ->
|
||||
@$scope.$emit "editor:track-changes:changed", @$scope.docId
|
||||
|
||||
recalculateReviewEntriesScreenPositions: () ->
|
||||
session = @editor.getSession()
|
||||
renderer = @editor.renderer
|
||||
entries = @_getCurrentDocEntries()
|
||||
for entry_id, entry of entries or {}
|
||||
doc_position = @_shareJsOffsetToAcePosition(entry.offset)
|
||||
screen_position = session.documentToScreenPosition(doc_position.row, doc_position.column)
|
||||
y = screen_position.row * renderer.lineHeight
|
||||
entry.screenPos ?= {}
|
||||
entry.screenPos.y = y
|
||||
entry.docPos = doc_position
|
||||
|
||||
@$scope.$apply()
|
||||
|
||||
_getCurrentDocEntries: () ->
|
||||
doc_id = @$scope.docId
|
||||
entries = @$scope.reviewPanel.entries[doc_id] ?= {}
|
||||
return entries
|
||||
|
||||
_makeZeroWidthRange: (position) ->
|
||||
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.
|
||||
# We do want to claim to be empty if we're off screen after clipping rows though.
|
||||
# This is the code we need to trick:
|
||||
# var range = marker.range.clipRows(config.firstRow, config.lastRow);
|
||||
# if (range.isEmpty()) continue;
|
||||
ace_range.clipRows = (first_row, last_row) ->
|
||||
@isEmpty = () ->
|
||||
first_row > @end.row or last_row < @start.row
|
||||
return @
|
||||
return ace_range
|
||||
|
||||
_createCalloutMarker: (position, klass) ->
|
||||
session = @editor.getSession()
|
||||
callout_range = @_makeZeroWidthRange(position)
|
||||
markerLayer = @editor.renderer.$markerBack
|
||||
callout_marker_id = session.addMarker callout_range, klass, (html, range, left, top, config) ->
|
||||
markerLayer.drawSingleLineMarker(html, range, "track-changes-marker-callout #{klass} ace_start", config, 0, "width: auto; right: 0;")
|
||||
|
||||
_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
|
||||
background_range = new Range(start.row, start.column, end.row, end.column)
|
||||
background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-added-marker", "text"
|
||||
callout_marker_id = @_createCalloutMarker(start, "track-changes-added-marker-callout")
|
||||
@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
|
||||
@broadcastChange()
|
||||
|
||||
_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
|
||||
markerLayer = @editor.renderer.$markerBack
|
||||
klass = "track-changes-marker track-changes-deleted-marker"
|
||||
background_range = @_makeZeroWidthRange(position)
|
||||
background_marker_id = session.addMarker background_range, klass, (html, range, left, top, config) ->
|
||||
markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, "")
|
||||
|
||||
marker_id = session.addMarker(ace_range, "track-changes-deleted-marker", "text")
|
||||
@changeIdToMarkerIdMap[change.id] = marker_id
|
||||
callout_marker_id = @_createCalloutMarker(position, "track-changes-deleted-marker-callout")
|
||||
@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
|
||||
@broadcastChange()
|
||||
|
||||
_onInsertRemoved: (change) ->
|
||||
marker_id = @changeIdToMarkerIdMap[change.id]
|
||||
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
|
||||
delete @changeIdToMarkerIdMap[change.id]
|
||||
session = @editor.getSession()
|
||||
session.removeMarker marker_id
|
||||
session.removeMarker background_marker_id
|
||||
session.removeMarker callout_marker_id
|
||||
@broadcastChange()
|
||||
|
||||
_onDeleteRemoved: (change) ->
|
||||
marker_id = @changeIdToMarkerIdMap[change.id]
|
||||
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
|
||||
delete @changeIdToMarkerIdMap[change.id]
|
||||
session = @editor.getSession()
|
||||
session.removeMarker marker_id
|
||||
session.removeMarker background_marker_id
|
||||
session.removeMarker callout_marker_id
|
||||
@broadcastChange()
|
||||
|
||||
_onCommentAdded: (comment) ->
|
||||
if !@changeIdToMarkerIdMap[comment.id]?
|
||||
# Only create new markers if they don't already exist
|
||||
start = @_shareJsOffsetToAcePosition(comment.offset)
|
||||
end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
|
||||
session = @editor.getSession()
|
||||
doc = session.getDocument()
|
||||
background_range = new Range(start.row, start.column, end.row, end.column)
|
||||
background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-comment-marker", "text"
|
||||
callout_marker_id = @_createCalloutMarker(start, "track-changes-comment-marker-callout")
|
||||
@changeIdToMarkerIdMap[comment.id] = { background_marker_id, callout_marker_id }
|
||||
@broadcastChange()
|
||||
|
||||
_onCommentRemoved: (comment) ->
|
||||
if @changeIdToMarkerIdMap[comment.id]?
|
||||
# Resolved comments may not have marker ids
|
||||
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
|
||||
delete @changeIdToMarkerIdMap[comment.id]
|
||||
session = @editor.getSession()
|
||||
session.removeMarker background_marker_id
|
||||
session.removeMarker callout_marker_id
|
||||
@broadcastChange()
|
||||
|
||||
_aceRangeToShareJs: (range) ->
|
||||
lines = @editor.getSession().getDocument().getLines 0, range.row
|
||||
return AceShareJsCodec.aceRangeToShareJs(range, lines)
|
||||
|
||||
_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}"
|
||||
lines = @editor.getSession().getDocument().getLines 0, delta.start.row
|
||||
return AceShareJsCodec.aceChangeToShareJs(delta, lines)
|
||||
|
||||
_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}
|
||||
return AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
|
||||
|
||||
_onChangesMoved: (changes) ->
|
||||
session = @editor.getSession()
|
||||
markers = session.getMarkers()
|
||||
# TODO: PERFORMANCE: Only run through the Ace lines once, and calculate all
|
||||
# change positions as we go.
|
||||
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
|
||||
@_updateMarker(change.id, start, end)
|
||||
@editor.renderer.updateBackMarkers()
|
||||
@broadcastChange()
|
||||
|
||||
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
|
||||
_onCommentMoved: (comment) ->
|
||||
start = @_shareJsOffsetToAcePosition(comment.offset)
|
||||
end = @_shareJsOffsetToAcePosition(comment.offset + comment.length)
|
||||
@_updateMarker(comment.id, start, end)
|
||||
@editor.renderer.updateBackMarkers()
|
||||
@broadcastChange()
|
||||
|
||||
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)
|
||||
_updateMarker: (change_id, start, end) ->
|
||||
return if !@changeIdToMarkerIdMap[change_id]?
|
||||
session = @editor.getSession()
|
||||
markers = session.getMarkers()
|
||||
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change_id]
|
||||
if background_marker_id?
|
||||
background_marker = markers[background_marker_id]
|
||||
background_marker.range.start = start
|
||||
background_marker.range.end = end
|
||||
if callout_marker_id?
|
||||
callout_marker = markers[callout_marker_id]
|
||||
callout_marker.range.start = start
|
||||
callout_marker.range.end = start
|
||||
|
||||
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
|
|
@ -207,15 +207,18 @@ define [
|
|||
return doc.split("\n")
|
||||
|
||||
_aceDeltaSetsToSimpleDeltaSets: (aceDeltaSets, docLines) ->
|
||||
simpleDeltaSets = []
|
||||
for deltaSet in aceDeltaSets
|
||||
if deltaSet.group == "doc" # ignore fold changes
|
||||
simpleDeltas = []
|
||||
for delta in deltaSet.deltas
|
||||
simpleDeltas.push @_aceDeltaToSimpleDelta(delta, docLines)
|
||||
docLines = @_applyAceDeltasToDocLines([delta], docLines)
|
||||
{
|
||||
simpleDeltaSets.push {
|
||||
deltas: simpleDeltas
|
||||
group: deltaSet.group
|
||||
}
|
||||
return simpleDeltaSets
|
||||
|
||||
_simpleDeltaSetsToAceDeltaSets: (simpleDeltaSets, docLines) ->
|
||||
for deltaSet in simpleDeltaSets
|
||||
|
@ -231,6 +234,17 @@ define [
|
|||
|
||||
_aceDeltaToSimpleDelta: (aceDelta, docLines) ->
|
||||
start = aceDelta.start
|
||||
if !start?
|
||||
JSONstringifyWithCycles = (o) ->
|
||||
seen = []
|
||||
return JSON.stringify o, (k,v) ->
|
||||
if (typeof v == 'object')
|
||||
if ( seen.indexOf(v) >= 0 )
|
||||
return '__cycle__'
|
||||
seen.push(v);
|
||||
return v
|
||||
error = new Error("aceDelta had no start event: #{JSONstringifyWithCycles(aceDelta)}")
|
||||
throw error
|
||||
linesBefore = docLines.slice(0, start.row)
|
||||
position =
|
||||
linesBefore.join("").length + # full lines
|
||||
|
|
|
@ -64,7 +64,7 @@ class Doc
|
|||
server_ = @type.transform server, client, 'right'
|
||||
return [client_, server_]
|
||||
|
||||
_otApply: (docOp, isRemote) ->
|
||||
_otApply: (docOp, isRemote, msg) ->
|
||||
oldSnapshot = @snapshot
|
||||
@snapshot = @type.apply(@snapshot, docOp)
|
||||
|
||||
|
@ -72,7 +72,7 @@ class Doc
|
|||
# The reason is that the OT type APIs might need to access the snapshots to
|
||||
# determine information about the received op.
|
||||
@emit 'change', docOp, oldSnapshot
|
||||
@emit 'remoteop', docOp, oldSnapshot if isRemote
|
||||
@emit 'remoteop', docOp, oldSnapshot, msg if isRemote
|
||||
|
||||
_connectionStateChanged: (state, data) ->
|
||||
switch state
|
||||
|
@ -185,7 +185,7 @@ class Doc
|
|||
# functionality, because its really a local op. Basically, the problem is that
|
||||
# if the client's op is rejected by the server, the editor window should update
|
||||
# to reflect the undo.
|
||||
@_otApply undo, true
|
||||
@_otApply undo, true, msg
|
||||
else
|
||||
@emit 'error', "Op apply failed (#{error}) and the op could not be reverted"
|
||||
|
||||
|
@ -234,7 +234,7 @@ class Doc
|
|||
|
||||
@version++
|
||||
# Finally, apply the op to @snapshot and trigger any event listeners
|
||||
@_otApply docOp, true
|
||||
@_otApply docOp, true, msg
|
||||
|
||||
else if msg.meta
|
||||
{path, value} = msg.meta
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
define [
|
||||
"moment"
|
||||
"ide/colors/ColorManager"
|
||||
"ide/history/controllers/HistoryListController"
|
||||
"ide/history/controllers/HistoryDiffController"
|
||||
"ide/history/directives/infiniteScroll"
|
||||
], (moment) ->
|
||||
], (moment, ColorManager) ->
|
||||
class HistoryManager
|
||||
constructor: (@ide, @$scope) ->
|
||||
@reset()
|
||||
|
@ -172,13 +173,13 @@ define [
|
|||
highlights.push {
|
||||
label: "Added by #{name} on #{date}"
|
||||
highlight: range
|
||||
hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
|
||||
hue: ColorManager.getHueForUserId(entry.meta.user?.id)
|
||||
}
|
||||
else if entry.d?
|
||||
highlights.push {
|
||||
label: "Deleted by #{name} on #{date}"
|
||||
strikeThrough: range
|
||||
hue: @ide.onlineUsersManager.getHueForUserId(entry.meta.user?.id)
|
||||
hue: ColorManager.getHueForUserId(entry.meta.user?.id)
|
||||
}
|
||||
|
||||
return {text, highlights}
|
||||
|
@ -192,7 +193,7 @@ define [
|
|||
|
||||
for user in update.meta.users or []
|
||||
if user?
|
||||
user.hue = @ide.onlineUsersManager.getHueForUserId(user.id)
|
||||
user.hue = ColorManager.getHueForUserId(user.id)
|
||||
|
||||
if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
|
||||
update.meta.first_in_day = true
|
||||
|
|
|
@ -50,7 +50,7 @@ define -> [
|
|||
regexToMatch: /No positions in optional float specifier/
|
||||
extraInfoURL: "https://www.sharelatex.com/learn/Errors/No_positions_in_optional_float_specifier"
|
||||
humanReadableHint: """
|
||||
You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \begin{figure}[h]), or remove the square brackets (e.g. \begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
|
||||
You have forgotten to include a float specifier, which tells LaTeX where to position your figure. To fix this, either insert a float specifier inside the square brackets (e.g. \\begin{figure}[h]), or remove the square brackets (e.g. \\begin{figure}). Find out more about float specifiers <a target=\"_blank\" href=\"https://www.sharelatex.com/learn/Positioning_of_Figures\">here</a>.
|
||||
"""
|
||||
,
|
||||
regexToMatch: /Undefined control sequence/
|
||||
|
@ -162,7 +162,7 @@ define -> [
|
|||
regexToMatch: /LaTeX Error: \\verb ended by end of line/
|
||||
extraInfoURL: "https://www.sharelatex.com/learn/Errors/LaTeX_Error:_%5Cverb_ended_by_end_of_line"
|
||||
humanReadableHint: """
|
||||
You have used a \\verb command incorrectly. Try replacling the \\verb command with \begin{verbatim}\u2026\end{verbatim}.
|
||||
You have used a \\verb command incorrectly. Try replacling the \\verb command with \\begin{verbatim}\u2026\\end{verbatim}.
|
||||
"""
|
||||
,
|
||||
regexToMatch: /Illegal unit of measure (pt inserted)/
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
define [
|
||||
"ide/colors/ColorManager"
|
||||
"libs/md5"
|
||||
"ide/online-users/controllers/OnlineUsersController"
|
||||
], () ->
|
||||
], (ColorManager) ->
|
||||
class OnlineUsersManager
|
||||
|
||||
cursorUpdateInterval:500
|
||||
|
@ -46,7 +47,7 @@ define [
|
|||
@refreshOnlineUsers()
|
||||
|
||||
@$scope.getHueForUserId = (user_id) =>
|
||||
@getHueForUserId(user_id)
|
||||
ColorManager.getHueForUserId(user_id)
|
||||
|
||||
refreshOnlineUsers: () ->
|
||||
@$scope.onlineUsersArray = []
|
||||
|
@ -74,7 +75,7 @@ define [
|
|||
cursor:
|
||||
row: client.row
|
||||
column: client.column
|
||||
hue: @getHueForUserId(client.user_id)
|
||||
hue: ColorManager.getHueForUserId(client.user_id)
|
||||
}
|
||||
|
||||
if @$scope.onlineUsersArray.length > 0
|
||||
|
@ -101,19 +102,3 @@ define [
|
|||
delete @cursorUpdateTimeout
|
||||
, @cursorUpdateInterval
|
||||
|
||||
OWN_HUE: 200 # We will always appear as this color to ourselves
|
||||
ANONYMOUS_HUE: 100
|
||||
getHueForUserId: (user_id) ->
|
||||
if !user_id? or user_id == "anonymous-user"
|
||||
return @ANONYMOUS_HUE
|
||||
|
||||
if window.user.id == user_id
|
||||
return @OWN_HUE
|
||||
|
||||
hash = CryptoJS.MD5(user_id)
|
||||
hue = parseInt(hash.toString().slice(0,8), 16) % 320
|
||||
# Avoid 20 degrees either side of the personal hue
|
||||
if hue > @OWNER_HUE - 20
|
||||
hue = hue + 40
|
||||
return hue
|
||||
|
||||
|
|
|
@ -469,7 +469,7 @@ define [
|
|||
|
||||
event_tracking.sendMB "subscription-start-trial", { source }
|
||||
|
||||
window.open("/user/subscription/new?planCode=student_free_trial_7_days")
|
||||
window.open("/user/subscription/new?planCode=#{$scope.startTrialPlanCode}")
|
||||
$scope.startedFreeTrial = true
|
||||
|
||||
App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
|
||||
|
|
|
@ -30,6 +30,10 @@ define [
|
|||
TEXTLAYER_TIMEOUT: 100
|
||||
|
||||
constructor: (@url, @options) ->
|
||||
# set up external character mappings - needed for Japanese etc
|
||||
window.PDFJS.cMapUrl = './bcmaps/'
|
||||
window.PDFJS.cMapPacked = true
|
||||
|
||||
if window.location?.search?.indexOf("disable-font-face=true") >= 0
|
||||
window.PDFJS.disableFontFace = true
|
||||
else
|
||||
|
|
|
@ -17,7 +17,12 @@ define [
|
|||
# When we join the project:
|
||||
# index all references files
|
||||
# and don't broadcast to all clients
|
||||
@inited = false
|
||||
@$scope.$on 'project:joined', (e) =>
|
||||
# We only need to grab the references when the editor first loads,
|
||||
# not on every reconnect
|
||||
if !@inited
|
||||
@inited = true
|
||||
@indexAllReferences(false)
|
||||
|
||||
setTimeout(
|
||||
|
|
|
@ -0,0 +1,455 @@
|
|||
define [
|
||||
"utils/EventEmitter"
|
||||
], (EventEmitter) ->
|
||||
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'
|
||||
# * Deletes by another user will consume deletes by the first user
|
||||
# * Inserts by another user will not combine with inserts by the first user. If they are in the
|
||||
# middle of a previous insert by the first user, the original insert will be split into two.
|
||||
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 = []
|
||||
@comments = []
|
||||
@id = 0
|
||||
|
||||
addComment: (offset, length, metadata) ->
|
||||
# TODO: Don't allow overlapping comments?
|
||||
@comments.push comment = {
|
||||
id: @_newId()
|
||||
offset, length, metadata
|
||||
}
|
||||
@emit "comment:added", comment
|
||||
return comment
|
||||
|
||||
getComment: (comment_id) ->
|
||||
comment = null
|
||||
for c in @comments
|
||||
if c.id == comment_id
|
||||
comment = c
|
||||
break
|
||||
return comment
|
||||
|
||||
resolveCommentId: (comment_id, resolved_data) ->
|
||||
comment = @getComment(comment_id)
|
||||
return if !comment?
|
||||
comment.metadata.resolved = true
|
||||
comment.metadata.resolved_data = resolved_data
|
||||
@emit "comment:resolved", comment
|
||||
|
||||
unresolveCommentId: (comment_id) ->
|
||||
comment = @getComment(comment_id)
|
||||
return if !comment?
|
||||
comment.metadata.resolved = false
|
||||
@emit "comment:unresolved", comment
|
||||
|
||||
removeCommentId: (comment_id) ->
|
||||
comment = @getComment(comment_id)
|
||||
return if !comment?
|
||||
@comments = @comments.filter (c) -> c.id != comment_id
|
||||
@emit "comment:removed", comment
|
||||
|
||||
getChange: (change_id) ->
|
||||
change = null
|
||||
for c in @changes
|
||||
if c.id == change_id
|
||||
change = c
|
||||
break
|
||||
return change
|
||||
|
||||
removeChangeId: (change_id) ->
|
||||
change = @getChange(change_id)
|
||||
return if !change?
|
||||
@_removeChange(change)
|
||||
|
||||
applyOp: (op, metadata) ->
|
||||
metadata.ts ?= new Date()
|
||||
# Apply an op that has been applied to the document to our changes to keep them up to date
|
||||
if op.i?
|
||||
@applyInsertToChanges(op, metadata)
|
||||
@applyInsertToComments(op)
|
||||
else if op.d?
|
||||
@applyDeleteToChanges(op, metadata)
|
||||
@applyDeleteToComments(op)
|
||||
|
||||
applyInsertToComments: (op) ->
|
||||
for comment in @comments
|
||||
if op.p <= comment.offset
|
||||
comment.offset += op.i.length
|
||||
@emit "comment:moved", comment
|
||||
else if op.p < comment.offset + comment.length
|
||||
comment.length += op.i.length
|
||||
@emit "comment:moved", comment
|
||||
|
||||
applyDeleteToComments: (op) ->
|
||||
op_start = op.p
|
||||
op_length = op.d.length
|
||||
op_end = op.p + op_length
|
||||
for comment in @comments
|
||||
comment_end = comment.offset + comment.length
|
||||
if op_end <= comment.offset
|
||||
# delete is fully before comment
|
||||
comment.offset -= op_length
|
||||
@emit "comment:moved", comment
|
||||
else if op_start >= comment_end
|
||||
# delete is fully after comment, nothing to do
|
||||
else
|
||||
# delete and comment overlap
|
||||
delete_length_before = Math.max(0, comment.offset - op_start)
|
||||
delete_length_after = Math.max(0, op_end - comment_end)
|
||||
delete_length_overlapping = op_length - delete_length_before - delete_length_after
|
||||
comment.offset = Math.min(comment.offset, op_start)
|
||||
comment.length -= delete_length_overlapping
|
||||
@emit "comment:moved", comment
|
||||
|
||||
applyInsertToChanges: (op, metadata) ->
|
||||
op_start = op.p
|
||||
op_length = op.i.length
|
||||
op_end = op.p + op_length
|
||||
|
||||
already_merged = false
|
||||
previous_change = null
|
||||
moved_changes = []
|
||||
remove_changes = []
|
||||
new_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 op_start == change_start
|
||||
# If the insert matches the start of the delete, just remove it from the delete instead
|
||||
if change.op.d.length >= op.i.length and change.op.d.slice(0, op.i.length) == op.i
|
||||
change.op.d = change.op.d.slice(op.i.length)
|
||||
change.op.p += op.i.length
|
||||
if change.op.d == ""
|
||||
remove_changes.push change
|
||||
else
|
||||
moved_changes.push change
|
||||
already_merged = true
|
||||
else
|
||||
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)
|
||||
|
||||
# Only merge inserts if they are from the same user
|
||||
is_same_user = metadata.user_id == change.metadata.user_id
|
||||
|
||||
# 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 @track_changes and
|
||||
is_change_overlapping and
|
||||
!is_insert_blocked_by_delete and
|
||||
!already_merged and
|
||||
is_same_user
|
||||
offset = op_start - change_start
|
||||
change.op.i = change.op.i.slice(0, offset) + op.i + change.op.i.slice(offset)
|
||||
change.metadata.ts = metadata.ts
|
||||
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
|
||||
else if (!is_same_user or !@track_changes) and change_start < op_start < change_end
|
||||
# This user is inserting inside a change by another user, so we need to split the
|
||||
# other user's change into one before and after this one.
|
||||
offset = op_start - change_start
|
||||
before_content = change.op.i.slice(0, offset)
|
||||
after_content = change.op.i.slice(offset)
|
||||
|
||||
# The existing change can become the 'before' change
|
||||
change.op.i = before_content
|
||||
moved_changes.push change
|
||||
|
||||
# Create a new op afterwards
|
||||
after_change = {
|
||||
op: {
|
||||
i: after_content
|
||||
p: change_start + offset + op_length
|
||||
}
|
||||
metadata: {}
|
||||
}
|
||||
after_change.metadata[key] = value for key, value of change.metadata
|
||||
new_changes.push after_change
|
||||
|
||||
previous_change = change
|
||||
|
||||
if @track_changes and !already_merged
|
||||
@_addOp op, metadata
|
||||
for {op, metadata} in new_changes
|
||||
@_addOp op, metadata
|
||||
|
||||
for change in remove_changes
|
||||
@_removeChange change
|
||||
|
||||
if moved_changes.length > 0
|
||||
@emit "changes:moved", moved_changes
|
||||
|
||||
applyDeleteToChanges: (op, metadata) ->
|
||||
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)
|
||||
change.metadata.ts = metadata.ts
|
||||
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 or (!@track_changes and op_end == change_start)
|
||||
# Shift ops after us back by our length.
|
||||
# If we're tracking changes, it must be strictly before, since we'll merge
|
||||
# below if they are touching. Otherwise, touching is fine.
|
||||
change.op.p -= op_length
|
||||
moved_changes.push change
|
||||
else if op_start <= change_start <= op_end
|
||||
if @track_changes
|
||||
# If we overlap a delete, add it in our content, and delete the existing change.
|
||||
# It's easier to do it this way, rather than modifying the existing delete in case
|
||||
# we overlap many deletes and we'd need to track that. We have a workaround to
|
||||
# update the delete in place if possible below.
|
||||
offset = change_start - op_start
|
||||
op_modifications.push { i: change.op.d, p: offset }
|
||||
remove_changes.push change
|
||||
else
|
||||
change.op.p = op_start
|
||||
moved_changes.push change
|
||||
|
||||
# Copy rather than modify because we still need to apply it to comments
|
||||
op = {
|
||||
p: op.p
|
||||
d: @_applyOpModifications(op.d, op_modifications)
|
||||
}
|
||||
|
||||
for change in remove_changes
|
||||
# This is a bit of hack to avoid removing one delete and replacing it with another.
|
||||
# If we don't do this, it causes the UI to flicker
|
||||
if op.d.length > 0 and change.op.d? and op.p <= change.op.p <= op.p + op.d.length
|
||||
change.op.p = op.p
|
||||
change.op.d = op.d
|
||||
change.metadata = metadata
|
||||
moved_changes.push change
|
||||
op.d = "" # stop it being added
|
||||
else
|
||||
@_removeChange change
|
||||
|
||||
if @track_changes and op.d.length > 0
|
||||
@_addOp op, metadata
|
||||
else
|
||||
# It's possible that we deleted an insert between two other inserts. I.e.
|
||||
# If we delete 'user_2 insert' in:
|
||||
# |-- user_1 insert --||-- user_2 insert --||-- user_1 insert --|
|
||||
# it becomes:
|
||||
# |-- user_1 insert --||-- user_1 insert --|
|
||||
# We need to merge these together again
|
||||
results = @_scanAndMergeAdjacentUpdates()
|
||||
moved_changes = moved_changes.concat(results.moved_changes)
|
||||
for change in results.remove_changes
|
||||
@_removeChange change
|
||||
moved_changes = moved_changes.filter (c) -> c != change
|
||||
|
||||
if moved_changes.length > 0
|
||||
@emit "changes:moved", moved_changes
|
||||
|
||||
_newId: () ->
|
||||
(@id++).toString()
|
||||
|
||||
_addOp: (op, metadata) ->
|
||||
change = {
|
||||
id: @_newId()
|
||||
op: op
|
||||
metadata: metadata
|
||||
}
|
||||
@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
|
||||
|
||||
_scanAndMergeAdjacentUpdates: () ->
|
||||
# This should only need calling when deleting an update between two
|
||||
# other updates. There's no other way to get two adjacent updates from the
|
||||
# same user, since they would be merged on insert.
|
||||
previous_change = null
|
||||
remove_changes = []
|
||||
moved_changes = []
|
||||
for change in @changes
|
||||
if previous_change?.op.i? and change.op.i?
|
||||
previous_change_end = previous_change.op.p + previous_change.op.i.length
|
||||
previous_change_user_id = previous_change.metadata.user_id
|
||||
change_start = change.op.p
|
||||
change_user_id = change.metadata.user_id
|
||||
if previous_change_end == change_start and previous_change_user_id == change_user_id
|
||||
remove_changes.push change
|
||||
previous_change.op.i += change.op.i
|
||||
moved_changes.push previous_change
|
||||
else if previous_change?.op.d? and change.op.d? and previous_change.op.p == change.op.p
|
||||
# Merge adjacent deletes
|
||||
previous_change.op.d += change.op.d
|
||||
remove_changes.push change
|
||||
moved_changes.push previous_change
|
||||
else # Only update to the current change if we haven't removed it.
|
||||
previous_change = change
|
||||
return { moved_changes, remove_changes }
|
|
@ -0,0 +1,9 @@
|
|||
define [
|
||||
"ide/review-panel/controllers/ReviewPanelController"
|
||||
"ide/review-panel/directives/reviewPanelSorted"
|
||||
"ide/review-panel/directives/reviewPanelToggle"
|
||||
"ide/review-panel/directives/changeEntry"
|
||||
"ide/review-panel/directives/commentEntry"
|
||||
"ide/review-panel/directives/addCommentEntry"
|
||||
"ide/review-panel/filters/orderOverviewEntries"
|
||||
], () ->
|
|
@ -0,0 +1,383 @@
|
|||
define [
|
||||
"base",
|
||||
"utils/EventEmitter"
|
||||
"ide/colors/ColorManager"
|
||||
"ide/review-panel/ChangesTracker"
|
||||
], (App, EventEmitter, ColorManager, ChangesTracker) ->
|
||||
App.controller "ReviewPanelController", ($scope, $element, ide, $timeout) ->
|
||||
$reviewPanelEl = $element.find "#review-panel"
|
||||
|
||||
$scope.SubViews =
|
||||
CUR_FILE : "cur_file"
|
||||
OVERVIEW : "overview"
|
||||
|
||||
$scope.reviewPanel =
|
||||
entries: {}
|
||||
trackNewChanges: false
|
||||
hasEntries: false
|
||||
subView: $scope.SubViews.CUR_FILE
|
||||
openSubView: $scope.SubViews.CUR_FILE
|
||||
|
||||
$scope.commentState =
|
||||
adding: false
|
||||
content: ""
|
||||
|
||||
$scope.reviewPanelEventsBridge = new EventEmitter()
|
||||
|
||||
changesTrackers = {}
|
||||
|
||||
getDocEntries = (doc_id) ->
|
||||
$scope.reviewPanel.entries[doc_id] ?= {}
|
||||
return $scope.reviewPanel.entries[doc_id]
|
||||
|
||||
getChangeTracker = (doc_id) ->
|
||||
changesTrackers[doc_id] ?= new ChangesTracker()
|
||||
return changesTrackers[doc_id]
|
||||
|
||||
# TODO Just for prototyping purposes; remove afterwards.
|
||||
mockedUserId = 'mock_user_id_1'
|
||||
mockedUserId2 = 'mock_user_id_2'
|
||||
|
||||
if window.location.search.match /mocktc=true/
|
||||
mock_changes = {
|
||||
"main.tex":
|
||||
changes: [{
|
||||
op: { i: "Habitat loss and conflicts with humans are the greatest causes of concern.", p: 925 - 38 }
|
||||
metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 30 * 60 * 1000) }
|
||||
}, {
|
||||
op: { d: "The lion is now a vulnerable species. ", p: 778 }
|
||||
metadata: { user_id: mockedUserId, ts: new Date(Date.now() - 31 * 60 * 1000) }
|
||||
}]
|
||||
comments: [{
|
||||
offset: 1375 - 38
|
||||
length: 79
|
||||
metadata:
|
||||
thread: [{
|
||||
content: "Do we have a source for this?"
|
||||
user_id: mockedUserId
|
||||
ts: new Date(Date.now() - 45 * 60 * 1000)
|
||||
}]
|
||||
}]
|
||||
"chapter_1.tex":
|
||||
changes: [{
|
||||
"op":{"p":740,"d":", to take down large animals"},
|
||||
"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 15 * 60 * 1000)}
|
||||
}, {
|
||||
"op":{"i":", to keep hold of the prey","p":920},
|
||||
"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 130 * 60 * 1000)}
|
||||
}, {
|
||||
"op":{"i":" being","p":1057},
|
||||
"metadata":{"user_id":mockedUserId2, ts: new Date(Date.now() - 72 * 60 * 1000)}
|
||||
}]
|
||||
comments:[{
|
||||
"offset":111,"length":5,
|
||||
"metadata":{
|
||||
"thread": [
|
||||
{"content":"Have we used 'pride' too much here?","user_id":mockedUserId, ts: new Date(Date.now() - 12 * 60 * 1000)},
|
||||
{"content":"No, I think this is OK","user_id":mockedUserId2, ts: new Date(Date.now() - 9 * 60 * 1000)}
|
||||
]
|
||||
}
|
||||
},{
|
||||
"offset":452,"length":21,
|
||||
"metadata":{
|
||||
"thread":[
|
||||
{"content":"TODO: Don't use as many parentheses!","user_id":mockedUserId2, ts: new Date(Date.now() - 99 * 60 * 1000)}
|
||||
]
|
||||
}
|
||||
}]
|
||||
"chapter_2.tex":
|
||||
changes: [{
|
||||
"op":{"p":458,"d":"other"},
|
||||
"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 133 * 60 * 1000)}
|
||||
},{
|
||||
"op":{"i":"usually 2-3, ","p":928},
|
||||
"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 27 * 60 * 1000)}
|
||||
},{
|
||||
"op":{"i":"If the parents are a male lion and a female tiger, it is called a liger. A tigon comes from a male tiger and a female lion.","p":1126},
|
||||
"metadata":{"user_id":mockedUserId, ts: new Date(Date.now() - 152 * 60 * 1000)}
|
||||
}]
|
||||
comments: [{
|
||||
"offset":299,"length":10,
|
||||
"metadata":{
|
||||
"thread":[{
|
||||
"content":"Should we use a different word here if 'den' needs clarifying?","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000)
|
||||
}]
|
||||
}
|
||||
},{
|
||||
"offset":843,"length":66,
|
||||
"metadata":{
|
||||
"thread":[{
|
||||
"content":"This sentence is a little ambiguous","user_id":mockedUserId,"ts": new Date(Date.now() - 430 * 60 * 1000)
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}
|
||||
ide.$scope.$on "file-tree:initialized", () ->
|
||||
ide.fileTreeManager.forEachEntity (entity) ->
|
||||
if mock_changes[entity.name]?
|
||||
changesTracker = getChangeTracker(entity.id)
|
||||
for change in mock_changes[entity.name].changes
|
||||
changesTracker._addOp change.op, change.metadata
|
||||
for comment in mock_changes[entity.name].comments
|
||||
changesTracker.addComment comment.offset, comment.length, comment.metadata
|
||||
for doc_id, changesTracker of changesTrackers
|
||||
updateEntries(doc_id)
|
||||
|
||||
scrollbar = {}
|
||||
$scope.reviewPanelEventsBridge.on "aceScrollbarVisibilityChanged", (isVisible, scrollbarWidth) ->
|
||||
scrollbar = {isVisible, scrollbarWidth}
|
||||
updateScrollbar()
|
||||
|
||||
updateScrollbar = () ->
|
||||
if scrollbar.isVisible and $scope.reviewPanel.subView == $scope.SubViews.CUR_FILE
|
||||
$reviewPanelEl.css "right", "#{ scrollbar.scrollbarWidth }px"
|
||||
else
|
||||
$reviewPanelEl.css "right", "0"
|
||||
|
||||
$scope.$watch "reviewPanel.subView", (subView) ->
|
||||
return if !subView?
|
||||
updateScrollbar()
|
||||
|
||||
$scope.$watch "ui.reviewPanelOpen", (open) ->
|
||||
return if !open?
|
||||
if !open
|
||||
# Always show current file when not open, but save current state
|
||||
$scope.reviewPanel.openSubView = $scope.reviewPanel.subView
|
||||
$scope.reviewPanel.subView = $scope.SubViews.CUR_FILE
|
||||
else
|
||||
# Reset back to what we had when previously open
|
||||
$scope.reviewPanel.subView = $scope.reviewPanel.openSubView
|
||||
|
||||
$scope.$watch "editor.open_doc_id", (open_doc_id) ->
|
||||
return if !open_doc_id?
|
||||
changesTrackers[open_doc_id] ?= new ChangesTracker()
|
||||
$scope.reviewPanel.changesTracker = changesTrackers[open_doc_id]
|
||||
|
||||
$scope.$watch (() ->
|
||||
entries = $scope.reviewPanel.entries[$scope.editor.open_doc_id] or {}
|
||||
Object.keys(entries).length
|
||||
), (nEntries) ->
|
||||
$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.trackChangesFeatureFlag
|
||||
|
||||
$scope.$watch "ui.reviewPanelOpen", (reviewPanelOpen) ->
|
||||
return if !reviewPanelOpen?
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:toggle"
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
updateEntries = (doc_id) ->
|
||||
changesTracker = getChangeTracker(doc_id)
|
||||
entries = getDocEntries(doc_id)
|
||||
|
||||
# Assume we'll delete everything until we see it, then we'll remove it from this object
|
||||
delete_changes = {}
|
||||
delete_changes[change_id] = true for change_id, change of entries
|
||||
|
||||
for change in changesTracker.changes
|
||||
delete delete_changes[change.id]
|
||||
entries[change.id] ?= {}
|
||||
|
||||
# Update in place to avoid a full DOM redraw via angular
|
||||
metadata = {}
|
||||
metadata[key] = value for key, value of change.metadata
|
||||
new_entry = {
|
||||
type: if change.op.i then "insert" else "delete"
|
||||
content: change.op.i or change.op.d
|
||||
offset: change.op.p
|
||||
metadata: change.metadata
|
||||
}
|
||||
for key, value of new_entry
|
||||
entries[change.id][key] = value
|
||||
|
||||
for comment in changesTracker.comments
|
||||
delete delete_changes[comment.id]
|
||||
entries[comment.id] ?= {}
|
||||
new_entry = {
|
||||
type: "comment"
|
||||
thread: comment.metadata.thread
|
||||
resolved: comment.metadata.resolved
|
||||
resolved_data: comment.metadata.resolved_data
|
||||
offset: comment.offset
|
||||
length: comment.length
|
||||
}
|
||||
for key, value of new_entry
|
||||
entries[comment.id][key] = value
|
||||
|
||||
for change_id, _ of delete_changes
|
||||
delete entries[change_id]
|
||||
|
||||
$scope.$on "editor:track-changes:changed", () ->
|
||||
doc_id = $scope.editor.open_doc_id
|
||||
updateEntries(doc_id)
|
||||
$scope.$broadcast "review-panel:recalculate-screen-positions"
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.$on "editor:focus:changed", (e, cursor_offset, selection) ->
|
||||
doc_id = $scope.editor.open_doc_id
|
||||
entries = getDocEntries(doc_id)
|
||||
|
||||
if !selection
|
||||
delete entries["add-comment"]
|
||||
else
|
||||
entries["add-comment"] = {
|
||||
type: "add-comment"
|
||||
offset: cursor_offset
|
||||
}
|
||||
|
||||
for id, entry of entries
|
||||
if entry.type == "comment" and not entry.resolved
|
||||
entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.length)
|
||||
else if entry.type == "insert"
|
||||
entry.focused = (entry.offset <= cursor_offset <= entry.offset + entry.content.length)
|
||||
else if entry.type == "delete"
|
||||
entry.focused = (entry.offset == cursor_offset)
|
||||
else if entry.type == "add-comment" and selection
|
||||
entry.focused = true
|
||||
|
||||
$scope.$broadcast "review-panel:recalculate-screen-positions"
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.acceptChange = (entry_id) ->
|
||||
$scope.$broadcast "change:accept", entry_id
|
||||
|
||||
$scope.rejectChange = (entry_id) ->
|
||||
$scope.$broadcast "change:reject", entry_id
|
||||
|
||||
$scope.startNewComment = () ->
|
||||
# $scope.commentState.adding = true
|
||||
$scope.$broadcast "comment:select_line"
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.submitNewComment = (content) ->
|
||||
# $scope.commentState.adding = false
|
||||
$scope.$broadcast "comment:add", content
|
||||
# $scope.commentState.content = ""
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.cancelNewComment = (entry) ->
|
||||
# $scope.commentState.adding = false
|
||||
# $scope.commentState.content = ""
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.startReply = (entry) ->
|
||||
entry.replying = true
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
# $scope.handleCommentReplyKeyPress = (ev, entry) ->
|
||||
# if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
|
||||
# ev.preventDefault()
|
||||
# ev.target.blur()
|
||||
# $scope.submitReply(entry)
|
||||
|
||||
$scope.submitReply = (entry, entry_id) ->
|
||||
$scope.unresolveComment(entry_id)
|
||||
entry.thread.push {
|
||||
content: entry.replyContent
|
||||
ts: new Date()
|
||||
user_id: window.user_id
|
||||
}
|
||||
entry.replyContent = ""
|
||||
entry.replying = false
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
# TODO Just for prototyping purposes; remove afterwards
|
||||
window.setTimeout((() ->
|
||||
$scope.$applyAsync(() -> submitMockedReply(entry))
|
||||
), 1000 * 2)
|
||||
|
||||
# TODO Just for prototyping purposes; remove afterwards.
|
||||
submitMockedReply = (entry) ->
|
||||
entry.thread.push {
|
||||
content: 'Sounds good!'
|
||||
ts: new Date()
|
||||
user_id: mockedUserId
|
||||
}
|
||||
entry.replyContent = ""
|
||||
entry.replying = false
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.cancelReply = (entry) ->
|
||||
entry.replying = false
|
||||
entry.replyContent = ""
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.resolveComment = (entry, entry_id) ->
|
||||
entry.showWhenResolved = false
|
||||
entry.focused = false
|
||||
$scope.$broadcast "comment:resolve", entry_id, window.user_id
|
||||
|
||||
$scope.unresolveComment = (entry_id) ->
|
||||
$scope.$broadcast "comment:unresolve", entry_id
|
||||
|
||||
$scope.deleteComment = (entry_id) ->
|
||||
$scope.$broadcast "comment:remove", entry_id
|
||||
|
||||
$scope.showThread = (entry) ->
|
||||
entry.showWhenResolved = true
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.hideThread = (entry) ->
|
||||
entry.showWhenResolved = false
|
||||
$timeout () ->
|
||||
$scope.$broadcast "review-panel:layout"
|
||||
|
||||
$scope.setSubView = (subView) ->
|
||||
$scope.reviewPanel.subView = subView
|
||||
|
||||
$scope.gotoEntry = (doc_id, entry) ->
|
||||
ide.editorManager.openDocId(doc_id, { gotoOffset: entry.offset })
|
||||
|
||||
DOC_ID_NAMES = {}
|
||||
$scope.getFileName = (doc_id) ->
|
||||
# This is called a lot and is relatively expensive, so cache the result
|
||||
if !DOC_ID_NAMES[doc_id]?
|
||||
entity = ide.fileTreeManager.findEntityById(doc_id)
|
||||
return if !entity?
|
||||
DOC_ID_NAMES[doc_id] = ide.fileTreeManager.getEntityPath(entity)
|
||||
return DOC_ID_NAMES[doc_id]
|
||||
|
||||
# TODO: Eventually we need to get this from the server, and update it
|
||||
# when we get an id we don't know. This'll do for client side testing
|
||||
refreshUsers = () ->
|
||||
$scope.users = {}
|
||||
# TODO Just for prototyping purposes; remove afterwards.
|
||||
$scope.users[mockedUserId] = {
|
||||
email: "paulo@sharelatex.com"
|
||||
name: "Paulo Reis"
|
||||
isSelf: false
|
||||
hue: 70
|
||||
avatar_text: "PR"
|
||||
}
|
||||
$scope.users[mockedUserId2] = {
|
||||
email: "james@sharelatex.com"
|
||||
name: "James Allen"
|
||||
isSelf: false
|
||||
hue: 320
|
||||
avatar_text: "JA"
|
||||
}
|
||||
|
||||
for member in $scope.project.members.concat($scope.project.owner)
|
||||
if member._id == window.user_id
|
||||
name = "You"
|
||||
isSelf = true
|
||||
else
|
||||
name = "#{member.first_name} #{member.last_name}"
|
||||
isSelf = false
|
||||
|
||||
$scope.users[member._id] = {
|
||||
email: member.email
|
||||
name: name
|
||||
isSelf: isSelf
|
||||
hue: ColorManager.getHueForUserId(member._id)
|
||||
avatar_text: [member.first_name, member.last_name].filter((n) -> n?).map((n) -> n[0]).join ""
|
||||
}
|
||||
|
||||
$scope.$watch "project.members", (members) ->
|
||||
return if !members?
|
||||
refreshUsers()
|
|
@ -0,0 +1,35 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.directive "addCommentEntry", () ->
|
||||
restrict: "E"
|
||||
templateUrl: "addCommentEntryTemplate"
|
||||
scope:
|
||||
onStartNew: "&"
|
||||
onSubmit: "&"
|
||||
onCancel: "&"
|
||||
onIndicatorClick: "&"
|
||||
link: (scope, element, attrs) ->
|
||||
scope.state =
|
||||
isAdding: false
|
||||
content: ""
|
||||
|
||||
scope.startNewComment = () ->
|
||||
scope.state.isAdding = true
|
||||
scope.onStartNew()
|
||||
|
||||
scope.cancelNewComment = () ->
|
||||
scope.state.isAdding = false
|
||||
scope.onCancel()
|
||||
|
||||
scope.handleCommentKeyPress = (ev) ->
|
||||
if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
|
||||
ev.preventDefault()
|
||||
ev.target.blur()
|
||||
scope.submitNewComment()
|
||||
|
||||
scope.submitNewComment = () ->
|
||||
console.log scope.state.content
|
||||
scope.onSubmit { content: scope.state.content }
|
||||
scope.state.isAdding = false
|
||||
scope.state.content = ""
|
|
@ -0,0 +1,13 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.directive "changeEntry", () ->
|
||||
restrict: "E"
|
||||
templateUrl: "changeEntryTemplate"
|
||||
scope:
|
||||
entry: "="
|
||||
user: "="
|
||||
onAccept: "&"
|
||||
onReject: "&"
|
||||
onIndicatorClick: "&"
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.directive "commentEntry", () ->
|
||||
restrict: "E"
|
||||
templateUrl: "commentEntryTemplate"
|
||||
scope:
|
||||
entry: "="
|
||||
users: "="
|
||||
onResolve: "&"
|
||||
onReply: "&"
|
||||
onIndicatorClick: "&"
|
||||
onDelete: "&"
|
||||
onUnresolve: "&"
|
||||
onShowThread: "&"
|
||||
onHideThread: "&"
|
||||
link: (scope, element, attrs) ->
|
||||
scope.handleCommentReplyKeyPress = (ev) ->
|
||||
if ev.keyCode == 13 and !ev.shiftKey and !ev.ctrlKey and !ev.metaKey
|
||||
ev.preventDefault()
|
||||
ev.target.blur()
|
||||
scope.onReply()
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.directive "reviewPanelSorted", ($timeout) ->
|
||||
return {
|
||||
link: (scope, element, attrs) ->
|
||||
previous_focused_entry_index = 0
|
||||
|
||||
layout = () ->
|
||||
sl_console.log "LAYOUT"
|
||||
if scope.ui.reviewPanelOpen
|
||||
PADDING = 8
|
||||
TOOLBAR_HEIGHT = 38
|
||||
else
|
||||
PADDING = 4
|
||||
TOOLBAR_HEIGHT = 4
|
||||
|
||||
entries = []
|
||||
for el in element.find(".rp-entry-wrapper")
|
||||
entry = {
|
||||
$indicator_el: $(el).find(".rp-entry-indicator")
|
||||
$box_el: $(el).find(".rp-entry")
|
||||
$callout_el: $(el).find(".rp-entry-callout")
|
||||
scope: angular.element(el).scope()
|
||||
}
|
||||
if scope.ui.reviewPanelOpen
|
||||
entry.$layout_el = entry.$box_el
|
||||
else
|
||||
entry.$layout_el = entry.$indicator_el
|
||||
entries.push entry
|
||||
entries.sort (a,b) -> a.scope.entry.offset - b.scope.entry.offset
|
||||
|
||||
return if entries.length == 0
|
||||
|
||||
focused_entry_index = Math.min(previous_focused_entry_index, entries.length - 1)
|
||||
for entry, i in entries
|
||||
if entry.scope.entry.focused
|
||||
focused_entry_index = i
|
||||
break
|
||||
entries_after = entries.slice(focused_entry_index + 1)
|
||||
entries_before = entries.slice(0, focused_entry_index)
|
||||
focused_entry = entries[focused_entry_index]
|
||||
previous_focused_entry_index = focused_entry_index
|
||||
|
||||
sl_console.log "focused_entry_index", focused_entry_index
|
||||
|
||||
line_height = 15
|
||||
|
||||
# Put the focused entry exactly where it wants to be
|
||||
focused_entry_top = Math.max(TOOLBAR_HEIGHT, focused_entry.scope.entry.screenPos.y)
|
||||
focused_entry.$box_el.css(top: focused_entry_top)
|
||||
focused_entry.$indicator_el.css(top: focused_entry_top)
|
||||
focused_entry.$callout_el.css(top: focused_entry_top + line_height, height: 0)
|
||||
|
||||
previousBottom = focused_entry_top + focused_entry.$layout_el.height()
|
||||
for entry in entries_after
|
||||
original_top = entry.scope.entry.screenPos.y
|
||||
height = entry.$layout_el.height()
|
||||
top = Math.max(original_top, previousBottom + PADDING)
|
||||
previousBottom = top + height
|
||||
entry.$box_el.css(top: top)
|
||||
entry.$indicator_el.css(top: top)
|
||||
entry.$callout_el.removeClass("rp-entry-callout-inverted")
|
||||
entry.$callout_el.css(top: original_top + line_height, height: top - original_top)
|
||||
sl_console.log "ENTRY", {entry: entry.scope.entry, top}
|
||||
|
||||
previousTop = focused_entry_top
|
||||
entries_before.reverse() # Work through backwards, starting with the one just above
|
||||
for entry in entries_before
|
||||
original_top = entry.scope.entry.screenPos.y
|
||||
height = entry.$layout_el.height()
|
||||
original_bottom = original_top + height
|
||||
bottom = Math.min(original_bottom, previousTop - PADDING)
|
||||
top = bottom - height
|
||||
previousTop = top
|
||||
entry.$box_el.css(top: top)
|
||||
entry.$indicator_el.css(top: top)
|
||||
entry.$callout_el.addClass("rp-entry-callout-inverted")
|
||||
entry.$callout_el.css(top: top + line_height + 1, height: original_top - top)
|
||||
sl_console.log "ENTRY", {entry: entry.scope.entry, top}
|
||||
|
||||
scope.$applyAsync () ->
|
||||
layout()
|
||||
|
||||
scope.$on "review-panel:layout", () ->
|
||||
scope.$applyAsync () ->
|
||||
layout()
|
||||
|
||||
## Scroll lock with Ace
|
||||
scroller = element
|
||||
list = element.find(".rp-entry-list-inner")
|
||||
|
||||
# If we listen for scroll events in the review panel natively, then with a Mac trackpad
|
||||
# the scroll is very smooth (natively done I'd guess), but we don't get polled regularly
|
||||
# enough to keep Ace in step, and it noticeably lags. If instead, we borrow the manual
|
||||
# mousewheel/trackpad scrolling behaviour from Ace, and turn mousewheel events into
|
||||
# scroll events ourselves, then it makes the review panel slightly less smooth (barely)
|
||||
# noticeable, but keeps it perfectly in step with Ace.
|
||||
ace.require("ace/lib/event").addMouseWheelListener scroller[0], (e) ->
|
||||
deltaY = e.wheelY
|
||||
old_top = parseInt(list.css("top"))
|
||||
top = Math.min(0, old_top - deltaY * 4)
|
||||
list.css(top: top)
|
||||
scrollAce(-top)
|
||||
e.preventDefault()
|
||||
|
||||
# Use these to avoid unnecessary updates. Scrolling one
|
||||
# panel causes us to scroll the other panel, but there's no
|
||||
# need to trigger the event back to the original panel.
|
||||
ignoreNextPanelEvent = false
|
||||
ignoreNextAceEvent = false
|
||||
|
||||
scrollPanel = (scrollTop, height) ->
|
||||
if ignoreNextAceEvent
|
||||
ignoreNextAceEvent = false
|
||||
else
|
||||
ignoreNextPanelEvent = true
|
||||
list.height(height)
|
||||
# console.log({height, scrollTop, top: height - scrollTop})
|
||||
list.css(top: - scrollTop)
|
||||
|
||||
scrollAce = (scrollTop) ->
|
||||
if ignoreNextPanelEvent
|
||||
ignoreNextPanelEvent = false
|
||||
else
|
||||
ignoreNextAceEvent = true
|
||||
scope.reviewPanelEventsBridge.emit "externalScroll", scrollTop
|
||||
|
||||
scope.reviewPanelEventsBridge.on "aceScroll", scrollPanel
|
||||
scope.$on "$destroy", () ->
|
||||
scope.reviewPanelEventsBridge.off "aceScroll"
|
||||
|
||||
scope.reviewPanelEventsBridge.emit "refreshScrollPosition"
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.directive "reviewPanelToggle", () ->
|
||||
restrict: "E"
|
||||
scope:
|
||||
innerModel: '=ngModel'
|
||||
template: """
|
||||
<div class="rp-toggle">
|
||||
<input id="rp-toggle-{{$id}}" type="checkbox" class="rp-toggle-hidden-input" ng-model="innerModel" />
|
||||
<label for="rp-toggle-{{$id}}" class="rp-toggle-btn"></label>
|
||||
</div>
|
||||
"""
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.filter "orderOverviewEntries", () ->
|
||||
(items) ->
|
||||
array = []
|
||||
for key, value of items
|
||||
value.entry_id = key
|
||||
array.push value
|
||||
array.sort (a, b) -> a.offset - b.offset
|
||||
return array
|
|
@ -40,6 +40,19 @@ define [
|
|||
message: -> message
|
||||
}
|
||||
|
||||
ide.showLockEditorMessageModal = (title, message) ->
|
||||
# modal to block the editor when connection is down
|
||||
$modal.open {
|
||||
templateUrl: "lockEditorModalTemplate"
|
||||
controller: "GenericMessageModalController"
|
||||
backdrop: "static" # prevent dismiss by click on background
|
||||
keyboard: false # prevent dismiss via keyboard
|
||||
resolve:
|
||||
title: -> title
|
||||
message: -> message
|
||||
windowClass: "lock-editor-modal"
|
||||
}
|
||||
|
||||
return ide
|
||||
]
|
||||
|
||||
|
|
|
@ -30,7 +30,7 @@ define [
|
|||
|
||||
$scope.$watch "settings.syntaxValidation", (syntaxValidation, oldSyntaxValidation) =>
|
||||
if syntaxValidation != oldSyntaxValidation
|
||||
settings.saveProjectSettings({syntaxValidation: syntaxValidation})
|
||||
settings.saveSettings({syntaxValidation: syntaxValidation})
|
||||
|
||||
$scope.$watch "project.spellCheckLanguage", (language, oldLanguage) =>
|
||||
return if @ignoreUpdates
|
||||
|
|
|
@ -8,6 +8,7 @@ define [
|
|||
}
|
||||
$scope.state = {
|
||||
error: null
|
||||
errorReason: null
|
||||
inflight: false
|
||||
startedFreeTrial: false
|
||||
invites: []
|
||||
|
@ -19,11 +20,16 @@ define [
|
|||
, 200
|
||||
|
||||
INFINITE_COLLABORATORS = -1
|
||||
$scope.$watch "(project.members.length + project.invites.length)", (noOfMembers) ->
|
||||
allowedNoOfMembers = $scope.project.features.collaborators
|
||||
$scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
|
||||
|
||||
window._m = projectMembers
|
||||
$scope.refreshCanAddCollaborators = () ->
|
||||
allowedNoOfMembers = $scope.project.features.collaborators
|
||||
$scope.canAddCollaborators = (
|
||||
($scope.project.members.length + $scope.project.invites.length) < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
|
||||
)
|
||||
$scope.refreshCanAddCollaborators()
|
||||
|
||||
$scope.$watch "(project.members.length + project.invites.length)", (_noOfMembers) ->
|
||||
$scope.refreshCanAddCollaborators()
|
||||
|
||||
$scope.autocompleteContacts = []
|
||||
do loadAutocompleteUsers = () ->
|
||||
|
@ -64,7 +70,8 @@ define [
|
|||
|
||||
members = $scope.inputs.contacts
|
||||
$scope.inputs.contacts = []
|
||||
$scope.state.error = null
|
||||
$scope.state.error = false
|
||||
$scope.state.errorReason = null
|
||||
$scope.state.inflight = true
|
||||
|
||||
if !$scope.project.invites?
|
||||
|
@ -96,6 +103,11 @@ define [
|
|||
|
||||
request
|
||||
.success (data) ->
|
||||
if data.error
|
||||
$scope.state.error = true
|
||||
$scope.state.errorReason = "#{data.error}"
|
||||
$scope.state.inflight = false
|
||||
else
|
||||
if data.invite
|
||||
invite = data.invite
|
||||
$scope.project.invites.push invite
|
||||
|
@ -116,6 +128,7 @@ define [
|
|||
.error () ->
|
||||
$scope.state.inflight = false
|
||||
$scope.state.error = true
|
||||
$scope.state.errorReason = null
|
||||
|
||||
$timeout addMembers, 50 # Give email list a chance to update
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ define [
|
|||
"main/subscription-dashboard"
|
||||
"main/new-subscription"
|
||||
"main/annual-upgrade"
|
||||
"main/announcements"
|
||||
"main/register-users"
|
||||
"main/subscription/group-subscription-invite-controller"
|
||||
"main/contact-us"
|
||||
|
@ -32,4 +33,18 @@ define [
|
|||
"filters/formatDate"
|
||||
"__MAIN_CLIENTSIDE_INCLUDES__"
|
||||
], () ->
|
||||
angular.bootstrap(document.body, ["SharelatexApp"])
|
||||
angular.module('SharelatexApp').config(
|
||||
($locationProvider) ->
|
||||
try
|
||||
$locationProvider.html5Mode({
|
||||
enabled: false,
|
||||
requireBase: false,
|
||||
rewriteLinks: false
|
||||
})
|
||||
catch e
|
||||
console.error "Error while trying to fix '#' links: ", e
|
||||
)
|
||||
angular.bootstrap(
|
||||
document.body,
|
||||
["SharelatexApp"]
|
||||
)
|
||||
|
|
|
@ -32,7 +32,10 @@ define [
|
|||
$scope.state =
|
||||
isValid : false
|
||||
deleteText: ""
|
||||
password: ""
|
||||
inflight: false
|
||||
error: false
|
||||
invalidCredentials: false
|
||||
|
||||
$modalInstance.opened.then () ->
|
||||
$timeout () ->
|
||||
|
@ -40,20 +43,38 @@ define [
|
|||
, 700
|
||||
|
||||
$scope.checkValidation = ->
|
||||
$scope.state.isValid = $scope.state.deleteText == $scope.email
|
||||
$scope.state.isValid = $scope.state.deleteText == $scope.email and $scope.state.password.length > 0
|
||||
|
||||
$scope.delete = () ->
|
||||
$scope.state.inflight = true
|
||||
|
||||
$scope.state.error = false
|
||||
$scope.state.invalidCredentials = false
|
||||
$http({
|
||||
method: "DELETE"
|
||||
url: "/user"
|
||||
method: "POST"
|
||||
url: "/user/delete"
|
||||
headers:
|
||||
"X-CSRF-Token": window.csrfToken
|
||||
"Content-Type": 'application/json'
|
||||
data:
|
||||
password: $scope.state.password
|
||||
disableAutoLoginRedirect: true # we want to handle errors ourselves
|
||||
})
|
||||
.success () ->
|
||||
$modalInstance.close()
|
||||
window.location = "/"
|
||||
$scope.state.inflight = false
|
||||
$scope.state.error = false
|
||||
$scope.state.invalidCredentials = false
|
||||
setTimeout(
|
||||
() ->
|
||||
window.location = "/login"
|
||||
, 1000
|
||||
)
|
||||
.error (data, status) ->
|
||||
$scope.state.inflight = false
|
||||
if status == 403
|
||||
$scope.state.invalidCredentials = true
|
||||
else
|
||||
$scope.state.error = true
|
||||
|
||||
$scope.cancel = () ->
|
||||
$modalInstance.dismiss('cancel')
|
||||
|
|
|
@ -6,14 +6,34 @@ define [
|
|||
$scope.buttonClass = "btn-primary"
|
||||
|
||||
$scope.startFreeTrial = (source, couponCode) ->
|
||||
event_tracking.sendMB "subscription-start-trial", { source }
|
||||
plan = 'collaborator_free_trial_7_days'
|
||||
|
||||
w = window.open()
|
||||
sixpack.convert "track-changes-discount", ->
|
||||
sixpack.participate 'in-editor-free-trial-plan', ['student', 'collaborator'], (planName, rawResponse)->
|
||||
go = () ->
|
||||
ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', source)
|
||||
url = "/user/subscription/new?planCode=#{planName}_free_trial_7_days&ssp=#{planName == 'collaborator'}"
|
||||
url = "/user/subscription/new?planCode=#{plan}&ssp=true"
|
||||
if couponCode?
|
||||
url = "#{url}&cc=#{couponCode}"
|
||||
$scope.startedFreeTrial = true
|
||||
|
||||
switch source
|
||||
when "dropbox"
|
||||
sixpack.participate 'teaser-dropbox-text', ['default', 'dropbox-focused'], (variant) ->
|
||||
event_tracking.sendMB "subscription-start-trial", { source, plan, variant }
|
||||
|
||||
when "history"
|
||||
sixpack.participate 'teaser-history', ['default', 'focused'], (variant) ->
|
||||
event_tracking.sendMB "subscription-start-trial", { source, plan, variant }
|
||||
|
||||
else
|
||||
event_tracking.sendMB "subscription-start-trial", { source, plan }
|
||||
|
||||
w.location = url
|
||||
|
||||
if $scope.shouldABTestPlans
|
||||
sixpack.participate 'plans-1610', ['default', 'heron', 'ibis'], (chosenVariation, rawResponse)->
|
||||
if chosenVariation in ['heron', 'ibis']
|
||||
plan = "collaborator_#{chosenVariation}"
|
||||
go()
|
||||
else
|
||||
go()
|
||||
|
|
29
services/web/public/coffee/main/announcements.coffee
Normal file
29
services/web/public/coffee/main/announcements.coffee
Normal file
|
@ -0,0 +1,29 @@
|
|||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.controller "AnnouncementsController", ($scope, $http, event_tracking, $window, _) ->
|
||||
$scope.announcements = []
|
||||
$scope.ui =
|
||||
isOpen: false
|
||||
newItems: 0
|
||||
|
||||
refreshAnnouncements = ->
|
||||
$http.get("/announcements").success (announcements) ->
|
||||
$scope.announcements = announcements
|
||||
$scope.ui.newItems = _.filter(announcements, (announcement) -> !announcement.read).length
|
||||
|
||||
markAnnouncementsAsRead = ->
|
||||
event_tracking.sendMB "announcement-alert-dismissed", { blogPostId: $scope.announcements[0].id }
|
||||
|
||||
refreshAnnouncements()
|
||||
|
||||
$scope.toggleAnnouncementsUI = ->
|
||||
$scope.ui.isOpen = !$scope.ui.isOpen
|
||||
|
||||
if !$scope.ui.isOpen and $scope.ui.newItems
|
||||
$scope.ui.newItems = 0
|
||||
markAnnouncementsAsRead()
|
||||
|
||||
$scope.showAll = ->
|
||||
$scope.ui.newItems = 0
|
||||
|
|
@ -53,16 +53,6 @@ define [
|
|||
@sendMB key, segmentation
|
||||
}
|
||||
|
||||
# App.directive "countlyTrack", () ->
|
||||
# return {
|
||||
# restrict: "A"
|
||||
# scope: false,
|
||||
# link: (scope, el, attrs) ->
|
||||
# eventKey = attrs.countlyTrack
|
||||
# if (eventKey?)
|
||||
# el.on "click", () ->
|
||||
# console.log eventKey
|
||||
# }
|
||||
|
||||
#header
|
||||
$('.navbar a').on "click", (e)->
|
||||
|
|
|
@ -10,7 +10,10 @@ define [
|
|||
$scope.plans = MultiCurrencyPricing.plans
|
||||
|
||||
$scope.switchToStudent = ()->
|
||||
window.location = "/user/subscription/new?planCode=student_free_trial_7_days¤cy=#{$scope.currencyCode}&cc=#{$scope.data.coupon}"
|
||||
currentPlanCode = window.plan_code
|
||||
planCode = currentPlanCode.replace('collaborator', 'student')
|
||||
event_tracking.sendMB 'subscription-form-switch-to-student', { plan: window.plan_code }
|
||||
window.location = "/user/subscription/new?planCode=#{planCode}¤cy=#{$scope.currencyCode}&cc=#{$scope.data.coupon}"
|
||||
|
||||
event_tracking.sendMB "subscription-form", { plan : window.plan_code }
|
||||
|
||||
|
|
|
@ -10,6 +10,165 @@ define [
|
|||
|
||||
return {
|
||||
currencyCode:currencyCode
|
||||
|
||||
heron:
|
||||
USD:
|
||||
student:
|
||||
monthly: "$6"
|
||||
annual: "$60"
|
||||
collaborator:
|
||||
monthly: "$12"
|
||||
annual: "$144"
|
||||
EUR:
|
||||
student:
|
||||
monthly: "€5"
|
||||
annual: "€50"
|
||||
collaborator:
|
||||
monthly: "€11"
|
||||
annual: "€132"
|
||||
GBP:
|
||||
student:
|
||||
monthly: "£5"
|
||||
annual: "£50"
|
||||
collaborator:
|
||||
monthly: "£10"
|
||||
annual: "£120"
|
||||
SEK:
|
||||
student:
|
||||
monthly: "45 kr"
|
||||
annual: "450 kr"
|
||||
collaborator:
|
||||
monthly: "90 kr"
|
||||
annual: "1080 kr"
|
||||
CAD:
|
||||
student:
|
||||
monthly: "$7"
|
||||
annual: "$70"
|
||||
collaborator:
|
||||
monthly: "$14"
|
||||
annual: "$168"
|
||||
NOK:
|
||||
student:
|
||||
monthly: "45 kr"
|
||||
annual: "450 kr"
|
||||
collaborator:
|
||||
monthly: "90 kr"
|
||||
annual: "1080 kr"
|
||||
DKK:
|
||||
student:
|
||||
monthly: "40 kr"
|
||||
annual: "400 kr"
|
||||
collaborator:
|
||||
monthly: "70 kr"
|
||||
annual: "840 kr"
|
||||
AUD:
|
||||
student:
|
||||
monthly: "$8"
|
||||
annual: "$80"
|
||||
collaborator:
|
||||
monthly: "$15"
|
||||
annual: "$180"
|
||||
NZD:
|
||||
student:
|
||||
monthly: "$8"
|
||||
annual: "$80"
|
||||
collaborator:
|
||||
monthly: "$15"
|
||||
annual: "$180"
|
||||
CHF:
|
||||
student:
|
||||
monthly: "Fr 6"
|
||||
annual: "Fr 60"
|
||||
collaborator:
|
||||
monthly: "Fr 12"
|
||||
annual: "Fr 144"
|
||||
SGD:
|
||||
student:
|
||||
monthly: "$8"
|
||||
annual: "$80"
|
||||
collaborator:
|
||||
monthly: "$16"
|
||||
annual: "$192"
|
||||
|
||||
ibis:
|
||||
USD:
|
||||
student:
|
||||
monthly: "$10"
|
||||
annual: "$100"
|
||||
collaborator:
|
||||
monthly: "$18"
|
||||
annual: "$216"
|
||||
EUR:
|
||||
student:
|
||||
monthly: "€9"
|
||||
annual: "€90"
|
||||
collaborator:
|
||||
monthly: "€17"
|
||||
annual: "€204"
|
||||
GBP:
|
||||
student:
|
||||
monthly: "£7"
|
||||
annual: "£70"
|
||||
collaborator:
|
||||
monthly: "£14"
|
||||
annual: "£168"
|
||||
SEK:
|
||||
student:
|
||||
monthly: "75 kr"
|
||||
annual: "750 kr"
|
||||
collaborator:
|
||||
monthly: "140 kr"
|
||||
annual: "1680 kr"
|
||||
CAD:
|
||||
student:
|
||||
monthly: "$12"
|
||||
annual: "$120"
|
||||
collaborator:
|
||||
monthly: "$22"
|
||||
annual: "$264"
|
||||
NOK:
|
||||
student:
|
||||
monthly: "75 kr"
|
||||
annual: "750 kr"
|
||||
collaborator:
|
||||
monthly: "140 kr"
|
||||
annual: "1680 kr"
|
||||
DKK:
|
||||
student:
|
||||
monthly: "68 kr"
|
||||
annual: "680 kr"
|
||||
collaborator:
|
||||
monthly: "110 kr"
|
||||
annual: "1320 kr"
|
||||
AUD:
|
||||
student:
|
||||
monthly: "$13"
|
||||
annual: "$130"
|
||||
collaborator:
|
||||
monthly: "$22"
|
||||
annual: "$264"
|
||||
NZD:
|
||||
student:
|
||||
monthly: "$14"
|
||||
annual: "$140"
|
||||
collaborator:
|
||||
monthly: "$22"
|
||||
annual: "$264"
|
||||
CHF:
|
||||
student:
|
||||
monthly: "Fr 10"
|
||||
annual: "Fr 100"
|
||||
collaborator:
|
||||
monthly: "Fr 18"
|
||||
annual: "Fr 216"
|
||||
SGD:
|
||||
student:
|
||||
monthly: "$14"
|
||||
annual: "$140"
|
||||
collaborator:
|
||||
monthly: "$25"
|
||||
annual: "$300"
|
||||
|
||||
plans:
|
||||
USD:
|
||||
symbol: "$"
|
||||
|
@ -141,30 +300,63 @@ define [
|
|||
professional:
|
||||
monthly: "$40"
|
||||
annual: "$480"
|
||||
|
||||
}
|
||||
|
||||
|
||||
App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http, sixpack) ->
|
||||
|
||||
$scope.showPlans = false
|
||||
|
||||
App.controller "PlansController", ($scope, $modal, event_tracking, abTestManager, MultiCurrencyPricing, $http) ->
|
||||
$scope.plansVariant = 'default'
|
||||
$scope.shouldABTestPlans = window.shouldABTestPlans
|
||||
|
||||
if $scope.shouldABTestPlans
|
||||
sixpack.participate 'plans-1610', ['default', 'heron', 'ibis'], (chosenVariation, rawResponse)->
|
||||
$scope.plansVariant = chosenVariation
|
||||
event_tracking.sendMB 'plans-page', {plans_variant: chosenVariation}
|
||||
if chosenVariation in ['heron', 'ibis']
|
||||
# overwrite student plans with alternative
|
||||
for currency, _v of $scope.plans
|
||||
$scope.plans[currency]['student'] = MultiCurrencyPricing[chosenVariation][currency]['student']
|
||||
$scope.plans[currency]['collaborator'] = MultiCurrencyPricing[chosenVariation][currency]['collaborator']
|
||||
$scope.showPlans = true
|
||||
else
|
||||
$scope.showPlans = true
|
||||
|
||||
$scope.plans = MultiCurrencyPricing.plans
|
||||
|
||||
$scope.currencyCode = MultiCurrencyPricing.currencyCode
|
||||
|
||||
$scope.trial_len = 7
|
||||
|
||||
$scope.planQueryString = '_free_trial_7_days'
|
||||
|
||||
$scope.ui =
|
||||
view: "monthly"
|
||||
|
||||
|
||||
$scope.changeCurreny = (newCurrency)->
|
||||
$scope.currencyCode = newCurrency
|
||||
|
||||
# because ternary logic in angular bindings is hard
|
||||
$scope.getCollaboratorPlanCode = () ->
|
||||
view = $scope.ui.view
|
||||
variant = $scope.plansVariant
|
||||
if view == "annual"
|
||||
if variant == "default"
|
||||
return "collaborator-annual"
|
||||
else
|
||||
return "collaborator-annual_#{variant}"
|
||||
else
|
||||
if variant == "default"
|
||||
return "collaborator#{$scope.planQueryString}"
|
||||
else
|
||||
return "collaborator_#{variant}"
|
||||
|
||||
$scope.signUpNowClicked = (plan, annual)->
|
||||
event_tracking.sendMB 'plans-page-start-trial', {plan}
|
||||
if $scope.ui.view == "annual"
|
||||
plan = "#{plan}_annual"
|
||||
|
||||
event_tracking.send 'subscription-funnel', 'sign_up_now_button', plan
|
||||
|
||||
$scope.switchToMonthly = ->
|
||||
|
|
|
@ -99,7 +99,7 @@ define [
|
|||
visible = true
|
||||
# Only show if it matches any search text
|
||||
if $scope.searchText.value? and $scope.searchText.value != ""
|
||||
if !project.name.toLowerCase().match($scope.searchText.value.toLowerCase())
|
||||
if project.name.toLowerCase().indexOf($scope.searchText.value.toLowerCase()) == -1
|
||||
visible = false
|
||||
# Only show if it matches the selected tag
|
||||
if $scope.filter == "tag" and selectedTag? and project.id not in selectedTag.project_ids
|
||||
|
|
|
@ -1,15 +1,21 @@
|
|||
define [
|
||||
"base"
|
||||
], (App)->
|
||||
|
||||
App.controller 'SuccessfulSubscriptionController', ($scope, sixpack) ->
|
||||
sixpack.convert 'plans-1610', () ->
|
||||
|
||||
|
||||
SUBSCRIPTION_URL = "/user/subscription/update"
|
||||
|
||||
setupReturly = _.once ->
|
||||
recurly?.configure window.recurlyApiKey
|
||||
PRICES = {}
|
||||
|
||||
|
||||
App.controller "CurrenyDropdownController", ($scope, MultiCurrencyPricing, $q)->
|
||||
|
||||
$scope.plans = MultiCurrencyPricing.plans
|
||||
# $scope.plans = MultiCurrencyPricing.plans
|
||||
$scope.currencyCode = MultiCurrencyPricing.currencyCode
|
||||
|
||||
$scope.changeCurrency = (newCurrency)->
|
||||
|
@ -31,7 +37,7 @@ define [
|
|||
$scope.currencyCode = MultiCurrencyPricing.currencyCode
|
||||
|
||||
$scope.pricing = MultiCurrencyPricing
|
||||
$scope.plans = MultiCurrencyPricing.plans
|
||||
# $scope.plans = MultiCurrencyPricing.plans
|
||||
$scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol
|
||||
|
||||
$scope.currencyCode = MultiCurrencyPricing.currencyCode
|
||||
|
@ -87,6 +93,8 @@ define [
|
|||
|
||||
|
||||
App.controller "UserSubscriptionController", ($scope, MultiCurrencyPricing, $http, sixpack, $modal) ->
|
||||
$scope.plans = MultiCurrencyPricing.plans
|
||||
|
||||
freeTrialEndDate = new Date(subscription?.trial_ends_at)
|
||||
|
||||
sevenDaysTime = new Date()
|
||||
|
@ -96,6 +104,16 @@ define [
|
|||
freeTrialExpiresUnderSevenDays = freeTrialEndDate < sevenDaysTime
|
||||
|
||||
$scope.view = 'overview'
|
||||
$scope.getSuffix = (planCode) ->
|
||||
planCode?.match(/(.*?)_(.*)/)?[2] || null
|
||||
$scope.subscriptionSuffix = $scope.getSuffix(window?.subscription?.planCode)
|
||||
if $scope.subscriptionSuffix == 'free_trial_7_days'
|
||||
$scope.subscriptionSuffix = ''
|
||||
$scope.isNextGenPlan = $scope.subscriptionSuffix in ['heron', 'ibis']
|
||||
|
||||
$scope.shouldShowPlan = (planCode) ->
|
||||
$scope.getSuffix(planCode) not in ['heron', 'ibis']
|
||||
|
||||
isMonthlyCollab = subscription?.planCode?.indexOf("collaborator") != -1 and subscription?.planCode?.indexOf("ann") == -1
|
||||
stillInFreeTrial = freeTrialInFuture and freeTrialExpiresUnderSevenDays
|
||||
|
||||
|
@ -135,8 +153,6 @@ define [
|
|||
$scope.inflight = true
|
||||
$http.post("/user/subscription/cancel", body)
|
||||
.success ->
|
||||
sixpack.convert 'cancelation-options-view', ->
|
||||
sixpack.convert 'upgrade-success-message', ->
|
||||
location.reload()
|
||||
.error ->
|
||||
console.log "something went wrong changing plan"
|
||||
|
@ -151,9 +167,7 @@ define [
|
|||
)
|
||||
|
||||
$scope.switchToCancelationView = ->
|
||||
sixpack.participate 'cancelation-options-view', ['basic', 'downgrade-options'], (view, rawResponse)->
|
||||
$scope.view = "cancelation"
|
||||
$scope.sixpackOpt = view
|
||||
|
||||
|
||||
|
||||
|
@ -166,6 +180,3 @@ define [
|
|||
location.reload()
|
||||
.error ->
|
||||
console.log "something went wrong changing plan"
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -9,6 +9,26 @@ app.config ['$provide', ($provide) ->
|
|||
]
|
||||
]
|
||||
|
||||
# TODO: add support for an errorHttpInterceptor to catch failing ajax
|
||||
# requests as described at
|
||||
# Interceptor to check auth failures in all $http requests
|
||||
# http://bahmutov.calepin.co/catch-all-errors-in-angular-app.html
|
||||
|
||||
app.factory 'unAuthHttpResponseInterceptor', ['$q','$location', ($q, $location) ->
|
||||
responseError: (response) ->
|
||||
# redirect any unauthorised or forbidden responses back to /login
|
||||
#
|
||||
# set disableAutoLoginRedirect:true in the http request config
|
||||
# to disable this behaviour
|
||||
if response.status in [401, 403] and not response.config?.disableAutoLoginRedirect
|
||||
# for /project urls set the ?redir parameter to come back here
|
||||
# otherwise just go to the login page
|
||||
if window.location.pathname.match(/^\/project/)
|
||||
window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
|
||||
else
|
||||
window.location = "/login"
|
||||
# pass the response back to the original requester
|
||||
return $q.reject(response)
|
||||
]
|
||||
|
||||
app.config ['$httpProvider', ($httpProvider) ->
|
||||
$httpProvider.interceptors.push 'unAuthHttpResponseInterceptor'
|
||||
]
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue