mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Sort out front end coffee/js files and minification
This commit is contained in:
parent
8525bf4a72
commit
b9909bbd84
193 changed files with 80 additions and 20601 deletions
|
@ -37,15 +37,13 @@ module.exports = (grunt) ->
|
||||||
join: true
|
join: true
|
||||||
files:
|
files:
|
||||||
"public/js/libs/sharejs.js": [
|
"public/js/libs/sharejs.js": [
|
||||||
"public/coffee/editor/ShareJSHeader.coffee"
|
"public/coffee/ide/editor/sharejs/header.coffee"
|
||||||
"public/coffee/editor/sharejs/types/helpers.coffee"
|
"public/coffee/ide/editor/sharejs/vendor/types/helpers.coffee"
|
||||||
"public/coffee/editor/sharejs/types/text.coffee"
|
"public/coffee/ide/editor/sharejs/vendor/types/text.coffee"
|
||||||
"public/coffee/editor/sharejs/types/text-api.coffee"
|
"public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee"
|
||||||
"public/coffee/editor/sharejs/types/json.coffee"
|
"public/coffee/ide/editor/sharejs/vendor/client/microevent.coffee"
|
||||||
"public/coffee/editor/sharejs/types/json-api.coffee"
|
"public/coffee/ide/editor/sharejs/vendor/client/doc.coffee"
|
||||||
"public/coffee/editor/sharejs/client/microevent.coffee"
|
"public/coffee/ide/editor/sharejs/vendor/client/ace.coffee"
|
||||||
"public/coffee/editor/sharejs/client/doc.coffee"
|
|
||||||
"public/coffee/editor/sharejs/client/ace.coffee"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
client:
|
client:
|
||||||
|
@ -89,12 +87,8 @@ module.exports = (grunt) ->
|
||||||
inlineText: false
|
inlineText: false
|
||||||
preserveLicenseComments: false
|
preserveLicenseComments: false
|
||||||
paths:
|
paths:
|
||||||
"underscore": "libs/underscore"
|
"moment": "libs/moment-2.7.0"
|
||||||
"jquery": "libs/jquery"
|
|
||||||
"moment": "libs/moment"
|
|
||||||
shim:
|
shim:
|
||||||
"libs/backbone":
|
|
||||||
deps: ["libs/underscore"]
|
|
||||||
"libs/pdfListView/PdfListView":
|
"libs/pdfListView/PdfListView":
|
||||||
deps: ["libs/pdf"]
|
deps: ["libs/pdf"]
|
||||||
"libs/pdf":
|
"libs/pdf":
|
||||||
|
@ -104,16 +98,12 @@ module.exports = (grunt) ->
|
||||||
modules: [
|
modules: [
|
||||||
{
|
{
|
||||||
name: "main",
|
name: "main",
|
||||||
exclude: ["jquery"]
|
exclude: ["libs"]
|
||||||
}, {
|
}, {
|
||||||
name: "ide",
|
name: "ide",
|
||||||
exclude: ["jquery"]
|
exclude: ["libs", "libs/jquery-layout"]
|
||||||
}, {
|
}, {
|
||||||
name: "home",
|
name: "libs"
|
||||||
exclude: ["jquery"]
|
|
||||||
}, {
|
|
||||||
name: "list",
|
|
||||||
exclude: ["jquery"]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -30,11 +30,8 @@ html(itemscope, itemtype='http://schema.org/Product')
|
||||||
script(type="text/javascript").
|
script(type="text/javascript").
|
||||||
window.csrfToken = "#{csrfToken}";
|
window.csrfToken = "#{csrfToken}";
|
||||||
|
|
||||||
script(src=jsPath+'libs/jquery.js')
|
script(src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js")
|
||||||
script(src=jsPath+'libs/angular-1.2.17.js')
|
script(src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.19/angular.min.js")
|
||||||
script(src=jsPath+'libs/moment-2.4.0.js')
|
|
||||||
script(src=jsPath+'libs/underscore-1.3.3.js')
|
|
||||||
block scripts
|
|
||||||
|
|
||||||
body
|
body
|
||||||
- if(typeof(suppressNavbar) == "undefined")
|
- if(typeof(suppressNavbar) == "undefined")
|
||||||
|
@ -61,10 +58,13 @@ html(itemscope, itemtype='http://schema.org/Product')
|
||||||
- if(typeof(suppressFooter) == "undefined")
|
- if(typeof(suppressFooter) == "undefined")
|
||||||
script(type='text/javascript').
|
script(type='text/javascript').
|
||||||
window.requirejs = {
|
window.requirejs = {
|
||||||
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'app/main.js')}"
|
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'app/main.js')}",
|
||||||
|
"paths" : {
|
||||||
|
"moment": "libs/moment-2.7.0"
|
||||||
|
}
|
||||||
};
|
};
|
||||||
script(
|
script(
|
||||||
data-main=jsPath+'app/main.js',
|
data-main=jsPath+'main.js',
|
||||||
baseurl=jsPath,
|
baseurl=jsPath,
|
||||||
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
|
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,13 +5,6 @@ block vars
|
||||||
- var suppressFooter = true
|
- var suppressFooter = true
|
||||||
- var suppressDefaultJs = true
|
- var suppressDefaultJs = true
|
||||||
|
|
||||||
block scripts
|
|
||||||
//- Only use the native bootstrap on the editor page,
|
|
||||||
//- since we use the Angular-based bootstrap elsewhere.
|
|
||||||
//- script(src=jsPath+'libs/bootstrap-3.1.1.js')
|
|
||||||
script(src=jsPath+'libs/jquery-layout.js')
|
|
||||||
script(src=jsPath+'libs/jquery.storage.js')
|
|
||||||
|
|
||||||
block content
|
block content
|
||||||
.editor(ng-controller="IdeController")
|
.editor(ng-controller="IdeController")
|
||||||
.loading-screen(ng-show="state.loading")
|
.loading-screen(ng-show="state.loading")
|
||||||
|
@ -92,19 +85,12 @@ block content
|
||||||
window.csrfToken = "!{csrfToken}";
|
window.csrfToken = "!{csrfToken}";
|
||||||
window.requirejs = {
|
window.requirejs = {
|
||||||
"paths" : {
|
"paths" : {
|
||||||
"underscore": "../libs/underscore-1.3.3",
|
|
||||||
"mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML",
|
"mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML",
|
||||||
"moment": "libs/moment-2.4.0",
|
"moment": "libs/moment-2.7.0"
|
||||||
"ace": "#{jsPath}ace",
|
|
||||||
"libs": "#{jsPath}libs",
|
|
||||||
"text": "#{jsPath}text"
|
|
||||||
},
|
},
|
||||||
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}",
|
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}",
|
||||||
"waitSeconds": 0,
|
"waitSeconds": 0,
|
||||||
"shim": {
|
"shim": {
|
||||||
"libs/backbone": {
|
|
||||||
deps: ["libs/underscore-1.3.3"]
|
|
||||||
},
|
|
||||||
"libs/pdfListView/PdfListView": {
|
"libs/pdfListView/PdfListView": {
|
||||||
deps: ["libs/pdf"]
|
deps: ["libs/pdf"]
|
||||||
},
|
},
|
||||||
|
@ -125,7 +111,7 @@ block content
|
||||||
window.sharelatex.pdfJsWorkerPath = "#{pdfJsWorkerPath}"
|
window.sharelatex.pdfJsWorkerPath = "#{pdfJsWorkerPath}"
|
||||||
|
|
||||||
script(
|
script(
|
||||||
data-main=jsPath+'app/ide.js',
|
data-main=jsPath+'ide.js',
|
||||||
baseurl=jsPath,
|
baseurl=jsPath,
|
||||||
data-ace-base=jsPath+'ace',
|
data-ace-base=jsPath+'ace',
|
||||||
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
|
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
extends ../layout
|
extends ../layout
|
||||||
|
|
||||||
block scripts
|
block content
|
||||||
script(type="text/javascript").
|
script(type="text/javascript").
|
||||||
window.data = {
|
window.data = {
|
||||||
projects: !{JSON.stringify(projects)},
|
projects: !{JSON.stringify(projects)},
|
||||||
|
@ -13,7 +13,6 @@ block scripts
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
block content
|
|
||||||
.content.content-alt(ng-controller="ProjectPageController")
|
.content.content-alt(ng-controller="ProjectPageController")
|
||||||
.container
|
.container
|
||||||
.row
|
.row
|
||||||
|
|
|
@ -1,88 +0,0 @@
|
||||||
require [
|
|
||||||
"libs/mustache"
|
|
||||||
"./main"
|
|
||||||
"underscore"
|
|
||||||
], (m)->
|
|
||||||
$(document).ready ->
|
|
||||||
|
|
||||||
tableRowTemplate = '''
|
|
||||||
<tr>
|
|
||||||
<td> <input type="checkbox" class="select-one"></td>
|
|
||||||
<td> {{ email }} </td>
|
|
||||||
<td> {{ first_name }} {{ last_name }} </td>
|
|
||||||
<td> {{ !holdingAccount }} </td>
|
|
||||||
<td>
|
|
||||||
<input type="hidden" name="user_id" value="{{_id}}" class="user_id">
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
'''
|
|
||||||
|
|
||||||
window.temp = tableRowTemplate
|
|
||||||
|
|
||||||
$form = $('form#addUserToGroup')
|
|
||||||
|
|
||||||
addUser = (e)->
|
|
||||||
|
|
||||||
parseEmails = (emailsString)->
|
|
||||||
regexBySpaceOrComma = /[\s,]+/
|
|
||||||
emails = emailsString.split(regexBySpaceOrComma)
|
|
||||||
emails = _.map emails, (email)->
|
|
||||||
email = email.trim()
|
|
||||||
emails = _.select emails, (email)->
|
|
||||||
email.indexOf("@") != -1
|
|
||||||
return emails
|
|
||||||
|
|
||||||
|
|
||||||
sendNewUserToServer = (email)->
|
|
||||||
$.ajax
|
|
||||||
url: "/subscription/group/user"
|
|
||||||
type: 'POST'
|
|
||||||
data:
|
|
||||||
email: email
|
|
||||||
_csrf: csrfToken
|
|
||||||
success: (data)->
|
|
||||||
if data.limitReached
|
|
||||||
alert("You have reached your maximum number of members")
|
|
||||||
else
|
|
||||||
renderNewUserInList data.user
|
|
||||||
|
|
||||||
renderNewUserInList = (user)->
|
|
||||||
html = Mustache.to_html(tableRowTemplate, user)
|
|
||||||
$('#userList').append(html)
|
|
||||||
|
|
||||||
e.preventDefault()
|
|
||||||
val = $form.find("input[name=email]").val()
|
|
||||||
emails = parseEmails(val)
|
|
||||||
emails.forEach (email)->
|
|
||||||
sendNewUserToServer(email)
|
|
||||||
$form.find("input").val('')
|
|
||||||
|
|
||||||
removeUsers = (e)->
|
|
||||||
selectedUserRows = $('td input.select-one:checked').closest('tr').find(".user_id").toArray()
|
|
||||||
do deleteNext = () ->
|
|
||||||
row = selectedUserRows.pop()
|
|
||||||
if row?
|
|
||||||
user_id = $(row).val()
|
|
||||||
$.ajax
|
|
||||||
url: "/subscription/group/user/#{user_id}"
|
|
||||||
type: 'DELETE'
|
|
||||||
data:
|
|
||||||
_csrf: csrfToken
|
|
||||||
success: ->
|
|
||||||
$(row).parents("tr").fadeOut(250)
|
|
||||||
deleteNext()
|
|
||||||
|
|
||||||
$form.on 'keypress', (e)->
|
|
||||||
if(e.keyCode == 13)
|
|
||||||
addUser(e)
|
|
||||||
|
|
||||||
$form.find(".addUser").on 'click', addUser
|
|
||||||
|
|
||||||
$('#deleteUsers').on 'click', removeUsers
|
|
||||||
|
|
||||||
$('input.select-all').on "change", () ->
|
|
||||||
if $(@).is(":checked")
|
|
||||||
$("input.select-one").prop( "checked", true )
|
|
||||||
else
|
|
||||||
$("input.select-one").prop( "checked", false )
|
|
||||||
|
|
|
@ -1,70 +0,0 @@
|
||||||
define [
|
|
||||||
"utils/Modal"
|
|
||||||
], (Modal) ->
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
AccountManager =
|
|
||||||
askToUpgrade: (ide, options = {}) ->
|
|
||||||
options.why ||= "to use this feature"
|
|
||||||
if ide.project.get("owner") == ide.user
|
|
||||||
if ide.user.get("subscription").freeTrial.allowed
|
|
||||||
@showCreditCardFreeTrialModal(options)
|
|
||||||
else
|
|
||||||
@showUpgradeDialog(ide, options)
|
|
||||||
else
|
|
||||||
@showAskOwnerDialog(ide, options)
|
|
||||||
|
|
||||||
showCreditCardFreeTrialModal: (options) ->
|
|
||||||
Modal.createModal
|
|
||||||
title: "Start your free trial"
|
|
||||||
message: "You need to upgrade your account #{options.why}. Would you like to start a 30 day free trial? You can cancel at any point."
|
|
||||||
buttons: [{
|
|
||||||
text: "Cancel"
|
|
||||||
class: ""
|
|
||||||
},{
|
|
||||||
text: "Enter Billing Information"
|
|
||||||
class: "btn-primary"
|
|
||||||
callback: () =>
|
|
||||||
options.onUpgrade?()
|
|
||||||
@gotoSubscriptionsPage()
|
|
||||||
}]
|
|
||||||
|
|
||||||
gotoSubscriptionsPage: () ->
|
|
||||||
window.open("/user/subscription/new?planCode=student_free_trial")
|
|
||||||
Modal.createModal
|
|
||||||
title: "Please refresh"
|
|
||||||
message: "Please refresh this page after starting your free trial. This will make sure all of your features are enabled."
|
|
||||||
buttons: [{
|
|
||||||
text: "OK"
|
|
||||||
class: ""
|
|
||||||
}]
|
|
||||||
|
|
||||||
showUpgradeDialog: (ide, options = {}) ->
|
|
||||||
options.message ||= """
|
|
||||||
Sorry, you need to upgrade your account #{options.why}.
|
|
||||||
You can do this on your account settings page,
|
|
||||||
accessible in the top right hand corner.
|
|
||||||
"""
|
|
||||||
Modal.createModal
|
|
||||||
title: "Please upgrade your account"
|
|
||||||
message: options.message
|
|
||||||
buttons: [{
|
|
||||||
text: "OK"
|
|
||||||
class: "btn"
|
|
||||||
callback: () -> options.onCancel() if options.onCancel
|
|
||||||
},{
|
|
||||||
text: "See Plans"
|
|
||||||
class: "btn-success"
|
|
||||||
callback: () -> window.open("/user/subscription/plans")
|
|
||||||
}]
|
|
||||||
|
|
||||||
showAskOwnerDialog: (ide, options = {}) ->
|
|
||||||
Modal.createModal
|
|
||||||
title: "Owner needs an upgraded account"
|
|
||||||
message: "Please ask the owner of this project to upgrade their account #{options.why}."
|
|
||||||
buttons: [{
|
|
||||||
text: "OK"
|
|
||||||
class: "btn-primary"
|
|
||||||
callback: () -> options.onCancel() if options.onCancel
|
|
||||||
}]
|
|
|
@ -1,33 +0,0 @@
|
||||||
require [
|
|
||||||
"main"
|
|
||||||
"libs/jquery.tablesorter"
|
|
||||||
], ()->
|
|
||||||
$(document).ready ()->
|
|
||||||
$('#connected-users').tablesorter()
|
|
||||||
|
|
||||||
$('button#disconnectAll').click (event)->
|
|
||||||
event.preventDefault()
|
|
||||||
$.ajax
|
|
||||||
url: "/admin/dissconectAllUsers",
|
|
||||||
type:'POST',
|
|
||||||
data:
|
|
||||||
_csrf: $(@).data("csrf")
|
|
||||||
success: (data)->
|
|
||||||
|
|
||||||
$('button#closeEditor').click (event)->
|
|
||||||
event.preventDefault()
|
|
||||||
$.ajax
|
|
||||||
url: "/admin/closeEditor",
|
|
||||||
type:'POST',
|
|
||||||
data:
|
|
||||||
_csrf: $(@).data("csrf")
|
|
||||||
success: (data)->
|
|
||||||
|
|
||||||
$('button#pollTpds').click (event)->
|
|
||||||
event.preventDefault()
|
|
||||||
$.ajax
|
|
||||||
url: "/admin/pollUsersWithDropbox",
|
|
||||||
type:'POST',
|
|
||||||
data:
|
|
||||||
_csrf: $(@).data("csrf")
|
|
||||||
success: (data)->
|
|
|
@ -1,33 +0,0 @@
|
||||||
define [
|
|
||||||
"libs/md5"
|
|
||||||
], () ->
|
|
||||||
class AnalyticsManager
|
|
||||||
constructor: (@ide) ->
|
|
||||||
@ide.editor.on "update:doc", () =>
|
|
||||||
@updateCount ||= 0
|
|
||||||
@updateCount++
|
|
||||||
if @updateCount == 100
|
|
||||||
ga('send', 'event', 'editor-interaction', 'multi-doc-update')
|
|
||||||
|
|
||||||
@ide.pdfManager.on "compile:pdf", () =>
|
|
||||||
@compileCount ||= 0
|
|
||||||
@compileCount++
|
|
||||||
if @compileCount == 1
|
|
||||||
ga('send', 'event', 'editor-interaction', 'single-compile')
|
|
||||||
if @compileCount == 3
|
|
||||||
ga('send', 'event', 'editor-interaction', 'multi-compile')
|
|
||||||
|
|
||||||
getABTestBucket: (test_name, buckets = []) ->
|
|
||||||
hash = CryptoJS.MD5("#{@ide.user.get("id")}:#{test_name}")
|
|
||||||
bucketIndex = parseInt(hash.toString().slice(0,2), 16) % buckets.length
|
|
||||||
return buckets[bucketIndex]
|
|
||||||
|
|
||||||
startABTest: (test_name, buckets = []) ->
|
|
||||||
value = @getABTestBucket(test_name, buckets)
|
|
||||||
ga('send', 'event', 'ab_tests', test_name, "viewed-#{value}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
endABTest: (test_name, buckets = []) ->
|
|
||||||
value = @getABTestBucket(test_name, buckets)
|
|
||||||
ga('send', 'event', 'ab_tests', test_name, "converted-#{value}")
|
|
||||||
return value
|
|
|
@ -1,14 +0,0 @@
|
||||||
define [
|
|
||||||
"../libs/angular-autocomplete/angular-autocomplete"
|
|
||||||
"../libs/ui-bootstrap"
|
|
||||||
"modules/recursionHelper"
|
|
||||||
"../libs/ng-context-menu-0.1.4"
|
|
||||||
], () ->
|
|
||||||
App = angular.module("SharelatexApp", [
|
|
||||||
"ui.bootstrap"
|
|
||||||
"autocomplete"
|
|
||||||
"RecursionHelper"
|
|
||||||
"ng-context-menu"
|
|
||||||
])
|
|
||||||
|
|
||||||
return App
|
|
|
@ -1,63 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "asyncForm", ($http) ->
|
|
||||||
return {
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
formName = attrs.asyncForm
|
|
||||||
|
|
||||||
scope[attrs.name].response = response = {}
|
|
||||||
|
|
||||||
element.on "submit", (e) ->
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
formData = {}
|
|
||||||
for data in element.serializeArray()
|
|
||||||
formData[data.name] = data.value
|
|
||||||
|
|
||||||
$http
|
|
||||||
.post(element.attr('action'), formData)
|
|
||||||
.success (data, status, headers, config) ->
|
|
||||||
response.success = true
|
|
||||||
response.error = false
|
|
||||||
|
|
||||||
if data.redir?
|
|
||||||
ga('send', 'event', formName, 'success')
|
|
||||||
window.location = data.redir
|
|
||||||
else if data.message?
|
|
||||||
response.message = data.message
|
|
||||||
|
|
||||||
if data.message.type == "error"
|
|
||||||
response.success = false
|
|
||||||
response.error = true
|
|
||||||
ga('send', 'event', formName, 'failure', data.message)
|
|
||||||
else
|
|
||||||
ga('send', 'event', formName, 'success')
|
|
||||||
|
|
||||||
.error (data, status, headers, config) ->
|
|
||||||
response.success = false
|
|
||||||
response.error = true
|
|
||||||
response.message =
|
|
||||||
text: data.message or "Something went wrong talking to the server :(. Please try again."
|
|
||||||
type: 'error'
|
|
||||||
ga('send', 'event', formName, 'failure', data.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive "formMessages", () ->
|
|
||||||
return {
|
|
||||||
restrict: "E"
|
|
||||||
template: """
|
|
||||||
<div class="alert" ng-class="{
|
|
||||||
'alert-danger': form.response.message.type == 'error',
|
|
||||||
'alert-success': form.response.message.type != 'error'
|
|
||||||
}" ng-show="!!form.response.message">
|
|
||||||
{{form.response.message.text}}
|
|
||||||
</div>
|
|
||||||
<div ng-transclude></div>
|
|
||||||
"""
|
|
||||||
transclude: true
|
|
||||||
scope: {
|
|
||||||
form: "=for"
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
|
|
||||||
App.directive 'equals', () ->
|
|
||||||
return {
|
|
||||||
require: "ngModel",
|
|
||||||
link: (scope, element, attrs, ngModel) ->
|
|
||||||
scope.$watch attrs.ngModel, () -> validate()
|
|
||||||
attrs.$observe 'equals', () -> validate()
|
|
||||||
|
|
||||||
validate = () ->
|
|
||||||
equal = (attrs.equals == ngModel.$viewValue)
|
|
||||||
ngModel.$setValidity('areEqual', equal)
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
"../../libs/fineuploader"
|
|
||||||
], (App) ->
|
|
||||||
App.directive 'fineUpload', ($timeout) ->
|
|
||||||
return {
|
|
||||||
scope: {
|
|
||||||
multiple: "="
|
|
||||||
endpoint: "@"
|
|
||||||
waitingForResponseText: "@"
|
|
||||||
failedUploadText: "@"
|
|
||||||
uploadButtonText: "@"
|
|
||||||
dragAreaText: "@"
|
|
||||||
hintText: "@"
|
|
||||||
allowedExtensions: "="
|
|
||||||
onCompleteCallback: "="
|
|
||||||
onUploadCallback: "="
|
|
||||||
params: "="
|
|
||||||
}
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
multiple = scope.multiple or false
|
|
||||||
endpoint = scope.endpoint
|
|
||||||
if scope.allowedExtensions?
|
|
||||||
validation =
|
|
||||||
allowedExtensions: scope.allowedExtensions
|
|
||||||
else
|
|
||||||
validation = {}
|
|
||||||
text =
|
|
||||||
waitingForResponse: scope.waitingForResponseText or "Processing..."
|
|
||||||
failUpload: scope.failedUploadText or "Failed :("
|
|
||||||
uploadButton: scope.uploadButtonText or "Upload"
|
|
||||||
dragAreaText = scope.dragAreaText or "drag here"
|
|
||||||
hintText = scope.hintText or ""
|
|
||||||
|
|
||||||
onComplete = scope.onCompleteCallback or () ->
|
|
||||||
onUpload = scope.onUploadCallback or () ->
|
|
||||||
params = scope.params or {}
|
|
||||||
params._csrf = window.csrfToken
|
|
||||||
|
|
||||||
new qq.FineUploader
|
|
||||||
element: element[0]
|
|
||||||
multiple: multiple
|
|
||||||
disabledCancelForFormUploads: true
|
|
||||||
validation: validation
|
|
||||||
request:
|
|
||||||
endpoint: endpoint
|
|
||||||
forceMultipart: true
|
|
||||||
params: params
|
|
||||||
paramsInBody: false
|
|
||||||
callbacks:
|
|
||||||
onComplete: onComplete
|
|
||||||
onUpload: onUpload
|
|
||||||
text: text
|
|
||||||
template: """
|
|
||||||
<div class="qq-uploader">
|
|
||||||
<div class="qq-upload-drop-area"><span>{dragZoneText}</span></div>
|
|
||||||
<div class="qq-upload-button btn btn-primary btn-lg">
|
|
||||||
<div>{uploadButtonText}</div>
|
|
||||||
</div>
|
|
||||||
<span class="or btn-lg"> or </span>
|
|
||||||
<span class="drag-here btn-lg">#{dragAreaText}</span>
|
|
||||||
<span class="qq-drop-processing"><span>{dropProcessingText}</span><span class="qq-drop-processing-spinner"></span></span>
|
|
||||||
<div class="small">#{hintText}</div>
|
|
||||||
<ul class="qq-upload-list"></ul>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "focusWhen", ($timeout) ->
|
|
||||||
return {
|
|
||||||
restrict: "A"
|
|
||||||
link: (scope, element, attr) ->
|
|
||||||
scope.$watch attr.focusWhen, (value) ->
|
|
||||||
if value
|
|
||||||
$timeout ->
|
|
||||||
element.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive 'focusOn', ($timeout) ->
|
|
||||||
return {
|
|
||||||
restrict: 'A'
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
scope.$on attrs.focusOn, () ->
|
|
||||||
element.focus()
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive "selectWhen", ($timeout) ->
|
|
||||||
return {
|
|
||||||
restrict: "A"
|
|
||||||
link: (scope, element, attr) ->
|
|
||||||
scope.$watch attr.selectWhen, (value) ->
|
|
||||||
if value
|
|
||||||
$timeout ->
|
|
||||||
element.select()
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive 'selectOn', ($timeout) ->
|
|
||||||
return {
|
|
||||||
restrict: 'A'
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
scope.$on attrs.selectOn, () ->
|
|
||||||
element.select()
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive "selectNameWhen", ($timeout) ->
|
|
||||||
return {
|
|
||||||
restrict: 'A'
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
scope.$watch attrs.selectNameWhen, (value) ->
|
|
||||||
if value
|
|
||||||
$timeout () ->
|
|
||||||
selectName(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive "selectNameOn", () ->
|
|
||||||
return {
|
|
||||||
restrict: 'A'
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
scope.$on attrs.selectNameOn, () ->
|
|
||||||
selectName(element)
|
|
||||||
}
|
|
||||||
|
|
||||||
selectName = (element) ->
|
|
||||||
# Select up to last '.'. I.e. everything
|
|
||||||
# except the file extension
|
|
||||||
element.focus()
|
|
||||||
name = element.val()
|
|
||||||
if element[0].setSelectionRange?
|
|
||||||
selectionEnd = name.lastIndexOf(".")
|
|
||||||
if selectionEnd == -1
|
|
||||||
selectionEnd = name.length
|
|
||||||
element[0].setSelectionRange(0, selectionEnd)
|
|
|
@ -1,10 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive 'onEnter', () ->
|
|
||||||
return (scope, element, attrs) ->
|
|
||||||
element.bind "keydown keypress", (event) ->
|
|
||||||
if event.which == 13
|
|
||||||
scope.$apply () ->
|
|
||||||
scope.$eval(attrs.onEnter, event: event)
|
|
||||||
event.preventDefault()
|
|
|
@ -1,67 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "selectAllList", () ->
|
|
||||||
return {
|
|
||||||
controller: ["$scope", ($scope) ->
|
|
||||||
# Selecting or deselecting all should apply to all projects
|
|
||||||
selectAll = () ->
|
|
||||||
$scope.$broadcast "select-all:select"
|
|
||||||
|
|
||||||
deselectAll = () ->
|
|
||||||
$scope.$broadcast "select-all:deselect"
|
|
||||||
|
|
||||||
clearSelectAllState = () ->
|
|
||||||
$scope.$broadcast "select-all:clear"
|
|
||||||
|
|
||||||
return {
|
|
||||||
clearSelectAllState: clearSelectAllState
|
|
||||||
selectAll: selectAll
|
|
||||||
deselectAll: deselectAll
|
|
||||||
}
|
|
||||||
]
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive "selectAll", () ->
|
|
||||||
return {
|
|
||||||
require: "^selectAllList"
|
|
||||||
link: (scope, element, attrs, selectAllListController) ->
|
|
||||||
scope.$on "select-all:clear", () ->
|
|
||||||
element.prop("checked", false)
|
|
||||||
|
|
||||||
element.change () ->
|
|
||||||
if element.is(":checked")
|
|
||||||
selectAllListController.selectAll()
|
|
||||||
else
|
|
||||||
selectAllListController.deselectAll()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive "selectIndividual", () ->
|
|
||||||
return {
|
|
||||||
require: "^selectAllList"
|
|
||||||
scope: {
|
|
||||||
ngModel: "="
|
|
||||||
}
|
|
||||||
link: (scope, element, attrs, selectAllListController) ->
|
|
||||||
ignoreChanges = false
|
|
||||||
|
|
||||||
scope.$watch "ngModel", (value) ->
|
|
||||||
if value? and !ignoreChanges
|
|
||||||
selectAllListController.clearSelectAllState()
|
|
||||||
|
|
||||||
scope.$on "select-all:select", () ->
|
|
||||||
ignoreChanges = true
|
|
||||||
scope.$apply () ->
|
|
||||||
scope.ngModel = true
|
|
||||||
ignoreChanges = false
|
|
||||||
|
|
||||||
scope.$on "select-all:deselect", () ->
|
|
||||||
ignoreChanges = true
|
|
||||||
scope.$apply () ->
|
|
||||||
scope.ngModel = false
|
|
||||||
ignoreChanges = false
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "stopPropagation", ($http) ->
|
|
||||||
return {
|
|
||||||
restrict: "A",
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
element.bind attrs.stopPropagation, (e) ->
|
|
||||||
e.stopPropagation()
|
|
||||||
}
|
|
||||||
|
|
||||||
App.directive "preventDefault", ($http) ->
|
|
||||||
return {
|
|
||||||
restrict: "A",
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
element.bind attrs.preventDefault, (e) ->
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
|
@ -1,18 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
moment.lang "en", calendar:
|
|
||||||
lastDay : '[Yesterday]'
|
|
||||||
sameDay : '[Today]'
|
|
||||||
nextDay : '[Tomorrow]'
|
|
||||||
lastWeek : "ddd, Do MMM YY"
|
|
||||||
nextWeek : "ddd, Do MMM YY"
|
|
||||||
sameElse : 'ddd, Do MMM YY'
|
|
||||||
|
|
||||||
App.filter "formatDate", () ->
|
|
||||||
(date, format = "Do MMM YYYY, h:mm a") ->
|
|
||||||
moment(date).format(format)
|
|
||||||
|
|
||||||
App.filter "relativeDate", () ->
|
|
||||||
(date) ->
|
|
||||||
moment(date).calendar()
|
|
|
@ -1,70 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
"ide/file-tree/FileTreeManager"
|
|
||||||
"ide/connection/ConnectionManager"
|
|
||||||
"ide/editor/EditorManager"
|
|
||||||
"ide/online-users/OnlineUsersManager"
|
|
||||||
"ide/track-changes/TrackChangesManager"
|
|
||||||
"ide/permissions/PermissionsManager"
|
|
||||||
"ide/pdf/PdfManager"
|
|
||||||
"ide/binary-files/BinaryFilesManager"
|
|
||||||
"ide/settings/index"
|
|
||||||
"ide/share/index"
|
|
||||||
"ide/chat/index"
|
|
||||||
"ide/directives/layout"
|
|
||||||
"ide/services/ide"
|
|
||||||
"directives/focus"
|
|
||||||
"directives/fineUpload"
|
|
||||||
"directives/onEnter"
|
|
||||||
"filters/formatDate"
|
|
||||||
], (
|
|
||||||
App
|
|
||||||
FileTreeManager
|
|
||||||
ConnectionManager
|
|
||||||
EditorManager
|
|
||||||
OnlineUsersManager
|
|
||||||
TrackChangesManager
|
|
||||||
PermissionsManager
|
|
||||||
PdfManager
|
|
||||||
BinaryFilesManager
|
|
||||||
) ->
|
|
||||||
App.controller "IdeController", ["$scope", "$timeout", "ide", ($scope, $timeout, ide) ->
|
|
||||||
# Don't freak out if we're already in an apply callback
|
|
||||||
$scope.$originalApply = $scope.$apply
|
|
||||||
$scope.$apply = (fn = () ->) ->
|
|
||||||
phase = @$root.$$phase
|
|
||||||
if (phase == '$apply' || phase == '$digest')
|
|
||||||
fn()
|
|
||||||
else
|
|
||||||
this.$originalApply(fn);
|
|
||||||
|
|
||||||
$scope.state = {
|
|
||||||
loading: true
|
|
||||||
load_progress: 40
|
|
||||||
}
|
|
||||||
$scope.ui = {
|
|
||||||
leftMenuShown: false
|
|
||||||
view: "editor"
|
|
||||||
chatOpen: false
|
|
||||||
}
|
|
||||||
$scope.user = window.user
|
|
||||||
$scope.settings = window.userSettings
|
|
||||||
|
|
||||||
$scope.chat = {}
|
|
||||||
|
|
||||||
window._ide = ide
|
|
||||||
|
|
||||||
ide.project_id = $scope.project_id = window.project_id
|
|
||||||
ide.$scope = $scope
|
|
||||||
|
|
||||||
ide.connectionManager = new ConnectionManager(ide, $scope)
|
|
||||||
ide.fileTreeManager = new FileTreeManager(ide, $scope)
|
|
||||||
ide.editorManager = new EditorManager(ide, $scope)
|
|
||||||
ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
|
|
||||||
ide.trackChangesManager = new TrackChangesManager(ide, $scope)
|
|
||||||
ide.pdfManager = new PdfManager(ide, $scope)
|
|
||||||
ide.permissionsManager = new PermissionsManager(ide, $scope)
|
|
||||||
ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
|
|
||||||
]
|
|
||||||
|
|
||||||
angular.bootstrap(document.body, ["SharelatexApp"])
|
|
|
@ -1,12 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/binary-files/controllers/BinaryFileController"
|
|
||||||
], () ->
|
|
||||||
class BinaryFilesManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@$scope.$on "entity:selected", (event, entity) =>
|
|
||||||
if (@$scope.ui.view != "track-changes" and entity.type == "file")
|
|
||||||
@openFile(entity)
|
|
||||||
|
|
||||||
openFile: (file) ->
|
|
||||||
@$scope.ui.view = "file"
|
|
||||||
@$scope.openFile = file
|
|
|
@ -1,7 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "BinaryFileController", ["$scope", ($scope) ->
|
|
||||||
$scope.extension = (file) ->
|
|
||||||
return file.name.split(".").pop()?.toLowerCase()
|
|
||||||
]
|
|
|
@ -1,7 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "ChatButtonController", ["$scope", ($scope) ->
|
|
||||||
$scope.toggleChat = () ->
|
|
||||||
$scope.ui.chatOpen = !$scope.ui.chatOpen
|
|
||||||
]
|
|
|
@ -1,64 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "ChatController", ["$scope", ($scope) ->
|
|
||||||
james = {
|
|
||||||
id: $scope.user.id
|
|
||||||
first_name: "James"
|
|
||||||
last_name: "Allen"
|
|
||||||
email: "james@sharelatex.com"
|
|
||||||
}
|
|
||||||
james2 = {
|
|
||||||
id: "james2-id"
|
|
||||||
first_name: "James"
|
|
||||||
last_name: "Allen"
|
|
||||||
email: "jamesallen0108@gmail.com"
|
|
||||||
}
|
|
||||||
henry = {
|
|
||||||
id: "henry-id"
|
|
||||||
first_name: "Henry"
|
|
||||||
last_name: "Oswald"
|
|
||||||
email: "henry.oswald@sharelatex.com"
|
|
||||||
}
|
|
||||||
$scope.chat.messages = [
|
|
||||||
{ content: "Hello world", timestamp: Date.now() - 2000, user: james2 }
|
|
||||||
{ content: "Hello, this is the new chat", timestamp: Date.now() - 4000, user: james }
|
|
||||||
{ content: "Here are some longer messages to show what it looks like when I say a lot!", timestamp: Date.now() - 20000, user: henry }
|
|
||||||
{ content: "What about some maths? $x^2 = 1$?", timestamp: Date.now() - 22000, user: james2 }
|
|
||||||
{ content: "Nope, that doesn't work yet!", timestamp: Date.now() - 45000, user: henry }
|
|
||||||
{ content: "I'm running out of things to say.", timestamp: Date.now() - 56000, user: henry }
|
|
||||||
{ content: "Yep, me too", timestamp: Date.now() - 100000, user: james }
|
|
||||||
{ content: "Hmm, looks like we've had this conversation backwards", timestamp: Date.now() - 120000, user: james }
|
|
||||||
{ content: "Hello world", timestamp: Date.now() - 202000, user: james2 }
|
|
||||||
{ content: "Hello, this is the new chat", timestamp: Date.now() - 204000, user: james }
|
|
||||||
{ content: "Here are some longer messages to show what it looks like when I say a lot!", timestamp: Date.now() - 2020000, user: henry }
|
|
||||||
{ content: "What about some maths? $x^2 = 1$?", timestamp: Date.now() - 12022000, user: james2 }
|
|
||||||
{ content: "Nope, that doesn't work yet!", timestamp: Date.now() - 12045000, user: henry }
|
|
||||||
{ content: "I'm running out of things to say.", timestamp: Date.now() - 22056000, user: henry }
|
|
||||||
{ content: "Yep, me too", timestamp: Date.now() - 220100000, user: james }
|
|
||||||
{ content: "Hmm, looks like we've had this conversation backwards", timestamp: Date.now() - 520120000, user: james }
|
|
||||||
].reverse()
|
|
||||||
|
|
||||||
$scope.$watch "chat.messages", (messages) ->
|
|
||||||
if messages?
|
|
||||||
$scope.chat.groupedMessages = groupMessages(messages)
|
|
||||||
|
|
||||||
TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 # 5 minutes
|
|
||||||
groupMessages = (messages) ->
|
|
||||||
previousMessage = null
|
|
||||||
groupedMessages = []
|
|
||||||
for message in messages
|
|
||||||
shouldGroup = previousMessage? and
|
|
||||||
previousMessage.user == message.user and
|
|
||||||
message.timestamp - previousMessage.timestamp < TIMESTAMP_GROUP_SIZE
|
|
||||||
if shouldGroup
|
|
||||||
previousMessage.timestamp = message.timestamp
|
|
||||||
previousMessage.contents.push message.content
|
|
||||||
else
|
|
||||||
groupedMessages.push(previousMessage = {
|
|
||||||
user: message.user
|
|
||||||
timestamp: message.timestamp
|
|
||||||
contents: [message.content]
|
|
||||||
})
|
|
||||||
return groupedMessages
|
|
||||||
]
|
|
|
@ -1,12 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
|
|
||||||
$scope.gravatarUrl = (user) ->
|
|
||||||
email = user.email.trim().toLowerCase()
|
|
||||||
hash = CryptoJS.MD5(email).toString()
|
|
||||||
return "//www.gravatar.com/avatar/#{hash}?d=mm&s=50"
|
|
||||||
|
|
||||||
$scope.hue = (user) ->
|
|
||||||
ide.onlineUsersManager.getHueForUserId(user.id)
|
|
||||||
]
|
|
|
@ -1,5 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/chat/controllers/ChatButtonController"
|
|
||||||
"ide/chat/controllers/ChatController"
|
|
||||||
"ide/chat/controllers/ChatMessageController"
|
|
||||||
], () ->
|
|
|
@ -1,113 +0,0 @@
|
||||||
define [], () ->
|
|
||||||
class ConnectionManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@connected = false
|
|
||||||
|
|
||||||
@$scope.connection =
|
|
||||||
reconnecting: false
|
|
||||||
# If we need to force everyone to reload the editor
|
|
||||||
forced_disconnect: false
|
|
||||||
|
|
||||||
@$scope.tryReconnectNow = () =>
|
|
||||||
@tryReconnect()
|
|
||||||
|
|
||||||
@ide.socket = io.connect null,
|
|
||||||
reconnect: false
|
|
||||||
"force new connection": true
|
|
||||||
|
|
||||||
@ide.socket.on "connect", () =>
|
|
||||||
@connected = true
|
|
||||||
@ide.pushEvent("connected")
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.connection.reconnecting = false
|
|
||||||
if @$scope.state.loading
|
|
||||||
@$scope.state.load_progress = 80
|
|
||||||
|
|
||||||
setTimeout(() =>
|
|
||||||
@joinProject()
|
|
||||||
, 100)
|
|
||||||
|
|
||||||
@ide.socket.on 'disconnect', () =>
|
|
||||||
@connected = false
|
|
||||||
@ide.pushEvent("disconnected")
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.connection.reconnecting = false
|
|
||||||
|
|
||||||
setTimeout(=>
|
|
||||||
ga('send', 'event', 'editor-interaction', 'disconnect')
|
|
||||||
, 2000)
|
|
||||||
|
|
||||||
if !$scope.connection.forced_disconnect
|
|
||||||
@startAutoReconnectCountdown()
|
|
||||||
|
|
||||||
@ide.socket.on 'forceDisconnect', (message) =>
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.connection.forced_disconnect = true
|
|
||||||
@socket.disconnect()
|
|
||||||
|
|
||||||
joinProject: () ->
|
|
||||||
@ide.socket.emit 'joinProject', {
|
|
||||||
project_id: @ide.project_id
|
|
||||||
}, (err, project, permissionsLevel, protocolVersion) =>
|
|
||||||
if @$scope.protocolVersion? and @$scope.protocolVersion != protocolVersion
|
|
||||||
location.reload(true)
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.protocolVersion = protocolVersion
|
|
||||||
@$scope.project = project
|
|
||||||
@$scope.permissionsLevel = permissionsLevel
|
|
||||||
@$scope.state.load_progress = 100
|
|
||||||
@$scope.state.loading = false
|
|
||||||
@$scope.$emit "project:joined"
|
|
||||||
|
|
||||||
reconnectImmediately: () ->
|
|
||||||
@disconnect()
|
|
||||||
@tryReconnect()
|
|
||||||
|
|
||||||
disconnect: () ->
|
|
||||||
@ide.socket.disconnect()
|
|
||||||
|
|
||||||
startAutoReconnectCountdown: () ->
|
|
||||||
lastUpdated = @ide.editorManager.lastUpdated()
|
|
||||||
|
|
||||||
twoMinutes = 2 * 60 * 1000
|
|
||||||
if lastUpdated? and new Date() - lastUpdated > twoMinutes
|
|
||||||
# between 1 minute and 3 minutes
|
|
||||||
countdown = 60 + Math.floor(Math.random() * 120)
|
|
||||||
else
|
|
||||||
countdown = 3 + Math.floor(Math.random() * 7)
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.connection.reconnecting = false
|
|
||||||
@$scope.connection.reconnection_countdown = countdown
|
|
||||||
|
|
||||||
setTimeout(=>
|
|
||||||
if !@connected
|
|
||||||
@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
|
|
||||||
, 200)
|
|
||||||
|
|
||||||
cancelReconnect: () ->
|
|
||||||
clearTimeout @timeoutId if @timeoutId?
|
|
||||||
|
|
||||||
decreaseCountdown: () ->
|
|
||||||
console.log "Decreasing countdown"
|
|
||||||
return if !@$scope.connection.reconnection_countdown?
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.connection.reconnection_countdown--
|
|
||||||
|
|
||||||
if @$scope.connection.reconnection_countdown <= 0
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@tryReconnect()
|
|
||||||
else
|
|
||||||
@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
|
|
||||||
|
|
||||||
tryReconnect: () ->
|
|
||||||
console.log "Trying reconnect"
|
|
||||||
@cancelReconnect()
|
|
||||||
@$scope.connection.reconnecting = true
|
|
||||||
delete @$scope.connection.reconnection_countdown
|
|
||||||
@ide.socket.socket.reconnect()
|
|
||||||
setTimeout (=> @startAutoReconnectCountdown() if !@connected), 2000
|
|
||||||
|
|
|
@ -1,101 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "layout", ["$parse", ($parse) ->
|
|
||||||
return {
|
|
||||||
compile: () ->
|
|
||||||
pre: (scope, element, attrs) ->
|
|
||||||
name = attrs.layout
|
|
||||||
|
|
||||||
if attrs.spacingOpen?
|
|
||||||
spacingOpen = parseInt(attrs.spacingOpen, 10)
|
|
||||||
else
|
|
||||||
spacingOpen = 24
|
|
||||||
|
|
||||||
if attrs.spacingClosed?
|
|
||||||
spacingClosed = parseInt(attrs.spacingClosed, 10)
|
|
||||||
else
|
|
||||||
spacingClosed = 24
|
|
||||||
|
|
||||||
options =
|
|
||||||
spacing_open: spacingOpen
|
|
||||||
spacing_closed: spacingClosed
|
|
||||||
slidable: false
|
|
||||||
onresize: () =>
|
|
||||||
onInternalResize()
|
|
||||||
maskIframesOnResize: scope.$eval(
|
|
||||||
attrs.maskIframesOnResize or "false"
|
|
||||||
)
|
|
||||||
east:
|
|
||||||
size: scope.$eval(attrs.initialSizeEast)
|
|
||||||
initClosed: scope.$eval(attrs.initClosedEast)
|
|
||||||
west:
|
|
||||||
size: scope.$eval(attrs.initialSizeEast)
|
|
||||||
initClosed: scope.$eval(attrs.initClosedWest)
|
|
||||||
|
|
||||||
# Restore previously recorded state
|
|
||||||
if (state = $.localStorage("layout.#{name}"))?
|
|
||||||
options.west = state.west
|
|
||||||
options.east = state.east
|
|
||||||
|
|
||||||
# Someone moved the resizer
|
|
||||||
onInternalResize = () ->
|
|
||||||
state = element.layout().readState()
|
|
||||||
scope.$broadcast "layout:#{name}:resize", state
|
|
||||||
repositionControls()
|
|
||||||
resetOpenStates()
|
|
||||||
|
|
||||||
oldWidth = element.width()
|
|
||||||
# Something resized our parent element
|
|
||||||
onExternalResize = () ->
|
|
||||||
console.log "EXTERNAL RESIOZE", name, attrs.resizeProportionally
|
|
||||||
if attrs.resizeProportionally? and scope.$eval(attrs.resizeProportionally)
|
|
||||||
eastState = element.layout().readState().east
|
|
||||||
if eastState?
|
|
||||||
newInternalWidth = eastState.size / oldWidth * element.width()
|
|
||||||
oldWidth = element.width()
|
|
||||||
element.layout().sizePane("east", newInternalWidth)
|
|
||||||
return
|
|
||||||
|
|
||||||
element.layout().resizeAll()
|
|
||||||
|
|
||||||
element.layout options
|
|
||||||
element.layout().resizeAll()
|
|
||||||
|
|
||||||
if attrs.resizeOn?
|
|
||||||
scope.$on attrs.resizeOn, () -> onExternalResize()
|
|
||||||
|
|
||||||
# Save state when exiting
|
|
||||||
$(window).unload () ->
|
|
||||||
$.localStorage("layout.#{name}", element.layout().readState())
|
|
||||||
|
|
||||||
repositionControls = () ->
|
|
||||||
state = element.layout().readState()
|
|
||||||
if state.east?
|
|
||||||
element.find("> .ui-layout-resizer-controls").css({
|
|
||||||
position: "absolute"
|
|
||||||
right: state.east.size
|
|
||||||
"z-index": 10
|
|
||||||
})
|
|
||||||
|
|
||||||
resetOpenStates = () ->
|
|
||||||
state = element.layout().readState()
|
|
||||||
if attrs.openEast?
|
|
||||||
openEast = $parse(attrs.openEast)
|
|
||||||
openEast.assign(scope, !state.east.initClosed)
|
|
||||||
|
|
||||||
if attrs.openEast?
|
|
||||||
scope.$watch attrs.openEast, (value, oldValue) ->
|
|
||||||
console.log "Open East", value, oldValue
|
|
||||||
if value? and value != oldValue
|
|
||||||
if value
|
|
||||||
element.layout().open("east")
|
|
||||||
else
|
|
||||||
element.layout().close("east")
|
|
||||||
setTimeout () ->
|
|
||||||
scope.$digest()
|
|
||||||
, 0
|
|
||||||
|
|
||||||
resetOpenStates()
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,254 +0,0 @@
|
||||||
define [
|
|
||||||
"utils/EventEmitter"
|
|
||||||
"ide/editor/ShareJsDoc"
|
|
||||||
"underscore"
|
|
||||||
], (EventEmitter, ShareJsDoc) ->
|
|
||||||
class Document extends EventEmitter
|
|
||||||
@getDocument: (ide, doc_id) ->
|
|
||||||
@openDocs ||= {}
|
|
||||||
if !@openDocs[doc_id]?
|
|
||||||
@openDocs[doc_id] = new Document(ide, doc_id)
|
|
||||||
return @openDocs[doc_id]
|
|
||||||
|
|
||||||
@hasUnsavedChanges: () ->
|
|
||||||
for doc_id, doc of (@openDocs or {})
|
|
||||||
return true if doc.hasBufferedOps()
|
|
||||||
return false
|
|
||||||
|
|
||||||
constructor: (@ide, @doc_id) ->
|
|
||||||
@connected = @ide.socket.socket.connected
|
|
||||||
@joined = false
|
|
||||||
@wantToBeJoined = false
|
|
||||||
@_checkConsistency = _.bind(@_checkConsistency, @)
|
|
||||||
@inconsistentCount = 0
|
|
||||||
@_bindToEditorEvents()
|
|
||||||
@_bindToSocketEvents()
|
|
||||||
|
|
||||||
attachToAce: (@ace) ->
|
|
||||||
@doc?.attachToAce(@ace)
|
|
||||||
editorDoc = @ace.getSession().getDocument()
|
|
||||||
editorDoc.on "change", @_checkConsistency
|
|
||||||
|
|
||||||
detachFromAce: () ->
|
|
||||||
@doc?.detachFromAce()
|
|
||||||
editorDoc = @ace?.getSession().getDocument()
|
|
||||||
editorDoc?.off "change", @_checkConsistency
|
|
||||||
|
|
||||||
_checkConsistency: () ->
|
|
||||||
# We've been seeing a lot of errors when I think there shouldn't be
|
|
||||||
# any, which may be related to this check happening before the change is
|
|
||||||
# applied. If we use a timeout, hopefully we can reduce this.
|
|
||||||
setTimeout () =>
|
|
||||||
editorValue = @ace?.getValue()
|
|
||||||
sharejsValue = @doc?.getSnapshot()
|
|
||||||
if editorValue != sharejsValue
|
|
||||||
@inconsistentCount++
|
|
||||||
else
|
|
||||||
@inconsistentCount = 0
|
|
||||||
|
|
||||||
if @inconsistentCount >= 3
|
|
||||||
@_onError new Error("Editor text does not match server text")
|
|
||||||
, 0
|
|
||||||
|
|
||||||
getSnapshot: () ->
|
|
||||||
@doc?.getSnapshot()
|
|
||||||
|
|
||||||
getType: () ->
|
|
||||||
@doc?.getType()
|
|
||||||
|
|
||||||
getInflightOp: () ->
|
|
||||||
@doc?.getInflightOp()
|
|
||||||
|
|
||||||
getPendingOp: () ->
|
|
||||||
@doc?.getPendingOp()
|
|
||||||
|
|
||||||
hasBufferedOps: () ->
|
|
||||||
@doc?.hasBufferedOps()
|
|
||||||
|
|
||||||
_bindToSocketEvents: () ->
|
|
||||||
@_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
|
|
||||||
@ide.socket.on "otUpdateApplied", @_onUpdateAppliedHandler
|
|
||||||
@_onErrorHandler = (error, update) => @_onError(error, update)
|
|
||||||
@ide.socket.on "otUpdateError", @_onErrorHandler
|
|
||||||
@_onDisconnectHandler = (error) => @_onDisconnect(error)
|
|
||||||
@ide.socket.on "disconnect", @_onDisconnectHandler
|
|
||||||
|
|
||||||
_bindToEditorEvents: () ->
|
|
||||||
onReconnectHandler = (update) =>
|
|
||||||
@_onReconnect(update)
|
|
||||||
@_unsubscribeReconnectHandler = @ide.$scope.$on "project:joined", onReconnectHandler
|
|
||||||
|
|
||||||
_unBindFromEditorEvents: () ->
|
|
||||||
@_unsubscribeReconnectHandler()
|
|
||||||
|
|
||||||
_unBindFromSocketEvents: () ->
|
|
||||||
@ide.socket.removeListener "otUpdateApplied", @_onUpdateAppliedHandler
|
|
||||||
@ide.socket.removeListener "otUpdateError", @_onUpdateErrorHandler
|
|
||||||
@ide.socket.removeListener "disconnect", @_onDisconnectHandler
|
|
||||||
|
|
||||||
leaveAndCleanUp: () ->
|
|
||||||
@leave (error) =>
|
|
||||||
@_cleanUp()
|
|
||||||
|
|
||||||
join: (callback = (error) ->) ->
|
|
||||||
@wantToBeJoined = true
|
|
||||||
@_cancelLeave()
|
|
||||||
if @connected
|
|
||||||
return @_joinDoc callback
|
|
||||||
else
|
|
||||||
@_joinCallbacks ||= []
|
|
||||||
@_joinCallbacks.push callback
|
|
||||||
|
|
||||||
leave: (callback = (error) ->) ->
|
|
||||||
@wantToBeJoined = false
|
|
||||||
@_cancelJoin()
|
|
||||||
if (@doc? and @doc.hasBufferedOps())
|
|
||||||
@_leaveCallbacks ||= []
|
|
||||||
@_leaveCallbacks.push callback
|
|
||||||
else if !@connected
|
|
||||||
callback()
|
|
||||||
else
|
|
||||||
@_leaveDoc(callback)
|
|
||||||
|
|
||||||
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()
|
|
||||||
if !inflightOp? and !pendingOp?
|
|
||||||
# there's nothing going on
|
|
||||||
saved = true
|
|
||||||
else if inflightOp == @oldInflightOp
|
|
||||||
saved = false
|
|
||||||
else if pendingOp?
|
|
||||||
saved = false
|
|
||||||
else
|
|
||||||
saved = true
|
|
||||||
|
|
||||||
@oldInflightOp = inflightOp
|
|
||||||
return saved
|
|
||||||
|
|
||||||
_cancelLeave: () ->
|
|
||||||
if @_leaveCallbacks?
|
|
||||||
delete @_leaveCallbacks
|
|
||||||
|
|
||||||
_cancelJoin: () ->
|
|
||||||
if @_joinCallbacks?
|
|
||||||
delete @_joinCallbacks
|
|
||||||
|
|
||||||
_onUpdateApplied: (update) ->
|
|
||||||
@ide.pushEvent "received-update",
|
|
||||||
doc_id: @doc_id
|
|
||||||
remote_doc_id: update?.doc
|
|
||||||
wantToBeJoined: @wantToBeJoined
|
|
||||||
update: update
|
|
||||||
|
|
||||||
if Math.random() < (@ide.disconnectRate or 0)
|
|
||||||
console.log "Simulating disconnect"
|
|
||||||
@ide.connectionManager.disconnect()
|
|
||||||
return
|
|
||||||
|
|
||||||
if Math.random() < (@ide.ignoreRate or 0)
|
|
||||||
console.log "Simulating lost update"
|
|
||||||
return
|
|
||||||
|
|
||||||
if update?.doc == @doc_id and @doc?
|
|
||||||
@doc.processUpdateFromServer update
|
|
||||||
|
|
||||||
if !@wantToBeJoined
|
|
||||||
@leave()
|
|
||||||
|
|
||||||
_onDisconnect: () ->
|
|
||||||
@connected = false
|
|
||||||
@joined = false
|
|
||||||
@doc?.updateConnectionState "disconnected"
|
|
||||||
|
|
||||||
_onReconnect: () ->
|
|
||||||
@ide.pushEvent "reconnected:afterJoinProject"
|
|
||||||
|
|
||||||
@connected = true
|
|
||||||
if @wantToBeJoined or @doc?.hasBufferedOps()
|
|
||||||
@_joinDoc (error) =>
|
|
||||||
return @_onError(error) if error?
|
|
||||||
@doc.updateConnectionState "ok"
|
|
||||||
@doc.flushPendingOps()
|
|
||||||
@_callJoinCallbacks()
|
|
||||||
|
|
||||||
_callJoinCallbacks: () ->
|
|
||||||
for callback in @_joinCallbacks or []
|
|
||||||
callback()
|
|
||||||
delete @_joinCallbacks
|
|
||||||
|
|
||||||
_joinDoc: (callback = (error) ->) ->
|
|
||||||
if @doc?
|
|
||||||
@ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@joined = true
|
|
||||||
@doc.catchUp( updates )
|
|
||||||
callback()
|
|
||||||
else
|
|
||||||
@ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@joined = true
|
|
||||||
@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
|
|
||||||
@_bindToShareJsDocEvents()
|
|
||||||
callback()
|
|
||||||
|
|
||||||
_leaveDoc: (callback = (error) ->) ->
|
|
||||||
@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@joined = false
|
|
||||||
for callback in @_leaveCallbacks or []
|
|
||||||
callback(error)
|
|
||||||
delete @_leaveCallbacks
|
|
||||||
callback(error)
|
|
||||||
|
|
||||||
_cleanUp: () ->
|
|
||||||
delete Document.openDocs[@doc_id]
|
|
||||||
@_unBindFromEditorEvents()
|
|
||||||
@_unBindFromSocketEvents()
|
|
||||||
|
|
||||||
_bindToShareJsDocEvents: () ->
|
|
||||||
@doc.on "error", (error, meta) => @_onError error, meta
|
|
||||||
@doc.on "externalUpdate", () =>
|
|
||||||
@ide.pushEvent "externalUpdate",
|
|
||||||
doc_id: @doc_id
|
|
||||||
@trigger "externalUpdate"
|
|
||||||
@doc.on "remoteop", () =>
|
|
||||||
@ide.pushEvent "remoteop",
|
|
||||||
doc_id: @doc_id
|
|
||||||
@trigger "remoteop"
|
|
||||||
@doc.on "op:sent", (op) =>
|
|
||||||
@ide.pushEvent "op:sent",
|
|
||||||
doc_id: @doc_id
|
|
||||||
op: op
|
|
||||||
@trigger "op:sent"
|
|
||||||
@doc.on "op:acknowledged", (op) =>
|
|
||||||
@ide.pushEvent "op:acknowledged",
|
|
||||||
doc_id: @doc_id
|
|
||||||
op: op
|
|
||||||
@trigger "op:acknowledged"
|
|
||||||
@doc.on "op:timeout", (op) =>
|
|
||||||
@ide.pushEvent "op:timeout",
|
|
||||||
doc_id: @doc_id
|
|
||||||
op: op
|
|
||||||
@trigger "op:timeout"
|
|
||||||
ga?('send', 'event', 'error', "op timeout", "Op was now acknowledged - #{@ide.socket.socket.transport.name}" )
|
|
||||||
@ide.connectionManager.reconnectImmediately()
|
|
||||||
@doc.on "flush", (inflightOp, pendingOp, version) =>
|
|
||||||
@ide.pushEvent "flush",
|
|
||||||
doc_id: @doc_id,
|
|
||||||
inflightOp: inflightOp,
|
|
||||||
pendingOp: pendingOp
|
|
||||||
v: version
|
|
||||||
|
|
||||||
_onError: (error, meta = {}) ->
|
|
||||||
console.error "ShareJS error", error, meta
|
|
||||||
ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
|
|
||||||
@ide.socket.disconnect()
|
|
||||||
meta.doc_id = @doc_id
|
|
||||||
@ide.reportError(error, meta)
|
|
||||||
@doc?.clearInflightAndPendingOps()
|
|
||||||
@_cleanUp()
|
|
||||||
@trigger "error", error
|
|
|
@ -1,103 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/editor/Document"
|
|
||||||
"ide/editor/directives/aceEditor"
|
|
||||||
"ide/editor/controllers/SavingNotificationController"
|
|
||||||
], (Document) ->
|
|
||||||
class EditorManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@$scope.editor = {
|
|
||||||
sharejs_doc: null
|
|
||||||
last_updated: null
|
|
||||||
open_doc_id: null
|
|
||||||
opening: true
|
|
||||||
cursorPosition: {}
|
|
||||||
gotoLine: null
|
|
||||||
}
|
|
||||||
|
|
||||||
@$scope.$on "entity:selected", (event, entity) =>
|
|
||||||
if (@$scope.ui.view != "track-changes" and entity.type == "doc")
|
|
||||||
@openDoc(entity)
|
|
||||||
|
|
||||||
initialized = false
|
|
||||||
@$scope.$on "file-tree:initialized", () =>
|
|
||||||
if !initialized
|
|
||||||
initialized = true
|
|
||||||
@autoOpenDoc()
|
|
||||||
|
|
||||||
autoOpenDoc: () ->
|
|
||||||
open_doc_id =
|
|
||||||
$.localStorage("doc.open_id.#{@$scope.project_id}") or
|
|
||||||
@$scope.project.rootDoc_id
|
|
||||||
return if !open_doc_id?
|
|
||||||
doc = @ide.fileTreeManager.findEntityById(open_doc_id)
|
|
||||||
return if !doc?
|
|
||||||
@openDoc(doc)
|
|
||||||
|
|
||||||
openDoc: (doc, options = {}) ->
|
|
||||||
@$scope.ui.view = "editor"
|
|
||||||
|
|
||||||
done = () =>
|
|
||||||
if options.gotoLine?
|
|
||||||
@$scope.editor.gotoLine = options.gotoLine
|
|
||||||
|
|
||||||
if doc.id == @$scope.editor.open_doc_id and !options.forceReopen
|
|
||||||
@$scope.$apply () =>
|
|
||||||
done()
|
|
||||||
return
|
|
||||||
|
|
||||||
@$scope.editor.open_doc_id = doc.id
|
|
||||||
|
|
||||||
$.localStorage "doc.open_id.#{@$scope.project_id}", doc.id
|
|
||||||
@ide.fileTreeManager.selectEntity(doc)
|
|
||||||
|
|
||||||
@$scope.editor.opening = true
|
|
||||||
@_openNewDocument doc, (error, sharejs_doc) =>
|
|
||||||
if error?
|
|
||||||
@ide.showGenericServerErrorMessage()
|
|
||||||
return
|
|
||||||
|
|
||||||
@$scope.$broadcast "doc:opened"
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.editor.opening = false
|
|
||||||
@$scope.editor.sharejs_doc = sharejs_doc
|
|
||||||
done()
|
|
||||||
|
|
||||||
_openNewDocument: (doc, callback = (error, sharejs_doc) ->) ->
|
|
||||||
current_sharejs_doc = @$scope.editor.sharejs_doc
|
|
||||||
if current_sharejs_doc?
|
|
||||||
current_sharejs_doc.leaveAndCleanUp()
|
|
||||||
@_unbindFromDocumentEvents(current_sharejs_doc)
|
|
||||||
|
|
||||||
new_sharejs_doc = Document.getDocument @ide, doc.id
|
|
||||||
|
|
||||||
new_sharejs_doc.join (error) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@_bindToDocumentEvents(doc, new_sharejs_doc)
|
|
||||||
callback null, new_sharejs_doc
|
|
||||||
|
|
||||||
_bindToDocumentEvents: (doc, sharejs_doc) ->
|
|
||||||
sharejs_doc.on "error", (error) =>
|
|
||||||
@openDoc(doc, forceReopen: true)
|
|
||||||
@ide.showGenericMessageModal(
|
|
||||||
"Out of sync"
|
|
||||||
"Sorry, this file has gone out of sync and we need to do a full refresh. Please let us know if this happens frequently."
|
|
||||||
)
|
|
||||||
|
|
||||||
sharejs_doc.on "externalUpdate", () =>
|
|
||||||
@ide.showGenericMessageModal(
|
|
||||||
"Document Updated Externally"
|
|
||||||
"This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions please look in the history."
|
|
||||||
)
|
|
||||||
|
|
||||||
_unbindFromDocumentEvents: (document) ->
|
|
||||||
document.off()
|
|
||||||
|
|
||||||
lastUpdated: () ->
|
|
||||||
@$scope.editor.last_updated
|
|
||||||
|
|
||||||
getCurrentDocValue: () ->
|
|
||||||
@$scope.editor.sharejs_doc?.getSnapshot()
|
|
||||||
|
|
||||||
getCurrentDocId: () ->
|
|
||||||
@$scope.editor.open_doc_id
|
|
|
@ -1,123 +0,0 @@
|
||||||
define [
|
|
||||||
"utils/EventEmitter"
|
|
||||||
"../../../libs/sharejs"
|
|
||||||
], (EventEmitter, ShareJs) ->
|
|
||||||
class ShareJsDoc extends EventEmitter
|
|
||||||
constructor: (@doc_id, docLines, version, @socket) ->
|
|
||||||
# Dencode any binary bits of data
|
|
||||||
# See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
|
|
||||||
@type = "text"
|
|
||||||
docLines = for line in docLines
|
|
||||||
if line.text?
|
|
||||||
@type = "json"
|
|
||||||
line.text = decodeURIComponent(escape(line.text))
|
|
||||||
else
|
|
||||||
@type = "text"
|
|
||||||
line = decodeURIComponent(escape(line))
|
|
||||||
line
|
|
||||||
|
|
||||||
if @type == "text"
|
|
||||||
snapshot = docLines.join("\n")
|
|
||||||
else if @type == "json"
|
|
||||||
snapshot = { lines: docLines }
|
|
||||||
else
|
|
||||||
throw new Error("Unknown type: #{@type}")
|
|
||||||
|
|
||||||
@connection = {
|
|
||||||
send: (update) =>
|
|
||||||
@_startInflightOpTimeout(update)
|
|
||||||
@socket.emit "applyOtUpdate", @doc_id, update
|
|
||||||
state: "ok"
|
|
||||||
id: @socket.socket.sessionid
|
|
||||||
}
|
|
||||||
|
|
||||||
@_doc = new ShareJs.Doc @connection, @doc_id,
|
|
||||||
type: @type
|
|
||||||
@_doc.on "change", () =>
|
|
||||||
@trigger "change"
|
|
||||||
@_doc.on "acknowledge", () =>
|
|
||||||
@trigger "acknowledge"
|
|
||||||
@_doc.on "remoteop", () =>
|
|
||||||
@trigger "remoteop"
|
|
||||||
|
|
||||||
@_bindToDocChanges(@_doc)
|
|
||||||
|
|
||||||
@processUpdateFromServer
|
|
||||||
open: true
|
|
||||||
v: version
|
|
||||||
snapshot: snapshot
|
|
||||||
|
|
||||||
submitOp: (args...) -> @_doc.submitOp(args...)
|
|
||||||
|
|
||||||
processUpdateFromServer: (message) ->
|
|
||||||
try
|
|
||||||
@_doc._onMessage message
|
|
||||||
catch error
|
|
||||||
# Version mismatches are thrown as errors
|
|
||||||
@_handleError(error)
|
|
||||||
|
|
||||||
if message?.meta?.type == "external"
|
|
||||||
@trigger "externalUpdate"
|
|
||||||
|
|
||||||
catchUp: (updates) ->
|
|
||||||
for update, i in updates
|
|
||||||
update.v = @_doc.version
|
|
||||||
update.doc = @doc_id
|
|
||||||
@processUpdateFromServer(update)
|
|
||||||
|
|
||||||
getSnapshot: () -> @_doc.snapshot
|
|
||||||
getVersion: () -> @_doc.version
|
|
||||||
getType: () -> @type
|
|
||||||
|
|
||||||
clearInflightAndPendingOps: () ->
|
|
||||||
@_doc.inflightOp = null
|
|
||||||
@_doc.inflightCallbacks = []
|
|
||||||
@_doc.pendingOp = null
|
|
||||||
@_doc.pendingCallbacks = []
|
|
||||||
|
|
||||||
flushPendingOps: () ->
|
|
||||||
# This will flush any ops that are pending.
|
|
||||||
# If there is an inflight op it will do nothing.
|
|
||||||
@_doc.flush()
|
|
||||||
|
|
||||||
updateConnectionState: (state) ->
|
|
||||||
@connection.state = state
|
|
||||||
@connection.id = @socket.socket.sessionid
|
|
||||||
@_doc.autoOpen = false
|
|
||||||
@_doc._connectionStateChanged(state)
|
|
||||||
|
|
||||||
hasBufferedOps: () ->
|
|
||||||
@_doc.inflightOp? or @_doc.pendingOp?
|
|
||||||
|
|
||||||
getInflightOp: () -> @_doc.inflightOp
|
|
||||||
getPendingOp: () -> @_doc.pendingOp
|
|
||||||
|
|
||||||
attachToAce: (ace) -> @_doc.attach_ace(ace)
|
|
||||||
detachFromAce: () -> @_doc.detach_ace?()
|
|
||||||
|
|
||||||
INFLIGHT_OP_TIMEOUT: 10000
|
|
||||||
_startInflightOpTimeout: (update) ->
|
|
||||||
meta =
|
|
||||||
v: update.v
|
|
||||||
op_sent_at: new Date()
|
|
||||||
timer = setTimeout () =>
|
|
||||||
@trigger "op:timeout", update
|
|
||||||
, @INFLIGHT_OP_TIMEOUT
|
|
||||||
@_doc.inflightCallbacks.push () =>
|
|
||||||
clearTimeout timer
|
|
||||||
|
|
||||||
_handleError: (error, meta = {}) ->
|
|
||||||
@trigger "error", error, meta
|
|
||||||
|
|
||||||
_bindToDocChanges: (doc) ->
|
|
||||||
submitOp = doc.submitOp
|
|
||||||
doc.submitOp = (args...) =>
|
|
||||||
@trigger "op:sent", args...
|
|
||||||
doc.pendingCallbacks.push () =>
|
|
||||||
@trigger "op:acknowledged", args...
|
|
||||||
submitOp.apply(doc, args)
|
|
||||||
|
|
||||||
flush = doc.flush
|
|
||||||
doc.flush = (args...) =>
|
|
||||||
@trigger "flush", doc.inflightOp, doc.pendingOp, doc.version
|
|
||||||
flush.apply(doc, args)
|
|
|
@ -1,95 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/editor/auto-complete/SuggestionManager"
|
|
||||||
"ide/editor/auto-complete/Snippets"
|
|
||||||
"ace/autocomplete/util"
|
|
||||||
"ace/autocomplete"
|
|
||||||
"ace/range"
|
|
||||||
"ace/ext/language_tools"
|
|
||||||
], (SuggestionManager, Snippets, Util, AutoComplete) ->
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
Autocomplete = AutoComplete.Autocomplete
|
|
||||||
|
|
||||||
Util.retrievePrecedingIdentifier = (text, pos, regex) ->
|
|
||||||
currentLineOffset = 0
|
|
||||||
for i in [(pos-1)..0]
|
|
||||||
if text[i] == "\n"
|
|
||||||
currentLineOffset = i + 1
|
|
||||||
break
|
|
||||||
currentLine = text.slice(currentLineOffset, pos)
|
|
||||||
fragment = getLastCommandFragment(currentLine) or ""
|
|
||||||
return fragment
|
|
||||||
|
|
||||||
getLastCommandFragment = (lineUpToCursor) ->
|
|
||||||
if m = lineUpToCursor.match(/(\\[^\\ ]+)$/)
|
|
||||||
return m[1]
|
|
||||||
else
|
|
||||||
return null
|
|
||||||
|
|
||||||
class AutoCompleteManager
|
|
||||||
constructor: (@$scope, @editor) ->
|
|
||||||
@suggestionManager = new SuggestionManager()
|
|
||||||
|
|
||||||
if !Autocomplete::_insertMatch?
|
|
||||||
# Only override this once since it's global but we may create multiple
|
|
||||||
# autocomplete handlers
|
|
||||||
Autocomplete::_insertMatch = Autocomplete::insertMatch
|
|
||||||
Autocomplete::insertMatch = (data) ->
|
|
||||||
pos = editor.getCursorPosition()
|
|
||||||
range = new Range(pos.row, pos.column, pos.row, pos.column + 1)
|
|
||||||
nextChar = editor.session.getTextRange(range)
|
|
||||||
|
|
||||||
console.log "INSERT MATCH", this
|
|
||||||
|
|
||||||
# If we are in \begin{it|}, then we need to remove the trailing }
|
|
||||||
# since it will be adding in with the autocomplete of \begin{item}...
|
|
||||||
if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
|
|
||||||
editor.session.remove(range)
|
|
||||||
|
|
||||||
Autocomplete::_insertMatch.call this, data
|
|
||||||
|
|
||||||
@$scope.$watch "autoComplete", (autocomplete) =>
|
|
||||||
console.log "autocomplete change", autocomplete
|
|
||||||
if autocomplete
|
|
||||||
@enable()
|
|
||||||
else
|
|
||||||
@disable()
|
|
||||||
|
|
||||||
|
|
||||||
@editor.on "changeSession", (e) =>
|
|
||||||
@bindToSession(e.session)
|
|
||||||
|
|
||||||
enable: () ->
|
|
||||||
@editor.setOptions({
|
|
||||||
enableBasicAutocompletion: true,
|
|
||||||
enableSnippets: true
|
|
||||||
})
|
|
||||||
|
|
||||||
SnippetCompleter =
|
|
||||||
getCompletions: (editor, session, pos, prefix, callback) ->
|
|
||||||
callback null, Snippets
|
|
||||||
@editor.completers = [@suggestionManager, SnippetCompleter]
|
|
||||||
|
|
||||||
disable: () ->
|
|
||||||
@editor.setOptions({
|
|
||||||
enableBasicAutocompletion: false,
|
|
||||||
enableSnippets: false
|
|
||||||
})
|
|
||||||
|
|
||||||
bindToSession: (@aceSession) ->
|
|
||||||
@aceSession.on "change", (change) => @onChange(change)
|
|
||||||
|
|
||||||
onChange: (change) ->
|
|
||||||
cursorPosition = @editor.getCursorPosition()
|
|
||||||
end = change.data.range.end
|
|
||||||
# Check that this change was made by us, not a collaborator
|
|
||||||
# (Cursor is still one place behind)
|
|
||||||
if end.row == cursorPosition.row and end.column == cursorPosition.column + 1
|
|
||||||
if change.data.action == "insertText"
|
|
||||||
range = new Range(end.row, 0, end.row, end.column)
|
|
||||||
lineUpToCursor = @aceSession.getTextRange(range)
|
|
||||||
commandFragment = getLastCommandFragment(lineUpToCursor)
|
|
||||||
|
|
||||||
if commandFragment? and commandFragment.length > 2
|
|
||||||
setTimeout () =>
|
|
||||||
@editor.execCommand("startAutocomplete")
|
|
||||||
, 0
|
|
|
@ -1,100 +0,0 @@
|
||||||
define () ->
|
|
||||||
environments = [
|
|
||||||
"abstract",
|
|
||||||
"align", "align*",
|
|
||||||
"equation", "equation*",
|
|
||||||
"gather", "gather*",
|
|
||||||
"multline", "multline*",
|
|
||||||
"split",
|
|
||||||
"verbatim"
|
|
||||||
]
|
|
||||||
|
|
||||||
snippets = for env in environments
|
|
||||||
{
|
|
||||||
caption: "\\begin{#{env}}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{#{env}}
|
|
||||||
$1
|
|
||||||
\\end{#{env}}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}
|
|
||||||
|
|
||||||
snippets = snippets.concat [{
|
|
||||||
caption: "\\begin{array}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{array}{${1:cc}}
|
|
||||||
$2 & $3 \\\\\\\\
|
|
||||||
$4 & $5
|
|
||||||
\\end{array}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{figure}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{figure}
|
|
||||||
\\centering
|
|
||||||
\\includegraphics{$1}
|
|
||||||
\\caption{${2:Caption}}
|
|
||||||
\\label{${3:fig:my_label}}
|
|
||||||
\\end{figure}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{tabular}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{tabular}{${1:c|c}}
|
|
||||||
$2 & $3 \\\\\\\\
|
|
||||||
$4 & $5
|
|
||||||
\\end{tabular}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{table}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{table}[$1]
|
|
||||||
\\centering
|
|
||||||
\\begin{tabular}{${2:c|c}}
|
|
||||||
$3 & $4 \\\\\\\\
|
|
||||||
$5 & $6
|
|
||||||
\\end{tabular}
|
|
||||||
\\caption{${7:Caption}}
|
|
||||||
\\label{${8:tab:my_label}}
|
|
||||||
\\end{table}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{list}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{list}
|
|
||||||
\\item $1
|
|
||||||
\\end{list}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{enumerate}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{enumerate}
|
|
||||||
\\item $1
|
|
||||||
\\end{enumerate}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{itemize}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{itemize}
|
|
||||||
\\item $1
|
|
||||||
\\end{itemize}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{frame}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{frame}{${1:Frame Title}}
|
|
||||||
$2
|
|
||||||
\\end{frame}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}]
|
|
||||||
|
|
||||||
return snippets
|
|
|
@ -1,126 +0,0 @@
|
||||||
define [], () ->
|
|
||||||
class Parser
|
|
||||||
constructor: (@doc) ->
|
|
||||||
|
|
||||||
parse: () ->
|
|
||||||
commands = []
|
|
||||||
seen = {}
|
|
||||||
while command = @nextCommand()
|
|
||||||
docState = @doc
|
|
||||||
|
|
||||||
optionalArgs = 0
|
|
||||||
while @consumeArgument("[", "]")
|
|
||||||
optionalArgs++
|
|
||||||
|
|
||||||
args = 0
|
|
||||||
while @consumeArgument("{", "}")
|
|
||||||
args++
|
|
||||||
|
|
||||||
commandHash = "#{command}\\#{optionalArgs}\\#{args}"
|
|
||||||
if !seen[commandHash]?
|
|
||||||
seen[commandHash] = true
|
|
||||||
commands.push [command, optionalArgs, args]
|
|
||||||
|
|
||||||
# Reset to before argument to handle nested commands
|
|
||||||
@doc = docState
|
|
||||||
|
|
||||||
return commands
|
|
||||||
|
|
||||||
# Ignore single letter commands since auto complete is moot then.
|
|
||||||
commandRegex: /\\([a-zA-Z][a-zA-Z]+)/
|
|
||||||
|
|
||||||
nextCommand: () ->
|
|
||||||
i = @doc.search(@commandRegex)
|
|
||||||
if i == -1
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
match = @doc.match(@commandRegex)[1]
|
|
||||||
@doc = @doc.substr(i + match.length + 1)
|
|
||||||
return match
|
|
||||||
|
|
||||||
consumeWhitespace: () ->
|
|
||||||
match = @doc.match(/^[ \t\n]*/m)[0]
|
|
||||||
@doc = @doc.substr(match.length)
|
|
||||||
|
|
||||||
consumeArgument: (openingBracket, closingBracket) ->
|
|
||||||
@consumeWhitespace()
|
|
||||||
|
|
||||||
if @doc[0] == openingBracket
|
|
||||||
i = 1
|
|
||||||
bracketParity = 1
|
|
||||||
while bracketParity > 0 and i < @doc.length
|
|
||||||
if @doc[i] == openingBracket
|
|
||||||
bracketParity++
|
|
||||||
else if @doc[i] == closingBracket
|
|
||||||
bracketParity--
|
|
||||||
i++
|
|
||||||
|
|
||||||
if bracketParity == 0
|
|
||||||
@doc = @doc.substr(i)
|
|
||||||
return true
|
|
||||||
else
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
return false
|
|
||||||
|
|
||||||
class SuggestionManager
|
|
||||||
getCompletions: (editor, session, pos, prefix, callback) ->
|
|
||||||
doc = session.getValue()
|
|
||||||
parser = new Parser(doc)
|
|
||||||
commands = parser.parse()
|
|
||||||
|
|
||||||
completions = []
|
|
||||||
for command in commands
|
|
||||||
caption = "\\#{command[0]}"
|
|
||||||
snippet = caption
|
|
||||||
i = 1
|
|
||||||
_.times command[1], () ->
|
|
||||||
snippet += "[${#{i}}]"
|
|
||||||
caption += "[]"
|
|
||||||
i++
|
|
||||||
_.times command[2], () ->
|
|
||||||
snippet += "{${#{i}}}"
|
|
||||||
caption += "{}"
|
|
||||||
i++
|
|
||||||
unless caption == prefix
|
|
||||||
completions.push {
|
|
||||||
caption: caption
|
|
||||||
snippet: snippet
|
|
||||||
meta: "cmd"
|
|
||||||
}
|
|
||||||
|
|
||||||
callback null, completions
|
|
||||||
|
|
||||||
loadCommandsFromDoc: (doc) ->
|
|
||||||
parser = new Parser(doc)
|
|
||||||
@commands = parser.parse()
|
|
||||||
|
|
||||||
getSuggestions: (commandFragment) ->
|
|
||||||
matchingCommands = _.filter @commands, (command) ->
|
|
||||||
command[0].slice(0, commandFragment.length) == commandFragment
|
|
||||||
|
|
||||||
return _.map matchingCommands, (command) ->
|
|
||||||
base = "\\" + commandFragment
|
|
||||||
|
|
||||||
args = ""
|
|
||||||
_.times command[1], () -> args = args + "[]"
|
|
||||||
_.times command[2], () -> args = args + "{}"
|
|
||||||
completionBase = command[0].slice(commandFragment.length)
|
|
||||||
|
|
||||||
squareArgsNo = command[1]
|
|
||||||
curlyArgsNo = command[2]
|
|
||||||
totalArgs = squareArgsNo + curlyArgsNo
|
|
||||||
if totalArgs == 0
|
|
||||||
completionBeforeCursor = completionBase
|
|
||||||
completionAfterCurspr = ""
|
|
||||||
else
|
|
||||||
completionBeforeCursor = completionBase + args[0]
|
|
||||||
completionAfterCursor = args.slice(1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
base: base,
|
|
||||||
completion: completionBase + args,
|
|
||||||
completionBeforeCursor: completionBeforeCursor
|
|
||||||
completionAfterCursor: completionAfterCursor
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
"ide/editor/Document"
|
|
||||||
], (App, Document) ->
|
|
||||||
App.controller "SavingNotificationController", ["$scope", "$interval", "ide", ($scope, $interval, ide) ->
|
|
||||||
$interval () ->
|
|
||||||
pollSavedStatus()
|
|
||||||
, 1000
|
|
||||||
|
|
||||||
$(window).bind 'beforeunload', () =>
|
|
||||||
warnAboutUnsavedChanges()
|
|
||||||
|
|
||||||
$scope.docSavingStatus = {}
|
|
||||||
pollSavedStatus = () ->
|
|
||||||
oldStatus = $scope.docSavingStatus
|
|
||||||
$scope.docSavingStatus = {}
|
|
||||||
|
|
||||||
for doc_id, doc of Document.openDocs
|
|
||||||
saving = doc.pollSavedStatus()
|
|
||||||
if !saving
|
|
||||||
if oldStatus[doc_id]?
|
|
||||||
$scope.docSavingStatus[doc_id] = oldStatus[doc_id]
|
|
||||||
$scope.docSavingStatus[doc_id].unsavedSeconds += 1
|
|
||||||
else
|
|
||||||
$scope.docSavingStatus[doc_id] = {
|
|
||||||
unsavedSeconds: 0
|
|
||||||
doc: ide.fileTreeManager.findEntityById(doc_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
warnAboutUnsavedChanges = () ->
|
|
||||||
if Document.hasUnsavedChanges()
|
|
||||||
return "You have unsaved changes. If you leave now they will not be saved."
|
|
||||||
]
|
|
|
@ -1,52 +0,0 @@
|
||||||
define [], () ->
|
|
||||||
class CursorPositionManager
|
|
||||||
constructor: (@$scope, @editor, @element) ->
|
|
||||||
|
|
||||||
@editor.on "changeSession", (e) =>
|
|
||||||
e.session.on "changeScrollTop", (e) =>
|
|
||||||
@onScrollTopChange(e)
|
|
||||||
|
|
||||||
e.session.selection.on 'changeCursor', (e) =>
|
|
||||||
@onCursorChange(e)
|
|
||||||
|
|
||||||
@gotoStoredPosition()
|
|
||||||
|
|
||||||
@editor.on "changeSelection", () =>
|
|
||||||
cursor = @editor.getCursorPosition()
|
|
||||||
@$scope.$apply () =>
|
|
||||||
if @$scope.cursorPosition?
|
|
||||||
@$scope.cursorPosition = cursor
|
|
||||||
|
|
||||||
@$scope.$watch "gotoLine", (value) =>
|
|
||||||
console.log "Going to line", value
|
|
||||||
if value?
|
|
||||||
setTimeout () =>
|
|
||||||
@gotoLine(value)
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.gotoLine = null
|
|
||||||
, 0
|
|
||||||
|
|
||||||
onScrollTopChange: (event) ->
|
|
||||||
if !@ignoreCursorPositionChanges and doc_id = @$scope.sharejsDoc?.doc_id
|
|
||||||
docPosition = $.localStorage("doc.position.#{doc_id}") || {}
|
|
||||||
docPosition.scrollTop = @editor.getSession().getScrollTop()
|
|
||||||
$.localStorage("doc.position.#{doc_id}", docPosition)
|
|
||||||
|
|
||||||
onCursorChange: (event) ->
|
|
||||||
if !@ignoreCursorPositionChanges and doc_id = @$scope.sharejsDoc?.doc_id
|
|
||||||
docPosition = $.localStorage("doc.position.#{doc_id}") || {}
|
|
||||||
docPosition.cursorPosition = @editor.getCursorPosition()
|
|
||||||
$.localStorage("doc.position.#{doc_id}", docPosition)
|
|
||||||
|
|
||||||
gotoStoredPosition: () ->
|
|
||||||
doc_id = @$scope.sharejsDoc?.doc_id
|
|
||||||
return if !doc_id?
|
|
||||||
pos = $.localStorage("doc.position.#{doc_id}") || {}
|
|
||||||
@ignoreCursorPositionChanges = true
|
|
||||||
@editor.moveCursorToPosition(pos.cursorPosition or {row: 0, column: 0})
|
|
||||||
@editor.getSession().setScrollTop(pos.scrollTop or 0)
|
|
||||||
delete @ignoreCursorPositionChanges
|
|
||||||
|
|
||||||
gotoLine: (line) ->
|
|
||||||
@editor.gotoLine(line)
|
|
||||||
@editor.focus()
|
|
|
@ -1,187 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
"ace/ace"
|
|
||||||
"ide/editor/undo/UndoManager"
|
|
||||||
"ide/editor/auto-complete/AutoCompleteManager"
|
|
||||||
"ide/editor/spell-check/SpellCheckManager"
|
|
||||||
"ide/editor/highlights/HighlightsManager"
|
|
||||||
"ide/editor/cursor-position/CursorPositionManager"
|
|
||||||
"ace/keyboard/vim"
|
|
||||||
"ace/keyboard/emacs"
|
|
||||||
"ace/mode/latex"
|
|
||||||
"ace/edit_session"
|
|
||||||
], (App, Ace, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager) ->
|
|
||||||
LatexMode = require("ace/mode/latex").Mode
|
|
||||||
EditSession = require('ace/edit_session').EditSession
|
|
||||||
|
|
||||||
App.directive "aceEditor", ["$timeout", ($timeout) ->
|
|
||||||
return {
|
|
||||||
scope: {
|
|
||||||
theme: "="
|
|
||||||
showPrintMargin: "="
|
|
||||||
keybindings: "="
|
|
||||||
fontSize: "="
|
|
||||||
autoComplete: "="
|
|
||||||
sharejsDoc: "="
|
|
||||||
lastUpdated: "="
|
|
||||||
spellCheckLanguage: "="
|
|
||||||
cursorPosition: "="
|
|
||||||
highlights: "="
|
|
||||||
text: "="
|
|
||||||
readOnly: "="
|
|
||||||
gotoLine: "="
|
|
||||||
annotations: "="
|
|
||||||
}
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
# Don't freak out if we're already in an apply callback
|
|
||||||
scope.$originalApply = scope.$apply
|
|
||||||
scope.$apply = (fn = () ->) ->
|
|
||||||
phase = @$root.$$phase
|
|
||||||
if (phase == '$apply' || phase == '$digest')
|
|
||||||
fn()
|
|
||||||
else
|
|
||||||
@$originalApply(fn);
|
|
||||||
|
|
||||||
editor = Ace.edit(element.find(".ace-editor-body")[0])
|
|
||||||
window.editors ||= []
|
|
||||||
window.editors.push editor
|
|
||||||
|
|
||||||
autoCompleteManager = new AutoCompleteManager(scope, editor, element)
|
|
||||||
spellCheckManager = new SpellCheckManager(scope, editor, element)
|
|
||||||
undoManager = new UndoManager(scope, editor, element)
|
|
||||||
highlightsManager = new HighlightsManager(scope, editor, element)
|
|
||||||
cursorPositionManager = new CursorPositionManager(scope, editor, element)
|
|
||||||
|
|
||||||
# Prevert Ctrl|Cmd-S from triggering save dialog
|
|
||||||
editor.commands.addCommand
|
|
||||||
name: "save",
|
|
||||||
bindKey: win: "Ctrl-S", mac: "Command-S"
|
|
||||||
exec: () ->
|
|
||||||
readOnly: true
|
|
||||||
editor.commands.removeCommand "transposeletters"
|
|
||||||
editor.commands.removeCommand "showSettingsMenu"
|
|
||||||
editor.commands.removeCommand "foldall"
|
|
||||||
|
|
||||||
if attrs.resizeOn?
|
|
||||||
for event in attrs.resizeOn.split(",")
|
|
||||||
scope.$on event, () ->
|
|
||||||
editor.resize()
|
|
||||||
|
|
||||||
scope.$watch "theme", (value) ->
|
|
||||||
editor.setTheme("ace/theme/#{value}")
|
|
||||||
|
|
||||||
scope.$watch "showPrintMargin", (value) ->
|
|
||||||
editor.setShowPrintMargin(value)
|
|
||||||
|
|
||||||
scope.$watch "keybindings", (value) ->
|
|
||||||
Vim = require("ace/keyboard/vim").handler
|
|
||||||
Emacs = require("ace/keyboard/emacs").handler
|
|
||||||
keybindings = vim: Vim, emacs: Emacs
|
|
||||||
editor.setKeyboardHandler(keybindings[value])
|
|
||||||
|
|
||||||
scope.$watch "fontSize", (value) ->
|
|
||||||
element.find(".ace_editor, .ace_content").css({
|
|
||||||
"font-size": value + "px"
|
|
||||||
})
|
|
||||||
|
|
||||||
scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
|
|
||||||
if old_sharejs_doc?
|
|
||||||
detachFromAce(old_sharejs_doc)
|
|
||||||
|
|
||||||
if sharejs_doc?
|
|
||||||
attachToAce(sharejs_doc)
|
|
||||||
|
|
||||||
scope.$watch "text", (text) ->
|
|
||||||
if text?
|
|
||||||
editor.setValue(text, -1)
|
|
||||||
session = editor.getSession()
|
|
||||||
session.setUseWrapMode(true)
|
|
||||||
session.setMode(new LatexMode())
|
|
||||||
|
|
||||||
scope.$watch "annotations", (annotations) ->
|
|
||||||
console.log "SETTING ANNOTATIONS", annotations
|
|
||||||
if annotations?
|
|
||||||
session = editor.getSession()
|
|
||||||
session.setAnnotations annotations
|
|
||||||
|
|
||||||
scope.$watch "readOnly", (value) ->
|
|
||||||
editor.setReadOnly !!value
|
|
||||||
|
|
||||||
resetSession = () ->
|
|
||||||
session = editor.getSession()
|
|
||||||
session.setUseWrapMode(true)
|
|
||||||
session.setMode(new LatexMode())
|
|
||||||
session.setAnnotations scope.annotations
|
|
||||||
|
|
||||||
attachToAce = (sharejs_doc) ->
|
|
||||||
lines = sharejs_doc.getSnapshot().split("\n")
|
|
||||||
editor.setSession(new EditSession(lines))
|
|
||||||
resetSession()
|
|
||||||
session = editor.getSession()
|
|
||||||
|
|
||||||
doc = session.getDocument()
|
|
||||||
doc.on "change", () ->
|
|
||||||
scope.$apply () ->
|
|
||||||
scope.lastUpdated = new Date()
|
|
||||||
|
|
||||||
sharejs_doc.on "remoteop.recordForUndo", () =>
|
|
||||||
undoManager.nextUpdateIsRemote = true
|
|
||||||
|
|
||||||
sharejs_doc.attachToAce(editor)
|
|
||||||
|
|
||||||
editor.focus()
|
|
||||||
|
|
||||||
detachFromAce = (sharejs_doc) ->
|
|
||||||
sharejs_doc.detachFromAce()
|
|
||||||
sharejs_doc.off "remoteop.recordForUndo"
|
|
||||||
|
|
||||||
template: """
|
|
||||||
<div class="ace-editor-wrapper">
|
|
||||||
<div
|
|
||||||
class="undo-conflict-warning alert alert-danger small"
|
|
||||||
ng-show="undo.show_remote_warning"
|
|
||||||
>
|
|
||||||
<strong>Watch out!</strong>
|
|
||||||
We had to undo some of your collaborators changes before we could undo yours.
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
class="pull-right"
|
|
||||||
ng-click="undo.show_remote_warning = false"
|
|
||||||
>Dismiss</a>
|
|
||||||
</div>
|
|
||||||
<div class="ace-editor-body"></div>
|
|
||||||
<div
|
|
||||||
id="spellCheckMenu"
|
|
||||||
class="dropdown context-menu"
|
|
||||||
ng-show="spellingMenu.open"
|
|
||||||
ng-style="{top: spellingMenu.top, left: spellingMenu.left}"
|
|
||||||
ng-class="{open: spellingMenu.open}"
|
|
||||||
>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li ng-repeat="suggestion in spellingMenu.highlight.suggestions | limitTo:8">
|
|
||||||
<a href ng-click="replaceWord(spellingMenu.highlight, suggestion)">{{ suggestion }}</a>
|
|
||||||
</li>
|
|
||||||
<li class="divider"></li>
|
|
||||||
<li>
|
|
||||||
<a href ng-click="learnWord(spellingMenu.highlight)">Add to Dictionary</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="annotation-label"
|
|
||||||
ng-show="annotationLabel.show"
|
|
||||||
ng-style="{
|
|
||||||
position: 'absolute',
|
|
||||||
left: annotationLabel.left,
|
|
||||||
right: annotationLabel.right,
|
|
||||||
bottom: annotationLabel.bottom,
|
|
||||||
top: annotationLabel.top,
|
|
||||||
'background-color': annotationLabel.backgroundColor
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
{{ annotationLabel.text }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,228 +0,0 @@
|
||||||
define [
|
|
||||||
"ace/range"
|
|
||||||
], () ->
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
|
|
||||||
class HighlightsManager
|
|
||||||
constructor: (@$scope, @editor, @element) ->
|
|
||||||
@markerIds = []
|
|
||||||
@labels = []
|
|
||||||
|
|
||||||
@$scope.annotationLabel = {
|
|
||||||
show: false
|
|
||||||
right: "auto"
|
|
||||||
left: "auto"
|
|
||||||
top: "auto"
|
|
||||||
bottom: "auto"
|
|
||||||
backgroundColor: "black"
|
|
||||||
text: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
@$scope.$watch "highlights", (value) =>
|
|
||||||
@redrawAnnotations()
|
|
||||||
|
|
||||||
@$scope.$watch "theme", (value) =>
|
|
||||||
@redrawAnnotations()
|
|
||||||
|
|
||||||
@editor.on "mousemove", (e) =>
|
|
||||||
position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
|
|
||||||
e.position = position
|
|
||||||
@showAnnotationLabels(position)
|
|
||||||
|
|
||||||
@editor.on "changeSession", () =>
|
|
||||||
@redrawAnnotations()
|
|
||||||
|
|
||||||
redrawAnnotations: () ->
|
|
||||||
@_clearMarkers()
|
|
||||||
@_clearLabels()
|
|
||||||
|
|
||||||
for annotation in @$scope.highlights or []
|
|
||||||
do (annotation) =>
|
|
||||||
colorScheme = @_getColorScheme(annotation.hue)
|
|
||||||
if annotation.cursor?
|
|
||||||
console.log "DRAWING CURSOR", annotation
|
|
||||||
@labels.push {
|
|
||||||
text: annotation.label
|
|
||||||
range: new Range(
|
|
||||||
annotation.cursor.row, annotation.cursor.column,
|
|
||||||
annotation.cursor.row, annotation.cursor.column + 1
|
|
||||||
)
|
|
||||||
colorScheme: colorScheme
|
|
||||||
snapToStartOfRange: true
|
|
||||||
}
|
|
||||||
@_drawCursor(annotation, colorScheme)
|
|
||||||
else if annotation.highlight?
|
|
||||||
@labels.push {
|
|
||||||
text: annotation.label
|
|
||||||
range: new Range(
|
|
||||||
annotation.highlight.start.row, annotation.highlight.start.column,
|
|
||||||
annotation.highlight.end.row, annotation.highlight.end.column
|
|
||||||
)
|
|
||||||
colorScheme: colorScheme
|
|
||||||
}
|
|
||||||
@_drawHighlight(annotation, colorScheme)
|
|
||||||
else if annotation.strikeThrough?
|
|
||||||
@labels.push {
|
|
||||||
text: annotation.label
|
|
||||||
range: new Range(
|
|
||||||
annotation.strikeThrough.start.row, annotation.strikeThrough.start.column,
|
|
||||||
annotation.strikeThrough.end.row, annotation.strikeThrough.end.column
|
|
||||||
)
|
|
||||||
colorScheme: colorScheme
|
|
||||||
}
|
|
||||||
@_drawStrikeThrough(annotation, colorScheme)
|
|
||||||
|
|
||||||
showAnnotationLabels: (position) ->
|
|
||||||
labelToShow = null
|
|
||||||
for label in @labels or []
|
|
||||||
if label.range.contains(position.row, position.column)
|
|
||||||
labelToShow = label
|
|
||||||
|
|
||||||
if !labelToShow?
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.annotationLabel.show = false
|
|
||||||
else
|
|
||||||
$ace = $(@editor.renderer.container).find(".ace_scroller")
|
|
||||||
# Move the label into the Ace content area so that offsets and positions are easy to calculate.
|
|
||||||
$ace.append(@element.find(".annotation-label"))
|
|
||||||
|
|
||||||
if labelToShow.snapToStartOfRange
|
|
||||||
coords = @editor.renderer.textToScreenCoordinates(labelToShow.range.start.row, labelToShow.range.start.column)
|
|
||||||
else
|
|
||||||
coords = @editor.renderer.textToScreenCoordinates(position.row, position.column)
|
|
||||||
|
|
||||||
offset = $ace.offset()
|
|
||||||
height = $ace.height()
|
|
||||||
coords.pageX = coords.pageX - offset.left
|
|
||||||
coords.pageY = coords.pageY - offset.top
|
|
||||||
|
|
||||||
if coords.pageY > @editor.renderer.lineHeight * 2
|
|
||||||
top = "auto"
|
|
||||||
bottom = height - coords.pageY
|
|
||||||
else
|
|
||||||
top = coords.pageY + @editor.renderer.lineHeight
|
|
||||||
bottom = "auto"
|
|
||||||
|
|
||||||
# Apply this first that the label has the correct width when calculating below
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.annotationLabel.text = labelToShow.text
|
|
||||||
@$scope.annotationLabel.show = true
|
|
||||||
|
|
||||||
$label = @element.find(".annotation-label")
|
|
||||||
console.log "pageX", coords.pageX, "label", $label.outerWidth(), "ace", $ace.width()
|
|
||||||
|
|
||||||
if coords.pageX + $label.outerWidth() < $ace.width()
|
|
||||||
left = coords.pageX
|
|
||||||
right = "auto"
|
|
||||||
else
|
|
||||||
right = 0
|
|
||||||
left = "auto"
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.annotationLabel = {
|
|
||||||
show: true
|
|
||||||
left: left
|
|
||||||
right: right
|
|
||||||
bottom: bottom
|
|
||||||
top: top
|
|
||||||
backgroundColor: labelToShow.colorScheme.labelBackgroundColor
|
|
||||||
text: labelToShow.text
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearMarkers: () ->
|
|
||||||
for marker_id in @markerIds
|
|
||||||
@editor.getSession().removeMarker(marker_id)
|
|
||||||
@markerIds = []
|
|
||||||
|
|
||||||
_clearLabels: () ->
|
|
||||||
@labels = []
|
|
||||||
|
|
||||||
_drawCursor: (annotation, colorScheme) ->
|
|
||||||
@markerIds.push @editor.getSession().addMarker new Range(
|
|
||||||
annotation.cursor.row, annotation.cursor.column,
|
|
||||||
annotation.cursor.row, annotation.cursor.column + 1
|
|
||||||
), "annotation remote-cursor", (html, range, left, top, config) ->
|
|
||||||
div = """
|
|
||||||
<div
|
|
||||||
class='remote-cursor custom ace_start'
|
|
||||||
style='height: #{config.lineHeight}px; top:#{top}px; left:#{left}px; border-color: #{colorScheme.cursor};'
|
|
||||||
>
|
|
||||||
<div class="nubbin" style="bottom: #{config.lineHeight}px; background-color: #{colorScheme.cursor};"></div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
html.push div
|
|
||||||
, true
|
|
||||||
|
|
||||||
_drawHighlight: (annotation, colorScheme) ->
|
|
||||||
@_addMarkerWithCustomStyle(
|
|
||||||
new Range(
|
|
||||||
annotation.highlight.start.row, annotation.highlight.start.column,
|
|
||||||
annotation.highlight.end.row, annotation.highlight.end.column + 1
|
|
||||||
),
|
|
||||||
"annotation highlight",
|
|
||||||
false,
|
|
||||||
"background-color: #{colorScheme.highlightBackgroundColor}"
|
|
||||||
)
|
|
||||||
|
|
||||||
_drawStrikeThrough: (annotation, colorScheme) ->
|
|
||||||
lineHeight = @editor.renderer.lineHeight
|
|
||||||
@_addMarkerWithCustomStyle(
|
|
||||||
new Range(
|
|
||||||
annotation.strikeThrough.start.row, annotation.strikeThrough.start.column,
|
|
||||||
annotation.strikeThrough.end.row, annotation.strikeThrough.end.column + 1
|
|
||||||
),
|
|
||||||
"annotation strike-through-background",
|
|
||||||
false,
|
|
||||||
"background-color: #{colorScheme.strikeThroughBackgroundColor}"
|
|
||||||
)
|
|
||||||
@_addMarkerWithCustomStyle(
|
|
||||||
new Range(
|
|
||||||
annotation.strikeThrough.start.row, annotation.strikeThrough.start.column,
|
|
||||||
annotation.strikeThrough.end.row, annotation.strikeThrough.end.column + 1
|
|
||||||
),
|
|
||||||
"annotation strike-through-foreground",
|
|
||||||
true,
|
|
||||||
"""
|
|
||||||
height: #{Math.round(lineHeight/2) + 2}px;
|
|
||||||
border-bottom: 2px solid #{colorScheme.strikeThroughForegroundColor};
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
_addMarkerWithCustomStyle: (range, klass, foreground, style) ->
|
|
||||||
if foreground?
|
|
||||||
markerLayer = @editor.renderer.$markerBack
|
|
||||||
else
|
|
||||||
markerLayer = @editor.renderer.$markerFront
|
|
||||||
|
|
||||||
@markerIds.push @editor.getSession().addMarker range, klass, (html, range, left, top, config) ->
|
|
||||||
if range.isMultiLine()
|
|
||||||
markerLayer.drawTextMarker(html, range, klass, config, style)
|
|
||||||
else
|
|
||||||
markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, style)
|
|
||||||
, foreground
|
|
||||||
|
|
||||||
_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,150 +0,0 @@
|
||||||
define [
|
|
||||||
"ace/range"
|
|
||||||
], () ->
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
|
|
||||||
class Highlight
|
|
||||||
constructor: (options) ->
|
|
||||||
@row = options.row
|
|
||||||
@column = options.column
|
|
||||||
@word = options.word
|
|
||||||
@suggestions = options.suggestions
|
|
||||||
|
|
||||||
class HighlightedWordManager
|
|
||||||
constructor: (@editor) ->
|
|
||||||
@highlights = rows: []
|
|
||||||
|
|
||||||
addHighlight: (highlight) ->
|
|
||||||
unless highlight instanceof Highlight
|
|
||||||
highlight = new Highlight(highlight)
|
|
||||||
range = new Range(
|
|
||||||
highlight.row, highlight.column,
|
|
||||||
highlight.row, highlight.column + highlight.word.length
|
|
||||||
)
|
|
||||||
highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight"
|
|
||||||
@highlights.rows[highlight.row] ||= []
|
|
||||||
@highlights.rows[highlight.row].push highlight
|
|
||||||
|
|
||||||
removeHighlight: (highlight) ->
|
|
||||||
@editor.getSession().removeMarker(highlight.markerId)
|
|
||||||
for h, i in @highlights.rows[highlight.row]
|
|
||||||
if h == highlight
|
|
||||||
@highlights.rows[highlight.row].splice(i, 1)
|
|
||||||
|
|
||||||
removeWord: (word) ->
|
|
||||||
toRemove = []
|
|
||||||
for row in @highlights.rows
|
|
||||||
for highlight in (row || [])
|
|
||||||
if highlight.word == word
|
|
||||||
toRemove.push(highlight)
|
|
||||||
for highlight in toRemove
|
|
||||||
@removeHighlight highlight
|
|
||||||
|
|
||||||
moveHighlight: (highlight, position) ->
|
|
||||||
@removeHighlight highlight
|
|
||||||
highlight.row = position.row
|
|
||||||
highlight.column = position.column
|
|
||||||
@addHighlight highlight
|
|
||||||
|
|
||||||
clearRows: (from, to) ->
|
|
||||||
from ||= 0
|
|
||||||
to ||= @highlights.rows.length - 1
|
|
||||||
for row in @highlights.rows.slice(from, to + 1)
|
|
||||||
for highlight in (row || []).slice(0)
|
|
||||||
@removeHighlight highlight
|
|
||||||
|
|
||||||
insertRows: (offset, number) ->
|
|
||||||
# rows are inserted after offset. i.e. offset row is not modified
|
|
||||||
affectedHighlights = []
|
|
||||||
for row in @highlights.rows.slice(offset)
|
|
||||||
affectedHighlights.push(highlight) for highlight in (row || [])
|
|
||||||
for highlight in affectedHighlights
|
|
||||||
@moveHighlight highlight,
|
|
||||||
row: highlight.row + number
|
|
||||||
column: highlight.column
|
|
||||||
|
|
||||||
removeRows: (offset, number) ->
|
|
||||||
# offset is the first row to delete
|
|
||||||
affectedHighlights = []
|
|
||||||
for row in @highlights.rows.slice(offset)
|
|
||||||
affectedHighlights.push(highlight) for highlight in (row || [])
|
|
||||||
for highlight in affectedHighlights
|
|
||||||
if highlight.row >= offset + number
|
|
||||||
@moveHighlight highlight,
|
|
||||||
row: highlight.row - number
|
|
||||||
column: highlight.column
|
|
||||||
else
|
|
||||||
@removeHighlight highlight
|
|
||||||
|
|
||||||
findHighlightWithinRange: (range) ->
|
|
||||||
rows = @highlights.rows.slice(range.start.row, range.end.row + 1)
|
|
||||||
for row in rows
|
|
||||||
for highlight in (row || [])
|
|
||||||
if @_doesHighlightOverlapRange(highlight, range.start, range.end)
|
|
||||||
return highlight
|
|
||||||
return null
|
|
||||||
|
|
||||||
applyChange: (change) ->
|
|
||||||
start = change.range.start
|
|
||||||
end = change.range.end
|
|
||||||
if change.action == "insertText"
|
|
||||||
if start.row != end.row
|
|
||||||
rowsAdded = end.row - start.row
|
|
||||||
@insertRows start.row + 1, rowsAdded
|
|
||||||
# make a copy since we're going to modify in place
|
|
||||||
oldHighlights = (@highlights.rows[start.row] || []).slice(0)
|
|
||||||
for highlight in oldHighlights
|
|
||||||
if highlight.column > start.column
|
|
||||||
# insertion was fully before this highlight
|
|
||||||
@moveHighlight highlight,
|
|
||||||
row: end.row
|
|
||||||
column: highlight.column + (end.column - start.column)
|
|
||||||
else if highlight.column + highlight.word.length >= start.column
|
|
||||||
# insertion was inside this highlight
|
|
||||||
@removeHighlight highlight
|
|
||||||
|
|
||||||
else if change.action == "insertLines"
|
|
||||||
@insertRows start.row, change.lines.length
|
|
||||||
|
|
||||||
else if change.action == "removeText"
|
|
||||||
if start.row == end.row
|
|
||||||
oldHighlights = (@highlights.rows[start.row] || []).slice(0)
|
|
||||||
else
|
|
||||||
rowsRemoved = end.row - start.row
|
|
||||||
oldHighlights =
|
|
||||||
(@highlights.rows[start.row] || []).concat(
|
|
||||||
(@highlights.rows[end.row] || [])
|
|
||||||
)
|
|
||||||
@removeRows start.row + 1, rowsRemoved
|
|
||||||
|
|
||||||
for highlight in oldHighlights
|
|
||||||
if @_doesHighlightOverlapRange highlight, start, end
|
|
||||||
@removeHighlight highlight
|
|
||||||
else if @_isHighlightAfterRange highlight, start, end
|
|
||||||
@moveHighlight highlight,
|
|
||||||
row: start.row
|
|
||||||
column: highlight.column - (end.column - start.column)
|
|
||||||
|
|
||||||
else if change.action == "removeLines"
|
|
||||||
@removeRows start.row, change.lines.length
|
|
||||||
|
|
||||||
_doesHighlightOverlapRange: (highlight, start, end) ->
|
|
||||||
highlightIsAllBeforeRange =
|
|
||||||
highlight.row < start.row or
|
|
||||||
(highlight.row == start.row and highlight.column + highlight.word.length <= start.column)
|
|
||||||
highlightIsAllAfterRange =
|
|
||||||
highlight.row > end.row or
|
|
||||||
(highlight.row == end.row and highlight.column >= end.column)
|
|
||||||
!(highlightIsAllBeforeRange or highlightIsAllAfterRange)
|
|
||||||
|
|
||||||
_isHighlightAfterRange: (highlight, start, end) ->
|
|
||||||
return true if highlight.row > end.row
|
|
||||||
return false if highlight.row < end.row
|
|
||||||
highlight.column >= end.column
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,191 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/editor/spell-check/HighlightedWordManager"
|
|
||||||
"ace/range"
|
|
||||||
], (HighlightedWordManager) ->
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
|
|
||||||
class SpellCheckManager
|
|
||||||
constructor: (@$scope, @editor, @element) ->
|
|
||||||
@updatedLines = []
|
|
||||||
@highlightedWordManager = new HighlightedWordManager(@editor)
|
|
||||||
|
|
||||||
@$scope.$watch "spellCheckLanguage", (language, oldLanguage) =>
|
|
||||||
if language != oldLanguage and oldLanguage?
|
|
||||||
@runFullCheck()
|
|
||||||
|
|
||||||
@editor.on "changeSession", (e) =>
|
|
||||||
@runFullCheck()
|
|
||||||
|
|
||||||
doc = e.session.getDocument()
|
|
||||||
doc.on "change", (e) =>
|
|
||||||
@runCheckOnChange(e)
|
|
||||||
|
|
||||||
@$scope.spellingMenu = {left: '0px', top: '0px'}
|
|
||||||
|
|
||||||
@editor.on "nativecontextmenu", (e) =>
|
|
||||||
@closeContextMenu(e.domEvent)
|
|
||||||
@openContextMenu(e.domEvent)
|
|
||||||
|
|
||||||
$(document).on "click", (e) =>
|
|
||||||
@closeContextMenu(e)
|
|
||||||
return true
|
|
||||||
# $(document).on "contextmenu", (e) =>
|
|
||||||
# @closeContextMenu(e)
|
|
||||||
|
|
||||||
@$scope.replaceWord = (highlight, suggestion) =>
|
|
||||||
@replaceWord(highlight, suggestion)
|
|
||||||
|
|
||||||
@$scope.learnWord = (highlight) =>
|
|
||||||
@learnWord(highlight)
|
|
||||||
|
|
||||||
runFullCheck: () ->
|
|
||||||
console.log "Running full check"
|
|
||||||
@highlightedWordManager.clearRows()
|
|
||||||
if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
|
|
||||||
@runSpellCheck()
|
|
||||||
|
|
||||||
runCheckOnChange: (e) ->
|
|
||||||
console.log "Checking change", e.data
|
|
||||||
if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
|
|
||||||
@highlightedWordManager.applyChange(e.data)
|
|
||||||
@markLinesAsUpdated(e.data)
|
|
||||||
@runSpellCheckSoon()
|
|
||||||
|
|
||||||
openContextMenu: (e) ->
|
|
||||||
position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
|
|
||||||
highlight = @highlightedWordManager.findHighlightWithinRange
|
|
||||||
start: position
|
|
||||||
end: position
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.spellingMenu.highlight = highlight
|
|
||||||
|
|
||||||
console.log "highlight", @$scope.highlight_under_mouse
|
|
||||||
|
|
||||||
if highlight
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
@editor.getSession().getSelection().setSelectionRange(
|
|
||||||
new Range(
|
|
||||||
highlight.row, highlight.column
|
|
||||||
highlight.row, highlight.column + highlight.word.length
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log "Height", @element.find(".context-menu").height()
|
|
||||||
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.spellingMenu.open = true
|
|
||||||
@$scope.spellingMenu.left = e.clientX + 'px'
|
|
||||||
@$scope.spellingMenu.top = e.clientY + 'px'
|
|
||||||
|
|
||||||
closeContextMenu: (e) ->
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.spellingMenu.open = false
|
|
||||||
|
|
||||||
replaceWord: (highlight, text) ->
|
|
||||||
@editor.getSession().replace(new Range(
|
|
||||||
highlight.row, highlight.column,
|
|
||||||
highlight.row, highlight.column + highlight.word.length
|
|
||||||
), text)
|
|
||||||
|
|
||||||
learnWord: (highlight) ->
|
|
||||||
@apiRequest "/learn", word: highlight.word
|
|
||||||
@highlightedWordManager.removeWord highlight.word
|
|
||||||
|
|
||||||
getHighlightedWordAtCursor: () ->
|
|
||||||
cursor = @editor.getCursorPosition()
|
|
||||||
highlight = @highlightedWordManager.findHighlightWithinRange
|
|
||||||
start: cursor
|
|
||||||
end: cursor
|
|
||||||
return highlight
|
|
||||||
|
|
||||||
runSpellCheckSoon: () ->
|
|
||||||
run = () =>
|
|
||||||
delete @timeoutId
|
|
||||||
@runSpellCheck(@updatedLines)
|
|
||||||
@updatedLines = []
|
|
||||||
if @timeoutId?
|
|
||||||
clearTimeout @timeoutId
|
|
||||||
@timeoutId = setTimeout run, 1000
|
|
||||||
|
|
||||||
markLinesAsUpdated: (change) ->
|
|
||||||
start = change.range.start
|
|
||||||
end = change.range.end
|
|
||||||
|
|
||||||
insertLines = () =>
|
|
||||||
lines = end.row - start.row
|
|
||||||
while lines--
|
|
||||||
@updatedLines.splice(start.row, 0, true)
|
|
||||||
|
|
||||||
removeLines = () =>
|
|
||||||
lines = end.row - start.row
|
|
||||||
while lines--
|
|
||||||
@updatedLines.splice(start.row + 1, 1)
|
|
||||||
|
|
||||||
if change.action == "insertText"
|
|
||||||
@updatedLines[start.row] = true
|
|
||||||
insertLines()
|
|
||||||
else if change.action == "removeText"
|
|
||||||
@updatedLines[start.row] = true
|
|
||||||
removeLines()
|
|
||||||
else if change.action == "insertLines"
|
|
||||||
insertLines()
|
|
||||||
else if change.action == "removeLines"
|
|
||||||
removeLines()
|
|
||||||
|
|
||||||
runSpellCheck: (linesToProcess) ->
|
|
||||||
{words, positions} = @getWords(linesToProcess)
|
|
||||||
language = @$scope.spellCheckLanguage
|
|
||||||
@apiRequest "/check", {language: language, words: words}, (error, result) =>
|
|
||||||
if error? or !result? or !result.misspellings?
|
|
||||||
return null
|
|
||||||
|
|
||||||
if linesToProcess?
|
|
||||||
for shouldProcess, row in linesToProcess
|
|
||||||
@highlightedWordManager.clearRows(row, row) if shouldProcess
|
|
||||||
else
|
|
||||||
@highlightedWordManager.clearRows()
|
|
||||||
|
|
||||||
for misspelling in result.misspellings
|
|
||||||
word = words[misspelling.index]
|
|
||||||
position = positions[misspelling.index]
|
|
||||||
@highlightedWordManager.addHighlight
|
|
||||||
column: position.column
|
|
||||||
row: position.row
|
|
||||||
word: word
|
|
||||||
suggestions: misspelling.suggestions
|
|
||||||
|
|
||||||
getWords: (linesToProcess) ->
|
|
||||||
lines = @editor.getValue().split("\n")
|
|
||||||
words = []
|
|
||||||
positions = []
|
|
||||||
for line, row in lines
|
|
||||||
if !linesToProcess? or linesToProcess[row]
|
|
||||||
wordRegex = /\\?['a-zA-Z\u00C0-\u00FF]+/g
|
|
||||||
while (result = wordRegex.exec(line))
|
|
||||||
word = result[0]
|
|
||||||
if word[0] == "'"
|
|
||||||
word = word.slice(1)
|
|
||||||
if word[word.length - 1] == "'"
|
|
||||||
word = word.slice(0,-1)
|
|
||||||
positions.push row: row, column: result.index
|
|
||||||
words.push(word)
|
|
||||||
return words: words, positions: positions
|
|
||||||
|
|
||||||
apiRequest: (endpoint, data, callback = (error, result) ->)->
|
|
||||||
data.token = window.user.id
|
|
||||||
data._csrf = window.csrfToken
|
|
||||||
options =
|
|
||||||
url: "/spelling" + endpoint
|
|
||||||
type: "POST"
|
|
||||||
dataType: "json"
|
|
||||||
headers:
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
data: JSON.stringify data
|
|
||||||
success: (data, status, xhr) ->
|
|
||||||
callback null, data
|
|
||||||
error: (xhr, status, error) ->
|
|
||||||
callback error
|
|
||||||
$.ajax options
|
|
|
@ -1,82 +0,0 @@
|
||||||
define [
|
|
||||||
"libs/backbone"
|
|
||||||
"libs/mustache"
|
|
||||||
], () ->
|
|
||||||
SUGGESTIONS_TO_SHOW = 5
|
|
||||||
|
|
||||||
SpellingMenuView = Backbone.View.extend
|
|
||||||
templates:
|
|
||||||
menu: $("#spellingMenuTemplate").html()
|
|
||||||
entry: $("#spellingMenuEntryTemplate").html()
|
|
||||||
|
|
||||||
events:
|
|
||||||
"click a#learnWord": ->
|
|
||||||
@trigger "click:learn", @_currentHighlight
|
|
||||||
@hide()
|
|
||||||
|
|
||||||
initialize: (options) ->
|
|
||||||
@ide = options.ide
|
|
||||||
@ide.editor.getContainerElement().append @render().el
|
|
||||||
@ide.editor.on "click", () => @hide()
|
|
||||||
@ide.editor.on "scroll", () => @hide()
|
|
||||||
@ide.editor.on "update:doc", () => @hide()
|
|
||||||
@ide.editor.on "change:doc", () => @hide()
|
|
||||||
|
|
||||||
render: () ->
|
|
||||||
@setElement($(@templates.menu))
|
|
||||||
@$el.css "z-index" : 10000
|
|
||||||
@$(".dropdown-toggle").dropdown()
|
|
||||||
@hide()
|
|
||||||
return @
|
|
||||||
|
|
||||||
showForHighlight: (highlight) ->
|
|
||||||
if @_currentHighlight? and highlight != @_currentHighlight
|
|
||||||
@_close()
|
|
||||||
|
|
||||||
if !@_currentHighlight?
|
|
||||||
@_currentHighlight = highlight
|
|
||||||
@_setSuggestions(highlight)
|
|
||||||
position = @ide.editor.textToEditorCoordinates(
|
|
||||||
highlight.row
|
|
||||||
highlight.column + highlight.word.length
|
|
||||||
)
|
|
||||||
@_position(position.x, position.y)
|
|
||||||
@_show()
|
|
||||||
|
|
||||||
hideIfAppropriate: (cursorPosition) ->
|
|
||||||
if @_currentHighlight?
|
|
||||||
if !@_cursorCloseToHighlight(cursorPosition, @_currentHighlight) and !@_isOpen()
|
|
||||||
@hide()
|
|
||||||
|
|
||||||
hide: () ->
|
|
||||||
delete @_currentHighlight
|
|
||||||
@_close()
|
|
||||||
@$el.hide()
|
|
||||||
|
|
||||||
_setSuggestions: (highlight) ->
|
|
||||||
@$(".spelling-suggestion").remove()
|
|
||||||
divider = @$(".divider")
|
|
||||||
for suggestion in highlight.suggestions.slice(0, SUGGESTIONS_TO_SHOW)
|
|
||||||
do (suggestion) =>
|
|
||||||
entry = $(Mustache.to_html(@templates.entry, word: suggestion))
|
|
||||||
divider.before(entry)
|
|
||||||
entry.on "click", () =>
|
|
||||||
@trigger "click:suggestion", suggestion, highlight
|
|
||||||
|
|
||||||
_show: () -> @$el.show()
|
|
||||||
|
|
||||||
_isOpen: () ->
|
|
||||||
@$(".dropdown-menu").is(":visible")
|
|
||||||
|
|
||||||
_close: () ->
|
|
||||||
if @_isOpen()
|
|
||||||
@$el.dropdown("toggle")
|
|
||||||
|
|
||||||
_cursorCloseToHighlight: (position, highlight) ->
|
|
||||||
position.row == highlight.row and
|
|
||||||
position.column >= highlight.column and
|
|
||||||
position.column <= highlight.column + highlight.word.length + 1
|
|
||||||
|
|
||||||
_position: (x,y) -> @$el.css left: x, top: y
|
|
||||||
|
|
||||||
|
|
|
@ -1,374 +0,0 @@
|
||||||
define [
|
|
||||||
"ace/range"
|
|
||||||
"ace/edit_session"
|
|
||||||
"ace/document"
|
|
||||||
], () ->
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
EditSession = require("ace/edit_session").EditSession
|
|
||||||
Doc = require("ace/document").Document
|
|
||||||
|
|
||||||
class UndoManager
|
|
||||||
constructor: (@$scope, @editor) ->
|
|
||||||
@$scope.undo =
|
|
||||||
show_remote_warning: false
|
|
||||||
|
|
||||||
@reset()
|
|
||||||
@nextUpdateIsRemote = false
|
|
||||||
|
|
||||||
@editor.on "changeSession", (e) =>
|
|
||||||
console.log "setting undo manager", e.session
|
|
||||||
e.session.setUndoManager(@)
|
|
||||||
|
|
||||||
showUndoConflictWarning: () ->
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.undo.show_remote_warning = true
|
|
||||||
|
|
||||||
setTimeout () =>
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.undo.show_remote_warning = false
|
|
||||||
, 4000
|
|
||||||
|
|
||||||
reset: () ->
|
|
||||||
@undoStack = []
|
|
||||||
@redoStack = []
|
|
||||||
|
|
||||||
execute: (options) ->
|
|
||||||
aceDeltaSets = options.args[0]
|
|
||||||
@session = options.args[1]
|
|
||||||
return if !aceDeltaSets?
|
|
||||||
|
|
||||||
lines = @session.getDocument().getAllLines()
|
|
||||||
linesBeforeChange = @_revertAceDeltaSetsOnDocLines(aceDeltaSets, lines)
|
|
||||||
simpleDeltaSets = @_aceDeltaSetsToSimpleDeltaSets(aceDeltaSets, linesBeforeChange)
|
|
||||||
@undoStack.push(
|
|
||||||
deltaSets: simpleDeltaSets
|
|
||||||
remote: @nextUpdateIsRemote
|
|
||||||
)
|
|
||||||
@redoStack = []
|
|
||||||
@nextUpdateIsRemote = false
|
|
||||||
|
|
||||||
undo: (dontSelect) ->
|
|
||||||
localUpdatesMade = @_shiftLocalChangeToTopOfUndoStack()
|
|
||||||
return if !localUpdatesMade
|
|
||||||
|
|
||||||
update = @undoStack.pop()
|
|
||||||
return if !update?
|
|
||||||
|
|
||||||
if update.remote
|
|
||||||
@showUndoConflictWarning()
|
|
||||||
|
|
||||||
lines = @session.getDocument().getAllLines()
|
|
||||||
linesBeforeDelta = @_revertSimpleDeltaSetsOnDocLines(update.deltaSets, lines)
|
|
||||||
deltaSets = @_simpleDeltaSetsToAceDeltaSets(update.deltaSets, linesBeforeDelta)
|
|
||||||
selectionRange = @session.undoChanges(deltaSets, dontSelect)
|
|
||||||
@redoStack.push(update)
|
|
||||||
return selectionRange
|
|
||||||
|
|
||||||
redo: (dontSelect) ->
|
|
||||||
update = @redoStack.pop()
|
|
||||||
return if !update?
|
|
||||||
lines = @session.getDocument().getAllLines()
|
|
||||||
deltaSets = @_simpleDeltaSetsToAceDeltaSets(update.deltaSets, lines)
|
|
||||||
selectionRange = @session.redoChanges(deltaSets, dontSelect)
|
|
||||||
@undoStack.push(update)
|
|
||||||
return selectionRange
|
|
||||||
|
|
||||||
_shiftLocalChangeToTopOfUndoStack: () ->
|
|
||||||
head = []
|
|
||||||
localChangeExists = false
|
|
||||||
while @undoStack.length > 0
|
|
||||||
update = @undoStack.pop()
|
|
||||||
head.unshift update
|
|
||||||
if !update.remote
|
|
||||||
localChangeExists = true
|
|
||||||
break
|
|
||||||
|
|
||||||
if !localChangeExists
|
|
||||||
@undoStack = @undoStack.concat head
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
# Undo stack looks like undoStack ++ reorderedhead ++ head
|
|
||||||
# Reordered head starts of empty and consumes entries from head
|
|
||||||
# while keeping the localChange at the top for as long as it can
|
|
||||||
localChange = head.shift()
|
|
||||||
reorderedHead = [localChange]
|
|
||||||
while head.length > 0
|
|
||||||
remoteChange = head.shift()
|
|
||||||
localChange = reorderedHead.pop()
|
|
||||||
result = @_swapSimpleDeltaSetsOrder(localChange.deltaSets, remoteChange.deltaSets)
|
|
||||||
if result?
|
|
||||||
remoteChange.deltaSets = result[0]
|
|
||||||
localChange.deltaSets = result[1]
|
|
||||||
reorderedHead.push remoteChange
|
|
||||||
reorderedHead.push localChange
|
|
||||||
else
|
|
||||||
reorderedHead.push localChange
|
|
||||||
reorderedHead.push remoteChange
|
|
||||||
break
|
|
||||||
@undoStack = @undoStack.concat(reorderedHead).concat(head)
|
|
||||||
return true
|
|
||||||
|
|
||||||
|
|
||||||
_swapSimpleDeltaSetsOrder: (firstDeltaSets, secondDeltaSets) ->
|
|
||||||
newFirstDeltaSets = @_copyDeltaSets(firstDeltaSets)
|
|
||||||
newSecondDeltaSets = @_copyDeltaSets(secondDeltaSets)
|
|
||||||
for firstDeltaSet in newFirstDeltaSets.slice(0).reverse()
|
|
||||||
for firstDelta in firstDeltaSet.deltas.slice(0).reverse()
|
|
||||||
for secondDeltaSet in newSecondDeltaSets
|
|
||||||
for secondDelta in secondDeltaSet.deltas
|
|
||||||
success = @_swapSimpleDeltaOrderInPlace(firstDelta, secondDelta)
|
|
||||||
return null if !success
|
|
||||||
return [newSecondDeltaSets, newFirstDeltaSets]
|
|
||||||
|
|
||||||
_copyDeltaSets: (deltaSets) ->
|
|
||||||
newDeltaSets = []
|
|
||||||
for deltaSet in deltaSets
|
|
||||||
newDeltaSet =
|
|
||||||
deltas: []
|
|
||||||
group: deltaSet.group
|
|
||||||
newDeltaSets.push newDeltaSet
|
|
||||||
for delta in deltaSet.deltas
|
|
||||||
newDelta =
|
|
||||||
position: delta.position
|
|
||||||
newDelta.insert = delta.insert if delta.insert?
|
|
||||||
newDelta.remove = delta.remove if delta.remove?
|
|
||||||
newDeltaSet.deltas.push newDelta
|
|
||||||
return newDeltaSets
|
|
||||||
|
|
||||||
_swapSimpleDeltaOrderInPlace: (firstDelta, secondDelta) ->
|
|
||||||
result = @_swapSimpleDeltaOrder(firstDelta, secondDelta)
|
|
||||||
return false if !result?
|
|
||||||
firstDelta.position = result[1].position
|
|
||||||
secondDelta.position = result[0].position
|
|
||||||
return true
|
|
||||||
|
|
||||||
_swapSimpleDeltaOrder: (firstDelta, secondDelta) ->
|
|
||||||
if firstDelta.insert? and secondDelta.insert?
|
|
||||||
if secondDelta.position >= firstDelta.position + firstDelta.insert.length
|
|
||||||
secondDelta.position -= firstDelta.insert.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else if secondDelta.position > firstDelta.position
|
|
||||||
return null
|
|
||||||
else
|
|
||||||
firstDelta.position += secondDelta.insert.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else if firstDelta.remove? and secondDelta.remove?
|
|
||||||
if secondDelta.position >= firstDelta.position
|
|
||||||
secondDelta.position += firstDelta.remove.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else if secondDelta.position + secondDelta.remove.length > firstDelta.position
|
|
||||||
return null
|
|
||||||
else
|
|
||||||
firstDelta.position -= secondDelta.remove.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else if firstDelta.insert? and secondDelta.remove?
|
|
||||||
if secondDelta.position >= firstDelta.position + firstDelta.insert.length
|
|
||||||
secondDelta.position -= firstDelta.insert.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else if secondDelta.position + secondDelta.remove.length > firstDelta.position
|
|
||||||
return null
|
|
||||||
else
|
|
||||||
firstDelta.position -= secondDelta.remove.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else if firstDelta.remove? and secondDelta.insert?
|
|
||||||
if secondDelta.position >= firstDelta.position
|
|
||||||
secondDelta.position += firstDelta.remove.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else
|
|
||||||
firstDelta.position += secondDelta.insert.length
|
|
||||||
return [secondDelta, firstDelta]
|
|
||||||
else
|
|
||||||
throw "Unknown delta types"
|
|
||||||
|
|
||||||
_applyAceDeltasToDocLines: (deltas, docLines) ->
|
|
||||||
doc = new Doc(docLines.join("\n"))
|
|
||||||
doc.applyDeltas(deltas)
|
|
||||||
return doc.getAllLines()
|
|
||||||
|
|
||||||
_revertAceDeltaSetsOnDocLines: (deltaSets, docLines) ->
|
|
||||||
session = new EditSession(docLines.join("\n"))
|
|
||||||
session.undoChanges(deltaSets)
|
|
||||||
return session.getDocument().getAllLines()
|
|
||||||
|
|
||||||
_revertSimpleDeltaSetsOnDocLines: (deltaSets, docLines) ->
|
|
||||||
doc = docLines.join("\n")
|
|
||||||
for deltaSet in deltaSets.slice(0).reverse()
|
|
||||||
for delta in deltaSet.deltas.slice(0).reverse()
|
|
||||||
if delta.remove?
|
|
||||||
doc = doc.slice(0, delta.position) + delta.remove + doc.slice(delta.position)
|
|
||||||
else if delta.insert?
|
|
||||||
doc = doc.slice(0, delta.position) + doc.slice(delta.position + delta.insert.length)
|
|
||||||
else
|
|
||||||
throw "Unknown delta type"
|
|
||||||
return doc.split("\n")
|
|
||||||
|
|
||||||
_aceDeltaSetsToSimpleDeltaSets: (aceDeltaSets, docLines) ->
|
|
||||||
for deltaSet in aceDeltaSets
|
|
||||||
simpleDeltas = []
|
|
||||||
for delta in deltaSet.deltas
|
|
||||||
simpleDeltas.push @_aceDeltaToSimpleDelta(delta, docLines)
|
|
||||||
docLines = @_applyAceDeltasToDocLines([delta], docLines)
|
|
||||||
{
|
|
||||||
deltas: simpleDeltas
|
|
||||||
group: deltaSet.group
|
|
||||||
}
|
|
||||||
|
|
||||||
_simpleDeltaSetsToAceDeltaSets: (simpleDeltaSets, docLines) ->
|
|
||||||
for deltaSet in simpleDeltaSets
|
|
||||||
aceDeltas = []
|
|
||||||
for delta in deltaSet.deltas
|
|
||||||
newAceDeltas = @_simpleDeltaToAceDeltas(delta, docLines)
|
|
||||||
docLines = @_applyAceDeltasToDocLines(newAceDeltas, docLines)
|
|
||||||
aceDeltas = aceDeltas.concat newAceDeltas
|
|
||||||
{
|
|
||||||
deltas: aceDeltas
|
|
||||||
group: deltaSet.group
|
|
||||||
}
|
|
||||||
|
|
||||||
_aceDeltaToSimpleDelta: (aceDelta, docLines) ->
|
|
||||||
start = aceDelta.range.start
|
|
||||||
linesBefore = docLines.slice(0, start.row)
|
|
||||||
position =
|
|
||||||
linesBefore.join("").length + # full lines
|
|
||||||
linesBefore.length + # new line characters
|
|
||||||
start.column # partial line
|
|
||||||
switch aceDelta.action
|
|
||||||
when "insertText"
|
|
||||||
return {
|
|
||||||
position: position
|
|
||||||
insert: aceDelta.text
|
|
||||||
}
|
|
||||||
when "insertLines"
|
|
||||||
return {
|
|
||||||
position: position
|
|
||||||
insert: aceDelta.lines.join("\n") + "\n"
|
|
||||||
}
|
|
||||||
when "removeText"
|
|
||||||
return {
|
|
||||||
position: position
|
|
||||||
remove: aceDelta.text
|
|
||||||
}
|
|
||||||
when "removeLines"
|
|
||||||
return {
|
|
||||||
position: position
|
|
||||||
remove: aceDelta.lines.join("\n") + "\n"
|
|
||||||
}
|
|
||||||
else
|
|
||||||
throw "Unknown Ace action: #{aceDelta.action}"
|
|
||||||
|
|
||||||
_simplePositionToAcePosition: (position, docLines) ->
|
|
||||||
column = 0
|
|
||||||
row = 0
|
|
||||||
for line in docLines
|
|
||||||
if position > line.length
|
|
||||||
row++
|
|
||||||
position -= (line + "\n").length
|
|
||||||
else
|
|
||||||
column = position
|
|
||||||
break
|
|
||||||
return {row: row, column: column}
|
|
||||||
|
|
||||||
_textToAceActions: (simpleText, row, column, type) ->
|
|
||||||
aceDeltas = []
|
|
||||||
lines = simpleText.split("\n")
|
|
||||||
|
|
||||||
range = (options) -> new Range(options.start.row, options.start.column, options.end.row, options.end.column)
|
|
||||||
|
|
||||||
do stripFirstLine = () ->
|
|
||||||
firstLine = lines.shift()
|
|
||||||
if firstLine.length > 0
|
|
||||||
aceDeltas.push {
|
|
||||||
text: firstLine
|
|
||||||
range: range(
|
|
||||||
start: column: column, row: row
|
|
||||||
end: column: column + firstLine.length, row: row
|
|
||||||
)
|
|
||||||
action: "#{type}Text"
|
|
||||||
}
|
|
||||||
column += firstLine.length
|
|
||||||
|
|
||||||
do stripFirstNewLine = () ->
|
|
||||||
if lines.length > 0
|
|
||||||
aceDeltas.push {
|
|
||||||
text: "\n"
|
|
||||||
range: range(
|
|
||||||
start: column: column, row: row
|
|
||||||
end: column: 0, row: row + 1
|
|
||||||
)
|
|
||||||
action: "#{type}Text"
|
|
||||||
}
|
|
||||||
row += 1
|
|
||||||
|
|
||||||
do stripMiddleFullLines = () ->
|
|
||||||
middleLines = lines.slice(0, -1)
|
|
||||||
if middleLines.length > 0
|
|
||||||
aceDeltas.push {
|
|
||||||
lines: middleLines
|
|
||||||
range: range(
|
|
||||||
start: column: 0, row: row
|
|
||||||
end: column: 0, row: row + middleLines.length
|
|
||||||
)
|
|
||||||
action: "#{type}Lines"
|
|
||||||
}
|
|
||||||
row += middleLines.length
|
|
||||||
|
|
||||||
do stripLastLine = () ->
|
|
||||||
if lines.length > 0
|
|
||||||
lastLine = lines.pop()
|
|
||||||
aceDeltas.push {
|
|
||||||
text: lastLine
|
|
||||||
range: range(
|
|
||||||
start: column: 0, row: row
|
|
||||||
end: column: lastLine.length , row: row
|
|
||||||
)
|
|
||||||
action: "#{type}Text"
|
|
||||||
}
|
|
||||||
|
|
||||||
return aceDeltas
|
|
||||||
|
|
||||||
|
|
||||||
_simpleDeltaToAceDeltas: (simpleDelta, docLines) ->
|
|
||||||
{row, column} = @_simplePositionToAcePosition(simpleDelta.position, docLines)
|
|
||||||
|
|
||||||
if simpleDelta.insert?
|
|
||||||
return @_textToAceActions(simpleDelta.insert, row, column, "insert")
|
|
||||||
if simpleDelta.remove?
|
|
||||||
return @_textToAceActions(simpleDelta.remove, row, column, "remove").reverse()
|
|
||||||
else
|
|
||||||
throw "Unknown simple delta: #{simpleDelta}"
|
|
||||||
|
|
||||||
_concatSimpleDeltas: (deltas) ->
|
|
||||||
return [] if deltas.length == 0
|
|
||||||
|
|
||||||
concattedDeltas = []
|
|
||||||
previousDelta = deltas.shift()
|
|
||||||
for delta in deltas
|
|
||||||
if delta.insert? and previousDelta.insert?
|
|
||||||
if previousDelta.position + previousDelta.insert.length == delta.position
|
|
||||||
previousDelta =
|
|
||||||
insert: previousDelta.insert + delta.insert
|
|
||||||
position: previousDelta.position
|
|
||||||
else
|
|
||||||
concattedDeltas.push previousDelta
|
|
||||||
previousDelta = delta
|
|
||||||
|
|
||||||
else if delta.remove? and previousDelta.remove?
|
|
||||||
if previousDelta.position == delta.position
|
|
||||||
previousDelta =
|
|
||||||
remove: previousDelta.remove + delta.remove
|
|
||||||
position: delta.position
|
|
||||||
else
|
|
||||||
concattedDeltas.push previousDelta
|
|
||||||
previousDelta = delta
|
|
||||||
else
|
|
||||||
concattedDeltas.push previousDelta
|
|
||||||
previousDelta = delta
|
|
||||||
concattedDeltas.push previousDelta
|
|
||||||
|
|
||||||
|
|
||||||
return concattedDeltas
|
|
||||||
|
|
||||||
|
|
||||||
hasUndo: () -> @undoStack.length > 0
|
|
||||||
hasRedo: () -> @redoStack.length > 0
|
|
||||||
|
|
|
@ -1,281 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/file-tree/directives/fileEntity"
|
|
||||||
"ide/file-tree/directives/draggable"
|
|
||||||
"ide/file-tree/directives/droppable"
|
|
||||||
"ide/file-tree/controllers/FileTreeController"
|
|
||||||
"ide/file-tree/controllers/FileTreeEntityController"
|
|
||||||
"ide/file-tree/controllers/FileTreeFolderController"
|
|
||||||
"ide/file-tree/controllers/FileTreeRootFolderController"
|
|
||||||
], () ->
|
|
||||||
class FileTreeManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@$scope.$on "project:joined", =>
|
|
||||||
@loadRootFolder()
|
|
||||||
@loadDeletedDocs()
|
|
||||||
@$scope.$emit "file-tree:initialized"
|
|
||||||
|
|
||||||
@_bindToSocketEvents()
|
|
||||||
|
|
||||||
_bindToSocketEvents: () ->
|
|
||||||
@ide.socket.on "reciveNewDoc", (parent_folder_id, doc) =>
|
|
||||||
parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder
|
|
||||||
@$scope.$apply () ->
|
|
||||||
parent_folder.children.push {
|
|
||||||
name: doc.name
|
|
||||||
id: doc._id
|
|
||||||
type: "doc"
|
|
||||||
}
|
|
||||||
|
|
||||||
@ide.socket.on "reciveNewFile", (parent_folder_id, file) =>
|
|
||||||
parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder
|
|
||||||
@$scope.$apply () ->
|
|
||||||
parent_folder.children.push {
|
|
||||||
name: file.name
|
|
||||||
id: file._id
|
|
||||||
type: "file"
|
|
||||||
}
|
|
||||||
|
|
||||||
@ide.socket.on "reciveNewFolder", (parent_folder_id, folder) =>
|
|
||||||
parent_folder = @findEntityById(parent_folder_id) or @$scope.rootFolder
|
|
||||||
@$scope.$apply () ->
|
|
||||||
parent_folder.children.push {
|
|
||||||
name: folder.name
|
|
||||||
id: folder._id
|
|
||||||
type: "folder"
|
|
||||||
children: []
|
|
||||||
}
|
|
||||||
|
|
||||||
@ide.socket.on "reciveEntityRename", (entity_id, name) =>
|
|
||||||
entity = @findEntityById(entity_id)
|
|
||||||
return if !entity?
|
|
||||||
@$scope.$apply () ->
|
|
||||||
entity.name = name
|
|
||||||
|
|
||||||
@ide.socket.on "removeEntity", (entity_id) =>
|
|
||||||
entity = @findEntityById(entity_id)
|
|
||||||
return if !entity?
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@_deleteEntityFromScope entity
|
|
||||||
|
|
||||||
@ide.socket.on "reciveEntityMove", (entity_id, folder_id) =>
|
|
||||||
entity = @findEntityById(entity_id)
|
|
||||||
folder = @findEntityById(folder_id)
|
|
||||||
console.log "Got recive ENTITY", entity_id, folder_id, entity, folder
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@_moveEntityInScope(entity, folder)
|
|
||||||
|
|
||||||
selectEntity: (entity) ->
|
|
||||||
@selected_entity_id = entity.id # For reselecting after a reconnect
|
|
||||||
@ide.fileTreeManager.forEachEntity (entity) ->
|
|
||||||
entity.selected = false
|
|
||||||
entity.selected = true
|
|
||||||
|
|
||||||
findSelectedEntity: () ->
|
|
||||||
selected = null
|
|
||||||
@forEachEntity (entity) ->
|
|
||||||
selected = entity if entity.selected
|
|
||||||
return selected
|
|
||||||
|
|
||||||
findEntityById: (id, options = {}) ->
|
|
||||||
return @$scope.rootFolder if @$scope.rootFolder.id == id
|
|
||||||
|
|
||||||
entity = @_findEntityByIdInFolder @$scope.rootFolder, id
|
|
||||||
return entity if entity?
|
|
||||||
|
|
||||||
if options.includeDeleted
|
|
||||||
for entity in @$scope.deletedDocs
|
|
||||||
return entity if entity.id == id
|
|
||||||
|
|
||||||
return null
|
|
||||||
|
|
||||||
_findEntityByIdInFolder: (folder, id) ->
|
|
||||||
for entity in folder.children or []
|
|
||||||
if entity.id == id
|
|
||||||
return entity
|
|
||||||
else if entity.children?
|
|
||||||
result = @_findEntityByIdInFolder(entity, id)
|
|
||||||
return result if result?
|
|
||||||
|
|
||||||
return null
|
|
||||||
|
|
||||||
findEntityByPath: (path) ->
|
|
||||||
@_findEntityByPathInFolder @$scope.rootFolder, path
|
|
||||||
|
|
||||||
_findEntityByPathInFolder: (folder, path) ->
|
|
||||||
parts = path.split("/")
|
|
||||||
name = parts.shift()
|
|
||||||
rest = parts.join("/")
|
|
||||||
|
|
||||||
if name == "."
|
|
||||||
return @_findEntityByPathInFolder(folder, rest)
|
|
||||||
|
|
||||||
for entity in folder.children
|
|
||||||
if entity.name == name
|
|
||||||
if rest == ""
|
|
||||||
return entity
|
|
||||||
else if entity.type == "folder"
|
|
||||||
return @_findEntityByPathInFolder(entity, rest)
|
|
||||||
return null
|
|
||||||
|
|
||||||
forEachEntity: (callback = (entity, parent_folder) ->) ->
|
|
||||||
@_forEachEntityInFolder(@$scope.rootFolder, callback)
|
|
||||||
|
|
||||||
for entity in @$scope.deletedDocs or []
|
|
||||||
callback(entity)
|
|
||||||
|
|
||||||
_forEachEntityInFolder: (folder, callback) ->
|
|
||||||
for entity in folder.children or []
|
|
||||||
callback(entity, folder)
|
|
||||||
if entity.children?
|
|
||||||
@_forEachEntityInFolder(entity, callback)
|
|
||||||
|
|
||||||
getEntityPath: (entity) ->
|
|
||||||
@_getEntityPathInFolder @$scope.rootFolder, entity
|
|
||||||
|
|
||||||
_getEntityPathInFolder: (folder, entity) ->
|
|
||||||
for child in folder.children or []
|
|
||||||
if child == entity
|
|
||||||
return entity.name
|
|
||||||
else if child.type == "folder"
|
|
||||||
path = @_getEntityPathInFolder(child, entity)
|
|
||||||
if path?
|
|
||||||
return child.name + "/" + path
|
|
||||||
return null
|
|
||||||
|
|
||||||
getRootDocDirname: () ->
|
|
||||||
rootDoc = @findEntityById @$scope.project.rootDoc_id
|
|
||||||
return if !rootDoc?
|
|
||||||
path = @getEntityPath(rootDoc)
|
|
||||||
return if !path?
|
|
||||||
return path.split("/").slice(0, -1).join("/")
|
|
||||||
|
|
||||||
# forEachFolder: (callback) ->
|
|
||||||
# @forEachEntity (entity) ->
|
|
||||||
# if entity.type == "folder"
|
|
||||||
# callback(entity)
|
|
||||||
|
|
||||||
loadRootFolder: () ->
|
|
||||||
@$scope.rootFolder = @_parseFolder(@$scope.project.rootFolder[0])
|
|
||||||
|
|
||||||
_parseFolder: (rawFolder) ->
|
|
||||||
folder = {
|
|
||||||
name: rawFolder.name
|
|
||||||
id: rawFolder._id
|
|
||||||
type: "folder"
|
|
||||||
children: []
|
|
||||||
selected: (rawFolder._id == @selected_entity_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for doc in rawFolder.docs or []
|
|
||||||
folder.children.push {
|
|
||||||
name: doc.name
|
|
||||||
id: doc._id
|
|
||||||
type: "doc"
|
|
||||||
selected: (doc._id == @selected_entity_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for file in rawFolder.fileRefs or []
|
|
||||||
folder.children.push {
|
|
||||||
name: file.name
|
|
||||||
id: file._id
|
|
||||||
type: "file"
|
|
||||||
selected: (file._id == @selected_entity_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
for childFolder in rawFolder.folders or []
|
|
||||||
folder.children.push @_parseFolder(childFolder)
|
|
||||||
|
|
||||||
return folder
|
|
||||||
|
|
||||||
loadDeletedDocs: () ->
|
|
||||||
@$scope.deletedDocs = []
|
|
||||||
for doc in @$scope.project.deletedDocs
|
|
||||||
@$scope.deletedDocs.push {
|
|
||||||
name: doc.name
|
|
||||||
id: doc._id
|
|
||||||
type: "doc"
|
|
||||||
deleted: true
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentFolder: () ->
|
|
||||||
# Return the root folder if nothing is selected
|
|
||||||
@_getCurrentFolder(@$scope.rootFolder) or @$scope.rootFolder
|
|
||||||
|
|
||||||
_getCurrentFolder: (startFolder = @$scope.rootFolder) ->
|
|
||||||
for entity in startFolder.children or []
|
|
||||||
# The 'current' folder is either the one selected, or
|
|
||||||
# the one containing the selected doc/file
|
|
||||||
if entity.selected
|
|
||||||
if entity.type == "folder"
|
|
||||||
return entity
|
|
||||||
else
|
|
||||||
return startFolder
|
|
||||||
|
|
||||||
if entity.type == "folder"
|
|
||||||
result = @_getCurrentFolder(entity)
|
|
||||||
return result if result?
|
|
||||||
|
|
||||||
return null
|
|
||||||
|
|
||||||
createDoc: (name, parent_folder = @getCurrentFolder()) ->
|
|
||||||
# We'll wait for the socket.io notification to actually
|
|
||||||
# add the doc for us.
|
|
||||||
@ide.$http.post "/project/#{@ide.project_id}/doc", {
|
|
||||||
name: name,
|
|
||||||
parent_folder_id: parent_folder?.id
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
createFolder: (name, parent_folder = @getCurrentFolder()) ->
|
|
||||||
# We'll wait for the socket.io notification to actually
|
|
||||||
# add the folder for us.
|
|
||||||
return @ide.$http.post "/project/#{@ide.project_id}/folder", {
|
|
||||||
name: name,
|
|
||||||
parent_folder_id: parent_folder?.id
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
renameEntity: (entity, name, callback = (error) ->) ->
|
|
||||||
return if entity.name == name
|
|
||||||
entity.name = name
|
|
||||||
return @ide.$http.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/rename", {
|
|
||||||
name: name,
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteEntity: (entity, callback = (error) ->) ->
|
|
||||||
# We'll wait for the socket.io notification to
|
|
||||||
# delete from scope.
|
|
||||||
return @ide.$http {
|
|
||||||
method: "DELETE"
|
|
||||||
url: "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}"
|
|
||||||
headers:
|
|
||||||
"X-Csrf-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
moveEntity: (entity, parent_folder, callback = (error) ->) ->
|
|
||||||
@_moveEntityInScope(entity, parent_folder)
|
|
||||||
return @ide.$http.post "/project/#{@ide.project_id}/#{entity.type}/#{entity.id}/move", {
|
|
||||||
folder_id: parent_folder.id
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
_deleteEntityFromScope: (entity, options = { moveToDeleted: true }) ->
|
|
||||||
parent_folder = null
|
|
||||||
@forEachEntity (possible_entity, folder) ->
|
|
||||||
if possible_entity == entity
|
|
||||||
parent_folder = folder
|
|
||||||
|
|
||||||
if parent_folder?
|
|
||||||
index = parent_folder.children.indexOf(entity)
|
|
||||||
if index > -1
|
|
||||||
parent_folder.children.splice(index, 1)
|
|
||||||
|
|
||||||
if entity.type == "doc" and options.moveToDeleted
|
|
||||||
entity.deleted = true
|
|
||||||
@$scope.deletedDocs.push entity
|
|
||||||
|
|
||||||
_moveEntityInScope: (entity, parent_folder) ->
|
|
||||||
return if entity in parent_folder.children
|
|
||||||
@_deleteEntityFromScope(entity, moveToDeleted: false)
|
|
||||||
parent_folder.children.push(entity)
|
|
|
@ -1,113 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "FileTreeController", ["$scope", "$modal", "ide", ($scope, $modal, ide) ->
|
|
||||||
$scope.openNewDocModal = () ->
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "newDocModalTemplate"
|
|
||||||
controller: "NewDocModalController"
|
|
||||||
resolve: {
|
|
||||||
parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.openNewFolderModal = () ->
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "newFolderModalTemplate"
|
|
||||||
controller: "NewFolderModalController"
|
|
||||||
resolve: {
|
|
||||||
parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.openUploadFileModal = () ->
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "uploadFileModalTemplate"
|
|
||||||
controller: "UploadFileModalController"
|
|
||||||
scope: $scope
|
|
||||||
resolve: {
|
|
||||||
parent_folder: () -> ide.fileTreeManager.getCurrentFolder()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.orderByFoldersFirst = (entity) ->
|
|
||||||
return '0' if entity.type == "folder"
|
|
||||||
return '1'
|
|
||||||
|
|
||||||
$scope.startRenamingSelected = () ->
|
|
||||||
$scope.$broadcast "rename:selected"
|
|
||||||
|
|
||||||
$scope.openDeleteModalForSelected = () ->
|
|
||||||
$scope.$broadcast "delete:selected"
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "NewDocModalController", [
|
|
||||||
"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
|
|
||||||
($scope, ide, $modalInstance, $timeout, parent_folder) ->
|
|
||||||
$scope.inputs =
|
|
||||||
name: "name.tex"
|
|
||||||
$scope.state =
|
|
||||||
inflight: false
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 200
|
|
||||||
|
|
||||||
$scope.create = () ->
|
|
||||||
$scope.state.inflight = true
|
|
||||||
ide.fileTreeManager
|
|
||||||
.createDoc($scope.inputs.name, parent_folder)
|
|
||||||
.success () ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "NewFolderModalController", [
|
|
||||||
"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
|
|
||||||
($scope, ide, $modalInstance, $timeout, parent_folder) ->
|
|
||||||
$scope.inputs =
|
|
||||||
name: "name"
|
|
||||||
$scope.state =
|
|
||||||
inflight: false
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 200
|
|
||||||
|
|
||||||
$scope.create = () ->
|
|
||||||
$scope.state.inflight = true
|
|
||||||
ide.fileTreeManager
|
|
||||||
.createFolder($scope.inputs.name, parent_folder)
|
|
||||||
.success () ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "UploadFileModalController", [
|
|
||||||
"$scope", "ide", "$modalInstance", "$timeout", "parent_folder",
|
|
||||||
($scope, ide, $modalInstance, $timeout, parent_folder) ->
|
|
||||||
console.log "PArent folder", parent_folder
|
|
||||||
$scope.parent_folder_id = parent_folder?.id
|
|
||||||
|
|
||||||
uploadCount = 0
|
|
||||||
$scope.onUpload = () ->
|
|
||||||
uploadCount++
|
|
||||||
|
|
||||||
$scope.onComplete = (error, name, response) ->
|
|
||||||
$timeout (() ->
|
|
||||||
uploadCount--
|
|
||||||
if uploadCount == 0 and response? and response.success
|
|
||||||
$modalInstance.close("done")
|
|
||||||
), 250
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
]
|
|
|
@ -1,49 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "FileTreeEntityController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
|
|
||||||
$scope.select = () ->
|
|
||||||
ide.fileTreeManager.selectEntity($scope.entity)
|
|
||||||
$scope.$emit "entity:selected", $scope.entity
|
|
||||||
|
|
||||||
$scope.inputs =
|
|
||||||
name: $scope.entity.name
|
|
||||||
|
|
||||||
$scope.startRenaming = () ->
|
|
||||||
$scope.entity.renaming = true
|
|
||||||
|
|
||||||
$scope.finishRenaming = () ->
|
|
||||||
delete $scope.entity.renaming
|
|
||||||
ide.fileTreeManager.renameEntity($scope.entity, $scope.inputs.name)
|
|
||||||
|
|
||||||
$scope.$on "rename:selected", () ->
|
|
||||||
$scope.startRenaming() if $scope.entity.selected
|
|
||||||
|
|
||||||
$scope.openDeleteModal = () ->
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "deleteEntityModalTemplate"
|
|
||||||
controller: "DeleteEntityModalController"
|
|
||||||
scope: $scope
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.$on "delete:selected", () ->
|
|
||||||
$scope.openDeleteModal() if $scope.entity.selected
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "DeleteEntityModalController", [
|
|
||||||
"$scope", "ide", "$modalInstance",
|
|
||||||
($scope, ide, $modalInstance) ->
|
|
||||||
$scope.state =
|
|
||||||
inflight: false
|
|
||||||
|
|
||||||
$scope.delete = () ->
|
|
||||||
$scope.state.inflight = true
|
|
||||||
ide.fileTreeManager
|
|
||||||
.deleteEntity($scope.entity)
|
|
||||||
.success () ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
]
|
|
|
@ -1,51 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "FileTreeFolderController", ["$scope", "ide", "$modal", ($scope, ide, $modal) ->
|
|
||||||
$scope.expanded = $.localStorage("folder.#{$scope.entity.id}.expanded") or false
|
|
||||||
|
|
||||||
$scope.toggleExpanded = () ->
|
|
||||||
$scope.expanded = !$scope.expanded
|
|
||||||
$.localStorage("folder.#{$scope.entity.id}.expanded", $scope.expanded)
|
|
||||||
|
|
||||||
$scope.onDrop = (events, ui) ->
|
|
||||||
source = $(ui.draggable).scope().entity
|
|
||||||
return if !source?
|
|
||||||
ide.fileTreeManager.moveEntity(source, $scope.entity)
|
|
||||||
|
|
||||||
$scope.orderByFoldersFirst = (entity) ->
|
|
||||||
# We need this here as well as in FileTreeController
|
|
||||||
# since the file-entity diretive creates a new scope
|
|
||||||
# that doesn't inherit from previous scopes.
|
|
||||||
return '0' if entity.type == "folder"
|
|
||||||
return '1'
|
|
||||||
|
|
||||||
$scope.openNewDocModal = () ->
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "newDocModalTemplate"
|
|
||||||
controller: "NewDocModalController"
|
|
||||||
resolve: {
|
|
||||||
parent_folder: () -> $scope.entity
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.openNewFolderModal = () ->
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "newFolderModalTemplate"
|
|
||||||
controller: "NewFolderModalController"
|
|
||||||
resolve: {
|
|
||||||
parent_folder: () -> $scope.entity
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.openUploadFileModal = () ->
|
|
||||||
$scope.project_id = ide.project_id
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "uploadFileModalTemplate"
|
|
||||||
controller: "UploadFileModalController"
|
|
||||||
scope: $scope
|
|
||||||
resolve: {
|
|
||||||
parent_folder: () -> $scope.entity
|
|
||||||
}
|
|
||||||
)
|
|
||||||
]
|
|
|
@ -1,13 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "FileTreeRootFolderController", ["$scope", "ide", ($scope, ide) ->
|
|
||||||
console.log "CREATING FileTreeRootFolderController"
|
|
||||||
rootFolder = $scope.rootFolder
|
|
||||||
console.log "ROOT FOLDER", rootFolder
|
|
||||||
$scope.onDrop = (events, ui) ->
|
|
||||||
source = $(ui.draggable).scope().entity
|
|
||||||
console.log "DROPPED INTO ROOT", source, rootFolder
|
|
||||||
return if !source?
|
|
||||||
ide.fileTreeManager.moveEntity(source, rootFolder)
|
|
||||||
]
|
|
|
@ -1,14 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "draggable", () ->
|
|
||||||
return {
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
scope.$watch attrs.draggable, (draggable) ->
|
|
||||||
if draggable
|
|
||||||
element.draggable
|
|
||||||
delay: 250
|
|
||||||
opacity: 0.7
|
|
||||||
helper: "clone"
|
|
||||||
scroll: true
|
|
||||||
}
|
|
|
@ -1,17 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "droppable", () ->
|
|
||||||
return {
|
|
||||||
scope: {
|
|
||||||
onDropCallback: "="
|
|
||||||
}
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
scope.$watch attrs.droppable, (droppable) ->
|
|
||||||
if droppable
|
|
||||||
element.droppable
|
|
||||||
greedy: true
|
|
||||||
hoverClass: "droppable-hover"
|
|
||||||
accept: attrs.accept
|
|
||||||
drop: scope.onDropCallback
|
|
||||||
}
|
|
|
@ -1,23 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "fileEntity", ["RecursionHelper", (RecursionHelper) ->
|
|
||||||
return {
|
|
||||||
restrict: "E"
|
|
||||||
scope: {
|
|
||||||
entity: "="
|
|
||||||
permissions: "="
|
|
||||||
}
|
|
||||||
templateUrl: "entityListItemTemplate"
|
|
||||||
compile: (element) ->
|
|
||||||
RecursionHelper.compile element, (scope, element, attrs, ctrl) ->
|
|
||||||
# Don't freak out if we're already in an apply callback
|
|
||||||
scope.$originalApply = scope.$apply
|
|
||||||
scope.$apply = (fn = () ->) ->
|
|
||||||
phase = @$root.$$phase
|
|
||||||
if (phase == '$apply' || phase == '$digest')
|
|
||||||
fn()
|
|
||||||
else
|
|
||||||
@$originalApply(fn);
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,71 +0,0 @@
|
||||||
define [
|
|
||||||
"../../../libs/md5"
|
|
||||||
], () ->
|
|
||||||
class OnlineUsersManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@$scope.onlineUsers = {}
|
|
||||||
@$scope.onlineUserCursorHighlights = {}
|
|
||||||
|
|
||||||
@$scope.$watch "editor.cursorPosition", (position) =>
|
|
||||||
if position?
|
|
||||||
@sendCursorPositionUpdate()
|
|
||||||
|
|
||||||
@ide.socket.on "clientTracking.clientUpdated", (client) =>
|
|
||||||
if client.id != @ide.socket.socket.sessionid # Check it's not me!
|
|
||||||
@$scope.$apply () =>
|
|
||||||
@$scope.onlineUsers[client.id] = client
|
|
||||||
@updateCursorHighlights()
|
|
||||||
|
|
||||||
@ide.socket.on "clientTracking.clientDisconnected", (client_id) =>
|
|
||||||
@$scope.$apply () =>
|
|
||||||
delete @$scope.onlineUsers[client_id]
|
|
||||||
@updateCursorHighlights()
|
|
||||||
|
|
||||||
updateCursorHighlights: () ->
|
|
||||||
console.log "UPDATING CURSOR HIGHLIGHTS"
|
|
||||||
@$scope.onlineUserCursorHighlights = {}
|
|
||||||
for client_id, client of @$scope.onlineUsers
|
|
||||||
doc_id = client.doc_id
|
|
||||||
continue if !doc_id?
|
|
||||||
@$scope.onlineUserCursorHighlights[doc_id] ||= []
|
|
||||||
@$scope.onlineUserCursorHighlights[doc_id].push {
|
|
||||||
label: client.name
|
|
||||||
cursor:
|
|
||||||
row: client.row
|
|
||||||
column: client.column
|
|
||||||
hue: @getHueForUserId(client.user_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
UPDATE_INTERVAL: 500
|
|
||||||
sendCursorPositionUpdate: () ->
|
|
||||||
console.log "SENDING CURSOR POSITION UPDATE", @$scope.editor.cursorPosition
|
|
||||||
if !@cursorUpdateTimeout?
|
|
||||||
@cursorUpdateTimeout = setTimeout ()=>
|
|
||||||
position = @$scope.editor.cursorPosition
|
|
||||||
doc_id = @$scope.editor.open_doc_id
|
|
||||||
|
|
||||||
@ide.socket.emit "clientTracking.updatePosition", {
|
|
||||||
row: position.row
|
|
||||||
column: position.column
|
|
||||||
doc_id: doc_id
|
|
||||||
}
|
|
||||||
|
|
||||||
delete @cursorUpdateTimeout
|
|
||||||
, @UPDATE_INTERVAL
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/pdf/controllers/PdfController"
|
|
||||||
"ide/pdf/directives/pdfJs"
|
|
||||||
], () ->
|
|
||||||
class PdfManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@$scope.pdf =
|
|
||||||
url: null # Pdf Url
|
|
||||||
error: false # Server error
|
|
||||||
timeout: false # Server timed out
|
|
||||||
failure: false # PDF failed to compile
|
|
||||||
compiling: false
|
|
||||||
uncompiled: true
|
|
||||||
logEntries: []
|
|
||||||
logEntryAnnotations: {}
|
|
||||||
rawLog: ""
|
|
||||||
view: null # 'pdf' 'logs'
|
|
||||||
showRawLog: false
|
|
||||||
highlights: []
|
|
||||||
position: null
|
|
|
@ -1,277 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
"libs/latex-log-parser"
|
|
||||||
], (App, LogParser) ->
|
|
||||||
App.controller "PdfController", ["$scope", "$http", "ide", "$modal", "synctex", ($scope, $http, ide, $modal, synctex) ->
|
|
||||||
autoCompile = true
|
|
||||||
$scope.$on "doc:opened", () ->
|
|
||||||
return if !autoCompile
|
|
||||||
autoCompile = false
|
|
||||||
$scope.recompile(isAutoCompile: true)
|
|
||||||
|
|
||||||
sendCompileRequest = (options = {}) ->
|
|
||||||
url = "/project/#{$scope.project_id}/compile"
|
|
||||||
if options.isAutoCompile
|
|
||||||
url += "?auto_compile=true"
|
|
||||||
return $http.post url, {
|
|
||||||
settingsOverride:
|
|
||||||
rootDoc_id: options.rootDocOverride_id or null
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
parseCompileResponse = (response) ->
|
|
||||||
# Reset everything
|
|
||||||
$scope.pdf.error = false
|
|
||||||
$scope.pdf.timedout = false
|
|
||||||
$scope.pdf.failure = false
|
|
||||||
$scope.pdf.uncompiled = false
|
|
||||||
$scope.pdf.url = null
|
|
||||||
|
|
||||||
if response.status == "timedout"
|
|
||||||
$scope.pdf.timedout = true
|
|
||||||
else if response.status == "autocompile-backoff"
|
|
||||||
$scope.pdf.uncompiled = true
|
|
||||||
else if response.status == "failure"
|
|
||||||
$scope.pdf.failure = true
|
|
||||||
fetchLogs()
|
|
||||||
else if response.status == "success"
|
|
||||||
$scope.pdf.url = "/project/#{$scope.project_id}/output/output.pdf?cache_bust=#{Date.now()}"
|
|
||||||
fetchLogs()
|
|
||||||
|
|
||||||
IGNORE_FILES = ["output.fls", "output.fdb_latexmk"]
|
|
||||||
$scope.pdf.outputFiles = []
|
|
||||||
for file in response.outputFiles
|
|
||||||
if IGNORE_FILES.indexOf(file.path) == -1
|
|
||||||
# Turn 'output.blg' into 'blg file'.
|
|
||||||
if file.path.match(/^output\./)
|
|
||||||
file.name = "#{file.path.replace(/^output\./, "")} file"
|
|
||||||
else
|
|
||||||
file.name = file.path
|
|
||||||
$scope.pdf.outputFiles.push file
|
|
||||||
|
|
||||||
fetchLogs = () ->
|
|
||||||
$http.get "/project/#{$scope.project_id}/output/output.log"
|
|
||||||
.success (log) ->
|
|
||||||
$scope.pdf.rawLog = log
|
|
||||||
logEntries = LogParser.parse(log, ignoreDuplicates: true)
|
|
||||||
$scope.pdf.logEntries = logEntries
|
|
||||||
$scope.pdf.logEntries.all = logEntries.errors.concat(logEntries.warnings).concat(logEntries.typesetting)
|
|
||||||
|
|
||||||
$scope.pdf.logEntryAnnotations = {}
|
|
||||||
for entry in logEntries.all
|
|
||||||
entry.file = normalizeFilePath(entry.file)
|
|
||||||
|
|
||||||
entity = ide.fileTreeManager.findEntityByPath(entry.file)
|
|
||||||
if entity?
|
|
||||||
$scope.pdf.logEntryAnnotations[entity.id] ||= []
|
|
||||||
$scope.pdf.logEntryAnnotations[entity.id].push {
|
|
||||||
row: entry.line - 1
|
|
||||||
type: if entry.level == "error" then "error" else "warning"
|
|
||||||
text: entry.message
|
|
||||||
}
|
|
||||||
|
|
||||||
.error () ->
|
|
||||||
$scope.pdf.logEntries = []
|
|
||||||
$scope.pdf.rawLog = ""
|
|
||||||
|
|
||||||
getRootDocOverride_id = () ->
|
|
||||||
doc = ide.editorManager.getCurrentDocValue()
|
|
||||||
return null if !doc?
|
|
||||||
for line in doc.split("\n")
|
|
||||||
match = line.match /(.*)\\documentclass/
|
|
||||||
if match and !match[1].match /%/
|
|
||||||
return ide.editorManager.getCurrentDocId()
|
|
||||||
return null
|
|
||||||
|
|
||||||
normalizeFilePath = (path) ->
|
|
||||||
path = path.replace(/^(.*)\/compiles\/[0-9a-f]{24}\/(\.\/)?/, "")
|
|
||||||
path = path.replace(/^\/compile\//, "")
|
|
||||||
|
|
||||||
rootDocDirname = ide.fileTreeManager.getRootDocDirname()
|
|
||||||
if rootDocDirname?
|
|
||||||
path = path.replace(/^\.\//, rootDocDirname + "/")
|
|
||||||
|
|
||||||
return path
|
|
||||||
|
|
||||||
$scope.recompile = (options = {}) ->
|
|
||||||
console.log "Recompiling", options
|
|
||||||
return if $scope.pdf.compiling
|
|
||||||
$scope.pdf.compiling = true
|
|
||||||
|
|
||||||
options.rootDocOverride_id = getRootDocOverride_id()
|
|
||||||
|
|
||||||
sendCompileRequest(options)
|
|
||||||
.success (data) ->
|
|
||||||
$scope.pdf.view = "pdf"
|
|
||||||
$scope.pdf.compiling = false
|
|
||||||
parseCompileResponse(data)
|
|
||||||
.error () ->
|
|
||||||
$scope.pdf.compiling = false
|
|
||||||
$scope.pdf.error = true
|
|
||||||
|
|
||||||
$scope.clearCache = () ->
|
|
||||||
$http {
|
|
||||||
url: "/project/#{$scope.project_id}/output"
|
|
||||||
method: "DELETE"
|
|
||||||
headers:
|
|
||||||
"X-Csrf-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.toggleLogs = () ->
|
|
||||||
if !$scope.pdf.view? or $scope.pdf.view == "pdf"
|
|
||||||
$scope.pdf.view = "logs"
|
|
||||||
else
|
|
||||||
$scope.pdf.view = "pdf"
|
|
||||||
|
|
||||||
$scope.showPdf = () ->
|
|
||||||
$scope.pdf.view = "pdf"
|
|
||||||
|
|
||||||
$scope.toggleRawLog = () ->
|
|
||||||
$scope.pdf.showRawLog = !$scope.pdf.showRawLog
|
|
||||||
|
|
||||||
$scope.openOutputFile = (file) ->
|
|
||||||
window.open("/project/#{$scope.project_id}/output/#{file.path}")
|
|
||||||
|
|
||||||
$scope.openClearCacheModal = () ->
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "clearCacheModalTemplate"
|
|
||||||
controller: "ClearCacheModalController"
|
|
||||||
scope: $scope
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.syncToCode = (position) ->
|
|
||||||
console.log "SYNCING VIA DBL CLICK", position
|
|
||||||
synctex
|
|
||||||
.syncToCode(position)
|
|
||||||
.then (data) ->
|
|
||||||
{doc, line} = data
|
|
||||||
ide.editorManager.openDoc(doc, gotoLine: line)
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) ->
|
|
||||||
synctex =
|
|
||||||
syncToPdf: (cursorPosition) ->
|
|
||||||
deferred = $q.defer()
|
|
||||||
|
|
||||||
doc_id = ide.editorManager.getCurrentDocId()
|
|
||||||
if !doc_id?
|
|
||||||
deferred.reject()
|
|
||||||
return deferred.promise
|
|
||||||
doc = ide.fileTreeManager.findEntityById(doc_id)
|
|
||||||
if !doc?
|
|
||||||
deferred.reject()
|
|
||||||
return deferred.promise
|
|
||||||
path = ide.fileTreeManager.getEntityPath(doc)
|
|
||||||
if !path?
|
|
||||||
deferred.reject()
|
|
||||||
return deferred.promise
|
|
||||||
|
|
||||||
# If the root file is folder/main.tex, then synctex sees the
|
|
||||||
# path as folder/./main.tex
|
|
||||||
rootDocDirname = ide.fileTreeManager.getRootDocDirname()
|
|
||||||
if rootDocDirname? and rootDocDirname != ""
|
|
||||||
path = path.replace(RegExp("^#{rootDocDirname}"), "#{rootDocDirname}/.")
|
|
||||||
|
|
||||||
{row, column} = cursorPosition
|
|
||||||
|
|
||||||
$http({
|
|
||||||
url: "/project/#{ide.project_id}/sync/code",
|
|
||||||
method: "GET",
|
|
||||||
params: {
|
|
||||||
file: path
|
|
||||||
line: row + 1
|
|
||||||
column: column
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.success (data) ->
|
|
||||||
deferred.resolve(data.pdf or [])
|
|
||||||
.error (error) ->
|
|
||||||
deferred.reject(error)
|
|
||||||
|
|
||||||
return deferred.promise
|
|
||||||
|
|
||||||
syncToCode: (position, options = {}) ->
|
|
||||||
deferred = $q.defer()
|
|
||||||
if !position?
|
|
||||||
deferred.reject()
|
|
||||||
return deferred.promise
|
|
||||||
|
|
||||||
# It's not clear exactly where we should sync to if it wasn't directly
|
|
||||||
# clicked on, but a little bit down from the very top seems best.
|
|
||||||
if options.includeVisualOffset
|
|
||||||
position.offset.top = position.offset.top + 80
|
|
||||||
|
|
||||||
$http({
|
|
||||||
url: "/project/#{ide.project_id}/sync/pdf",
|
|
||||||
method: "GET",
|
|
||||||
params: {
|
|
||||||
page: position.page + 1
|
|
||||||
h: position.offset.left.toFixed(2)
|
|
||||||
v: position.offset.top.toFixed(2)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.success (data) ->
|
|
||||||
if data.code? and data.code.length > 0
|
|
||||||
doc = ide.fileTreeManager.findEntityByPath(data.code[0].file)
|
|
||||||
return if !doc?
|
|
||||||
deferred.resolve({doc: doc, line: data.code[0].line})
|
|
||||||
.error (error) ->
|
|
||||||
deferred.reject(error)
|
|
||||||
|
|
||||||
return deferred.promise
|
|
||||||
|
|
||||||
return synctex
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "PdfSynctexController", ["$scope", "synctex", "ide", ($scope, synctex, ide) ->
|
|
||||||
$scope.showControls = true
|
|
||||||
$scope.$on "layout:pdf:resize", (event, data) ->
|
|
||||||
if data.east.initClosed
|
|
||||||
$scope.showControls = false
|
|
||||||
else
|
|
||||||
$scope.showControls = true
|
|
||||||
setTimeout () ->
|
|
||||||
$scope.$digest()
|
|
||||||
, 0
|
|
||||||
|
|
||||||
$scope.syncToPdf = () ->
|
|
||||||
synctex
|
|
||||||
.syncToPdf($scope.editor.cursorPosition)
|
|
||||||
.then (highlights) ->
|
|
||||||
$scope.pdf.highlights = highlights
|
|
||||||
|
|
||||||
$scope.syncToCode = () ->
|
|
||||||
synctex
|
|
||||||
.syncToCode($scope.pdf.position, includeVisualOffset: true)
|
|
||||||
.then (data) ->
|
|
||||||
{doc, line} = data
|
|
||||||
console.log "OPENING DOC", doc, line
|
|
||||||
ide.editorManager.openDoc(doc, gotoLine: line)
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "PdfLogEntryController", ["$scope", "ide", ($scope, ide) ->
|
|
||||||
$scope.openInEditor = (entry) ->
|
|
||||||
console.log "OPENING", entry.file, entry.line
|
|
||||||
entity = ide.fileTreeManager.findEntityByPath(entry.file)
|
|
||||||
return if !entity? or entity.type != "doc"
|
|
||||||
if entry.line?
|
|
||||||
line = entry.line
|
|
||||||
ide.editorManager.openDoc(entity, gotoLine: line)
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller 'ClearCacheModalController', ["$scope", "$modalInstance", ($scope, $modalInstance) ->
|
|
||||||
$scope.state =
|
|
||||||
inflight: false
|
|
||||||
|
|
||||||
$scope.clear = () ->
|
|
||||||
$scope.state.inflight = true
|
|
||||||
$scope
|
|
||||||
.clearCache()
|
|
||||||
.then () ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
]
|
|
|
@ -1,196 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
"libs/pdfListView/PdfListView"
|
|
||||||
"libs/pdfListView/TextLayerBuilder"
|
|
||||||
"libs/pdfListView/AnnotationsLayerBuilder"
|
|
||||||
"libs/pdfListView/HighlightsLayerBuilder"
|
|
||||||
"text!libs/pdfListView/TextLayer.css"
|
|
||||||
"text!libs/pdfListView/AnnotationsLayer.css"
|
|
||||||
"text!libs/pdfListView/HighlightsLayer.css"
|
|
||||||
], (
|
|
||||||
App
|
|
||||||
PDFListView
|
|
||||||
TextLayerBuilder
|
|
||||||
AnnotationsLayerBuilder
|
|
||||||
HighlightsLayerBuilder
|
|
||||||
textLayerCss
|
|
||||||
annotationsLayerCss
|
|
||||||
highlightsLayerCss
|
|
||||||
) ->
|
|
||||||
if PDFJS?
|
|
||||||
PDFJS.workerSrc = "#{window.sharelatex.pdfJsWorkerPath}"
|
|
||||||
|
|
||||||
style = $("<style/>")
|
|
||||||
style.text(textLayerCss + "\n" + annotationsLayerCss + "\n" + highlightsLayerCss)
|
|
||||||
$("body").append(style)
|
|
||||||
|
|
||||||
App.directive "pdfjs", ["$timeout", ($timeout) ->
|
|
||||||
return {
|
|
||||||
scope: {
|
|
||||||
"pdfSrc": "="
|
|
||||||
"highlights": "="
|
|
||||||
"position": "="
|
|
||||||
"dblClickCallback": "="
|
|
||||||
}
|
|
||||||
link: (scope, element, attrs) ->
|
|
||||||
pdfListView = new PDFListView element.find(".pdfjs-viewer")[0],
|
|
||||||
textLayerBuilder: TextLayerBuilder
|
|
||||||
annotationsLayerBuilder: AnnotationsLayerBuilder
|
|
||||||
highlightsLayerBuilder: HighlightsLayerBuilder
|
|
||||||
ondblclick: (e) -> onDoubleClick(e)
|
|
||||||
logLevel: PDFListView.Logger.DEBUG
|
|
||||||
pdfListView.listView.pageWidthOffset = 20
|
|
||||||
pdfListView.listView.pageHeightOffset = 20
|
|
||||||
|
|
||||||
scope.loading = false
|
|
||||||
|
|
||||||
onProgress = (progress) ->
|
|
||||||
scope.$apply () ->
|
|
||||||
scope.progress = Math.floor(progress.loaded/progress.total*100)
|
|
||||||
console.log "PROGRESS", scope.progress, progress.loaded, progress.total
|
|
||||||
|
|
||||||
initializedPosition = false
|
|
||||||
initializePosition = () ->
|
|
||||||
return if initializedPosition
|
|
||||||
initializedPosition = true
|
|
||||||
|
|
||||||
if (scale = $.localStorage("pdf.scale"))?
|
|
||||||
pdfListView.setScaleMode(scale.scaleMode, scale.scale)
|
|
||||||
else
|
|
||||||
pdfListView.setToFitWidth()
|
|
||||||
|
|
||||||
if (position = $.localStorage("pdf.position.#{attrs.key}"))
|
|
||||||
pdfListView.setPdfPosition(position)
|
|
||||||
|
|
||||||
scope.position = pdfListView.getPdfPosition(true)
|
|
||||||
|
|
||||||
$(window).unload () =>
|
|
||||||
$.localStorage "pdf.scale", {
|
|
||||||
scaleMode: pdfListView.getScaleMode()
|
|
||||||
scale: pdfListView.getScale()
|
|
||||||
}
|
|
||||||
$.localStorage "pdf.position.#{attrs.key}", pdfListView.getPdfPosition()
|
|
||||||
|
|
||||||
flashControls = () ->
|
|
||||||
scope.flashControls = true
|
|
||||||
$timeout () ->
|
|
||||||
scope.flashControls = false
|
|
||||||
, 1000
|
|
||||||
|
|
||||||
element.find(".pdfjs-viewer").scroll () ->
|
|
||||||
console.log "UPDATING POSITION", pdfListView.getPdfPosition(true)
|
|
||||||
scope.position = pdfListView.getPdfPosition(true)
|
|
||||||
|
|
||||||
onDoubleClick = (e) ->
|
|
||||||
scope.dblClickCallback?(page: e.page, offset: { top: e.y, left: e.x })
|
|
||||||
|
|
||||||
scope.$watch "pdfSrc", (url) ->
|
|
||||||
if url
|
|
||||||
scope.loading = true
|
|
||||||
scope.progress = 0
|
|
||||||
|
|
||||||
pdfListView
|
|
||||||
.loadPdf(url, onProgress)
|
|
||||||
.then () ->
|
|
||||||
scope.$apply () ->
|
|
||||||
scope.loading = false
|
|
||||||
delete scope.progress
|
|
||||||
initializePosition()
|
|
||||||
flashControls()
|
|
||||||
|
|
||||||
scope.$watch "highlights", (areas) ->
|
|
||||||
console.log "UPDATING HIGHLIGHTS", areas
|
|
||||||
return if !areas?
|
|
||||||
highlights = for area in areas or []
|
|
||||||
{
|
|
||||||
page: area.page - 1
|
|
||||||
highlight:
|
|
||||||
left: area.h
|
|
||||||
top: area.v
|
|
||||||
height: area.height
|
|
||||||
width: area.width
|
|
||||||
}
|
|
||||||
|
|
||||||
if highlights.length > 0
|
|
||||||
first = highlights[0]
|
|
||||||
pdfListView.setPdfPosition({
|
|
||||||
page: first.page
|
|
||||||
offset:
|
|
||||||
left: first.highlight.left
|
|
||||||
top: first.highlight.top - 80
|
|
||||||
}, true)
|
|
||||||
|
|
||||||
pdfListView.clearHighlights()
|
|
||||||
pdfListView.setHighlights(highlights, true)
|
|
||||||
|
|
||||||
setTimeout () =>
|
|
||||||
pdfListView.clearHighlights()
|
|
||||||
, 1000
|
|
||||||
|
|
||||||
scope.fitToHeight = () ->
|
|
||||||
pdfListView.setToFitHeight()
|
|
||||||
|
|
||||||
scope.fitToWidth = () ->
|
|
||||||
pdfListView.setToFitWidth()
|
|
||||||
|
|
||||||
scope.zoomIn = () ->
|
|
||||||
scale = pdfListView.getScale()
|
|
||||||
pdfListView.setScale(scale * 1.2)
|
|
||||||
|
|
||||||
scope.zoomOut = () ->
|
|
||||||
scale = pdfListView.getScale()
|
|
||||||
pdfListView.setScale(scale / 1.2)
|
|
||||||
|
|
||||||
if attrs.resizeOn?
|
|
||||||
for event in attrs.resizeOn.split(",")
|
|
||||||
scope.$on event, () ->
|
|
||||||
pdfListView.onResize()
|
|
||||||
|
|
||||||
template: """
|
|
||||||
<div class="pdfjs-viewer"></div>
|
|
||||||
<div class="pdfjs-controls" ng-class="{'flash': flashControls }">
|
|
||||||
<div class="btn-group">
|
|
||||||
<a href
|
|
||||||
class="btn btn-info btn-lg"
|
|
||||||
ng-click="fitToWidth()"
|
|
||||||
tooltip="Fit to Width"
|
|
||||||
tooltip-append-to-body="true"
|
|
||||||
tooltip-placement="bottom"
|
|
||||||
>
|
|
||||||
<i class="fa fa-fw fa-arrows-h"></i>
|
|
||||||
</a>
|
|
||||||
<a href
|
|
||||||
class="btn btn-info btn-lg"
|
|
||||||
ng-click="fitToHeight()"
|
|
||||||
tooltip="Fit to Height"
|
|
||||||
tooltip-append-to-body="true"
|
|
||||||
tooltip-placement="bottom"
|
|
||||||
>
|
|
||||||
<i class="fa fa-fw fa-arrows-v"></i>
|
|
||||||
</a>
|
|
||||||
<a href
|
|
||||||
class="btn btn-info btn-lg"
|
|
||||||
ng-click="zoomIn()"
|
|
||||||
tooltip="Zoom In"
|
|
||||||
tooltip-append-to-body="true"
|
|
||||||
tooltip-placement="bottom"
|
|
||||||
>
|
|
||||||
<i class="fa fa-fw fa-search-plus"></i>
|
|
||||||
</a>
|
|
||||||
<a href
|
|
||||||
class="btn btn-info btn-lg"
|
|
||||||
ng-click="zoomOut()"
|
|
||||||
tooltip="Zoom Out"
|
|
||||||
tooltip-append-to-body="true"
|
|
||||||
tooltip-placement="bottom"
|
|
||||||
>
|
|
||||||
<i class="fa fa-fw fa-search-minus"></i>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="progress-thin" ng-show="loading">
|
|
||||||
<div class="progress-bar" ng-style="{ 'width': progress + '%' }"></div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,19 +0,0 @@
|
||||||
define [], () ->
|
|
||||||
class PermissionsManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@$scope.$watch "permissionsLevel", (permissionsLevel) =>
|
|
||||||
@$scope.permissions =
|
|
||||||
read: false
|
|
||||||
write: false
|
|
||||||
admin: false
|
|
||||||
if permissionsLevel?
|
|
||||||
if permissionsLevel == "readOnly"
|
|
||||||
@$scope.permissions.read = true
|
|
||||||
else if permissionsLevel == "readAndWrite"
|
|
||||||
@$scope.permissions.read = true
|
|
||||||
@$scope.permissions.write = true
|
|
||||||
else if permissionsLevel == "owner"
|
|
||||||
@$scope.permissions.read = true
|
|
||||||
@$scope.permissions.write = true
|
|
||||||
@$scope.permissions.admin = true
|
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
# We create and provide this as service so that we can access the global ide
|
|
||||||
# from within other parts of the angular app.
|
|
||||||
App.factory "ide", ["$http", "$modal", ($http, $modal) ->
|
|
||||||
ide = {}
|
|
||||||
ide.$http = $http
|
|
||||||
|
|
||||||
ide.pushEvent = () ->
|
|
||||||
#console.log "PUSHING EVENT STUB", arguments
|
|
||||||
|
|
||||||
ide.reportError = () ->
|
|
||||||
console.log "REPORTING ERROR STUB", arguments
|
|
||||||
|
|
||||||
ide.showGenericServerErrorMessage = () ->
|
|
||||||
console.error "GENERIC SERVER ERROR MESSAGE STUB"
|
|
||||||
|
|
||||||
ide.showGenericMessageModal = (title, message) ->
|
|
||||||
$modal.open {
|
|
||||||
templateUrl: "genericMessageModalTemplate"
|
|
||||||
controller: "GenericMessageModalController"
|
|
||||||
resolve:
|
|
||||||
title: -> title
|
|
||||||
message: -> message
|
|
||||||
}
|
|
||||||
|
|
||||||
return ide
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "GenericMessageModalController", ["$scope", "$modalInstance", "title", "message", ($scope, $modalInstance, title, message) ->
|
|
||||||
$scope.title = title
|
|
||||||
$scope.message = message
|
|
||||||
|
|
||||||
$scope.done = () ->
|
|
||||||
$modalInstance.close()
|
|
||||||
]
|
|
|
@ -1,26 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "ProjectNameController", ["$scope", "settings", "ide", ($scope, settings, ide) ->
|
|
||||||
$scope.state =
|
|
||||||
renaming: false
|
|
||||||
$scope.inputs = {}
|
|
||||||
|
|
||||||
$scope.startRenaming = () ->
|
|
||||||
$scope.inputs.name = $scope.project.name
|
|
||||||
$scope.state.renaming = true
|
|
||||||
$scope.$emit "project:rename:start"
|
|
||||||
|
|
||||||
$scope.finishRenaming = () ->
|
|
||||||
$scope.project.name = $scope.inputs.name
|
|
||||||
settings.saveProjectSettings({name: $scope.inputs.name})
|
|
||||||
$scope.state.renaming = false
|
|
||||||
|
|
||||||
ide.socket.on "projectNameUpdated", (name) ->
|
|
||||||
$scope.$apply () ->
|
|
||||||
$scope.project.name = name
|
|
||||||
|
|
||||||
$scope.$watch "project.name", (name) ->
|
|
||||||
if name?
|
|
||||||
window.document.title = name + " - Online LaTeX Editor ShareLaTeX"
|
|
||||||
]
|
|
|
@ -1,51 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "SettingsController", ["$scope", "settings", "ide", ($scope, settings, ide) ->
|
|
||||||
if $scope.settings.mode not in ["default", "vim", "emacs"]
|
|
||||||
$scope.settings.mode = "default"
|
|
||||||
|
|
||||||
$scope.$watch "settings.theme", (theme, oldTheme) =>
|
|
||||||
if theme != oldTheme
|
|
||||||
settings.saveSettings({theme: theme})
|
|
||||||
|
|
||||||
$scope.$watch "settings.fontSize", (fontSize, oldFontSize) =>
|
|
||||||
if fontSize != oldFontSize
|
|
||||||
settings.saveSettings({fontSize: parseInt(fontSize, 10)})
|
|
||||||
|
|
||||||
$scope.$watch "settings.mode", (mode, oldMode) =>
|
|
||||||
if mode != oldMode
|
|
||||||
settings.saveSettings({mode: mode})
|
|
||||||
|
|
||||||
$scope.$watch "settings.autoComplete", (autoComplete, oldAutoComplete) =>
|
|
||||||
if autoComplete != oldAutoComplete
|
|
||||||
settings.saveSettings({autoComplete: autoComplete})
|
|
||||||
|
|
||||||
$scope.$watch "settings.pdfViewer", (pdfViewer, oldPdfViewer) =>
|
|
||||||
if pdfViewer != oldPdfViewer
|
|
||||||
settings.saveSettings({pdfViewer: pdfViewer})
|
|
||||||
|
|
||||||
$scope.$watch "project.spellCheckLanguage", (language, oldLanguage) =>
|
|
||||||
return if @ignoreUpdates
|
|
||||||
if oldLanguage? and language != oldLanguage
|
|
||||||
settings.saveProjectSettings({spellCheckLanguage: language})
|
|
||||||
# Also set it as the default for the user
|
|
||||||
settings.saveSettings({spellCheckLanguage: language})
|
|
||||||
|
|
||||||
$scope.$watch "project.compiler", (compiler, oldCompiler) =>
|
|
||||||
return if @ignoreUpdates
|
|
||||||
if oldCompiler? and compiler != oldCompiler
|
|
||||||
settings.saveProjectSettings({compiler: compiler})
|
|
||||||
|
|
||||||
ide.socket.on "compilerUpdated", (compiler) =>
|
|
||||||
@ignoreUpdates = true
|
|
||||||
$scope.$apply () =>
|
|
||||||
$scope.project.compiler = compiler
|
|
||||||
delete @ignoreUpdates
|
|
||||||
|
|
||||||
ide.socket.on "spellCheckLanguageUpdated", (languageCode) =>
|
|
||||||
@ignoreUpdates = true
|
|
||||||
$scope.$apply () =>
|
|
||||||
$scope.project.spellCheckLanguage = languageCode
|
|
||||||
delete @ignoreUpdates
|
|
||||||
]
|
|
|
@ -1,7 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/settings/services/settings"
|
|
||||||
"ide/settings/controllers/SettingsController"
|
|
||||||
"ide/settings/controllers/ProjectNameController"
|
|
||||||
|
|
||||||
], () ->
|
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.factory "settings", ["ide", (ide) ->
|
|
||||||
return {
|
|
||||||
saveSettings: (data) ->
|
|
||||||
data._csrf = window.csrfToken
|
|
||||||
ide.$http.post "/user/settings", data
|
|
||||||
|
|
||||||
saveProjectSettings: (data) ->
|
|
||||||
data._csrf = window.csrfToken
|
|
||||||
ide.$http.post "/project/#{ide.project_id}/settings", data
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,11 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
|
|
||||||
$scope.openShareProjectModal = () ->
|
|
||||||
$modal.open(
|
|
||||||
templateUrl: "shareProjectModalTemplate"
|
|
||||||
controller: "ShareProjectModalController"
|
|
||||||
scope: $scope
|
|
||||||
)
|
|
||||||
]
|
|
|
@ -1,104 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "ShareProjectModalController", ["$scope", "$modalInstance", "$timeout", "projectMembers", "$modal", ($scope, $modalInstance, $timeout, projectMembers, $modal) ->
|
|
||||||
$scope.inputs = {
|
|
||||||
privileges: "readAndWrite"
|
|
||||||
email: ""
|
|
||||||
}
|
|
||||||
$scope.state = {
|
|
||||||
error: null
|
|
||||||
inflight: false
|
|
||||||
startedFreeTrial: false
|
|
||||||
}
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 200
|
|
||||||
|
|
||||||
INFINITE_COLLABORATORS = -1
|
|
||||||
$scope.$watch "project.members.length", (noOfMembers) ->
|
|
||||||
allowedNoOfMembers = $scope.project.features.collaborators
|
|
||||||
$scope.canAddCollaborators = noOfMembers < allowedNoOfMembers or allowedNoOfMembers == INFINITE_COLLABORATORS
|
|
||||||
|
|
||||||
$scope.addMember = () ->
|
|
||||||
console.log "EMAIL", $scope.inputs.email
|
|
||||||
return if !$scope.inputs.email? or $scope.inputs.email == ""
|
|
||||||
$scope.state.error = null
|
|
||||||
$scope.state.inflight = true
|
|
||||||
projectMembers
|
|
||||||
.addMember($scope.inputs.email, $scope.inputs.privileges)
|
|
||||||
.then (user) ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$scope.inputs.email = ""
|
|
||||||
console.log "GOT USER", user
|
|
||||||
$scope.project.members.push user
|
|
||||||
.catch () ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$scope.state.error = "Sorry, something went wrong :("
|
|
||||||
|
|
||||||
|
|
||||||
$scope.removeMember = (member) ->
|
|
||||||
$scope.state.error = null
|
|
||||||
$scope.state.inflight = true
|
|
||||||
projectMembers
|
|
||||||
.removeMember(member)
|
|
||||||
.then () ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
index = $scope.project.members.indexOf(member)
|
|
||||||
return if index == -1
|
|
||||||
$scope.project.members.splice(index, 1)
|
|
||||||
.catch () ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$scope.state.error = "Sorry, something went wrong :("
|
|
||||||
|
|
||||||
$scope.startFreeTrial = () ->
|
|
||||||
ga?('send', 'event', 'subscription-funnel', 'upgraded-free-trial', "projectMembers")
|
|
||||||
window.open("/user/subscription/plans")
|
|
||||||
$scope.state.startedFreeTrial = true
|
|
||||||
|
|
||||||
$scope.openMakePublicModal = () ->
|
|
||||||
$modal.open {
|
|
||||||
templateUrl: "makePublicModalTemplate"
|
|
||||||
controller: "MakePublicModalController"
|
|
||||||
scope: $scope
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.openMakePrivateModal = () ->
|
|
||||||
$modal.open {
|
|
||||||
templateUrl: "makePrivateModalTemplate"
|
|
||||||
controller: "MakePrivateModalController"
|
|
||||||
scope: $scope
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.done = () ->
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss()
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "MakePublicModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
|
|
||||||
$scope.inputs = {
|
|
||||||
privileges: "readAndWrite"
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.makePublic = () ->
|
|
||||||
$scope.project.publicAccesLevel = $scope.inputs.privileges
|
|
||||||
settings.saveProjectSettings({publicAccessLevel: $scope.inputs.privileges})
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss()
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "MakePrivateModalController", ["$scope", "$modalInstance", "settings", ($scope, $modalInstance, settings) ->
|
|
||||||
$scope.makePrivate = () ->
|
|
||||||
$scope.project.publicAccesLevel = "private"
|
|
||||||
settings.saveProjectSettings({publicAccessLevel: "private"})
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss()
|
|
||||||
]
|
|
|
@ -1,5 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/share/controllers/ShareController"
|
|
||||||
"ide/share/controllers/ShareProjectModalController"
|
|
||||||
"ide/share/services/projectMembers"
|
|
||||||
], () ->
|
|
|
@ -1,30 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.factory "projectMembers", ["ide", "$q", (ide, $q) ->
|
|
||||||
return {
|
|
||||||
removeMember: (member) ->
|
|
||||||
deferred = $q.defer()
|
|
||||||
|
|
||||||
ide.socket.emit "removeUserFromProject", member._id, (error) =>
|
|
||||||
if error?
|
|
||||||
return deferred.reject(error)
|
|
||||||
deferred.resolve()
|
|
||||||
|
|
||||||
return deferred.promise
|
|
||||||
|
|
||||||
addMember: (email, privileges) ->
|
|
||||||
deferred = $q.defer()
|
|
||||||
|
|
||||||
ide.socket.emit "addUserToProject", email, privileges, (error, user) =>
|
|
||||||
if error?
|
|
||||||
return deferred.reject(error)
|
|
||||||
|
|
||||||
if !user
|
|
||||||
deferred.reject()
|
|
||||||
else
|
|
||||||
deferred.resolve(user)
|
|
||||||
|
|
||||||
return deferred.promise
|
|
||||||
}
|
|
||||||
]
|
|
|
@ -1,256 +0,0 @@
|
||||||
define [
|
|
||||||
"ide/track-changes/controllers/TrackChangesListController"
|
|
||||||
"ide/track-changes/controllers/TrackChangesDiffController"
|
|
||||||
"ide/track-changes/directives/infiniteScroll"
|
|
||||||
], () ->
|
|
||||||
class TrackChangesManager
|
|
||||||
constructor: (@ide, @$scope) ->
|
|
||||||
@reset()
|
|
||||||
|
|
||||||
@$scope.toggleTrackChanges = () =>
|
|
||||||
if @$scope.ui.view == "track-changes"
|
|
||||||
@hide()
|
|
||||||
else
|
|
||||||
@show()
|
|
||||||
|
|
||||||
@$scope.$watch "trackChanges.selection.updates", (updates) =>
|
|
||||||
if updates? and updates.length > 0
|
|
||||||
@_selectDocFromUpdates()
|
|
||||||
@reloadDiff()
|
|
||||||
|
|
||||||
@$scope.$on "entity:selected", (event, entity) =>
|
|
||||||
if (@$scope.ui.view == "track-changes") and (entity.type == "doc")
|
|
||||||
@$scope.trackChanges.selection.doc = entity
|
|
||||||
@reloadDiff()
|
|
||||||
|
|
||||||
show: () ->
|
|
||||||
@$scope.ui.view = "track-changes"
|
|
||||||
@reset()
|
|
||||||
|
|
||||||
hide: () ->
|
|
||||||
@$scope.ui.view = "editor"
|
|
||||||
# Make sure we run the 'open' logic for whatever is currently selected
|
|
||||||
@$scope.$emit "entity:selected", @ide.fileTreeManager.findSelectedEntity()
|
|
||||||
|
|
||||||
reset: () ->
|
|
||||||
@$scope.trackChanges = {
|
|
||||||
updates: []
|
|
||||||
nextBeforeTimestamp: null
|
|
||||||
atEnd: false
|
|
||||||
selection: {
|
|
||||||
updates: []
|
|
||||||
doc: null
|
|
||||||
range: {
|
|
||||||
fromV: null
|
|
||||||
toV: null
|
|
||||||
start_ts: null
|
|
||||||
end_ts: null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
diff: null
|
|
||||||
}
|
|
||||||
|
|
||||||
autoSelectRecentUpdates: () ->
|
|
||||||
console.log "AUTO SELECTING UPDATES", @$scope.trackChanges.updates.length
|
|
||||||
return if @$scope.trackChanges.updates.length == 0
|
|
||||||
|
|
||||||
@$scope.trackChanges.updates[0].selectedTo = true
|
|
||||||
|
|
||||||
indexOfLastUpdateNotByMe = 0
|
|
||||||
for update, i in @$scope.trackChanges.updates
|
|
||||||
if @_updateContainsUserId(update, @$scope.user.id)
|
|
||||||
break
|
|
||||||
indexOfLastUpdateNotByMe = i
|
|
||||||
|
|
||||||
@$scope.trackChanges.updates[indexOfLastUpdateNotByMe].selectedFrom = true
|
|
||||||
|
|
||||||
BATCH_SIZE: 4
|
|
||||||
fetchNextBatchOfUpdates: () ->
|
|
||||||
url = "/project/#{@ide.project_id}/updates?min_count=#{@BATCH_SIZE}"
|
|
||||||
if @$scope.trackChanges.nextBeforeTimestamp?
|
|
||||||
url += "&before=#{@$scope.trackChanges.nextBeforeTimestamp}"
|
|
||||||
@$scope.trackChanges.loading = true
|
|
||||||
@ide.$http
|
|
||||||
.get(url)
|
|
||||||
.success (data) =>
|
|
||||||
@_loadUpdates(data.updates)
|
|
||||||
@$scope.trackChanges.nextBeforeTimestamp = data.nextBeforeTimestamp
|
|
||||||
if !data.nextBeforeTimestamp?
|
|
||||||
@$scope.trackChanges.atEnd = true
|
|
||||||
@$scope.trackChanges.loading = false
|
|
||||||
|
|
||||||
reloadDiff: () ->
|
|
||||||
diff = @$scope.trackChanges.diff
|
|
||||||
{updates, doc} = @$scope.trackChanges.selection
|
|
||||||
{fromV, toV} = @_calculateRangeFromSelection()
|
|
||||||
|
|
||||||
console.log "Checking if diff has changed", doc?.id, fromV, toV, updates
|
|
||||||
|
|
||||||
return if !doc?
|
|
||||||
|
|
||||||
return if diff? and
|
|
||||||
diff.doc == doc and
|
|
||||||
diff.fromV == fromV and
|
|
||||||
diff.toV == toV
|
|
||||||
|
|
||||||
console.log "Loading diff", fromV, toV, doc?.id
|
|
||||||
|
|
||||||
@$scope.trackChanges.diff = diff = {
|
|
||||||
fromV: fromV
|
|
||||||
toV: toV
|
|
||||||
doc: doc
|
|
||||||
error: false
|
|
||||||
}
|
|
||||||
|
|
||||||
if !doc.deleted
|
|
||||||
diff.loading = true
|
|
||||||
url = "/project/#{@$scope.project_id}/doc/#{diff.doc.id}/diff"
|
|
||||||
if diff.fromV? and diff.toV?
|
|
||||||
url += "?from=#{diff.fromV}&to=#{diff.toV}"
|
|
||||||
|
|
||||||
@ide.$http
|
|
||||||
.get(url)
|
|
||||||
.success (data) =>
|
|
||||||
diff.loading = false
|
|
||||||
{text, highlights} = @_parseDiff(data)
|
|
||||||
diff.text = text
|
|
||||||
diff.highlights = highlights
|
|
||||||
.error () ->
|
|
||||||
diff.loading = false
|
|
||||||
diff.error = true
|
|
||||||
else
|
|
||||||
diff.deleted = true
|
|
||||||
console.log "DOC IS DELETED - NO DIFF FOR YOU!"
|
|
||||||
|
|
||||||
restoreDeletedDoc: (doc) ->
|
|
||||||
@ide.$http.post "/project/#{@$scope.project_id}/doc/#{doc.id}/restore", {
|
|
||||||
name: doc.name
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
_parseDiff: (diff) ->
|
|
||||||
row = 0
|
|
||||||
column = 0
|
|
||||||
highlights = []
|
|
||||||
text = ""
|
|
||||||
for entry, i in diff.diff or []
|
|
||||||
content = entry.u or entry.i or entry.d
|
|
||||||
content ||= ""
|
|
||||||
text += content
|
|
||||||
lines = content.split("\n")
|
|
||||||
startRow = row
|
|
||||||
startColumn = column
|
|
||||||
if lines.length > 1
|
|
||||||
endRow = startRow + lines.length - 1
|
|
||||||
endColumn = lines[lines.length - 1].length
|
|
||||||
else
|
|
||||||
endRow = startRow
|
|
||||||
endColumn = startColumn + lines[0].length
|
|
||||||
row = endRow
|
|
||||||
column = endColumn
|
|
||||||
|
|
||||||
range = {
|
|
||||||
start:
|
|
||||||
row: startRow
|
|
||||||
column: startColumn
|
|
||||||
end:
|
|
||||||
row: endRow
|
|
||||||
column: endColumn
|
|
||||||
}
|
|
||||||
|
|
||||||
if entry.i? or entry.d?
|
|
||||||
if entry.meta.user?
|
|
||||||
name = "#{entry.meta.user.first_name} #{entry.meta.user.last_name}"
|
|
||||||
else
|
|
||||||
name = "Anonymous"
|
|
||||||
if entry.meta.user?.id == @$scope.user.id
|
|
||||||
name = "you"
|
|
||||||
date = moment(entry.meta.end_ts).format("Do MMM YYYY, h:mm a")
|
|
||||||
if entry.i?
|
|
||||||
highlights.push {
|
|
||||||
label: "Added by #{name} on #{date}"
|
|
||||||
highlight: range
|
|
||||||
hue: @ide.onlineUsersManager.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {text, highlights}
|
|
||||||
|
|
||||||
_loadUpdates: (updates = []) ->
|
|
||||||
previousUpdate = @$scope.trackChanges.updates[@$scope.trackChanges.updates.length - 1]
|
|
||||||
|
|
||||||
for update in updates
|
|
||||||
for doc_id, doc of update.docs or {}
|
|
||||||
doc.entity = @ide.fileTreeManager.findEntityById(doc_id, includeDeleted: true)
|
|
||||||
|
|
||||||
for user in update.meta.users or []
|
|
||||||
user.hue = @ide.onlineUsersManager.getHueForUserId(user.id)
|
|
||||||
|
|
||||||
if !previousUpdate? or !moment(previousUpdate.meta.end_ts).isSame(update.meta.end_ts, "day")
|
|
||||||
update.meta.first_in_day = true
|
|
||||||
|
|
||||||
update.selectedFrom = false
|
|
||||||
update.selectedTo = false
|
|
||||||
update.inSelection = false
|
|
||||||
|
|
||||||
previousUpdate = update
|
|
||||||
|
|
||||||
firstLoad = @$scope.trackChanges.updates.length == 0
|
|
||||||
|
|
||||||
@$scope.trackChanges.updates =
|
|
||||||
@$scope.trackChanges.updates.concat(updates)
|
|
||||||
|
|
||||||
@autoSelectRecentUpdates() if firstLoad
|
|
||||||
|
|
||||||
_calculateRangeFromSelection: () ->
|
|
||||||
fromV = toV = start_ts = end_ts = null
|
|
||||||
|
|
||||||
selected_doc_id = @$scope.trackChanges.selection.doc?.id
|
|
||||||
|
|
||||||
for update in @$scope.trackChanges.selection.updates or []
|
|
||||||
for doc_id, doc of update.docs
|
|
||||||
if doc_id == selected_doc_id
|
|
||||||
if fromV? and toV?
|
|
||||||
fromV = Math.min(fromV, doc.fromV)
|
|
||||||
toV = Math.max(toV, doc.toV)
|
|
||||||
start_ts = Math.min(start_ts, update.meta.start_ts)
|
|
||||||
end_ts = Math.max(end_ts, update.meta.end_ts)
|
|
||||||
else
|
|
||||||
fromV = doc.fromV
|
|
||||||
toV = doc.toV
|
|
||||||
start_ts = update.meta.start_ts
|
|
||||||
end_ts = update.meta.end_ts
|
|
||||||
break
|
|
||||||
|
|
||||||
return {fromV, toV, start_ts, end_ts}
|
|
||||||
|
|
||||||
# Set the track changes selected doc to one of the docs in the range
|
|
||||||
# of currently selected updates. If we already have a selected doc
|
|
||||||
# then prefer this one if present.
|
|
||||||
_selectDocFromUpdates: () ->
|
|
||||||
affected_docs = {}
|
|
||||||
for update in @$scope.trackChanges.selection.updates
|
|
||||||
for doc_id, doc of update.docs
|
|
||||||
affected_docs[doc_id] = doc.entity
|
|
||||||
|
|
||||||
selected_doc = @$scope.trackChanges.selection.doc
|
|
||||||
if selected_doc? and affected_docs[selected_doc.id]?
|
|
||||||
console.log "An affected doc is already open, bravo!"
|
|
||||||
else
|
|
||||||
console.log "selected doc is not open, selecting first one"
|
|
||||||
for doc_id, doc of affected_docs
|
|
||||||
selected_doc = doc
|
|
||||||
break
|
|
||||||
|
|
||||||
@$scope.trackChanges.selection.doc = selected_doc
|
|
||||||
@ide.fileTreeManager.selectEntity(selected_doc)
|
|
||||||
|
|
||||||
_updateContainsUserId: (update, user_id) ->
|
|
||||||
for user in update.meta.users
|
|
||||||
return true if user.id == user_id
|
|
||||||
return false
|
|
|
@ -1,9 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "TrackChangesDiffController", ["$scope", "ide", ($scope, ide) ->
|
|
||||||
$scope.restoreDeletedDoc = () ->
|
|
||||||
ide.trackChangesManager.restoreDeletedDoc(
|
|
||||||
$scope.trackChanges.diff.doc
|
|
||||||
)
|
|
||||||
]
|
|
|
@ -1,109 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "TrackChangesListController", ["$scope", "ide", ($scope, ide) ->
|
|
||||||
$scope.hoveringOverListSelectors = false
|
|
||||||
|
|
||||||
$scope.loadMore = () =>
|
|
||||||
ide.trackChangesManager.fetchNextBatchOfUpdates()
|
|
||||||
|
|
||||||
$scope.recalculateSelectedUpdates = () ->
|
|
||||||
console.log "RECALCULATING UPDATES"
|
|
||||||
beforeSelection = true
|
|
||||||
afterSelection = false
|
|
||||||
$scope.trackChanges.selection.updates = []
|
|
||||||
for update in $scope.trackChanges.updates
|
|
||||||
if update.selectedTo
|
|
||||||
inSelection = true
|
|
||||||
beforeSelection = false
|
|
||||||
|
|
||||||
update.beforeSelection = beforeSelection
|
|
||||||
update.inSelection = inSelection
|
|
||||||
update.afterSelection = afterSelection
|
|
||||||
|
|
||||||
if inSelection
|
|
||||||
$scope.trackChanges.selection.updates.push update
|
|
||||||
|
|
||||||
if update.selectedFrom
|
|
||||||
inSelection = false
|
|
||||||
afterSelection = true
|
|
||||||
|
|
||||||
$scope.recalculateHoveredUpdates = () ->
|
|
||||||
hoverSelectedFrom = false
|
|
||||||
hoverSelectedTo = false
|
|
||||||
for update in $scope.trackChanges.updates
|
|
||||||
# Figure out whether the to or from selector is hovered over
|
|
||||||
if update.hoverSelectedFrom
|
|
||||||
hoverSelectedFrom = true
|
|
||||||
if update.hoverSelectedTo
|
|
||||||
hoverSelectedTo = true
|
|
||||||
|
|
||||||
if hoverSelectedFrom
|
|
||||||
# We want to 'hover select' everything between hoverSelectedFrom and selectedTo
|
|
||||||
inHoverSelection = false
|
|
||||||
for update in $scope.trackChanges.updates
|
|
||||||
if update.selectedTo
|
|
||||||
update.hoverSelectedTo = true
|
|
||||||
inHoverSelection = true
|
|
||||||
update.inHoverSelection = inHoverSelection
|
|
||||||
if update.hoverSelectedFrom
|
|
||||||
inHoverSelection = false
|
|
||||||
if hoverSelectedTo
|
|
||||||
# We want to 'hover select' everything between hoverSelectedTo and selectedFrom
|
|
||||||
inHoverSelection = false
|
|
||||||
for update in $scope.trackChanges.updates
|
|
||||||
if update.hoverSelectedTo
|
|
||||||
inHoverSelection = true
|
|
||||||
update.inHoverSelection = inHoverSelection
|
|
||||||
if update.selectedFrom
|
|
||||||
update.hoverSelectedFrom = true
|
|
||||||
inHoverSelection = false
|
|
||||||
|
|
||||||
$scope.resetHoverState = () ->
|
|
||||||
for update in $scope.trackChanges.updates
|
|
||||||
delete update.hoverSelectedFrom
|
|
||||||
delete update.hoverSelectedTo
|
|
||||||
delete update.inHoverSelection
|
|
||||||
|
|
||||||
$scope.$watch "trackChanges.updates.length", () ->
|
|
||||||
$scope.recalculateSelectedUpdates()
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "TrackChangesListItemController", ["$scope", ($scope) ->
|
|
||||||
$scope.$watch "update.selectedFrom", (selectedFrom, oldSelectedFrom) ->
|
|
||||||
if selectedFrom
|
|
||||||
console.log "SELECTED FROM CHANGED", $scope.update, selectedFrom, oldSelectedFrom
|
|
||||||
for update in $scope.trackChanges.updates
|
|
||||||
update.selectedFrom = false unless update == $scope.update
|
|
||||||
$scope.recalculateSelectedUpdates()
|
|
||||||
|
|
||||||
$scope.$watch "update.selectedTo", (selectedTo, oldSelectedTo) ->
|
|
||||||
if selectedTo
|
|
||||||
console.log "SELECTED TO CHANGED", $scope.update, selectedTo, oldSelectedTo
|
|
||||||
for update in $scope.trackChanges.updates
|
|
||||||
update.selectedTo = false unless update == $scope.update
|
|
||||||
$scope.recalculateSelectedUpdates()
|
|
||||||
|
|
||||||
$scope.select = () ->
|
|
||||||
$scope.update.selectedTo = true
|
|
||||||
$scope.update.selectedFrom = true
|
|
||||||
|
|
||||||
$scope.mouseOverSelectedFrom = () ->
|
|
||||||
$scope.trackChanges.hoveringOverListSelectors = true
|
|
||||||
$scope.update.hoverSelectedFrom = true
|
|
||||||
$scope.recalculateHoveredUpdates()
|
|
||||||
|
|
||||||
$scope.mouseOutSelectedFrom = () ->
|
|
||||||
$scope.trackChanges.hoveringOverListSelectors = false
|
|
||||||
$scope.resetHoverState()
|
|
||||||
|
|
||||||
$scope.mouseOverSelectedTo = () ->
|
|
||||||
$scope.trackChanges.hoveringOverListSelectors = true
|
|
||||||
$scope.update.hoverSelectedTo = true
|
|
||||||
$scope.recalculateHoveredUpdates()
|
|
||||||
|
|
||||||
$scope.mouseOutSelectedTo = () ->
|
|
||||||
$scope.trackChanges.hoveringOverListSelectors = false
|
|
||||||
$scope.resetHoverState()
|
|
||||||
|
|
||||||
]
|
|
|
@ -1,45 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.directive "infiniteScroll", () ->
|
|
||||||
return {
|
|
||||||
link: (scope, element, attrs, ctrl) ->
|
|
||||||
innerElement = element.find(".infinite-scroll-inner")
|
|
||||||
element.css 'overflow-y': 'auto'
|
|
||||||
|
|
||||||
atEndOfListView = () ->
|
|
||||||
element.scrollTop() + element.height() >= innerElement.height() - 30
|
|
||||||
|
|
||||||
listShorterThanContainer = () ->
|
|
||||||
element.innerHeight() > @$(".change-list").outerHeight()
|
|
||||||
|
|
||||||
loadUntilFull = () ->
|
|
||||||
if (listShorterThanContainer() or atEndOfListView()) and not scope.$eval(attrs.infiniteScrollDisabled)
|
|
||||||
console.log "Loading more"
|
|
||||||
promise = scope.$eval(attrs.infiniteScroll)
|
|
||||||
console.log promise
|
|
||||||
promise.then () ->
|
|
||||||
loadUntilFull()
|
|
||||||
# @collection.fetchNextBatch
|
|
||||||
# error: (error) =>
|
|
||||||
# @hideLoading()
|
|
||||||
# @showEmptyMessageIfCollectionEmpty()
|
|
||||||
# callback(error)
|
|
||||||
# success: (collection, response) =>
|
|
||||||
# @hideLoading()
|
|
||||||
# if @collection.isAtEnd()
|
|
||||||
# @atEndOfCollection = true
|
|
||||||
# @showEmptyMessageIfCollectionEmpty()
|
|
||||||
# callback()
|
|
||||||
# else
|
|
||||||
# @loadUntilFull(callback)
|
|
||||||
|
|
||||||
element.on "scroll", (event) ->
|
|
||||||
loadUntilFull()
|
|
||||||
|
|
||||||
scope.$watch attrs.infiniteScrollInitialize, (value) ->
|
|
||||||
console.log "INITIALIZE", value
|
|
||||||
if value
|
|
||||||
loadUntilFull()
|
|
||||||
|
|
||||||
}
|
|
|
@ -1,16 +0,0 @@
|
||||||
define [
|
|
||||||
"main/project-list"
|
|
||||||
"main/user-details"
|
|
||||||
"main/account-settings"
|
|
||||||
"main/plans"
|
|
||||||
"main/group-members"
|
|
||||||
"directives/asyncForm"
|
|
||||||
"directives/stopPropagation"
|
|
||||||
"directives/focus"
|
|
||||||
"directives/equals"
|
|
||||||
"directives/fineUpload"
|
|
||||||
"directives/onEnter"
|
|
||||||
"directives/selectAll"
|
|
||||||
"filters/formatDate"
|
|
||||||
], () ->
|
|
||||||
angular.bootstrap(document.body, ["SharelatexApp"])
|
|
|
@ -1,54 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "AccountSettingsController", ["$scope", "$http", "$modal", ($scope, $http, $modal) ->
|
|
||||||
$scope.subscribed = true
|
|
||||||
|
|
||||||
$scope.unsubscribe = () ->
|
|
||||||
$scope.unsubscribing = true
|
|
||||||
$http({
|
|
||||||
method: "DELETE"
|
|
||||||
url: "/user/newsletter/unsubscribe"
|
|
||||||
headers:
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
})
|
|
||||||
.success () ->
|
|
||||||
$scope.unsubscribing = false
|
|
||||||
$scope.subscribed = false
|
|
||||||
.error () ->
|
|
||||||
$scope.unsubscribing = true
|
|
||||||
|
|
||||||
$scope.deleteAccount = () ->
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "deleteAccountModalTemplate"
|
|
||||||
controller: "DeleteAccountModalController"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "DeleteAccountModalController", [
|
|
||||||
"$scope", "$modalInstance", "$timeout", "$http",
|
|
||||||
($scope, $modalInstance, $timeout, $http) ->
|
|
||||||
$scope.state =
|
|
||||||
inflight: false
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 700
|
|
||||||
|
|
||||||
$scope.delete = () ->
|
|
||||||
$scope.state.inflight = true
|
|
||||||
|
|
||||||
$http({
|
|
||||||
method: "DELETE"
|
|
||||||
url: "/user"
|
|
||||||
headers:
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
})
|
|
||||||
.success () ->
|
|
||||||
$modalInstance.close()
|
|
||||||
window.location = "/"
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
]
|
|
|
@ -1,54 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "GroupMembersController", ($scope, queuedHttp) ->
|
|
||||||
$scope.users = window.users
|
|
||||||
$scope.groupSize = window.groupSize
|
|
||||||
$scope.selectedUsers = []
|
|
||||||
|
|
||||||
$scope.inputs =
|
|
||||||
emails: ""
|
|
||||||
|
|
||||||
parseEmails = (emailsString)->
|
|
||||||
regexBySpaceOrComma = /[\s,]+/
|
|
||||||
emails = emailsString.split(regexBySpaceOrComma)
|
|
||||||
emails = _.map emails, (email)->
|
|
||||||
email = email.trim()
|
|
||||||
emails = _.select emails, (email)->
|
|
||||||
email.indexOf("@") != -1
|
|
||||||
return emails
|
|
||||||
|
|
||||||
$scope.addMembers = () ->
|
|
||||||
emails = parseEmails($scope.inputs.emails)
|
|
||||||
for email in emails
|
|
||||||
queuedHttp
|
|
||||||
.post("/subscription/group/user", {
|
|
||||||
email: email,
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
})
|
|
||||||
.success (data) ->
|
|
||||||
$scope.users.push data.user if data.user?
|
|
||||||
$scope.inputs.emails = ""
|
|
||||||
|
|
||||||
$scope.removeMembers = () ->
|
|
||||||
for user in $scope.selectedUsers
|
|
||||||
do (user) ->
|
|
||||||
queuedHttp({
|
|
||||||
method: "DELETE",
|
|
||||||
url: "/subscription/group/user/#{user._id}"
|
|
||||||
headers:
|
|
||||||
"X-Csrf-Token": window.csrfToken
|
|
||||||
})
|
|
||||||
.success () ->
|
|
||||||
index = $scope.users.indexOf(user)
|
|
||||||
return if index == -1
|
|
||||||
$scope.users.splice(index, 1)
|
|
||||||
$scope.selectedUsers = []
|
|
||||||
|
|
||||||
$scope.updateSelectedUsers = () ->
|
|
||||||
$scope.selectedUsers = $scope.users.filter (user) -> user.selected
|
|
||||||
|
|
||||||
App.controller "GroupMemberListItemController", ($scope) ->
|
|
||||||
$scope.$watch "user.selected", (value) ->
|
|
||||||
if value?
|
|
||||||
$scope.updateSelectedUsers()
|
|
|
@ -1,6 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.controller "PlansController", ($scope) ->
|
|
||||||
$scope.ui =
|
|
||||||
view: "monthly"
|
|
|
@ -1,583 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
], (App) ->
|
|
||||||
App.factory "queuedHttp", ["$http", "$q", ($http, $q) ->
|
|
||||||
pendingRequests = []
|
|
||||||
inflight = false
|
|
||||||
|
|
||||||
processPendingRequests = () ->
|
|
||||||
return if inflight
|
|
||||||
doRequest = pendingRequests.shift()
|
|
||||||
if doRequest?
|
|
||||||
inflight = true
|
|
||||||
doRequest()
|
|
||||||
.success () ->
|
|
||||||
inflight = false
|
|
||||||
processPendingRequests()
|
|
||||||
.error () ->
|
|
||||||
inflight = false
|
|
||||||
processPendingRequests()
|
|
||||||
|
|
||||||
queuedHttp = (args...) ->
|
|
||||||
deferred = $q.defer()
|
|
||||||
promise = deferred.promise
|
|
||||||
|
|
||||||
# Adhere to the $http promise conventions
|
|
||||||
promise.success = (callback) ->
|
|
||||||
promise.then(callback)
|
|
||||||
return promise
|
|
||||||
|
|
||||||
promise.error = (callback) ->
|
|
||||||
promise.catch(callback)
|
|
||||||
return promise
|
|
||||||
|
|
||||||
doRequest = () ->
|
|
||||||
$http(args...)
|
|
||||||
.success (successArgs...) ->
|
|
||||||
deferred.resolve(successArgs...)
|
|
||||||
.error (errorArgs...) ->
|
|
||||||
deferred.reject(errorArgs...)
|
|
||||||
|
|
||||||
pendingRequests.push doRequest
|
|
||||||
processPendingRequests()
|
|
||||||
|
|
||||||
return promise
|
|
||||||
|
|
||||||
queuedHttp.post = (url, data) ->
|
|
||||||
queuedHttp({method: "POST", url: url, data: data})
|
|
||||||
|
|
||||||
return queuedHttp
|
|
||||||
|
|
||||||
]
|
|
||||||
|
|
||||||
App.controller "ProjectPageController", ($scope, $modal, $q, queuedHttp) ->
|
|
||||||
$scope.projects = window.data.projects
|
|
||||||
$scope.visibleProjects = $scope.projects
|
|
||||||
$scope.tags = window.data.tags
|
|
||||||
$scope.allSelected = false
|
|
||||||
$scope.selectedProjects = []
|
|
||||||
$scope.filter = "all"
|
|
||||||
|
|
||||||
# Allow tags to be accessed on projects as well
|
|
||||||
projectsById = {}
|
|
||||||
for project in $scope.projects
|
|
||||||
projectsById[project.id] = project
|
|
||||||
|
|
||||||
for tag in $scope.tags
|
|
||||||
for project_id in tag.project_ids or []
|
|
||||||
project = projectsById[project_id]
|
|
||||||
if project?
|
|
||||||
project.tags ||= []
|
|
||||||
project.tags.push tag
|
|
||||||
|
|
||||||
$scope.$watch "searchText", (value) ->
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
|
|
||||||
$scope.clearSearchText = () ->
|
|
||||||
$scope.searchText = ""
|
|
||||||
$scope.$emit "search:clear"
|
|
||||||
|
|
||||||
$scope.setFilter = (filter) ->
|
|
||||||
$scope.filter = filter
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
|
|
||||||
$scope.updateSelectedProjects = () ->
|
|
||||||
$scope.selectedProjects = $scope.projects.filter (project) -> project.selected
|
|
||||||
|
|
||||||
$scope.getSelectedProjects = () ->
|
|
||||||
$scope.selectedProjects
|
|
||||||
|
|
||||||
$scope.getSelectedProjectIds = () ->
|
|
||||||
$scope.selectedProjects.map (project) -> project.id
|
|
||||||
|
|
||||||
$scope.getFirstSelectedProject = () ->
|
|
||||||
$scope.selectedProjects[0]
|
|
||||||
|
|
||||||
$scope.updateVisibleProjects = () ->
|
|
||||||
$scope.visibleProjects = []
|
|
||||||
selectedTag = $scope.getSelectedTag()
|
|
||||||
for project in $scope.projects
|
|
||||||
visible = true
|
|
||||||
# Only show if it matches any search text
|
|
||||||
if $scope.searchText? and $scope.searchText != ""
|
|
||||||
if !project.name.toLowerCase().match($scope.searchText.toLowerCase())
|
|
||||||
visible = false
|
|
||||||
# Only show if it matches the selected tag
|
|
||||||
if $scope.filter == "tag" and selectedTag? and project.id not in selectedTag.project_ids
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
# Hide projects we own if we only want to see shared projects
|
|
||||||
if $scope.filter == "shared" and project.accessLevel == "owner"
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
# Hide projects we don't own if we only want to see owned projects
|
|
||||||
if $scope.filter == "owned" and project.accessLevel != "owner"
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
if $scope.filter == "archived"
|
|
||||||
# Only show archived projects
|
|
||||||
if !project.archived
|
|
||||||
visible = false
|
|
||||||
else
|
|
||||||
# Only show non-archived projects
|
|
||||||
if project.archived
|
|
||||||
visible = false
|
|
||||||
|
|
||||||
if visible
|
|
||||||
$scope.visibleProjects.push project
|
|
||||||
else
|
|
||||||
# We don't want hidden selections
|
|
||||||
project.selected = false
|
|
||||||
$scope.updateSelectedProjects()
|
|
||||||
|
|
||||||
$scope.getSelectedTag = () ->
|
|
||||||
for tag in $scope.tags
|
|
||||||
return tag if tag.selected
|
|
||||||
return null
|
|
||||||
|
|
||||||
$scope._removeProjectIdsFromTagArray = (tag, remove_project_ids) ->
|
|
||||||
# Remove project_id from tag.project_ids
|
|
||||||
remaining_project_ids = []
|
|
||||||
removed_project_ids = []
|
|
||||||
for project_id in tag.project_ids
|
|
||||||
if project_id not in remove_project_ids
|
|
||||||
remaining_project_ids.push project_id
|
|
||||||
else
|
|
||||||
removed_project_ids.push project_id
|
|
||||||
tag.project_ids = remaining_project_ids
|
|
||||||
return removed_project_ids
|
|
||||||
|
|
||||||
$scope._removeProjectFromList = (project) ->
|
|
||||||
index = $scope.projects.indexOf(project)
|
|
||||||
if index > -1
|
|
||||||
$scope.projects.splice(index, 1)
|
|
||||||
|
|
||||||
$scope.removeSelectedProjectsFromTag = (tag) ->
|
|
||||||
tag.showWhenEmpty = true
|
|
||||||
|
|
||||||
selected_project_ids = $scope.getSelectedProjectIds()
|
|
||||||
selected_projects = $scope.getSelectedProjects()
|
|
||||||
|
|
||||||
removed_project_ids = $scope._removeProjectIdsFromTagArray(tag, selected_project_ids)
|
|
||||||
|
|
||||||
# Remove tag from project.tags
|
|
||||||
remaining_tags = []
|
|
||||||
for project in selected_projects
|
|
||||||
project.tags ||= []
|
|
||||||
index = project.tags.indexOf tag
|
|
||||||
if index > -1
|
|
||||||
project.tags.splice(index, 1)
|
|
||||||
|
|
||||||
for project_id in removed_project_ids
|
|
||||||
queuedHttp.post "/project/#{project_id}/tag", {
|
|
||||||
deletedTag: tag.name
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
# If we're filtering by this tag then we need to remove
|
|
||||||
# the projects from view
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
|
|
||||||
$scope.addSelectedProjectsToTag = (tag) ->
|
|
||||||
selected_projects = $scope.getSelectedProjects()
|
|
||||||
|
|
||||||
# Add project_ids into tag.project_ids
|
|
||||||
added_project_ids = []
|
|
||||||
for project_id in $scope.getSelectedProjectIds()
|
|
||||||
unless project_id in tag.project_ids
|
|
||||||
tag.project_ids.push project_id
|
|
||||||
added_project_ids.push project_id
|
|
||||||
|
|
||||||
# Add tag into each project.tags
|
|
||||||
for project in selected_projects
|
|
||||||
project.tags ||= []
|
|
||||||
unless tag in project.tags
|
|
||||||
project.tags.push tag
|
|
||||||
|
|
||||||
for project_id in added_project_ids
|
|
||||||
queuedHttp.post "/project/#{project_id}/tag", {
|
|
||||||
tag: tag.name
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.createTag = (name) ->
|
|
||||||
$scope.tags.push {
|
|
||||||
name: name
|
|
||||||
project_ids: []
|
|
||||||
showWhenEmpty: true
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.openNewTagModal = (e) ->
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "newTagModalTemplate"
|
|
||||||
controller: "NewTagModalController"
|
|
||||||
)
|
|
||||||
|
|
||||||
modalInstance.result.then(
|
|
||||||
(newTagName) ->
|
|
||||||
$scope.createTag(newTagName)
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.createProject = (name, template = "none") ->
|
|
||||||
deferred = $q.defer()
|
|
||||||
|
|
||||||
queuedHttp
|
|
||||||
.post("/project/new", {
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
projectName: name
|
|
||||||
template: template
|
|
||||||
})
|
|
||||||
.success((data, status, headers, config) ->
|
|
||||||
$scope.projects.push {
|
|
||||||
name: name
|
|
||||||
_id: data.project_id
|
|
||||||
accessLevel: "owner"
|
|
||||||
# TODO: Check access level if correct after adding it in
|
|
||||||
# to the rest of the app
|
|
||||||
}
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
deferred.resolve(data.project_id)
|
|
||||||
)
|
|
||||||
.error((data, status, headers, config) ->
|
|
||||||
deferred.reject()
|
|
||||||
)
|
|
||||||
|
|
||||||
return deferred.promise
|
|
||||||
|
|
||||||
$scope.openCreateProjectModal = (template = "none") ->
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "newProjectModalTemplate"
|
|
||||||
controller: "NewProjectModalController"
|
|
||||||
resolve:
|
|
||||||
template: () -> template
|
|
||||||
scope: $scope
|
|
||||||
)
|
|
||||||
|
|
||||||
modalInstance.result.then (project_id) ->
|
|
||||||
window.location = "/project/#{project_id}"
|
|
||||||
|
|
||||||
$scope.renameProject = (project, newName) ->
|
|
||||||
project.name = newName
|
|
||||||
queuedHttp.post "/project/#{project.id}/rename", {
|
|
||||||
newProjectName: newName
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.openRenameProjectModal = () ->
|
|
||||||
project = $scope.getFirstSelectedProject()
|
|
||||||
return if !project? or project.accessLevel != "owner"
|
|
||||||
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "renameProjectModalTemplate"
|
|
||||||
controller: "RenameProjectModalController"
|
|
||||||
resolve:
|
|
||||||
projectName: () -> project.name
|
|
||||||
)
|
|
||||||
|
|
||||||
modalInstance.result.then(
|
|
||||||
(newName) ->
|
|
||||||
$scope.renameProject(project, newName)
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.cloneProject = (project, cloneName) ->
|
|
||||||
deferred = $q.defer()
|
|
||||||
|
|
||||||
queuedHttp
|
|
||||||
.post("/project/#{project.id}/clone", {
|
|
||||||
_csrf: window.csrfToken
|
|
||||||
projectName: cloneName
|
|
||||||
})
|
|
||||||
.success((data, status, headers, config) ->
|
|
||||||
$scope.projects.push {
|
|
||||||
name: cloneName
|
|
||||||
id: data.project_id
|
|
||||||
accessLevel: "owner"
|
|
||||||
# TODO: Check access level if correct after adding it in
|
|
||||||
# to the rest of the app
|
|
||||||
}
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
deferred.resolve(data.project_id)
|
|
||||||
)
|
|
||||||
.error((data, status, headers, config) ->
|
|
||||||
deferred.reject()
|
|
||||||
)
|
|
||||||
|
|
||||||
return deferred.promise
|
|
||||||
|
|
||||||
$scope.openCloneProjectModal = () ->
|
|
||||||
project = $scope.getFirstSelectedProject()
|
|
||||||
return if !project?
|
|
||||||
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "cloneProjectModalTemplate"
|
|
||||||
controller: "CloneProjectModalController"
|
|
||||||
resolve:
|
|
||||||
project: () -> project
|
|
||||||
scope: $scope
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.openArchiveProjectsModal = () ->
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "deleteProjectsModalTemplate"
|
|
||||||
controller: "DeleteProjectsModalController"
|
|
||||||
resolve:
|
|
||||||
projects: () -> $scope.getSelectedProjects()
|
|
||||||
)
|
|
||||||
|
|
||||||
modalInstance.result.then () ->
|
|
||||||
$scope.archiveOrLeaveSelectedProjects()
|
|
||||||
|
|
||||||
$scope.archiveOrLeaveSelectedProjects = () ->
|
|
||||||
selected_projects = $scope.getSelectedProjects()
|
|
||||||
selected_project_ids = $scope.getSelectedProjectIds()
|
|
||||||
|
|
||||||
# Remove project from any tags
|
|
||||||
for tag in $scope.tags
|
|
||||||
$scope._removeProjectIdsFromTagArray(tag, selected_project_ids)
|
|
||||||
|
|
||||||
for project in selected_projects
|
|
||||||
if project.accessLevel == "owner"
|
|
||||||
project.archived = true
|
|
||||||
queuedHttp {
|
|
||||||
method: "DELETE"
|
|
||||||
url: "/project/#{project.id}"
|
|
||||||
headers:
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
else
|
|
||||||
$scope._removeProjectFromList project
|
|
||||||
|
|
||||||
queuedHttp {
|
|
||||||
method: "POST"
|
|
||||||
url: "/project/#{project.id}/leave"
|
|
||||||
headers:
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
|
|
||||||
|
|
||||||
$scope.openDeleteProjectsModal = () ->
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "deleteProjectsModalTemplate"
|
|
||||||
controller: "DeleteProjectsModalController"
|
|
||||||
resolve:
|
|
||||||
projects: () -> $scope.getSelectedProjects()
|
|
||||||
)
|
|
||||||
|
|
||||||
modalInstance.result.then () ->
|
|
||||||
$scope.deleteSelectedProjects()
|
|
||||||
|
|
||||||
$scope.deleteSelectedProjects = () ->
|
|
||||||
selected_projects = $scope.getSelectedProjects()
|
|
||||||
selected_project_ids = $scope.getSelectedProjectIds()
|
|
||||||
|
|
||||||
# Remove projects from array
|
|
||||||
for project in selected_projects
|
|
||||||
$scope._removeProjectFromList project
|
|
||||||
|
|
||||||
# Remove project from any tags
|
|
||||||
for tag in $scope.tags
|
|
||||||
$scope._removeProjectIdsFromTagArray(tag, selected_project_ids)
|
|
||||||
|
|
||||||
for project_id in selected_project_ids
|
|
||||||
queuedHttp {
|
|
||||||
method: "DELETE"
|
|
||||||
url: "/project/#{project_id}?forever=true"
|
|
||||||
headers:
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
|
|
||||||
$scope.restoreSelectedProjects = () ->
|
|
||||||
selected_projects = $scope.getSelectedProjects()
|
|
||||||
selected_project_ids = $scope.getSelectedProjectIds()
|
|
||||||
|
|
||||||
for project in selected_projects
|
|
||||||
project.archived = false
|
|
||||||
|
|
||||||
for project_id in selected_project_ids
|
|
||||||
queuedHttp {
|
|
||||||
method: "POST"
|
|
||||||
url: "/project/#{project_id}/restore"
|
|
||||||
headers:
|
|
||||||
"X-CSRF-Token": window.csrfToken
|
|
||||||
}
|
|
||||||
|
|
||||||
$scope.updateVisibleProjects()
|
|
||||||
|
|
||||||
$scope.openUploadProjectModal = () ->
|
|
||||||
modalInstance = $modal.open(
|
|
||||||
templateUrl: "uploadProjectModalTemplate"
|
|
||||||
controller: "UploadProjectModalController"
|
|
||||||
)
|
|
||||||
|
|
||||||
$scope.downloadSelectedProjects = () ->
|
|
||||||
selected_project_ids = $scope.getSelectedProjectIds()
|
|
||||||
|
|
||||||
if selected_project_ids.length > 1
|
|
||||||
path = "/project/download/zip?project_ids=#{selected_project_ids.join(',')}"
|
|
||||||
else
|
|
||||||
path = "/project/#{selected_project_ids[0]}/download/zip"
|
|
||||||
|
|
||||||
window.location = path
|
|
||||||
|
|
||||||
|
|
||||||
App.controller "ProjectListItemController", ($scope) ->
|
|
||||||
$scope.ownerName = () ->
|
|
||||||
if $scope.project.accessLevel == "owner"
|
|
||||||
return "You"
|
|
||||||
else if $scope.project.owner?
|
|
||||||
return "#{$scope.project.owner.first_name} #{$scope.project.owner.last_name}"
|
|
||||||
else
|
|
||||||
return "?"
|
|
||||||
|
|
||||||
$scope.$watch "project.selected", (value) ->
|
|
||||||
if value?
|
|
||||||
$scope.updateSelectedProjects()
|
|
||||||
|
|
||||||
App.controller "TagListController", ($scope) ->
|
|
||||||
$scope.filterProjects = (filter = "all") ->
|
|
||||||
$scope._clearTags()
|
|
||||||
$scope.setFilter(filter)
|
|
||||||
|
|
||||||
$scope._clearTags = () ->
|
|
||||||
for tag in $scope.tags
|
|
||||||
tag.selected = false
|
|
||||||
|
|
||||||
$scope.nonEmpty = (tag) ->
|
|
||||||
# The showWhenEmpty property will be set on any tag which we have
|
|
||||||
# modified during this session. Otherwise, tags which are empty
|
|
||||||
# when loading the page are not shown.
|
|
||||||
tag.project_ids.length > 0 or !!tag.showWhenEmpty
|
|
||||||
|
|
||||||
App.controller "TagListItemController", ($scope) ->
|
|
||||||
$scope.selectTag = () ->
|
|
||||||
$scope._clearTags()
|
|
||||||
$scope.tag.selected = true
|
|
||||||
$scope.setFilter("tag")
|
|
||||||
|
|
||||||
App.controller "TagDropdownItemController", ($scope) ->
|
|
||||||
$scope.recalculateProjectsInTag = () ->
|
|
||||||
$scope.areSelectedProjectsInTag = false
|
|
||||||
for project_id in $scope.getSelectedProjectIds()
|
|
||||||
if project_id in $scope.tag.project_ids
|
|
||||||
$scope.areSelectedProjectsInTag = true
|
|
||||||
else
|
|
||||||
partialSelection = true
|
|
||||||
|
|
||||||
if $scope.areSelectedProjectsInTag and partialSelection
|
|
||||||
$scope.areSelectedProjectsInTag = "partial"
|
|
||||||
|
|
||||||
$scope.addOrRemoveProjectsFromTag = () ->
|
|
||||||
if $scope.areSelectedProjectsInTag == true
|
|
||||||
$scope.removeSelectedProjectsFromTag($scope.tag)
|
|
||||||
$scope.areSelectedProjectsInTag = false
|
|
||||||
else if $scope.areSelectedProjectsInTag == false or $scope.areSelectedProjectsInTag == "partial"
|
|
||||||
$scope.addSelectedProjectsToTag($scope.tag)
|
|
||||||
$scope.areSelectedProjectsInTag = true
|
|
||||||
|
|
||||||
$scope.$watch "selectedProjects", () ->
|
|
||||||
$scope.recalculateProjectsInTag()
|
|
||||||
$scope.recalculateProjectsInTag()
|
|
||||||
|
|
||||||
App.controller 'NewTagModalController', ($scope, $modalInstance, $timeout) ->
|
|
||||||
$scope.inputs =
|
|
||||||
newTagName: ""
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 700
|
|
||||||
|
|
||||||
$scope.create = () ->
|
|
||||||
$modalInstance.close($scope.inputs.newTagName)
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
|
|
||||||
App.controller 'RenameProjectModalController', ($scope, $modalInstance, $timeout, projectName) ->
|
|
||||||
$scope.inputs =
|
|
||||||
projectName: projectName
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 700
|
|
||||||
|
|
||||||
$scope.rename = () ->
|
|
||||||
$modalInstance.close($scope.inputs.projectName)
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
|
|
||||||
App.controller 'CloneProjectModalController', ($scope, $modalInstance, $timeout, project) ->
|
|
||||||
$scope.inputs =
|
|
||||||
projectName: project.name + " (Copy)"
|
|
||||||
$scope.state =
|
|
||||||
inflight: false
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 700
|
|
||||||
|
|
||||||
$scope.clone = () ->
|
|
||||||
$scope.state.inflight = true
|
|
||||||
$scope
|
|
||||||
.cloneProject(project, $scope.inputs.projectName)
|
|
||||||
.then (project_id) ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$modalInstance.close(project_id)
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
|
|
||||||
App.controller 'NewProjectModalController', ($scope, $modalInstance, $timeout, template) ->
|
|
||||||
$scope.inputs =
|
|
||||||
projectName: ""
|
|
||||||
$scope.state =
|
|
||||||
inflight: false
|
|
||||||
|
|
||||||
$modalInstance.opened.then () ->
|
|
||||||
$timeout () ->
|
|
||||||
$scope.$broadcast "open"
|
|
||||||
, 700
|
|
||||||
|
|
||||||
$scope.create = () ->
|
|
||||||
$scope.state.inflight = true
|
|
||||||
$scope
|
|
||||||
.createProject($scope.inputs.projectName, template)
|
|
||||||
.then (project_id) ->
|
|
||||||
$scope.state.inflight = false
|
|
||||||
$modalInstance.close(project_id)
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
|
|
||||||
App.controller 'DeleteProjectsModalController', ($scope, $modalInstance, $timeout, projects) ->
|
|
||||||
$scope.projectsToDelete = projects.filter (project) -> project.accessLevel == "owner"
|
|
||||||
$scope.projectsToLeave = projects.filter (project) -> project.accessLevel != "owner"
|
|
||||||
|
|
||||||
if $scope.projectsToLeave.length > 0 and $scope.projectsToDelete.length > 0
|
|
||||||
$scope.action = "Delete & Leave"
|
|
||||||
else if $scope.projectsToLeave.length == 0 and $scope.projectsToDelete.length > 0
|
|
||||||
$scope.action = "Delete"
|
|
||||||
else
|
|
||||||
$scope.action = "Leave"
|
|
||||||
|
|
||||||
$scope.delete = () ->
|
|
||||||
$modalInstance.close()
|
|
||||||
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
|
|
||||||
|
|
||||||
App.controller 'UploadProjectModalController', ($scope, $modalInstance, $timeout) ->
|
|
||||||
$scope.cancel = () ->
|
|
||||||
$modalInstance.dismiss('cancel')
|
|
||||||
|
|
||||||
$scope.onComplete = (error, name, response) ->
|
|
||||||
if response.project_id?
|
|
||||||
window.location = '/project/' + response.project_id
|
|
|
@ -1,58 +0,0 @@
|
||||||
define [
|
|
||||||
"base"
|
|
||||||
"../../libs/algolia"
|
|
||||||
], (App, algolia)->
|
|
||||||
app.factory "Institutions", ->
|
|
||||||
new AlgoliaSearch(window.algolia.institutions.app_id, window.algolia.institutions.api_key).initIndex("institutions")
|
|
||||||
|
|
||||||
App.controller "UserProfileController", ($scope, $modal, $http)->
|
|
||||||
$scope.institutions = []
|
|
||||||
$http.get("/user/personal_info").success (data)->
|
|
||||||
$scope.userInfoForm =
|
|
||||||
first_name: data.first_name || ""
|
|
||||||
last_name: data.last_name || ""
|
|
||||||
role: data.role || ""
|
|
||||||
institution: data.institution || ""
|
|
||||||
_csrf : window.csrfToken
|
|
||||||
|
|
||||||
$scope.showForm = ->
|
|
||||||
$scope.formVisable = true
|
|
||||||
|
|
||||||
$scope.getPercentComplete = ->
|
|
||||||
results = _.filter $scope.userInfoForm, (value)-> !value? or value?.length != 0
|
|
||||||
results.length * 20
|
|
||||||
|
|
||||||
$scope.$watch "userInfoForm", (value) ->
|
|
||||||
if value?
|
|
||||||
$scope.percentComplete = $scope.getPercentComplete()
|
|
||||||
, true
|
|
||||||
|
|
||||||
$scope.openUserProfileModal = () ->
|
|
||||||
$modal.open {
|
|
||||||
templateUrl: "userProfileModalTemplate"
|
|
||||||
controller: "UserProfileModalController"
|
|
||||||
scope: $scope
|
|
||||||
}
|
|
||||||
|
|
||||||
App.controller "UserProfileModalController", ($scope, $modalInstance, $http, Institutions) ->
|
|
||||||
$scope.roles = ["Student", "Post-graduate student", "Post-doctoral researcher", "Lecturer", "Professor"]
|
|
||||||
|
|
||||||
$scope.sendUpdate = ->
|
|
||||||
request = $http.post "/user/settings", $scope.userInfoForm
|
|
||||||
request.success (data, status)->
|
|
||||||
request.error (data, status)->
|
|
||||||
console.log "the request failed"
|
|
||||||
|
|
||||||
$scope.updateInstitutionsList = (inputVal)->
|
|
||||||
|
|
||||||
# this is a little hack to use until we change auto compelete lib with redesign and can
|
|
||||||
# listen for blur events on institution field to send the post
|
|
||||||
if inputVal?.indexOf("(") != -1 and inputVal?.indexOf(")") != -1
|
|
||||||
$scope.sendUpdate()
|
|
||||||
|
|
||||||
Institutions.search $scope.userInfoForm.institution, (err, response)->
|
|
||||||
$scope.institutions = _.map response.hits, (institution)->
|
|
||||||
"#{institution.name} (#{institution.domain})"
|
|
||||||
|
|
||||||
$scope.done = () ->
|
|
||||||
$modalInstance.close()
|
|
|
@ -1,44 +0,0 @@
|
||||||
#
|
|
||||||
# * An Angular service which helps with creating recursive directives.
|
|
||||||
# * @author Mark Lagendijk
|
|
||||||
# * @license MIT
|
|
||||||
#
|
|
||||||
# From: https://github.com/marklagendijk/angular-recursion
|
|
||||||
angular.module("RecursionHelper", []).factory "RecursionHelper", [
|
|
||||||
"$compile"
|
|
||||||
($compile) ->
|
|
||||||
|
|
||||||
###
|
|
||||||
Manually compiles the element, fixing the recursion loop.
|
|
||||||
@param element
|
|
||||||
@param [link] A post-link function, or an object with function(s) registered via pre and post properties.
|
|
||||||
@returns An object containing the linking functions.
|
|
||||||
###
|
|
||||||
return compile: (element, link) ->
|
|
||||||
|
|
||||||
# Normalize the link parameter
|
|
||||||
link = post: link if angular.isFunction(link)
|
|
||||||
|
|
||||||
# Break the recursion loop by removing the contents
|
|
||||||
contents = element.contents().remove()
|
|
||||||
compiledContents = undefined
|
|
||||||
pre: (if (link and link.pre) then link.pre else null)
|
|
||||||
|
|
||||||
###
|
|
||||||
Compiles and re-adds the contents
|
|
||||||
###
|
|
||||||
post: (scope, element) ->
|
|
||||||
|
|
||||||
# Compile the contents
|
|
||||||
compiledContents = $compile(contents) unless compiledContents
|
|
||||||
|
|
||||||
# Re-add the compiled contents to the element
|
|
||||||
compiledContents scope, (clone) ->
|
|
||||||
element.append clone
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
# Call the post-linking function, if any
|
|
||||||
link.post.apply null, arguments if link and link.post
|
|
||||||
return
|
|
||||||
]
|
|
|
@ -1,33 +0,0 @@
|
||||||
define [], () ->
|
|
||||||
class EventEmitter
|
|
||||||
on: (event, callback) ->
|
|
||||||
@events ||= {}
|
|
||||||
[event, namespace] = event.split(".")
|
|
||||||
@events[event] ||= []
|
|
||||||
@events[event].push {
|
|
||||||
callback: callback
|
|
||||||
namespace: namespace
|
|
||||||
}
|
|
||||||
|
|
||||||
off: (event) ->
|
|
||||||
@events ||= {}
|
|
||||||
if event?
|
|
||||||
[event, namespace] = event.split(".")
|
|
||||||
if !namespace?
|
|
||||||
# Clear all listeners for event
|
|
||||||
delete @events[event]
|
|
||||||
else
|
|
||||||
# Clear only namespaced listeners
|
|
||||||
remaining_events = []
|
|
||||||
for callback in @events[event] or []
|
|
||||||
if callback.namespace != namespace
|
|
||||||
remaining_events.push callback
|
|
||||||
@events[event] = remaining_events
|
|
||||||
else
|
|
||||||
# Remove all listeners
|
|
||||||
@events = {}
|
|
||||||
|
|
||||||
trigger: (event, args...) ->
|
|
||||||
@events ||= {}
|
|
||||||
for callback in @events[event] or []
|
|
||||||
callback.callback(args...)
|
|
|
@ -1,79 +0,0 @@
|
||||||
define [
|
|
||||||
"auto-complete/SuggestionManager"
|
|
||||||
"auto-complete/Snippets"
|
|
||||||
"ace/autocomplete/util"
|
|
||||||
"ace/autocomplete"
|
|
||||||
"ace/range"
|
|
||||||
"ace/ext/language_tools"
|
|
||||||
], (SuggestionManager, Snippets, Util, AutoComplete) ->
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
Autocomplete = AutoComplete.Autocomplete
|
|
||||||
|
|
||||||
Util.retrievePrecedingIdentifier = (text, pos, regex) ->
|
|
||||||
currentLineOffset = 0
|
|
||||||
for i in [(pos-1)..0]
|
|
||||||
if text[i] == "\n"
|
|
||||||
currentLineOffset = i + 1
|
|
||||||
break
|
|
||||||
currentLine = text.slice(currentLineOffset, pos)
|
|
||||||
fragment = getLastCommandFragment(currentLine) or ""
|
|
||||||
return fragment
|
|
||||||
|
|
||||||
getLastCommandFragment = (lineUpToCursor) ->
|
|
||||||
if m = lineUpToCursor.match(/(\\[^\\ ]+)$/)
|
|
||||||
return m[1]
|
|
||||||
else
|
|
||||||
return null
|
|
||||||
|
|
||||||
class AutoCompleteManager
|
|
||||||
constructor: (@ide) ->
|
|
||||||
@aceEditor = @ide.editor.aceEditor
|
|
||||||
@aceEditor.setOptions({
|
|
||||||
enableBasicAutocompletion: true,
|
|
||||||
enableSnippets: true
|
|
||||||
})
|
|
||||||
|
|
||||||
SnippetCompleter =
|
|
||||||
getCompletions: (editor, session, pos, prefix, callback) ->
|
|
||||||
callback null, Snippets
|
|
||||||
@suggestionManager = new SuggestionManager()
|
|
||||||
|
|
||||||
@aceEditor.completers = [@suggestionManager, SnippetCompleter]
|
|
||||||
|
|
||||||
insertMatch = Autocomplete::insertMatch
|
|
||||||
editor = @aceEditor
|
|
||||||
Autocomplete::insertMatch = (data) ->
|
|
||||||
pos = editor.getCursorPosition()
|
|
||||||
range = new Range(pos.row, pos.column, pos.row, pos.column + 1)
|
|
||||||
nextChar = editor.session.getTextRange(range)
|
|
||||||
|
|
||||||
console.log "CALLING OLD"
|
|
||||||
|
|
||||||
# If we are in \begin{it|}, then we need to remove the trailing }
|
|
||||||
# since it will be adding in with the autocomplete of \begin{item}...
|
|
||||||
if this.completions.filterText.match(/^\\begin\{/) and nextChar == "}"
|
|
||||||
editor.session.remove(range)
|
|
||||||
|
|
||||||
insertMatch.call editor.completer, data
|
|
||||||
|
|
||||||
@bindToEditorEvents()
|
|
||||||
|
|
||||||
bindToEditorEvents: () ->
|
|
||||||
@ide.editor.on "change:doc", (@aceSession) =>
|
|
||||||
@aceSession.on "change", (change) => @onChange(change)
|
|
||||||
|
|
||||||
onChange: (change) ->
|
|
||||||
cursorPosition = @aceEditor.getCursorPosition()
|
|
||||||
end = change.data.range.end
|
|
||||||
# Check that this change was made by us, not a collaborator
|
|
||||||
# (Cursor is still one place behind)
|
|
||||||
if end.row == cursorPosition.row and end.column == cursorPosition.column + 1
|
|
||||||
if change.data.action == "insertText"
|
|
||||||
range = new Range(end.row, 0, end.row, end.column)
|
|
||||||
lineUpToCursor = @aceSession.getTextRange(range)
|
|
||||||
commandFragment = getLastCommandFragment(lineUpToCursor)
|
|
||||||
|
|
||||||
if commandFragment? and commandFragment.length > 2
|
|
||||||
setTimeout () =>
|
|
||||||
@aceEditor.execCommand("startAutocomplete")
|
|
||||||
, 0
|
|
|
@ -1,100 +0,0 @@
|
||||||
define () ->
|
|
||||||
environments = [
|
|
||||||
"abstract",
|
|
||||||
"align", "align*",
|
|
||||||
"equation", "equation*",
|
|
||||||
"gather", "gather*",
|
|
||||||
"multline", "multline*",
|
|
||||||
"split",
|
|
||||||
"verbatim"
|
|
||||||
]
|
|
||||||
|
|
||||||
snippets = for env in environments
|
|
||||||
{
|
|
||||||
caption: "\\begin{#{env}}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{#{env}}
|
|
||||||
$1
|
|
||||||
\\end{#{env}}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}
|
|
||||||
|
|
||||||
snippets = snippets.concat [{
|
|
||||||
caption: "\\begin{array}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{array}{${1:cc}}
|
|
||||||
$2 & $3 \\\\\\\\
|
|
||||||
$4 & $5
|
|
||||||
\\end{array}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{figure}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{figure}
|
|
||||||
\\centering
|
|
||||||
\\includegraphics{$1}
|
|
||||||
\\caption{${2:Caption}}
|
|
||||||
\\label{${3:fig:my_label}}
|
|
||||||
\\end{figure}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{tabular}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{tabular}{${1:c|c}}
|
|
||||||
$2 & $3 \\\\\\\\
|
|
||||||
$4 & $5
|
|
||||||
\\end{tabular}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{table}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{table}[$1]
|
|
||||||
\\centering
|
|
||||||
\\begin{tabular}{${2:c|c}}
|
|
||||||
$3 & $4 \\\\\\\\
|
|
||||||
$5 & $6
|
|
||||||
\\end{tabular}
|
|
||||||
\\caption{${7:Caption}}
|
|
||||||
\\label{${8:tab:my_label}}
|
|
||||||
\\end{table}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{list}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{list}
|
|
||||||
\\item $1
|
|
||||||
\\end{list}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{enumerate}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{enumerate}
|
|
||||||
\\item $1
|
|
||||||
\\end{enumerate}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{itemize}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{itemize}
|
|
||||||
\\item $1
|
|
||||||
\\end{itemize}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}, {
|
|
||||||
caption: "\\begin{frame}..."
|
|
||||||
snippet: """
|
|
||||||
\\begin{frame}{${1:Frame Title}}
|
|
||||||
$2
|
|
||||||
\\end{frame}
|
|
||||||
"""
|
|
||||||
meta: "env"
|
|
||||||
}]
|
|
||||||
|
|
||||||
return snippets
|
|
|
@ -1,126 +0,0 @@
|
||||||
define [], () ->
|
|
||||||
class Parser
|
|
||||||
constructor: (@doc) ->
|
|
||||||
|
|
||||||
parse: () ->
|
|
||||||
commands = []
|
|
||||||
seen = {}
|
|
||||||
while command = @nextCommand()
|
|
||||||
docState = @doc
|
|
||||||
|
|
||||||
optionalArgs = 0
|
|
||||||
while @consumeArgument("[", "]")
|
|
||||||
optionalArgs++
|
|
||||||
|
|
||||||
args = 0
|
|
||||||
while @consumeArgument("{", "}")
|
|
||||||
args++
|
|
||||||
|
|
||||||
commandHash = "#{command}\\#{optionalArgs}\\#{args}"
|
|
||||||
if !seen[commandHash]?
|
|
||||||
seen[commandHash] = true
|
|
||||||
commands.push [command, optionalArgs, args]
|
|
||||||
|
|
||||||
# Reset to before argument to handle nested commands
|
|
||||||
@doc = docState
|
|
||||||
|
|
||||||
return commands
|
|
||||||
|
|
||||||
# Ignore single letter commands since auto complete is moot then.
|
|
||||||
commandRegex: /\\([a-zA-Z][a-zA-Z]+)/
|
|
||||||
|
|
||||||
nextCommand: () ->
|
|
||||||
i = @doc.search(@commandRegex)
|
|
||||||
if i == -1
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
match = @doc.match(@commandRegex)[1]
|
|
||||||
@doc = @doc.substr(i + match.length + 1)
|
|
||||||
return match
|
|
||||||
|
|
||||||
consumeWhitespace: () ->
|
|
||||||
match = @doc.match(/^[ \t\n]*/m)[0]
|
|
||||||
@doc = @doc.substr(match.length)
|
|
||||||
|
|
||||||
consumeArgument: (openingBracket, closingBracket) ->
|
|
||||||
@consumeWhitespace()
|
|
||||||
|
|
||||||
if @doc[0] == openingBracket
|
|
||||||
i = 1
|
|
||||||
bracketParity = 1
|
|
||||||
while bracketParity > 0 and i < @doc.length
|
|
||||||
if @doc[i] == openingBracket
|
|
||||||
bracketParity++
|
|
||||||
else if @doc[i] == closingBracket
|
|
||||||
bracketParity--
|
|
||||||
i++
|
|
||||||
|
|
||||||
if bracketParity == 0
|
|
||||||
@doc = @doc.substr(i)
|
|
||||||
return true
|
|
||||||
else
|
|
||||||
return false
|
|
||||||
else
|
|
||||||
return false
|
|
||||||
|
|
||||||
class SuggestionManager
|
|
||||||
getCompletions: (editor, session, pos, prefix, callback) ->
|
|
||||||
doc = session.getValue()
|
|
||||||
parser = new Parser(doc)
|
|
||||||
commands = parser.parse()
|
|
||||||
|
|
||||||
completions = []
|
|
||||||
for command in commands
|
|
||||||
caption = "\\#{command[0]}"
|
|
||||||
snippet = caption
|
|
||||||
i = 1
|
|
||||||
_.times command[1], () ->
|
|
||||||
snippet += "[${#{i}}]"
|
|
||||||
caption += "[]"
|
|
||||||
i++
|
|
||||||
_.times command[2], () ->
|
|
||||||
snippet += "{${#{i}}}"
|
|
||||||
caption += "{}"
|
|
||||||
i++
|
|
||||||
unless caption == prefix
|
|
||||||
completions.push {
|
|
||||||
caption: caption
|
|
||||||
snippet: snippet
|
|
||||||
meta: "cmd"
|
|
||||||
}
|
|
||||||
|
|
||||||
callback null, completions
|
|
||||||
|
|
||||||
loadCommandsFromDoc: (doc) ->
|
|
||||||
parser = new Parser(doc)
|
|
||||||
@commands = parser.parse()
|
|
||||||
|
|
||||||
getSuggestions: (commandFragment) ->
|
|
||||||
matchingCommands = _.filter @commands, (command) ->
|
|
||||||
command[0].slice(0, commandFragment.length) == commandFragment
|
|
||||||
|
|
||||||
return _.map matchingCommands, (command) ->
|
|
||||||
base = "\\" + commandFragment
|
|
||||||
|
|
||||||
args = ""
|
|
||||||
_.times command[1], () -> args = args + "[]"
|
|
||||||
_.times command[2], () -> args = args + "{}"
|
|
||||||
completionBase = command[0].slice(commandFragment.length)
|
|
||||||
|
|
||||||
squareArgsNo = command[1]
|
|
||||||
curlyArgsNo = command[2]
|
|
||||||
totalArgs = squareArgsNo + curlyArgsNo
|
|
||||||
if totalArgs == 0
|
|
||||||
completionBeforeCursor = completionBase
|
|
||||||
completionAfterCurspr = ""
|
|
||||||
else
|
|
||||||
completionBeforeCursor = completionBase + args[0]
|
|
||||||
completionAfterCursor = args.slice(1)
|
|
||||||
|
|
||||||
return {
|
|
||||||
base: base,
|
|
||||||
completion: completionBase + args,
|
|
||||||
completionBeforeCursor: completionBeforeCursor
|
|
||||||
completionAfterCursor: completionAfterCursor
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,99 +0,0 @@
|
||||||
define () ->
|
|
||||||
class CursorManager
|
|
||||||
UPDATE_INTERVAL: 500
|
|
||||||
|
|
||||||
constructor: (@ide) ->
|
|
||||||
@clients = {}
|
|
||||||
@ide.socket.on "clientTracking.clientUpdated", (cursorUpdate) => @onRemoteClientUpdate(cursorUpdate)
|
|
||||||
@ide.socket.on "clientTracking.clientDisconnected", (client_id) => @onRemoteClientDisconnect(client_id)
|
|
||||||
@ide.editor.on "change:doc", (session) =>
|
|
||||||
@bindToAceSession(session)
|
|
||||||
@ide.editor.on "mousemove", (e) =>
|
|
||||||
@mousePosition = e.position
|
|
||||||
@updateVisibleNames()
|
|
||||||
|
|
||||||
bindToAceSession: (session) ->
|
|
||||||
@clients = {}
|
|
||||||
@ide.editor.aceEditor.on "changeSelection", => @onLocalCursorUpdate()
|
|
||||||
|
|
||||||
onLocalCursorUpdate: () ->
|
|
||||||
if !@cursorUpdateTimeout?
|
|
||||||
@cursorUpdateTimeout = setTimeout (=>
|
|
||||||
@_sendLocalCursorUpdate()
|
|
||||||
delete @cursorUpdateTimeout
|
|
||||||
), @UPDATE_INTERVAL
|
|
||||||
|
|
||||||
_sendLocalCursorUpdate: () ->
|
|
||||||
cursor = @ide.editor.getCursorPosition()
|
|
||||||
if !@currentCursorPosition? or not (cursor.row == @currentCursorPosition.row and cursor.column == @currentCursorPosition.column)
|
|
||||||
@currentCursorPosition = cursor
|
|
||||||
@ide.socket.emit "clientTracking.updatePosition", {
|
|
||||||
row: cursor.row
|
|
||||||
column: cursor.column
|
|
||||||
doc_id: @ide.editor.getCurrentDocId()
|
|
||||||
}
|
|
||||||
|
|
||||||
onRemoteClientUpdate: (clientData) ->
|
|
||||||
if clientData.id != ide.socket.socket.sessionid
|
|
||||||
client = @clients[clientData.id] ||= {}
|
|
||||||
client.row = clientData.row
|
|
||||||
client.column = clientData.column
|
|
||||||
client.name = clientData.name
|
|
||||||
client.doc_id = clientData.doc_id
|
|
||||||
@redrawCursors()
|
|
||||||
|
|
||||||
onRemoteClientDisconnect: (client_id) ->
|
|
||||||
@removeCursor(client_id)
|
|
||||||
delete @clients[client_id]
|
|
||||||
|
|
||||||
removeCursor: (client_id) ->
|
|
||||||
client = @clients[client_id]
|
|
||||||
return if !client?
|
|
||||||
@ide.editor.removeMarker(client.cursorMarkerId)
|
|
||||||
delete client.cursorMarkerId
|
|
||||||
|
|
||||||
redrawCursors: () ->
|
|
||||||
for clientId, clientData of @clients
|
|
||||||
do (clientId, clientData) =>
|
|
||||||
if clientData.cursorMarkerId?
|
|
||||||
@removeCursor(clientId)
|
|
||||||
if clientData.doc_id == @ide.editor.getCurrentDocId()
|
|
||||||
colorId = @getColorIdFromName(clientData.name)
|
|
||||||
clientData.cursorMarkerId = @ide.editor.addMarker {
|
|
||||||
row: clientData.row
|
|
||||||
column: clientData.column
|
|
||||||
length: 1
|
|
||||||
}, "sharelatex-remote-cursor", (html, range, left, top, config) ->
|
|
||||||
div = """
|
|
||||||
<div
|
|
||||||
id='cursor-#{clientId}'
|
|
||||||
class='sharelatex-remote-cursor custom ace_start sharelatex-remote-cursor-#{colorId}'
|
|
||||||
style='height: #{config.lineHeight}px; top:#{top}px; left:#{left}px;'
|
|
||||||
>
|
|
||||||
<div class="nubbin" style="bottom: #{config.lineHeight - 2}px"></div>
|
|
||||||
<div class="name" style="display: none; bottom: #{config.lineHeight - 2}px">#{$('<div/>').text(clientData.name).html()}</div>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
html.push div
|
|
||||||
, true
|
|
||||||
setTimeout =>
|
|
||||||
@updateVisibleNames()
|
|
||||||
, 0
|
|
||||||
|
|
||||||
updateVisibleNames: () ->
|
|
||||||
for clientId, clientData of @clients
|
|
||||||
if @mousePosition? and clientData.row == @mousePosition.row and clientData.column == @mousePosition.column
|
|
||||||
$("#cursor-#{clientId}").find(".name").show()
|
|
||||||
$("#cursor-#{clientId}").find(".nubbin").hide()
|
|
||||||
else
|
|
||||||
$("#cursor-#{clientId}").find(".name").hide()
|
|
||||||
$("#cursor-#{clientId}").find(".nubbin").show()
|
|
||||||
|
|
||||||
getColorIdFromName: (name) ->
|
|
||||||
@currentColorId ||= 0
|
|
||||||
@colorIds ||= {}
|
|
||||||
if !@colorIds[name]?
|
|
||||||
@colorIds[name] = @currentColorId
|
|
||||||
@currentColorId++
|
|
||||||
return @colorIds[name]
|
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
define [
|
|
||||||
"utils/Modal"
|
|
||||||
], (Modal) ->
|
|
||||||
class DebugManager
|
|
||||||
template: $("#DebugLinkTemplate").html()
|
|
||||||
|
|
||||||
constructor: (@ide) ->
|
|
||||||
@$el = $(@template)
|
|
||||||
$("#toolbar-footer").append(@$el)
|
|
||||||
@$el.on "click", (e) =>
|
|
||||||
e.preventDefault()
|
|
||||||
@showDebugModal()
|
|
||||||
|
|
||||||
showDebugModal: () ->
|
|
||||||
useragent = navigator.userAgent
|
|
||||||
server_id = document.cookie.match(/SERVERID=([^;]*)/)?[1]
|
|
||||||
transport = @ide.socket.socket.transport.name
|
|
||||||
|
|
||||||
new Modal(
|
|
||||||
title: "Debug info"
|
|
||||||
message: """
|
|
||||||
Please give this information to the ShareLaTeX team:
|
|
||||||
<p><pre>
|
|
||||||
user-agent: #{useragent}
|
|
||||||
server-id: #{server_id}
|
|
||||||
transport: #{transport}
|
|
||||||
</pre></p>
|
|
||||||
"""
|
|
||||||
buttons: [
|
|
||||||
text: "OK"
|
|
||||||
]
|
|
||||||
)
|
|
|
@ -1,40 +0,0 @@
|
||||||
define [
|
|
||||||
"documentUpdater"
|
|
||||||
"ace/range"
|
|
||||||
], () ->
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
Modal = require("utils/Modal")
|
|
||||||
|
|
||||||
class AceUpdateManager
|
|
||||||
constructor: (@editor) ->
|
|
||||||
@ide = @editor.ide
|
|
||||||
|
|
||||||
guidGenerator=()->
|
|
||||||
S4 = ()->
|
|
||||||
return (((1+Math.random())*0x10000)|0).toString(16).substring(1)
|
|
||||||
return (S4()+S4()+"-"+S4()+"-"+S4()+"-"+S4()+"-"+S4()+S4()+S4())
|
|
||||||
@window_id = guidGenerator()
|
|
||||||
|
|
||||||
@bindToServerEvents()
|
|
||||||
|
|
||||||
bindToServerEvents: () ->
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ide.socket.on 'reciveTextUpdate', (updating_id, change) =>
|
|
||||||
@ide.savingAreaManager.saved()
|
|
||||||
if(@window_id == updating_id)
|
|
||||||
return
|
|
||||||
@ownChange = true
|
|
||||||
doc = @editor.getDocument()
|
|
||||||
documentUpdater.applyChange doc, change, Range, ()=>
|
|
||||||
@ownChange = false
|
|
||||||
|
|
||||||
bindToDocument: (doc_id, docLines, version) ->
|
|
||||||
@current_doc_id = doc_id
|
|
||||||
aceDoc = @editor.getDocument()
|
|
||||||
aceDoc.on 'change', (change) =>
|
|
||||||
if(!@ownChange)
|
|
||||||
@ide.socket.emit 'sendUpdate',
|
|
||||||
@current_doc_id, @window_id, change.data
|
|
|
@ -1,257 +0,0 @@
|
||||||
define [
|
|
||||||
"editor/ShareJsDoc"
|
|
||||||
"libs/backbone"
|
|
||||||
"underscore"
|
|
||||||
], (ShareJsDoc) ->
|
|
||||||
class Document
|
|
||||||
@getDocument: (ide, doc_id) ->
|
|
||||||
@openDocs ||= {}
|
|
||||||
if !@openDocs[doc_id]?
|
|
||||||
@openDocs[doc_id] = new Document(ide, doc_id)
|
|
||||||
return @openDocs[doc_id]
|
|
||||||
|
|
||||||
@hasUnsavedChanges: () ->
|
|
||||||
for doc_id, doc of (@openDocs or {})
|
|
||||||
return true if doc.hasBufferedOps()
|
|
||||||
return false
|
|
||||||
|
|
||||||
constructor: (@ide, @doc_id) ->
|
|
||||||
@connected = @ide.socket.socket.connected
|
|
||||||
@joined = false
|
|
||||||
@wantToBeJoined = false
|
|
||||||
@_checkConsistency = _.bind(@_checkConsistency, @)
|
|
||||||
@inconsistentCount = 0
|
|
||||||
@_bindToEditorEvents()
|
|
||||||
@_bindToSocketEvents()
|
|
||||||
|
|
||||||
attachToAce: (@ace) ->
|
|
||||||
@doc?.attachToAce(@ace)
|
|
||||||
editorDoc = @ace.getSession().getDocument()
|
|
||||||
editorDoc.on "change", @_checkConsistency
|
|
||||||
|
|
||||||
detachFromAce: () ->
|
|
||||||
@doc?.detachFromAce()
|
|
||||||
editorDoc = @ace?.getSession().getDocument()
|
|
||||||
editorDoc?.off "change", @_checkConsistency
|
|
||||||
|
|
||||||
_checkConsistency: () ->
|
|
||||||
# We've been seeing a lot of errors when I think there shouldn't be
|
|
||||||
# any, which may be related to this check happening before the change is
|
|
||||||
# applied. If we use a timeout, hopefully we can reduce this.
|
|
||||||
setTimeout () =>
|
|
||||||
editorValue = @ace?.getValue()
|
|
||||||
sharejsValue = @doc?.getSnapshot()
|
|
||||||
if editorValue != sharejsValue
|
|
||||||
@inconsistentCount++
|
|
||||||
else
|
|
||||||
@inconsistentCount = 0
|
|
||||||
|
|
||||||
if @inconsistentCount >= 3
|
|
||||||
@_onError new Error("Editor text does not match server text")
|
|
||||||
, 0
|
|
||||||
|
|
||||||
getSnapshot: () ->
|
|
||||||
@doc?.getSnapshot()
|
|
||||||
|
|
||||||
getType: () ->
|
|
||||||
@doc?.getType()
|
|
||||||
|
|
||||||
getInflightOp: () ->
|
|
||||||
@doc?.getInflightOp()
|
|
||||||
|
|
||||||
getPendingOp: () ->
|
|
||||||
@doc?.getPendingOp()
|
|
||||||
|
|
||||||
hasBufferedOps: () ->
|
|
||||||
@doc?.hasBufferedOps()
|
|
||||||
|
|
||||||
_bindToSocketEvents: () ->
|
|
||||||
@_onUpdateAppliedHandler = (update) => @_onUpdateApplied(update)
|
|
||||||
@ide.socket.on "otUpdateApplied", @_onUpdateAppliedHandler
|
|
||||||
@_onErrorHandler = (error, update) => @_onError(error, update)
|
|
||||||
@ide.socket.on "otUpdateError", @_onErrorHandler
|
|
||||||
@_onDisconnectHandler = (error) => @_onDisconnect(error)
|
|
||||||
@ide.socket.on "disconnect", @_onDisconnectHandler
|
|
||||||
|
|
||||||
_bindToEditorEvents: () ->
|
|
||||||
@_onReconnectHandler = (update) => @_onReconnect(update)
|
|
||||||
@ide.on "afterJoinProject", @_onReconnectHandler
|
|
||||||
|
|
||||||
unBindFromSocketEvents: () ->
|
|
||||||
@ide.socket.removeListener "otUpdateApplied", @_onUpdateAppliedHandler
|
|
||||||
@ide.socket.removeListener "otUpdateError", @_onUpdateErrorHandler
|
|
||||||
@ide.socket.removeListener "disconnect", @_onDisconnectHandler
|
|
||||||
|
|
||||||
unBindFromEditorEvents: () ->
|
|
||||||
@ide.off "afterJoinProject", @_onReconnectHandler
|
|
||||||
|
|
||||||
leaveAndCleanUp: () ->
|
|
||||||
@leave (error) =>
|
|
||||||
@_cleanUp()
|
|
||||||
|
|
||||||
join: (callback = (error) ->) ->
|
|
||||||
@wantToBeJoined = true
|
|
||||||
@_cancelLeave()
|
|
||||||
if @connected
|
|
||||||
return @_joinDoc callback
|
|
||||||
else
|
|
||||||
@_joinCallbacks ||= []
|
|
||||||
@_joinCallbacks.push callback
|
|
||||||
|
|
||||||
leave: (callback = (error) ->) ->
|
|
||||||
@wantToBeJoined = false
|
|
||||||
@_cancelJoin()
|
|
||||||
if (@doc? and @doc.hasBufferedOps())
|
|
||||||
@_leaveCallbacks ||= []
|
|
||||||
@_leaveCallbacks.push callback
|
|
||||||
else if !@connected
|
|
||||||
callback()
|
|
||||||
else
|
|
||||||
@_leaveDoc(callback)
|
|
||||||
|
|
||||||
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()
|
|
||||||
if !inflightOp? and !pendingOp?
|
|
||||||
# there's nothing going on
|
|
||||||
saved = true
|
|
||||||
else if inflightOp == @oldInflightOp
|
|
||||||
saved = false
|
|
||||||
else if pendingOp?
|
|
||||||
saved = false
|
|
||||||
else
|
|
||||||
saved = true
|
|
||||||
|
|
||||||
@oldInflightOp = inflightOp
|
|
||||||
return saved
|
|
||||||
|
|
||||||
_cancelLeave: () ->
|
|
||||||
if @_leaveCallbacks?
|
|
||||||
delete @_leaveCallbacks
|
|
||||||
|
|
||||||
_cancelJoin: () ->
|
|
||||||
if @_joinCallbacks?
|
|
||||||
delete @_joinCallbacks
|
|
||||||
|
|
||||||
_onUpdateApplied: (update) ->
|
|
||||||
@ide.pushEvent "received-update",
|
|
||||||
doc_id: @doc_id
|
|
||||||
remote_doc_id: update?.doc
|
|
||||||
wantToBeJoined: @wantToBeJoined
|
|
||||||
update: update
|
|
||||||
|
|
||||||
if Math.random() < (@ide.disconnectRate or 0)
|
|
||||||
console.log "Simulating disconnect"
|
|
||||||
@ide.connectionManager.disconnect()
|
|
||||||
return
|
|
||||||
|
|
||||||
if update?.doc == @doc_id and @doc?
|
|
||||||
@doc.processUpdateFromServer update
|
|
||||||
|
|
||||||
if !@wantToBeJoined
|
|
||||||
@leave()
|
|
||||||
|
|
||||||
_onDisconnect: () ->
|
|
||||||
@connected = false
|
|
||||||
@joined = false
|
|
||||||
@doc?.updateConnectionState "disconnected"
|
|
||||||
|
|
||||||
_onReconnect: () ->
|
|
||||||
@ide.pushEvent "reconnected:afterJoinProject"
|
|
||||||
|
|
||||||
@connected = true
|
|
||||||
if @wantToBeJoined or @doc?.hasBufferedOps()
|
|
||||||
@_joinDoc (error) =>
|
|
||||||
return @_onError(error) if error?
|
|
||||||
@doc.updateConnectionState "ok"
|
|
||||||
@doc.flushPendingOps()
|
|
||||||
@_callJoinCallbacks()
|
|
||||||
|
|
||||||
_callJoinCallbacks: () ->
|
|
||||||
for callback in @_joinCallbacks or []
|
|
||||||
callback()
|
|
||||||
delete @_joinCallbacks
|
|
||||||
|
|
||||||
_joinDoc: (callback = (error) ->) ->
|
|
||||||
if @doc?
|
|
||||||
@ide.socket.emit 'joinDoc', @doc_id, @doc.getVersion(), (error, docLines, version, updates) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@joined = true
|
|
||||||
@doc.catchUp( updates )
|
|
||||||
callback()
|
|
||||||
else
|
|
||||||
@ide.socket.emit 'joinDoc', @doc_id, (error, docLines, version) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@joined = true
|
|
||||||
@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
|
|
||||||
@_bindToShareJsDocEvents()
|
|
||||||
callback()
|
|
||||||
|
|
||||||
_leaveDoc: (callback = (error) ->) ->
|
|
||||||
@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@joined = false
|
|
||||||
for callback in @_leaveCallbacks or []
|
|
||||||
callback(error)
|
|
||||||
delete @_leaveCallbacks
|
|
||||||
callback(error)
|
|
||||||
|
|
||||||
_cleanUp: () ->
|
|
||||||
delete Document.openDocs[@doc_id]
|
|
||||||
@unBindFromEditorEvents()
|
|
||||||
@unBindFromSocketEvents()
|
|
||||||
|
|
||||||
_bindToShareJsDocEvents: () ->
|
|
||||||
@doc.on "error", (error, meta) => @_onError error, meta
|
|
||||||
@doc.on "externalUpdate", () =>
|
|
||||||
@ide.pushEvent "externalUpdate",
|
|
||||||
doc_id: @doc_id
|
|
||||||
@trigger "externalUpdate"
|
|
||||||
@doc.on "remoteop", () =>
|
|
||||||
@ide.pushEvent "remoteop",
|
|
||||||
doc_id: @doc_id
|
|
||||||
@trigger "remoteop"
|
|
||||||
@doc.on "op:sent", (op) =>
|
|
||||||
@ide.pushEvent "op:sent",
|
|
||||||
doc_id: @doc_id
|
|
||||||
op: op
|
|
||||||
@trigger "op:sent"
|
|
||||||
@doc.on "op:acknowledged", (op) =>
|
|
||||||
@ide.pushEvent "op:acknowledged",
|
|
||||||
doc_id: @doc_id
|
|
||||||
op: op
|
|
||||||
@trigger "op:acknowledged"
|
|
||||||
@doc.on "op:timeout", (op) =>
|
|
||||||
@ide.pushEvent "op:timeout",
|
|
||||||
doc_id: @doc_id
|
|
||||||
op: op
|
|
||||||
@trigger "op:timeout"
|
|
||||||
ga?('send', 'event', 'error', "op timeout", "Op was now acknowledged - #{ide.socket.socket.transport.name}" )
|
|
||||||
@ide.connectionManager.reconnectImmediately()
|
|
||||||
@doc.on "flush", (inflightOp, pendingOp, version) =>
|
|
||||||
@ide.pushEvent "flush",
|
|
||||||
doc_id: @doc_id,
|
|
||||||
inflightOp: inflightOp,
|
|
||||||
pendingOp: pendingOp
|
|
||||||
v: version
|
|
||||||
|
|
||||||
_onError: (error, meta = {}) ->
|
|
||||||
console.error "ShareJS error", error, meta
|
|
||||||
ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{ide.socket.socket.transport.name}" )
|
|
||||||
@ide.socket.disconnect()
|
|
||||||
meta.doc_id = @doc_id
|
|
||||||
@ide.reportError(error, meta)
|
|
||||||
@doc?.clearInflightAndPendingOps()
|
|
||||||
@_cleanUp()
|
|
||||||
@trigger "error", error
|
|
||||||
|
|
||||||
_.extend(Document::, Backbone.Events)
|
|
||||||
|
|
||||||
return Document
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,374 +0,0 @@
|
||||||
define [
|
|
||||||
"editor/Document"
|
|
||||||
"undo/UndoManager"
|
|
||||||
"utils/Modal"
|
|
||||||
"ace/ace"
|
|
||||||
"ace/edit_session"
|
|
||||||
"ace/mode/latex"
|
|
||||||
"ace/range"
|
|
||||||
"ace/keyboard/vim"
|
|
||||||
"ace/keyboard/emacs"
|
|
||||||
"libs/backbone"
|
|
||||||
"libs/jquery.storage"
|
|
||||||
], (Document, UndoManager, Modal) ->
|
|
||||||
AceEditor = require("ace/ace")
|
|
||||||
EditSession = require('ace/edit_session').EditSession
|
|
||||||
LatexMode = require("ace/mode/latex").Mode
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
Vim = require("ace/keyboard/vim").handler
|
|
||||||
Emacs = require("ace/keyboard/emacs").handler
|
|
||||||
keybindings = ace: null, vim: Vim, emacs: Emacs
|
|
||||||
|
|
||||||
class Editor
|
|
||||||
templates:
|
|
||||||
editorPanel: $("#editorPanelTemplate").html()
|
|
||||||
loadingIndicator: $("#loadingIndicatorTemplate").html()
|
|
||||||
|
|
||||||
viewOptions: {flatView:"flatView", splitView:"splitView"}
|
|
||||||
currentViewState: undefined
|
|
||||||
compilationErrors: {}
|
|
||||||
|
|
||||||
constructor: (@ide) ->
|
|
||||||
_.extend @, Backbone.Events
|
|
||||||
@editorPanel = $(@templates.editorPanel)
|
|
||||||
@ide.mainAreaManager.addArea
|
|
||||||
identifier: "editor"
|
|
||||||
element: @editorPanel
|
|
||||||
@initializeEditor()
|
|
||||||
@bindToFileTreeEvents()
|
|
||||||
@enable()
|
|
||||||
@loadingIndicator = $(@templates.loadingIndicator)
|
|
||||||
@editorPanel.find("#editor").append(@loadingIndicator)
|
|
||||||
@leftPanel = @editorPanel.find("#leftEditorPanel")
|
|
||||||
@rightPanel = @editorPanel.find("#rightEditorPanel")
|
|
||||||
@initSplitView()
|
|
||||||
@switchToFlatView()
|
|
||||||
|
|
||||||
bindToFileTreeEvents: () ->
|
|
||||||
@ide.fileTreeManager.on "open:doc", (doc_id, options = {}) =>
|
|
||||||
if @enabled
|
|
||||||
@openDoc doc_id, options
|
|
||||||
|
|
||||||
initSplitView: () ->
|
|
||||||
@$splitter = splitter = @editorPanel.find("#editorSplitter")
|
|
||||||
options =
|
|
||||||
spacing_open: 8
|
|
||||||
spacing_closed: 16
|
|
||||||
east:
|
|
||||||
size: "50%"
|
|
||||||
maskIframesOnResize: true
|
|
||||||
onresize: () =>
|
|
||||||
@trigger("resize")
|
|
||||||
|
|
||||||
if (state = $.localStorage("layout.editor"))?
|
|
||||||
options.east = state.east
|
|
||||||
|
|
||||||
splitter.layout options
|
|
||||||
|
|
||||||
$(window).unload () =>
|
|
||||||
@_saveSplitterState()
|
|
||||||
|
|
||||||
_saveSplitterState: () ->
|
|
||||||
if $("#editorSplitter").is(":visible")
|
|
||||||
state = $("#editorSplitter").layout().readState()
|
|
||||||
eastWidth = state.east.size + $("#editorSplitter .ui-layout-resizer-east").width()
|
|
||||||
percentWidth = eastWidth / $("#editorSplitter").width() * 100 + "%"
|
|
||||||
state.east.size = percentWidth
|
|
||||||
$.localStorage("layout.editor", state)
|
|
||||||
|
|
||||||
switchToSplitView: () ->
|
|
||||||
if @currentViewState != @viewOptions.splitView
|
|
||||||
@currentViewState = @viewOptions.splitView
|
|
||||||
@leftPanel.prepend(
|
|
||||||
@editorPanel.find("#editorWrapper")
|
|
||||||
)
|
|
||||||
splitter = @editorPanel.find("#editorSplitter")
|
|
||||||
splitter.show()
|
|
||||||
@ide.layoutManager.resizeAllSplitters()
|
|
||||||
|
|
||||||
switchToFlatView: () ->
|
|
||||||
if @currentViewState != @viewOptions.flatView
|
|
||||||
@_saveSplitterState()
|
|
||||||
@currentViewState = @viewOptions.flatView
|
|
||||||
@editorPanel.prepend(
|
|
||||||
@editorPanel.find("#editorWrapper")
|
|
||||||
)
|
|
||||||
@editorPanel.find("#editorSplitter").hide()
|
|
||||||
@aceEditor.resize(true)
|
|
||||||
|
|
||||||
showLoading: () ->
|
|
||||||
delay = 600 # ms
|
|
||||||
@loading = true
|
|
||||||
setTimeout ( =>
|
|
||||||
if @loading
|
|
||||||
@loadingIndicator.show()
|
|
||||||
), delay
|
|
||||||
|
|
||||||
hideLoading: () ->
|
|
||||||
@loading = false
|
|
||||||
@loadingIndicator.hide()
|
|
||||||
|
|
||||||
showUndoConflictWarning: () ->
|
|
||||||
$("#editor").prepend($("#undoConflictWarning"))
|
|
||||||
$("#undoConflictWarning").show()
|
|
||||||
hideBtn = $("#undoConflictWarning .js-hide")
|
|
||||||
hideBtn.off("click")
|
|
||||||
hideBtn.on "click", (e) ->
|
|
||||||
e.preventDefault()
|
|
||||||
$("#undoConflictWarning").hide()
|
|
||||||
if @hideUndoWarningTimeout?
|
|
||||||
clearTimeout @hideUndoWarningTimeout
|
|
||||||
delete @hideUndoWarningTimeout
|
|
||||||
@hideUndoWarningTimeout = setTimeout ->
|
|
||||||
$("#undoConflictWarning").fadeOut("slow")
|
|
||||||
, 4000
|
|
||||||
|
|
||||||
|
|
||||||
initializeEditor: () ->
|
|
||||||
@aceEditor = aceEditor = AceEditor.edit("editor")
|
|
||||||
|
|
||||||
@on "resize", => @aceEditor.resize()
|
|
||||||
@ide.layoutManager.on "resize", => @trigger "resize"
|
|
||||||
|
|
||||||
mode = window.userSettings.mode
|
|
||||||
theme = window.userSettings.theme
|
|
||||||
|
|
||||||
chosenKeyBindings = keybindings[mode]
|
|
||||||
aceEditor.setKeyboardHandler(chosenKeyBindings)
|
|
||||||
aceEditor.setTheme("ace/theme/#{window.userSettings.theme}")
|
|
||||||
aceEditor.setShowPrintMargin(false)
|
|
||||||
|
|
||||||
# Prevert Ctrl|Cmd-S from triggering save dialog
|
|
||||||
aceEditor.commands.addCommand
|
|
||||||
name: "save",
|
|
||||||
bindKey: win: "Ctrl-S", mac: "Command-S"
|
|
||||||
exec: () ->
|
|
||||||
readOnly: true
|
|
||||||
aceEditor.commands.removeCommand "transposeletters"
|
|
||||||
aceEditor.commands.removeCommand "showSettingsMenu"
|
|
||||||
aceEditor.commands.removeCommand "foldall"
|
|
||||||
|
|
||||||
aceEditor.showCommandLine = (args...) =>
|
|
||||||
@trigger "showCommandLine", aceEditor, args...
|
|
||||||
|
|
||||||
aceEditor.on "dblclick", (e) => @trigger "dblclick", e
|
|
||||||
aceEditor.on "click", (e) => @trigger "click", e
|
|
||||||
aceEditor.on "mousemove", (e) =>
|
|
||||||
position = @aceEditor.renderer.screenToTextCoordinates(e.clientX, e.clientY)
|
|
||||||
e.position = position
|
|
||||||
@trigger "mousemove", e
|
|
||||||
|
|
||||||
setIdeToEditorPanel: (options = {}) ->
|
|
||||||
@aceEditor.focus()
|
|
||||||
@aceEditor.resize()
|
|
||||||
loadDocument = =>
|
|
||||||
@refreshCompilationErrors()
|
|
||||||
|
|
||||||
@ide.layoutManager.resizeAllSplitters()
|
|
||||||
|
|
||||||
if options.line?
|
|
||||||
@gotoLine(options.line)
|
|
||||||
else
|
|
||||||
pos = $.localStorage("doc.position.#{@current_doc_id}") || {}
|
|
||||||
@ignoreCursorPositionChanges = true
|
|
||||||
@setCursorPosition(pos.cursorPosition or {row: 0, column: 0})
|
|
||||||
@setScrollTop(pos.scrollTop or 0)
|
|
||||||
@ignoreCursorPositionChanges = false
|
|
||||||
@ide.mainAreaManager.change 'editor', =>
|
|
||||||
setTimeout loadDocument, 0
|
|
||||||
|
|
||||||
refreshCompilationErrors: () ->
|
|
||||||
@getSession().setAnnotations @compilationErrors[@current_doc_id]
|
|
||||||
|
|
||||||
openDoc: (doc_id, options = {}) ->
|
|
||||||
if @current_doc_id == doc_id && !options.forceReopen
|
|
||||||
@setIdeToEditorPanel(line: options.line)
|
|
||||||
else
|
|
||||||
@showLoading()
|
|
||||||
@current_doc_id = doc_id
|
|
||||||
@_openNewDocument doc_id, (error, document) =>
|
|
||||||
if error?
|
|
||||||
@ide.showGenericServerErrorMessage()
|
|
||||||
return
|
|
||||||
|
|
||||||
@setIdeToEditorPanel(line: options.line)
|
|
||||||
@hideLoading()
|
|
||||||
@trigger "change:doc", @getSession()
|
|
||||||
|
|
||||||
_openNewDocument: (doc_id, callback = (error, document) ->) ->
|
|
||||||
if @document?
|
|
||||||
@document.leaveAndCleanUp()
|
|
||||||
@_unbindFromDocumentEvents(@document)
|
|
||||||
@_detachDocumentFromEditor(@document)
|
|
||||||
|
|
||||||
@document = Document.getDocument @ide, doc_id
|
|
||||||
|
|
||||||
@document.join (error) =>
|
|
||||||
return callback(error) if error?
|
|
||||||
@_bindToDocumentEvents(@document)
|
|
||||||
@_bindDocumentToEditor(@document)
|
|
||||||
callback null, @document
|
|
||||||
|
|
||||||
_bindToDocumentEvents: (document) ->
|
|
||||||
document.on "remoteop", () =>
|
|
||||||
@undoManager.nextUpdateIsRemote = true
|
|
||||||
|
|
||||||
document.on "error", (error) =>
|
|
||||||
@openDoc(document.doc_id, forceReopen: true)
|
|
||||||
|
|
||||||
Modal.createModal
|
|
||||||
title: "Out of sync"
|
|
||||||
message: "Sorry, this file has gone out of sync and we need to do a full refresh. Please let us know if this happens frequently."
|
|
||||||
buttons:[
|
|
||||||
text: "Ok"
|
|
||||||
]
|
|
||||||
|
|
||||||
document.on "externalUpdate", () =>
|
|
||||||
Modal.createModal
|
|
||||||
title: "Document Updated Externally"
|
|
||||||
message: "This document was just updated externally. Any recent changes you have made may have been overwritten. To see previous versions please look in the history."
|
|
||||||
buttons:[
|
|
||||||
text: "Ok"
|
|
||||||
]
|
|
||||||
|
|
||||||
_unbindFromDocumentEvents: (document) ->
|
|
||||||
document.off()
|
|
||||||
|
|
||||||
_bindDocumentToEditor: (document) ->
|
|
||||||
$("#editor").show()
|
|
||||||
@_bindNewDocToAce(document)
|
|
||||||
|
|
||||||
_detachDocumentFromEditor: (document) ->
|
|
||||||
document.detachFromAce()
|
|
||||||
|
|
||||||
_bindNewDocToAce: (document) ->
|
|
||||||
@_createNewSessionFromDocLines(document.getSnapshot().split("\n"))
|
|
||||||
@_setReadWritePermission()
|
|
||||||
@_bindToAceEvents()
|
|
||||||
|
|
||||||
# Updating the doc can cause the cursor to jump around
|
|
||||||
# but we shouldn't record that
|
|
||||||
@ignoreCursorPositionChanges = true
|
|
||||||
document.attachToAce(@aceEditor)
|
|
||||||
@ignoreCursorPositionChanges = false
|
|
||||||
|
|
||||||
_bindToAceEvents: () ->
|
|
||||||
aceDoc = @getDocument()
|
|
||||||
aceDoc.on 'change', (change) => @onDocChange(change)
|
|
||||||
|
|
||||||
session = @getSession()
|
|
||||||
session.on "changeScrollTop", (e) => @onScrollTopChange(e)
|
|
||||||
session.selection.on 'changeCursor', (e) => @onCursorChange(e)
|
|
||||||
|
|
||||||
_createNewSessionFromDocLines: (docLines) ->
|
|
||||||
@aceEditor.setSession(new EditSession(docLines))
|
|
||||||
session = @getSession()
|
|
||||||
session.setUseWrapMode(true)
|
|
||||||
session.setMode(new LatexMode())
|
|
||||||
@undoManager = new UndoManager(@)
|
|
||||||
session.setUndoManager @undoManager
|
|
||||||
|
|
||||||
_setReadWritePermission: () ->
|
|
||||||
if !@ide.isAllowedToDoIt 'readAndWrite'
|
|
||||||
@makeReadOnly()
|
|
||||||
else
|
|
||||||
@makeWritable()
|
|
||||||
|
|
||||||
onDocChange: (change) ->
|
|
||||||
@lastUpdated = new Date()
|
|
||||||
@trigger "update:doc", change
|
|
||||||
|
|
||||||
onScrollTopChange: (event) ->
|
|
||||||
@trigger "scroll", event
|
|
||||||
if !@ignoreCursorPositionChanges
|
|
||||||
docPosition = $.localStorage("doc.position.#{@current_doc_id}") || {}
|
|
||||||
docPosition.scrollTop = @getScrollTop()
|
|
||||||
$.localStorage("doc.position.#{@current_doc_id}", docPosition)
|
|
||||||
|
|
||||||
onCursorChange: (event) ->
|
|
||||||
@trigger "cursor:change", event
|
|
||||||
if !@ignoreCursorPositionChanges
|
|
||||||
docPosition = $.localStorage("doc.position.#{@current_doc_id}") || {}
|
|
||||||
docPosition.cursorPosition = @getCursorPosition()
|
|
||||||
$.localStorage("doc.position.#{@current_doc_id}", docPosition)
|
|
||||||
|
|
||||||
makeReadOnly: () ->
|
|
||||||
@aceEditor.setReadOnly true
|
|
||||||
|
|
||||||
makeWritable: () ->
|
|
||||||
@aceEditor.setReadOnly false
|
|
||||||
|
|
||||||
getSession: () -> @aceEditor.getSession()
|
|
||||||
|
|
||||||
getDocument: () -> @getSession().getDocument()
|
|
||||||
|
|
||||||
gotoLine: (line) ->
|
|
||||||
@aceEditor.gotoLine(line)
|
|
||||||
|
|
||||||
getCurrentLine: () ->
|
|
||||||
@aceEditor.selection?.getCursor()?.row
|
|
||||||
|
|
||||||
getCurrentColumn: () ->
|
|
||||||
@aceEditor.selection?.getCursor()?.column
|
|
||||||
|
|
||||||
getLines: (from, to) ->
|
|
||||||
if from? and to?
|
|
||||||
@getSession().doc.getLines(from, to)
|
|
||||||
else
|
|
||||||
@getSession().doc.getAllLines()
|
|
||||||
|
|
||||||
addMarker: (position, klass, type, inFront) ->
|
|
||||||
range = new Range(
|
|
||||||
position.row, position.column,
|
|
||||||
position.row, position.column + position.length
|
|
||||||
)
|
|
||||||
@getSession().addMarker range, klass, type, inFront
|
|
||||||
|
|
||||||
removeMarker: (markerId) ->
|
|
||||||
@getSession().removeMarker markerId
|
|
||||||
|
|
||||||
getCursorPosition: () -> @aceEditor.getCursorPosition()
|
|
||||||
setCursorPosition: (pos) -> @aceEditor.moveCursorToPosition(pos)
|
|
||||||
|
|
||||||
getScrollTop: () -> @getSession().getScrollTop()
|
|
||||||
setScrollTop: (pos) -> @getSession().setScrollTop(pos)
|
|
||||||
|
|
||||||
replaceText: (range, text) ->
|
|
||||||
@getSession().replace(new Range(
|
|
||||||
range.start.row, range.start.column,
|
|
||||||
range.end.row, range.end.column
|
|
||||||
), text)
|
|
||||||
|
|
||||||
getContainerElement: () ->
|
|
||||||
$(@aceEditor.renderer.getContainerElement())
|
|
||||||
|
|
||||||
getCursorElement: () ->
|
|
||||||
@getContainerElement().find(".ace_cursor")
|
|
||||||
|
|
||||||
textToEditorCoordinates: (x, y) ->
|
|
||||||
editorAreaOffset = @getContainerElement().offset()
|
|
||||||
{pageX, pageY} = @aceEditor.renderer.textToScreenCoordinates(x, y)
|
|
||||||
return {
|
|
||||||
x: pageX - editorAreaOffset.left
|
|
||||||
y: pageY - editorAreaOffset.top
|
|
||||||
}
|
|
||||||
|
|
||||||
chaosMonkey: (line = 0, char = "a") ->
|
|
||||||
@_cm = setInterval () =>
|
|
||||||
@aceEditor.session.insert({row: line, column: 0}, char)
|
|
||||||
, 100
|
|
||||||
|
|
||||||
clearChaosMonkey: () ->
|
|
||||||
clearInterval @_cm
|
|
||||||
|
|
||||||
getCurrentDocId: () ->
|
|
||||||
@current_doc_id
|
|
||||||
|
|
||||||
enable: () ->
|
|
||||||
@enabled = true
|
|
||||||
|
|
||||||
disable: () ->
|
|
||||||
@enabled = false
|
|
||||||
|
|
||||||
hasUnsavedChanges: () ->
|
|
||||||
Document.hasUnsavedChanges()
|
|
|
@ -1,3 +0,0 @@
|
||||||
WEB = true
|
|
||||||
window.sharejs = exports = {}
|
|
||||||
types = exports.types = {}
|
|
|
@ -1,127 +0,0 @@
|
||||||
define [
|
|
||||||
"libs/sharejs"
|
|
||||||
"libs/backbone"
|
|
||||||
], (ShareJs) ->
|
|
||||||
class ShareJsDoc
|
|
||||||
constructor: (@doc_id, docLines, version, @socket) ->
|
|
||||||
# Dencode any binary bits of data
|
|
||||||
# See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
|
|
||||||
@type = "text"
|
|
||||||
docLines = for line in docLines
|
|
||||||
if line.text?
|
|
||||||
@type = "json"
|
|
||||||
line.text = decodeURIComponent(escape(line.text))
|
|
||||||
else
|
|
||||||
@type = "text"
|
|
||||||
line = decodeURIComponent(escape(line))
|
|
||||||
line
|
|
||||||
|
|
||||||
if @type == "text"
|
|
||||||
snapshot = docLines.join("\n")
|
|
||||||
else if @type == "json"
|
|
||||||
snapshot = { lines: docLines }
|
|
||||||
else
|
|
||||||
throw new Error("Unknown type: #{@type}")
|
|
||||||
|
|
||||||
@connection = {
|
|
||||||
send: (update) =>
|
|
||||||
@_startInflightOpTimeout(update)
|
|
||||||
@socket.emit "applyOtUpdate", @doc_id, update
|
|
||||||
state: "ok"
|
|
||||||
id: @socket.socket.sessionid
|
|
||||||
}
|
|
||||||
|
|
||||||
@_doc = new ShareJs.Doc @connection, @doc_id,
|
|
||||||
type: @type
|
|
||||||
@_doc.on "change", () =>
|
|
||||||
@trigger "change"
|
|
||||||
@_doc.on "acknowledge", () =>
|
|
||||||
@trigger "acknowledge"
|
|
||||||
@_doc.on "remoteop", () =>
|
|
||||||
@trigger "remoteop"
|
|
||||||
|
|
||||||
@_bindToDocChanges(@_doc)
|
|
||||||
|
|
||||||
@processUpdateFromServer
|
|
||||||
open: true
|
|
||||||
v: version
|
|
||||||
snapshot: snapshot
|
|
||||||
|
|
||||||
submitOp: (args...) -> @_doc.submitOp(args...)
|
|
||||||
|
|
||||||
processUpdateFromServer: (message) ->
|
|
||||||
try
|
|
||||||
@_doc._onMessage message
|
|
||||||
catch error
|
|
||||||
# Version mismatches are thrown as errors
|
|
||||||
@_handleError(error)
|
|
||||||
|
|
||||||
if message?.meta?.type == "external"
|
|
||||||
@trigger "externalUpdate"
|
|
||||||
|
|
||||||
catchUp: (updates) ->
|
|
||||||
for update, i in updates
|
|
||||||
update.v = @_doc.version
|
|
||||||
update.doc = @doc_id
|
|
||||||
@processUpdateFromServer(update)
|
|
||||||
|
|
||||||
getSnapshot: () -> @_doc.snapshot
|
|
||||||
getVersion: () -> @_doc.version
|
|
||||||
getType: () -> @type
|
|
||||||
|
|
||||||
clearInflightAndPendingOps: () ->
|
|
||||||
@_doc.inflightOp = null
|
|
||||||
@_doc.inflightCallbacks = []
|
|
||||||
@_doc.pendingOp = null
|
|
||||||
@_doc.pendingCallbacks = []
|
|
||||||
|
|
||||||
flushPendingOps: () ->
|
|
||||||
# This will flush any ops that are pending.
|
|
||||||
# If there is an inflight op it will do nothing.
|
|
||||||
@_doc.flush()
|
|
||||||
|
|
||||||
updateConnectionState: (state) ->
|
|
||||||
@connection.state = state
|
|
||||||
@connection.id = @socket.socket.sessionid
|
|
||||||
@_doc.autoOpen = false
|
|
||||||
@_doc._connectionStateChanged(state)
|
|
||||||
|
|
||||||
hasBufferedOps: () ->
|
|
||||||
@_doc.inflightOp? or @_doc.pendingOp?
|
|
||||||
|
|
||||||
getInflightOp: () -> @_doc.inflightOp
|
|
||||||
getPendingOp: () -> @_doc.pendingOp
|
|
||||||
|
|
||||||
attachToAce: (ace) -> @_doc.attach_ace(ace)
|
|
||||||
detachFromAce: () -> @_doc.detach_ace?()
|
|
||||||
|
|
||||||
INFLIGHT_OP_TIMEOUT: 10000
|
|
||||||
_startInflightOpTimeout: (update) ->
|
|
||||||
meta =
|
|
||||||
v: update.v
|
|
||||||
op_sent_at: new Date()
|
|
||||||
timer = setTimeout () =>
|
|
||||||
@trigger "op:timeout", update
|
|
||||||
, @INFLIGHT_OP_TIMEOUT
|
|
||||||
@_doc.inflightCallbacks.push () =>
|
|
||||||
clearTimeout timer
|
|
||||||
|
|
||||||
_handleError: (error, meta = {}) ->
|
|
||||||
@trigger "error", error, meta
|
|
||||||
|
|
||||||
_bindToDocChanges: (doc) ->
|
|
||||||
submitOp = doc.submitOp
|
|
||||||
doc.submitOp = (args...) =>
|
|
||||||
@trigger "op:sent", args...
|
|
||||||
doc.pendingCallbacks.push () =>
|
|
||||||
@trigger "op:acknowledged", args...
|
|
||||||
submitOp.apply(doc, args)
|
|
||||||
|
|
||||||
flush = doc.flush
|
|
||||||
doc.flush = (args...) =>
|
|
||||||
@trigger "flush", doc.inflightOp, doc.pendingOp, doc.version
|
|
||||||
flush.apply(doc, args)
|
|
||||||
|
|
||||||
_.extend(ShareJsDoc::, Backbone.Events)
|
|
||||||
|
|
||||||
return ShareJsDoc
|
|
|
@ -1,130 +0,0 @@
|
||||||
# This is some utility code to connect an ace editor to a sharejs document.
|
|
||||||
|
|
||||||
Range = require("ace/range").Range
|
|
||||||
|
|
||||||
# Convert an ace delta into an op understood by share.js
|
|
||||||
applyToShareJS = (editorDoc, delta, doc) ->
|
|
||||||
# Get the start position of the range, in no. of characters
|
|
||||||
getStartOffsetPosition = (range) ->
|
|
||||||
# This is quite inefficient - getLines makes a copy of the entire
|
|
||||||
# lines array in the document. It would be nice if we could just
|
|
||||||
# access them directly.
|
|
||||||
lines = editorDoc.getLines 0, range.start.row
|
|
||||||
|
|
||||||
offset = 0
|
|
||||||
|
|
||||||
for line, i in lines
|
|
||||||
offset += if i < range.start.row
|
|
||||||
line.length
|
|
||||||
else
|
|
||||||
range.start.column
|
|
||||||
|
|
||||||
# Add the row number to include newlines.
|
|
||||||
offset + range.start.row
|
|
||||||
|
|
||||||
pos = getStartOffsetPosition(delta.range)
|
|
||||||
|
|
||||||
switch delta.action
|
|
||||||
when 'insertText' then doc.insert pos, delta.text
|
|
||||||
when 'removeText' then doc.del pos, delta.text.length
|
|
||||||
|
|
||||||
when 'insertLines'
|
|
||||||
text = delta.lines.join('\n') + '\n'
|
|
||||||
doc.insert pos, text
|
|
||||||
|
|
||||||
when 'removeLines'
|
|
||||||
text = delta.lines.join('\n') + '\n'
|
|
||||||
doc.del pos, text.length
|
|
||||||
|
|
||||||
else throw new Error "unknown action: #{delta.action}"
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
||||||
# Attach an ace editor to the document. The editor's contents are replaced
|
|
||||||
# with the document's contents unless keepEditorContents is true. (In which case the document's
|
|
||||||
# contents are nuked and replaced with the editor's).
|
|
||||||
window.sharejs.extendDoc 'attach_ace', (editor, keepEditorContents) ->
|
|
||||||
throw new Error 'Only text documents can be attached to ace' unless @provides['text']
|
|
||||||
|
|
||||||
doc = this
|
|
||||||
editorDoc = editor.getSession().getDocument()
|
|
||||||
editorDoc.setNewLineMode 'unix'
|
|
||||||
|
|
||||||
check = ->
|
|
||||||
window.setTimeout ->
|
|
||||||
editorText = editorDoc.getValue()
|
|
||||||
otText = doc.getText()
|
|
||||||
|
|
||||||
if editorText != otText
|
|
||||||
console.error "Text does not match!"
|
|
||||||
console.error "editor: #{editorText}"
|
|
||||||
console.error "ot: #{otText}"
|
|
||||||
# Should probably also replace the editor text with the doc snapshot.
|
|
||||||
, 0
|
|
||||||
|
|
||||||
# MODIFIED by James: We will set the doc contents ourselves to
|
|
||||||
# avoid an extra entry in the undo stack.
|
|
||||||
# if keepEditorContents
|
|
||||||
# doc.del 0, doc.getText().length
|
|
||||||
# doc.insert 0, editorDoc.getValue()
|
|
||||||
# else
|
|
||||||
# editorDoc.setValue doc.getText()
|
|
||||||
|
|
||||||
check()
|
|
||||||
|
|
||||||
# When we apply ops from sharejs, ace emits edit events. We need to ignore those
|
|
||||||
# to prevent an infinite typing loop.
|
|
||||||
suppress = false
|
|
||||||
|
|
||||||
# Listen for edits in ace
|
|
||||||
editorListener = (change) ->
|
|
||||||
return if suppress
|
|
||||||
applyToShareJS editorDoc, change.data, doc
|
|
||||||
|
|
||||||
check()
|
|
||||||
|
|
||||||
editorDoc.on 'change', editorListener
|
|
||||||
|
|
||||||
# Listen for remote ops on the sharejs document
|
|
||||||
docListener = (op) ->
|
|
||||||
suppress = true
|
|
||||||
applyToDoc editorDoc, op
|
|
||||||
suppress = false
|
|
||||||
|
|
||||||
check()
|
|
||||||
|
|
||||||
|
|
||||||
# Horribly inefficient.
|
|
||||||
offsetToPos = (offset) ->
|
|
||||||
# Again, very inefficient.
|
|
||||||
lines = editorDoc.getAllLines()
|
|
||||||
|
|
||||||
row = 0
|
|
||||||
for line, row in lines
|
|
||||||
break if offset <= line.length
|
|
||||||
|
|
||||||
# +1 for the newline.
|
|
||||||
offset -= lines[row].length + 1
|
|
||||||
|
|
||||||
row:row, column:offset
|
|
||||||
|
|
||||||
doc.on 'insert', (pos, text) ->
|
|
||||||
suppress = true
|
|
||||||
editorDoc.insert offsetToPos(pos), text
|
|
||||||
suppress = false
|
|
||||||
check()
|
|
||||||
|
|
||||||
doc.on 'delete', (pos, text) ->
|
|
||||||
suppress = true
|
|
||||||
range = Range.fromPoints offsetToPos(pos), offsetToPos(pos + text.length)
|
|
||||||
editorDoc.remove range
|
|
||||||
suppress = false
|
|
||||||
check()
|
|
||||||
|
|
||||||
doc.detach_ace = ->
|
|
||||||
doc.removeListener 'remoteop', docListener
|
|
||||||
editorDoc.removeListener 'change', editorListener
|
|
||||||
delete doc.detach_ace
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
|
@ -1,119 +0,0 @@
|
||||||
// Generated by CoffeeScript 1.4.0
|
|
||||||
(function() {
|
|
||||||
var Range, applyToShareJS;
|
|
||||||
|
|
||||||
Range = require("ace/range").Range;
|
|
||||||
|
|
||||||
applyToShareJS = function(editorDoc, delta, doc) {
|
|
||||||
var getStartOffsetPosition, pos, text;
|
|
||||||
getStartOffsetPosition = function(range) {
|
|
||||||
var i, line, lines, offset, _i, _len;
|
|
||||||
lines = editorDoc.getLines(0, range.start.row);
|
|
||||||
offset = 0;
|
|
||||||
for (i = _i = 0, _len = lines.length; _i < _len; i = ++_i) {
|
|
||||||
line = lines[i];
|
|
||||||
offset += i < range.start.row ? line.length : range.start.column;
|
|
||||||
}
|
|
||||||
return offset + range.start.row;
|
|
||||||
};
|
|
||||||
pos = getStartOffsetPosition(delta.range);
|
|
||||||
switch (delta.action) {
|
|
||||||
case 'insertText':
|
|
||||||
doc.insert(pos, delta.text);
|
|
||||||
break;
|
|
||||||
case 'removeText':
|
|
||||||
doc.del(pos, delta.text.length);
|
|
||||||
break;
|
|
||||||
case 'insertLines':
|
|
||||||
text = delta.lines.join('\n') + '\n';
|
|
||||||
doc.insert(pos, text);
|
|
||||||
break;
|
|
||||||
case 'removeLines':
|
|
||||||
text = delta.lines.join('\n') + '\n';
|
|
||||||
doc.del(pos, text.length);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw new Error("unknown action: " + delta.action);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.sharejs.extendDoc('attach_ace', function(editor, keepEditorContents) {
|
|
||||||
var check, doc, docListener, editorDoc, editorListener, offsetToPos, suppress;
|
|
||||||
if (!this.provides['text']) {
|
|
||||||
throw new Error('Only text documents can be attached to ace');
|
|
||||||
}
|
|
||||||
doc = this;
|
|
||||||
editorDoc = editor.getSession().getDocument();
|
|
||||||
editorDoc.setNewLineMode('unix');
|
|
||||||
check = function() {
|
|
||||||
return window.setTimeout(function() {
|
|
||||||
var editorText, otText;
|
|
||||||
editorText = editorDoc.getValue();
|
|
||||||
otText = doc.getText();
|
|
||||||
if (editorText !== otText) {
|
|
||||||
console.error("Text does not match!");
|
|
||||||
console.error("editor: " + editorText);
|
|
||||||
return console.error("ot: " + otText);
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
if (keepEditorContents) {
|
|
||||||
doc.del(0, doc.getText().length);
|
|
||||||
doc.insert(0, editorDoc.getValue());
|
|
||||||
} else {
|
|
||||||
editorDoc.setValue(doc.getText());
|
|
||||||
}
|
|
||||||
check();
|
|
||||||
suppress = false;
|
|
||||||
editorListener = function(change) {
|
|
||||||
if (suppress) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyToShareJS(editorDoc, change.data, doc);
|
|
||||||
return check();
|
|
||||||
};
|
|
||||||
editorDoc.on('change', editorListener);
|
|
||||||
docListener = function(op) {
|
|
||||||
suppress = true;
|
|
||||||
applyToDoc(editorDoc, op);
|
|
||||||
suppress = false;
|
|
||||||
return check();
|
|
||||||
};
|
|
||||||
offsetToPos = function(offset) {
|
|
||||||
var line, lines, row, _i, _len;
|
|
||||||
lines = editorDoc.getAllLines();
|
|
||||||
row = 0;
|
|
||||||
for (row = _i = 0, _len = lines.length; _i < _len; row = ++_i) {
|
|
||||||
line = lines[row];
|
|
||||||
if (offset <= line.length) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
offset -= lines[row].length + 1;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
row: row,
|
|
||||||
column: offset
|
|
||||||
};
|
|
||||||
};
|
|
||||||
doc.on('insert', function(pos, text) {
|
|
||||||
suppress = true;
|
|
||||||
editorDoc.insert(offsetToPos(pos), text);
|
|
||||||
suppress = false;
|
|
||||||
return check();
|
|
||||||
});
|
|
||||||
doc.on('delete', function(pos, text) {
|
|
||||||
var range;
|
|
||||||
suppress = true;
|
|
||||||
range = Range.fromPoints(offsetToPos(pos), offsetToPos(pos + text.length));
|
|
||||||
editorDoc.remove(range);
|
|
||||||
suppress = false;
|
|
||||||
return check();
|
|
||||||
});
|
|
||||||
doc.detach_ace = function() {
|
|
||||||
doc.removeListener('remoteop', docListener);
|
|
||||||
editorDoc.removeListener('change', editorListener);
|
|
||||||
return delete doc.detach_ace;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
}).call(this);
|
|
|
@ -1,94 +0,0 @@
|
||||||
# This is some utility code to connect a CodeMirror editor
|
|
||||||
# to a sharejs document.
|
|
||||||
# It is heavily inspired from the Ace editor hook.
|
|
||||||
|
|
||||||
# Convert a CodeMirror delta into an op understood by share.js
|
|
||||||
applyToShareJS = (editorDoc, delta, doc) ->
|
|
||||||
# CodeMirror deltas give a text replacement.
|
|
||||||
# I tuned this operation a little bit, for speed.
|
|
||||||
startPos = 0 # Get character position from # of chars in each line.
|
|
||||||
i = 0 # i goes through all lines.
|
|
||||||
|
|
||||||
while i < delta.from.line
|
|
||||||
startPos += editorDoc.lineInfo(i).text.length + 1 # Add 1 for '\n'
|
|
||||||
i++
|
|
||||||
|
|
||||||
startPos += delta.from.ch
|
|
||||||
|
|
||||||
if delta.to.line == delta.from.line &&
|
|
||||||
delta.to.ch == delta.from.ch # Then nothing was removed.
|
|
||||||
doc.insert startPos, delta.text.join '\n'
|
|
||||||
else
|
|
||||||
delLen = delta.to.ch - delta.from.ch
|
|
||||||
while i < delta.to.line
|
|
||||||
delLen += editorDoc.lineInfo(i).text.length + 1 # Add 1 for '\n'
|
|
||||||
i++
|
|
||||||
doc.del startPos, delLen
|
|
||||||
doc.insert startPos, delta.text.join '\n' if delta.text
|
|
||||||
|
|
||||||
applyToShareJS editorDoc, delta.next, doc if delta.next
|
|
||||||
|
|
||||||
# Attach a CodeMirror editor to the document. The editor's contents are replaced
|
|
||||||
# with the document's contents unless keepEditorContents is true. (In which case
|
|
||||||
# the document's contents are nuked and replaced with the editor's).
|
|
||||||
window.sharejs.extendDoc 'attach_cm', (editor, keepEditorContents) ->
|
|
||||||
unless @provides.text
|
|
||||||
throw new Error 'Only text documents can be attached to CodeMirror2'
|
|
||||||
|
|
||||||
sharedoc = @
|
|
||||||
check = ->
|
|
||||||
window.setTimeout ->
|
|
||||||
editorText = editor.getValue()
|
|
||||||
otText = sharedoc.getValue()
|
|
||||||
|
|
||||||
if editorText != otText
|
|
||||||
console.error "Text does not match!"
|
|
||||||
console.error "editor: #{editorText}"
|
|
||||||
console.error "ot: #{otText}"
|
|
||||||
# Replace the editor text with the doc snapshot.
|
|
||||||
editor.setValue sharedoc.getValue()
|
|
||||||
, 0
|
|
||||||
|
|
||||||
if keepEditorContents
|
|
||||||
@del 0, sharedoc.getText().length
|
|
||||||
@insert 0, editor.getValue()
|
|
||||||
else
|
|
||||||
editor.setValue sharedoc.getText()
|
|
||||||
|
|
||||||
check()
|
|
||||||
|
|
||||||
# When we apply ops from sharejs, CodeMirror emits edit events.
|
|
||||||
# We need to ignore those to prevent an infinite typing loop.
|
|
||||||
suppress = false
|
|
||||||
|
|
||||||
# Listen for edits in CodeMirror.
|
|
||||||
editorListener = (ed, change) ->
|
|
||||||
return if suppress
|
|
||||||
applyToShareJS editor, change, sharedoc
|
|
||||||
|
|
||||||
check()
|
|
||||||
|
|
||||||
editor.setOption 'onChange', editorListener
|
|
||||||
|
|
||||||
@on 'insert', (pos, text) ->
|
|
||||||
suppress = true
|
|
||||||
# All the primitives we need are already in CM's API.
|
|
||||||
editor.replaceRange text, editor.posFromIndex(pos)
|
|
||||||
suppress = false
|
|
||||||
check()
|
|
||||||
|
|
||||||
@on 'delete', (pos, text) ->
|
|
||||||
suppress = true
|
|
||||||
from = editor.posFromIndex pos
|
|
||||||
to = editor.posFromIndex (pos + text.length)
|
|
||||||
editor.replaceRange '', from, to
|
|
||||||
suppress = false
|
|
||||||
check()
|
|
||||||
|
|
||||||
@detach_cm = ->
|
|
||||||
# TODO: can we remove the insert and delete event callbacks?
|
|
||||||
editor.setOption 'onChange', null
|
|
||||||
delete @detach_cm
|
|
||||||
|
|
||||||
return
|
|
||||||
|
|
|
@ -1,167 +0,0 @@
|
||||||
# A Connection wraps a persistant BC connection to a sharejs server.
|
|
||||||
#
|
|
||||||
# This class implements the client side of the protocol defined here:
|
|
||||||
# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
|
|
||||||
#
|
|
||||||
# The equivalent server code is in src/server/browserchannel.coffee.
|
|
||||||
#
|
|
||||||
# This file is a bit of a mess. I'm dreadfully sorry about that. It passes all the tests,
|
|
||||||
# so I have hope that its *correct* even if its not clean.
|
|
||||||
#
|
|
||||||
# Most of Connection exists to support the open() method, which creates a new document
|
|
||||||
# reference.
|
|
||||||
|
|
||||||
if WEB?
|
|
||||||
types = exports.types
|
|
||||||
throw new Error 'Must load browserchannel before this library' unless window.BCSocket
|
|
||||||
{BCSocket} = window
|
|
||||||
else
|
|
||||||
types = require '../types'
|
|
||||||
{BCSocket} = require 'browserchannel'
|
|
||||||
Doc = require('./doc').Doc
|
|
||||||
|
|
||||||
class Connection
|
|
||||||
constructor: (host) ->
|
|
||||||
# Map of docname -> doc
|
|
||||||
@docs = {}
|
|
||||||
|
|
||||||
# States:
|
|
||||||
# - 'connecting': The connection is being established
|
|
||||||
# - 'handshaking': The connection has been established, but we don't have the auth ID yet
|
|
||||||
# - 'ok': We have connected and recieved our client ID. Ready for data.
|
|
||||||
# - 'disconnected': The connection is closed, but it will not reconnect automatically.
|
|
||||||
# - 'stopped': The connection is closed, and will not reconnect.
|
|
||||||
@state = 'connecting'
|
|
||||||
@socket = new BCSocket host, reconnect:true
|
|
||||||
|
|
||||||
@socket.onmessage = (msg) =>
|
|
||||||
if msg.auth is null
|
|
||||||
# Auth failed.
|
|
||||||
@lastError = msg.error # 'forbidden'
|
|
||||||
@disconnect()
|
|
||||||
return @emit 'connect failed', msg.error
|
|
||||||
else if msg.auth
|
|
||||||
# Our very own client id.
|
|
||||||
@id = msg.auth
|
|
||||||
@setState 'ok'
|
|
||||||
return
|
|
||||||
|
|
||||||
docName = msg.doc
|
|
||||||
|
|
||||||
if docName isnt undefined
|
|
||||||
@lastReceivedDoc = docName
|
|
||||||
else
|
|
||||||
msg.doc = docName = @lastReceivedDoc
|
|
||||||
|
|
||||||
if @docs[docName]
|
|
||||||
@docs[docName]._onMessage msg
|
|
||||||
else
|
|
||||||
console?.error 'Unhandled message', msg
|
|
||||||
|
|
||||||
@connected = false
|
|
||||||
@socket.onclose = (reason) =>
|
|
||||||
#console.warn 'onclose', reason
|
|
||||||
@setState 'disconnected', reason
|
|
||||||
if reason in ['Closed', 'Stopped by server']
|
|
||||||
@setState 'stopped', @lastError or reason
|
|
||||||
|
|
||||||
@socket.onerror = (e) =>
|
|
||||||
#console.warn 'onerror', e
|
|
||||||
@emit 'error', e
|
|
||||||
|
|
||||||
@socket.onopen = =>
|
|
||||||
#console.warn 'onopen'
|
|
||||||
@lastError = @lastReceivedDoc = @lastSentDoc = null
|
|
||||||
@setState 'handshaking'
|
|
||||||
|
|
||||||
@socket.onconnecting = =>
|
|
||||||
#console.warn 'connecting'
|
|
||||||
@setState 'connecting'
|
|
||||||
|
|
||||||
setState: (state, data) ->
|
|
||||||
return if @state is state
|
|
||||||
@state = state
|
|
||||||
|
|
||||||
delete @id if state is 'disconnected'
|
|
||||||
@emit state, data
|
|
||||||
|
|
||||||
# Documents could just subscribe to the state change events, but there's less state to
|
|
||||||
# clean up when you close a document if I just notify the doucments directly.
|
|
||||||
for docName, doc of @docs
|
|
||||||
doc._connectionStateChanged state, data
|
|
||||||
|
|
||||||
send: (data) ->
|
|
||||||
docName = data.doc
|
|
||||||
|
|
||||||
if docName is @lastSentDoc
|
|
||||||
delete data.doc
|
|
||||||
else
|
|
||||||
@lastSentDoc = docName
|
|
||||||
|
|
||||||
#console.warn 'c->s', data
|
|
||||||
@socket.send data
|
|
||||||
|
|
||||||
disconnect: ->
|
|
||||||
# This will call @socket.onclose(), which in turn will emit the 'disconnected' event.
|
|
||||||
#console.warn 'calling close on the socket'
|
|
||||||
@socket.close()
|
|
||||||
|
|
||||||
# *** Doc management
|
|
||||||
|
|
||||||
makeDoc: (name, data, callback) ->
|
|
||||||
throw new Error("Doc #{name} already open") if @docs[name]
|
|
||||||
doc = new Doc(@, name, data)
|
|
||||||
@docs[name] = doc
|
|
||||||
|
|
||||||
doc.open (error) =>
|
|
||||||
delete @docs[name] if error
|
|
||||||
callback error, (doc unless error)
|
|
||||||
|
|
||||||
# Open a document that already exists
|
|
||||||
# callback(error, doc)
|
|
||||||
openExisting: (docName, callback) ->
|
|
||||||
return callback 'connection closed' if @state is 'stopped'
|
|
||||||
return callback null, @docs[docName] if @docs[docName]
|
|
||||||
doc = @makeDoc docName, {}, callback
|
|
||||||
|
|
||||||
# Open a document. It will be created if it doesn't already exist.
|
|
||||||
# Callback is passed a document or an error
|
|
||||||
# type is either a type name (eg 'text' or 'simple') or the actual type object.
|
|
||||||
# Types must be supported by the server.
|
|
||||||
# callback(error, doc)
|
|
||||||
open: (docName, type, callback) ->
|
|
||||||
return callback 'connection closed' if @state is 'stopped'
|
|
||||||
|
|
||||||
if typeof type is 'function'
|
|
||||||
callback = type
|
|
||||||
type = 'text'
|
|
||||||
|
|
||||||
callback ||= ->
|
|
||||||
|
|
||||||
type = types[type] if typeof type is 'string'
|
|
||||||
|
|
||||||
throw new Error "OT code for document type missing" unless type
|
|
||||||
|
|
||||||
throw new Error 'Server-generated random doc names are not currently supported' unless docName?
|
|
||||||
|
|
||||||
if @docs[docName]
|
|
||||||
doc = @docs[docName]
|
|
||||||
if doc.type == type
|
|
||||||
callback null, doc
|
|
||||||
else
|
|
||||||
callback 'Type mismatch', doc
|
|
||||||
return
|
|
||||||
|
|
||||||
@makeDoc docName, {create:true, type:type.name}, callback
|
|
||||||
|
|
||||||
# Not currently working.
|
|
||||||
# create: (type, callback) ->
|
|
||||||
# open null, type, callback
|
|
||||||
|
|
||||||
# Make connections event emitters.
|
|
||||||
unless WEB?
|
|
||||||
MicroEvent = require './microevent'
|
|
||||||
|
|
||||||
MicroEvent.mixin Connection
|
|
||||||
|
|
||||||
exports.Connection = Connection
|
|
|
@ -1,327 +0,0 @@
|
||||||
unless WEB?
|
|
||||||
types = require '../types'
|
|
||||||
|
|
||||||
if WEB?
|
|
||||||
exports.extendDoc = (name, fn) ->
|
|
||||||
Doc::[name] = fn
|
|
||||||
|
|
||||||
# A Doc is a client's view on a sharejs document.
|
|
||||||
#
|
|
||||||
# Documents are created by calling Connection.open().
|
|
||||||
#
|
|
||||||
# Documents are event emitters - use doc.on(eventname, fn) to subscribe.
|
|
||||||
#
|
|
||||||
# Documents get mixed in with their type's API methods. So, you can .insert('foo', 0) into
|
|
||||||
# a text document and stuff like that.
|
|
||||||
#
|
|
||||||
# Events:
|
|
||||||
# - remoteop (op)
|
|
||||||
# - changed (op)
|
|
||||||
# - acknowledge (op)
|
|
||||||
# - error
|
|
||||||
# - open, closing, closed. 'closing' is not guaranteed to fire before closed.
|
|
||||||
class Doc
|
|
||||||
# connection is a Connection object.
|
|
||||||
# name is the documents' docName.
|
|
||||||
# data can optionally contain known document data, and initial open() call arguments:
|
|
||||||
# {v[erson], snapshot={...}, type, create=true/false/undefined}
|
|
||||||
# callback will be called once the document is first opened.
|
|
||||||
constructor: (@connection, @name, openData) ->
|
|
||||||
# Any of these can be null / undefined at this stage.
|
|
||||||
openData ||= {}
|
|
||||||
@version = openData.v
|
|
||||||
@snapshot = openData.snaphot
|
|
||||||
@_setType openData.type if openData.type
|
|
||||||
|
|
||||||
@state = 'closed'
|
|
||||||
@autoOpen = false
|
|
||||||
|
|
||||||
# Has the document already been created?
|
|
||||||
@_create = openData.create
|
|
||||||
|
|
||||||
# The op that is currently roundtripping to the server, or null.
|
|
||||||
#
|
|
||||||
# When the connection reconnects, the inflight op is resubmitted.
|
|
||||||
@inflightOp = null
|
|
||||||
@inflightCallbacks = []
|
|
||||||
# The auth ids which the client has previously used to attempt to send inflightOp. This is
|
|
||||||
# usually empty.
|
|
||||||
@inflightSubmittedIds = []
|
|
||||||
|
|
||||||
# All ops that are waiting for the server to acknowledge @inflightOp
|
|
||||||
@pendingOp = null
|
|
||||||
@pendingCallbacks = []
|
|
||||||
|
|
||||||
# Some recent ops, incase submitOp is called with an old op version number.
|
|
||||||
@serverOps = {}
|
|
||||||
|
|
||||||
# Transform a server op by a client op, and vice versa.
|
|
||||||
_xf: (client, server) ->
|
|
||||||
if @type.transformX
|
|
||||||
@type.transformX(client, server)
|
|
||||||
else
|
|
||||||
client_ = @type.transform client, server, 'left'
|
|
||||||
server_ = @type.transform server, client, 'right'
|
|
||||||
return [client_, server_]
|
|
||||||
|
|
||||||
_otApply: (docOp, isRemote) ->
|
|
||||||
oldSnapshot = @snapshot
|
|
||||||
@snapshot = @type.apply(@snapshot, docOp)
|
|
||||||
|
|
||||||
# Its important that these event handlers are called with oldSnapshot.
|
|
||||||
# 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
|
|
||||||
|
|
||||||
_connectionStateChanged: (state, data) ->
|
|
||||||
switch state
|
|
||||||
when 'disconnected'
|
|
||||||
@state = 'closed'
|
|
||||||
# This is used by the server to make sure that when an op is resubmitted it
|
|
||||||
# doesn't end up getting applied twice.
|
|
||||||
@inflightSubmittedIds.push @connection.id if @inflightOp
|
|
||||||
|
|
||||||
@emit 'closed'
|
|
||||||
|
|
||||||
when 'ok' # Might be able to do this when we're connecting... that would save a roundtrip.
|
|
||||||
@open() if @autoOpen
|
|
||||||
|
|
||||||
when 'stopped'
|
|
||||||
@_openCallback? data
|
|
||||||
|
|
||||||
@emit state, data
|
|
||||||
|
|
||||||
_setType: (type) ->
|
|
||||||
if typeof type is 'string'
|
|
||||||
type = types[type]
|
|
||||||
|
|
||||||
throw new Error 'Support for types without compose() is not implemented' unless type and type.compose
|
|
||||||
|
|
||||||
@type = type
|
|
||||||
if type.api
|
|
||||||
this[k] = v for k, v of type.api
|
|
||||||
@_register?()
|
|
||||||
else
|
|
||||||
@provides = {}
|
|
||||||
|
|
||||||
_onMessage: (msg) ->
|
|
||||||
#console.warn 's->c', msg
|
|
||||||
if msg.open == true
|
|
||||||
# The document has been successfully opened.
|
|
||||||
@state = 'open'
|
|
||||||
@_create = false # Don't try and create the document again next time open() is called.
|
|
||||||
unless @created?
|
|
||||||
@created = !!msg.create
|
|
||||||
|
|
||||||
@_setType msg.type if msg.type
|
|
||||||
if msg.create
|
|
||||||
@created = true
|
|
||||||
@snapshot = @type.create()
|
|
||||||
else
|
|
||||||
@created = false unless @created is true
|
|
||||||
@snapshot = msg.snapshot if msg.snapshot isnt undefined
|
|
||||||
|
|
||||||
@version = msg.v if msg.v?
|
|
||||||
|
|
||||||
# Resend any previously queued operation.
|
|
||||||
if @inflightOp
|
|
||||||
response =
|
|
||||||
doc: @name
|
|
||||||
op: @inflightOp
|
|
||||||
v: @version
|
|
||||||
response.dupIfSource = @inflightSubmittedIds if @inflightSubmittedIds.length
|
|
||||||
@connection.send response
|
|
||||||
else
|
|
||||||
@flush()
|
|
||||||
|
|
||||||
@emit 'open'
|
|
||||||
|
|
||||||
@_openCallback? null
|
|
||||||
|
|
||||||
else if msg.open == false
|
|
||||||
# The document has either been closed, or an open request has failed.
|
|
||||||
if msg.error
|
|
||||||
# An error occurred opening the document.
|
|
||||||
console?.error "Could not open document: #{msg.error}"
|
|
||||||
@emit 'error', msg.error
|
|
||||||
@_openCallback? msg.error
|
|
||||||
|
|
||||||
@state = 'closed'
|
|
||||||
@emit 'closed'
|
|
||||||
|
|
||||||
@_closeCallback?()
|
|
||||||
@_closeCallback = null
|
|
||||||
|
|
||||||
else if msg.op is null and error is 'Op already submitted'
|
|
||||||
# We've tried to resend an op to the server, which has already been received successfully. Do nothing.
|
|
||||||
# The op will be confirmed normally when we get the op itself was echoed back from the server
|
|
||||||
# (handled below).
|
|
||||||
|
|
||||||
else if (msg.op is undefined and msg.v isnt undefined) or (msg.op and msg.meta.source in @inflightSubmittedIds)
|
|
||||||
# Our inflight op has been acknowledged.
|
|
||||||
oldInflightOp = @inflightOp
|
|
||||||
@inflightOp = null
|
|
||||||
@inflightSubmittedIds.length = 0
|
|
||||||
|
|
||||||
error = msg.error
|
|
||||||
if error
|
|
||||||
# The server has rejected an op from the client for some reason.
|
|
||||||
# We'll send the error message to the user and roll back the change.
|
|
||||||
#
|
|
||||||
# If the server isn't going to allow edits anyway, we should probably
|
|
||||||
# figure out some way to flag that (readonly:true in the open request?)
|
|
||||||
|
|
||||||
if @type.invert
|
|
||||||
undo = @type.invert oldInflightOp
|
|
||||||
|
|
||||||
# Now we have to transform the undo operation by any server ops & pending ops
|
|
||||||
if @pendingOp
|
|
||||||
[@pendingOp, undo] = @_xf @pendingOp, undo
|
|
||||||
|
|
||||||
# ... and apply it locally, reverting the changes.
|
|
||||||
#
|
|
||||||
# This call will also call @emit 'remoteop'. I'm still not 100% sure about this
|
|
||||||
# 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
|
|
||||||
else
|
|
||||||
@emit 'error', "Op apply failed (#{error}) and the op could not be reverted"
|
|
||||||
|
|
||||||
callback error for callback in @inflightCallbacks
|
|
||||||
else
|
|
||||||
# The op applied successfully.
|
|
||||||
throw new Error('Invalid version from server') unless msg.v == @version
|
|
||||||
|
|
||||||
@serverOps[@version] = oldInflightOp
|
|
||||||
@version++
|
|
||||||
@emit 'acknowledge', oldInflightOp
|
|
||||||
callback null, oldInflightOp for callback in @inflightCallbacks
|
|
||||||
|
|
||||||
# Send the next op.
|
|
||||||
@flush()
|
|
||||||
|
|
||||||
else if msg.op
|
|
||||||
# We got a new op from the server.
|
|
||||||
# msg is {doc:, op:, v:}
|
|
||||||
|
|
||||||
# There is a bug in socket.io (produced on firefox 3.6) which causes messages
|
|
||||||
# to be duplicated sometimes.
|
|
||||||
# We'll just silently drop subsequent messages.
|
|
||||||
return if msg.v < @version
|
|
||||||
|
|
||||||
return @emit 'error', "Expected docName '#{@name}' but got #{msg.doc}" unless msg.doc == @name
|
|
||||||
return @emit 'error', "Expected version #{@version} but got #{msg.v}" unless msg.v == @version
|
|
||||||
|
|
||||||
# p "if: #{i @inflightOp} pending: #{i @pendingOp} doc '#{@snapshot}' op: #{i msg.op}"
|
|
||||||
|
|
||||||
op = msg.op
|
|
||||||
@serverOps[@version] = op
|
|
||||||
|
|
||||||
docOp = op
|
|
||||||
if @inflightOp != null
|
|
||||||
[@inflightOp, docOp] = @_xf @inflightOp, docOp
|
|
||||||
if @pendingOp != null
|
|
||||||
[@pendingOp, docOp] = @_xf @pendingOp, docOp
|
|
||||||
|
|
||||||
@version++
|
|
||||||
# Finally, apply the op to @snapshot and trigger any event listeners
|
|
||||||
@_otApply docOp, true
|
|
||||||
|
|
||||||
else if msg.meta
|
|
||||||
{path, value} = msg.meta
|
|
||||||
|
|
||||||
switch path?[0]
|
|
||||||
when 'shout'
|
|
||||||
return @emit 'shout', value
|
|
||||||
else
|
|
||||||
console?.warn 'Unhandled meta op:', msg
|
|
||||||
|
|
||||||
else
|
|
||||||
console?.warn 'Unhandled document message:', msg
|
|
||||||
|
|
||||||
|
|
||||||
# Send ops to the server, if appropriate.
|
|
||||||
#
|
|
||||||
# Only one op can be in-flight at a time, so if an op is already on its way then
|
|
||||||
# this method does nothing.
|
|
||||||
flush: =>
|
|
||||||
return unless @connection.state == 'ok' and @inflightOp == null and @pendingOp != null
|
|
||||||
|
|
||||||
# Rotate null -> pending -> inflight
|
|
||||||
@inflightOp = @pendingOp
|
|
||||||
@inflightCallbacks = @pendingCallbacks
|
|
||||||
|
|
||||||
@pendingOp = null
|
|
||||||
@pendingCallbacks = []
|
|
||||||
|
|
||||||
@connection.send {doc:@name, op:@inflightOp, v:@version}
|
|
||||||
|
|
||||||
# Submit an op to the server. The op maybe held for a little while before being sent, as only one
|
|
||||||
# op can be inflight at any time.
|
|
||||||
submitOp: (op, callback) ->
|
|
||||||
op = @type.normalize(op) if @type.normalize?
|
|
||||||
|
|
||||||
# If this throws an exception, no changes should have been made to the doc
|
|
||||||
@snapshot = @type.apply @snapshot, op
|
|
||||||
|
|
||||||
if @pendingOp != null
|
|
||||||
@pendingOp = @type.compose(@pendingOp, op)
|
|
||||||
else
|
|
||||||
@pendingOp = op
|
|
||||||
|
|
||||||
@pendingCallbacks.push callback if callback
|
|
||||||
|
|
||||||
@emit 'change', op
|
|
||||||
|
|
||||||
# A timeout is used so if the user sends multiple ops at the same time, they'll be composed
|
|
||||||
# & sent together.
|
|
||||||
setTimeout @flush, 0
|
|
||||||
|
|
||||||
shout: (msg) =>
|
|
||||||
# Meta ops don't have to queue, they can go direct. Good/bad idea?
|
|
||||||
@connection.send {doc:@name, meta: { path: ['shout'], value: msg } }
|
|
||||||
|
|
||||||
# Open a document. The document starts closed.
|
|
||||||
open: (callback) ->
|
|
||||||
@autoOpen = true
|
|
||||||
return unless @state is 'closed'
|
|
||||||
|
|
||||||
message =
|
|
||||||
doc: @name
|
|
||||||
open: true
|
|
||||||
|
|
||||||
message.snapshot = null if @snapshot is undefined
|
|
||||||
message.type = @type.name if @type
|
|
||||||
message.v = @version if @version?
|
|
||||||
message.create = true if @_create
|
|
||||||
|
|
||||||
@connection.send message
|
|
||||||
|
|
||||||
@state = 'opening'
|
|
||||||
|
|
||||||
@_openCallback = (error) =>
|
|
||||||
@_openCallback = null
|
|
||||||
callback? error
|
|
||||||
|
|
||||||
# Close a document.
|
|
||||||
close: (callback) ->
|
|
||||||
@autoOpen = false
|
|
||||||
return callback?() if @state is 'closed'
|
|
||||||
|
|
||||||
@connection.send {doc:@name, open:false}
|
|
||||||
|
|
||||||
# Should this happen immediately or when we get open:false back from the server?
|
|
||||||
@state = 'closed'
|
|
||||||
|
|
||||||
@emit 'closing'
|
|
||||||
@_closeCallback = callback
|
|
||||||
|
|
||||||
# Make documents event emitters
|
|
||||||
unless WEB?
|
|
||||||
MicroEvent = require './microevent'
|
|
||||||
|
|
||||||
MicroEvent.mixin Doc
|
|
||||||
|
|
||||||
exports.Doc = Doc
|
|
|
@ -1,332 +0,0 @@
|
||||||
// Generated by CoffeeScript 1.4.0
|
|
||||||
(function() {
|
|
||||||
var Doc, MicroEvent, types,
|
|
||||||
__bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
|
|
||||||
__indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
|
|
||||||
|
|
||||||
if (typeof WEB === "undefined" || WEB === null) {
|
|
||||||
types = require('../types');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof WEB !== "undefined" && WEB !== null) {
|
|
||||||
exports.extendDoc = function(name, fn) {
|
|
||||||
return Doc.prototype[name] = fn;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Doc = (function() {
|
|
||||||
|
|
||||||
function Doc(connection, name, openData) {
|
|
||||||
this.connection = connection;
|
|
||||||
this.name = name;
|
|
||||||
this.shout = __bind(this.shout, this);
|
|
||||||
|
|
||||||
this.flush = __bind(this.flush, this);
|
|
||||||
|
|
||||||
openData || (openData = {});
|
|
||||||
this.version = openData.v;
|
|
||||||
this.snapshot = openData.snaphot;
|
|
||||||
if (openData.type) {
|
|
||||||
this._setType(openData.type);
|
|
||||||
}
|
|
||||||
this.state = 'closed';
|
|
||||||
this.autoOpen = false;
|
|
||||||
this._create = openData.create;
|
|
||||||
this.inflightOp = null;
|
|
||||||
this.inflightCallbacks = [];
|
|
||||||
this.inflightSubmittedIds = [];
|
|
||||||
this.pendingOp = null;
|
|
||||||
this.pendingCallbacks = [];
|
|
||||||
this.serverOps = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
Doc.prototype._xf = function(client, server) {
|
|
||||||
var client_, server_;
|
|
||||||
if (this.type.transformX) {
|
|
||||||
return this.type.transformX(client, server);
|
|
||||||
} else {
|
|
||||||
client_ = this.type.transform(client, server, 'left');
|
|
||||||
server_ = this.type.transform(server, client, 'right');
|
|
||||||
return [client_, server_];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype._otApply = function(docOp, isRemote) {
|
|
||||||
var oldSnapshot;
|
|
||||||
oldSnapshot = this.snapshot;
|
|
||||||
this.snapshot = this.type.apply(this.snapshot, docOp);
|
|
||||||
this.emit('change', docOp, oldSnapshot);
|
|
||||||
if (isRemote) {
|
|
||||||
return this.emit('remoteop', docOp, oldSnapshot);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype._connectionStateChanged = function(state, data) {
|
|
||||||
switch (state) {
|
|
||||||
case 'disconnected':
|
|
||||||
this.state = 'closed';
|
|
||||||
if (this.inflightOp) {
|
|
||||||
this.inflightSubmittedIds.push(this.connection.id);
|
|
||||||
}
|
|
||||||
this.emit('closed');
|
|
||||||
break;
|
|
||||||
case 'ok':
|
|
||||||
if (this.autoOpen) {
|
|
||||||
this.open();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'stopped':
|
|
||||||
if (typeof this._openCallback === "function") {
|
|
||||||
this._openCallback(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.emit(state, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype._setType = function(type) {
|
|
||||||
var k, v, _ref;
|
|
||||||
if (typeof type === 'string') {
|
|
||||||
type = types[type];
|
|
||||||
}
|
|
||||||
if (!(type && type.compose)) {
|
|
||||||
throw new Error('Support for types without compose() is not implemented');
|
|
||||||
}
|
|
||||||
this.type = type;
|
|
||||||
if (type.api) {
|
|
||||||
_ref = type.api;
|
|
||||||
for (k in _ref) {
|
|
||||||
v = _ref[k];
|
|
||||||
this[k] = v;
|
|
||||||
}
|
|
||||||
return typeof this._register === "function" ? this._register() : void 0;
|
|
||||||
} else {
|
|
||||||
return this.provides = {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype._onMessage = function(msg) {
|
|
||||||
var callback, docOp, error, oldInflightOp, op, path, response, undo, value, _i, _j, _len, _len1, _ref, _ref1, _ref2, _ref3, _ref4, _ref5, _ref6;
|
|
||||||
if (msg.open === true) {
|
|
||||||
this.state = 'open';
|
|
||||||
this._create = false;
|
|
||||||
if (this.created == null) {
|
|
||||||
this.created = !!msg.create;
|
|
||||||
}
|
|
||||||
if (msg.type) {
|
|
||||||
this._setType(msg.type);
|
|
||||||
}
|
|
||||||
if (msg.create) {
|
|
||||||
this.created = true;
|
|
||||||
this.snapshot = this.type.create();
|
|
||||||
} else {
|
|
||||||
if (this.created !== true) {
|
|
||||||
this.created = false;
|
|
||||||
}
|
|
||||||
if (msg.snapshot !== void 0) {
|
|
||||||
this.snapshot = msg.snapshot;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (msg.v != null) {
|
|
||||||
this.version = msg.v;
|
|
||||||
}
|
|
||||||
if (this.inflightOp) {
|
|
||||||
response = {
|
|
||||||
doc: this.name,
|
|
||||||
op: this.inflightOp,
|
|
||||||
v: this.version
|
|
||||||
};
|
|
||||||
if (this.inflightSubmittedIds.length) {
|
|
||||||
response.dupIfSource = this.inflightSubmittedIds;
|
|
||||||
}
|
|
||||||
this.connection.send(response);
|
|
||||||
} else {
|
|
||||||
this.flush();
|
|
||||||
}
|
|
||||||
this.emit('open');
|
|
||||||
return typeof this._openCallback === "function" ? this._openCallback(null) : void 0;
|
|
||||||
} else if (msg.open === false) {
|
|
||||||
if (msg.error) {
|
|
||||||
if (typeof console !== "undefined" && console !== null) {
|
|
||||||
console.error("Could not open document: " + msg.error);
|
|
||||||
}
|
|
||||||
this.emit('error', msg.error);
|
|
||||||
if (typeof this._openCallback === "function") {
|
|
||||||
this._openCallback(msg.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.state = 'closed';
|
|
||||||
this.emit('closed');
|
|
||||||
if (typeof this._closeCallback === "function") {
|
|
||||||
this._closeCallback();
|
|
||||||
}
|
|
||||||
return this._closeCallback = null;
|
|
||||||
} else if (msg.op === null && error === 'Op already submitted') {
|
|
||||||
|
|
||||||
} else if ((msg.op === void 0 && msg.v !== void 0) || (msg.op && (_ref = msg.meta.source, __indexOf.call(this.inflightSubmittedIds, _ref) >= 0))) {
|
|
||||||
oldInflightOp = this.inflightOp;
|
|
||||||
this.inflightOp = null;
|
|
||||||
this.inflightSubmittedIds.length = 0;
|
|
||||||
error = msg.error;
|
|
||||||
if (error) {
|
|
||||||
if (this.type.invert) {
|
|
||||||
undo = this.type.invert(oldInflightOp);
|
|
||||||
if (this.pendingOp) {
|
|
||||||
_ref1 = this._xf(this.pendingOp, undo), this.pendingOp = _ref1[0], undo = _ref1[1];
|
|
||||||
}
|
|
||||||
this._otApply(undo, true);
|
|
||||||
} else {
|
|
||||||
this.emit('error', "Op apply failed (" + error + ") and the op could not be reverted");
|
|
||||||
}
|
|
||||||
_ref2 = this.inflightCallbacks;
|
|
||||||
for (_i = 0, _len = _ref2.length; _i < _len; _i++) {
|
|
||||||
callback = _ref2[_i];
|
|
||||||
callback(error);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (msg.v !== this.version) {
|
|
||||||
throw new Error('Invalid version from server');
|
|
||||||
}
|
|
||||||
this.serverOps[this.version] = oldInflightOp;
|
|
||||||
this.version++;
|
|
||||||
this.emit('acknowledge', oldInflightOp);
|
|
||||||
_ref3 = this.inflightCallbacks;
|
|
||||||
for (_j = 0, _len1 = _ref3.length; _j < _len1; _j++) {
|
|
||||||
callback = _ref3[_j];
|
|
||||||
callback(null, oldInflightOp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.flush();
|
|
||||||
} else if (msg.op) {
|
|
||||||
if (msg.v < this.version) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.doc !== this.name) {
|
|
||||||
return this.emit('error', "Expected docName '" + this.name + "' but got " + msg.doc);
|
|
||||||
}
|
|
||||||
if (msg.v !== this.version) {
|
|
||||||
return this.emit('error', "Expected version " + this.version + " but got " + msg.v);
|
|
||||||
}
|
|
||||||
op = msg.op;
|
|
||||||
this.serverOps[this.version] = op;
|
|
||||||
docOp = op;
|
|
||||||
if (this.inflightOp !== null) {
|
|
||||||
_ref4 = this._xf(this.inflightOp, docOp), this.inflightOp = _ref4[0], docOp = _ref4[1];
|
|
||||||
}
|
|
||||||
if (this.pendingOp !== null) {
|
|
||||||
_ref5 = this._xf(this.pendingOp, docOp), this.pendingOp = _ref5[0], docOp = _ref5[1];
|
|
||||||
}
|
|
||||||
this.version++;
|
|
||||||
return this._otApply(docOp, true);
|
|
||||||
} else if (msg.meta) {
|
|
||||||
_ref6 = msg.meta, path = _ref6.path, value = _ref6.value;
|
|
||||||
switch (path != null ? path[0] : void 0) {
|
|
||||||
case 'shout':
|
|
||||||
return this.emit('shout', value);
|
|
||||||
default:
|
|
||||||
return typeof console !== "undefined" && console !== null ? console.warn('Unhandled meta op:', msg) : void 0;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return typeof console !== "undefined" && console !== null ? console.warn('Unhandled document message:', msg) : void 0;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype.flush = function() {
|
|
||||||
if (!(this.connection.state === 'ok' && this.inflightOp === null && this.pendingOp !== null)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.inflightOp = this.pendingOp;
|
|
||||||
this.inflightCallbacks = this.pendingCallbacks;
|
|
||||||
this.pendingOp = null;
|
|
||||||
this.pendingCallbacks = [];
|
|
||||||
return this.connection.send({
|
|
||||||
doc: this.name,
|
|
||||||
op: this.inflightOp,
|
|
||||||
v: this.version
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype.submitOp = function(op, callback) {
|
|
||||||
if (this.type.normalize != null) {
|
|
||||||
op = this.type.normalize(op);
|
|
||||||
}
|
|
||||||
this.snapshot = this.type.apply(this.snapshot, op);
|
|
||||||
if (this.pendingOp !== null) {
|
|
||||||
this.pendingOp = this.type.compose(this.pendingOp, op);
|
|
||||||
} else {
|
|
||||||
this.pendingOp = op;
|
|
||||||
}
|
|
||||||
if (callback) {
|
|
||||||
this.pendingCallbacks.push(callback);
|
|
||||||
}
|
|
||||||
this.emit('change', op);
|
|
||||||
return setTimeout(this.flush, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype.shout = function(msg) {
|
|
||||||
return this.connection.send({
|
|
||||||
doc: this.name,
|
|
||||||
meta: {
|
|
||||||
path: ['shout'],
|
|
||||||
value: msg
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype.open = function(callback) {
|
|
||||||
var message,
|
|
||||||
_this = this;
|
|
||||||
this.autoOpen = true;
|
|
||||||
if (this.state !== 'closed') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
message = {
|
|
||||||
doc: this.name,
|
|
||||||
open: true
|
|
||||||
};
|
|
||||||
if (this.snapshot === void 0) {
|
|
||||||
message.snapshot = null;
|
|
||||||
}
|
|
||||||
if (this.type) {
|
|
||||||
message.type = this.type.name;
|
|
||||||
}
|
|
||||||
if (this.version != null) {
|
|
||||||
message.v = this.version;
|
|
||||||
}
|
|
||||||
if (this._create) {
|
|
||||||
message.create = true;
|
|
||||||
}
|
|
||||||
this.connection.send(message);
|
|
||||||
this.state = 'opening';
|
|
||||||
return this._openCallback = function(error) {
|
|
||||||
_this._openCallback = null;
|
|
||||||
return typeof callback === "function" ? callback(error) : void 0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Doc.prototype.close = function(callback) {
|
|
||||||
this.autoOpen = false;
|
|
||||||
if (this.state === 'closed') {
|
|
||||||
return typeof callback === "function" ? callback() : void 0;
|
|
||||||
}
|
|
||||||
this.connection.send({
|
|
||||||
doc: this.name,
|
|
||||||
open: false
|
|
||||||
});
|
|
||||||
this.state = 'closed';
|
|
||||||
this.emit('closing');
|
|
||||||
return this._closeCallback = callback;
|
|
||||||
};
|
|
||||||
|
|
||||||
return Doc;
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (typeof WEB === "undefined" || WEB === null) {
|
|
||||||
MicroEvent = require('./microevent');
|
|
||||||
}
|
|
||||||
|
|
||||||
MicroEvent.mixin(Doc);
|
|
||||||
|
|
||||||
exports.Doc = Doc;
|
|
||||||
|
|
||||||
}).call(this);
|
|
|
@ -1,73 +0,0 @@
|
||||||
# This file implements the sharejs client, as defined here:
|
|
||||||
# https://github.com/josephg/ShareJS/wiki/Client-API
|
|
||||||
#
|
|
||||||
# It works from both a node.js context and a web context (though in the latter case,
|
|
||||||
# it needs to be compiled to work.)
|
|
||||||
#
|
|
||||||
# It should become a little nicer once I start using more of the new RPC features added
|
|
||||||
# in socket.io 0.7.
|
|
||||||
#
|
|
||||||
# Note that anything declared in the global scope here is shared with other files
|
|
||||||
# built by closure. Be careful what you put in this namespace.
|
|
||||||
|
|
||||||
unless WEB?
|
|
||||||
Connection = require('./connection').Connection
|
|
||||||
|
|
||||||
# Open a document with the given name. The connection is created implicitly and reused.
|
|
||||||
#
|
|
||||||
# This function uses a local (private) set of connections to support .open().
|
|
||||||
#
|
|
||||||
# Open returns the connection its using to access the document.
|
|
||||||
exports.open = do ->
|
|
||||||
# This is a private connection pool for implicitly created connections.
|
|
||||||
connections = {}
|
|
||||||
|
|
||||||
getConnection = (origin) ->
|
|
||||||
if WEB?
|
|
||||||
location = window.location
|
|
||||||
origin ?= "#{location.protocol}//#{location.host}/channel"
|
|
||||||
|
|
||||||
unless connections[origin]
|
|
||||||
c = new Connection origin
|
|
||||||
|
|
||||||
del = -> delete connections[origin]
|
|
||||||
c.on 'disconnecting', del
|
|
||||||
c.on 'connect failed', del
|
|
||||||
connections[origin] = c
|
|
||||||
|
|
||||||
connections[origin]
|
|
||||||
|
|
||||||
# If you're using the bare API, connections are cleaned up as soon as there's no
|
|
||||||
# documents using them.
|
|
||||||
maybeClose = (c) ->
|
|
||||||
numDocs = 0
|
|
||||||
for name, doc of c.docs
|
|
||||||
numDocs++ if doc.state isnt 'closed' || doc.autoOpen
|
|
||||||
|
|
||||||
if numDocs == 0
|
|
||||||
c.disconnect()
|
|
||||||
|
|
||||||
(docName, type, origin, callback) ->
|
|
||||||
if typeof origin == 'function'
|
|
||||||
callback = origin
|
|
||||||
origin = null
|
|
||||||
|
|
||||||
c = getConnection origin
|
|
||||||
c.numDocs++
|
|
||||||
c.open docName, type, (error, doc) ->
|
|
||||||
if error
|
|
||||||
callback error
|
|
||||||
maybeClose c
|
|
||||||
else
|
|
||||||
doc.on 'closed', -> maybeClose c
|
|
||||||
|
|
||||||
callback null, doc
|
|
||||||
|
|
||||||
c.on 'connect failed'
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
unless WEB?
|
|
||||||
exports.Doc = require('./doc').Doc
|
|
||||||
exports.Connection = require('./connection').Connection
|
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
# This is a simple port of microevent.js to Coffeescript. I've changed the
|
|
||||||
# function names to be consistent with node.js EventEmitter.
|
|
||||||
#
|
|
||||||
# microevent.js is copyright Jerome Etienne, and licensed under the MIT license:
|
|
||||||
# https://github.com/jeromeetienne/microevent.js
|
|
||||||
|
|
||||||
nextTick = if WEB? then (fn) -> setTimeout fn, 0 else process['nextTick']
|
|
||||||
|
|
||||||
class MicroEvent
|
|
||||||
on: (event, fct) ->
|
|
||||||
@_events ||= {}
|
|
||||||
@_events[event] ||= []
|
|
||||||
@_events[event].push(fct)
|
|
||||||
this
|
|
||||||
|
|
||||||
removeListener: (event, fct) ->
|
|
||||||
@_events ||= {}
|
|
||||||
listeners = (@_events[event] ||= [])
|
|
||||||
|
|
||||||
# Sadly, there's no IE8- support for indexOf.
|
|
||||||
i = 0
|
|
||||||
while i < listeners.length
|
|
||||||
listeners[i] = undefined if listeners[i] == fct
|
|
||||||
i++
|
|
||||||
|
|
||||||
nextTick => @_events[event] = (x for x in @_events[event] when x)
|
|
||||||
|
|
||||||
this
|
|
||||||
|
|
||||||
emit: (event, args...) ->
|
|
||||||
return this unless @_events?[event]
|
|
||||||
fn.apply this, args for fn in @_events[event] when fn
|
|
||||||
this
|
|
||||||
|
|
||||||
# mixin will delegate all MicroEvent.js function in the destination object
|
|
||||||
MicroEvent.mixin = (obj) ->
|
|
||||||
proto = obj.prototype || obj
|
|
||||||
|
|
||||||
# Damn closure compiler :/
|
|
||||||
proto.on = MicroEvent.prototype.on
|
|
||||||
proto.removeListener = MicroEvent.prototype.removeListener
|
|
||||||
proto.emit = MicroEvent.prototype.emit
|
|
||||||
obj
|
|
||||||
|
|
||||||
module.exports = MicroEvent unless WEB?
|
|
||||||
|
|
|
@ -1,85 +0,0 @@
|
||||||
// Generated by CoffeeScript 1.4.0
|
|
||||||
(function() {
|
|
||||||
var MicroEvent, nextTick,
|
|
||||||
__slice = [].slice;
|
|
||||||
|
|
||||||
nextTick = typeof WEB !== "undefined" && WEB !== null ? function(fn) {
|
|
||||||
return setTimeout(fn, 0);
|
|
||||||
} : process['nextTick'];
|
|
||||||
|
|
||||||
MicroEvent = (function() {
|
|
||||||
|
|
||||||
function MicroEvent() {}
|
|
||||||
|
|
||||||
MicroEvent.prototype.on = function(event, fct) {
|
|
||||||
var _base;
|
|
||||||
this._events || (this._events = {});
|
|
||||||
(_base = this._events)[event] || (_base[event] = []);
|
|
||||||
this._events[event].push(fct);
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
MicroEvent.prototype.removeListener = function(event, fct) {
|
|
||||||
var i, listeners, _base,
|
|
||||||
_this = this;
|
|
||||||
this._events || (this._events = {});
|
|
||||||
listeners = ((_base = this._events)[event] || (_base[event] = []));
|
|
||||||
i = 0;
|
|
||||||
while (i < listeners.length) {
|
|
||||||
if (listeners[i] === fct) {
|
|
||||||
listeners[i] = void 0;
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
nextTick(function() {
|
|
||||||
var x;
|
|
||||||
return _this._events[event] = (function() {
|
|
||||||
var _i, _len, _ref, _results;
|
|
||||||
_ref = this._events[event];
|
|
||||||
_results = [];
|
|
||||||
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
|
|
||||||
x = _ref[_i];
|
|
||||||
if (x) {
|
|
||||||
_results.push(x);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _results;
|
|
||||||
}).call(_this);
|
|
||||||
});
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
MicroEvent.prototype.emit = function() {
|
|
||||||
var args, event, fn, _i, _len, _ref, _ref1;
|
|
||||||
event = arguments[0], args = 2 <= arguments.length ? __slice.call(arguments, 1) : [];
|
|
||||||
if (!((_ref = this._events) != null ? _ref[event] : void 0)) {
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
_ref1 = this._events[event];
|
|
||||||
for (_i = 0, _len = _ref1.length; _i < _len; _i++) {
|
|
||||||
fn = _ref1[_i];
|
|
||||||
if (fn) {
|
|
||||||
fn.apply(this, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
};
|
|
||||||
|
|
||||||
return MicroEvent;
|
|
||||||
|
|
||||||
})();
|
|
||||||
|
|
||||||
MicroEvent.mixin = function(obj) {
|
|
||||||
var proto;
|
|
||||||
proto = obj.prototype || obj;
|
|
||||||
proto.on = MicroEvent.prototype.on;
|
|
||||||
proto.removeListener = MicroEvent.prototype.removeListener;
|
|
||||||
proto.emit = MicroEvent.prototype.emit;
|
|
||||||
return obj;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof WEB === "undefined" || WEB === null) {
|
|
||||||
module.exports = MicroEvent;
|
|
||||||
}
|
|
||||||
|
|
||||||
}).call(this);
|
|
|
@ -1,69 +0,0 @@
|
||||||
# Create an op which converts oldval -> newval.
|
|
||||||
#
|
|
||||||
# This function should be called every time the text element is changed. Because changes are
|
|
||||||
# always localised, the diffing is quite easy.
|
|
||||||
#
|
|
||||||
# This algorithm is O(N), but I suspect you could speed it up somehow using regular expressions.
|
|
||||||
applyChange = (doc, oldval, newval) ->
|
|
||||||
return if oldval == newval
|
|
||||||
commonStart = 0
|
|
||||||
commonStart++ while oldval.charAt(commonStart) == newval.charAt(commonStart)
|
|
||||||
|
|
||||||
commonEnd = 0
|
|
||||||
commonEnd++ while oldval.charAt(oldval.length - 1 - commonEnd) == newval.charAt(newval.length - 1 - commonEnd) and
|
|
||||||
commonEnd + commonStart < oldval.length and commonEnd + commonStart < newval.length
|
|
||||||
|
|
||||||
doc.del commonStart, oldval.length - commonStart - commonEnd unless oldval.length == commonStart + commonEnd
|
|
||||||
doc.insert commonStart, newval[commonStart ... newval.length - commonEnd] unless newval.length == commonStart + commonEnd
|
|
||||||
|
|
||||||
window.sharejs.extendDoc 'attach_textarea', (elem) ->
|
|
||||||
doc = this
|
|
||||||
elem.value = @getText()
|
|
||||||
prevvalue = elem.value
|
|
||||||
|
|
||||||
replaceText = (newText, transformCursor) ->
|
|
||||||
newSelection = [
|
|
||||||
transformCursor elem.selectionStart
|
|
||||||
transformCursor elem.selectionEnd
|
|
||||||
]
|
|
||||||
|
|
||||||
scrollTop = elem.scrollTop
|
|
||||||
elem.value = newText
|
|
||||||
elem.scrollTop = scrollTop if elem.scrollTop != scrollTop
|
|
||||||
[elem.selectionStart, elem.selectionEnd] = newSelection
|
|
||||||
|
|
||||||
@on 'insert', (pos, text) ->
|
|
||||||
transformCursor = (cursor) ->
|
|
||||||
if pos < cursor
|
|
||||||
cursor + text.length
|
|
||||||
else
|
|
||||||
cursor
|
|
||||||
#for IE8 and Opera that replace \n with \r\n.
|
|
||||||
prevvalue = elem.value.replace /\r\n/g, '\n'
|
|
||||||
replaceText prevvalue[...pos] + text + prevvalue[pos..], transformCursor
|
|
||||||
|
|
||||||
@on 'delete', (pos, text) ->
|
|
||||||
transformCursor = (cursor) ->
|
|
||||||
if pos < cursor
|
|
||||||
cursor - Math.min(text.length, cursor - pos)
|
|
||||||
else
|
|
||||||
cursor
|
|
||||||
#for IE8 and Opera that replace \n with \r\n.
|
|
||||||
prevvalue = elem.value.replace /\r\n/g, '\n'
|
|
||||||
replaceText prevvalue[...pos] + prevvalue[pos + text.length..], transformCursor
|
|
||||||
|
|
||||||
genOp = (event) ->
|
|
||||||
onNextTick = (fn) -> setTimeout fn, 0
|
|
||||||
onNextTick ->
|
|
||||||
if elem.value != prevvalue
|
|
||||||
# IE constantly replaces unix newlines with \r\n. ShareJS docs
|
|
||||||
# should only have unix newlines.
|
|
||||||
prevvalue = elem.value
|
|
||||||
applyChange doc, doc.getText(), elem.value.replace /\r\n/g, '\n'
|
|
||||||
|
|
||||||
for event in ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste']
|
|
||||||
if elem.addEventListener
|
|
||||||
elem.addEventListener event, genOp, false
|
|
||||||
else
|
|
||||||
elem.attachEvent 'on'+event, genOp
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
# This file is included at the top of the compiled client-side javascript
|
|
||||||
|
|
||||||
# This way all the modules can add stuff to exports, and for the web client they'll all get exported.
|
|
||||||
window.sharejs = exports =
|
|
||||||
'version': '0.5.0'
|
|
||||||
|
|
||||||
# This is compiled out when compiled with uglifyjs, but its important for the share.uncompressed.js.
|
|
||||||
#
|
|
||||||
# Maybe I should rename WEB to __SHAREJS_WEB or something, but its only relevant for testing
|
|
||||||
# anyway.
|
|
||||||
if typeof WEB == 'undefined'
|
|
||||||
# This will put WEB in the global scope in a browser.
|
|
||||||
window.WEB = true
|
|
|
@ -1,5 +0,0 @@
|
||||||
exports.server = require './server'
|
|
||||||
exports.client = require './client'
|
|
||||||
exports.types = require './types'
|
|
||||||
|
|
||||||
exports.version = '0.5.0'
|
|
|
@ -1,333 +0,0 @@
|
||||||
# This implements the network API for ShareJS.
|
|
||||||
#
|
|
||||||
# The wire protocol is speccced out here:
|
|
||||||
# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
|
|
||||||
#
|
|
||||||
# When a client connects the server first authenticates it and sends:
|
|
||||||
#
|
|
||||||
# S: {auth:<agent session id>}
|
|
||||||
# or
|
|
||||||
# S: {auth:null, error:'forbidden'}
|
|
||||||
#
|
|
||||||
# After that, the client can open documents:
|
|
||||||
#
|
|
||||||
# C: {doc:'foo', open:true, snapshot:null, create:true, type:'text'}
|
|
||||||
# S: {doc:'foo', open:true, snapshot:{snapshot:'hi there', v:5, meta:{}}, create:false}
|
|
||||||
#
|
|
||||||
# ...
|
|
||||||
#
|
|
||||||
# The client can send open requests as soon as the socket has opened - it doesn't need to
|
|
||||||
# wait for auth.
|
|
||||||
#
|
|
||||||
# The wire protocol is documented here:
|
|
||||||
# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
|
|
||||||
|
|
||||||
browserChannel = require('browserchannel').server
|
|
||||||
util = require 'util'
|
|
||||||
hat = require 'hat'
|
|
||||||
|
|
||||||
syncQueue = require './syncqueue'
|
|
||||||
|
|
||||||
# Attach the streaming protocol to the supplied http.Server.
|
|
||||||
#
|
|
||||||
# Options = {}
|
|
||||||
module.exports = (createAgent, options) ->
|
|
||||||
options or= {}
|
|
||||||
|
|
||||||
browserChannel options, (session) ->
|
|
||||||
#console.log "New BC session from #{session.address} with id #{session.id}"
|
|
||||||
data =
|
|
||||||
headers: session.headers
|
|
||||||
remoteAddress: session.address
|
|
||||||
|
|
||||||
# This is the user agent through which a connecting client acts. It is set when the
|
|
||||||
# session is authenticated. The agent is responsible for making sure client requests are
|
|
||||||
# properly authorized, and metadata is kept up to date.
|
|
||||||
agent = null
|
|
||||||
|
|
||||||
# To save on network traffic, the agent & server can leave out the docName with each message to mean
|
|
||||||
# 'same as the last message'
|
|
||||||
lastSentDoc = null
|
|
||||||
lastReceivedDoc = null
|
|
||||||
|
|
||||||
# Map from docName -> {queue, listener if open}
|
|
||||||
docState = {}
|
|
||||||
|
|
||||||
# We'll only handle one message from each client at a time.
|
|
||||||
handleMessage = (query) ->
|
|
||||||
#console.log "Message from #{session.id}", query
|
|
||||||
|
|
||||||
error = null
|
|
||||||
error = 'Invalid docName' unless query.doc is null or typeof query.doc is 'string' or (query.doc is undefined and lastReceivedDoc)
|
|
||||||
error = "'create' must be true or missing" unless query.create in [true, undefined]
|
|
||||||
error = "'open' must be true, false or missing" unless query.open in [true, false, undefined]
|
|
||||||
error = "'snapshot' must be null or missing" unless query.snapshot in [null, undefined]
|
|
||||||
error = "'type' invalid" unless query.type is undefined or typeof query.type is 'string'
|
|
||||||
error = "'v' invalid" unless query.v is undefined or (typeof query.v is 'number' and query.v >= 0)
|
|
||||||
|
|
||||||
if error
|
|
||||||
console.warn "Invalid query #{JSON.stringify query} from #{agent.sessionId}: #{error}"
|
|
||||||
session.abort()
|
|
||||||
return callback()
|
|
||||||
|
|
||||||
# The agent can specify null as the docName to get a random doc name.
|
|
||||||
if query.doc is null
|
|
||||||
query.doc = lastReceivedDoc = hat()
|
|
||||||
else if query.doc != undefined
|
|
||||||
lastReceivedDoc = query.doc
|
|
||||||
else
|
|
||||||
unless lastReceivedDoc
|
|
||||||
console.warn "msg.doc missing in query #{JSON.stringify query} from #{agent.sessionId}"
|
|
||||||
# The disconnect handler will be called when we do this, which will clean up the open docs.
|
|
||||||
return session.abort()
|
|
||||||
|
|
||||||
query.doc = lastReceivedDoc
|
|
||||||
|
|
||||||
docState[query.doc] or= queue: syncQueue (query, callback) ->
|
|
||||||
# When the session is closed, we'll nuke docState. When that happens, no more messages
|
|
||||||
# should be handled.
|
|
||||||
return callback() unless docState
|
|
||||||
|
|
||||||
# Close messages are {open:false}
|
|
||||||
if query.open == false
|
|
||||||
handleClose query, callback
|
|
||||||
|
|
||||||
# Open messages are {open:true}. There's a lot of shared logic with getting snapshots
|
|
||||||
# and creating documents. These operations can be done together; and I'll handle them
|
|
||||||
# together.
|
|
||||||
else if query.open or query.snapshot is null or query.create
|
|
||||||
# You can open, request a snapshot and create all in the same
|
|
||||||
# request. They're all handled together.
|
|
||||||
handleOpenCreateSnapshot query, callback
|
|
||||||
|
|
||||||
# The socket is submitting an op.
|
|
||||||
else if query.op? or query.meta?.path?
|
|
||||||
handleOp query, callback
|
|
||||||
|
|
||||||
else
|
|
||||||
console.warn "Invalid query #{JSON.stringify query} from #{agent.sessionId}"
|
|
||||||
session.abort()
|
|
||||||
callback()
|
|
||||||
|
|
||||||
# ... And add the message to the queue.
|
|
||||||
docState[query.doc].queue query
|
|
||||||
|
|
||||||
|
|
||||||
# # Some utility methods for message handlers
|
|
||||||
|
|
||||||
# Send a message to the socket.
|
|
||||||
# msg _must_ have the doc:DOCNAME property set. We'll remove it if its the same as lastReceivedDoc.
|
|
||||||
send = (response) ->
|
|
||||||
if response.doc is lastSentDoc
|
|
||||||
delete response.doc
|
|
||||||
else
|
|
||||||
lastSentDoc = response.doc
|
|
||||||
|
|
||||||
# Its invalid to send a message to a closed session. We'll silently drop messages if the
|
|
||||||
# session has closed.
|
|
||||||
if session.state isnt 'closed'
|
|
||||||
#console.log "Sending", response
|
|
||||||
session.send response
|
|
||||||
|
|
||||||
# Open the given document name, at the requested version.
|
|
||||||
# callback(error, version)
|
|
||||||
open = (docName, version, callback) ->
|
|
||||||
return callback 'Session closed' unless docState
|
|
||||||
return callback 'Document already open' if docState[docName].listener
|
|
||||||
#p "Registering listener on #{docName} by #{socket.id} at #{version}"
|
|
||||||
|
|
||||||
docState[docName].listener = listener = (opData) ->
|
|
||||||
throw new Error 'Consistency violation - doc listener invalid' unless docState[docName].listener == listener
|
|
||||||
|
|
||||||
#p "listener doc:#{docName} opdata:#{i opData} v:#{version}"
|
|
||||||
|
|
||||||
# Skip the op if this socket sent it.
|
|
||||||
return if opData.meta.source is agent.sessionId
|
|
||||||
|
|
||||||
opMsg =
|
|
||||||
doc: docName
|
|
||||||
op: opData.op
|
|
||||||
v: opData.v
|
|
||||||
meta: opData.meta
|
|
||||||
|
|
||||||
send opMsg
|
|
||||||
|
|
||||||
# Tell the socket the doc is open at the requested version
|
|
||||||
agent.listen docName, version, listener, (error, v) ->
|
|
||||||
delete docState[docName].listener if error
|
|
||||||
callback error, v
|
|
||||||
|
|
||||||
# Close the named document.
|
|
||||||
# callback([error])
|
|
||||||
close = (docName, callback) ->
|
|
||||||
#p "Closing #{docName}"
|
|
||||||
return callback 'Session closed' unless docState
|
|
||||||
listener = docState[docName].listener
|
|
||||||
return callback 'Doc already closed' unless listener?
|
|
||||||
|
|
||||||
agent.removeListener docName
|
|
||||||
delete docState[docName].listener
|
|
||||||
callback()
|
|
||||||
|
|
||||||
# Handles messages with any combination of the open:true, create:true and snapshot:null parameters
|
|
||||||
handleOpenCreateSnapshot = (query, finished) ->
|
|
||||||
docName = query.doc
|
|
||||||
msg = doc:docName
|
|
||||||
|
|
||||||
callback = (error) ->
|
|
||||||
if error
|
|
||||||
close(docName) if msg.open == true
|
|
||||||
msg.open = false if query.open == true
|
|
||||||
msg.snapshot = null if query.snapshot != undefined
|
|
||||||
delete msg.create
|
|
||||||
|
|
||||||
msg.error = error
|
|
||||||
|
|
||||||
send msg
|
|
||||||
finished()
|
|
||||||
|
|
||||||
return callback 'No docName specified' unless query.doc?
|
|
||||||
|
|
||||||
if query.create == true
|
|
||||||
if typeof query.type != 'string'
|
|
||||||
return callback 'create:true requires type specified'
|
|
||||||
|
|
||||||
if query.meta != undefined
|
|
||||||
unless typeof query.meta == 'object' and Array.isArray(query.meta) == false
|
|
||||||
return callback 'meta must be an object'
|
|
||||||
|
|
||||||
docData = undefined
|
|
||||||
|
|
||||||
# This is implemented with a series of cascading methods for each different type of
|
|
||||||
# thing this method can handle. This would be so much nicer with an async library. Welcome to
|
|
||||||
# callback hell.
|
|
||||||
|
|
||||||
step1Create = ->
|
|
||||||
return step2Snapshot() if query.create != true
|
|
||||||
|
|
||||||
# The document obviously already exists if we have a snapshot.
|
|
||||||
if docData
|
|
||||||
msg.create = false
|
|
||||||
step2Snapshot()
|
|
||||||
else
|
|
||||||
agent.create docName, query.type, query.meta || {}, (error) ->
|
|
||||||
if error is 'Document already exists'
|
|
||||||
# We've called getSnapshot (-> null), then create (-> already exists). Its possible
|
|
||||||
# another agent has called create() between our getSnapshot and create() calls.
|
|
||||||
agent.getSnapshot docName, (error, data) ->
|
|
||||||
return callback error if error
|
|
||||||
|
|
||||||
docData = data
|
|
||||||
msg.create = false
|
|
||||||
step2Snapshot()
|
|
||||||
else if error
|
|
||||||
callback error
|
|
||||||
else
|
|
||||||
msg.create = true
|
|
||||||
step2Snapshot()
|
|
||||||
|
|
||||||
# The socket requested a document snapshot
|
|
||||||
step2Snapshot = ->
|
|
||||||
# if query.create or query.open or query.snapshot == null
|
|
||||||
# msg.meta = docData.meta
|
|
||||||
|
|
||||||
# Skip inserting a snapshot if the document was just created.
|
|
||||||
if query.snapshot != null or msg.create == true
|
|
||||||
step3Open()
|
|
||||||
return
|
|
||||||
|
|
||||||
if docData
|
|
||||||
msg.v = docData.v
|
|
||||||
msg.type = docData.type.name unless query.type == docData.type.name
|
|
||||||
msg.snapshot = docData.snapshot
|
|
||||||
else
|
|
||||||
return callback 'Document does not exist'
|
|
||||||
|
|
||||||
step3Open()
|
|
||||||
|
|
||||||
# Attempt to open a document with a given name. Version is optional.
|
|
||||||
# callback(opened at version) or callback(null, errormessage)
|
|
||||||
step3Open = ->
|
|
||||||
return callback() if query.open != true
|
|
||||||
|
|
||||||
# Verify the type matches
|
|
||||||
return callback 'Type mismatch' if query.type and docData and query.type != docData.type.name
|
|
||||||
|
|
||||||
open docName, query.v, (error, version) ->
|
|
||||||
return callback error if error
|
|
||||||
|
|
||||||
# + Should fail if the type is wrong.
|
|
||||||
|
|
||||||
#p "Opened #{docName} at #{version} by #{socket.id}"
|
|
||||||
msg.open = true
|
|
||||||
msg.v = version
|
|
||||||
callback()
|
|
||||||
|
|
||||||
# Technically, we don't need a snapshot if the user called create but not open or createSnapshot,
|
|
||||||
# but no clients do that yet anyway.
|
|
||||||
if query.snapshot == null or query.open == true #and query.type
|
|
||||||
agent.getSnapshot query.doc, (error, data) ->
|
|
||||||
return callback error if error and error != 'Document does not exist'
|
|
||||||
|
|
||||||
docData = data
|
|
||||||
step1Create()
|
|
||||||
else
|
|
||||||
step1Create()
|
|
||||||
|
|
||||||
# The socket closes a document
|
|
||||||
handleClose = (query, callback) ->
|
|
||||||
close query.doc, (error) ->
|
|
||||||
if error
|
|
||||||
# An error closing still results in the doc being closed.
|
|
||||||
send {doc:query.doc, open:false, error:error}
|
|
||||||
else
|
|
||||||
send {doc:query.doc, open:false}
|
|
||||||
|
|
||||||
callback()
|
|
||||||
|
|
||||||
# We received an op from the socket
|
|
||||||
handleOp = (query, callback) ->
|
|
||||||
# ...
|
|
||||||
#throw new Error 'No version specified' unless query.v?
|
|
||||||
|
|
||||||
opData = {v:query.v, op:query.op, meta:query.meta, dupIfSource:query.dupIfSource}
|
|
||||||
|
|
||||||
# If it's a metaOp don't send a response
|
|
||||||
agent.submitOp query.doc, opData, if (not opData.op? and opData.meta?.path?) then callback else (error, appliedVersion) ->
|
|
||||||
msg = if error
|
|
||||||
#p "Sending error to socket: #{error}"
|
|
||||||
{doc:query.doc, v:null, error:error}
|
|
||||||
else
|
|
||||||
{doc:query.doc, v:appliedVersion}
|
|
||||||
|
|
||||||
send msg
|
|
||||||
callback()
|
|
||||||
|
|
||||||
# We don't process any messages from the agent until they've authorized. Instead,
|
|
||||||
# they are stored in this buffer.
|
|
||||||
buffer = []
|
|
||||||
session.on 'message', bufferMsg = (msg) -> buffer.push msg
|
|
||||||
|
|
||||||
createAgent data, (error, agent_) ->
|
|
||||||
if error
|
|
||||||
# The client is not authorized, so they shouldn't try and reconnect.
|
|
||||||
session.send {auth:null, error}
|
|
||||||
session.stop()
|
|
||||||
else
|
|
||||||
agent = agent_
|
|
||||||
session.send auth:agent.sessionId
|
|
||||||
|
|
||||||
# Ok. Now we can handle all the messages in the buffer. They'll go straight to
|
|
||||||
# handleMessage from now on.
|
|
||||||
session.removeListener 'message', bufferMsg
|
|
||||||
handleMessage msg for msg in buffer
|
|
||||||
buffer = null
|
|
||||||
session.on 'message', handleMessage
|
|
||||||
|
|
||||||
session.on 'close', ->
|
|
||||||
return unless agent
|
|
||||||
#console.log "Client #{agent.sessionId} disconnected"
|
|
||||||
for docName, {listener} of docState
|
|
||||||
agent.removeListener docName if listener
|
|
||||||
docState = null
|
|
||||||
|
|
|
@ -1,149 +0,0 @@
|
||||||
# OT storage for CouchDB
|
|
||||||
# Author: Max Ogden (@maxogden)
|
|
||||||
#
|
|
||||||
# The couchdb database contains two kinds of documents:
|
|
||||||
#
|
|
||||||
# - Document snapshots have a key which is doc:the document name
|
|
||||||
# - Document ops have a random key, but docName: defined.
|
|
||||||
|
|
||||||
request = require('request').defaults json: true
|
|
||||||
|
|
||||||
# Helper method to parse errors out of couchdb. There's way more ways
|
|
||||||
# things can go wrong, but I think this catches all the ones I care about.
|
|
||||||
#
|
|
||||||
# callback(error) or callback()
|
|
||||||
parseError = (err, resp, body, callback) ->
|
|
||||||
body = body[0] if Array.isArray body and body.length >= 1
|
|
||||||
|
|
||||||
if err
|
|
||||||
# This indicates an HTTP error
|
|
||||||
callback err
|
|
||||||
else if resp.statusCode is 404
|
|
||||||
callback 'Document does not exist'
|
|
||||||
else if resp.statusCode is 403
|
|
||||||
callback 'forbidden'
|
|
||||||
else if typeof body is 'object'
|
|
||||||
if body.error is 'conflict'
|
|
||||||
callback 'Document already exists'
|
|
||||||
else if body.error
|
|
||||||
callback "#{body.error} reason: #{body.reason}"
|
|
||||||
else
|
|
||||||
callback()
|
|
||||||
else
|
|
||||||
callback()
|
|
||||||
|
|
||||||
module.exports = (options) ->
|
|
||||||
options ?= {}
|
|
||||||
db = options.uri or "http://localhost:5984/sharejs"
|
|
||||||
|
|
||||||
uriForDoc = (docName) -> "#{db}/doc:#{encodeURIComponent docName}"
|
|
||||||
uriForOps = (docName, start, end, include_docs) ->
|
|
||||||
startkey = encodeURIComponent(JSON.stringify [docName, start])
|
|
||||||
# {} is sorted after all numbers - so this will get all ops in the case that end is null.
|
|
||||||
endkey = encodeURIComponent(JSON.stringify [docName, end ? {}])
|
|
||||||
|
|
||||||
# Another way to write this method would be to use node's builtin uri-encoder.
|
|
||||||
extra = if include_docs then '&include_docs=true' else ''
|
|
||||||
"#{db}/_design/sharejs/_view/operations?startkey=#{startkey}&endkey=#{endkey}&inclusive_end=false#{extra}"
|
|
||||||
|
|
||||||
# Helper method to get the revision of a document snapshot.
|
|
||||||
getRev = (docName, dbMeta, callback) ->
|
|
||||||
if dbMeta?.rev
|
|
||||||
callback null, dbMeta.rev
|
|
||||||
else
|
|
||||||
# JSON defaults to true, and that makes request think I'm trying to sneak a request
|
|
||||||
# body in. Ugh.
|
|
||||||
request.head {uri:uriForDoc(docName), json:false}, (err, resp, body) ->
|
|
||||||
parseError err, resp, body, (error) ->
|
|
||||||
if error
|
|
||||||
callback error
|
|
||||||
else
|
|
||||||
# The etag is the rev in quotes.
|
|
||||||
callback null, JSON.parse(resp.headers.etag)
|
|
||||||
|
|
||||||
writeSnapshotInternal = (docName, data, rev, callback) ->
|
|
||||||
body = data
|
|
||||||
body.fieldType = 'Document'
|
|
||||||
body._rev = rev if rev?
|
|
||||||
|
|
||||||
request.put uri:(uriForDoc docName), body:body, (err, resp, body) ->
|
|
||||||
parseError err, resp, body, (error) ->
|
|
||||||
if error
|
|
||||||
#console.log 'create error'
|
|
||||||
# This will send write conflicts as 'document already exists'. Thats kinda wierd, but
|
|
||||||
# it shouldn't happen anyway
|
|
||||||
callback? error
|
|
||||||
else
|
|
||||||
# We pass the document revision back to the db cache so it can give it back to couchdb on subsequent requests.
|
|
||||||
callback? null, {rev: body.rev}
|
|
||||||
|
|
||||||
# getOps returns all ops between start and end. end can be null.
|
|
||||||
getOps: (docName, start, end, callback) ->
|
|
||||||
return callback null, [] if start == end
|
|
||||||
|
|
||||||
# Its a bit gross having this end parameter here....
|
|
||||||
endkey = if end? then [docName, end - 1]
|
|
||||||
|
|
||||||
request uriForOps(docName, start, end), (err, resp, body) ->
|
|
||||||
# Rows look like this:
|
|
||||||
# {"id":"<uuid>","key":["doc name",0],"value":{"op":[{"p":0,"i":"hi"}],"meta":{}}}
|
|
||||||
data = ({op: row.value.op, meta: row.value.meta} for row in body.rows)
|
|
||||||
callback null, data
|
|
||||||
|
|
||||||
# callback(error, db metadata)
|
|
||||||
create: (docName, data, callback) ->
|
|
||||||
writeSnapshotInternal docName, data, null, callback
|
|
||||||
|
|
||||||
delete: del = (docName, dbMeta, callback) ->
|
|
||||||
getRev docName, dbMeta, (error, rev) ->
|
|
||||||
return callback? error if error
|
|
||||||
|
|
||||||
docs = [{_id:"doc:#{docName}", _rev:rev, _deleted:true}]
|
|
||||||
# Its annoying, but we need to get the revision from the document. I don't think there's a simple way to do this.
|
|
||||||
# This request will get all the ops twice.
|
|
||||||
request uriForOps(docName, 0, null, true), (err, resp, body) ->
|
|
||||||
# Rows look like this:
|
|
||||||
# {"id":"<uuid>","key":["doc name",0],"value":{"op":[{"p":0,"i":"hi"}],"meta":{}},
|
|
||||||
# "doc":{"_id":"<uuid>","_rev":"1-21a40c56ebd5d424ffe56950e77bc847","op":[{"p":0,"i":"hi"}],"v":0,"meta":{},"docName":"doc6"}}
|
|
||||||
for row in body.rows
|
|
||||||
row.doc._deleted = true
|
|
||||||
docs.push row.doc
|
|
||||||
|
|
||||||
request.post url: "#{db}/_bulk_docs", body: {docs}, (err, resp, body) ->
|
|
||||||
if body[0].error is 'conflict'
|
|
||||||
# Somebody has edited the document since we did a GET on the revision information. Recurse.
|
|
||||||
# By passing null to dbMeta I'm forcing the revision information to be reacquired.
|
|
||||||
del docName, null, callback
|
|
||||||
else
|
|
||||||
parseError err, resp, body, (error) -> callback? error
|
|
||||||
|
|
||||||
writeOp: (docName, opData, callback) ->
|
|
||||||
body =
|
|
||||||
docName: docName
|
|
||||||
op: opData.op
|
|
||||||
v: opData.v
|
|
||||||
meta: opData.meta
|
|
||||||
|
|
||||||
request.post url:db, body:body, (err, resp, body) ->
|
|
||||||
parseError err, resp, body, callback
|
|
||||||
|
|
||||||
writeSnapshot: (docName, docData, dbMeta, callback) ->
|
|
||||||
getRev docName, dbMeta, (error, rev) ->
|
|
||||||
return callback? error if error
|
|
||||||
|
|
||||||
writeSnapshotInternal docName, docData, rev, callback
|
|
||||||
|
|
||||||
getSnapshot: (docName, callback) ->
|
|
||||||
request uriForDoc(docName), (err, resp, body) ->
|
|
||||||
parseError err, resp, body, (error) ->
|
|
||||||
if error
|
|
||||||
callback error
|
|
||||||
else
|
|
||||||
callback null,
|
|
||||||
snapshot: body.snapshot
|
|
||||||
type: body.type
|
|
||||||
meta: body.meta
|
|
||||||
v: body.v
|
|
||||||
, {rev: body._rev} # dbMeta
|
|
||||||
|
|
||||||
close: ->
|
|
|
@ -1,28 +0,0 @@
|
||||||
# This is a simple switch for the different database implementations.
|
|
||||||
#
|
|
||||||
# The interface is the same as the regular database implementations, except
|
|
||||||
# the options object can have another type:<TYPE> parameter which specifies
|
|
||||||
# which type of database to use.
|
|
||||||
#
|
|
||||||
# Example usage:
|
|
||||||
# require('server/db').create {type:'redis'}
|
|
||||||
|
|
||||||
defaultType = 'redis'
|
|
||||||
|
|
||||||
module.exports = (options) ->
|
|
||||||
options ?= {}
|
|
||||||
type = options.type ? defaultType
|
|
||||||
|
|
||||||
console.warn "Database type: 'memory' detected. This has been deprecated and will
|
|
||||||
be removed in a future version. Use 'none' instead, or just remove the db:{} block
|
|
||||||
from your options." if type is 'memory'
|
|
||||||
|
|
||||||
if type in ['none', 'memory']
|
|
||||||
null
|
|
||||||
else
|
|
||||||
Db = switch type
|
|
||||||
when 'redis' then require './redis'
|
|
||||||
when 'couchdb' then require './couchdb'
|
|
||||||
when 'pg' then require './pg'
|
|
||||||
else throw new Error "Invalid or unsupported database type: '#{type}'"
|
|
||||||
new Db options
|
|
|
@ -1,198 +0,0 @@
|
||||||
# This is an implementation of the OT data backend for PostgreSQL. It requires
|
|
||||||
# that you have two tables defined in your schema: one for the snapshots
|
|
||||||
# and one for the operations. You must also install the 'pg' package.
|
|
||||||
#
|
|
||||||
#
|
|
||||||
# Example usage:
|
|
||||||
#
|
|
||||||
# var connect = require('connect');
|
|
||||||
# var share = require('share').server;
|
|
||||||
#
|
|
||||||
# var server = connect(connect.logger());
|
|
||||||
#
|
|
||||||
# var options = {
|
|
||||||
# db: {
|
|
||||||
# type: 'pg',
|
|
||||||
# uri: 'tcp://josh:@localhost/sharejs',
|
|
||||||
# create_tables_automatically: true
|
|
||||||
# }
|
|
||||||
# };
|
|
||||||
#
|
|
||||||
# share.attach(server, options);
|
|
||||||
# server.listen(9000);
|
|
||||||
#
|
|
||||||
# You can run bin/setup_pg to create the SQL tables initially.
|
|
||||||
|
|
||||||
pg = require('pg').native
|
|
||||||
|
|
||||||
defaultOptions =
|
|
||||||
schema: 'sharejs'
|
|
||||||
create_tables_automatically: true
|
|
||||||
operations_table: 'ops'
|
|
||||||
snapshot_table: 'snapshots'
|
|
||||||
|
|
||||||
module.exports = PgDb = (options) ->
|
|
||||||
return new Db if !(this instanceof PgDb)
|
|
||||||
|
|
||||||
options ?= {}
|
|
||||||
options[k] ?= v for k, v of defaultOptions
|
|
||||||
|
|
||||||
client = new pg.Client options.uri
|
|
||||||
client.connect()
|
|
||||||
|
|
||||||
snapshot_table = options.schema and "#{options.schema}.#{options.snapshot_table}" or options.snapshot_table
|
|
||||||
operations_table = options.schema and "#{options.schema}.#{options.operations_table}" or options.operations_table
|
|
||||||
|
|
||||||
@close = ->
|
|
||||||
client.end()
|
|
||||||
|
|
||||||
@initialize = (callback) ->
|
|
||||||
console.warn 'Creating postgresql database tables'
|
|
||||||
|
|
||||||
sql = """
|
|
||||||
CREATE SCHEMA #{options.schema};
|
|
||||||
|
|
||||||
CREATE TABLE #{snapshot_table} (
|
|
||||||
doc text NOT NULL,
|
|
||||||
v int4 NOT NULL,
|
|
||||||
type text NOT NULL,
|
|
||||||
snapshot text NOT NULL,
|
|
||||||
meta text NOT NULL,
|
|
||||||
created_at timestamp(6) NOT NULL,
|
|
||||||
CONSTRAINT snapshots_pkey PRIMARY KEY (doc, v)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE #{operations_table} (
|
|
||||||
doc text NOT NULL,
|
|
||||||
v int4 NOT NULL,
|
|
||||||
op text NOT NULL,
|
|
||||||
meta text NOT NULL,
|
|
||||||
CONSTRAINT operations_pkey PRIMARY KEY (doc, v)
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
client.query sql, (error, result) ->
|
|
||||||
callback? error?.message
|
|
||||||
|
|
||||||
# This will perminantly delete all data in the database.
|
|
||||||
@dropTables = (callback) ->
|
|
||||||
sql = "DROP SCHEMA #{options.schema} CASCADE;"
|
|
||||||
client.query sql, (error, result) ->
|
|
||||||
callback? error.message
|
|
||||||
|
|
||||||
@create = (docName, docData, callback) ->
|
|
||||||
sql = """
|
|
||||||
INSERT INTO #{snapshot_table} ("doc", "v", "snapshot", "meta", "type", "created_at")
|
|
||||||
VALUES ($1, $2, $3, $4, $5, now())
|
|
||||||
"""
|
|
||||||
values = [docName, docData.v, JSON.stringify(docData.snapshot), JSON.stringify(docData.meta), docData.type]
|
|
||||||
client.query sql, values, (error, result) ->
|
|
||||||
if !error?
|
|
||||||
callback?()
|
|
||||||
else if error.toString().match "duplicate key value violates unique constraint"
|
|
||||||
callback? "Document already exists"
|
|
||||||
else
|
|
||||||
callback? error?.message
|
|
||||||
|
|
||||||
@delete = (docName, dbMeta, callback) ->
|
|
||||||
sql = """
|
|
||||||
DELETE FROM #{operations_table}
|
|
||||||
WHERE "doc" = $1
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
values = [docName]
|
|
||||||
client.query sql, values, (error, result) ->
|
|
||||||
if !error?
|
|
||||||
sql = """
|
|
||||||
DELETE FROM #{snapshot_table}
|
|
||||||
WHERE "doc" = $1
|
|
||||||
RETURNING *
|
|
||||||
"""
|
|
||||||
client.query sql, values, (error, result) ->
|
|
||||||
if !error? and result.rows.length > 0
|
|
||||||
callback?()
|
|
||||||
else if !error?
|
|
||||||
callback? "Document does not exist"
|
|
||||||
else
|
|
||||||
callback? error?.message
|
|
||||||
else
|
|
||||||
callback? error?.message
|
|
||||||
|
|
||||||
@getSnapshot = (docName, callback) ->
|
|
||||||
sql = """
|
|
||||||
SELECT *
|
|
||||||
FROM #{snapshot_table}
|
|
||||||
WHERE "doc" = $1
|
|
||||||
ORDER BY "v" DESC
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
values = [docName]
|
|
||||||
client.query sql, values, (error, result) ->
|
|
||||||
if !error? and result.rows.length > 0
|
|
||||||
row = result.rows[0]
|
|
||||||
data =
|
|
||||||
v: row.v
|
|
||||||
snapshot: JSON.parse(row.snapshot)
|
|
||||||
meta: JSON.parse(row.meta)
|
|
||||||
type: row.type
|
|
||||||
callback? null, data
|
|
||||||
else if !error?
|
|
||||||
callback? "Document does not exist"
|
|
||||||
else
|
|
||||||
callback? error?.message
|
|
||||||
|
|
||||||
@writeSnapshot = (docName, docData, dbMeta, callback) ->
|
|
||||||
sql = """
|
|
||||||
UPDATE #{snapshot_table}
|
|
||||||
SET "v" = $2, "snapshot" = $3, "meta" = $4
|
|
||||||
WHERE "doc" = $1
|
|
||||||
"""
|
|
||||||
values = [docName, docData.v, JSON.stringify(docData.snapshot), JSON.stringify(docData.meta)]
|
|
||||||
client.query sql, values, (error, result) ->
|
|
||||||
if !error?
|
|
||||||
callback?()
|
|
||||||
else
|
|
||||||
callback? error?.message
|
|
||||||
|
|
||||||
@getOps = (docName, start, end, callback) ->
|
|
||||||
end = if end? then end - 1 else 2147483647
|
|
||||||
sql = """
|
|
||||||
SELECT *
|
|
||||||
FROM #{operations_table}
|
|
||||||
WHERE "v" BETWEEN $1 AND $2
|
|
||||||
AND "doc" = $3
|
|
||||||
ORDER BY "v" ASC
|
|
||||||
"""
|
|
||||||
values = [start, end, docName]
|
|
||||||
client.query sql, values, (error, result) ->
|
|
||||||
if !error?
|
|
||||||
data = result.rows.map (row) ->
|
|
||||||
return {
|
|
||||||
op: JSON.parse row.op
|
|
||||||
# v: row.version
|
|
||||||
meta: JSON.parse row.meta
|
|
||||||
}
|
|
||||||
callback? null, data
|
|
||||||
else
|
|
||||||
callback? error?.message
|
|
||||||
|
|
||||||
@writeOp = (docName, opData, callback) ->
|
|
||||||
sql = """
|
|
||||||
INSERT INTO #{operations_table} ("doc", "op", "v", "meta")
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
"""
|
|
||||||
values = [docName, JSON.stringify(opData.op), opData.v, JSON.stringify(opData.meta)]
|
|
||||||
client.query sql, values, (error, result) ->
|
|
||||||
if !error?
|
|
||||||
callback?()
|
|
||||||
else
|
|
||||||
callback? error?.message
|
|
||||||
|
|
||||||
# Immediately try and create the database tables if need be. Its possible that a query
|
|
||||||
# which happens immediately will happen before the database has been initialized.
|
|
||||||
#
|
|
||||||
# But, its not really a big problem.
|
|
||||||
if options.create_tables_automatically
|
|
||||||
client.query "SELECT * from #{snapshot_table} LIMIT 0", (error, result) =>
|
|
||||||
@initialize() if error?.message.match "does not exist"
|
|
||||||
|
|
||||||
this
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue