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
|
||||
files:
|
||||
"public/js/libs/sharejs.js": [
|
||||
"public/coffee/editor/ShareJSHeader.coffee"
|
||||
"public/coffee/editor/sharejs/types/helpers.coffee"
|
||||
"public/coffee/editor/sharejs/types/text.coffee"
|
||||
"public/coffee/editor/sharejs/types/text-api.coffee"
|
||||
"public/coffee/editor/sharejs/types/json.coffee"
|
||||
"public/coffee/editor/sharejs/types/json-api.coffee"
|
||||
"public/coffee/editor/sharejs/client/microevent.coffee"
|
||||
"public/coffee/editor/sharejs/client/doc.coffee"
|
||||
"public/coffee/editor/sharejs/client/ace.coffee"
|
||||
"public/coffee/ide/editor/sharejs/header.coffee"
|
||||
"public/coffee/ide/editor/sharejs/vendor/types/helpers.coffee"
|
||||
"public/coffee/ide/editor/sharejs/vendor/types/text.coffee"
|
||||
"public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee"
|
||||
"public/coffee/ide/editor/sharejs/vendor/client/microevent.coffee"
|
||||
"public/coffee/ide/editor/sharejs/vendor/client/doc.coffee"
|
||||
"public/coffee/ide/editor/sharejs/vendor/client/ace.coffee"
|
||||
]
|
||||
|
||||
client:
|
||||
|
@ -89,12 +87,8 @@ module.exports = (grunt) ->
|
|||
inlineText: false
|
||||
preserveLicenseComments: false
|
||||
paths:
|
||||
"underscore": "libs/underscore"
|
||||
"jquery": "libs/jquery"
|
||||
"moment": "libs/moment"
|
||||
"moment": "libs/moment-2.7.0"
|
||||
shim:
|
||||
"libs/backbone":
|
||||
deps: ["libs/underscore"]
|
||||
"libs/pdfListView/PdfListView":
|
||||
deps: ["libs/pdf"]
|
||||
"libs/pdf":
|
||||
|
@ -104,16 +98,12 @@ module.exports = (grunt) ->
|
|||
modules: [
|
||||
{
|
||||
name: "main",
|
||||
exclude: ["jquery"]
|
||||
exclude: ["libs"]
|
||||
}, {
|
||||
name: "ide",
|
||||
exclude: ["jquery"]
|
||||
exclude: ["libs", "libs/jquery-layout"]
|
||||
}, {
|
||||
name: "home",
|
||||
exclude: ["jquery"]
|
||||
}, {
|
||||
name: "list",
|
||||
exclude: ["jquery"]
|
||||
name: "libs"
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
@ -30,11 +30,8 @@ html(itemscope, itemtype='http://schema.org/Product')
|
|||
script(type="text/javascript").
|
||||
window.csrfToken = "#{csrfToken}";
|
||||
|
||||
script(src=jsPath+'libs/jquery.js')
|
||||
script(src=jsPath+'libs/angular-1.2.17.js')
|
||||
script(src=jsPath+'libs/moment-2.4.0.js')
|
||||
script(src=jsPath+'libs/underscore-1.3.3.js')
|
||||
block scripts
|
||||
script(src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js")
|
||||
script(src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.19/angular.min.js")
|
||||
|
||||
body
|
||||
- if(typeof(suppressNavbar) == "undefined")
|
||||
|
@ -61,10 +58,13 @@ html(itemscope, itemtype='http://schema.org/Product')
|
|||
- if(typeof(suppressFooter) == "undefined")
|
||||
script(type='text/javascript').
|
||||
window.requirejs = {
|
||||
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'app/main.js')}"
|
||||
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'app/main.js')}",
|
||||
"paths" : {
|
||||
"moment": "libs/moment-2.7.0"
|
||||
}
|
||||
};
|
||||
script(
|
||||
data-main=jsPath+'app/main.js',
|
||||
data-main=jsPath+'main.js',
|
||||
baseurl=jsPath,
|
||||
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
|
||||
)
|
||||
|
|
|
@ -5,13 +5,6 @@ block vars
|
|||
- var suppressFooter = 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
|
||||
.editor(ng-controller="IdeController")
|
||||
.loading-screen(ng-show="state.loading")
|
||||
|
@ -92,19 +85,12 @@ block content
|
|||
window.csrfToken = "!{csrfToken}";
|
||||
window.requirejs = {
|
||||
"paths" : {
|
||||
"underscore": "../libs/underscore-1.3.3",
|
||||
"mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML",
|
||||
"moment": "libs/moment-2.4.0",
|
||||
"ace": "#{jsPath}ace",
|
||||
"libs": "#{jsPath}libs",
|
||||
"text": "#{jsPath}text"
|
||||
"moment": "libs/moment-2.7.0"
|
||||
},
|
||||
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}",
|
||||
"waitSeconds": 0,
|
||||
"shim": {
|
||||
"libs/backbone": {
|
||||
deps: ["libs/underscore-1.3.3"]
|
||||
},
|
||||
"libs/pdfListView/PdfListView": {
|
||||
deps: ["libs/pdf"]
|
||||
},
|
||||
|
@ -125,7 +111,7 @@ block content
|
|||
window.sharelatex.pdfJsWorkerPath = "#{pdfJsWorkerPath}"
|
||||
|
||||
script(
|
||||
data-main=jsPath+'app/ide.js',
|
||||
data-main=jsPath+'ide.js',
|
||||
baseurl=jsPath,
|
||||
data-ace-base=jsPath+'ace',
|
||||
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
extends ../layout
|
||||
|
||||
block scripts
|
||||
block content
|
||||
script(type="text/javascript").
|
||||
window.data = {
|
||||
projects: !{JSON.stringify(projects)},
|
||||
|
@ -13,7 +13,6 @@ block scripts
|
|||
}
|
||||
};
|
||||
|
||||
block content
|
||||
.content.content-alt(ng-controller="ProjectPageController")
|
||||
.container
|
||||
.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