Sort out front end coffee/js files and minification

This commit is contained in:
James Allen 2014-07-08 12:01:32 +01:00
parent 8525bf4a72
commit b9909bbd84
193 changed files with 80 additions and 20601 deletions

View file

@ -37,15 +37,13 @@ module.exports = (grunt) ->
join: true join: true
files: files:
"public/js/libs/sharejs.js": [ "public/js/libs/sharejs.js": [
"public/coffee/editor/ShareJSHeader.coffee" "public/coffee/ide/editor/sharejs/header.coffee"
"public/coffee/editor/sharejs/types/helpers.coffee" "public/coffee/ide/editor/sharejs/vendor/types/helpers.coffee"
"public/coffee/editor/sharejs/types/text.coffee" "public/coffee/ide/editor/sharejs/vendor/types/text.coffee"
"public/coffee/editor/sharejs/types/text-api.coffee" "public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee"
"public/coffee/editor/sharejs/types/json.coffee" "public/coffee/ide/editor/sharejs/vendor/client/microevent.coffee"
"public/coffee/editor/sharejs/types/json-api.coffee" "public/coffee/ide/editor/sharejs/vendor/client/doc.coffee"
"public/coffee/editor/sharejs/client/microevent.coffee" "public/coffee/ide/editor/sharejs/vendor/client/ace.coffee"
"public/coffee/editor/sharejs/client/doc.coffee"
"public/coffee/editor/sharejs/client/ace.coffee"
] ]
client: client:
@ -89,12 +87,8 @@ module.exports = (grunt) ->
inlineText: false inlineText: false
preserveLicenseComments: false preserveLicenseComments: false
paths: paths:
"underscore": "libs/underscore" "moment": "libs/moment-2.7.0"
"jquery": "libs/jquery"
"moment": "libs/moment"
shim: shim:
"libs/backbone":
deps: ["libs/underscore"]
"libs/pdfListView/PdfListView": "libs/pdfListView/PdfListView":
deps: ["libs/pdf"] deps: ["libs/pdf"]
"libs/pdf": "libs/pdf":
@ -104,16 +98,12 @@ module.exports = (grunt) ->
modules: [ modules: [
{ {
name: "main", name: "main",
exclude: ["jquery"] exclude: ["libs"]
}, { }, {
name: "ide", name: "ide",
exclude: ["jquery"] exclude: ["libs", "libs/jquery-layout"]
}, { }, {
name: "home", name: "libs"
exclude: ["jquery"]
}, {
name: "list",
exclude: ["jquery"]
} }
] ]

View file

@ -30,11 +30,8 @@ html(itemscope, itemtype='http://schema.org/Product')
script(type="text/javascript"). script(type="text/javascript").
window.csrfToken = "#{csrfToken}"; window.csrfToken = "#{csrfToken}";
script(src=jsPath+'libs/jquery.js') script(src="//ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js")
script(src=jsPath+'libs/angular-1.2.17.js') script(src="//ajax.googleapis.com/ajax/libs/angularjs/1.2.19/angular.min.js")
script(src=jsPath+'libs/moment-2.4.0.js')
script(src=jsPath+'libs/underscore-1.3.3.js')
block scripts
body body
- if(typeof(suppressNavbar) == "undefined") - if(typeof(suppressNavbar) == "undefined")
@ -61,10 +58,13 @@ html(itemscope, itemtype='http://schema.org/Product')
- if(typeof(suppressFooter) == "undefined") - if(typeof(suppressFooter) == "undefined")
script(type='text/javascript'). script(type='text/javascript').
window.requirejs = { window.requirejs = {
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'app/main.js')}" "urlArgs" : "fingerprint=#{fingerprint(jsPath + 'app/main.js')}",
"paths" : {
"moment": "libs/moment-2.7.0"
}
}; };
script( script(
data-main=jsPath+'app/main.js', data-main=jsPath+'main.js',
baseurl=jsPath, baseurl=jsPath,
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js') src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')
) )

View file

@ -5,13 +5,6 @@ block vars
- var suppressFooter = true - var suppressFooter = true
- var suppressDefaultJs = true - var suppressDefaultJs = true
block scripts
//- Only use the native bootstrap on the editor page,
//- since we use the Angular-based bootstrap elsewhere.
//- script(src=jsPath+'libs/bootstrap-3.1.1.js')
script(src=jsPath+'libs/jquery-layout.js')
script(src=jsPath+'libs/jquery.storage.js')
block content block content
.editor(ng-controller="IdeController") .editor(ng-controller="IdeController")
.loading-screen(ng-show="state.loading") .loading-screen(ng-show="state.loading")
@ -92,19 +85,12 @@ block content
window.csrfToken = "!{csrfToken}"; window.csrfToken = "!{csrfToken}";
window.requirejs = { window.requirejs = {
"paths" : { "paths" : {
"underscore": "../libs/underscore-1.3.3",
"mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML", "mathjax": "https://c328740.ssl.cf1.rackcdn.com/mathjax/latest/MathJax.js?config=TeX-AMS_HTML",
"moment": "libs/moment-2.4.0", "moment": "libs/moment-2.7.0"
"ace": "#{jsPath}ace",
"libs": "#{jsPath}libs",
"text": "#{jsPath}text"
}, },
"urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}", "urlArgs" : "fingerprint=#{fingerprint(jsPath + 'ide.js')}",
"waitSeconds": 0, "waitSeconds": 0,
"shim": { "shim": {
"libs/backbone": {
deps: ["libs/underscore-1.3.3"]
},
"libs/pdfListView/PdfListView": { "libs/pdfListView/PdfListView": {
deps: ["libs/pdf"] deps: ["libs/pdf"]
}, },
@ -125,7 +111,7 @@ block content
window.sharelatex.pdfJsWorkerPath = "#{pdfJsWorkerPath}" window.sharelatex.pdfJsWorkerPath = "#{pdfJsWorkerPath}"
script( script(
data-main=jsPath+'app/ide.js', data-main=jsPath+'ide.js',
baseurl=jsPath, baseurl=jsPath,
data-ace-base=jsPath+'ace', data-ace-base=jsPath+'ace',
src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js') src=jsPath+'libs/require.js?fingerprint='+fingerprint(jsPath + 'libs/require.js')

View file

@ -1,6 +1,6 @@
extends ../layout extends ../layout
block scripts block content
script(type="text/javascript"). script(type="text/javascript").
window.data = { window.data = {
projects: !{JSON.stringify(projects)}, projects: !{JSON.stringify(projects)},
@ -13,7 +13,6 @@ block scripts
} }
}; };
block content
.content.content-alt(ng-controller="ProjectPageController") .content.content-alt(ng-controller="ProjectPageController")
.container .container
.row .row

View file

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

View file

@ -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
}]

View file

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

View file

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

View file

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

View file

@ -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"
}
}

View file

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

View file

@ -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>
"""
}

View file

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

View file

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

View file

@ -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
}

View file

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

View file

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

View file

@ -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"])

View file

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

View file

@ -1,7 +0,0 @@
define [
"base"
], (App) ->
App.controller "BinaryFileController", ["$scope", ($scope) ->
$scope.extension = (file) ->
return file.name.split(".").pop()?.toLowerCase()
]

View file

@ -1,7 +0,0 @@
define [
"base"
], (App) ->
App.controller "ChatButtonController", ["$scope", ($scope) ->
$scope.toggleChat = () ->
$scope.ui.chatOpen = !$scope.ui.chatOpen
]

View file

@ -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
]

View file

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

View file

@ -1,5 +0,0 @@
define [
"ide/chat/controllers/ChatButtonController"
"ide/chat/controllers/ChatController"
"ide/chat/controllers/ChatMessageController"
], () ->

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
}

View file

@ -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."
]

View file

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

View file

@ -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>
"""
}
]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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')
]

View file

@ -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')
]

View file

@ -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
}
)
]

View file

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

View file

@ -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
}

View file

@ -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
}

View file

@ -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);
}
]

View file

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

View file

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

View file

@ -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')
]

View file

@ -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>
"""
}
]

View file

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

View file

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

View file

@ -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"
]

View file

@ -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
]

View file

@ -1,7 +0,0 @@
define [
"ide/settings/services/settings"
"ide/settings/controllers/SettingsController"
"ide/settings/controllers/ProjectNameController"
], () ->

View file

@ -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
}
]

View file

@ -1,11 +0,0 @@
define [
"base"
], (App) ->
App.controller "ShareController", ["$scope", "$modal", ($scope, $modal) ->
$scope.openShareProjectModal = () ->
$modal.open(
templateUrl: "shareProjectModalTemplate"
controller: "ShareProjectModalController"
scope: $scope
)
]

View file

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

View file

@ -1,5 +0,0 @@
define [
"ide/share/controllers/ShareController"
"ide/share/controllers/ShareProjectModalController"
"ide/share/services/projectMembers"
], () ->

View file

@ -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
}
]

View file

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

View file

@ -1,9 +0,0 @@
define [
"base"
], (App) ->
App.controller "TrackChangesDiffController", ["$scope", "ide", ($scope, ide) ->
$scope.restoreDeletedDoc = () ->
ide.trackChangesManager.restoreDeletedDoc(
$scope.trackChanges.diff.doc
)
]

View file

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

View file

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

View file

@ -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"])

View file

@ -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')
]

View file

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

View file

@ -1,6 +0,0 @@
define [
"base"
], (App) ->
App.controller "PlansController", ($scope) ->
$scope.ui =
view: "monthly"

View file

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

View file

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

View file

@ -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
]

View file

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

View file

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

View file

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

View file

@ -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
}

View file

@ -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]

View file

@ -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"
]
)

View file

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

View file

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

View file

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

View file

@ -1,3 +0,0 @@
WEB = true
window.sharejs = exports = {}
types = exports.types = {}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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?

View file

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

View file

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

View file

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

View file

@ -1,5 +0,0 @@
exports.server = require './server'
exports.client = require './client'
exports.types = require './types'
exports.version = '0.5.0'

View file

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

View file

@ -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: ->

View file

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

View file

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