Merge pull request #1037 from sharelatex/as-decaffeinate

Decaffeinate frontend

GitOrigin-RevId: 1c8c53dedecfe55f9936a13408df17b852f996de
This commit is contained in:
Alasdair Smith
2018-11-05 10:06:39 +00:00
committed by sharelatex
parent 4842a45d8c
commit 659242b457
449 changed files with 32747 additions and 24202 deletions

6
services/web/.babelrc Normal file
View File

@@ -0,0 +1,6 @@
{
"presets": [
"react",
["env", { "modules": false }]
]
}

View File

@@ -2,9 +2,13 @@
"extends": [
"standard",
"standard-react",
"prettier",
"prettier/react",
"prettier/standard",
"plugin:jsx-a11y/recommended"
],
"plugins": [
"prettier",
"jsx-a11y",
"mocha",
"chai-expect",
@@ -24,11 +28,15 @@
},
"rules": {
"max-len": ["error", {
// Ignore long describe/it test blocks
"ignoreUrls": true,
"ignorePattern": "^\\s*(it|describe)\\s*\\(['\"]"
// Ignore long describe/it test blocks, long import/require statements
"ignorePattern": "(^\\s*(it|describe)\\s*\\(['\"]|^import\\s*.*\\s*from\\s*['\"]|^.*\\s*=\\s*require\\(['\"])"
}],
// Fix conflict between prettier & standard by overriding to prefer
// double quotes
"jsx-quotes": ["error", "prefer-double"],
// Override weird behaviour of jsx-a11y label-has-for (says labels must be
// nested *and* have for/id attributes)
"jsx-a11y/label-has-for": [

View File

@@ -53,7 +53,6 @@ public/es/modules
public/js/*.js
public/js/*.map
public/js/libs/sharejs.js
public/js/analytics/
public/js/directives/
public/js/components/

View File

@@ -0,0 +1,12 @@
app/js
modules/**/app/js
modules/**/scripts
modules/**/index.js
public/js
public/minjs
modules/**/public/js
test/**/js
modules/**/test/**/js
app.js
webpack.config.*
karma.conf.js

4
services/web/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}

View File

@@ -6,23 +6,19 @@ PROJECT_NAME = web
MODULE_DIRS := $(shell find modules -mindepth 1 -maxdepth 1 -type d -not -name '.git' )
MODULE_MAKEFILES := $(MODULE_DIRS:=/Makefile)
COFFEE := node_modules/.bin/coffee $(COFFEE_OPTIONS)
BABEL := node_modules/.bin/babel
GRUNT := node_modules/.bin/grunt
APP_COFFEE_FILES := $(shell find app/coffee -name '*.coffee')
FRONT_END_COFFEE_FILES := $(shell find public/coffee -name '*.coffee')
FRONT_END_SRC_FILES := $(shell find public/src -name '*.js')
TEST_COFFEE_FILES := $(shell find test/*/coffee -name '*.coffee')
MODULE_MAIN_COFFEE_FILES := $(shell find modules -type f -wholename '*main/index.coffee')
MODULE_IDE_COFFEE_FILES := $(shell find modules -type f -wholename '*ide/index.coffee')
TEST_SRC_FILES := $(shell find test/*/src -name '*.js')
MODULE_MAIN_SRC_FILES := $(shell find modules -type f -wholename '*main/index.js')
MODULE_IDE_SRC_FILES := $(shell find modules -type f -wholename '*ide/index.js')
COFFEE_FILES := app.coffee $(APP_COFFEE_FILES) $(FRONT_END_COFFEE_FILES) $(TEST_COFFEE_FILES)
SRC_FILES := $(FRONT_END_SRC_FILES) $(TEST_SRC_FILES)
JS_FILES := $(subst coffee,js,$(COFFEE_FILES))
SHAREJS_COFFEE_FILES := \
public/coffee/ide/editor/sharejs/header.coffee \
public/coffee/ide/editor/sharejs/vendor/types/helpers.coffee \
public/coffee/ide/editor/sharejs/vendor/types/text.coffee \
public/coffee/ide/editor/sharejs/vendor/types/text-api.coffee \
public/coffee/ide/editor/sharejs/vendor/client/microevent.coffee \
public/coffee/ide/editor/sharejs/vendor/client/doc.coffee \
public/coffee/ide/editor/sharejs/vendor/client/ace.coffee \
public/coffee/ide/editor/sharejs/vendor/client/cm.coffee
OUTPUT_SRC_FILES := $(subst src,js,$(SRC_FILES))
LESS_FILES := $(shell find public/stylesheets -name '*.less')
CSS_FILES := public/stylesheets/style.css public/stylesheets/ol-style.css public/stylesheets/ol-light-style.css
@@ -34,9 +30,9 @@ app/js/%.js: app/coffee/%.coffee
@mkdir -p $(@D)
$(COFFEE) --compile -o $(@D) $<
public/js/%.js: public/coffee/%.coffee
public/js/%.js: public/src/%.js
@mkdir -p $(@D)
$(COFFEE) --output $(@D) --map --compile $<
$(BABEL) $< --out-file $@
test/unit/js/%.js: test/unit/coffee/%.coffee
@mkdir -p $(@D)
@@ -46,55 +42,49 @@ test/acceptance/js/%.js: test/acceptance/coffee/%.coffee
@mkdir -p $(@D)
$(COFFEE) --compile -o $(@D) $<
test/unit_frontend/js/%.js: test/unit_frontend/coffee/%.coffee
test/unit_frontend/js/%.js: test/unit_frontend/src/%.js
@mkdir -p $(@D)
$(COFFEE) --compile -o $(@D) $<
$(BABEL) $< --out-file $@
test/smoke/js/%.js: test/smoke/coffee/%.coffee
@mkdir -p $(@D)
$(COFFEE) --compile -o $(@D) $<
public/js/libs/sharejs.js: $(SHAREJS_COFFEE_FILES)
@echo "Compiling public/js/libs/sharejs.js"
@echo 'define(["ace/ace"], function() {' > public/js/libs/sharejs.js
@cat $(SHAREJS_COFFEE_FILES) | $(COFFEE) --stdio --print >> public/js/libs/sharejs.js
@echo "" >> public/js/libs/sharejs.js
@echo "return window.sharejs; });" >> public/js/libs/sharejs.js
public/js/ide.js: public/coffee/ide.coffee $(MODULE_IDE_COFFEE_FILES)
public/js/ide.js: public/src/ide.js $(MODULE_IDE_SRC_FILES)
@echo Compiling and injecting module includes into public/js/ide.js
@INCLUDES=""; \
for dir in modules/*; \
do \
MODULE=`echo $$dir | cut -d/ -f2`; \
if [ -e $$dir/public/coffee/ide/index.coffee ]; then \
if [ -e $$dir/public/src/ide/index.js ]; then \
INCLUDES="\"ide/$$MODULE/index\",$$INCLUDES"; \
fi \
done; \
INCLUDES=$${INCLUDES%?}; \
$(COFFEE) --compile --print $< | \
sed -e s=\"__IDE_CLIENTSIDE_INCLUDES__\"=$$INCLUDES= \
$(BABEL) $< | \
sed -e s=\'__IDE_CLIENTSIDE_INCLUDES__\'=$$INCLUDES= \
> $@
public/js/main.js: public/coffee/main.coffee $(MODULE_MAIN_COFFEE_FILES)
public/js/main.js: public/src/main.js $(MODULE_MAIN_SRC_FILES)
@echo Compiling and injecting module includes into public/js/main.js
@INCLUDES=""; \
for dir in modules/*; \
do \
MODULE=`echo $$dir | cut -d/ -f2`; \
if [ -e $$dir/public/coffee/main/index.coffee ]; then \
if [ -e $$dir/public/src/main/index.js ]; then \
INCLUDES="\"main/$$MODULE/index\",$$INCLUDES"; \
fi \
done; \
INCLUDES=$${INCLUDES%?}; \
$(COFFEE) --compile --print $< | \
sed -e s=\"__MAIN_CLIENTSIDE_INCLUDES__\"=$$INCLUDES= \
$(BABEL) $< | \
sed -e s=\'__MAIN_CLIENTSIDE_INCLUDES__\'=$$INCLUDES= \
> $@
$(CSS_FILES): $(LESS_FILES)
$(GRUNT) compile:css
minify: $(CSS_FILES) $(JS_FILES)
minify: $(CSS_FILES) $(JS_FILES) $(OUTPUT_SRC_FILES)
$(GRUNT) compile:minify
$(MAKE) minify_es
@@ -103,17 +93,17 @@ minify_es:
css: $(CSS_FILES)
compile: $(JS_FILES) css public/js/libs/sharejs.js public/js/main.js public/js/ide.js
compile: $(JS_FILES) $(OUTPUT_SRC_FILES) css public/js/main.js public/js/ide.js
@$(MAKE) compile_modules
compile_full:
$(COFFEE) -c -p app.coffee > app.js
$(COFFEE) -o app/js -c app/coffee
$(COFFEE) -o public/js -c public/coffee
$(BABEL) public/src --out-dir public/js
$(COFFEE) -o test/acceptance/js -c test/acceptance/coffee
$(COFFEE) -o test/smoke/js -c test/smoke/coffee
$(COFFEE) -o test/unit/js -c test/unit/coffee
$(COFFEE) -o test/unit_frontend/js -c test/unit_frontend/coffee
$(BABEL) test/unit_frontend/src --out-dir test/unit_frontend/js
rm -f public/js/ide.js public/js/main.js # We need to generate ide.js, main.js manually later
$(MAKE) $(CSS_FILES)
$(MAKE) compile_modules_full
@@ -160,7 +150,6 @@ clean_app:
clean_frontend:
rm -rf public/js/{analytics,directives,es,filters,ide,main,modules,services,utils}
rm -f public/js/*.{js,map}
rm -f public/js/libs/sharejs.{js,map}
clean_tests:
rm -rf test/unit/js
@@ -239,6 +228,9 @@ ci:
MOCHA_ARGS="--reporter tap" \
$(MAKE) test
format:
npm -q run format
lint:
npm -q run lint

View File

@@ -1,19 +1,22 @@
MODULE_NAME := $(notdir $(shell pwd))
MODULE_DIR := modules/$(MODULE_NAME)
COFFEE := ../../node_modules/.bin/coffee
BABEL := ../../node_modules/.bin/babel
APP_COFFEE_FILES := $(shell [ -e app/coffee ] && find app/coffee -name '*.coffee') \
$(shell [ -e test/unit/coffee ] && find test/unit/coffee -name '*.coffee') \
$(shell [ -e test/acceptance/coffee ] && find test/acceptance/coffee -name '*.coffee')
APP_JS_FILES := $(subst coffee,js,$(APP_COFFEE_FILES))
IDE_COFFEE_FILES := $(shell [ -e public/coffee/ide ] && find public/coffee/ide -name '*.coffee')
IDE_JS_FILES := $(subst public/coffee/ide,../../public/js/ide/$(MODULE_NAME),$(IDE_COFFEE_FILES))
IDE_JS_FILES := $(subst coffee,js,$(IDE_JS_FILES))
IDE_TEST_COFFEE_FILES := $(shell [ -e test/unit_frontend/coffee ] && find test/unit_frontend/coffee -name '*.coffee')
IDE_TEST_JS_FILES := $(subst test/unit_frontend/coffee/ide,../../test/unit_frontend/js/ide/$(MODULE_NAME),$(IDE_TEST_COFFEE_FILES))
IDE_TEST_JS_FILES := $(subst coffee,js,$(IDE_TEST_JS_FILES))
MAIN_COFFEE_FILES := $(shell [ -e public/coffee/main ] && find public/coffee/main -name '*.coffee')
MAIN_JS_FILES := $(subst public/coffee/main,../../public/js/main/$(MODULE_NAME),$(MAIN_COFFEE_FILES))
MAIN_JS_FILES := $(subst coffee,js,$(MAIN_JS_FILES))
IDE_SRC_FILES := $(shell [ -e public/src/ide ] && find public/src/ide -name '*.js')
IDE_OUTPUT_FILES := $(subst public/src/ide,../../public/js/ide/$(MODULE_NAME),$(IDE_SRC_FILES))
IDE_TEST_SRC_FILES := $(shell [ -e test/unit_frontend/src/ide ] && find test/unit_frontend/src/ide -name '*.js')
IDE_TEST_OUTPUT_FILES := $(subst test/unit_frontend/src/ide,../../test/unit_frontend/js/ide/$(MODULE_NAME),$(IDE_TEST_SRC_FILES))
MAIN_SRC_FILES := $(shell [ -e public/src/main ] && find public/src/main -name '*.js')
MAIN_OUTPUT_FILES := $(subst public/src/main,../../public/js/main/$(MODULE_NAME),$(MAIN_SRC_FILES))
DOCKER_COMPOSE_FLAGS := -f $(MODULE_DIR)/docker-compose.yml
DOCKER_COMPOSE := cd ../../ && MODULE_DIR=$(MODULE_DIR) docker-compose -f docker-compose.yml ${DOCKER_COMPOSE_FLAGS}
@@ -29,30 +32,31 @@ test/acceptance/js/%.js: test/acceptance/coffee/%.coffee
@mkdir -p $(dir $@)
$(COFFEE) --compile --print $< > $@
../../test/unit_frontend/js/ide/$(MODULE_NAME)/%.js: test/unit_frontend/coffee/ide/%.coffee
../../test/unit_frontend/js/ide/$(MODULE_NAME)/%.js: test/unit_frontend/src/ide/%.js
@mkdir -p $(dir $@)
$(COFFEE) --compile --print $< > $@
$(BABEL) $< --out-file $@
../../public/js/ide/$(MODULE_NAME)/%.js: public/coffee/ide/%.coffee
../../public/js/ide/$(MODULE_NAME)/%.js: public/src/ide/%.js
@mkdir -p $(dir $@)
$(COFFEE) --compile --print $< > $@
$(BABEL) $< --out-file $@
../../public/js/main/$(MODULE_NAME)/%.js: public/coffee/main/%.coffee
../../public/js/main/$(MODULE_NAME)/%.js: public/src/main/%.js
@mkdir -p $(dir $@)
$(COFFEE) --compile --print $< > $@
$(BABEL) $< --out-file $@
index.js: index.coffee
$(COFFEE) --compile --print $< > $@
compile: $(APP_JS_FILES) $(IDE_JS_FILES) $(MAIN_JS_FILES) $(IDE_TEST_JS_FILES) index.js
compile: $(APP_JS_FILES) $(IDE_OUTPUT_FILES) $(MAIN_OUTPUT_FILES) $(IDE_TEST_OUTPUT_FILES) index.js
@echo > /dev/null
compile_full:
if [ -e app/coffee ]; then $(COFFEE) -o app/js -c app/coffee; fi
if [ -e test/unit/coffee ]; then $(COFFEE) -o test/unit/js -c test/unit/coffee; fi
if [ -e test/acceptance/coffee ]; then $(COFFEE) -o test/acceptance/js -c test/acceptance/coffee; fi
if [ -e public/coffee/ide/ ]; then $(COFFEE) -o ../../public/js/ide/$(MODULE_NAME) -c public/coffee/ide/; fi
if [ -e public/coffee/main/ ]; then $(COFFEE) -o ../../public/js/main/$(MODULE_NAME) -c public/coffee/main/; fi
if [ -e public/src/ide ]; then $(BABEL) public/src/ide --out-dir ../../public/js/ide/$(MODULE_NAME); fi
if [ -e public/src/main ]; then $(BABEL) public/src/main --out-dir ../../public/js/main/$(MODULE_NAME); fi
if [ -e test/unit_frontend/src/ide ]; then $(BABEL) test/unit_frontend/src/ide --out-dir ../../test/unit_frontend/js/ide/$(MODULE_NAME); fi
@$(MAKE) compile
test_acceptance: test_acceptance_start_service test_acceptance_run

57
services/web/decaffeinate.sh Executable file
View File

@@ -0,0 +1,57 @@
set -ex
npx bulk-decaffeinate convert --dir public/coffee
for module in modules/**/public/coffee; do
npx bulk-decaffeinate convert --dir $module
done
npx bulk-decaffeinate clean
git mv public/coffee public/src
for module in modules/**/public; do
if [ -e $module/coffee ]; then
git mv $module/coffee $module/src
fi
done
git commit -m "Rename public/coffee dir to public/src"
npx prettier-eslint 'public/src/**/*.js' --write
for module in modules/**/public/src; do
npx prettier-eslint "$module/**/*.js" --write
done
git add .
git commit -m "Prettier: convert public/src decaffeinated files to Prettier format"
npx bulk-decaffeinate convert --dir test/unit_frontend/coffee
for module in modules/**/test/unit_frontend/coffee; do
npx bulk-decaffeinate convert --dir $module
done
npx bulk-decaffeinate clean
git mv test/unit_frontend/coffee test/unit_frontend/src
for module in modules/**/test/unit_frontend; do
if [ -e $module/coffee ]; then
git mv $module/coffee $module/src
fi
done
git commit -m "Rename test/unit_frontend/coffee to test/unit_frontend/src"
npx prettier-eslint 'test/unit_frontend/src/**/*.js' --write
for module in modules/**/test/unit_frontend/src; do
npx prettier-eslint "$module/**/*.js" --write
done
git add .
git commit -m "Prettier: convert test/unit_frontend decaffeinated files to Prettier format"
echo "done"

View File

@@ -6,9 +6,9 @@
"verbose": true,
"exec": "make compile || exit 1",
"watch": [
"public/coffee/",
"public/src/",
"public/stylesheets/",
"modules/**/public/coffee/"
"modules/**/public/src/"
],
"ext": "coffee less"
"ext": "js less"
}

View File

@@ -459,6 +459,38 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.0.2.tgz",
"dev": true
},
"babel-cli": {
"version": "6.26.0",
"from": "babel-cli@latest",
"resolved": "https://registry.npmjs.org/babel-cli/-/babel-cli-6.26.0.tgz",
"dev": true,
"dependencies": {
"commander": {
"version": "2.19.0",
"from": "commander@>=2.11.0 <3.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.19.0.tgz",
"dev": true
},
"glob": {
"version": "7.1.3",
"from": "glob@>=7.1.2 <8.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
"dev": true
},
"minimatch": {
"version": "3.0.4",
"from": "minimatch@>=3.0.4 <4.0.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"dev": true
},
"source-map": {
"version": "0.5.7",
"from": "source-map@>=0.5.6 <0.6.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
"dev": true
}
}
},
"babel-code-frame": {
"version": "6.26.0",
"from": "babel-code-frame@>=6.26.0 <7.0.0",
@@ -817,6 +849,20 @@
"resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz",
"dev": true
},
"babel-polyfill": {
"version": "6.26.0",
"from": "babel-polyfill@>=6.13.0 <7.0.0",
"resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz",
"dev": true,
"dependencies": {
"regenerator-runtime": {
"version": "0.10.5",
"from": "regenerator-runtime@>=0.10.5 <0.11.0",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz",
"dev": true
}
}
},
"babel-preset-env": {
"version": "1.7.0",
"from": "babel-preset-env@>=1.6.1 <2.0.0",
@@ -1138,6 +1184,12 @@
}
}
},
"boolify": {
"version": "1.0.1",
"from": "boolify@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/boolify/-/boolify-1.0.1.tgz",
"dev": true
},
"boom": {
"version": "4.3.1",
"from": "boom@>=4.0.0 <5.0.0",
@@ -1737,6 +1789,12 @@
"from": "commander@2.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.1.0.tgz"
},
"common-tags": {
"version": "1.8.0",
"from": "common-tags@>=1.4.0 <2.0.0",
"resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz",
"dev": true
},
"commondir": {
"version": "1.0.1",
"from": "commondir@>=1.0.1 <2.0.0",
@@ -2473,6 +2531,12 @@
"resolved": "https://registry.npmjs.org/director/-/director-1.2.7.tgz",
"dev": true
},
"dlv": {
"version": "1.1.2",
"from": "dlv@>=1.1.0 <2.0.0",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.2.tgz",
"dev": true
},
"dns-equal": {
"version": "1.0.0",
"from": "dns-equal@>=1.0.0 <2.0.0",
@@ -2947,6 +3011,20 @@
}
}
},
"eslint-config-prettier": {
"version": "3.1.0",
"from": "eslint-config-prettier@latest",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-3.1.0.tgz",
"dev": true,
"dependencies": {
"get-stdin": {
"version": "6.0.0",
"from": "get-stdin@>=6.0.0 <7.0.0",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-6.0.0.tgz",
"dev": true
}
}
},
"eslint-config-standard": {
"version": "11.0.0",
"from": "eslint-config-standard@>=11.0.0 <12.0.0",
@@ -3101,6 +3179,12 @@
}
}
},
"eslint-plugin-prettier": {
"version": "2.7.0",
"from": "eslint-plugin-prettier@>=2.7.0 <3.0.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-2.7.0.tgz",
"dev": true
},
"eslint-plugin-promise": {
"version": "3.7.0",
"from": "eslint-plugin-promise@>=3.6.0 <4.0.0",
@@ -3441,6 +3525,12 @@
"from": "fast-deep-equal@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz"
},
"fast-diff": {
"version": "1.2.0",
"from": "fast-diff@>=1.1.1 <2.0.0",
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
"dev": true
},
"fast-json-stable-stringify": {
"version": "2.0.0",
"from": "fast-json-stable-stringify@>=2.0.0 <3.0.0",
@@ -3714,6 +3804,12 @@
"from": "fs-minipass@>=1.2.5 <2.0.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-1.2.5.tgz"
},
"fs-readdir-recursive": {
"version": "1.1.0",
"from": "fs-readdir-recursive@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz",
"dev": true
},
"fs.realpath": {
"version": "1.0.0",
"from": "fs.realpath@>=1.0.0 <2.0.0",
@@ -5414,6 +5510,12 @@
}
}
},
"jest-docblock": {
"version": "21.2.0",
"from": "jest-docblock@>=21.0.0 <22.0.0",
"resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-21.2.0.tgz",
"dev": true
},
"jmespath": {
"version": "0.15.0",
"from": "jmespath@0.15.0",
@@ -6191,6 +6293,18 @@
"from": "lodash.keys@>=4.2.0 <5.0.0",
"resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-4.2.0.tgz"
},
"lodash.memoize": {
"version": "4.1.2",
"from": "lodash.memoize@>=4.1.2 <5.0.0",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
"dev": true
},
"lodash.merge": {
"version": "4.6.1",
"from": "lodash.merge@>=4.6.0 <5.0.0",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz",
"dev": true
},
"lodash.mergewith": {
"version": "4.6.1",
"from": "lodash.mergewith@>=4.6.0 <5.0.0",
@@ -6232,6 +6346,12 @@
"from": "lodash.shuffle@>=4.2.0 <5.0.0",
"resolved": "https://registry.npmjs.org/lodash.shuffle/-/lodash.shuffle-4.2.0.tgz"
},
"lodash.unescape": {
"version": "4.0.1",
"from": "lodash.unescape@4.0.1",
"resolved": "https://registry.npmjs.org/lodash.unescape/-/lodash.unescape-4.0.1.tgz",
"dev": true
},
"lodash.values": {
"version": "4.3.0",
"from": "lodash.values@>=4.3.0 <5.0.0",
@@ -6764,6 +6884,12 @@
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.6.1.tgz",
"dev": true
},
"loglevel-colored-level-prefix": {
"version": "1.0.0",
"from": "loglevel-colored-level-prefix@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/loglevel-colored-level-prefix/-/loglevel-colored-level-prefix-1.0.0.tgz",
"dev": true
},
"lolex": {
"version": "1.3.2",
"from": "lolex@1.3.2",
@@ -6879,6 +7005,12 @@
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz",
"dev": true
},
"make-plural": {
"version": "4.3.0",
"from": "make-plural@>=4.1.1 <5.0.0",
"resolved": "https://registry.npmjs.org/make-plural/-/make-plural-4.3.0.tgz",
"dev": true
},
"mandrill-api": {
"version": "1.0.45",
"from": "mandrill-api@>=1.0.45 <2.0.0",
@@ -7040,6 +7172,32 @@
"from": "mersenne@>=0.0.3 <0.1.0",
"resolved": "https://registry.npmjs.org/mersenne/-/mersenne-0.0.4.tgz"
},
"messageformat": {
"version": "1.1.1",
"from": "messageformat@>=1.0.2 <2.0.0",
"resolved": "https://registry.npmjs.org/messageformat/-/messageformat-1.1.1.tgz",
"dev": true,
"dependencies": {
"glob": {
"version": "7.0.6",
"from": "glob@>=7.0.6 <7.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.0.6.tgz",
"dev": true
},
"minimatch": {
"version": "3.0.4",
"from": "minimatch@^3.0.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"dev": true
}
}
},
"messageformat-parser": {
"version": "1.1.0",
"from": "messageformat-parser@>=1.1.0 <2.0.0",
"resolved": "https://registry.npmjs.org/messageformat-parser/-/messageformat-parser-1.1.0.tgz",
"dev": true
},
"method-override": {
"version": "2.3.10",
"from": "method-override@>=2.3.3 <3.0.0",
@@ -8393,6 +8551,12 @@
"from": "osenv@>=0.1.4 <0.2.0",
"resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.4.tgz"
},
"output-file-sync": {
"version": "1.1.2",
"from": "output-file-sync@>=1.1.2 <2.0.0",
"resolved": "https://registry.npmjs.org/output-file-sync/-/output-file-sync-1.1.2.tgz",
"dev": true
},
"p-finally": {
"version": "1.0.0",
"from": "p-finally@>=1.0.0 <2.0.0",
@@ -8817,6 +8981,172 @@
"resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz",
"dev": true
},
"prettier": {
"version": "1.14.3",
"from": "prettier@>=1.7.0 <2.0.0",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.14.3.tgz",
"dev": true
},
"prettier-eslint": {
"version": "8.8.2",
"from": "prettier-eslint@>=8.5.0 <9.0.0",
"resolved": "https://registry.npmjs.org/prettier-eslint/-/prettier-eslint-8.8.2.tgz",
"dev": true,
"dependencies": {
"indent-string": {
"version": "3.2.0",
"from": "indent-string@^3.2.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz",
"dev": true
}
}
},
"prettier-eslint-cli": {
"version": "4.7.1",
"from": "prettier-eslint-cli@latest",
"resolved": "https://registry.npmjs.org/prettier-eslint-cli/-/prettier-eslint-cli-4.7.1.tgz",
"dev": true,
"dependencies": {
"ansi-regex": {
"version": "3.0.0",
"from": "ansi-regex@^3.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"dev": true
},
"ansi-styles": {
"version": "3.2.1",
"from": "ansi-styles@>=3.1.0 <4.0.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"dev": true
},
"camelcase": {
"version": "4.1.0",
"from": "camelcase@>=4.1.0 <5.0.0",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
"dev": true
},
"camelcase-keys": {
"version": "4.2.0",
"from": "camelcase-keys@>=4.1.0 <5.0.0",
"resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-4.2.0.tgz",
"dev": true
},
"chalk": {
"version": "2.3.0",
"from": "chalk@2.3.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.3.0.tgz",
"dev": true
},
"cliui": {
"version": "3.2.0",
"from": "cliui@>=3.2.0 <4.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz",
"dev": true,
"dependencies": {
"string-width": {
"version": "1.0.2",
"from": "string-width@>=1.0.1 <2.0.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
"dev": true
}
}
},
"get-stdin": {
"version": "5.0.1",
"from": "get-stdin@>=5.0.1 <6.0.0",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz",
"dev": true
},
"glob": {
"version": "7.1.3",
"from": "glob@>=7.1.1 <8.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz",
"dev": true
},
"has-flag": {
"version": "2.0.0",
"from": "has-flag@>=2.0.0 <3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz",
"dev": true
},
"indent-string": {
"version": "3.2.0",
"from": "indent-string@>=3.1.0 <4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-3.2.0.tgz",
"dev": true
},
"map-obj": {
"version": "2.0.0",
"from": "map-obj@>=2.0.0 <3.0.0",
"resolved": "https://registry.npmjs.org/map-obj/-/map-obj-2.0.0.tgz",
"dev": true
},
"minimatch": {
"version": "3.0.4",
"from": "minimatch@>=3.0.4 <4.0.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
"dev": true
},
"string-width": {
"version": "2.1.1",
"from": "string-width@>=2.0.0 <3.0.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz",
"dev": true,
"dependencies": {
"is-fullwidth-code-point": {
"version": "2.0.0",
"from": "is-fullwidth-code-point@>=2.0.0 <3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
"dev": true
},
"strip-ansi": {
"version": "4.0.0",
"from": "strip-ansi@>=4.0.0 <5.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz",
"dev": true
}
}
},
"supports-color": {
"version": "4.5.0",
"from": "supports-color@>=4.0.0 <5.0.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz",
"dev": true
},
"yargs": {
"version": "10.0.3",
"from": "yargs@10.0.3",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-10.0.3.tgz",
"dev": true
},
"yargs-parser": {
"version": "8.1.0",
"from": "yargs-parser@>=8.0.0 <9.0.0",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-8.1.0.tgz",
"dev": true
}
}
},
"pretty-format": {
"version": "23.6.0",
"from": "pretty-format@>=23.0.1 <24.0.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-23.6.0.tgz",
"dev": true,
"dependencies": {
"ansi-regex": {
"version": "3.0.0",
"from": "ansi-regex@>=3.0.0 <4.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz",
"dev": true
},
"ansi-styles": {
"version": "3.2.1",
"from": "ansi-styles@^3.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"dev": true
}
}
},
"private": {
"version": "0.1.8",
"from": "private@>=0.1.7 <0.2.0",
@@ -9097,6 +9427,12 @@
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-0.0.4.tgz",
"dev": true
},
"quick-lru": {
"version": "1.1.0",
"from": "quick-lru@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-1.1.0.tgz",
"dev": true
},
"ramda": {
"version": "0.25.0",
"from": "ramda@>=0.25.0 <0.26.0",
@@ -9459,6 +9795,12 @@
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz",
"dev": true
},
"require-relative": {
"version": "0.8.7",
"from": "require-relative@>=0.8.7 <0.9.0",
"resolved": "https://registry.npmjs.org/require-relative/-/require-relative-0.8.7.tgz",
"dev": true
},
"require-uncached": {
"version": "1.0.3",
"from": "require-uncached@>=1.0.3 <2.0.0",
@@ -9484,6 +9826,12 @@
"from": "requires-port@>=1.0.0 <2.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz"
},
"reserved-words": {
"version": "0.1.2",
"from": "reserved-words@>=0.1.2 <0.2.0",
"resolved": "https://registry.npmjs.org/reserved-words/-/reserved-words-0.1.2.tgz",
"dev": true
},
"resolve": {
"version": "1.5.0",
"from": "resolve@>=1.1.6 <2.0.0",
@@ -9601,6 +9949,12 @@
"resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz",
"dev": true
},
"rxjs": {
"version": "5.5.12",
"from": "rxjs@>=5.3.0 <6.0.0",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-5.5.12.tgz",
"dev": true
},
"safe-buffer": {
"version": "5.1.1",
"from": "safe-buffer@>=5.1.1 <5.2.0",
@@ -10546,6 +10900,12 @@
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz",
"dev": true
},
"symbol-observable": {
"version": "1.0.1",
"from": "symbol-observable@1.0.1",
"resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.0.1.tgz",
"dev": true
},
"table": {
"version": "4.0.2",
"from": "table@>=4.0.1 <5.0.0",
@@ -10925,6 +11285,26 @@
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"dev": true
},
"typescript": {
"version": "2.9.2",
"from": "typescript@>=2.5.1 <3.0.0",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-2.9.2.tgz",
"dev": true
},
"typescript-eslint-parser": {
"version": "16.0.1",
"from": "typescript-eslint-parser@>=16.0.0 <17.0.0",
"resolved": "https://registry.npmjs.org/typescript-eslint-parser/-/typescript-eslint-parser-16.0.1.tgz",
"dev": true,
"dependencies": {
"semver": {
"version": "5.5.0",
"from": "semver@5.5.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.5.0.tgz",
"dev": true
}
}
},
"ua-parser-js": {
"version": "0.7.17",
"from": "ua-parser-js@>=0.7.9 <0.8.0",
@@ -11204,6 +11584,12 @@
}
}
},
"user-home": {
"version": "1.1.1",
"from": "user-home@>=1.1.1 <2.0.0",
"resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz",
"dev": true
},
"useragent": {
"version": "2.2.1",
"from": "useragent@2.2.1",
@@ -11411,6 +11797,12 @@
}
}
},
"v8flags": {
"version": "2.1.1",
"from": "v8flags@>=2.1.1 <3.0.0",
"resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz",
"dev": true
},
"valid-url": {
"version": "1.0.9",
"from": "valid-url@>=1.0.9 <2.0.0",
@@ -11482,6 +11874,26 @@
"from": "void-elements@>=2.0.1 <3.0.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz"
},
"vue-eslint-parser": {
"version": "2.0.3",
"from": "vue-eslint-parser@>=2.0.2 <3.0.0",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-2.0.3.tgz",
"dev": true,
"dependencies": {
"debug": {
"version": "3.2.6",
"from": "debug@>=3.1.0 <4.0.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
"dev": true
},
"ms": {
"version": "2.1.1",
"from": "ms@>=2.1.1 <3.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
"dev": true
}
}
},
"watch": {
"version": "0.13.0",
"from": "watch@>=0.13.0 <0.14.0",

View File

@@ -23,7 +23,8 @@
"nodemon:frontend": "nodemon --config nodemon.frontend.json",
"webpack": "webpack-dev-server --config webpack.config.dev.js",
"webpack:production": "webpack --config webpack.config.prod.js",
"lint": "eslint -f unix ."
"lint": "eslint -f unix .",
"format": "prettier-eslint '**/*.js' --list-different"
},
"dependencies": {
"archiver": "0.9.0",
@@ -108,6 +109,7 @@
},
"devDependencies": {
"autoprefixer": "^6.6.1",
"babel-cli": "^6.26.0",
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.1",
@@ -119,6 +121,7 @@
"coffee-script": "^1.7.1",
"es6-promise": "^4.0.5",
"eslint": "^4.18.1",
"eslint-config-prettier": "^3.1.0",
"eslint-config-standard": "^11.0.0",
"eslint-config-standard-jsx": "^5.0.0",
"eslint-config-standard-react": "^6.0.0",
@@ -128,6 +131,7 @@
"eslint-plugin-jsx-a11y": "^6.1.2",
"eslint-plugin-mocha": "^5.2.0",
"eslint-plugin-node": "^6.0.0",
"eslint-plugin-prettier": "^2.7.0",
"eslint-plugin-promise": "^3.6.0",
"eslint-plugin-react": "^7.11.1",
"eslint-plugin-standard": "^3.0.1",
@@ -162,6 +166,7 @@
"karma-webpack": "^2.0.9",
"mkdirp": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
"nodemon": "^1.14.3",
"prettier-eslint-cli": "^4.7.1",
"requirejs": "^2.1.22",
"sandboxed-module": "0.2.0",
"sinon": "^1.17.0",

View File

@@ -1,71 +0,0 @@
define [
"base"
"libs/md5"
], (App) ->
oldKeys = [
"sl_abt_multi_currency_editor_eu-eu"
"sl_abt_multi_currency_eu-eu"
"sl_abt_multi_currency_editor_eu-usd"
"sl_abt_multi_currency_eu-usd"
"sl_abt_trial_len_14d"
"sl_abt_trial_len_7d"
"sl_abt_trial_len_30d"
"sl_utt"
"sl_utt_trial_len"
"sl_utt_multi_currency"
]
App.factory "abTestManager", ($http, ipCookie) ->
_.each oldKeys, (oldKey)->
ipCookie.remove(oldKey)
_buildCookieKey = (testName, bucket)->
key = "sl_abt_#{testName}_#{bucket}"
return key
_getTestCookie = (testName, bucket)->
cookieKey = _buildCookieKey(testName, bucket)
cookie = ipCookie(cookieKey)
return cookie
_persistCookieStep = (testName, bucket, newStep)->
cookieKey = _buildCookieKey(testName, bucket)
ipCookie(cookieKey, {step:newStep}, {expires:100, path:"/"})
ga('send', 'event', 'ab_tests', "#{testName}:#{bucket}", "step-#{newStep}")
_checkIfStepIsNext = (cookieStep, newStep)->
if !cookieStep? and newStep != 0
return false
else if newStep == 0
return true
else if (cookieStep+1) == newStep
return true
else
return false
_getUsersHash = (testName)->
sl_user_test_token = "sl_utt_#{testName}"
user_uuid = ipCookie(sl_user_test_token)
if !user_uuid?
user_uuid = Math.random()
ipCookie(sl_user_test_token, user_uuid, {expires:365, path:"/"})
hash = CryptoJS.MD5("#{user_uuid}:#{testName}")
return hash
processTestWithStep: processTestWithStep = (testName, bucket, newStep)->
currentCookieStep = _getTestCookie(testName, bucket)?.step
if _checkIfStepIsNext(currentCookieStep, newStep)
_persistCookieStep(testName, bucket, newStep)
getABTestBucket: getABTestBucket = (test_name, buckets) ->
hash = _getUsersHash(test_name)
bucketIndex = parseInt(hash.toString().slice(0,2), 16) % (buckets?.length or 2)
return buckets[bucketIndex]
App.controller "AbTestController", ($scope, abTestManager)->
testKeys = _.keys(window.ab)
_.each window.ab, (event)->
abTestManager.processTestWithStep event.testName, event.bucket, event.step

View File

@@ -1,61 +0,0 @@
define [
"libraries"
"modules/recursionHelper"
"modules/errorCatcher"
"modules/localStorage"
"utils/underscore"
], () ->
App = angular.module("SharelatexApp", [
"ui.bootstrap"
"autocomplete"
"RecursionHelper"
"ng-context-menu"
"underscore"
"ngSanitize"
"ipCookie"
"mvdSixpack"
"ErrorCatcher"
"localStorage"
"ngTagsInput"
"ui.select"
]).config ($qProvider, sixpackProvider, $httpProvider, uiSelectConfig) ->
$qProvider.errorOnUnhandledRejections(false)
uiSelectConfig.spinnerClass = 'fa fa-refresh ui-select-spin'
sixpackProvider.setOptions({
debug: false
baseUrl: window.sharelatex.sixpackDomain
client_id: window.user_id
})
MathJax?.Hub?.Config(
messageStyle: "none"
imageFont:null
"HTML-CSS":
availableFonts: ["TeX"]
# MathJax's automatic font scaling does not work well when we render math
# that isn't yet on the page, so we disable it and set a global font
# scale factor
scale: 110
matchFontHeight: false
TeX:
equationNumbers: { autoNumber: "AMS" }
useLabelIDs: false
skipStartupTypeset: true
tex2jax:
processEscapes: true
# Dollar delimiters are added by the mathjax directive
inlineMath: [ ["\\(","\\)"] ]
displayMath: [ ['$$','$$'], ["\\[","\\]"] ]
)
App.run ($templateCache) ->
# UI Select templates are hard-coded and use Glyphicon icons (which we don't import).
# The line below simply overrides the hard-coded template with our own, which is
# basically the same but using Font Awesome icons.
$templateCache.put "bootstrap/match.tpl.html", "<div class=\"ui-select-match\" ng-hide=\"$select.open && $select.searchEnabled\" ng-disabled=\"$select.disabled\" ng-class=\"{\'btn-default-focus\':$select.focus}\"><span tabindex=\"-1\" class=\"btn btn-default form-control ui-select-toggle\" aria-label=\"{{ $select.baseTitle }} activate\" ng-disabled=\"$select.disabled\" ng-click=\"$select.activate()\" style=\"outline: 0;\"><span ng-show=\"$select.isEmpty()\" class=\"ui-select-placeholder text-muted\">{{$select.placeholder}}</span> <span ng-hide=\"$select.isEmpty()\" class=\"ui-select-match-text pull-left\" ng-class=\"{\'ui-select-allow-clear\': $select.allowClear && !$select.isEmpty()}\" ng-transclude=\"\"></span> <i class=\"caret pull-right\" ng-click=\"$select.toggle($event)\"></i> <a ng-show=\"$select.allowClear && !$select.isEmpty() && ($select.disabled !== true)\" aria-label=\"{{ $select.baseTitle }} clear\" style=\"margin-right: 10px\" ng-click=\"$select.clear($event)\" class=\"btn btn-xs btn-link pull-right\"><i class=\"fa fa-times\" aria-hidden=\"true\"></i></a></span></div>"
sl_debugging = window.location?.search?.match(/debug=true/)?
window.sl_console =
log: (args...) -> console.log(args...) if sl_debugging
return App

View File

@@ -1,74 +0,0 @@
define [
"base"
], (App) ->
inputSuggestionsController = ($scope, $element, $attrs, Keys) ->
ctrl = @
ctrl.showHint = false
ctrl.hasFocus = false
ctrl.handleFocus = () ->
ctrl.hasFocus = true
ctrl.suggestion = null
ctrl.handleBlur = () ->
ctrl.showHint = false
ctrl.hasFocus = false
ctrl.suggestion = null
ctrl.onBlur()
ctrl.handleKeyDown = ($event) ->
if ($event.which == Keys.TAB or $event.which == Keys.ENTER) and ctrl.suggestion? and ctrl.suggestion != ""
$event.preventDefault()
ctrl.localNgModel += ctrl.suggestion
ctrl.suggestion = null
ctrl.showHint = false
$scope.$watch "$ctrl.localNgModel", (newVal, oldVal) ->
if ctrl.hasFocus and newVal != oldVal
ctrl.suggestion = null
ctrl.showHint = false
ctrl.getSuggestion({ userInput: newVal })
.then (suggestion) ->
if suggestion? and newVal == ctrl.localNgModel
ctrl.showHint = true
ctrl.suggestion = suggestion.replace newVal, ""
.catch () -> ctrl.suggestion = null
return
App.component "inputSuggestions", {
bindings:
localNgModel: "=ngModel"
localNgModelOptions: "=?ngModelOptions"
getSuggestion: "&"
onBlur: "&?"
inputId: "@?"
inputName: "@?"
inputPlaceholder: "@?"
inputType: "@?"
inputRequired: "=?"
controller: inputSuggestionsController
template: [
'<div class="input-suggestions">',
'<div class="form-control input-suggestions-shadow">',
'<span ng-bind="$ctrl.localNgModel"',
' class="input-suggestions-shadow-existing"',
' ng-show="$ctrl.showHint">',
'</span>',
'<span ng-bind="$ctrl.suggestion"',
' class="input-suggestions-shadow-suggested"',
' ng-show="$ctrl.showHint">',
'</span>',
'</div>',
'<input type="text"',
' class="form-control input-suggestions-main"',
' ng-focus="$ctrl.handleFocus()"',
' ng-keyDown="$ctrl.handleKeyDown($event)"',
' ng-blur="$ctrl.handleBlur()"',
' ng-model="$ctrl.localNgModel"',
' ng-model-options="$ctrl.localNgModelOptions"',
' ng-model-options="{ debounce: 50 }"',
' ng-attr-id="{{ ::$ctrl.inputId }}"',
' ng-attr-placeholder="{{ ::$ctrl.inputPlaceholder }}"',
' ng-attr-type="{{ ::$ctrl.inputType }}"',
' ng-attr-name="{{ ::$ctrl.inputName }}"',
' ng-required="::$ctrl.inputRequired">',
'</div>'
].join ""
}

View File

@@ -1,168 +0,0 @@
define [
"base"
"libs/passfield"
], (App) ->
App.directive "asyncForm", ($http, validateCaptcha) ->
return {
controller: ['$scope', ($scope) ->
@getEmail = () ->
return $scope.email
return this
]
link: (scope, element, attrs) ->
formName = attrs.asyncForm
scope[attrs.name].response = response = {}
scope[attrs.name].inflight = false
validateCaptchaIfEnabled = (callback = (response) ->) ->
if attrs.captcha?
validateCaptcha callback
else
callback()
submitRequest = (grecaptchaResponse) ->
formData = {}
for data in element.serializeArray()
formData[data.name] = data.value
if grecaptchaResponse?
formData['g-recaptcha-response'] = grecaptchaResponse
scope[attrs.name].inflight = true
# for asyncForm prevent automatic redirect to /login if
# authentication fails, we will handle it ourselves
$http
.post(element.attr('action'), formData, {disableAutoLoginRedirect: true})
.then (httpResponse) ->
{ data, status, headers, config } = httpResponse
scope[attrs.name].inflight = false
response.success = true
response.error = false
onSuccessHandler = scope[attrs.onSuccess]
if onSuccessHandler
onSuccessHandler(httpResponse)
return
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')
.catch (httpResponse) ->
{ data, status, headers, config } = httpResponse
scope[attrs.name].inflight = false
response.success = false
response.error = true
onErrorHandler = scope[attrs.onError]
if onErrorHandler
onErrorHandler(httpResponse)
return
if status == 400 # Bad Request
response.message =
text: "Invalid Request. Please correct the data and try again."
type: 'error'
else if status == 403 # Forbidden
response.message =
text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies."
type: "error"
else
response.message =
text: data.message?.text or data.message or "Something went wrong talking to the server :(. Please try again."
type: 'error'
ga('send', 'event', formName, 'failure', data.message)
submit = () ->
validateCaptchaIfEnabled (response) ->
submitRequest response
element.on "submit", (e) ->
e.preventDefault()
submit()
if attrs.autoSubmit
submit()
}
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"
}
}
App.directive 'complexPassword', ->
require: ['^asyncForm', 'ngModel']
link: (scope, element, attrs, ctrl) ->
PassField.Config.blackList = []
defaultPasswordOpts =
pattern: ""
length:
min: 6
max: 128
allowEmpty: false
allowAnyChars: false
isMasked: true
showToggle: false
showGenerate: false
showTip:false
showWarn:false
checkMode : PassField.CheckModes.STRICT
chars:
digits: "1234567890"
letters: "abcdefghijklmnopqrstuvwxyz"
letters_up: "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
symbols: "@#$%^&*()-_=+[]{};:<>/?!£€.,"
opts = _.defaults(window.passwordStrengthOptions || {}, defaultPasswordOpts)
if opts.length.min == 1
opts.acceptRate = 0 #this allows basically anything to be a valid password
passField = new PassField.Field("passwordField", opts);
[asyncFormCtrl, ngModelCtrl] = ctrl
ngModelCtrl.$parsers.unshift (modelValue) ->
isValid = passField.validatePass()
email = asyncFormCtrl.getEmail() || window.usersEmail
if !isValid
scope.complexPasswordErrorMessage = passField.getPassValidationMessage()
else if (email? and email != "")
startOfEmail = email?.split("@")?[0]
if modelValue.indexOf(email) != -1 or modelValue.indexOf(startOfEmail) != -1
isValid = false
scope.complexPasswordErrorMessage = "Password can not contain email address"
if opts.length.max? and modelValue.length == opts.length.max
isValid = false
scope.complexPasswordErrorMessage = "Maximum password length #{opts.length.max} reached"
if opts.length.min? and modelValue.length < opts.length.min
isValid = false
scope.complexPasswordErrorMessage = "Password too short, minimum #{opts.length.min}"
ngModelCtrl.$setValidity('complexPassword', isValid)
return modelValue

View File

@@ -1,43 +0,0 @@
define [
"base"
], (App) ->
App.directive "bookmarkableTabset", ($location, _) ->
restrict: "A"
require: "tabset"
link: (scope, el, attrs, tabset) ->
_makeActive = (hash) ->
if hash? and hash != ""
matchingTab = _.find tabset.tabs, (tab) ->
tab.bookmarkableTabId == hash
if matchingTab?
matchingTab.select()
el.children()[0].scrollIntoView({ behavior: "smooth" })
scope.$applyAsync () ->
# for page load
hash = $location.hash()
_makeActive(hash)
# for links within page to a tab
# this needs to be within applyAsync because there could be a link
# within a tab to another tab
linksToTabs = document.querySelectorAll(".link-to-tab");
_clickLinkToTab = (event) ->
_makeActive(event.currentTarget.getAttribute("href").replace('#', ''))
if linksToTabs
for link in linksToTabs
link.addEventListener("click", _clickLinkToTab)
App.directive "bookmarkableTab", ($location) ->
restrict: "A"
require: "tab"
link: (scope, el, attrs, tab) ->
tabScope = el.isolateScope()
tabId = attrs.bookmarkableTab
if tabScope? and tabId? and tabId != ""
tabScope.bookmarkableTabId = tabId
tabScope.$watch "active", (isActive, wasActive) ->
if isActive and !wasActive and $location.hash() != tabId
$location.hash tabId

View File

@@ -1,539 +0,0 @@
define [
"base"
], (App) ->
App.factory 'ccUtils', () ->
defaultFormat = /(\d{1,4})/g;
defaultInputFormat = /(?:^|\s)(\d{4})$/
cards = [
# Credit cards
{
type: 'visa'
patterns: [4]
format: defaultFormat
length: [13, 16]
cvcLength: [3]
luhn: true
}
{
type: 'mastercard'
patterns: [
51, 52, 53, 54, 55,
22, 23, 24, 25, 26, 27
]
format: defaultFormat
length: [16]
cvcLength: [3]
luhn: true
}
{
type: 'amex'
patterns: [34, 37]
format: /(\d{1,4})(\d{1,6})?(\d{1,5})?/
length: [15]
cvcLength: [3..4]
luhn: true
}
{
type: 'dinersclub'
patterns: [30, 36, 38, 39]
format: /(\d{1,4})(\d{1,6})?(\d{1,4})?/
length: [14]
cvcLength: [3]
luhn: true
}
{
type: 'discover'
patterns: [60, 64, 65, 622]
format: defaultFormat
length: [16]
cvcLength: [3]
luhn: true
}
{
type: 'unionpay'
patterns: [62, 88]
format: defaultFormat
length: [16..19]
cvcLength: [3]
luhn: false
}
{
type: 'jcb'
patterns: [35]
format: defaultFormat
length: [16]
cvcLength: [3]
luhn: true
}
]
cardFromNumber = (num) ->
num = (num + '').replace(/\D/g, "")
for card in cards
for pattern in card.patterns
p = pattern + ""
return card if num.substr(0, p.length) == p
cardFromType = (type) ->
return card for card in cards when card.type is type
cardType = (num) ->
return null unless num
cardFromNumber(num)?.type or null
formatCardNumber = (num) ->
num = num.replace(/\D/g, '')
card = cardFromNumber(num)
return num unless card
upperLength = card.length[card.length.length - 1]
num = num[0...upperLength]
if card.format.global
num.match(card.format)?.join(' ')
else
groups = card.format.exec(num)
return unless groups?
groups.shift()
groups = $.grep(groups, (n) -> n) # Filter empty groups
groups.join(' ')
formatExpiry = (expiry) ->
parts = expiry.match(/^\D*(\d{1,2})(\D+)?(\d{1,4})?/)
return '' unless parts
mon = parts[1] || ''
sep = parts[2] || ''
year = parts[3] || ''
if year.length > 0
sep = ' / '
else if sep is ' /'
mon = mon.substring(0, 1)
sep = ''
else if mon.length == 2 or sep.length > 0
sep = ' / '
else if mon.length == 1 and mon not in ['0', '1']
mon = "0#{mon}"
sep = ' / '
return mon + sep + year
parseExpiry = (value = "") ->
[month, year] = value.split(/[\s\/]+/, 2)
# Allow for year shortcut
if year?.length is 2 and /^\d+$/.test(year)
prefix = (new Date).getFullYear()
prefix = prefix.toString()[0..1]
year = prefix + year
month = parseInt(month, 10)
year = parseInt(year, 10)
return unless !isNaN(month) and !isNaN(year)
month: month, year: year
return {
fromNumber: cardFromNumber
fromType: cardFromType
cardType: cardType
formatExpiry: formatExpiry
formatCardNumber: formatCardNumber
defaultFormat: defaultFormat
defaultInputFormat: defaultInputFormat
parseExpiry: parseExpiry
}
App.factory 'ccFormat', (ccUtils, $filter) ->
hasTextSelected = ($target) ->
# If some text is selected
return true if $target.prop('selectionStart')? and
$target.prop('selectionStart') isnt $target.prop('selectionEnd')
# If some text is selected in IE
if document?.selection?.createRange?
return true if document.selection.createRange().text
false
safeVal = (value, $target) ->
try
cursor = $target.prop('selectionStart')
catch error
cursor = null
last = $target.val()
$target.val(value)
if cursor != null && $target.is(":focus")
cursor = value.length if cursor is last.length
# This hack looks for scenarios where we are changing an input's value such
# that "X| " is replaced with " |X" (where "|" is the cursor). In those
# scenarios, we want " X|".
#
# For example:
# 1. Input field has value "4444| "
# 2. User types "1"
# 3. Input field has value "44441| "
# 4. Reformatter changes it to "4444 |1"
# 5. By incrementing the cursor, we make it "4444 1|"
#
# This is awful, and ideally doesn't go here, but given the current design
# of the system there does not appear to be a better solution.
#
# Note that we can't just detect when the cursor-1 is " ", because that
# would incorrectly increment the cursor when backspacing, e.g. pressing
# backspace in this scenario: "4444 1|234 5".
if last != value
prevPair = last[cursor-1..cursor]
currPair = value[cursor-1..cursor]
digit = value[cursor]
cursor = cursor + 1 if /\d/.test(digit) and
prevPair == "#{digit} " and currPair == " #{digit}"
$target.prop('selectionStart', cursor)
$target.prop('selectionEnd', cursor)
# Replace Full-Width Chars
replaceFullWidthChars = (str = '') ->
fullWidth = '\uff10\uff11\uff12\uff13\uff14\uff15\uff16\uff17\uff18\uff19'
halfWidth = '0123456789'
value = ''
chars = str.split('')
# Avoid using reserved word `char`
for chr in chars
idx = fullWidth.indexOf(chr)
chr = halfWidth[idx] if idx > -1
value += chr
value
# Format Numeric
reFormatNumeric = (e) ->
$target = $(e.currentTarget)
setTimeout ->
value = $target.val()
value = replaceFullWidthChars(value)
value = value.replace(/\D/g, '')
safeVal(value, $target)
# Format Card Number
reFormatCardNumber = (e) ->
$target = $(e.currentTarget)
setTimeout ->
value = $target.val()
value = replaceFullWidthChars(value)
value = ccUtils.formatCardNumber(value)
safeVal(value, $target)
formatCardNumber = (e) ->
# Only format if input is a number
digit = String.fromCharCode(e.which)
return unless /^\d+$/.test(digit)
$target = $(e.currentTarget)
value = $target.val()
card = ccUtils.fromNumber(value + digit)
length = (value.replace(/\D/g, '') + digit).length
upperLength = 16
upperLength = card.length[card.length.length - 1] if card
return if length >= upperLength
# Return if focus isn't at the end of the text
return if $target.prop('selectionStart')? and
$target.prop('selectionStart') isnt value.length
if card && card.type is 'amex'
# AMEX cards are formatted differently
re = /^(\d{4}|\d{4}\s\d{6})$/
else
re = /(?:^|\s)(\d{4})$/
# If '4242' + 4
if re.test(value)
e.preventDefault()
setTimeout -> $target.val(value + ' ' + digit)
# If '424' + 2
else if re.test(value + digit)
e.preventDefault()
setTimeout -> $target.val(value + digit + ' ')
formatBackCardNumber = (e) ->
$target = $(e.currentTarget)
value = $target.val()
# Return unless backspacing
return unless e.which is 8
# Return if focus isn't at the end of the text
return if $target.prop('selectionStart')? and
$target.prop('selectionStart') isnt value.length
# Remove the digit + trailing space
if /\d\s$/.test(value)
e.preventDefault()
setTimeout -> $target.val(value.replace(/\d\s$/, ''))
# Remove digit if ends in space + digit
else if /\s\d?$/.test(value)
e.preventDefault()
setTimeout -> $target.val(value.replace(/\d$/, ''))
getFormattedCardNumber = (num) ->
num = num.replace(/\D/g, '')
card = ccUtils.fromNumber(num)
return num unless card
upperLength = card.length[card.length.length - 1]
num = num[0...upperLength]
if card.format.global
num.match(card.format)?.join(' ')
else
groups = card.format.exec(num)
return unless groups?
groups.shift()
groups = $.grep(groups, (n) -> n) # Filter empty groups
groups.join(' ')
parseCardNumber = (value) ->
if value? then value.replace(/\s/g, '') else value
# Format Expiry
reFormatExpiry = (e) ->
$target = $(e.currentTarget)
setTimeout ->
value = $target.val()
value = replaceFullWidthChars(value)
value = ccUtils.formatExpiry(value)
safeVal(value, $target)
formatExpiry = (e) ->
# Only format if input is a number
digit = String.fromCharCode(e.which)
return unless /^\d+$/.test(digit)
$target = $(e.currentTarget)
val = $target.val() + digit
if /^\d$/.test(val) and val not in ['0', '1']
e.preventDefault()
setTimeout -> $target.val("0#{val} / ")
else if /^\d\d$/.test(val)
e.preventDefault()
setTimeout ->
# Split for months where we have the second digit > 2 (past 12) and turn
# that into (m1)(m2) => 0(m1) / (m2)
m1 = parseInt(val[0], 10)
m2 = parseInt(val[1], 10)
if m2 > 2 and m1 != 0
$target.val("0#{m1} / #{m2}")
else
$target.val("#{val} / ")
formatForwardExpiry = (e) ->
digit = String.fromCharCode(e.which)
return unless /^\d+$/.test(digit)
$target = $(e.currentTarget)
val = $target.val()
if /^\d\d$/.test(val)
$target.val("#{val} / ")
formatForwardSlash = (e) ->
which = String.fromCharCode(e.which)
return unless which is '/' or which is ' '
$target = $(e.currentTarget)
val = $target.val()
if /^\d$/.test(val) and val isnt '0'
$target.val("0#{val} / ")
formatBackExpiry = (e) ->
$target = $(e.currentTarget)
value = $target.val()
# Return unless backspacing
return unless e.which is 8
# Return if focus isn't at the end of the text
return if $target.prop('selectionStart')? and
$target.prop('selectionStart') isnt value.length
# Remove the trailing space + last digit
if /\d\s\/\s$/.test(value)
e.preventDefault()
setTimeout -> $target.val(value.replace(/\d\s\/\s$/, ''))
parseExpiry = (value) ->
if value?
dateAsObj = ccUtils.parseExpiry(value)
return unless dateAsObj?
expiry = new Date dateAsObj.year, dateAsObj.month - 1
return $filter('date')(expiry, 'MM/yyyy')
# Format CVC
reFormatCVC = (e) ->
$target = $(e.currentTarget)
setTimeout ->
value = $target.val()
value = replaceFullWidthChars(value)
value = value.replace(/\D/g, '')[0...4]
safeVal(value, $target)
# Restrictions
restrictNumeric = (e) ->
# Key event is for a browser shortcut
return true if e.metaKey or e.ctrlKey
# If keycode is a space
return false if e.which is 32
# If keycode is a special char (WebKit)
return true if e.which is 0
# If char is a special char (Firefox)
return true if e.which < 33
input = String.fromCharCode(e.which)
# Char is a number or a space
!!/[\d\s]/.test(input)
restrictCardNumber = (e) ->
$target = $(e.currentTarget)
digit = String.fromCharCode(e.which)
return unless /^\d+$/.test(digit)
return if hasTextSelected($target)
# Restrict number of digits
value = ($target.val() + digit).replace(/\D/g, '')
card = ccUtils.fromNumber(value)
if card
value.length <= card.length[card.length.length - 1]
else
# All other cards are 16 digits long
value.length <= 16
restrictExpiry = (e) ->
$target = $(e.currentTarget)
digit = String.fromCharCode(e.which)
return unless /^\d+$/.test(digit)
return if hasTextSelected($target)
value = $target.val() + digit
value = value.replace(/\D/g, '')
return false if value.length > 6
restrictCVC = (e) ->
$target = $(e.currentTarget)
digit = String.fromCharCode(e.which)
return unless /^\d+$/.test(digit)
return if hasTextSelected($target)
val = $target.val() + digit
val.length <= 4
setCardType = (e) ->
$target = $(e.currentTarget)
val = $target.val()
cardType = ccUtils.cardType(val) or 'unknown'
unless $target.hasClass(cardType)
allTypes = (card.type for card in cards)
$target.removeClass('unknown')
$target.removeClass(allTypes.join(' '))
$target.addClass(cardType)
$target.toggleClass('identified', cardType isnt 'unknown')
$target.trigger('payment.cardType', cardType)
return {
hasTextSelected
replaceFullWidthChars
reFormatNumeric
reFormatCardNumber
formatCardNumber
formatBackCardNumber
getFormattedCardNumber
parseCardNumber
reFormatExpiry
formatExpiry
formatForwardExpiry
formatForwardSlash
formatBackExpiry
parseExpiry
reFormatCVC
restrictNumeric
restrictCardNumber
restrictExpiry
restrictCVC
setCardType
}
App.directive "ccFormatExpiry", (ccFormat) ->
restrict: "A"
require: "ngModel"
link: (scope, el, attrs, ngModel) ->
el.on "keypress", ccFormat.restrictNumeric
el.on "keypress", ccFormat.restrictExpiry
el.on "keypress", ccFormat.formatExpiry
el.on "keypress", ccFormat.formatForwardSlash
el.on "keypress", ccFormat.formatForwardExpiry
el.on "keydown", ccFormat.formatBackExpiry
el.on "change", ccFormat.reFormatExpiry
el.on "input", ccFormat.reFormatExpiry
el.on "paste", ccFormat.reFormatExpiry
ngModel.$parsers.push ccFormat.parseExpiry
ngModel.$formatters.push ccFormat.parseExpiry
App.directive "ccFormatCardNumber", (ccFormat) ->
restrict: "A"
require: "ngModel"
link: (scope, el, attrs, ngModel) ->
el.on "keypress", ccFormat.restrictNumeric
el.on "keypress", ccFormat.restrictCardNumber
el.on "keypress", ccFormat.formatCardNumber
el.on "keydown", ccFormat.formatBackCardNumber
el.on "paste", ccFormat.reFormatCardNumber
ngModel.$parsers.push ccFormat.parseCardNumber
ngModel.$formatters.push ccFormat.getFormattedCardNumber
App.directive "ccFormatSecCode", (ccFormat) ->
restrict: "A"
require: "ngModel"
link: (scope, el, attrs, ngModel) ->
el.on "keypress", ccFormat.restrictNumeric
el.on "keypress", ccFormat.restrictCVC
el.on "paste", ccFormat.reFormatCVC
el.on "change", ccFormat.reFormatCVC
el.on "input", ccFormat.reFormatCVC

View File

@@ -1,15 +0,0 @@
define [
"base"
], (App) ->
App.directive "equals", [->
return {
require: "ngModel"
link: (scope, elem, attrs, ctrl) ->
firstField = "#" + attrs.equals
elem.add(firstField).on "keyup", ->
scope.$apply ->
equal = elem.val() == $(firstField).val()
ctrl.$setValidity "areEqual", equal
}
]

View File

@@ -1,75 +0,0 @@
# For sending event data to metabase and google analytics
# ---
# by default,
# event not sent to MB.
# for MB, add event-tracking-mb='true'
# by default, event sent to MB via sendMB
# event not sent to GA.
# for GA, add event-tracking-ga attribute, where the value is the GA category
# Either GA or MB can use the attribute event-tracking-send-once='true' to
# send event just once
# MB will use the key and GA will use the action to determine if the event
# has been sent
# event-tracking-trigger attribute is required to send event
isInViewport = (element) ->
elTop = element.offset().top
elBtm = elTop + element.outerHeight()
viewportTop = $(window).scrollTop()
viewportBtm = viewportTop + $(window).height()
elBtm > viewportTop && elTop < viewportBtm
define [
'base'
], (App) ->
App.directive 'eventTracking', ['event_tracking', (event_tracking) ->
return {
scope: {
eventTracking: '@',
eventSegmentation: '=?'
}
link: (scope, element, attrs) ->
sendGA = attrs.eventTrackingGa || false
sendMB = attrs.eventTrackingMb || false
sendMBFunction = if attrs.eventTrackingSendOnce then 'sendMBOnce' else 'sendMB'
sendGAFunction = if attrs.eventTrackingSendOnce then 'sendGAOnce' else 'send'
segmentation = scope.eventSegmentation || {}
segmentation.page = window.location.pathname
sendEvent = (scrollEvent) ->
###
@param {boolean} scrollEvent Use to unbind scroll event
###
if sendMB
event_tracking[sendMBFunction] scope.eventTracking, segmentation
if sendGA
event_tracking[sendGAFunction] attrs.eventTrackingGa, attrs.eventTrackingAction || scope.eventTracking, attrs.eventTrackingLabel || ''
if scrollEvent
$(window).unbind('resize scroll')
if attrs.eventTrackingTrigger == 'load'
sendEvent()
else if attrs.eventTrackingTrigger == 'click'
element.on 'click', (e) ->
sendEvent()
else if attrs.eventTrackingTrigger == 'hover'
timer = null
timeoutAmt = 500
if attrs.eventHoverAmt
timeoutAmt = parseInt(attrs.eventHoverAmt, 10)
element.on 'mouseenter', () ->
timer = setTimeout((-> sendEvent()), timeoutAmt)
return
.on 'mouseleave', () ->
clearTimeout(timer)
else if attrs.eventTrackingTrigger == 'scroll'
if !event_tracking.eventInCache(scope.eventTracking)
$(window).on 'resize scroll', () ->
_.throttle(
if isInViewport(element) && !event_tracking.eventInCache(scope.eventTracking)
sendEvent(true)
, 500)
}
]

View File

@@ -1,17 +0,0 @@
define [
"base"
], (App) ->
App.directive "expandableTextArea", () ->
restrict: "A"
link: (scope, el) ->
resetHeight = () ->
curHeight = el.outerHeight()
fitHeight = el.prop("scrollHeight")
if fitHeight > curHeight and el.val() != ""
scope.$emit "expandable-text-area:resize"
el.css("height", fitHeight)
scope.$watch (() -> el.val()), resetHeight

View File

@@ -1,68 +0,0 @@
define [
"base"
"fineuploader"
], (App, qq) ->
App.directive 'fineUpload', ($timeout) ->
return {
scope: {
multiple: "="
endpoint: "@"
templateId: "@"
allowedExtensions: "="
onCompleteCallback: "="
onUploadCallback: "="
onValidateBatch: "="
onErrorCallback: "="
onSubmitCallback: "="
onCancelCallback: "="
autoUpload: "="
params: "="
control: "="
}
link: (scope, element, attrs) ->
multiple = scope.multiple or false
endpoint = scope.endpoint
templateId = scope.templateId
if scope.allowedExtensions?
validation =
allowedExtensions: scope.allowedExtensions
else
validation = {}
maxConnections = scope.maxConnections or 1
onComplete = scope.onCompleteCallback or () ->
onUpload = scope.onUploadCallback or () ->
onError = scope.onErrorCallback or () ->
onValidateBatch = scope.onValidateBatch or () ->
onSubmit = scope.onSubmitCallback or () ->
onCancel = scope.onCancelCallback or () ->
if !scope.autoUpload?
autoUpload = true
else
autoUpload = scope.autoUpload
params = scope.params or {}
params._csrf = window.csrfToken
q = new qq.FineUploader
element: element[0]
multiple: multiple
autoUpload: autoUpload
disabledCancelForFormUploads: true
validation: validation
maxConnections: maxConnections
request:
endpoint: endpoint
forceMultipart: true
params: params
paramsInBody: false
callbacks:
onComplete: onComplete
onUpload: onUpload
onValidateBatch: onValidateBatch
onError: onError
onSubmit: onSubmit
onCancel: onCancel
template: templateId
window.q = q
scope.control?.q = q
return q
}

View File

@@ -1,78 +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)
}
App.directive "focus", ($timeout) ->
scope:
trigger: "@focus"
link: (scope, element) ->
scope.$watch "trigger", (value) ->
if value is "true"
$timeout ->
element[0].focus()
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,21 +0,0 @@
define [
"base"
], (App) ->
App.directive "mathjax", () ->
return {
link: (scope, element, attrs) ->
if attrs.delimiter != 'no-single-dollar'
inlineMathConfig = MathJax?.Hub?.config?.tex2jax.inlineMath
alreadyConfigured = _.find inlineMathConfig, (c) ->
c[0] == '$' and c[1] == '$'
if !alreadyConfigured?
MathJax?.Hub?.Config(
tex2jax:
inlineMath: inlineMathConfig.concat([['$', '$']])
)
setTimeout () ->
MathJax?.Hub?.Queue(["Typeset", MathJax?.Hub, element.get(0)])
, 0
}

View File

@@ -1,11 +0,0 @@
define [
"base"
], (App) ->
App.directive "maxHeight", () ->
return {
restrict: "A"
link: (scope, element, attrs) ->
scope.$watch attrs.maxHeight, (value) ->
if value?
element.css("max-height": value)
}

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,12 +0,0 @@
define [
"base"
], (App) ->
App.directive "rightClick", () ->
return {
restrict: "A",
link: (scope, element, attrs) ->
element.bind "contextmenu", (e) ->
e.preventDefault()
e.stopPropagation()
scope.$eval(attrs.rightClick)
}

View File

@@ -1,36 +0,0 @@
define [
"base"
], (App) ->
App.directive "updateScrollBottomOn", ($timeout) ->
return {
restrict: "A"
link: (scope, element, attrs, ctrls) ->
# We keep the offset from the bottom fixed whenever the event fires
#
# ^ | ^
# | | | scrollTop
# | | v
# | |-----------
# | | ^
# | | |
# | | | clientHeight (viewable area)
# | | |
# | | |
# | | v
# | |-----------
# | | ^
# | | | scrollBottom
# v | v
# \
# scrollHeight
scrollBottom = 0
element.on "scroll", (e) ->
scrollBottom = element[0].scrollHeight - element[0].scrollTop - element[0].clientHeight
scope.$on attrs.updateScrollBottomOn, () ->
$timeout () ->
element.scrollTop(element[0].scrollHeight - element[0].clientHeight - scrollBottom)
, 0
}

View File

@@ -1,81 +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
]
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", () ->
return if element.prop('disabled')
ignoreChanges = true
scope.$apply () ->
scope.ngModel = true
ignoreChanges = false
scope.$on "select-all:deselect", () ->
return if element.prop('disabled')
ignoreChanges = true
scope.$apply () ->
scope.ngModel = false
ignoreChanges = false
scope.$on "select-all:row-clicked", () ->
return if element.prop('disabled')
ignoreChanges = true
scope.$apply () ->
scope.ngModel = !scope.ngModel
if !scope.ngModel
selectAllListController.clearSelectAllState()
ignoreChanges = false
}
App.directive "selectRow", () ->
return {
scope: true
link: (scope, element, attrs) ->
element.on "click", (e) ->
scope.$broadcast "select-all:row-clicked"
}

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,15 +0,0 @@
define [
"base"
], (App) ->
App.directive "videoPlayState", ($parse) ->
return {
restrict: "A",
link: (scope, element, attrs) ->
videoDOMEl = element[0]
scope.$watch (() -> $parse(attrs.videoPlayState)(scope)), (shouldPlay) ->
if shouldPlay
videoDOMEl.currentTime = 0
videoDOMEl.play()
else
videoDOMEl.pause()
}

View File

@@ -1,23 +0,0 @@
define [
"base"
"moment"
], (App, moment) ->
moment.locale "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()
App.filter "fromNowDate", () ->
(date) ->
moment(date).fromNow()

View File

@@ -1,25 +0,0 @@
define [
"base"
], (App) ->
DEF_MIN_LENGTH = 20
_decodeHTMLEntities = (str) ->
str.replace /&#(\d+);/g, (match, dec) ->
String.fromCharCode dec;
_getWrappedWordsString = (baseStr, wrapperElName, minLength) ->
minLength = minLength || DEF_MIN_LENGTH
words = baseStr.split ' '
wordsWrapped = for word in words
if _decodeHTMLEntities(word).length >= minLength
"<#{wrapperElName} class=\"break-word\">#{word}</#{wrapperElName}>"
else
word
outputStr = wordsWrapped.join ' '
App.filter "wrapLongWords", () ->
(input, minLength) ->
_getWrappedWordsString input, "span", minLength

View File

@@ -1,245 +0,0 @@
define [
"base"
"ide/file-tree/FileTreeManager"
"ide/connection/ConnectionManager"
"ide/editor/EditorManager"
"ide/online-users/OnlineUsersManager"
"ide/history/HistoryManager"
"ide/history/HistoryV2Manager"
"ide/permissions/PermissionsManager"
"ide/pdf/PdfManager"
"ide/binary-files/BinaryFilesManager"
"ide/references/ReferencesManager"
"ide/metadata/MetadataManager"
"ide/review-panel/ReviewPanelManager"
"ide/SafariScrollPatcher"
"ide/settings/index"
"ide/share/index"
"ide/chat/index"
"ide/clone/index"
"ide/hotkeys/index"
"ide/test-controls/index"
"ide/wordcount/index"
"ide/directives/layout"
"ide/directives/validFile"
"ide/services/ide"
"__IDE_CLIENTSIDE_INCLUDES__"
"analytics/AbTestingManager"
"directives/focus"
"directives/fineUpload"
"directives/scroll"
"directives/onEnter"
"directives/stopPropagation"
"directives/rightClick"
"directives/expandableTextArea"
"directives/videoPlayState"
"services/queued-http"
"services/validateCaptcha"
"services/wait-for"
"filters/formatDate"
"main/event"
"main/account-upgrade"
], (
App
FileTreeManager
ConnectionManager
EditorManager
OnlineUsersManager
HistoryManager
HistoryV2Manager
PermissionsManager
PdfManager
BinaryFilesManager
ReferencesManager
MetadataManager
ReviewPanelManager
SafariScrollPatcher
) ->
App.controller "IdeController", ($scope, $timeout, ide, localStorage, sixpack, event_tracking, metadata, $q) ->
# 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
error: null
}
$scope.ui = {
leftMenuShown: false
view: "editor"
chatOpen: false
pdfLayout: 'sideBySide'
pdfHidden: false
pdfWidth: 0
reviewPanelOpen: localStorage("ui.reviewPanelOpen.#{window.project_id}")
miniReviewPanelVisible: false
chatResizerSizeOpen: window.uiConfig.chatResizerSizeOpen
chatResizerSizeClosed: window.uiConfig.chatResizerSizeClosed
defaultFontFamily: window.uiConfig.defaultFontFamily
defaultLineHeight: window.uiConfig.defaultLineHeight
}
$scope.user = window.user
$scope.settings = window.userSettings
$scope.anonymous = window.anonymous
$scope.isTokenMember = window.isTokenMember
$scope.chat = {}
ide.toggleReviewPanel = $scope.toggleReviewPanel = () ->
if !$scope.project.features.trackChangesVisible
return
$scope.ui.reviewPanelOpen = !$scope.ui.reviewPanelOpen
event_tracking.sendMB "rp-toggle-panel", { value : $scope.ui.reviewPanelOpen }
$scope.$watch "ui.reviewPanelOpen", (value) ->
if value?
localStorage "ui.reviewPanelOpen.#{window.project_id}", value
$scope.$on "layout:pdf:resize", (_, layoutState) ->
$scope.ui.pdfHidden = layoutState.east.initClosed
$scope.ui.pdfWidth = layoutState.east.size
# Tracking code.
$scope.$watch "ui.view", (newView, oldView) ->
if newView? and newView != "editor" and newView != "pdf"
event_tracking.sendMBOnce "ide-open-view-#{ newView }-once"
$scope.$watch "ui.chatOpen", (isOpen) ->
event_tracking.sendMBOnce "ide-open-chat-once" if isOpen
$scope.$watch "ui.leftMenuShown", (isOpen) ->
event_tracking.sendMBOnce "ide-open-left-menu-once" if isOpen
$scope.trackHover = (feature) ->
event_tracking.sendMBOnce "ide-hover-#{feature}-once"
# End of tracking code.
window._ide = ide
ide.validFileRegex = '^[^\*\/]*$' # Don't allow * and /
ide.project_id = $scope.project_id = window.project_id
ide.$scope = $scope
ide.referencesSearchManager = new ReferencesManager(ide, $scope)
ide.connectionManager = new ConnectionManager(ide, $scope)
ide.fileTreeManager = new FileTreeManager(ide, $scope)
ide.editorManager = new EditorManager(ide, $scope, localStorage)
ide.onlineUsersManager = new OnlineUsersManager(ide, $scope)
if window.data.useV2History
ide.historyManager = new HistoryV2Manager(ide, $scope)
else
ide.historyManager = new HistoryManager(ide, $scope)
ide.pdfManager = new PdfManager(ide, $scope)
ide.permissionsManager = new PermissionsManager(ide, $scope)
ide.binaryFilesManager = new BinaryFilesManager(ide, $scope)
ide.metadataManager = new MetadataManager(ide, $scope, metadata)
inited = false
$scope.$on "project:joined", () ->
return if inited
inited = true
if $scope?.project?.deletedByExternalDataSource
ide.showGenericMessageModal("Project Renamed or Deleted", """
This project has either been renamed or deleted by an external data source such as Dropbox.
We don't want to delete your data on ShareLaTeX, so this project still contains your history and collaborators.
If the project has been renamed please look in your project list for a new project under the new name.
""")
$timeout(
() ->
if $scope.permissions.write
ide.metadataManager.loadProjectMetaFromServer()
_labelsInitialLoadDone = true
, 200
)
# Count the first 'doc:opened' as a sign that the ide is loaded
# and broadcast a message. This is a good event to listen for
# if you want to wait until the ide is fully loaded and initialized
_loaded = false
$scope.$on 'doc:opened', () ->
if _loaded
return
$scope.$broadcast('ide:loaded')
_loaded = true
$scope.$on 'cursor:editor:update', event_tracking.editingSessionHeartbeat
DARK_THEMES = [
"ambiance", "chaos", "clouds_midnight", "cobalt", "idle_fingers",
"merbivore", "merbivore_soft", "mono_industrial", "monokai",
"pastel_on_dark", "solarized_dark", "terminal", "tomorrow_night",
"tomorrow_night_blue", "tomorrow_night_bright", "tomorrow_night_eighties",
"twilight", "vibrant_ink"
]
$scope.darkTheme = false
$scope.$watch "settings.editorTheme", (theme) ->
if theme in DARK_THEMES
$scope.darkTheme = true
else
$scope.darkTheme = false
ide.localStorage = localStorage
ide.browserIsSafari = false
$scope.switchToFlatLayout = (view) ->
$scope.ui.pdfLayout = 'flat'
$scope.ui.view = view
ide.localStorage "pdf.layout", "flat"
$scope.switchToSideBySideLayout = (view) ->
$scope.ui.pdfLayout = 'sideBySide'
$scope.ui.view = view
localStorage "pdf.layout", "split"
if pdfLayout = localStorage("pdf.layout")
$scope.switchToSideBySideLayout() if pdfLayout == "split"
$scope.switchToFlatLayout() if pdfLayout == "flat"
else
$scope.switchToSideBySideLayout()
try
userAgent = navigator.userAgent
ide.browserIsSafari = (
userAgent &&
/.*Safari\/.*/.test(userAgent) &&
!/.*Chrome\/.*/.test(userAgent) &&
!/.*Chromium\/.*/.test(userAgent)
)
catch err
console.error err
if ide.browserIsSafari
ide.safariScrollPatcher = new SafariScrollPatcher($scope)
# Fix Chrome 61 and 62 text-shadow rendering
browserIsChrome61or62 = false
try
chromeVersion = parseFloat(navigator.userAgent.split(" Chrome/")[1]) || null;
browserIsChrome61or62 = (
chromeVersion?
)
if browserIsChrome61or62
document.styleSheets[0].insertRule(".ace_editor.ace_autocomplete .ace_completion-highlight { text-shadow: none !important; font-weight: bold; }", 1)
catch err
console.error err
# User can append ?ft=somefeature to url to activate a feature toggle
ide.featureToggle = location?.search?.match(/^\?ft=(\w+)$/)?[1]
ide.socket.on 'project:publicAccessLevel:changed', (data) =>
if data.newAccessLevel?
ide.$scope.project.publicAccesLevel = data.newAccessLevel
$scope.$digest()
angular.bootstrap(document.body, ["SharelatexApp"])

View File

@@ -1,78 +0,0 @@
define [
], () ->
class SafariScrollPatcher
constructor: ($scope) ->
@isOverAce = false # Flag to control if the pointer is over Ace.
@pdfDiv = null
@aceDiv = null
# Start listening to PDF wheel events when the pointer leaves the PDF region.
# P.S. This is the problem in a nutshell: although the pointer is elsewhere,
# wheel events keep being dispatched to the PDF.
@handlePdfDivMouseLeave = () =>
@pdfDiv.addEventListener "wheel", @dispatchToAce
# Stop listening to wheel events when the pointer enters the PDF region. If
# the pointer is over the PDF, native behaviour is adequate.
@handlePdfDivMouseEnter = () =>
@pdfDiv.removeEventListener "wheel", @dispatchToAce
# Set the "pointer over Ace" flag as false, when the mouse leaves its area.
@handleAceDivMouseLeave = () =>
@isOverAce = false
# Set the "pointer over Ace" flag as true, when the mouse enters its area.
@handleAceDivMouseEnter = () =>
@isOverAce = true
# Grab the elements (pdfDiv, aceDiv) and set the "hover" event listeners.
# If elements are already defined, clear existing event listeners and do
# the process again (grab elements, set listeners).
@setListeners = () =>
@isOverAce = false
# If elements aren't null, remove existing listeners.
if @pdfDiv?
@pdfDiv.removeEventListener @handlePdfDivMouseLeave
@pdfDiv.removeEventListener @handlePdfDivMouseEnter
if @aceDiv?
@aceDiv.removeEventListener @handleAceDivMouseLeave
@aceDiv.removeEventListener @handleAceDivMouseEnter
# Grab elements.
@pdfDiv = document.querySelector ".pdfjs-viewer" # Grab the PDF div.
@aceDiv = document.querySelector ".ace_content" # Also the editor.
# Set hover-related listeners.
@pdfDiv.addEventListener "mouseleave", @handlePdfDivMouseLeave
@pdfDiv.addEventListener "mouseenter", @handlePdfDivMouseEnter
@aceDiv.addEventListener "mouseleave", @handleAceDivMouseLeave
@aceDiv.addEventListener "mouseenter", @handleAceDivMouseEnter
# Handler for wheel events on the PDF.
# If the pointer is over Ace, grab the event, prevent default behaviour
# and dispatch it to Ace.
@dispatchToAce = (e) =>
if @isOverAce
# If this is logged, the problem just happened: the event arrived
# here (the PDF wheel handler), but it should've gone to Ace.
# Small timeout - if we dispatch immediately, an exception is thrown.
window.setTimeout(() =>
# Dispatch the exact same event to Ace (this will keep values
# values e.g. `wheelDelta` consistent with user interaction).
@aceDiv.dispatchEvent e
, 1)
# Avoid scrolling the PDF, as we assume this was intended to the
# editor.
e.preventDefault()
# "loaded" event is emitted from the pdfViewer controller $scope. This means
# that the previous PDF DOM element was destroyed and a new one is available,
# so we need to grab the elements and set the listeners again.
$scope.$on "loaded", () =>
@setListeners()

View File

@@ -1,21 +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) ->
@ide.fileTreeManager.selectEntity(file)
@$scope.ui.view = "file"
@$scope.openFile = null
@$scope.$apply()
window.setTimeout(
() =>
@$scope.openFile = file
@$scope.$apply()
, 0
, this
)

View File

@@ -1,125 +0,0 @@
define [
"base"
"moment"
], (App, moment) ->
App.controller "BinaryFileController", ["$scope", "$rootScope", "$http", "$timeout", "$element", "ide", "waitFor", ($scope, $rootScope, $http, $timeout, $element, ide, waitFor) ->
TWO_MEGABYTES = 2 * 1024 * 1024
textExtensions = ['bib', 'tex', 'txt', 'cls', 'sty']
imageExtentions = ['png', 'jpg', 'jpeg', 'gif']
previewableExtensions = ['eps', 'pdf']
extension = (file) ->
return file.name.split(".").pop()?.toLowerCase()
$scope.isTextFile = () =>
textExtensions.indexOf(extension($scope.openFile)) > -1
$scope.isImageFile = () =>
imageExtentions.indexOf(extension($scope.openFile)) > -1
$scope.isPreviewableFile = () =>
previewableExtensions.indexOf(extension($scope.openFile)) > -1
$scope.isUnpreviewableFile = () ->
!$scope.isTextFile() and
!$scope.isImageFile() and
!$scope.isPreviewableFile()
$scope.textPreview =
loading: false
shouldShowDots: false
error: false
data: null
$scope.refreshing = false
$scope.refreshError = null
MAX_URL_LENGTH = 60
FRONT_OF_URL_LENGTH = 35
FILLER = '...'
TAIL_OF_URL_LENGTH = MAX_URL_LENGTH - FRONT_OF_URL_LENGTH - FILLER.length
$scope.displayUrl = (url) ->
if !url?
return
if url.length > MAX_URL_LENGTH
front = url.slice(0, FRONT_OF_URL_LENGTH)
tail = url.slice(url.length - TAIL_OF_URL_LENGTH)
return front + FILLER + tail
else
return url
$scope.refreshFile = (file) ->
$scope.refreshing = true
$scope.refreshError = null
ide.fileTreeManager.refreshLinkedFile(file)
.then (response) ->
{ data } = response
{ new_file_id } = data
$timeout(
() ->
waitFor(
() ->
ide.fileTreeManager.findEntityById(new_file_id)
5000
)
.then (newFile) ->
ide.binaryFilesManager.openFile(newFile)
.catch (err) ->
console.warn(err)
, 0
)
$scope.refreshError = null
.catch (response) ->
$scope.refreshError = response.data
.finally () ->
$scope.refreshing = false
# Callback fired when the `img` tag fails to load,
# `failedLoad` used to show the "No Preview" message
$scope.failedLoad = false
window.sl_binaryFilePreviewError = () =>
$scope.failedLoad = true
$scope.$apply()
# Callback fired when the `img` tag is done loading,
# `imgLoaded` is used to show the spinner gif while loading
$scope.imgLoaded = false
window.sl_binaryFilePreviewLoaded = () =>
$scope.imgLoaded = true
$scope.$apply()
do loadTextFileFilePreview = () ->
return unless $scope.isTextFile()
url = "/project/#{project_id}/file/#{$scope.openFile.id}?range=0-#{TWO_MEGABYTES}"
$scope.textPreview.data = null
$scope.textPreview.loading = true
$scope.textPreview.shouldShowDots = false
$scope.$apply()
$http({
url: url,
method: 'GET',
transformResponse: null # Don't parse JSON
})
.then (response) ->
{ data } = response
$scope.textPreview.error = false
# show dots when payload is closs to cutoff
if data.length >= (TWO_MEGABYTES - 200)
$scope.textPreview.shouldShowDots = true
# remove last partial line
data = data?.replace?(/\n.*$/, '')
$scope.textPreview.data = data
$timeout(setHeight, 0)
.catch (error) ->
console.error(error)
$scope.textPreview.error = true
$scope.textPreview.loading = false
setHeight = () ->
$preview = $element.find('.text-preview .scroll-container')
$footer = $element.find('.binary-file-footer')
maxHeight = $element.height() - $footer.height() - 14 # borders + margin
$preview.css('max-height': maxHeight)
# Don't show the preview until we've set the height, otherwise we jump around
$scope.textPreview.loading = false
]

View File

@@ -1,46 +0,0 @@
define [
"base"
], (App) ->
App.controller "ChatButtonController", ($scope, ide) ->
$scope.toggleChat = () ->
$scope.ui.chatOpen = !$scope.ui.chatOpen
$scope.resetUnreadMessages()
$scope.unreadMessages = 0
$scope.resetUnreadMessages = () ->
$scope.unreadMessages = 0
$scope.$on "chat:resetUnreadMessages", (e) ->
$scope.resetUnreadMessages()
$scope.$on "chat:newMessage", (e, message) ->
if message?
if message?.user?.id != ide.$scope.user.id
if !$scope.ui.chatOpen
$scope.unreadMessages += 1
flashTitle()
focussed = true
newMessageNotificationTimeout = null
originalTitle = null
$(window).on "focus", () ->
clearNewMessageNotification()
focussed = true
$(window).on "blur", () ->
focussed = false
flashTitle = () ->
if !focussed and !newMessageNotificationTimeout?
originalTitle ||= window.document.title
do changeTitle = () =>
if window.document.title == originalTitle
window.document.title = "New Message"
else
window.document.title = originalTitle
newMessageNotificationTimeout = setTimeout changeTitle, 800
clearNewMessageNotification = () ->
clearTimeout newMessageNotificationTimeout
newMessageNotificationTimeout = null
if originalTitle?
window.document.title = originalTitle

View File

@@ -1,33 +0,0 @@
define [
"base"
"ide/chat/services/chatMessages"
], (App) ->
App.controller "ChatController", ($scope, chatMessages, ide, $location) ->
$scope.chat = chatMessages.state
$scope.$watch "chat.messages", (messages) ->
if messages?
$scope.$emit "updateScrollPosition"
, true # Deep watch
$scope.$on "layout:chat:resize", () ->
$scope.$emit "updateScrollPosition"
$scope.$watch "chat.newMessage", (message) ->
if message?
ide.$scope.$broadcast "chat:newMessage", message
$scope.resetUnreadMessages = () ->
ide.$scope.$broadcast "chat:resetUnreadMessages"
$scope.sendMessage = ->
message = $scope.newMessageContent
$scope.newMessageContent = ""
chatMessages
.sendMessage message
$scope.loadMoreMessages = ->
chatMessages.loadMoreMessages()

View File

@@ -1,24 +0,0 @@
define [
"base"
"ide/colors/ColorManager"
], (App, ColorManager) ->
App.controller "ChatMessageController", ["$scope", "ide", ($scope, ide) ->
hslColorConfigs =
borderSaturation: window.uiConfig?.chatMessageBorderSaturation or "70%"
borderLightness : window.uiConfig?.chatMessageBorderLightness or "70%"
bgSaturation : window.uiConfig?.chatMessageBgSaturation or "60%"
bgLightness : window.uiConfig?.chatMessageBgLightness or "97%"
hue = (user) ->
if !user?
return 0
else
return ColorManager.getHueForUserId(user.id)
$scope.getMessageStyle = (user) ->
"border-color" : "hsl(#{ hue(user) }, #{ hslColorConfigs.borderSaturation }, #{ hslColorConfigs.borderLightness })"
"background-color" : "hsl(#{ hue(user) }, #{ hslColorConfigs.bgSaturation }, #{ hslColorConfigs.bgLightness })"
$scope.getArrowStyle = (user) ->
"border-color" : "hsl(#{ hue(user) }, #{ hslColorConfigs.borderSaturation }, #{ hslColorConfigs.borderLightness })"
]

View File

@@ -1,7 +0,0 @@
define [
"ide/chat/controllers/ChatButtonController"
"ide/chat/controllers/ChatController"
"ide/chat/controllers/ChatMessageController"
"directives/mathjax"
"filters/wrapLongWords"
], () ->

View File

@@ -1,108 +0,0 @@
define [
"base"
"libs/md5"
], (App) ->
App.factory "chatMessages", ($http, ide) ->
MESSAGES_URL = "/project/#{ide.project_id}/messages"
MESSAGE_LIMIT = 50
CONNECTED_USER_URL = "/project/#{ide.project_id}/connected_users"
chat = {
state:
messages: []
loading: false
atEnd: false
errored:false
nextBeforeTimestamp: null
newMessage: null
}
justSent = false
ide.socket.on "new-chat-message", (message) =>
if message?.user?.id == ide.$scope.user.id and justSent
# Nothing to do
else
ide.$scope.$apply () ->
appendMessage(message)
justSent = false
chat.sendMessage = (message) ->
body =
content: message
_csrf : window.csrfToken
justSent = true
appendMessage({
user: ide.$scope.user
content: message
timestamp: Date.now()
})
return $http.post(MESSAGES_URL, body)
chat.loadMoreMessages = () ->
return if chat.state.atEnd
return if chat.state.errored
url = MESSAGES_URL + "?limit=#{MESSAGE_LIMIT}"
if chat.state.nextBeforeTimestamp?
url += "&before=#{chat.state.nextBeforeTimestamp}"
chat.state.loading = true
return $http
.get(url)
.then (response) ->
messages = response.data ? []
chat.state.loading = false
if messages.length < MESSAGE_LIMIT
chat.state.atEnd = true
if !messages.reverse?
Raven?.captureException(new Error("messages has no reverse property #{typeof(messages)}"))
if typeof messages.reverse isnt 'function'
Raven?.captureException(new Error("messages.reverse not a function #{typeof(messages.reverse)} #{typeof(messages)}"))
chat.state.errored = true
else
messages.reverse()
prependMessages(messages)
chat.state.nextBeforeTimestamp = chat.state.messages[0]?.timestamp
TIMESTAMP_GROUP_SIZE = 5 * 60 * 1000 # 5 minutes
prependMessage = (message) ->
firstMessage = chat.state.messages[0]
shouldGroup = firstMessage? and
firstMessage.user.id == message?.user?.id and
firstMessage.timestamp - message.timestamp < TIMESTAMP_GROUP_SIZE
if shouldGroup
firstMessage.timestamp = message.timestamp
firstMessage.contents.unshift message.content
else
chat.state.messages.unshift({
user: formatUser(message.user)
timestamp: message.timestamp
contents: [message.content]
})
prependMessages = (messages) ->
for message in messages.slice(0).reverse()
prependMessage(message)
appendMessage = (message) ->
chat.state.newMessage = message
lastMessage = chat.state.messages[chat.state.messages.length - 1]
shouldGroup = lastMessage? and
lastMessage.user.id == message?.user?.id and
message.timestamp - lastMessage.timestamp < TIMESTAMP_GROUP_SIZE
if shouldGroup
lastMessage.timestamp = message.timestamp
lastMessage.contents.push message.content
else
chat.state.messages.push({
user: formatUser(message.user)
timestamp: message.timestamp
contents: [message.content]
})
formatUser = (user) ->
hash = CryptoJS.MD5(user.email.toLowerCase())
user.gravatar_url = "//www.gravatar.com/avatar/#{hash}"
return user
return chat

View File

@@ -1,9 +0,0 @@
define [
"base"
], (App) ->
App.controller 'CloneProjectController', ($scope, $modal) ->
$scope.openCloneProjectModal = () ->
$modal.open {
templateUrl: "cloneProjectModalTemplate"
controller: "CloneProjectModalController"
}

View File

@@ -1,38 +0,0 @@
define [
"base"
], (App) ->
App.controller 'CloneProjectModalController', ($scope, $modalInstance, $timeout, $http, ide) ->
$scope.inputs =
projectName: ide.$scope.project.name + " (Copy)"
$scope.state =
inflight: false
error: false
$modalInstance.opened.then () ->
$timeout () ->
$scope.$broadcast "open"
, 200
cloneProject = (cloneName) ->
$http.post("/project/#{ide.$scope.project._id}/clone", {
_csrf: window.csrfToken
projectName: cloneName
})
$scope.clone = () ->
$scope.state.inflight = true
$scope.state.error = false
cloneProject($scope.inputs.projectName)
.then (response) ->
{ data } = response
window.location = "/project/#{data.project_id}"
.catch (response) ->
{ data, status } = response
$scope.state.inflight = false
if status == 400
$scope.state.error = { message: data }
else
$scope.state.error = true
$scope.cancel = () ->
$modalInstance.dismiss('cancel')

View File

@@ -1,4 +0,0 @@
define [
"ide/clone/controllers/CloneProjectController"
"ide/clone/controllers/CloneProjectModalController"
], () ->

View File

@@ -1,44 +0,0 @@
define [], () ->
ColorManager =
getColorScheme: (hue, element) ->
if @isDarkTheme(element)
return {
cursor: "hsl(#{hue}, 70%, 50%)"
labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
highlightBackgroundColor: "hsl(#{hue}, 100%, 28%);"
strikeThroughBackgroundColor: "hsl(#{hue}, 100%, 20%);"
strikeThroughForegroundColor: "hsl(#{hue}, 100%, 60%);"
}
else
return {
cursor: "hsl(#{hue}, 70%, 50%)"
labelBackgroundColor: "hsl(#{hue}, 70%, 50%)"
highlightBackgroundColor: "hsl(#{hue}, 70%, 85%);"
strikeThroughBackgroundColor: "hsl(#{hue}, 70%, 95%);"
strikeThroughForegroundColor: "hsl(#{hue}, 70%, 40%);"
}
isDarkTheme: (element) ->
rgb = element.find(".ace_editor").css("background-color");
[m, r, g, b] = rgb.match(/rgb\(([0-9]+), ([0-9]+), ([0-9]+)\)/)
r = parseInt(r, 10)
g = parseInt(g, 10)
b = parseInt(b, 10)
return r + g + b < 3 * 128
OWN_HUE: 200 # We will always appear as this color to ourselves
ANONYMOUS_HUE: 100
getHueForUserId: (user_id) ->
if !user_id? or user_id == "anonymous-user"
return @ANONYMOUS_HUE
if window.user.id == user_id
return @OWN_HUE
hash = CryptoJS.MD5(user_id)
hue = parseInt(hash.toString().slice(0,8), 16) % 320
# Avoid 20 degrees either side of the personal hue
if hue > @OWNER_HUE - 20
hue = hue + 40
return hue

View File

@@ -1,281 +0,0 @@
define [], () ->
ONEHOUR = 1000 * 60 * 60
class ConnectionManager
disconnectAfterMs: ONEHOUR * 24
lastUserAction : new Date()
constructor: (@ide, @$scope) ->
if !io?
console.error "Socket.io javascript not loaded. Please check that the real-time service is running and accessible."
@ide.socket =
on: () ->
@$scope.$apply () =>
@$scope.state.error = "Could not connect to websocket server :("
return
setInterval(() =>
@disconnectIfInactive()
, ONEHOUR)
# trigger a reconnect immediately if network comes back online
window.addEventListener 'online', =>
sl_console.log "[online] browser notified online"
if !@connected
@tryReconnectWithRateLimit({force:true})
@userIsLeavingPage = false
window.addEventListener 'beforeunload', =>
@userIsLeavingPage = true
return # Don't return true or it will show a pop up
@connected = false
@userIsInactive = false
@gracefullyReconnecting = false
@$scope.connection =
reconnecting: false
# If we need to force everyone to reload the editor
forced_disconnect: false
inactive_disconnect: false
@$scope.tryReconnectNow = () =>
# user manually requested reconnection via "Try now" button
@tryReconnectWithRateLimit({force:true})
@$scope.$on 'cursor:editor:update', () =>
@lastUserAction = new Date() # time of last edit
if !@connected
# user is editing, try to reconnect
@tryReconnectWithRateLimit()
document.querySelector('body').addEventListener 'click', (e) =>
if !@connected and e.target.id != 'try-reconnect-now-button'
# user is editing, try to reconnect
@tryReconnectWithRateLimit()
@ide.socket = io.connect null,
reconnect: false
'connect timeout': 30 * 1000
"force new connection": true
# The "connect" event is the first event we get back. It only
# indicates that the websocket is connected, we still need to
# pass authentication to join a project.
@ide.socket.on "connect", () =>
sl_console.log "[socket.io connect] Connected"
# The next event we should get is an authentication response
# from the server, either "connectionAccepted" or
# "connectionRejected".
@ide.socket.on 'connectionAccepted', (message) =>
sl_console.log "[socket.io connectionAccepted] allowed to connect"
@connected = true
@gracefullyReconnecting = false
@ide.pushEvent("connected")
@$scope.$apply () =>
@$scope.connection.reconnecting = false
@$scope.connection.inactive_disconnect = false
if @$scope.state.loading
@$scope.state.load_progress = 70
# we have passed authentication so we can now join the project
setTimeout(() =>
@joinProject()
, 100)
@ide.socket.on 'connectionRejected', (err) =>
sl_console.log "[socket.io connectionRejected] session not valid or other connection error"
# we have failed authentication, usually due to an invalid session cookie
return @reportConnectionError(err)
# Alternatively the attempt to connect can fail completely, so
# we never get into the "connect" state.
@ide.socket.on "connect_failed", () =>
@connected = false
@$scope.$apply () =>
@$scope.state.error = "Unable to connect, please view the <u><a href='/learn/Kb/Connection_problems'>connection problems guide</a></u> to fix the issue."
# We can get a "disconnect" event at any point after the
# "connect" event.
@ide.socket.on 'disconnect', () =>
sl_console.log "[socket.io disconnect] Disconnected"
@connected = false
@ide.pushEvent("disconnected")
@$scope.$apply () =>
@$scope.connection.reconnecting = false
if !@$scope.connection.forced_disconnect and !@userIsInactive and !@gracefullyReconnecting
@startAutoReconnectCountdown()
# Site administrators can send the forceDisconnect event to all users
@ide.socket.on 'forceDisconnect', (message) =>
@$scope.$apply () =>
@$scope.permissions.write = false
@$scope.connection.forced_disconnect = true
@ide.socket.disconnect()
@ide.showGenericMessageModal("Please Refresh", """
We're performing maintenance on ShareLaTeX and you need to refresh the editor.
Sorry for any inconvenience.
The editor will refresh in automatically in 10 seconds.
""")
setTimeout () ->
location.reload()
, 10 * 1000
@ide.socket.on "reconnectGracefully", () =>
sl_console.log "Reconnect gracefully"
@reconnectGracefully()
# Error reporting, which can reload the page if appropriate
reportConnectionError: (err) ->
sl_console.log "[socket.io] reporting connection error"
if err?.message == "not authorized" or err?.message == "invalid session"
window.location = "/login?redir=#{encodeURI(window.location.pathname)}"
else
@ide.socket.disconnect()
@ide.showGenericMessageModal("Something went wrong connecting", """
Something went wrong connecting to your project. Please refresh if this continues to happen.
""")
joinProject: () ->
sl_console.log "[joinProject] joining..."
# Note: if the "joinProject" message doesn't reach the server
# (e.g. if we are in a disconnected state at this point) the
# callback will never be executed
data = {
project_id: @ide.project_id
}
if window.anonymousAccessToken
data.anonymousAccessToken = window.anonymousAccessToken
@ide.socket.emit 'joinProject', data, (err, project, permissionsLevel, protocolVersion) =>
if err? or !project?
return @reportConnectionError(err)
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.$broadcast "project:joined"
reconnectImmediately: () ->
@disconnect()
@tryReconnect()
disconnect: () ->
sl_console.log "[socket.io] disconnecting client"
@ide.socket.disconnect()
startAutoReconnectCountdown: () ->
sl_console.log "[ConnectionManager] starting autoreconnect countdown"
twoMinutes = 2 * 60 * 1000
if @lastUserAction? and new Date() - @lastUserAction > twoMinutes
# between 1 minute and 3 minutes
countdown = 60 + Math.floor(Math.random() * 120)
else
countdown = 3 + Math.floor(Math.random() * 7)
if @userIsLeavingPage #user will have pressed refresh or back etc
return
@$scope.$apply () =>
@$scope.connection.reconnecting = false
@$scope.connection.reconnection_countdown = countdown
setTimeout(=>
if !@connected
@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
, 200)
cancelReconnect: () ->
# clear timeout and set to null so we know there is no countdown running
if @timeoutId?
sl_console.log "[ConnectionManager] cancelling existing reconnect timer"
clearTimeout @timeoutId
@timeoutId = null
decreaseCountdown: () ->
@timeoutId = null
return if !@$scope.connection.reconnection_countdown?
sl_console.log "[ConnectionManager] decreasing countdown", @$scope.connection.reconnection_countdown
@$scope.$apply () =>
@$scope.connection.reconnection_countdown--
if @$scope.connection.reconnection_countdown <= 0
@$scope.$apply () =>
@tryReconnect()
else
@timeoutId = setTimeout (=> @decreaseCountdown()), 1000
tryReconnect: () ->
sl_console.log "[ConnectionManager] tryReconnect"
@cancelReconnect()
delete @$scope.connection.reconnection_countdown
return if @connected
@$scope.connection.reconnecting = true
# use socket.io connect() here to make a single attempt, the
# reconnect() method makes multiple attempts
@ide.socket.socket.connect()
# record the time of the last attempt to connect
@lastConnectionAttempt = new Date()
setTimeout (=> @startAutoReconnectCountdown() if !@connected), 2000
MIN_RETRY_INTERVAL: 1000 # ms, rate limit on reconnects for user clicking "try now"
BACKGROUND_RETRY_INTERVAL : 5 * 1000 # ms, rate limit on reconnects for other user activity (e.g. cursor moves)
tryReconnectWithRateLimit: (options) ->
# bail out if the reconnect is already in progress
return if @$scope.connection?.reconnecting
# bail out if we are going to reconnect soon anyway
reconnectingSoon = @$scope.connection?.reconnection_countdown? and @$scope.connection.reconnection_countdown <= 5
clickedTryNow = options?.force # user requested reconnection
return if reconnectingSoon and not clickedTryNow
# bail out if we tried reconnecting recently
allowedInterval = if clickedTryNow then @MIN_RETRY_INTERVAL else @BACKGROUND_RETRY_INTERVAL
return if @lastConnectionAttempt? and new Date() - @lastConnectionAttempt < allowedInterval
@tryReconnect()
disconnectIfInactive: ()->
@userIsInactive = (new Date() - @lastUserAction) > @disconnectAfterMs
if @userIsInactive and @connected
@disconnect()
@$scope.$apply () =>
@$scope.connection.inactive_disconnect = true
RECONNECT_GRACEFULLY_RETRY_INTERVAL: 5000 # ms
MAX_RECONNECT_GRACEFULLY_INTERVAL: 60 * 5 * 1000 # 5 minutes
reconnectGracefully: () ->
@reconnectGracefullyStarted ?= new Date()
userIsInactive = (new Date() - @lastUserAction) > @RECONNECT_GRACEFULLY_RETRY_INTERVAL
maxIntervalReached = (new Date() - @reconnectGracefullyStarted) > @MAX_RECONNECT_GRACEFULLY_INTERVAL
if userIsInactive or maxIntervalReached
sl_console.log "[reconnectGracefully] User didn't do anything for last 5 seconds, reconnecting"
@_reconnectGracefullyNow()
else
sl_console.log "[reconnectGracefully] User is working, will try again in 5 seconds"
setTimeout () =>
@reconnectGracefully()
, @RECONNECT_GRACEFULLY_RETRY_INTERVAL
_reconnectGracefullyNow: () ->
@gracefullyReconnecting = true
@reconnectGracefullyStarted = null
# Clear cookie so we don't go to the same backend server
$.cookie("SERVERID", "", { expires: -1, path: "/" })
@reconnectImmediately()

View File

@@ -1,76 +0,0 @@
# This file is shared between the frontend and server code of web, so that
# filename validation is the same in both implementations.
# Both copies must be kept in sync:
# app/coffee/Features/Project/SafePath.coffee
# public/coffee/ide/directives/SafePath.coffee
load = () ->
BADCHAR_RX = ///
[
\/ # no forward slashes
\\ # no back slashes
\* # no asterisk
\u0000-\u001F # no control characters (0-31)
\u007F # no delete
\u0080-\u009F # no unicode control characters (C1)
\uD800-\uDFFF # no unicode surrogate characters
]
///g
BADFILE_RX = ///
(^\.$) # reject . as a filename
| (^\.\.$) # reject .. as a filename
| (^\s+) # reject leading space
| (\s+$) # reject trailing space
///g
# Put a block on filenames which match javascript property names, as they
# can cause exceptions where the code puts filenames into a hash. This is a
# temporary workaround until the code in other places is made safe against
# property names.
#
# The list of property names is taken from
# ['prototype'].concat(Object.getOwnPropertyNames(Object.prototype))
BLOCKEDFILE_RX = ///
^(
prototype
|constructor
|toString
|toLocaleString
|valueOf
|hasOwnProperty
|isPrototypeOf
|propertyIsEnumerable
|__defineGetter__
|__lookupGetter__
|__defineSetter__
|__lookupSetter__
|__proto__
)$
///
MAX_PATH = 1024 # Maximum path length, in characters. This is fairly arbitrary.
SafePath =
clean: (filename) ->
filename = filename.replace BADCHAR_RX, '_'
# for BADFILE_RX replace any matches with an equal number of underscores
filename = filename.replace BADFILE_RX, (match) ->
return new Array(match.length + 1).join("_")
# replace blocked filenames 'prototype' with '@prototype'
filename = filename.replace BLOCKEDFILE_RX, "@$1"
return filename
isCleanFilename: (filename) ->
return SafePath.isAllowedLength(filename) and
not BADCHAR_RX.test(filename) and
not BADFILE_RX.test(filename) and
not BLOCKEDFILE_RX.test(filename)
isAllowedLength: (pathname) ->
return pathname.length > 0 && pathname.length <= MAX_PATH
if define?
define [], load
else
module.exports = load()

View File

@@ -1,203 +0,0 @@
define [
"base"
"libs/jquery-layout"
], (App) ->
App.directive "layout", ["$parse", "$compile", "ide", ($parse, $compile, ide) ->
return {
compile: () ->
pre: (scope, element, attrs) ->
name = attrs.layout
customTogglerPane = attrs.customTogglerPane
customTogglerMsgWhenOpen = attrs.customTogglerMsgWhenOpen
customTogglerMsgWhenClosed = attrs.customTogglerMsgWhenClosed
hasCustomToggler = customTogglerPane? and customTogglerMsgWhenOpen? and customTogglerMsgWhenClosed?
if attrs.spacingOpen?
spacingOpen = parseInt(attrs.spacingOpen, 10)
else
spacingOpen = window.uiConfig.defaultResizerSizeOpen
if attrs.spacingClosed?
spacingClosed = parseInt(attrs.spacingClosed, 10)
else
spacingClosed = window.uiConfig.defaultResizerSizeClosed
options =
spacing_open: spacingOpen
spacing_closed: spacingClosed
slidable: false
enableCursorHotkey: false
onopen: (pane) =>
onPaneOpen(pane)
onclose: (pane) =>
onPaneClose(pane)
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 = ide.localStorage("layout.#{name}"))?
if state.east?
if !attrs.minimumRestoreSizeEast? or (state.east.size >= attrs.minimumRestoreSizeEast and !state.east.initClosed)
options.east = state.east
if state.west?
if !attrs.minimumRestoreSizeWest? or (state.west.size >= attrs.minimumRestoreSizeWest and !state.west.initClosed)
options.west = state.west
if window.uiConfig.eastResizerCursor?
options.east.resizerCursor = window.uiConfig.eastResizerCursor
if window.uiConfig.westResizerCursor?
options.west.resizerCursor = window.uiConfig.westResizerCursor
repositionControls = () ->
state = element.layout().readState()
if state.east?
controls = element.find("> .ui-layout-resizer-controls")
if state.east.initClosed
controls.hide()
else
controls.show()
controls.css({
right: state.east.size
})
repositionCustomToggler = () ->
if !customTogglerEl?
return
state = element.layout().readState()
positionAnchor = if customTogglerPane == "east" then "right" else "left"
paneState = state[customTogglerPane]
if paneState?
customTogglerEl.css(positionAnchor, if paneState.initClosed then 0 else paneState.size)
resetOpenStates = () ->
state = element.layout().readState()
if attrs.openEast? and state.east?
openEast = $parse(attrs.openEast)
openEast.assign(scope, !state.east.initClosed)
# Someone moved the resizer
onInternalResize = () ->
state = element.layout().readState()
scope.$broadcast "layout:#{name}:resize", state
repositionControls()
if hasCustomToggler
repositionCustomToggler()
resetOpenStates()
oldWidth = element.width()
# Something resized our parent element
onExternalResize = () ->
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?
for event in attrs.resizeOn.split ","
scope.$on event, () -> onExternalResize()
if hasCustomToggler
state = element.layout().readState()
customTogglerScope = scope.$new()
customTogglerScope.isOpen = true
customTogglerScope.isVisible = true
if state[customTogglerPane]?.initClosed == true
customTogglerScope.isOpen = false
customTogglerScope.tooltipMsgWhenOpen = customTogglerMsgWhenOpen
customTogglerScope.tooltipMsgWhenClosed = customTogglerMsgWhenClosed
customTogglerScope.tooltipPlacement = if customTogglerPane == "east" then "left" else "right"
customTogglerScope.handleClick = () ->
element.layout().toggle(customTogglerPane)
repositionCustomToggler()
customTogglerEl = $compile("
<a href
ng-show=\"isVisible\"
class=\"custom-toggler #{ 'custom-toggler-' + customTogglerPane }\"
ng-class=\"isOpen ? 'custom-toggler-open' : 'custom-toggler-closed'\"
tooltip=\"{{ isOpen ? tooltipMsgWhenOpen : tooltipMsgWhenClosed }}\"
tooltip-placement=\"{{ tooltipPlacement }}\"
ng-click=\"handleClick()\">
")(customTogglerScope)
element.append(customTogglerEl)
onPaneOpen = (pane) ->
if !hasCustomToggler and pane != customTogglerPane
return
customTogglerEl.scope().$applyAsync () ->
customTogglerEl.scope().isOpen = true
onPaneClose = (pane) ->
if !hasCustomToggler and pane != customTogglerPane
return
customTogglerEl.scope().$applyAsync () ->
customTogglerEl.scope().isOpen = false
# Save state when exiting
$(window).unload () ->
ide.localStorage("layout.#{name}", element.layout().readState())
if attrs.openEast?
scope.$watch attrs.openEast, (value, oldValue) ->
if value? and value != oldValue
if value
element.layout().open("east")
else
element.layout().close("east")
setTimeout () ->
scope.$digest()
, 0
if attrs.allowOverflowOn?
layoutObj = element.layout()
overflowPane = scope.$eval(attrs.allowOverflowOn)
overflowPaneEl = layoutObj.panes[overflowPane]
# Set the panel as overflowing (gives it higher z-index and sets overflow rules)
layoutObj.allowOverflow overflowPane
# Read the given z-index value and increment it, so that it's higher than synctex controls.
overflowPaneZVal = overflowPaneEl.zIndex()
overflowPaneEl.css "z-index", overflowPaneZVal + 1
resetOpenStates()
onInternalResize()
if attrs.layoutDisabled?
scope.$watch attrs.layoutDisabled, (value) ->
if value
element.layout().hide("east")
else
element.layout().show("east")
if hasCustomToggler
customTogglerEl.scope().$applyAsync () ->
customTogglerEl.scope().isOpen = !value
customTogglerEl.scope().isVisible = !value
post: (scope, element, attrs) ->
name = attrs.layout
state = element.layout().readState()
scope.$broadcast "layout:#{name}:linked", state
}
]

View File

@@ -1,12 +0,0 @@
define [
"base"
"ide/directives/SafePath"
], (App, SafePath) ->
App.directive "validFile", () ->
return {
require: 'ngModel'
link: (scope, element, attrs, ngModelCtrl) ->
ngModelCtrl.$validators.validFile = (filename) ->
return SafePath.isCleanFilename filename
}

View File

@@ -1,29 +0,0 @@
define [], () ->
AceShareJsCodec =
aceRangeToShareJs: (range, lines) ->
offset = 0
for line, i in lines
offset += if i < range.row
line.length
else
range.column
offset += range.row # Include newlines
return offset
aceChangeToShareJs: (delta, lines) ->
offset = AceShareJsCodec.aceRangeToShareJs(delta.start, lines)
text = delta.lines.join('\n')
switch delta.action
when 'insert'
return { i: text, p: offset }
when 'remove'
return { d: text, p: offset }
else throw new Error "unknown action: #{delta.action}"
shareJsOffsetToAcePosition: (offset, lines) ->
row = 0
for line, row in lines
break if offset <= line.length
offset -= lines[row].length + 1 # + 1 for newline char
return {row:row, column:offset}

View File

@@ -1,410 +0,0 @@
define [
"utils/EventEmitter"
"ide/editor/ShareJsDoc"
"ide/review-panel/RangesTracker"
], (EventEmitter, ShareJsDoc, RangesTracker) ->
class Document extends EventEmitter
@getDocument: (ide, doc_id) ->
@openDocs ||= {}
if !@openDocs[doc_id]?
sl_console.log "[getDocument] Creating new document instance for #{doc_id}"
@openDocs[doc_id] = new Document(ide, doc_id)
else
sl_console.log "[getDocument] Returning existing document instance for #{doc_id}"
return @openDocs[doc_id]
@hasUnsavedChanges: () ->
for doc_id, doc of (@openDocs or {})
return true if doc.hasBufferedOps()
return false
@flushAll: () ->
for doc_id, doc of @openDocs
doc.flush()
constructor: (@ide, @doc_id) ->
@connected = @ide.socket.socket.connected
@joined = false
@wantToBeJoined = false
@_checkAceConsistency = _.bind(@_checkConsistency, @, @ace)
@_checkCMConsistency = _.bind(@_checkConsistency, @, @cm)
@inconsistentCount = 0
@_bindToEditorEvents()
@_bindToSocketEvents()
attachToAce: (@ace) ->
@doc?.attachToAce(@ace)
editorDoc = @ace.getSession().getDocument()
editorDoc.on "change", @_checkAceConsistency
@ide.$scope.$emit 'document:opened', @doc
detachFromAce: () ->
@doc?.detachFromAce()
editorDoc = @ace?.getSession().getDocument()
editorDoc?.off "change", @_checkAceConsistency
@ide.$scope.$emit 'document:closed', @doc
attachToCM: (@cm) ->
@doc?.attachToCM(@cm)
@cm?.on "change", @_checkCMConsistency
@ide.$scope.$emit 'document:opened', @doc
detachFromCM: () ->
@doc?.detachFromCM()
@cm?.off "change", @_checkCMConsistency
@ide.$scope.$emit 'document:closed', @doc
submitOp: (args...) -> @doc?.submitOp(args...)
_checkConsistency: (editor) ->
return () =>
# 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 = editor?.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()
getRecentAck: () ->
@doc?.getRecentAck()
getOpSize: (op) ->
@doc?.getOpSize(op)
hasBufferedOps: () ->
@doc?.hasBufferedOps()
setTrackingChanges: (track_changes) ->
@doc.track_changes = track_changes
getTrackingChanges: () ->
!!@doc.track_changes
setTrackChangesIdSeeds: (id_seeds) ->
@doc.track_changes_id_seeds = id_seeds
_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", @_onErrorHandler
@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())
sl_console.log "[leave] Doc has buffered ops, pushing callback for later"
@_leaveCallbacks ||= []
@_leaveCallbacks.push callback
else if !@connected
sl_console.log "[leave] Not connected, returning now"
callback()
else
sl_console.log "[leave] Leaving now"
@_leaveDoc(callback)
flush: () ->
@doc?.flushPendingOps()
chaosMonkey: (line = 0, char = "a") ->
orig = char
copy = null
pos = 0
timer = () =>
unless copy? and copy.length
copy = orig.slice() + ' ' + new Date() + '\n'
line += if Math.random() > 0.1 then 1 else -2
line = 0 if line < 0
pos = 0
char = copy[0]
copy = copy.slice(1)
@ace.session.insert({row: line, column: pos}, char)
pos += 1
@_cm = setTimeout timer, 100 + if Math.random() < 0.1 then 1000 else 0
@_cm = timer()
clearChaosMonkey: () ->
clearTimeout @_cm
MAX_PENDING_OP_SIZE: 64 # pending ops bigger than this are always considered unsaved
pollSavedStatus: () ->
# returns false if doc has ops waiting to be acknowledged or
# sent that haven't changed since the last time we checked.
# Otherwise returns true.
inflightOp = @getInflightOp()
pendingOp = @getPendingOp()
recentAck = @getRecentAck()
pendingOpSize = pendingOp? && @getOpSize(pendingOp)
if !inflightOp? and !pendingOp?
# there's nothing going on, this is ok.
saved = true
sl_console.log "[pollSavedStatus] no inflight or pending ops"
else if inflightOp? and inflightOp == @oldInflightOp
# The same inflight op has been sitting unacked since we
# last checked, this is bad.
saved = false
sl_console.log "[pollSavedStatus] inflight op is same as before"
else if pendingOp? and recentAck && pendingOpSize < @MAX_PENDING_OP_SIZE
# There is an op waiting to go to server but it is small and
# within the flushDelay, this is ok for now.
saved = true
sl_console.log "[pollSavedStatus] pending op (small with recent ack) assume ok", pendingOp, pendingOpSize
else
# In any other situation, assume the document is unsaved.
saved = false
sl_console.log "[pollSavedStatus] assuming not saved (inflightOp?: #{inflightOp?}, pendingOp?: #{pendingOp?})"
@oldInflightOp = inflightOp
return saved
_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 window.disconnectOnAck? and Math.random() < window.disconnectOnAck
sl_console.log "Disconnecting on ack", update
window._ide.socket.socket.disconnect()
# Pretend we never received the ack
return
if window.dropAcks? and Math.random() < window.dropAcks
if !update.op? # Only drop our own acks, not collaborator updates
sl_console.log "Simulating a lost ack", update
return
if update?.doc == @doc_id and @doc?
@doc.processUpdateFromServer update
if !@wantToBeJoined
@leave()
_onDisconnect: () ->
sl_console.log '[onDisconnect] disconnecting'
@connected = false
@joined = false
@doc?.updateConnectionState "disconnected"
_onReconnect: () ->
sl_console.log "[onReconnect] reconnected (joined project)"
@ide.pushEvent "reconnected:afterJoinProject"
@connected = true
if @wantToBeJoined or @doc?.hasBufferedOps()
sl_console.log "[onReconnect] Rejoining (wantToBeJoined: #{@wantToBeJoined} OR hasBufferedOps: #{@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(), { encodeRanges: true }, (error, docLines, version, updates, ranges) =>
return callback(error) if error?
@joined = true
@doc.catchUp( updates )
@_decodeRanges(ranges)
@_catchUpRanges(ranges?.changes, ranges?.comments)
callback()
else
@ide.socket.emit 'joinDoc', @doc_id, { encodeRanges: true }, (error, docLines, version, updates, ranges) =>
return callback(error) if error?
@joined = true
@doc = new ShareJsDoc @doc_id, docLines, version, @ide.socket
@_decodeRanges(ranges)
@ranges = new RangesTracker(ranges?.changes, ranges?.comments)
@_bindToShareJsDocEvents()
callback()
_decodeRanges: (ranges) ->
decodeFromWebsockets = (text) -> decodeURIComponent(escape(text))
try
for change in ranges.changes or []
change.op.i = decodeFromWebsockets(change.op.i) if change.op.i?
change.op.d = decodeFromWebsockets(change.op.d) if change.op.d?
for comment in ranges.comments or []
comment.op.c = decodeFromWebsockets(comment.op.c) if comment.op.c?
catch err
console.log(err)
_leaveDoc: (callback = (error) ->) ->
sl_console.log '[_leaveDoc] Sending leaveDoc request'
@ide.socket.emit 'leaveDoc', @doc_id, (error) =>
return callback(error) if error?
@joined = false
for callback in @_leaveCallbacks or []
sl_console.log '[_leaveDoc] Calling buffered callback', callback
callback(error)
delete @_leaveCallbacks
callback(error)
_cleanUp: () ->
if Document.openDocs[@doc_id] == @
sl_console.log "[_cleanUp] Removing self (#{@doc_id}) from in openDocs"
delete Document.openDocs[@doc_id]
else
# It's possible that this instance has error, and the doc has been reloaded.
# This creates a new instance in Document.openDoc with the same id. We shouldn't
# clear it because it's not this instance.
sl_console.log "[_cleanUp] New instance of (#{@doc_id}) created. Not removing"
@_unBindFromEditorEvents()
@_unBindFromSocketEvents()
_bindToShareJsDocEvents: () ->
@doc.on "error", (error, meta) => @_onError error, meta
@doc.on "externalUpdate", (update) =>
@ide.pushEvent "externalUpdate",
doc_id: @doc_id
@trigger "externalUpdate", update
@doc.on "remoteop", (args...) =>
@ide.pushEvent "remoteop",
doc_id: @doc_id
@trigger "remoteop", args...
@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
@ide.$scope.$emit "ide:opAcknowledged",
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"
@_onError new Error("op timed out"), {op: op}
@doc.on "flush", (inflightOp, pendingOp, version) =>
@ide.pushEvent "flush",
doc_id: @doc_id,
inflightOp: inflightOp,
pendingOp: pendingOp
v: version
@doc.on "change", (ops, oldSnapshot, msg) =>
@_applyOpsToRanges(ops, oldSnapshot, msg)
@ide.$scope.$emit "doc:changed",
doc_id: @doc_id
@doc.on "flipped_pending_to_inflight", () =>
@trigger "flipped_pending_to_inflight"
@doc.on "saved", () =>
@ide.$scope.$emit "doc:saved",
doc_id: @doc_id
_onError: (error, meta = {}) ->
meta.doc_id = @doc_id
sl_console.log "ShareJS error", error, meta
ga?('send', 'event', 'error', "shareJsError", "#{error.message} - #{@ide.socket.socket.transport.name}" )
@doc?.clearInflightAndPendingOps()
@trigger "error", error, meta
# The clean up should run after the error is triggered because the error triggers a
# disconnect. If we run the clean up first, we remove our event handlers and miss
# the disconnect event, which means we try to leaveDoc when the connection comes back.
# This could intefere with the new connection of a new instance of this document.
@_cleanUp()
_applyOpsToRanges: (ops = [], oldSnapshot, msg) ->
track_changes_as = null
remote_op = msg?
if msg?.meta?.tc?
old_id_seed = @ranges.getIdSeed()
@ranges.setIdSeed(msg.meta.tc)
if remote_op and msg.meta?.tc
track_changes_as = msg.meta.user_id
else if !remote_op and @track_changes_as?
track_changes_as = @track_changes_as
@ranges.track_changes = track_changes_as?
for op in ops
@ranges.applyOp op, { user_id: track_changes_as }
if old_id_seed?
@ranges.setIdSeed(old_id_seed)
if remote_op
# With remote ops, Ace hasn't been updated when we receive this op,
# so defer updating track changes until it has
setTimeout () => @emit "ranges:dirty"
else
@emit "ranges:dirty"
_catchUpRanges: (changes = [], comments = []) ->
# We've just been given the current server's ranges, but need to apply any local ops we have.
# Reset to the server state then apply our local ops again.
@emit "ranges:clear"
@ranges.changes = changes
@ranges.comments = comments
@ranges.track_changes = @doc.track_changes
for op in @doc.getInflightOp() or []
@ranges.setIdSeed(@doc.track_changes_id_seeds.inflight)
@ranges.applyOp(op, { user_id: @track_changes_as })
for op in @doc.getPendingOp() or []
@ranges.setIdSeed(@doc.track_changes_id_seeds.pending)
@ranges.applyOp(op, { user_id: @track_changes_as })
@emit "ranges:redraw"

View File

@@ -1,196 +0,0 @@
define [
"ide/editor/Document"
"ide/editor/components/spellMenu"
"ide/editor/directives/aceEditor"
"ide/editor/directives/toggleSwitch"
"ide/editor/controllers/SavingNotificationController"
], (Document) ->
class EditorManager
constructor: (@ide, @$scope, @localStorage) ->
@$scope.editor = {
sharejs_doc: null
open_doc_id: null
open_doc_name: null
opening: true
trackChanges: false
wantTrackChanges: false
showRichText: @showRichText()
}
@$scope.$on "entity:selected", (event, entity) =>
if (@$scope.ui.view != "history" and entity.type == "doc")
@openDoc(entity)
@$scope.$on "entity:deleted", (event, entity) =>
if @$scope.editor.open_doc_id == entity.id
return if !@$scope.project.rootDoc_id
doc = @ide.fileTreeManager.findEntityById(@$scope.project.rootDoc_id)
return if !doc?
@openDoc(doc)
initialized = false
@$scope.$on "file-tree:initialized", () =>
if !initialized
initialized = true
@autoOpenDoc()
@$scope.$on "flush-changes", () =>
Document.flushAll()
@$scope.$watch "editor.wantTrackChanges", (value) =>
return if !value?
@_syncTrackChangesState(@$scope.editor.sharejs_doc)
showRichText: () ->
if !window.richTextEnabled
return false
@localStorage("editor.mode.#{@$scope.project_id}") == 'rich-text'
autoOpenDoc: () ->
open_doc_id =
@ide.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)
openDocId: (doc_id, options = {}) ->
doc = @ide.fileTreeManager.findEntityById(doc_id)
return if !doc?
@openDoc(doc, options)
openDoc: (doc, options = {}) ->
sl_console.log "[openDoc] Opening #{doc.id}"
@$scope.ui.view = "editor"
done = () =>
if options.gotoLine?
# allow Ace to display document before moving, delay until next tick
# added delay to make this happen later that gotoStoredPosition in
# CursorPositionManager
setTimeout () =>
@$scope.$broadcast "editor:gotoLine", options.gotoLine, options.gotoColumn
, 0
else if options.gotoOffset?
setTimeout () =>
@$scope.$broadcast "editor:gotoOffset", options.gotoOffset
, 0
if doc.id == @$scope.editor.open_doc_id and !options.forceReopen
@$scope.$apply () =>
done()
return
@$scope.editor.open_doc_id = doc.id
@$scope.editor.open_doc_name = doc.name
@ide.localStorage "doc.open_id.#{@$scope.project_id}", doc.id
@ide.fileTreeManager.selectEntity(doc)
@$scope.editor.opening = true
@_openNewDocument doc, (error, sharejs_doc) =>
if error?
@ide.showGenericMessageModal(
"Error opening document"
"Sorry, something went wrong opening this document. Please try again."
)
return
@_syncTrackChangesState(sharejs_doc)
@$scope.$broadcast "doc:opened"
@$scope.$apply () =>
@$scope.editor.opening = false
@$scope.editor.sharejs_doc = sharejs_doc
done()
_openNewDocument: (doc, callback = (error, sharejs_doc) ->) ->
sl_console.log "[_openNewDocument] Opening..."
current_sharejs_doc = @$scope.editor.sharejs_doc
if current_sharejs_doc?
sl_console.log "[_openNewDocument] Leaving existing open 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, meta) =>
if error?.message?
message = error.message
else if typeof error == "string"
message = error
else
message = ""
if /maxDocLength/.test(message)
@ide.showGenericMessageModal(
"Document Too Long"
"Sorry, this file is too long to be edited manually. Please upload it directly."
)
else if /too many comments or tracked changes/.test(message)
@ide.showGenericMessageModal(
"Too many comments or tracked changes"
"Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments."
)
else
@ide.socket.disconnect()
@ide.reportError(error, meta)
@ide.showGenericMessageModal(
"Out of sync"
"Sorry, this file has gone out of sync and we need to do a full refresh. <br> <a href='/learn/Kb/Editor_out_of_sync_problems'>Please see this help guide for more information</a>"
)
@openDoc(doc, forceReopen: true)
sharejs_doc.on "externalUpdate", (update) =>
return if @_ignoreExternalUpdates
@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()
getCurrentDocValue: () ->
@$scope.editor.sharejs_doc?.getSnapshot()
getCurrentDocId: () ->
@$scope.editor.open_doc_id
startIgnoringExternalUpdates: () ->
@_ignoreExternalUpdates = true
stopIgnoringExternalUpdates: () ->
@_ignoreExternalUpdates = false
_syncTimeout: null
_syncTrackChangesState: (doc) ->
return if !doc?
if @_syncTimeout?
clearTimeout @_syncTimeout
@_syncTimeout = null
want = @$scope.editor.wantTrackChanges
have = doc.getTrackingChanges()
if want == have
@$scope.editor.trackChanges = want
return
do tryToggle = () =>
saved = !doc.getInflightOp()? and !doc.getPendingOp()?
if saved
doc.setTrackingChanges(want)
@$scope.$apply () =>
@$scope.editor.trackChanges = want
else
@_syncTimeout = setTimeout tryToggle, 100

View File

@@ -1,190 +0,0 @@
define [
"utils/EventEmitter"
"libs/sharejs"
], (EventEmitter, ShareJs) ->
SINGLE_USER_FLUSH_DELAY = 1000 #ms
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 = (decodeURIComponent(escape(line)) for line in docLines)
snapshot = docLines.join("\n")
@track_changes = false
@connection = {
send: (update) =>
@_startInflightOpTimeout(update)
if window.disconnectOnUpdate? and Math.random() < window.disconnectOnUpdate
sl_console.log "Disconnecting on update", update
window._ide.socket.socket.disconnect()
if window.dropUpdates? and Math.random() < window.dropUpdates
sl_console.log "Simulating a lost update", update
return
if @track_changes
update.meta ?= {}
update.meta.tc = @track_changes_id_seeds.inflight
@socket.emit "applyOtUpdate", @doc_id, update, (error) =>
return @_handleError(error) if error?
state: "ok"
id: @socket.socket.sessionid
}
@_doc = new ShareJs.Doc @connection, @doc_id,
type: @type
@_doc.setFlushDelay(SINGLE_USER_FLUSH_DELAY)
@_doc.on "change", (args...) =>
@trigger "change", args...
@_doc.on "acknowledge", () =>
@lastAcked = new Date() # note time of last ack from server for an op we sent
@trigger "acknowledge"
@_doc.on "remoteop", (args...) =>
# As soon as we're working with a collaborator, start sending
# ops as quickly as possible for low latency.
@_doc.setFlushDelay(0)
@trigger "remoteop", args...
@_doc.on "flipped_pending_to_inflight", () =>
@trigger "flipped_pending_to_inflight"
@_doc.on "saved", () =>
@trigger "saved"
@_doc.on "error", (e) =>
@_handleError(e)
@_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
console.log error
@_handleError(error)
if message?.meta?.type == "external"
@trigger "externalUpdate", message
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) ->
sl_console.log "[updateConnectionState] Setting state to #{state}"
@connection.state = state
@connection.id = @socket.socket.sessionid
@_doc.autoOpen = false
@_doc._connectionStateChanged(state)
@lastAcked = null # reset the last ack time when connection changes
hasBufferedOps: () ->
@_doc.inflightOp? or @_doc.pendingOp?
getInflightOp: () -> @_doc.inflightOp
getPendingOp: () -> @_doc.pendingOp
getRecentAck: () ->
# check if we have received an ack recently (within a factor of two of the single user flush delay)
@lastAcked? and new Date() - @lastAcked < 2 * SINGLE_USER_FLUSH_DELAY
getOpSize: (op) ->
# compute size of an op from its components
# (total number of characters inserted and deleted)
size = 0
for component in op or []
if component?.i?
size += component.i.length
if component?.d?
size += component.d.length
return size
attachToAce: (ace) -> @_doc.attach_ace(ace, false, window.maxDocLength)
detachFromAce: () -> @_doc.detach_ace?()
attachToCM: (cm) -> @_doc.attach_cm(cm, false)
detachFromCM: () -> @_doc.detach_cm?()
INFLIGHT_OP_TIMEOUT: 5000 # Retry sending ops after 5 seconds without an ack
WAIT_FOR_CONNECTION_TIMEOUT: 500 # If we're waiting for the project to join, try again in 0.5 seconds
_startInflightOpTimeout: (update) ->
@_startFatalTimeoutTimer(update)
retryOp = () =>
# Only send the update again if inflightOp is still populated
# This can be cleared when hard reloading the document in which
# case we don't want to keep trying to send it.
sl_console.log "[inflightOpTimeout] Trying op again"
if @_doc.inflightOp?
# When there is a socket.io disconnect, @_doc.inflightSubmittedIds
# is updated with the socket.io client id of the current op in flight
# (meta.source of the op).
# @connection.id is the client id of the current socket.io session.
# So we need both depending on whether the op was submitted before
# one or more disconnects, or if it was submitted during the current session.
update.dupIfSource = [@connection.id, @_doc.inflightSubmittedIds...]
# We must be joined to a project for applyOtUpdate to work on the real-time
# service, so don't send an op if we're not. Connection state is set to 'ok'
# when we've joined the project
if @connection.state != "ok"
sl_console.log "[inflightOpTimeout] Not connected, retrying in 0.5s"
timer = setTimeout retryOp, @WAIT_FOR_CONNECTION_TIMEOUT
else
sl_console.log "[inflightOpTimeout] Sending"
@connection.send(update)
timer = setTimeout retryOp, @INFLIGHT_OP_TIMEOUT
@_doc.inflightCallbacks.push () =>
@_clearFatalTimeoutTimer()
clearTimeout timer
FATAL_OP_TIMEOUT: 30000 # 30 seconds
_startFatalTimeoutTimer: (update) ->
# If an op doesn't get acked within FATAL_OP_TIMEOUT, something has
# gone unrecoverably wrong (the op will have been retried multiple times)
return if @_timeoutTimer?
@_timeoutTimer = setTimeout () =>
@_clearFatalTimeoutTimer()
@trigger "op:timeout", update
, @FATAL_OP_TIMEOUT
_clearFatalTimeoutTimer: () ->
return if !@_timeoutTimer?
clearTimeout @_timeoutTimer
@_timeoutTimer = null
_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,35 +0,0 @@
define ["base"], (App) ->
App.component "spellMenu", {
bindings: {
open: "<"
top: "<"
left: "<"
layoutFromBottom: "<"
highlight: "<"
replaceWord: "&"
learnWord: "&"
}
template: """
<div
class="dropdown context-menu spell-check-menu"
ng-show="$ctrl.open"
ng-style="{top: $ctrl.top, left: $ctrl.left}"
ng-class="{open: $ctrl.open, 'spell-check-menu-from-bottom': $ctrl.layoutFromBottom}"
>
<ul class="dropdown-menu">
<li ng-repeat="suggestion in $ctrl.highlight.suggestions | limitTo:8">
<a
href
ng-click="$ctrl.replaceWord({ highlight: $ctrl.highlight, suggestion: suggestion })"
>
{{ suggestion }}
</a>
</li>
<li class="divider"></li>
<li>
<a href ng-click="$ctrl.learnWord({ highlight: $ctrl.highlight })">Add to Dictionary</a>
</li>
</ul>
</div>
"""
}

View File

@@ -1,62 +0,0 @@
define [
"base"
"ide/editor/Document"
], (App, Document) ->
App.controller "SavingNotificationController", ["$scope", "$interval", "ide", ($scope, $interval, ide) ->
setInterval () ->
pollSavedStatus()
, 1000
$(window).bind 'beforeunload', () =>
warnAboutUnsavedChanges()
lockEditorModal = null # modal showing "connection lost"
MAX_UNSAVED_SECONDS = 15 # lock the editor after this time if unsaved
$scope.docSavingStatus = {}
pollSavedStatus = () ->
oldStatus = $scope.docSavingStatus
oldUnsavedCount = $scope.docSavingStatusCount
newStatus = {}
newUnsavedCount = 0
maxUnsavedSeconds = 0
for doc_id, doc of Document.openDocs
saving = doc.pollSavedStatus()
if !saving
newUnsavedCount++
if oldStatus[doc_id]?
newStatus[doc_id] = oldStatus[doc_id]
t = newStatus[doc_id].unsavedSeconds += 1
if t > maxUnsavedSeconds
maxUnsavedSeconds = t
else
newStatus[doc_id] = {
unsavedSeconds: 0
doc: ide.fileTreeManager.findEntityById(doc_id)
}
if newUnsavedCount > 0 and t > MAX_UNSAVED_SECONDS and not lockEditorModal
lockEditorModal = ide.showLockEditorMessageModal(
"Connection lost"
"Sorry, the connection to the server is down."
)
lockEditorModal.result.finally () ->
lockEditorModal = null # unset the modal if connection comes back
if lockEditorModal and newUnsavedCount is 0
lockEditorModal.dismiss "connection back up"
# for performance, only update the display if the old or new
# counts of unsaved files are nonzeror. If both old and new
# unsaved counts are zero then we know we are in a good state
# and don't need to do anything to the UI.
if newUnsavedCount or oldUnsavedCount
$scope.docSavingStatus = newStatus
$scope.docSavingStatusCount = newUnsavedCount
$scope.$apply()
warnAboutUnsavedChanges = () ->
if Document.hasUnsavedChanges()
return "You have unsaved changes. If you leave now they will not be saved."
]

View File

@@ -1,633 +0,0 @@
define [
"base"
"ace/ace"
"ace/ext-searchbox"
"ace/ext-modelist"
"ace/keybinding-vim"
"ide/editor/directives/aceEditor/undo/UndoManager"
"ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager"
"ide/editor/directives/aceEditor/spell-check/SpellCheckManager"
"ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter"
"ide/editor/directives/aceEditor/highlights/HighlightsManager"
"ide/editor/directives/aceEditor/cursor-position/CursorPositionManager"
"ide/editor/directives/aceEditor/cursor-position/CursorPositionAdapter"
"ide/editor/directives/aceEditor/track-changes/TrackChangesManager"
"ide/editor/directives/aceEditor/metadata/MetadataManager"
"ide/metadata/services/metadata"
"ide/graphics/services/graphics"
"ide/preamble/services/preamble"
"ide/files/services/files"
], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, CursorPositionAdapter, TrackChangesManager, MetadataManager) ->
EditSession = ace.require('ace/edit_session').EditSession
ModeList = ace.require('ace/ext/modelist')
Vim = ace.require('ace/keyboard/vim').Vim
# set the path for ace workers if using a CDN (from editor.pug)
if window.aceWorkerPath != ""
syntaxValidationEnabled = true
ace.config.set('workerPath', "#{window.aceWorkerPath}")
else
syntaxValidationEnabled = false
# By default, don't use workers - enable them per-session as required
ace.config.setDefaultValue("session", "useWorker", false)
# Ace loads its script itself, so we need to hook in to be able to clear
# the cache.
if !ace.config._moduleUrl?
ace.config._moduleUrl = ace.config.moduleUrl
ace.config.moduleUrl = (args...) ->
url = ace.config._moduleUrl(args...)
return url
App.directive "aceEditor", ($timeout, $compile, $rootScope, event_tracking, localStorage, $cacheFactory, metadata, graphics, preamble, files, $http, $q, $window) ->
monkeyPatchSearch($rootScope, $compile)
return {
scope: {
theme: "="
showPrintMargin: "="
keybindings: "="
fontSize: "="
autoComplete: "="
autoPairDelimiters: "="
sharejsDoc: "="
spellCheck: "="
spellCheckLanguage: "="
highlights: "="
text: "="
readOnly: "="
annotations: "="
navigateHighlights: "="
fileName: "="
onCtrlEnter: "=" # Compile
onCtrlJ: "=" # Toggle the review panel
onCtrlShiftC: "=" # Add a new comment
onCtrlShiftA: "=" # Toggle track-changes on/off
onSave: "=" # Cmd/Ctrl-S or :w in Vim
syntaxValidation: "="
reviewPanel: "="
eventsBridge: "="
trackChanges: "="
trackChangesEnabled: "="
docId: "="
rendererData: "="
lineHeight: "="
fontFamily: "="
}
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])
editor.$blockScrolling = Infinity
# auto-insertion of braces, brackets, dollars
editor.setOption('behavioursEnabled', scope.autoPairDelimiters || false)
editor.setOption('wrapBehavioursEnabled', false)
scope.$watch "autoPairDelimiters", (autoPairDelimiters) =>
if autoPairDelimiters
editor.setOption('behavioursEnabled', true)
else
editor.setOption('behavioursEnabled', false)
window._debug_editors ||= []
window._debug_editors.push editor
scope.name = attrs.aceEditor
if scope.spellCheck # only enable spellcheck when explicitly required
spellCheckCache = $cacheFactory.get("spellCheck-#{scope.name}") || $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000})
spellCheckManager = new SpellCheckManager(scope, spellCheckCache, $http, $q, new SpellCheckAdapter(editor))
undoManager = new UndoManager(scope, editor, element)
highlightsManager = new HighlightsManager(scope, editor, element)
cursorPositionManager = new CursorPositionManager(scope, new CursorPositionAdapter(editor), localStorage)
trackChangesManager = new TrackChangesManager(scope, editor, element)
metadataManager = new MetadataManager(scope, editor, element, metadata)
autoCompleteManager = new AutoCompleteManager(scope, editor, element, metadataManager, graphics, preamble, files)
scope.$watch "onSave", (callback) ->
if callback?
Vim.defineEx 'write', 'w', callback
editor.commands.addCommand
name: "save",
bindKey: win: "Ctrl-S", mac: "Command-S"
exec: callback
readOnly: true
# Not technically 'save', but Ctrl-. recompiles in OL v1
# so maintain compatibility
editor.commands.addCommand
name: "recompile_v1",
bindKey: win: "Ctrl-.", mac: "Ctrl-."
exec: callback
readOnly: true
editor.commands.removeCommand "transposeletters"
editor.commands.removeCommand "showSettingsMenu"
editor.commands.removeCommand "foldall"
# For European keyboards, the / is above 7 so needs Shift pressing.
# This comes through as Command-Shift-/ on OS X, which is mapped to
# toggleBlockComment.
# This doesn't do anything for LaTeX, so remap this to togglecomment to
# work for European keyboards as normal.
# On Windows, the key combo comes as Ctrl-Shift-7.
editor.commands.removeCommand "toggleBlockComment"
editor.commands.removeCommand "togglecomment"
editor.commands.addCommand {
name: "togglecomment",
bindKey: { win: "Ctrl-/|Ctrl-Shift-7", mac: "Command-/|Command-Shift-/" },
exec: (editor) -> editor.toggleCommentLines(),
multiSelectAction: "forEachLine",
scrollIntoView: "selectionPart"
}
# Trigger search AND replace on CMD+F
editor.commands.addCommand
name: "find",
bindKey: win: "Ctrl-F", mac: "Command-F"
exec: (editor) ->
ace.require("ace/ext/searchbox").Search(editor, true)
readOnly: true
# Bold text on CMD+B
editor.commands.addCommand
name: "bold",
bindKey: win: "Ctrl-B", mac: "Command-B"
exec: (editor) ->
selection = editor.getSelection()
if selection.isEmpty()
editor.insert("\\textbf{}")
editor.navigateLeft(1)
else
text = editor.getCopyText()
editor.insert("\\textbf{" + text + "}")
readOnly: false
# Italicise text on CMD+I
editor.commands.addCommand
name: "italics",
bindKey: win: "Ctrl-I", mac: "Command-I"
exec: (editor) ->
selection = editor.getSelection()
if selection.isEmpty()
editor.insert("\\textit{}")
editor.navigateLeft(1)
else
text = editor.getCopyText()
editor.insert("\\textit{" + text + "}")
readOnly: false
scope.$watch "onCtrlEnter", (callback) ->
if callback?
editor.commands.addCommand
name: "compile",
bindKey: win: "Ctrl-Enter", mac: "Command-Enter"
exec: (editor) =>
callback()
readOnly: true
scope.$watch "onCtrlJ", (callback) ->
if callback?
editor.commands.addCommand
name: "toggle-review-panel",
bindKey: win: "Ctrl-J", mac: "Command-J"
exec: (editor) =>
callback()
readOnly: true
scope.$watch "onCtrlShiftC", (callback) ->
if callback?
editor.commands.addCommand
name: "add-new-comment",
bindKey: win: "Ctrl-Shift-C", mac: "Command-Shift-C"
exec: (editor) =>
callback()
readOnly: true
scope.$watch "onCtrlShiftA", (callback) ->
if callback?
editor.commands.addCommand
name: "toggle-track-changes",
bindKey: win: "Ctrl-Shift-A", mac: "Command-Shift-A"
exec: (editor) =>
callback()
readOnly: true
# Make '/' work for search in vim mode.
editor.showCommandLine = (arg) =>
if arg == "/"
ace.require("ace/ext/searchbox").Search(editor, true)
getCursorScreenPosition = () ->
session = editor.getSession()
cursorPosition = session.selection.getCursor()
sessionPos = session.documentToScreenPosition(cursorPosition.row, cursorPosition.column)
screenPos = editor.renderer.textToScreenCoordinates(sessionPos.row, sessionPos.column)
return sessionPos.row * editor.renderer.lineHeight - session.getScrollTop()
if attrs.resizeOn?
for event in attrs.resizeOn.split(",")
scope.$on event, () ->
previousScreenPosition = getCursorScreenPosition()
editor.resize()
# Put cursor back to same vertical position on screen
newScreenPosition = getCursorScreenPosition()
session = editor.getSession()
session.setScrollTop(session.getScrollTop() + newScreenPosition - previousScreenPosition)
scope.$on "#{scope.name}:set-scroll-size", (e, size) ->
# Make sure that the editor has enough scroll margin above and below
# to scroll the review panel with the given size
marginTop = size.overflowTop
maxHeight = editor.renderer.layerConfig.maxHeight
marginBottom = Math.max(size.height - maxHeight, 0)
setScrollMargins(marginTop, marginBottom)
setScrollMargins = (marginTop, marginBottom) ->
marginChanged = false
if editor.renderer.scrollMargin.top != marginTop
editor.renderer.scrollMargin.top = marginTop
marginChanged = true
if editor.renderer.scrollMargin.bottom != marginBottom
editor.renderer.scrollMargin.bottom = marginBottom
marginChanged = true
if marginChanged
editor.renderer.updateFull()
resetScrollMargins = () ->
setScrollMargins(0,0)
scope.$watch "theme", (value) ->
editor.setTheme("ace/theme/#{value}")
scope.$watch "showPrintMargin", (value) ->
editor.setShowPrintMargin(value)
scope.$watch "keybindings", (value) ->
if value in ["vim", "emacs"]
editor.setKeyboardHandler("ace/keyboard/#{value}")
else
editor.setKeyboardHandler(null)
scope.$watch "fontSize", (value) ->
element.find(".ace_editor, .ace_content").css({
"font-size": value + "px"
})
scope.$watch "fontFamily", (value) ->
if value?
switch value
when 'monaco'
editor.setOption('fontFamily', '"Monaco", "Menlo", "Ubuntu Mono", "Consolas", "source-code-pro", monospace')
when 'lucida'
editor.setOption('fontFamily', '"Lucida Console", monospace')
else
editor.setOption('fontFamily', null)
scope.$watch "lineHeight", (value) ->
if value?
switch value
when 'compact'
editor.container.style.lineHeight = 1.33
when 'normal'
editor.container.style.lineHeight = 1.6
when 'wide'
editor.container.style.lineHeight = 2
else
editor.container.style.lineHeight = 1.6
editor.renderer.updateFontSize()
scope.$watch "sharejsDoc", (sharejs_doc, old_sharejs_doc) ->
if old_sharejs_doc?
scope.$broadcast('beforeChangeDocument')
detachFromAce(old_sharejs_doc)
if sharejs_doc?
attachToAce(sharejs_doc)
if sharejs_doc? and old_sharejs_doc?
scope.$broadcast('afterChangeDocument')
scope.$watch "text", (text) ->
if text?
editor.setValue(text, -1)
session = editor.getSession()
session.setUseWrapMode(true)
scope.$watch "annotations", (annotations) ->
session = editor.getSession()
session.setAnnotations annotations
scope.$watch "readOnly", (value) ->
editor.setReadOnly !!value
scope.$watch "syntaxValidation", (value) ->
# ignore undefined settings here
# only instances of ace with an explicit value should set useWorker
# the history instance will have syntaxValidation undefined
if value? and syntaxValidationEnabled
session = editor.getSession()
session.setOption("useWorker", value);
editor.setOption("scrollPastEnd", true)
updateCount = 0
onChange = () ->
updateCount++
if updateCount == 100
event_tracking.send 'editor-interaction', 'multi-doc-update'
scope.$emit "#{scope.name}:change"
onScroll = (scrollTop) ->
return if !scope.eventsBridge?
height = editor.renderer.layerConfig.maxHeight
scope.eventsBridge.emit "aceScroll", scrollTop, height
onScrollbarVisibilityChanged = (event, vRenderer) ->
return if !scope.eventsBridge?
scope.eventsBridge.emit "aceScrollbarVisibilityChanged", vRenderer.scrollBarV.isVisible, vRenderer.scrollBarV.width
if scope.eventsBridge?
editor.renderer.on "scrollbarVisibilityChanged", onScrollbarVisibilityChanged
scope.eventsBridge.on "externalScroll", (position) ->
editor.getSession().setScrollTop(position)
scope.eventsBridge.on "refreshScrollPosition", () ->
session = editor.getSession()
session.setScrollTop(session.getScrollTop() + 1)
session.setScrollTop(session.getScrollTop() - 1)
onSessionChangeForSpellCheck = (e) ->
spellCheckManager.onSessionChange()
e.oldSession?.getDocument().off "change", spellCheckManager.onChange
e.session.getDocument().on "change", spellCheckManager.onChange
e.oldSession?.off "changeScrollTop", spellCheckManager.onScroll
e.session.on "changeScrollTop", spellCheckManager.onScroll
initSpellCheck = () ->
spellCheckManager.init()
editor.on 'changeSession', onSessionChangeForSpellCheck
onSessionChangeForSpellCheck({ session: editor.getSession() }) # Force initial setup
editor.on 'nativecontextmenu', spellCheckManager.onContextMenu
tearDownSpellCheck = () ->
editor.off 'changeSession', onSessionChangeForSpellCheck
editor.off 'nativecontextmenu', spellCheckManager.onContextMenu
onSessionChangeForCursorPosition = (e) ->
e.oldSession?.selection.off 'changeCursor', cursorPositionManager.onCursorChange
e.session.selection.on 'changeCursor', cursorPositionManager.onCursorChange
onUnloadForCursorPosition = () ->
cursorPositionManager.onUnload(editor.getSession())
initCursorPosition = () ->
editor.on 'changeSession', onSessionChangeForCursorPosition
onSessionChangeForCursorPosition({ session: editor.getSession() }) # Force initial setup
$(window).on "unload", onUnloadForCursorPosition
tearDownCursorPosition = () ->
editor.off 'changeSession', onSessionChangeForCursorPosition
$(window).off "unload", onUnloadForCursorPosition
initCursorPosition()
# Trigger the event once *only* - this is called after Ace is connected
# to the ShareJs instance but this event should only be triggered the
# first time the editor is opened. Not every time the docs opened
triggerEditorInitEvent = _.once () ->
scope.$broadcast('editorInit')
attachToAce = (sharejs_doc) ->
lines = sharejs_doc.getSnapshot().split("\n")
session = editor.getSession()
if session?
session.destroy()
# see if we can lookup a suitable mode from ace
# but fall back to text by default
try
if /\.(Rtex|bbl)$/i.test(scope.fileName)
# recognise Rtex and bbl as latex
mode = "ace/mode/latex"
else if /\.(sty|cls|clo)$/.test(scope.fileName)
# recognise some common files as tex
mode = "ace/mode/tex"
else
mode = ModeList.getModeForPath(scope.fileName).mode
# we prefer plain_text mode over text mode because ace's
# text mode is actually for code and has unwanted
# indenting (see wrapMethod in ace edit_session.js)
if mode is "ace/mode/text"
mode = "ace/mode/plain_text"
catch
mode = "ace/mode/plain_text"
# create our new session
session = new EditSession(lines, mode)
session.setUseWrapMode(true)
# use syntax validation only when explicitly set
if scope.syntaxValidation? and syntaxValidationEnabled and !/\.bib$/.test(scope.fileName)
session.setOption("useWorker", scope.syntaxValidation);
# now attach session to editor
editor.setReadOnly(true) # set to readonly until document change handlers are attached
editor.setSession(session)
doc = session.getDocument()
doc.on "change", onChange
editor.initing = true
sharejs_doc.attachToAce(editor)
editor.initing = false
# now ready to edit document
editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false
triggerEditorInitEvent()
initSpellCheck()
resetScrollMargins()
# need to set annotations after attaching because attaching
# deletes and then inserts document content
session.setAnnotations scope.annotations
session.on "changeScrollTop", event_tracking.editingSessionHeartbeat
angular.element($window).on('click',
event_tracking.editingSessionHeartbeat)
scope.$on "$destroy", () ->
angular.element($window).off('click',
event_tracking.editingSessionHeartbeat)
if scope.eventsBridge?
session.on "changeScrollTop", onScroll
$rootScope.hasLintingError = false
session.on('changeAnnotation', () ->
# Both linter errors and compile logs are set as error annotations,
# however when the user types something, the compile logs are
# replaced with linter errors. When we check for lint errors before
# autocompile we are guaranteed to get linter errors
hasErrors = session
.getAnnotations()
.filter((annotation) -> annotation.type != 'info')
.length > 0
if ($rootScope.hasLintingError != hasErrors)
$rootScope.hasLintingError = hasErrors
)
setTimeout () ->
# Let any listeners init themselves
onScroll(editor.renderer.getScrollTop())
editor.focus()
detachFromAce = (sharejs_doc) ->
tearDownSpellCheck()
sharejs_doc.detachFromAce()
sharejs_doc.off "remoteop.recordRemote"
session = editor.getSession()
session.off "changeScrollTop"
doc = session.getDocument()
doc.off "change", onChange
editor.renderer.on "changeCharacterSize", () ->
scope.$apply () ->
scope.rendererData.lineHeight = editor.renderer.lineHeight
scope.$watch "rendererData", (rendererData) ->
if rendererData?
rendererData.lineHeight = editor.renderer.lineHeight
scope.$on '$destroy', () ->
if scope.sharejsDoc?
scope.$broadcast('changeEditor')
tearDownSpellCheck()
tearDownCursorPosition()
detachFromAce(scope.sharejsDoc)
session = editor.getSession()
session?.destroy()
scope.eventsBridge.emit "aceScrollbarVisibilityChanged", false, 0
scope.$emit "#{scope.name}:inited", editor
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>
<spell-menu
open="spellMenu.open"
top="spellMenu.top"
left="spellMenu.left"
layout-from-bottom="spellMenu.layoutFromBottom"
highlight="spellMenu.highlight"
replace-word="replaceWord(highlight, suggestion)"
learn-word="learnWord(highlight)"
></spell-menu>
<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>
<a
href
class="highlights-before-label btn btn-info btn-xs"
ng-show="updateLabels.highlightsBefore > 0"
ng-click="gotoHighlightAbove()"
>
<i class="fa fa-fw fa-arrow-up"></i>
{{ updateLabels.highlightsBefore }} more update{{ updateLabels.highlightsBefore > 1 && "" || "s" }} above
</a>
<a
href
class="highlights-after-label btn btn-info btn-xs"
ng-show="updateLabels.highlightsAfter > 0"
ng-click="gotoHighlightBelow()"
>
<i class="fa fa-fw fa-arrow-down"></i>
{{ updateLabels.highlightsAfter }} more update{{ updateLabels.highlightsAfter > 1 && "" || "s" }} below
</a>
</div>
"""
}
monkeyPatchSearch = ($rootScope, $compile) ->
SearchBox = ace.require("ace/ext/searchbox").SearchBox
searchHtml = """
<div class="ace_search right">
<a href type="button" action="hide" class="ace_searchbtn_close">
<i class="fa fa-fw fa-times"></i>
</a>
<div class="ace_search_form">
<input class="ace_search_field form-control input-sm" placeholder="Search for" spellcheck="false"></input>
<div class="btn-group">
<button type="button" action="findNext" class="ace_searchbtn next btn btn-default btn-sm">
<i class="fa fa-chevron-down fa-fw"></i>
</button>
<button type="button" action="findPrev" class="ace_searchbtn prev btn btn-default btn-sm">
<i class="fa fa-chevron-up fa-fw"></i>
</button>
</div>
</div>
<div class="ace_replace_form">
<input class="ace_search_field form-control input-sm" placeholder="Replace with" spellcheck="false"></input>
<div class="btn-group">
<button type="button" action="replaceAndFindNext" class="ace_replacebtn btn btn-default btn-sm">Replace</button>
<button type="button" action="replaceAll" class="ace_replacebtn btn btn-default btn-sm">All</button>
</div>
</div>
<div class="ace_search_options">
<div class="btn-group">
<span action="toggleRegexpMode" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="RegExp Search">.*</span>
<span action="toggleCaseSensitive" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="CaseSensitive Search">Aa</span>
<span action="toggleWholeWords" class="btn btn-default btn-sm" tooltip-placement="bottom" tooltip-append-to-body="true" tooltip="Whole Word Search">"..."</span>
</div>
</div>
</div>
"""
# Remove Ace CSS
$("#ace_searchbox").remove()
$init = SearchBox::$init
SearchBox::$init = () ->
@element = $compile(searchHtml)($rootScope.$new())[0];
$init.apply(@)

View File

@@ -1,320 +0,0 @@
define [
"ide/editor/directives/aceEditor/auto-complete/CommandManager"
"ide/editor/directives/aceEditor/auto-complete/EnvironmentManager"
"ide/editor/directives/aceEditor/auto-complete/PackageManager"
"ide/editor/directives/aceEditor/auto-complete/Helpers"
"ace/ace"
"ace/ext-language_tools"
], (CommandManager, EnvironmentManager, PackageManager, Helpers) ->
Range = ace.require("ace/range").Range
aceSnippetManager = ace.require('ace/snippets').snippetManager
class AutoCompleteManager
constructor: (@$scope, @editor, @element, @metadataManager, @graphics, @preamble, @files) ->
@monkeyPatchAutocomplete()
@$scope.$watch "autoComplete", (autocomplete) =>
if autocomplete
@enable()
else
@disable()
onChange = (change) =>
@onChange(change)
@editor.on "changeSession", (e) =>
e.oldSession.off "change", onChange
e.session.on "change", onChange
enable: () ->
@editor.setOptions({
enableBasicAutocompletion: true
enableSnippets: true
enableLiveAutocompletion: false
})
CommandCompleter = new CommandManager(@metadataManager)
SnippetCompleter = new EnvironmentManager()
PackageCompleter = new PackageManager(@metadataManager, Helpers)
Graphics = @graphics
Preamble = @preamble
Files = @files
GraphicsCompleter =
getCompletions: (editor, session, pos, prefix, callback) ->
{commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
match = commandFragment.match(/^~?\\(includegraphics(?:\[.*])?){([^}]*, *)?(\w*)/)
if match
[_, commandName, _, currentArg] = match
graphicsPaths = Preamble.getGraphicsPaths()
result = []
for graphic in Graphics.getGraphicsFiles()
path = graphic.path
for graphicsPath in graphicsPaths
if path.indexOf(graphicsPath) == 0
path = path.slice(graphicsPath.length)
break
result.push {
caption: "\\#{commandName}{#{path}}"
value: "\\#{commandName}{#{path}}"
meta: "graphic"
score: 50
}
callback null, result
metadataManager = @metadataManager
FilesCompleter =
getCompletions: (editor, session, pos, prefix, callback) =>
{commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
match = commandFragment.match(/^\\(input|include){(\w*)/)
if match
[_, commandName, currentArg] = match
result = []
for file in Files.getTeXFiles()
if file.id != @$scope.docId
path = file.path
result.push {
caption: "\\#{commandName}{#{path}}"
value: "\\#{commandName}{#{path}}"
meta: "file"
score: 50
}
callback null, result
LabelsCompleter =
getCompletions: (editor, session, pos, prefix, callback) ->
{commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
refMatch = commandFragment.match(/^~?\\([a-zA-Z]*ref){([^}]*, *)?(\w*)/)
if refMatch
[_, commandName, currentArg] = refMatch
result = []
if commandName != 'ref' # ref is in top 100 commands
result.push {
caption: "\\#{commandName}{}"
snippet: "\\#{commandName}{}"
meta: "cross-reference"
score: 60
}
for label in metadataManager.getAllLabels()
result.push {
caption: "\\#{commandName}{#{label}}"
value: "\\#{commandName}{#{label}}"
meta: "cross-reference"
score: 50
}
callback null, result
references = @$scope.$root._references
ReferencesCompleter =
getCompletions: (editor, session, pos, prefix, callback) ->
{commandFragment} = Helpers.getContext(editor, pos)
if commandFragment
citeMatch = commandFragment.match(
/^~?\\([a-z]*cite[a-z]*(?:\[.*])?){([^}]*, *)?(\w*)/
)
if citeMatch
[_, commandName, previousArgs, currentArg] = citeMatch
if !previousArgs?
previousArgs = ""
previousArgsCaption = if previousArgs.length > 8 then "…," else previousArgs
result = []
result.push {
caption: "\\#{commandName}{}"
snippet: "\\#{commandName}{}"
meta: "reference"
score: 60
}
if references.keys and references.keys.length > 0
references.keys.forEach (key) ->
if key?
result.push({
caption: "\\#{commandName}{#{previousArgsCaption}#{key}}"
value: "\\#{commandName}{#{previousArgs}#{key}}"
meta: "reference"
score: 50
})
callback null, result
else
callback null, result
@editor.completers = [
CommandCompleter
SnippetCompleter
PackageCompleter
ReferencesCompleter
LabelsCompleter
GraphicsCompleter
FilesCompleter
]
disable: () ->
@editor.setOptions({
enableBasicAutocompletion: false,
enableSnippets: false
})
onChange: (change) ->
cursorPosition = @editor.getCursorPosition()
end = change.end
{lineUpToCursor, commandFragment} = Helpers.getContext(@editor, end)
if ((i = lineUpToCursor.indexOf('%')) > -1 and lineUpToCursor[i-1] != '\\')
return
lastCharIsBackslash = lineUpToCursor.slice(-1) == "\\"
lastTwoChars = lineUpToCursor.slice(-2)
# Don't offer autocomplete on double-backslash, backslash-colon, etc
if /^\\[^a-zA-Z]$/.test(lastTwoChars)
@editor?.completer?.detach?()
return
# Check that this change was made by us, not a collaborator
# (Cursor is still one place behind)
# NOTE: this is also the case when a user backspaces over a highlighted region
if (
change.action == "insert" and
end.row == cursorPosition.row and
end.column == cursorPosition.column + 1
)
if commandFragment?.length > 2 or lastCharIsBackslash
setTimeout () =>
@editor.execCommand("startAutocomplete")
, 0
if (
change.action == "insert" and
/(begin|end|[a-zA-Z]*ref|usepackage|[a-z]*cite[a-z]*|input|include)/.test(
change.lines[0].match(/\\(\w+){}/)?[1]
)
)
setTimeout () =>
@editor.execCommand("startAutocomplete")
, 0
monkeyPatchAutocomplete: () ->
Autocomplete = ace.require("ace/autocomplete").Autocomplete
Util = ace.require("ace/autocomplete/util")
editor = @editor
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)
# 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 /^\\\w+{/.test(this.completions.filterText) and nextChar == "}"
editor.session.remove(range)
# Provide our own `insertMatch` implementation.
# See the `insertMatch` method of Autocomplete in `ext-language_tools.js`.
# We need this to account for editing existing commands, particularly when
# adding a prefix.
# We fix this by detecting when the cursor is in the middle of an existing
# command, and adjusting the insertions/deletions accordingly.
# Example:
# when changing `\ref{}` to `\href{}`, ace default behaviour
# is likely to end up with `\href{}ref{}`
if !data?
completions = this.completions
popup = this.popup
data = popup.getData(popup.getRow())
data.completer =
insertMatch: (editor, matchData) ->
for range in editor.selection.getAllRanges()
leftRange = _.clone(range)
rightRange = _.clone(range)
# trim to left of cursor
lineUpToCursor = editor.getSession().getTextRange(
new Range(
range.start.row,
0,
range.start.row,
range.start.column,
)
)
# Delete back to command start, as appropriate
commandStartIndex = Helpers.getLastCommandFragmentIndex(lineUpToCursor)
if commandStartIndex != -1
leftRange.start.column = commandStartIndex
else
leftRange.start.column -= completions.filterText.length
editor.session.remove(leftRange)
# look at text after cursor
lineBeyondCursor = editor.getSession().getTextRange(
new Range(
rightRange.start.row,
rightRange.start.column,
rightRange.end.row,
99999
)
)
if lineBeyondCursor
if partialCommandMatch = lineBeyondCursor.match(/^([a-zA-Z0-9]+)\{/)
# We've got a partial command after the cursor
commandTail = partialCommandMatch[1]
# remove rest of the partial command, right of cursor
rightRange.end.column += commandTail.length - completions.filterText.length
editor.session.remove(rightRange);
# trim the completion text to just the command, without braces or brackets
# example: '\cite{}' -> '\cite'
if matchData.snippet?
matchData.snippet = matchData.snippet.replace(/[{\[].*[}\]]/, '')
if matchData.caption?
matchData.caption = matchData.caption.replace(/[{\[].*[}\]]/, '')
if matchData.value?
matchData.value = matchData.value.replace(/[{\[].*[}\]]/, '')
# finally, insert the match
if matchData.snippet
aceSnippetManager.insertSnippet(editor, matchData.snippet);
else
editor.execCommand("insertstring", matchData.value || matchData);
Autocomplete::_insertMatch.call this, data
# Overwrite this to set autoInsert = false and set font size
Autocomplete.startCommand = {
name: "startAutocomplete",
exec: (editor) =>
if (!editor.completer)
editor.completer = new Autocomplete()
editor.completer.autoInsert = false
editor.completer.autoSelect = true
editor.completer.showPopup(editor)
editor.completer.cancelContextMenu()
container = $(editor.completer.popup?.container)
container.css({'font-size': @$scope.fontSize + 'px'})
# Dynamically set width of autocomplete popup
if filtered = editor?.completer?.completions?.filtered
longestCaption = _.max(filtered.map( (c) -> c.caption.length ))
longestMeta = _.max(filtered.map( (c) -> c.meta.length ))
charWidth = editor.renderer.characterWidth
# between 280 and 700 px
width = Math.max(
Math.min(
Math.round(longestCaption*charWidth + longestMeta*charWidth + 5*charWidth),
700
),
280
)
container.css({width: "#{width}px"})
if editor.completer?.completions?.filtered?.length == 0
editor.completer.detach()
bindKey: "Ctrl-Space|Ctrl-Shift-Space|Alt-Space"
}
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 = Helpers.getLastCommandFragment(currentLine) or ""
return fragment

View File

@@ -1,167 +0,0 @@
define [
"./snippets/TopHundredSnippets"
], (topHundred) ->
class Parser
constructor: (@doc, @prefix) ->
parse: () ->
# Safari regex is super slow, freezes browser for minutes on end,
# hacky solution: limit iterations
limit = null
if window?._ide?.browserIsSafari
limit = 5000
# fully formed commands
realCommands = []
# commands which match the prefix exactly,
# and could be partially typed or malformed
incidentalCommands = []
seen = {}
iterations = 0
while command = @nextCommand()
iterations += 1
if limit && iterations > limit
return realCommands
docState = @doc
optionalArgs = 0
while @consumeArgument("[", "]")
optionalArgs++
args = 0
while @consumeArgument("{", "}")
args++
commandHash = "#{command}\\#{optionalArgs}\\#{args}"
if @prefix? && "\\#{command}" == @prefix
incidentalCommands.push [command, optionalArgs, args]
else
if !seen[commandHash]?
seen[commandHash] = true
realCommands.push [command, optionalArgs, args]
# Reset to before argument to handle nested commands
@doc = docState
# check incidentals, see if we should pluck out a match
if incidentalCommands.length > 1
bestMatch = incidentalCommands.sort((a, b) => a[1]+a[2] < b[1]+b[2])[0]
realCommands.push bestMatch
return realCommands
# Ignore single letter commands since auto complete is moot then.
commandRegex: /\\([a-zA-Z]{2,})/
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 CommandManager
constructor: (@metadataManager) ->
getCompletions: (editor, session, pos, prefix, callback) ->
commandNames = {}
for snippet in topHundred
commandNames[snippet.caption.match(/\w+/)[0]] = true
packages = @metadataManager.getAllPackages()
packageCommands = []
for pkg, snippets of packages
for snippet in snippets
packageCommands.push snippet
commandNames[snippet.caption.match(/\w+/)[0]] = true
doc = session.getValue()
parser = new Parser(doc, prefix)
commands = parser.parse()
completions = []
for command in commands
if not commandNames[command[0]]
caption = "\\#{command[0]}"
score = if caption == prefix then 99 else 50
snippet = caption
i = 1
_.times command[1], () ->
snippet += "[${#{i}}]"
caption += "[]"
i++
_.times command[2], () ->
snippet += "{${#{i}}}"
caption += "{}"
i++
completions.push {
caption: caption
snippet: snippet
meta: "cmd"
score: score
}
completions = completions.concat topHundred, packageCommands
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
completionAfterCursor = ""
else
completionBeforeCursor = completionBase + args[0]
completionAfterCursor = args.slice(1)
return {
base: base
completion: completionBase + args
completionBeforeCursor: completionBeforeCursor
completionAfterCursor: completionAfterCursor
}

View File

@@ -1,194 +0,0 @@
define [
'ide/editor/directives/aceEditor/auto-complete/snippets/Environments'
], (Environments) ->
staticSnippets = for env in Environments.withoutSnippets
{
caption: "\\begin{#{env}}..."
snippet: """
\\begin{#{env}}
\t$1
\\end{#{env}}
"""
meta: "env"
}
staticSnippets = staticSnippets.concat [{
caption: "\\begin{array}..."
snippet: """
\\begin{array}{${1:cc}}
\t$2 & $3 \\\\\\\\
\t$4 & $5
\\end{array}
"""
meta: "env"
}, {
caption: "\\begin{figure}..."
snippet: """
\\begin{figure}
\t\\centering
\t\\includegraphics{$1}
\t\\caption{${2:Caption}}
\t\\label{${3:fig:my_label}}
\\end{figure}
"""
meta: "env"
}, {
caption: "\\begin{tabular}..."
snippet: """
\\begin{tabular}{${1:c|c}}
\t$2 & $3 \\\\\\\\
\t$4 & $5
\\end{tabular}
"""
meta: "env"
}, {
caption: "\\begin{table}..."
snippet: """
\\begin{table}[$1]
\t\\centering
\t\\begin{tabular}{${2:c|c}}
\t\t$3 & $4 \\\\\\\\
\t\t$5 & $6
\t\\end{tabular}
\t\\caption{${7:Caption}}
\t\\label{${8:tab:my_label}}
\\end{table}
"""
meta: "env"
}, {
caption: "\\begin{list}..."
snippet: """
\\begin{list}
\t\\item $1
\\end{list}
"""
meta: "env"
}, {
caption: "\\begin{enumerate}..."
snippet: """
\\begin{enumerate}
\t\\item $1
\\end{enumerate}
"""
meta: "env"
}, {
caption: "\\begin{itemize}..."
snippet: """
\\begin{itemize}
\t\\item $1
\\end{itemize}
"""
meta: "env"
}, {
caption: "\\begin{frame}..."
snippet: """
\\begin{frame}{${1:Frame Title}}
\t$2
\\end{frame}
"""
meta: "env"
}]
documentSnippet = {
caption: "\\begin{document}..."
snippet: """
\\begin{document}
$1
\\end{document}
"""
meta: "env"
}
bibliographySnippet = {
caption: "\\begin{thebibliography}..."
snippet: """
\\begin{thebibliography}{$1}
\\bibitem{$2}
$3
\\end{thebibliography}
"""
meta: "env"
}
staticSnippets.push(documentSnippet)
parseCustomEnvironments = (text) ->
re = /^\\newenvironment{(\w+)}.*$/gm
result = []
iterations = 0
while match = re.exec(text)
result.push {name: match[1], whitespace: null}
iterations += 1
if iterations >= 1000
return result
return result
parseBeginCommands = (text) ->
re = /^\\begin{(\w+)}.*\n([\t ]*).*$/gm
result = []
iterations = 0
while match = re.exec(text)
if match[1] not in Environments.all and match[1] != "document"
result.push {name: match[1], whitespace: match[2]}
iterations += 1
if iterations >= 1000
return result
return result
hasDocumentEnvironment = (text) ->
re = /^\\begin{document}/m
return re.exec(text) != null
hasBibliographyEnvironment = (text) ->
re = /^\\begin{thebibliography}/m
return re.exec(text) != null
class EnvironmentManager
getCompletions: (editor, session, pos, prefix, callback) ->
docText = session.getValue()
customEnvironments = parseCustomEnvironments(docText)
beginCommands = parseBeginCommands(docText)
if hasDocumentEnvironment(docText)
ind = staticSnippets.indexOf(documentSnippet)
if ind != -1
staticSnippets.splice(ind, 1)
else
staticSnippets.push documentSnippet
if hasBibliographyEnvironment(docText)
ind = staticSnippets.indexOf(bibliographySnippet)
if ind != -1
staticSnippets.splice(ind, 1)
else
staticSnippets.push bibliographySnippet
parsedItemsMap = {}
for environment in customEnvironments
parsedItemsMap[environment.name] = environment
for command in beginCommands
parsedItemsMap[command.name] = command
parsedItems = _.values(parsedItemsMap)
snippets = staticSnippets.concat(
parsedItems.map (item) ->
{
caption: "\\begin{#{item.name}}..."
snippet: """
\\begin{#{item.name}}
#{item.whitespace || ''}$0
\\end{#{item.name}}
"""
meta: "env"
}
).concat(
# arguably these `end` commands shouldn't be here, as they're not snippets
# but this is where we have access to the `begin` environment names
# *shrug*
parsedItems.map (item) ->
{
caption: "\\end{#{item.name}}"
value: "\\end{#{item.name}}"
meta: "env"
}
)
callback null, snippets
return EnvironmentManager

View File

@@ -1,43 +0,0 @@
define [
"ace/ace"
"ace/ext-language_tools"
], () ->
Range = ace.require("ace/range").Range
Helpers =
getLastCommandFragment: (lineUpToCursor) ->
if (index = Helpers.getLastCommandFragmentIndex(lineUpToCursor)) > -1
return lineUpToCursor.slice(index)
else
return null
getLastCommandFragmentIndex: (lineUpToCursor) ->
# This is hack to let us skip over commands in arguments, and
# go to the command on the same 'level' as us. E.g.
# \includegraphics[width=\textwidth]{..
# should not match the \textwidth.
blankArguments = lineUpToCursor.replace /\[([^\]]*)\]/g, (args) ->
Array(args.length + 1).join('.')
if m = blankArguments.match(/(\\[^\\]*)$/)
return m.index
else
return -1
getCommandNameFromFragment: (commandFragment) ->
commandFragment?.match(/\\(\w+)\{/)?[1]
getContext: (editor, pos) ->
upToCursorRange = new Range(pos.row, 0, pos.row, pos.column)
lineUpToCursor = editor.getSession().getTextRange(upToCursorRange)
commandFragment = Helpers.getLastCommandFragment(lineUpToCursor)
commandName = Helpers.getCommandNameFromFragment(commandFragment)
beyondCursorRange = new Range(pos.row, pos.column, pos.row, 99999)
lineBeyondCursor = editor.getSession().getTextRange(beyondCursorRange)
return {
lineUpToCursor,
commandFragment,
commandName,
lineBeyondCursor
}
return Helpers

View File

@@ -1,45 +0,0 @@
define [], () ->
packages = [
'inputenc', 'graphicx', 'amsmath', 'geometry', 'amssymb', 'hyperref',
'babel', 'color', 'xcolor', 'url', 'natbib', 'fontenc', 'fancyhdr',
'amsfonts', 'booktabs', 'amsthm', 'float', 'tikz', 'caption',
'setspace', 'multirow', 'array', 'multicol', 'titlesec', 'enumitem',
'ifthen', 'listings', 'blindtext', 'subcaption', 'times', 'bm',
'subfigure', 'algorithm', 'fontspec', 'biblatex', 'tabularx',
'microtype', 'etoolbox', 'parskip', 'calc', 'verbatim', 'mathtools',
'epsfig', 'wrapfig', 'lipsum', 'cite', 'textcomp', 'longtable',
'textpos', 'algpseudocode', 'enumerate', 'subfig', 'pdfpages',
'epstopdf', 'latexsym', 'lmodern', 'pifont', 'ragged2e', 'rotating',
'dcolumn', 'xltxtra', 'marvosym', 'indentfirst', 'xspace', 'csquotes',
'xparse', 'changepage', 'soul', 'xunicode', 'comment', 'mathrsfs',
'tocbibind', 'lastpage', 'algorithm2e', 'pgfplots', 'lineno',
'graphics', 'algorithmic', 'fullpage', 'mathptmx', 'todonotes',
'ulem', 'tweaklist', 'moderncvstyleclassic', 'collection',
'moderncvcompatibility', 'gensymb', 'helvet', 'siunitx', 'adjustbox',
'placeins', 'colortbl', 'appendix', 'makeidx', 'supertabular', 'ifpdf',
'framed', 'aliascnt', 'layaureo', 'authblk'
]
class PackageManager
constructor: (@metadataManager) ->
getCompletions: (editor, session, pos, prefix, callback) ->
usedPackages = Object.keys(@metadataManager.getAllPackages())
packageSnippets = []
for pkg in packages
if pkg not in usedPackages
packageSnippets.push {
caption: "\\usepackage{#{pkg}}"
snippet: "\\usepackage{#{pkg}}"
meta: "pkg"
}
packageSnippets.push {
caption: "\\usepackage{}"
snippet: "\\usepackage{$1}"
meta: "pkg"
score: 70
}
callback null, packageSnippets
return PackageManager

View File

@@ -1,29 +0,0 @@
define () ->
envs = [
"abstract",
"align", "align*",
"equation", "equation*",
"gather", "gather*",
"multline", "multline*",
"split",
"verbatim",
"quote",
"center"
]
envsWithSnippets = [
"array",
"figure",
"tabular",
"table",
"list",
"enumerate",
"itemize",
"frame",
"thebibliography"
]
return {
all: envs.concat(envsWithSnippets)
withoutSnippets: envs
}

View File

@@ -1,691 +0,0 @@
define -> [{
"caption": "\\begin{}",
"snippet": "\\begin{$1}",
"meta": "env",
"score": 7.849662248028187
}, {
"caption": "\\begin{}[]",
"snippet": "\\begin{$1}[$2]",
"meta": "env",
"score": 7.849662248028187
}, {
"caption": "\\begin{}{}",
"snippet": "\\begin{$1}{$2}",
"meta": "env",
"score": 7.849662248028187
}, {
"caption": "\\end{}",
"snippet": "\\end{$1}",
"meta": "env",
"score": 7.847906405228455
}, {
"caption": "\\usepackage[]{}",
"snippet": "\\usepackage[$1]{$2}",
"meta": "pkg",
"score": 5.427890758130527
}, {
"caption": "\\item",
"snippet": "\\item",
"meta": "cmd",
"score": 3.800886892251021
}, {
"caption": "\\item[]",
"snippet": "\\item[$1]",
"meta": "cmd",
"score": 3.800886892251021
}, {
"caption": "\\section{}",
"snippet": "\\section{$1}",
"meta": "cmd",
"score": 3.0952612541683835
}, {
"caption": "\\textbf{}",
"snippet": "\\textbf{$1}",
"meta": "cmd",
"score": 2.627755982816738
}, {
"caption": "\\cite{}",
"snippet": "\\cite{$1}",
"meta": "cmd",
"score": 2.341195220791228
}, {
"caption": "\\label{}",
"snippet": "\\label{$1}",
"meta": "cmd",
"score": 1.897791904799601
}, {
"caption": "\\textit{}",
"snippet": "\\textit{$1}",
"meta": "cmd",
"score": 1.6842996195493385
}, {
"caption": "\\includegraphics[]{}",
"snippet": "\\includegraphics[$1]{$2}",
"meta": "cmd",
"score": 1.4595731795525781
}, {
"caption": "\\documentclass[]{}",
"snippet": "\\documentclass[$1]{$2}",
"meta": "cmd",
"score": 1.4425339817971206
}, {
"caption": "\\documentclass{}",
"snippet": "\\documentclass{$1}",
"meta": "cmd",
"score": 1.4425339817971206
}, {
"caption": "\\ref{}",
"snippet": "\\ref{$1}",
"meta": "cross-reference",
"score": 0.014379554883991673
}, {
"caption": "\\frac{}{}",
"snippet": "\\frac{$1}{$2}",
"meta": "cmd",
"score": 1.4341091141105058
}, {
"caption": "\\subsection{}",
"snippet": "\\subsection{$1}",
"meta": "cmd",
"score": 1.3890912739512353
}, {
"caption": "\\hline",
"snippet": "\\hline",
"meta": "cmd",
"score": 1.3209538327406387
}, {
"caption": "\\caption{}",
"snippet": "\\caption{$1}",
"meta": "cmd",
"score": 1.2569477427490174
}, {
"caption": "\\centering",
"snippet": "\\centering",
"meta": "cmd",
"score": 1.1642881814937829
}, {
"caption": "\\vspace{}",
"snippet": "\\vspace{$1}",
"meta": "cmd",
"score": 0.9533807826673939
}, {
"caption": "\\title{}",
"snippet": "\\title{$1}",
"meta": "cmd",
"score": 0.9202908262245683
}, {
"caption": "\\author{}",
"snippet": "\\author{$1}",
"meta": "cmd",
"score": 0.8973590434087177
}, {
"caption": "\\author[]{}",
"snippet": "\\author[$1]{$2}",
"meta": "cmd",
"score": 0.8973590434087177
}, {
"caption": "\\maketitle",
"snippet": "\\maketitle",
"meta": "cmd",
"score": 0.7504160124360846
}, {
"caption": "\\textwidth",
"snippet": "\\textwidth",
"meta": "cmd",
"score": 0.7355328080889112
}, {
"caption": "\\newcommand{}{}",
"snippet": "\\newcommand{$1}{$2}",
"meta": "cmd",
"score": 0.7264891987129375
}, {
"caption": "\\newcommand{}[]{}",
"snippet": "\\newcommand{$1}[$2]{$3}",
"meta": "cmd",
"score": 0.7264891987129375
}, {
"caption": "\\date{}",
"snippet": "\\date{$1}",
"meta": "cmd",
"score": 0.7225518453076786
}, {
"caption": "\\emph{}",
"snippet": "\\emph{$1}",
"meta": "cmd",
"score": 0.7060308784832261
}, {
"caption": "\\textsc{}",
"snippet": "\\textsc{$1}",
"meta": "cmd",
"score": 0.6926466355384758
}, {
"caption": "\\multicolumn{}{}{}",
"snippet": "\\multicolumn{$1}{$2}{$3}",
"meta": "cmd",
"score": 0.5473606021405326
}, {
"caption": "\\input{}",
"snippet": "\\input{$1}",
"meta": "cmd",
"score": 0.4966021927742672
}, {
"caption": "\\alpha",
"snippet": "\\alpha",
"meta": "cmd",
"score": 0.49520006391384913
}, {
"caption": "\\in",
"snippet": "\\in",
"meta": "cmd",
"score": 0.4716039670146658
}, {
"caption": "\\mathbf{}",
"snippet": "\\mathbf{$1}",
"meta": "cmd",
"score": 0.4682018419466319
}, {
"caption": "\\right",
"snippet": "\\right",
"meta": "cmd",
"score": 0.4299239459457309
}, {
"caption": "\\left",
"snippet": "\\left",
"meta": "cmd",
"score": 0.42937815279867964
}, {
"caption": "\\left[]",
"snippet": "\\left[$1]",
"meta": "cmd",
"score": 0.42937815279867964
}, {
"caption": "\\sum",
"snippet": "\\sum",
"meta": "cmd",
"score": 0.42607994509619934
}, {
"caption": "\\noindent",
"snippet": "\\noindent",
"meta": "cmd",
"score": 0.42355747798114207
}, {
"caption": "\\chapter{}",
"snippet": "\\chapter{$1}",
"meta": "cmd",
"score": 0.422097569591803
}, {
"caption": "\\par",
"snippet": "\\par",
"meta": "cmd",
"score": 0.413853376001159
}, {
"caption": "\\lambda",
"snippet": "\\lambda",
"meta": "cmd",
"score": 0.39389600578684125
}, {
"caption": "\\subsubsection{}",
"snippet": "\\subsubsection{$1}",
"meta": "cmd",
"score": 0.3727781330132016
}, {
"caption": "\\bibitem{}",
"snippet": "\\bibitem{$1}",
"meta": "cmd",
"score": 0.3689547570562042
}, {
"caption": "\\bibitem[]{}",
"snippet": "\\bibitem[$1]{$2}",
"meta": "cmd",
"score": 0.3689547570562042
}, {
"caption": "\\text{}",
"snippet": "\\text{$1}",
"meta": "cmd",
"score": 0.3608680734736821
}, {
"caption": "\\setlength{}{}",
"snippet": "\\setlength{$1}{$2}",
"meta": "cmd",
"score": 0.354445763583904
}, {
"caption": "\\setlength",
"snippet": "\\setlength",
"meta": "cmd",
"score": 0.354445763583904
}, {
"caption": "\\mathcal{}",
"snippet": "\\mathcal{$1}",
"meta": "cmd",
"score": 0.35084018920966636
}, {
"caption": "\\newline",
"snippet": "\\newline",
"meta": "cmd",
"score": 0.3311721696201715
}, {
"caption": "\\newpage",
"snippet": "\\newpage",
"meta": "cmd",
"score": 0.3277033727934986
}, {
"caption": "\\renewcommand{}{}",
"snippet": "\\renewcommand{$1}{$2}",
"meta": "cmd",
"score": 0.3267437011085663
}, {
"caption": "\\renewcommand",
"snippet": "\\renewcommand",
"meta": "cmd",
"score": 0.3267437011085663
}, {
"caption": "\\theta",
"snippet": "\\theta",
"meta": "cmd",
"score": 0.3210417159232142
}, {
"caption": "\\hspace{}",
"snippet": "\\hspace{$1}",
"meta": "cmd",
"score": 0.3147206476372336
}, {
"caption": "\\beta",
"snippet": "\\beta",
"meta": "cmd",
"score": 0.3061799530337638
}, {
"caption": "\\texttt{}",
"snippet": "\\texttt{$1}",
"meta": "cmd",
"score": 0.3019066753744355
}, {
"caption": "\\times",
"snippet": "\\times",
"meta": "cmd",
"score": 0.2957960629411553
}, {
"caption": "\\citep{}",
"snippet": "\\citep{$1}",
"meta": "cmd",
"score": 0.2941882834697057
}, {
"caption": "\\color[]{}",
"snippet": "\\color[$1]{$2}",
"meta": "cmd",
"score": 0.2864294797053033
}, {
"caption": "\\color{}",
"snippet": "\\color{$1}",
"meta": "cmd",
"score": 0.2864294797053033
}, {
"caption": "\\mu",
"snippet": "\\mu",
"meta": "cmd",
"score": 0.27635652476799255
}, {
"caption": "\\bibliography{}",
"snippet": "\\bibliography{$1}",
"meta": "cmd",
"score": 0.2659628337907604
}, {
"caption": "\\linewidth",
"snippet": "\\linewidth",
"meta": "cmd",
"score": 0.2639498312518439
}, {
"caption": "\\delta",
"snippet": "\\delta",
"meta": "cmd",
"score": 0.2620578600722735
}, {
"caption": "\\sigma",
"snippet": "\\sigma",
"meta": "cmd",
"score": 0.25940147926344487
}, {
"caption": "\\pi",
"snippet": "\\pi",
"meta": "cmd",
"score": 0.25920934567729714
}, {
"caption": "\\hat{}",
"snippet": "\\hat{$1}",
"meta": "cmd",
"score": 0.25264309033778715
}, {
"caption": "\\hat",
"snippet": "\\hat",
"meta": "cmd",
"score": 0.25264309033778715
}, {
"caption": "\\bibliographystyle{}",
"snippet": "\\bibliographystyle{$1}",
"meta": "cmd",
"score": 0.25122317941387773
}, {
"caption": "\\small",
"snippet": "\\small",
"meta": "cmd",
"score": 0.2447632045426295
}, {
"caption": "\\small{}",
"snippet": "\\small{$1}",
"meta": "cmd",
"score": 0.2447632045426295
}, {
"caption": "\\LaTeX",
"snippet": "\\LaTeX",
"meta": "cmd",
"score": 0.2334089308452787
}, {
"caption": "\\LaTeX{}",
"snippet": "\\LaTeX{$1}",
"meta": "cmd",
"score": 0.2334089308452787
}, {
"caption": "\\cdot",
"snippet": "\\cdot",
"meta": "cmd",
"score": 0.23029085545522762
}, {
"caption": "\\footnote{}",
"snippet": "\\footnote{$1}",
"meta": "cmd",
"score": 0.2253056071787701
}, {
"caption": "\\newtheorem{}[]{}",
"snippet": "\\newtheorem{$1}[$2]{$3}",
"meta": "cmd",
"score": 0.215689795055434
}, {
"caption": "\\newtheorem{}{}",
"snippet": "\\newtheorem{$1}{$2}",
"meta": "cmd",
"score": 0.215689795055434
}, {
"caption": "\\newtheorem{}{}[]",
"snippet": "\\newtheorem{$1}{$2}[$3]",
"meta": "cmd",
"score": 0.215689795055434
}, {
"caption": "\\Delta",
"snippet": "\\Delta",
"meta": "cmd",
"score": 0.21386475063892618
}, {
"caption": "\\tau",
"snippet": "\\tau",
"meta": "cmd",
"score": 0.21236188205859796
}, {
"caption": "\\hfill",
"snippet": "\\hfill",
"meta": "cmd",
"score": 0.2058248088519886
}, {
"caption": "\\leq",
"snippet": "\\leq",
"meta": "cmd",
"score": 0.20498894440637172
}, {
"caption": "\\footnotesize",
"snippet": "\\footnotesize",
"meta": "cmd",
"score": 0.2038592081252624
}, {
"caption": "\\footnotesize{}",
"snippet": "\\footnotesize{$1}",
"meta": "cmd",
"score": 0.2038592081252624
}, {
"caption": "\\large",
"snippet": "\\large",
"meta": "cmd",
"score": 0.20377416734108866
}, {
"caption": "\\large{}",
"snippet": "\\large{$1}",
"meta": "cmd",
"score": 0.20377416734108866
}, {
"caption": "\\sqrt{}",
"snippet": "\\sqrt{$1}",
"meta": "cmd",
"score": 0.20240160977404634
}, {
"caption": "\\epsilon",
"snippet": "\\epsilon",
"meta": "cmd",
"score": 0.2005136761359043
}, {
"caption": "\\Large",
"snippet": "\\Large",
"meta": "cmd",
"score": 0.1987771081149759
}, {
"caption": "\\Large{}",
"snippet": "\\Large{$1}",
"meta": "cmd",
"score": 0.1987771081149759
}, {
"caption": "\\cvitem{}{}",
"snippet": "\\cvitem{$1}{$2}",
"meta": "cmd",
"score": 0.19605476980016281
}, {
"caption": "\\rho",
"snippet": "\\rho",
"meta": "cmd",
"score": 0.1959287380541684
}, {
"caption": "\\omega",
"snippet": "\\omega",
"meta": "cmd",
"score": 0.19326783415115262
}, {
"caption": "\\mathrm{}",
"snippet": "\\mathrm{$1}",
"meta": "cmd",
"score": 0.19117752976172653
}, {
"caption": "\\boldsymbol{}",
"snippet": "\\boldsymbol{$1}",
"meta": "cmd",
"score": 0.18137737738638837
}, {
"caption": "\\boldsymbol",
"snippet": "\\boldsymbol",
"meta": "cmd",
"score": 0.18137737738638837
}, {
"caption": "\\gamma",
"snippet": "\\gamma",
"meta": "cmd",
"score": 0.17940276535431304
}, {
"caption": "\\clearpage",
"snippet": "\\clearpage",
"meta": "cmd",
"score": 0.1789117552185788
}, {
"caption": "\\infty",
"snippet": "\\infty",
"meta": "cmd",
"score": 0.17837290019711305
}, {
"caption": "\\phi",
"snippet": "\\phi",
"meta": "cmd",
"score": 0.17405809173097808
}, {
"caption": "\\partial",
"snippet": "\\partial",
"meta": "cmd",
"score": 0.17168102367966637
}, {
"caption": "\\include{}",
"snippet": "\\include{$1}",
"meta": "cmd",
"score": 0.1547080054979312
}, {
"caption": "\\address{}",
"snippet": "\\address{$1}",
"meta": "cmd",
"score": 0.1525055392611109
}, {
"caption": "\\address[]{}",
"snippet": "\\address[$1]{$2}",
"meta": "cmd",
"score": 0.1525055392611109
}, {
"caption": "\\address{}{}{}",
"snippet": "\\address{$1}{$2}{$3}",
"meta": "cmd",
"score": 0.1525055392611109
}, {
"caption": "\\quad",
"snippet": "\\quad",
"meta": "cmd",
"score": 0.15242755832392743
}, {
"caption": "\\email{}",
"snippet": "\\email{$1}",
"meta": "cmd",
"score": 0.1522294670109857
}, {
"caption": "\\paragraph{}",
"snippet": "\\paragraph{$1}",
"meta": "cmd",
"score": 0.152074250347974
}, {
"caption": "\\varepsilon",
"snippet": "\\varepsilon",
"meta": "cmd",
"score": 0.05411564201390573
}, {
"caption": "\\zeta",
"snippet": "\\zeta",
"meta": "cmd",
"score": 0.023330249803752954
}, {
"caption": "\\eta",
"snippet": "\\eta",
"meta": "cmd",
"score": 0.11088718379889091
}, {
"caption": "\\vartheta",
"snippet": "\\vartheta",
"meta": "cmd",
"score": 0.0025822992078068712
}, {
"caption": "\\iota",
"snippet": "\\iota",
"meta": "cmd",
"score": 0.0024774003791525486
}, {
"caption": "\\kappa",
"snippet": "\\kappa",
"meta": "cmd",
"score": 0.04887876299369008
}, {
"caption": "\\nu",
"snippet": "\\nu",
"meta": "cmd",
"score": 0.09206962821059342
}, {
"caption": "\\xi",
"snippet": "\\xi",
"meta": "cmd",
"score": 0.06496042899265699
}, {
"caption": "\\varpi",
"snippet": "\\varpi",
"meta": "cmd",
"score": 0.0007039358167790341
}, {
"caption": "\\varrho",
"snippet": "\\varrho",
"meta": "cmd",
"score": 0.0011279491613898612
}, {
"caption": "\\varsigma",
"snippet": "\\varsigma",
"meta": "cmd",
"score": 0.0010424880711234978
}, {
"caption": "\\varsigma{}",
"snippet": "\\varsigma{$1}",
"meta": "cmd",
"score": 0.0010424880711234978
}, {
"caption": "\\upsilon",
"snippet": "\\upsilon",
"meta": "cmd",
"score": 0.00420715572598688
}, {
"caption": "\\varphi",
"snippet": "\\varphi",
"meta": "cmd",
"score": 0.03351251516668212
}, {
"caption": "\\chi",
"snippet": "\\chi",
"meta": "cmd",
"score": 0.043373492287805675
}, {
"caption": "\\psi",
"snippet": "\\psi",
"meta": "cmd",
"score": 0.09994508706163642
}, {
"caption": "\\Gamma",
"snippet": "\\Gamma",
"meta": "cmd",
"score": 0.04801549269801977
}, {
"caption": "\\Theta",
"snippet": "\\Theta",
"meta": "cmd",
"score": 0.038090902146599444
}, {
"caption": "\\Lambda",
"snippet": "\\Lambda",
"meta": "cmd",
"score": 0.032206594305977686
}, {
"caption": "\\Xi",
"snippet": "\\Xi",
"meta": "cmd",
"score": 0.01060997225400494
}, {
"caption": "\\Pi",
"snippet": "\\Pi",
"meta": "cmd",
"score": 0.021264671817473237
}, {
"caption": "\\Sigma",
"snippet": "\\Sigma",
"meta": "cmd",
"score": 0.05769642802079917
}, {
"caption": "\\Upsilon",
"snippet": "\\Upsilon",
"meta": "cmd",
"score": 0.00032875192955749566
}, {
"caption": "\\Phi",
"snippet": "\\Phi",
"meta": "cmd",
"score": 0.0538724950042562
}, {
"caption": "\\Psi",
"snippet": "\\Psi",
"meta": "cmd",
"score": 0.03056589143021648
}, {
"caption": "\\Omega",
"snippet": "\\Omega",
"meta": "cmd",
"score": 0.09490387997853639
}]

View File

@@ -1,32 +0,0 @@
define [
"ide/editor/AceShareJsCodec"
], (AceShareJsCodec) ->
class CursorPositionAdapter
constructor: (@editor) ->
getCursor: () ->
@editor.getCursorPosition()
getEditorScrollPosition: () ->
@editor.getFirstVisibleRow()
setCursor: (pos) ->
pos = pos.cursorPosition or { row: 0, column: 0 }
@editor.moveCursorToPosition(pos)
setEditorScrollPosition: (pos) ->
pos = pos.firstVisibleLine or 0
@editor.scrollToLine(pos)
clearSelection: () ->
@editor.selection.clearSelection()
gotoLine: (line, column) ->
@editor.gotoLine(line, column)
@editor.scrollToLine(line, true, true) # centre and animate
@editor.focus()
gotoOffset: (offset) ->
lines = @editor.getSession().getDocument().getAllLines()
position = AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
@gotoLine(position.row + 1, position.column)

View File

@@ -1,67 +0,0 @@
define [], () ->
class CursorPositionManager
constructor: (@$scope, @adapter, @localStorage) ->
@$scope.$on 'editorInit', @jumpToPositionInNewDoc
@$scope.$on 'beforeChangeDocument', @storePositionAndLine
@$scope.$on 'afterChangeDocument', @jumpToPositionInNewDoc
@$scope.$on 'changeEditor', @storePositionAndLine
@$scope.$on "#{@$scope.name}:gotoLine", (e, line, column) =>
if line?
setTimeout () =>
@adapter.gotoLine(line, column)
, 10 # Hack: Must happen after @gotoStoredPosition
@$scope.$on "#{@$scope.name}:gotoOffset", (e, offset) =>
if offset?
setTimeout () =>
@adapter.gotoOffset(offset)
, 10 # Hack: Must happen after @gotoStoredPosition
@$scope.$on "#{@$scope.name}:clearSelection", (e) =>
@adapter.clearSelection()
storePositionAndLine: () =>
@storeCursorPosition()
@storeFirstVisibleLine()
jumpToPositionInNewDoc: () =>
@doc_id = @$scope.sharejsDoc?.doc_id
setTimeout () =>
@gotoStoredPosition()
, 0
onUnload: () =>
@storeCursorPosition()
@storeFirstVisibleLine()
onCursorChange: () =>
@emitCursorUpdateEvent()
onSyncToPdf: () =>
@$scope.$emit "cursor:#{@$scope.name}:syncToPdf"
storeFirstVisibleLine: () ->
if @doc_id?
docPosition = @localStorage("doc.position.#{@doc_id}") || {}
docPosition.firstVisibleLine = @adapter.getEditorScrollPosition()
@localStorage("doc.position.#{@doc_id}", docPosition)
storeCursorPosition: () ->
if @doc_id?
docPosition = @localStorage("doc.position.#{@doc_id}") || {}
docPosition.cursorPosition = @adapter.getCursor()
@localStorage("doc.position.#{@doc_id}", docPosition)
emitCursorUpdateEvent: () ->
cursor = @adapter.getCursor()
@$scope.$emit "cursor:#{@$scope.name}:update", cursor
gotoStoredPosition: () ->
return if !@doc_id?
pos = @localStorage("doc.position.#{@doc_id}") || {}
@adapter.setCursor(pos)
@adapter.setEditorScrollPosition(pos)

View File

@@ -1,265 +0,0 @@
define [
"ace/ace"
"ide/colors/ColorManager"
], (_, ColorManager) ->
Range = ace.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.updateLabels = {
updatesAbove: 0
updatesBelow: 0
}
@$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)
onChangeScrollTop = () =>
@updateShowMoreLabels()
@editor.getSession().on "changeScrollTop", onChangeScrollTop
@$scope.$watch "text", () =>
if @$scope.navigateHighlights
setTimeout () =>
@scrollToFirstHighlight()
, 0
@editor.on "changeSession", (e) =>
e.oldSession?.off "changeScrollTop", onChangeScrollTop
e.session.on "changeScrollTop", onChangeScrollTop
@redrawAnnotations()
@$scope.gotoHighlightBelow = () =>
return if !@firstHiddenHighlightAfter?
@editor.scrollToLine(@firstHiddenHighlightAfter.end.row, true, false)
@$scope.gotoHighlightAbove = () =>
return if !@lastHiddenHighlightBefore?
@editor.scrollToLine(@lastHiddenHighlightBefore.start.row, true, false)
redrawAnnotations: () ->
@_clearMarkers()
@_clearLabels()
for annotation in @$scope.highlights or []
do (annotation) =>
colorScheme = ColorManager.getColorScheme(annotation.hue, @element)
if annotation.cursor?
@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)
@updateShowMoreLabels()
showAnnotationLabels: (position) ->
labelToShow = null
for label in @labels or []
if label.range.contains(position.row, position.column)
labelToShow = label
if !labelToShow?
# this is the most common path, triggered on mousemove, so
# for performance only apply setting when it changes
if @$scope?.annotationLabel?.show != false
@$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")
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
}
updateShowMoreLabels: () ->
return if !@$scope.navigateHighlights
setTimeout () =>
firstRow = @editor.getFirstVisibleRow()
lastRow = @editor.getLastVisibleRow()
highlightsBefore = 0
highlightsAfter = 0
@lastHiddenHighlightBefore = null
@firstHiddenHighlightAfter = null
for annotation in @$scope.highlights or []
range = annotation.highlight or annotation.strikeThrough
continue if !range?
if range.start.row < firstRow
highlightsBefore += 1
@lastHiddenHighlightBefore = range
if range.end.row > lastRow
highlightsAfter += 1
@firstHiddenHighlightAfter ||= range
@$scope.$apply =>
@$scope.updateLabels = {
highlightsBefore: highlightsBefore
highlightsAfter: highlightsAfter
}
, 100
scrollToFirstHighlight: () ->
for annotation in @$scope.highlights or []
range = annotation.highlight or annotation.strikeThrough
continue if !range?
@editor.scrollToLine(range.start.row, true, false)
break
_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
),
"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
),
"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
),
"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

View File

@@ -1,66 +0,0 @@
define [
"ace/ace"
], () ->
Range = ace.require("ace/range").Range
getLastCommandFragment = (lineUpToCursor) ->
if m = lineUpToCursor.match(/(\\[^\\]+)$/)
return m[1]
else
return null
class MetadataManager
constructor: (@$scope, @editor, @element, @Metadata) ->
@debouncer = {} # DocId => Timeout
onChange = (change) =>
if change.remote
return
if change.action not in ['remove', 'insert']
return
cursorPosition = @editor.getCursorPosition()
end = change.end
range = new Range(end.row, 0, end.row, end.column)
lineUpToCursor = @editor.getSession().getTextRange range
if lineUpToCursor.trim() == '%' or lineUpToCursor.slice(0, 1) == '\\'
range = new Range(end.row, 0, end.row, end.column + 80)
lineUpToCursor = @editor.getSession().getTextRange range
commandFragment = getLastCommandFragment lineUpToCursor
linesContainPackage = _.any(
change.lines,
(line) -> line.match(/^\\usepackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
)
linesContainReqPackage = _.any(
change.lines,
(line) -> line.match(/^\\RequirePackage(?:\[.{0,80}?])?{(.{0,80}?)}/)
)
linesContainLabel = _.any(
change.lines,
(line) -> line.match(/\\label{(.{0,80}?)}/)
)
linesContainMeta =
linesContainPackage or
linesContainLabel or
linesContainReqPackage
lastCommandFragmentIsLabel = commandFragment?.slice(0, 7) == '\\label{'
lastCommandFragmentIsPackage = commandFragment?.slice(0, 11) == '\\usepackage'
lastCommandFragmentIsReqPack = commandFragment?.slice(0, 15) == '\\RequirePackage'
lastCommandFragmentIsMeta =
lastCommandFragmentIsPackage or
lastCommandFragmentIsLabel or
lastCommandFragmentIsReqPack
if linesContainMeta or lastCommandFragmentIsMeta
@Metadata.scheduleLoadDocMetaFromServer @$scope.docId
@editor.on "changeSession", (e) =>
e.oldSession.off "change", onChange
e.session.on "change", onChange
getAllLabels: () ->
@Metadata.getAllLabels()
getAllPackages: () ->
@Metadata.getAllPackages()

View File

@@ -1,96 +0,0 @@
define [
"ace/ace"
], () ->
Range = ace.require("ace/range").Range
class Highlight
constructor: (@markerId, @range, options) ->
@word = options.word
@suggestions = options.suggestions
class HighlightedWordManager
constructor: (@editor) ->
@reset()
reset: () ->
@highlights?.forEach (highlight) =>
@editor.getSession().removeMarker(highlight.markerId)
@highlights = []
addHighlight: (options) ->
session = @editor.getSession()
doc = session.getDocument()
# Set up Range that will automatically update it's positions when the
# document changes
range = new Range()
range.start = doc.createAnchor({
row: options.row,
column: options.column
})
range.end = doc.createAnchor({
row: options.row,
column: options.column + options.word.length
})
# Prevent range from adding newly typed characters to the end of the word.
# This makes it appear as if the spelling error continues to the next word
# even after a space
range.end.$insertRight = true
markerId = session.addMarker range, "spelling-highlight", 'text', false
@highlights.push new Highlight(markerId, range, options)
removeHighlight: (highlight) ->
@editor.getSession().removeMarker(highlight.markerId)
@highlights = @highlights.filter (hl) ->
hl != highlight
removeWord: (word) ->
@highlights.filter (highlight) ->
highlight.word == word
.forEach (highlight) =>
@removeHighlight(highlight)
clearRow: (row) ->
@highlights.filter (highlight) ->
highlight.range.start.row == row
.forEach (highlight) =>
@removeHighlight(highlight)
findHighlightWithinRange: (range) ->
_.find @highlights, (highlight) =>
@_doesHighlightOverlapRange highlight, range.start, range.end
_doesHighlightOverlapRange: (highlight, start, end) ->
highlightRow = highlight.range.start.row
highlightStartColumn = highlight.range.start.column
highlightEndColumn = highlight.range.end.column
highlightIsAllBeforeRange =
highlightRow < start.row or
(highlightRow == start.row and highlightEndColumn <= start.column)
highlightIsAllAfterRange =
highlightRow > end.row or
(highlightRow == end.row and highlightStartColumn >= end.column)
!(highlightIsAllBeforeRange or highlightIsAllAfterRange)
clearHighlightTouchingRange: (range) ->
highlight = _.find @highlights, (hl) =>
@_doesHighlightTouchRange hl, range.start, range.end
if highlight
@removeHighlight highlight
_doesHighlightTouchRange: (highlight, start, end) ->
highlightRow = highlight.range.start.row
highlightStartColumn = highlight.range.start.column
highlightEndColumn = highlight.range.end.column
rangeStartIsWithinHighlight =
highlightStartColumn <= start.column and
highlightEndColumn >= start.column
rangeEndIsWithinHighlight =
highlightStartColumn <= end.column and
highlightEndColumn >= end.column
highlightRow == start.row and
(rangeStartIsWithinHighlight or rangeEndIsWithinHighlight)

View File

@@ -1,62 +0,0 @@
define [
"ace/ace"
"ide/editor/directives/aceEditor/spell-check/HighlightedWordManager"
], (Ace, HighlightedWordManager) ->
Range = ace.require('ace/range').Range
class SpellCheckAdapter
constructor: (@editor) ->
@highlightedWordManager = new HighlightedWordManager(@editor)
getLines: () ->
@editor.getValue().split('\n')
normalizeChangeEvent: (e) -> e
getCoordsFromContextMenuEvent: (e) ->
e.domEvent.stopPropagation()
return {
x: e.domEvent.clientX,
y: e.domEvent.clientY
}
preventContextMenuEventDefault: (e) ->
e.domEvent.preventDefault()
getHighlightFromCoords: (coords) ->
position = @editor.renderer.screenToTextCoordinates(coords.x, coords.y)
@highlightedWordManager.findHighlightWithinRange({
start: position
end: position
})
isContextMenuEventOnBottomHalf: (e) ->
clientY = e.domEvent.clientY
editorBoundingRect = e.target.container.getBoundingClientRect()
relativeYPos = (clientY - editorBoundingRect.top) / editorBoundingRect.height
return relativeYPos > 0.5
selectHighlightedWord: (highlight) ->
row = highlight.range.start.row
startColumn = highlight.range.start.column
endColumn = highlight.range.end.column
@editor.getSession().getSelection().setSelectionRange(
new Range(
row, startColumn,
row, endColumn
)
)
replaceWord: (highlight, newWord) =>
row = highlight.range.start.row
startColumn = highlight.range.start.column
endColumn = highlight.range.end.column
@editor.getSession().replace(new Range(
row, startColumn,
row, endColumn
), newWord)
# Bring editor back into focus after clicking on suggestion
@editor.focus()

View File

@@ -1,565 +0,0 @@
define [
"ace/ace"
"utils/EventEmitter"
"ide/colors/ColorManager"
"ide/editor/AceShareJsCodec"
], (_, EventEmitter, ColorManager, AceShareJsCodec) ->
class TrackChangesManager
Range = ace.require("ace/range").Range
constructor: (@$scope, @editor, @element) ->
window.trackChangesManager ?= @
@$scope.$watch "trackChanges", (track_changes) =>
return if !track_changes?
@setTrackChanges(track_changes)
@$scope.$watch "sharejsDoc", (doc, oldDoc) =>
return if !doc?
if oldDoc?
@disconnectFromDoc(oldDoc)
@connectToDoc(doc)
@$scope.$on "comment:add", (e, thread_id, offset, length) =>
@addCommentToSelection(thread_id, offset, length)
@$scope.$on "comment:select_line", (e) =>
@selectLineIfNoSelection()
@$scope.$on "changes:accept", (e, change_ids) =>
@acceptChangeIds(change_ids)
@$scope.$on "changes:reject", (e, change_ids) =>
@rejectChangeIds(change_ids)
@$scope.$on "comment:remove", (e, comment_id) =>
@removeCommentId(comment_id)
@$scope.$on "comment:resolve_threads", (e, thread_ids) =>
@hideCommentsByThreadIds(thread_ids)
@$scope.$on "comment:unresolve_thread", (e, thread_id) =>
@showCommentByThreadId(thread_id)
@$scope.$on "review-panel:recalculate-screen-positions", () =>
@recalculateReviewEntriesScreenPositions()
changingSelection = false
onChangeSelection = () =>
# Deletes can send about 5 changeSelection events, so
# just act on the last one.
if !changingSelection
changingSelection = true
@$scope.$evalAsync () =>
changingSelection = false
@updateFocus()
onResize = () =>
@recalculateReviewEntriesScreenPositions()
onChangeSession = (e) =>
@clearAnnotations()
@redrawAnnotations()
@editor.session.on "changeScrollTop", onChangeScroll
_scrollTimeout = null
onChangeScroll = () =>
if _scrollTimeout?
return
else
_scrollTimeout = setTimeout () =>
@recalculateVisibleEntries()
@$scope.$apply()
_scrollTimeout = null
, 200
@_resetCutState()
onCut = () => @onCut()
onPaste = () => @onPaste()
bindToAce = () =>
@editor.on "changeSelection", onChangeSelection
@editor.on "change", onChangeSelection # Selection also moves with updates elsewhere in the document
@editor.on "changeSession", onChangeSession
@editor.on "cut", onCut
@editor.on "paste", onPaste
@editor.renderer.on "resize", onResize
unbindFromAce = () =>
@editor.off "changeSelection", onChangeSelection
@editor.off "change", onChangeSelection
@editor.off "changeSession", onChangeSession
@editor.off "cut", onCut
@editor.off "paste", onPaste
@editor.renderer.off "resize", onResize
@$scope.$watch "trackChangesEnabled", (enabled) =>
return if !enabled?
if enabled
bindToAce()
else
unbindFromAce()
disconnectFromDoc: (doc) ->
@changeIdToMarkerIdMap = {}
doc.off "ranges:clear"
doc.off "ranges:redraw"
doc.off "ranges:dirty"
setTrackChanges: (value) ->
if value
@$scope.sharejsDoc?.track_changes_as = window.user.id or "anonymous"
else
@$scope.sharejsDoc?.track_changes_as = null
connectToDoc: (doc) ->
@rangesTracker = doc.ranges
@setTrackChanges(@$scope.trackChanges)
doc.on "ranges:dirty", () =>
@updateAnnotations()
doc.on "ranges:clear", () =>
@clearAnnotations()
doc.on "ranges:redraw", () =>
@redrawAnnotations()
clearAnnotations: () ->
session = @editor.getSession()
for change_id, markers of @changeIdToMarkerIdMap
for marker_name, marker_id of markers
session.removeMarker marker_id
@changeIdToMarkerIdMap = {}
redrawAnnotations: () ->
for change in @rangesTracker.changes
if change.op.i?
@_onInsertAdded(change)
else if change.op.d?
@_onDeleteAdded(change)
for comment in @rangesTracker.comments
@_onCommentAdded(comment)
@broadcastChange()
_doneUpdateThisLoop: false
_pendingUpdates: false
updateAnnotations: () ->
# Doc updates with multiple ops, like search/replace or block comments
# will call this with every individual op in a single event loop. So only
# do the first this loop, then schedule an update for the next loop for the rest.
if !@_doneUpdateThisLoop
@_doUpdateAnnotations()
@_doneUpdateThisLoop = true
setTimeout () =>
if @_pendingUpdates
@_doUpdateAnnotations()
@_doneUpdateThisLoop = false
@_pendingUpdates = false
else
@_pendingUpdates = true
_doUpdateAnnotations: () ->
dirty = @rangesTracker.getDirtyState()
updateMarkers = false
for id, change of dirty.change.added
if change.op.i?
@_onInsertAdded(change)
else if change.op.d?
@_onDeleteAdded(change)
for id, change of dirty.change.removed
if change.op.i?
@_onInsertRemoved(change)
else if change.op.d?
@_onDeleteRemoved(change)
for id, change of dirty.change.moved
updateMarkers = true
@_onChangeMoved(change)
for id, comment of dirty.comment.added
@_onCommentAdded(comment)
for id, comment of dirty.comment.removed
@_onCommentRemoved(comment)
for id, comment of dirty.comment.moved
updateMarkers = true
@_onCommentMoved(comment)
@rangesTracker.resetDirtyState()
if updateMarkers
@editor.renderer.updateBackMarkers()
@broadcastChange()
addComment: (offset, content, thread_id) ->
op = { c: content, p: offset, t: thread_id }
# @rangesTracker.applyOp op # Will apply via sharejs
@$scope.sharejsDoc.submitOp op
addCommentToSelection: (thread_id, offset, length) ->
start = @_shareJsOffsetToAcePosition(offset)
end = @_shareJsOffsetToAcePosition(offset + length)
range = new Range(start.row, start.column, end.row, end.column)
content = @editor.session.getTextRange(range)
@addComment(offset, content, thread_id)
selectLineIfNoSelection: () ->
if @editor.selection.isEmpty()
@editor.selection.selectLine()
acceptChangeIds: (change_ids) ->
@rangesTracker.removeChangeIds(change_ids)
@updateAnnotations()
@updateFocus()
rejectChangeIds: (change_ids) ->
changes = @rangesTracker.getChanges(change_ids)
return if changes.length == 0
# When doing bulk rejections, adjacent changes might interact with each other.
# Consider an insertion with an adjacent deletion (which is a common use-case, replacing words):
#
# "foo bar baz" -> "foo quux baz"
#
# The change above will be modeled with two ops, with the insertion going first:
#
# foo quux baz
# |--| -> insertion of "quux", op 1, at position 4
# | -> deletion of "bar", op 2, pushed forward by "quux" to position 8
#
# When rejecting these changes at once, if the insertion is rejected first, we get unexpected
# results. What happens is:
#
# 1) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
# starting from position 4;
#
# "foo quux baz" -> "foo baz"
# |--| -> 4 characters to be removed
#
# 2) Rejecting the deletion adds the deleted word "bar" at position 8 (i.e. it will act as if
# the word "quuux" was still present).
#
# "foo baz" -> "foo bazbar"
# | -> deletion of "bar" is reverted by reinserting "bar" at position 8
#
# While the intended result would be "foo bar baz", what we get is:
#
# "foo bazbar" (note "bar" readded at position 8)
#
# The issue happens because of step 1. To revert the insertion of "quux", 4 characters are deleted
# from position 4. This includes the position where the deletion exists; when that position is
# cleared, the RangesTracker considers that the deletion is gone and stops tracking/updating it.
# As we still hold a reference to it, the code tries to revert it by readding the deleted text, but
# does so at the outdated position (position 8, which was valid when "quux" was present).
#
# To avoid this kind of problem, we need to make sure that reverting operations doesn't affect
# subsequent operations that come after. Reverse sorting the operations based on position will
# achieve it; in the case above, it makes sure that the the deletion is reverted first:
#
# 1) Rejecting the deletion adds the deleted word "bar" at position 8
#
# "foo quux baz" -> "foo quuxbar baz"
# | -> deletion of "bar" is reverted by
# reinserting "bar" at position 8
#
# 2) Rejecting the insertion deletes the added word "quux", i.e., it removes 4 chars
# starting from position 4 and achieves the expected result:
#
# "foo quuxbar baz" -> "foo bar baz"
# |--| -> 4 characters to be removed
changes.sort((a, b) -> b.op.p - a.op.p)
session = @editor.getSession()
for change in changes
if change.op.d?
content = change.op.d
position = @_shareJsOffsetToAcePosition(change.op.p)
session.$fromReject = true # Tell track changes to cancel out delete
session.insert(position, content)
session.$fromReject = false
else if change.op.i?
start = @_shareJsOffsetToAcePosition(change.op.p)
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
editor_text = session.getDocument().getTextRange({start, end})
if editor_text != change.op.i
throw new Error("Op to be removed (#{JSON.stringify(change.op)}), does not match editor text, '#{editor_text}'")
session.$fromReject = true
session.remove({start, end})
session.$fromReject = false
else
throw new Error("unknown change: #{JSON.stringify(change)}")
setTimeout () => @updateFocus()
removeCommentId: (comment_id) ->
@rangesTracker.removeCommentId(comment_id)
@updateAnnotations()
hideCommentsByThreadIds: (thread_ids) ->
resolve_ids = {}
for id in thread_ids
resolve_ids[id] = true
for comment in @rangesTracker?.comments or []
if resolve_ids[comment.op.t]
@_onCommentRemoved(comment)
@broadcastChange()
showCommentByThreadId: (thread_id) ->
for comment in @rangesTracker?.comments or []
if comment.op.t == thread_id
@_onCommentAdded(comment)
@broadcastChange()
_resetCutState: () ->
@_cutState = {
text: null
comments: []
docId: null
}
onCut: () ->
@_resetCutState()
selection = @editor.getSelectionRange()
selection_start = @_aceRangeToShareJs(selection.start)
selection_end = @_aceRangeToShareJs(selection.end)
@_cutState.text = @editor.getSelectedText()
@_cutState.docId = @$scope.docId
for comment in @rangesTracker.comments
comment_start = comment.op.p
comment_end = comment_start + comment.op.c.length
if selection_start <= comment_start and comment_end <= selection_end
@_cutState.comments.push {
offset: comment.op.p - selection_start
text: comment.op.c
comment: comment
}
onPaste: () =>
@editor.once "change", (change) =>
return if change.action != "insert"
pasted_text = change.lines.join("\n")
paste_offset = @_aceRangeToShareJs(change.start)
# We have to wait until the change has been processed by the range tracker,
# since if we move the ops into place beforehand, they will be moved again
# when the changes are processed by the range tracker. This ranges:dirty
# event is fired after the doc has applied the changes to the range tracker.
@$scope.sharejsDoc.on "ranges:dirty.paste", () =>
@$scope.sharejsDoc.off "ranges:dirty.paste" # Doc event emitter uses namespaced events
if pasted_text == @_cutState.text and @$scope.docId == @_cutState.docId
for {comment, offset, text} in @_cutState.comments
op = { c: text, p: paste_offset + offset, t: comment.id }
@$scope.sharejsDoc.submitOp op # Resubmitting an existing comment op (by thread id) will move it
@_resetCutState()
# Check that comments still match text. Will throw error if not.
@rangesTracker.validate(@editor.getValue())
checkMapping: () ->
# TODO: reintroduce this check
session = @editor.getSession()
# Make a copy of session.getMarkers() so we can modify it
markers = {}
for marker_id, marker of session.getMarkers()
markers[marker_id] = marker
expected_markers = []
for change in @rangesTracker.changes
if @changeIdToMarkerIdMap[change.id]?
op = change.op
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
start = @_shareJsOffsetToAcePosition(op.p)
if op.i?
end = @_shareJsOffsetToAcePosition(op.p + op.i.length)
else if op.d?
end = start
expected_markers.push { marker_id: background_marker_id, start, end }
expected_markers.push { marker_id: callout_marker_id, start, end: start }
for comment in @rangesTracker.comments
if @changeIdToMarkerIdMap[comment.id]?
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
start = @_shareJsOffsetToAcePosition(comment.op.p)
end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
expected_markers.push { marker_id: background_marker_id, start, end }
expected_markers.push { marker_id: callout_marker_id, start, end: start }
for {marker_id, start, end} in expected_markers
marker = markers[marker_id]
delete markers[marker_id]
if marker.range.start.row != start.row or
marker.range.start.column != start.column or
marker.range.end.row != end.row or
marker.range.end.column != end.column
console.error "Change doesn't match marker anymore", {change, marker, start, end}
for marker_id, marker of markers
if /track-changes/.test(marker.clazz)
console.error "Orphaned ace marker", marker
updateFocus: () ->
selection = @editor.getSelectionRange()
selection_start = @_aceRangeToShareJs(selection.start)
selection_end = @_aceRangeToShareJs(selection.end)
entries = @_getCurrentDocEntries()
is_selection = (selection_start != selection_end)
@$scope.$emit "editor:focus:changed", selection_start, selection_end, is_selection
broadcastChange: () ->
@$scope.$emit "editor:track-changes:changed", @$scope.docId
recalculateReviewEntriesScreenPositions: () ->
session = @editor.getSession()
renderer = @editor.renderer
{firstRow, lastRow} = renderer.layerConfig
entries = @_getCurrentDocEntries()
for entry_id, entry of entries or {}
doc_position = @_shareJsOffsetToAcePosition(entry.offset)
screen_position = session.documentToScreenPosition(doc_position.row, doc_position.column)
y = screen_position.row * renderer.lineHeight
entry.screenPos ?= {}
entry.screenPos.y = y
entry.docPos = doc_position
@recalculateVisibleEntries()
@$scope.$apply()
recalculateVisibleEntries: () ->
OFFSCREEN_ROWS = 20
CULL_AFTER = 100 # With less than this number of entries, don't bother culling to avoid little UI jumps when scrolling.
{firstRow, lastRow} = @editor.renderer.layerConfig
entries = @_getCurrentDocEntries() or {}
entriesLength = Object.keys(entries).length
changed = false
for entry_id, entry of entries
old = entry.visible
entry.visible = (entriesLength < CULL_AFTER) or (firstRow - OFFSCREEN_ROWS <= entry.docPos.row <= lastRow + OFFSCREEN_ROWS)
if (entry.visible != old)
changed = true
if changed
@$scope.$emit "editor:track-changes:visibility_changed"
_getCurrentDocEntries: () ->
doc_id = @$scope.docId
entries = @$scope.reviewPanel.entries[doc_id] ?= {}
return entries
_makeZeroWidthRange: (position) ->
ace_range = new Range(position.row, position.column, position.row, position.column)
# Our delete marker is zero characters wide, but Ace doesn't draw ranges
# that are empty. So we monkey patch the range to tell Ace it's not empty.
# We do want to claim to be empty if we're off screen after clipping rows though.
# This is the code we need to trick:
# var range = marker.range.clipRows(config.firstRow, config.lastRow);
# if (range.isEmpty()) continue;
ace_range.clipRows = (first_row, last_row) ->
@isEmpty = () ->
first_row > @end.row or last_row < @start.row
return @
return ace_range
_createCalloutMarker: (position, klass) ->
session = @editor.getSession()
callout_range = @_makeZeroWidthRange(position)
markerLayer = @editor.renderer.$markerBack
callout_marker_id = session.addMarker callout_range, klass, (html, range, left, top, config) ->
markerLayer.drawSingleLineMarker(html, range, "track-changes-marker-callout #{klass} ace_start", config, 0, "width: auto; right: 0;")
_onInsertAdded: (change) ->
start = @_shareJsOffsetToAcePosition(change.op.p)
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
session = @editor.getSession()
doc = session.getDocument()
background_range = new Range(start.row, start.column, end.row, end.column)
background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-added-marker", "text"
callout_marker_id = @_createCalloutMarker(start, "track-changes-added-marker-callout")
@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
_onDeleteAdded: (change) ->
position = @_shareJsOffsetToAcePosition(change.op.p)
session = @editor.getSession()
doc = session.getDocument()
markerLayer = @editor.renderer.$markerBack
klass = "track-changes-marker track-changes-deleted-marker"
background_range = @_makeZeroWidthRange(position)
background_marker_id = session.addMarker background_range, klass, (html, range, left, top, config) ->
markerLayer.drawSingleLineMarker(html, range, "#{klass} ace_start", config, 0, "")
callout_marker_id = @_createCalloutMarker(position, "track-changes-deleted-marker-callout")
@changeIdToMarkerIdMap[change.id] = { background_marker_id, callout_marker_id }
_onInsertRemoved: (change) ->
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
delete @changeIdToMarkerIdMap[change.id]
session = @editor.getSession()
session.removeMarker background_marker_id
session.removeMarker callout_marker_id
_onDeleteRemoved: (change) ->
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change.id]
delete @changeIdToMarkerIdMap[change.id]
session = @editor.getSession()
session.removeMarker background_marker_id
session.removeMarker callout_marker_id
_onCommentAdded: (comment) ->
if @rangesTracker.resolvedThreadIds[comment.op.t]
# Comment is resolved so shouldn't be displayed.
return
if !@changeIdToMarkerIdMap[comment.id]?
# Only create new markers if they don't already exist
start = @_shareJsOffsetToAcePosition(comment.op.p)
end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
session = @editor.getSession()
doc = session.getDocument()
background_range = new Range(start.row, start.column, end.row, end.column)
background_marker_id = session.addMarker background_range, "track-changes-marker track-changes-comment-marker", "text"
callout_marker_id = @_createCalloutMarker(start, "track-changes-comment-marker-callout")
@changeIdToMarkerIdMap[comment.id] = { background_marker_id, callout_marker_id }
_onCommentRemoved: (comment) ->
if @changeIdToMarkerIdMap[comment.id]?
# Resolved comments may not have marker ids
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[comment.id]
delete @changeIdToMarkerIdMap[comment.id]
session = @editor.getSession()
session.removeMarker background_marker_id
session.removeMarker callout_marker_id
_aceRangeToShareJs: (range) ->
lines = @editor.getSession().getDocument().getLines 0, range.row
return AceShareJsCodec.aceRangeToShareJs(range, lines)
_aceChangeToShareJs: (delta) ->
lines = @editor.getSession().getDocument().getLines 0, delta.start.row
return AceShareJsCodec.aceChangeToShareJs(delta, lines)
_shareJsOffsetToAcePosition: (offset) ->
lines = @editor.getSession().getDocument().getAllLines()
return AceShareJsCodec.shareJsOffsetToAcePosition(offset, lines)
_onChangeMoved: (change) ->
start = @_shareJsOffsetToAcePosition(change.op.p)
if change.op.i?
end = @_shareJsOffsetToAcePosition(change.op.p + change.op.i.length)
else
end = start
@_updateMarker(change.id, start, end)
_onCommentMoved: (comment) ->
start = @_shareJsOffsetToAcePosition(comment.op.p)
end = @_shareJsOffsetToAcePosition(comment.op.p + comment.op.c.length)
@_updateMarker(comment.id, start, end)
_updateMarker: (change_id, start, end) ->
return if !@changeIdToMarkerIdMap[change_id]?
session = @editor.getSession()
markers = session.getMarkers()
{background_marker_id, callout_marker_id} = @changeIdToMarkerIdMap[change_id]
if background_marker_id? and markers[background_marker_id]?
background_marker = markers[background_marker_id]
background_marker.range.start = start
background_marker.range.end = end
if callout_marker_id? and markers[callout_marker_id]?
callout_marker = markers[callout_marker_id]
callout_marker.range.start = start
callout_marker.range.end = start

View File

@@ -1,367 +0,0 @@
define [
"ace/ace"
], () ->
Range = ace.require("ace/range").Range
EditSession = ace.require("ace/edit_session").EditSession
Doc = ace.require("ace/document").Document
class UndoManager
constructor: (@$scope, @editor) ->
@$scope.undo =
show_remote_warning: false
@reset()
@editor.on "changeSession", (e) =>
@reset()
@session = 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: () ->
@firstUpdate = true
@undoStack = []
@redoStack = []
execute: (options) ->
if @firstUpdate
# The first update we receive is Ace setting the document, which we should
# ignore
@firstUpdate = false
return
aceDeltaSets = options.args[0]
return if !aceDeltaSets?
@session = options.args[1]
# We need to split the delta sets into local or remote groups before pushing onto
# the undo stack, since these are treated differently.
splitDeltaSets = []
currentDeltaSet = null # Make global to this function
do newDeltaSet = () ->
currentDeltaSet = {group: "doc", deltas: []}
splitDeltaSets.push currentDeltaSet
currentRemoteState = null
for deltaSet in aceDeltaSets or []
if deltaSet.group == "doc" # ignore code folding etc.
for delta in deltaSet.deltas
if currentDeltaSet.remote? and currentDeltaSet.remote != !!delta.remote
newDeltaSet()
currentDeltaSet.deltas.push delta
currentDeltaSet.remote = !!delta.remote
# The lines are currently as they are after applying all these deltas, but to turn into simple deltas,
# we need the lines before each delta group.
docLines = @session.getDocument().getAllLines()
docLines = @_revertAceDeltaSetsOnDocLines(aceDeltaSets, docLines)
for deltaSet in splitDeltaSets
{simpleDeltaSet, docLines} = @_aceDeltaSetToSimpleDeltaSet(deltaSet, docLines)
frame = {
deltaSets: [simpleDeltaSet]
remote: deltaSet.remote
}
@undoStack.push frame
@redoStack = []
undo: (dontSelect) ->
# We rely on the doclines being in sync with the undo stack, so make sure
# any pending undo deltas are processed.
@session.$syncInformUndoManager()
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")
_aceDeltaSetToSimpleDeltaSet: (deltaSet, docLines) ->
simpleDeltas = []
for delta in deltaSet.deltas
simpleDeltas.push @_aceDeltaToSimpleDelta(delta, docLines)
docLines = @_applyAceDeltasToDocLines([delta], docLines)
simpleDeltaSet = {
deltas: simpleDeltas
group: deltaSet.group
}
return {simpleDeltaSet, docLines}
_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.start
if !start?
JSONstringifyWithCycles = (o) ->
seen = []
return JSON.stringify o, (k,v) ->
if (typeof v == 'object')
if ( seen.indexOf(v) >= 0 )
return '__cycle__'
seen.push(v);
return v
error = new Error("aceDelta had no start event: #{JSONstringifyWithCycles(aceDelta)}")
throw error
linesBefore = docLines.slice(0, start.row)
position =
linesBefore.join("").length + # full lines
linesBefore.length + # new line characters
start.column # partial line
switch aceDelta.action
when "insert"
simpleDelta = {
position: position
insert: aceDelta.lines.join("\n")
}
when "remove"
simpleDelta = {
position: position
remove: aceDelta.lines.join("\n")
}
else
throw new Error("Unknown Ace action: #{aceDelta.action}")
return simpleDelta
_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}
_simpleDeltaToAceDeltas: (simpleDelta, docLines) ->
{row, column} = @_simplePositionToAcePosition(simpleDelta.position, docLines)
lines = (simpleDelta.insert or simpleDelta.remove or "").split("\n")
start = {column, row}
if lines.length > 1
end = {
row: row + lines.length - 1,
column: lines[lines.length - 1].length
}
else
end = {
row,
column: column + lines[0].length
}
if simpleDelta.insert?
aceDelta = {
action: "insert"
lines, start, end
}
else if simpleDelta.remove?
aceDelta = {
action: "remove"
lines, start, end
}
else
throw "Unknown simple delta: #{simpleDelta}"
return [aceDelta]
_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,3 +0,0 @@
WEB = true
window.sharejs = exports = {}
types = exports.types = {}

View File

@@ -1,157 +0,0 @@
# This is some utility code to connect an ace editor to a sharejs document.
Range = ace.require("ace/range").Range
# Convert an ace delta into an op understood by share.js
applyAceToShareJS = (editorDoc, delta, doc, fromUndo) ->
# Get the start position of the range, in no. of characters
getStartOffsetPosition = (start) ->
# 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, start.row
offset = 0
for line, i in lines
offset += if i < start.row
line.length
else
start.column
# Add the row number to include newlines.
offset + start.row
pos = getStartOffsetPosition(delta.start)
switch delta.action
when 'insert'
text = delta.lines.join('\n')
doc.insert pos, text, fromUndo
when 'remove'
text = delta.lines.join('\n')
doc.del pos, text.length, fromUndo
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, maxDocLength) ->
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
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
if maxDocLength? and editorDoc.getValue().length > maxDocLength
doc.emit "error", new Error("document length is greater than maxDocLength")
return
fromUndo = !!(editor.getSession().$fromUndo or editor.getSession().$fromReject)
applyAceToShareJS editorDoc, change, doc, fromUndo
check()
editorDoc.on 'change', editorListener
# 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
# We want to insert a remote:true into the delta if the op comes from the
# underlying sharejs doc (which means it is from a remote op), so we have to do
# the work of editorDoc.insert and editorDoc.remove manually. These methods are
# copied from ace.js doc#insert and #remove, and then inject the remote:true
# flag into the delta.
onInsert = (pos, text) ->
if (editorDoc.getLength() <= 1)
editorDoc.$detectNewLine(text)
lines = editorDoc.$split(text)
position = offsetToPos(pos)
start = editorDoc.clippedPos(position.row, position.column)
end = {
row: start.row + lines.length - 1,
column: (if lines.length == 1 then start.column else 0) + lines[lines.length - 1].length
}
suppress = true
editorDoc.applyDelta({
start: start,
end: end,
action: "insert",
lines: lines,
remote: true
});
suppress = false
check()
onDelete = (pos, text) ->
range = Range.fromPoints offsetToPos(pos), offsetToPos(pos + text.length)
start = editorDoc.clippedPos(range.start.row, range.start.column)
end = editorDoc.clippedPos(range.end.row, range.end.column)
suppress = true
editorDoc.applyDelta({
start: start,
end: end,
action: "remove",
lines: editorDoc.getLinesForRange({start: start, end: end})
remote: true
});
suppress = false
check()
doc.on 'insert', onInsert
doc.on 'delete', onDelete
doc.detach_ace = ->
doc.removeListener 'insert', onInsert
doc.removeListener 'delete', onDelete
editorDoc.removeListener 'change', editorListener
delete doc.detach_ace
return

View File

@@ -1,89 +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
applyCMToShareJS = (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
doc.del startPos, delta.removed.join('\n').length if delta.removed
doc.insert startPos, delta.text.join('\n') if delta.text
# 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 = @
editorDoc = editor.getDoc()
check = ->
window.setTimeout ->
editorText = editor.getValue()
otText = sharedoc.getText()
if editorText != otText
console.error "Text does not match!"
console.error "editor: #{editorText}"
console.error "ot: #{otText}"
# Removed editor.setValue here as it would cause recursive loops if
# consistency check failed - because setting the value would trigger
# the change event
, 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
applyCMToShareJS editorDoc, change, sharedoc
check()
editorDoc.on 'change', editorListener
onInsert = (pos, text) ->
suppress = true
# All the primitives we need are already in CM's API.
editor.replaceRange text, editor.posFromIndex(pos)
suppress = false
check()
onDelete = (pos, text) ->
suppress = true
from = editor.posFromIndex pos
to = editor.posFromIndex (pos + text.length)
editor.replaceRange '', from, to
suppress = false
check()
@on 'insert', onInsert
@on 'delete', onDelete
@detach_cm = ->
@removeListener 'insert', onInsert
@removeListener 'delete', onDelete
editorDoc.off 'change', editorListener
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,349 +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, msg) ->
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, msg
@emit 'remoteop', docOp, oldSnapshot, msg 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
if @pendingOp == null # All ops are acked
@emit 'saved'
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, msg
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.
# We may get multiple acks of the same message if we retried it,
# so its ok if we receive an ack for a version that we've already gone past.
# If so, just ignore it
if msg.v < @version
return
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.
@delayedFlush()
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, msg
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: =>
@flushTimeout = null
#console.log "CALLED FLUSH"
return unless @connection.state == 'ok' and @inflightOp == null and @pendingOp != null
# Rotate null -> pending -> inflight
@inflightOp = @pendingOp
@inflightCallbacks = @pendingCallbacks
@pendingOp = null
@pendingCallbacks = []
@emit "flipped_pending_to_inflight"
#console.log "SENDING OP TO SERVER", @inflightOp, @version
@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?
oldSnapshot = @snapshot
# 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, oldSnapshot
@delayedFlush()
delayedFlush: () ->
if !@flushTimeout?
@flushTimeout = setTimeout @flush, @_flushDelay || 0
setFlushDelay: (delay) =>
@_flushDelay = delay
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,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,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 /duplicate key value violates unique constraint/.test(error.toString())
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

View File

@@ -1,140 +0,0 @@
# This is an implementation of the OT data backend for redis.
# http://redis.io/
#
# This implementation isn't written to support multiple frontends
# talking to a single redis backend using redis's transactions.
redis = require 'redis'
defaultOptions = {
# Prefix for all database keys.
prefix: 'ShareJS:'
# Inherit the default options from redis. (Hostname: 127.0.0.1, port: 6379)
hostname: null
port: null
redisOptions: null
auth: null
# If this is set to true, the client will select db 15 and wipe all data in
# this database.
testing: false
}
# Valid options as above.
module.exports = RedisDb = (options) ->
return new Db if !(this instanceof RedisDb)
options ?= {}
options[k] ?= v for k, v of defaultOptions
keyForOps = (docName) -> "#{options.prefix}ops:#{docName}"
keyForDoc = (docName) -> "#{options.prefix}doc:#{docName}"
client = redis.createClient options.port, options.hostname, options.redisOptions
if options.auth and typeof options.auth == "string"
client.auth(if ":" in options.auth then options.auth.split(":").pop() else options.auth)
client.select 15 if options.testing
# Creates a new document.
# data = {snapshot, type:typename, [meta]}
# calls callback(true) if the document was created or callback(false) if a document with that name
# already exists.
@create = (docName, data, callback) ->
value = JSON.stringify(data)
client.setnx keyForDoc(docName), value, (err, result) ->
return callback? err if err
if result
callback?()
else
callback? 'Document already exists'
# Get all ops with version = start to version = end. Noninclusive.
# end is trimmed to the size of the document.
# If any documents are passed to the callback, the first one has v = start
# end can be null. If so, returns all documents from start onwards.
# Each document returned is in the form {op:o, meta:m, v:version}.
@getOps = (docName, start, end, callback) ->
if start == end
callback null, []
return
# In redis, lrange values are inclusive.
if end?
end--
else
end = -1
client.lrange keyForOps(docName), start, end, (err, values) ->
throw err if err?
ops = (JSON.parse value for value in values)
callback null, ops
# Write an op to a document.
#
# opData = {op:the op to append, v:version, meta:optional metadata object containing author, etc.}
# callback = callback when op committed
#
# opData.v MUST be the subsequent version for the document.
#
# This function has UNDEFINED BEHAVIOUR if you call append before calling create().
# (its either that, or I have _another_ check when you append an op that the document already exists
# ... and that would slow it down a bit.)
@writeOp = (docName, opData, callback) ->
# ****** NOT SAFE FOR MULTIPLE PROCESSES. Rewrite me using transactions or something.
# The version isn't stored.
json = JSON.stringify {op:opData.op, meta:opData.meta}
client.rpush keyForOps(docName), json, (err, response) ->
return callback err if err
if response == opData.v + 1
callback()
else
# The document has been corrupted by the change. For now, throw an exception.
# Later, rebuild the snapshot.
callback "Version mismatch in db.append. '#{docName}' is corrupted."
# Write new snapshot data to the database.
#
# docData = resultant document snapshot data. {snapshot:s, type:t, meta}
#
# The callback just takes an optional error.
#
# This function has UNDEFINED BEHAVIOUR if you call append before calling create().
@writeSnapshot = (docName, docData, dbMeta, callback) ->
client.set keyForDoc(docName), JSON.stringify(docData), (err, response) ->
callback? err
# Data = {v, snapshot, type}. Snapshot == null and v = 0 if the document doesn't exist.
@getSnapshot = (docName, callback) ->
client.get keyForDoc(docName), (err, response) ->
throw err if err?
if response != null
docData = JSON.parse(response)
callback null, docData
else
callback 'Document does not exist'
# Perminantly deletes a document. There is no undo.
# Callback takes a single argument which is true iff something was deleted.
@delete = (docName, dbMeta, callback) ->
client.del keyForOps(docName)
client.del keyForDoc(docName), (err, response) ->
throw err if err?
if callback
if response == 1
# Something was deleted.
callback null
else
callback 'Document does not exist'
# Close the connection to the database
@close = ->
client.quit()
this

View File

@@ -1,57 +0,0 @@
# The server module...
connect = require 'connect'
Model = require './model'
createDb = require './db'
rest = require './rest'
socketio = require './socketio'
browserChannel = require './browserchannel'
# Create an HTTP server and attach whatever frontends are specified in the options.
#
# The model will be created based on options if it is not specified.
module.exports = create = (options, model = createModel(options)) ->
attach(connect(), options, model)
# Create an OT document model attached to a database.
create.createModel = createModel = (options) ->
dbOptions = options?.db
db = createDb dbOptions
new Model db, options
# Attach the OT server frontends to the provided Node HTTP server. Use this if you
# already have a http.Server or https.Server and want to make some URL paths do OT.
#
# The options object specifies options for everything. If settings are missing,
# defaults will be provided.
#
# Set options.rest == null or options.socketio == null to turn off that frontend.
create.attach = attach = (server, options, model = createModel(options)) ->
options ?= {}
options.staticpath ?= '/share'
server.model = model
server.on 'close', -> model.closeDb()
server.use options.staticpath, connect.static("#{__dirname}/../../webclient") if options.staticpath != null
createAgent = require('./useragent') model, options
# The client frontend doesn't get access to the model at all, to make sure security stuff is
# done properly.
server.use rest(createAgent, options.rest) if options.rest != null
# Socketio frontend is now disabled by default.
socketio.attach(server, createAgent, options.socketio or {}) if options.socketio?
if options.browserChannel != null
options.browserChannel ?= {}
#options.browserChannel.base ?= '/sjs'
options.browserChannel.server = server
server.use browserChannel(createAgent, options.browserChannel)
server

View File

@@ -1,602 +0,0 @@
# The model of all the ops. Responsible for applying & transforming remote deltas
# and managing the storage layer.
#
# Actual storage is handled by the database wrappers in db/*, wrapped by DocCache
{EventEmitter} = require 'events'
queue = require './syncqueue'
types = require '../types'
isArray = (o) -> Object.prototype.toString.call(o) == '[object Array]'
# This constructor creates a new Model object. There will be one model object
# per server context.
#
# The model object is responsible for a lot of things:
#
# - It manages the interactions with the database
# - It maintains (in memory) a set of all active documents
# - It calls out to the OT functions when necessary
#
# The model is an event emitter. It emits the following events:
#
# create(docName, data): A document has been created with the specified name & data
module.exports = Model = (db, options) ->
# db can be null if the user doesn't want persistance.
return new Model(db, options) if !(this instanceof Model)
model = this
options ?= {}
# This is a cache of 'live' documents.
#
# The cache is a map from docName -> {
# ops:[{op, meta}]
# snapshot
# type
# v
# meta
# eventEmitter
# reapTimer
# committedVersion: v
# snapshotWriteLock: bool to make sure writeSnapshot isn't re-entrant
# dbMeta: database specific data
# opQueue: syncQueue for processing ops
# }
#
# The ops list contains the document's last options.numCachedOps ops. (Or all
# of them if we're using a memory store).
#
# Documents are stored in this set so long as the document has been accessed in
# the last few seconds (options.reapTime) OR at least one client has the document
# open. I don't know if I should keep open (but not being edited) documents live -
# maybe if a client has a document open but the document isn't being edited, I should
# flush it from the cache.
#
# In any case, the API to model is designed such that if we want to change that later
# it should be pretty easy to do so without any external-to-the-model code changes.
docs = {}
# This is a map from docName -> [callback]. It is used when a document hasn't been
# cached and multiple getSnapshot() / getVersion() requests come in. All requests
# are added to the callback list and called when db.getSnapshot() returns.
#
# callback(error, snapshot data)
awaitingGetSnapshot = {}
# The time that documents which no clients have open will stay in the cache.
# Should be > 0.
options.reapTime ?= 3000
# The number of operations the cache holds before reusing the space
options.numCachedOps ?= 10
# This option forces documents to be reaped, even when there's no database backend.
# This is useful when you don't care about persistance and don't want to gradually
# fill memory.
#
# You might want to set reapTime to a day or something.
options.forceReaping ?= false
# Until I come up with a better strategy, we'll save a copy of the document snapshot
# to the database every ~20 submitted ops.
options.opsBeforeCommit ?= 20
# It takes some processing time to transform client ops. The server will punt ops back to the
# client to transform if they're too old.
options.maximumAge ?= 40
# **** Cache API methods
# Its important that all ops are applied in order. This helper method creates the op submission queue
# for a single document. This contains the logic for transforming & applying ops.
makeOpQueue = (docName, doc) -> queue (opData, callback) ->
return callback 'Version missing' unless opData.v >= 0
return callback 'Op at future version' if opData.v > doc.v
# Punt the transforming work back to the client if the op is too old.
return callback 'Op too old' if opData.v + options.maximumAge < doc.v
opData.meta ||= {}
opData.meta.ts = Date.now()
# We'll need to transform the op to the current version of the document. This
# calls the callback immediately if opVersion == doc.v.
getOps docName, opData.v, doc.v, (error, ops) ->
return callback error if error
unless doc.v - opData.v == ops.length
# This should never happen. It indicates that we didn't get all the ops we
# asked for. Its important that the submitted op is correctly transformed.
console.error "Could not get old ops in model for document #{docName}"
console.error "Expected ops #{opData.v} to #{doc.v} and got #{ops.length} ops"
return callback 'Internal error'
if ops.length > 0
try
# If there's enough ops, it might be worth spinning this out into a webworker thread.
for oldOp in ops
# Dup detection works by sending the id(s) the op has been submitted with previously.
# If the id matches, we reject it. The client can also detect the op has been submitted
# already if it sees its own previous id in the ops it sees when it does catchup.
if oldOp.meta.source and opData.dupIfSource and oldOp.meta.source in opData.dupIfSource
return callback 'Op already submitted'
opData.op = doc.type.transform opData.op, oldOp.op, 'left'
opData.v++
catch error
console.error error.stack
return callback error.message
try
snapshot = doc.type.apply doc.snapshot, opData.op
catch error
console.error error.stack
return callback error.message
# The op data should be at the current version, and the new document data should be at
# the next version.
#
# This should never happen in practice, but its a nice little check to make sure everything
# is hunky-dory.
unless opData.v == doc.v
# This should never happen.
console.error "Version mismatch detected in model. File a ticket - this is a bug."
console.error "Expecting #{opData.v} == #{doc.v}"
return callback 'Internal error'
#newDocData = {snapshot, type:type.name, v:opVersion + 1, meta:docData.meta}
writeOp = db?.writeOp or (docName, newOpData, callback) -> callback()
writeOp docName, opData, (error) ->
if error
# The user should probably know about this.
console.warn "Error writing ops to database: #{error}"
return callback error
options.stats?.writeOp?()
# This is needed when we emit the 'change' event, below.
oldSnapshot = doc.snapshot
# All the heavy lifting is now done. Finally, we'll update the cache with the new data
# and (maybe!) save a new document snapshot to the database.
doc.v = opData.v + 1
doc.snapshot = snapshot
doc.ops.push opData
doc.ops.shift() if db and doc.ops.length > options.numCachedOps
model.emit 'applyOp', docName, opData, snapshot, oldSnapshot
doc.eventEmitter.emit 'op', opData, snapshot, oldSnapshot
# The callback is called with the version of the document at which the op was applied.
# This is the op.v after transformation, and its doc.v - 1.
callback null, opData.v
# I need a decent strategy here for deciding whether or not to save the snapshot.
#
# The 'right' strategy looks something like "Store the snapshot whenever the snapshot
# is smaller than the accumulated op data". For now, I'll just store it every 20
# ops or something. (Configurable with doc.committedVersion)
if !doc.snapshotWriteLock and doc.committedVersion + options.opsBeforeCommit <= doc.v
tryWriteSnapshot docName, (error) ->
console.warn "Error writing snapshot #{error}. This is nonfatal" if error
# Add the data for the given docName to the cache. The named document shouldn't already
# exist in the doc set.
#
# Returns the new doc.
add = (docName, error, data, committedVersion, ops, dbMeta) ->
callbacks = awaitingGetSnapshot[docName]
delete awaitingGetSnapshot[docName]
if error
callback error for callback in callbacks if callbacks
else
doc = docs[docName] =
snapshot: data.snapshot
v: data.v
type: data.type
meta: data.meta
# Cache of ops
ops: ops or []
eventEmitter: new EventEmitter
# Timer before the document will be invalidated from the cache (if the document has no
# listeners)
reapTimer: null
# Version of the snapshot thats in the database
committedVersion: committedVersion ? data.v
snapshotWriteLock: false
dbMeta: dbMeta
doc.opQueue = makeOpQueue docName, doc
refreshReapingTimeout docName
model.emit 'add', docName, data
callback null, doc for callback in callbacks if callbacks
doc
# This is a little helper wrapper around db.getOps. It does two things:
#
# - If there's no database set, it returns an error to the callback
# - It adds version numbers to each op returned from the database
# (These can be inferred from context so the DB doesn't store them, but its useful to have them).
getOpsInternal = (docName, start, end, callback) ->
return callback? 'Document does not exist' unless db
db.getOps docName, start, end, (error, ops) ->
return callback? error if error
v = start
op.v = v++ for op in ops
callback? null, ops
# Load the named document into the cache. This function is re-entrant.
#
# The callback is called with (error, doc)
load = (docName, callback) ->
if docs[docName]
# The document is already loaded. Return immediately.
options.stats?.cacheHit? 'getSnapshot'
return callback null, docs[docName]
# We're a memory store. If we don't have it, nobody does.
return callback 'Document does not exist' unless db
callbacks = awaitingGetSnapshot[docName]
# The document is being loaded already. Add ourselves as a callback.
return callbacks.push callback if callbacks
options.stats?.cacheMiss? 'getSnapshot'
# The document isn't loaded and isn't being loaded. Load it.
awaitingGetSnapshot[docName] = [callback]
db.getSnapshot docName, (error, data, dbMeta) ->
return add docName, error if error
type = types[data.type]
unless type
console.warn "Type '#{data.type}' missing"
return callback "Type not found"
data.type = type
committedVersion = data.v
# The server can close without saving the most recent document snapshot.
# In this case, there are extra ops which need to be applied before
# returning the snapshot.
getOpsInternal docName, data.v, null, (error, ops) ->
return callback error if error
if ops.length > 0
console.log "Catchup #{docName} #{data.v} -> #{data.v + ops.length}"
try
for op in ops
data.snapshot = type.apply data.snapshot, op.op
data.v++
catch e
# This should never happen - it indicates that whats in the
# database is invalid.
console.error "Op data invalid for #{docName}: #{e.stack}"
return callback 'Op data invalid'
model.emit 'load', docName, data
add docName, error, data, committedVersion, ops, dbMeta
# This makes sure the cache contains a document. If the doc cache doesn't contain
# a document, it is loaded from the database and stored.
#
# Documents are stored so long as either:
# - They have been accessed within the past #{PERIOD}
# - At least one client has the document open
refreshReapingTimeout = (docName) ->
doc = docs[docName]
return unless doc
# I want to let the clients list be updated before this is called.
process.nextTick ->
# This is an awkward way to find out the number of clients on a document. If this
# causes performance issues, add a numClients field to the document.
#
# The first check is because its possible that between refreshReapingTimeout being called and this
# event being fired, someone called delete() on the document and hence the doc is something else now.
if doc == docs[docName] and
doc.eventEmitter.listeners('op').length == 0 and
(db or options.forceReaping) and
doc.opQueue.busy is false
clearTimeout doc.reapTimer
doc.reapTimer = reapTimer = setTimeout ->
tryWriteSnapshot docName, ->
# If the reaping timeout has been refreshed while we're writing the snapshot, or if we're
# in the middle of applying an operation, don't reap.
delete docs[docName] if docs[docName].reapTimer is reapTimer and doc.opQueue.busy is false
, options.reapTime
tryWriteSnapshot = (docName, callback) ->
return callback?() unless db
doc = docs[docName]
# The doc is closed
return callback?() unless doc
# The document is already saved.
return callback?() if doc.committedVersion is doc.v
return callback? 'Another snapshot write is in progress' if doc.snapshotWriteLock
doc.snapshotWriteLock = true
options.stats?.writeSnapshot?()
writeSnapshot = db?.writeSnapshot or (docName, docData, dbMeta, callback) -> callback()
data =
v: doc.v
meta: doc.meta
snapshot: doc.snapshot
# The database doesn't know about object types.
type: doc.type.name
# Commit snapshot.
writeSnapshot docName, data, doc.dbMeta, (error, dbMeta) ->
doc.snapshotWriteLock = false
# We have to use data.v here because the version in the doc could
# have been updated between the call to writeSnapshot() and now.
doc.committedVersion = data.v
doc.dbMeta = dbMeta
callback? error
# *** Model interface methods
# Create a new document.
#
# data should be {snapshot, type, [meta]}. The version of a new document is 0.
@create = (docName, type, meta, callback) ->
[meta, callback] = [{}, meta] if typeof meta is 'function'
return callback? 'Invalid document name' if /\//.test(docName)
return callback? 'Document already exists' if docs[docName]
type = types[type] if typeof type == 'string'
return callback? 'Type not found' unless type
data =
snapshot:type.create()
type:type.name
meta:meta or {}
v:0
done = (error, dbMeta) ->
# dbMeta can be used to cache extra state needed by the database to access the document, like an ID or something.
return callback? error if error
# From here on we'll store the object version of the type name.
data.type = type
add docName, null, data, 0, [], dbMeta
model.emit 'create', docName, data
callback?()
if db
db.create docName, data, done
else
done()
# Perminantly deletes the specified document.
# If listeners are attached, they are removed.
#
# The callback is called with (error) if there was an error. If error is null / undefined, the
# document was deleted.
#
# WARNING: This isn't well supported throughout the code. (Eg, streaming clients aren't told about the
# deletion. Subsequent op submissions will fail).
@delete = (docName, callback) ->
doc = docs[docName]
if doc
clearTimeout doc.reapTimer
delete docs[docName]
done = (error) ->
model.emit 'delete', docName unless error
callback? error
if db
db.delete docName, doc?.dbMeta, done
else
done (if !doc then 'Document does not exist')
# This gets all operations from [start...end]. (That is, its not inclusive.)
#
# end can be null. This means 'get me all ops from start'.
#
# Each op returned is in the form {op:o, meta:m, v:version}.
#
# Callback is called with (error, [ops])
#
# If the document does not exist, getOps doesn't necessarily return an error. This is because
# its awkward to figure out whether or not the document exists for things
# like the redis database backend. I guess its a bit gross having this inconsistant
# with the other DB calls, but its certainly convenient.
#
# Use getVersion() to determine if a document actually exists, if thats what you're
# after.
@getOps = getOps = (docName, start, end, callback) ->
# getOps will only use the op cache if its there. It won't fill the op cache in.
throw new Error 'start must be 0+' unless start >= 0
[end, callback] = [null, end] if typeof end is 'function'
ops = docs[docName]?.ops
if ops
version = docs[docName].v
# Ops contains an array of ops. The last op in the list is the last op applied
end ?= version
start = Math.min start, end
return callback null, [] if start == end
# Base is the version number of the oldest op we have cached
base = version - ops.length
# If the database is null, we'll trim to the ops we do have and hope thats enough.
if start >= base or db is null
refreshReapingTimeout docName
options.stats?.cacheHit 'getOps'
return callback null, ops[(start - base)...(end - base)]
options.stats?.cacheMiss 'getOps'
getOpsInternal docName, start, end, callback
# Gets the snapshot data for the specified document.
# getSnapshot(docName, callback)
# Callback is called with (error, {v: <version>, type: <type>, snapshot: <snapshot>, meta: <meta>})
@getSnapshot = (docName, callback) ->
load docName, (error, doc) ->
callback error, if doc then {v:doc.v, type:doc.type, snapshot:doc.snapshot, meta:doc.meta}
# Gets the latest version # of the document.
# getVersion(docName, callback)
# callback is called with (error, version).
@getVersion = (docName, callback) ->
load docName, (error, doc) -> callback error, doc?.v
# Apply an op to the specified document.
# The callback is passed (error, applied version #)
# opData = {op:op, v:v, meta:metadata}
#
# Ops are queued before being applied so that the following code applies op C before op B:
# model.applyOp 'doc', OPA, -> model.applyOp 'doc', OPB
# model.applyOp 'doc', OPC
@applyOp = (docName, opData, callback) ->
# All the logic for this is in makeOpQueue, above.
load docName, (error, doc) ->
return callback error if error
process.nextTick -> doc.opQueue opData, (error, newVersion) ->
refreshReapingTimeout docName
callback? error, newVersion
# TODO: store (some) metadata in DB
# TODO: op and meta should be combineable in the op that gets sent
@applyMetaOp = (docName, metaOpData, callback) ->
{path, value} = metaOpData.meta
return callback? "path should be an array" unless isArray path
load docName, (error, doc) ->
if error?
callback? error
else
applied = false
switch path[0]
when 'shout'
doc.eventEmitter.emit 'op', metaOpData
applied = true
model.emit 'applyMetaOp', docName, path, value if applied
callback? null, doc.v
# Listen to all ops from the specified version. If version is in the past, all
# ops since that version are sent immediately to the listener.
#
# The callback is called once the listener is attached, but before any ops have been passed
# to the listener.
#
# This will _not_ edit the document metadata.
#
# If there are any listeners, we don't purge the document from the cache. But be aware, this behaviour
# might change in a future version.
#
# version is the document version at which the document is opened. It can be left out if you want to open
# the document at the most recent version.
#
# listener is called with (opData) each time an op is applied.
#
# callback(error, openedVersion)
@listen = (docName, version, listener, callback) ->
[version, listener, callback] = [null, version, listener] if typeof version is 'function'
load docName, (error, doc) ->
return callback? error if error
clearTimeout doc.reapTimer
if version?
getOps docName, version, null, (error, data) ->
return callback? error if error
doc.eventEmitter.on 'op', listener
callback? null, version
for op in data
listener op
# The listener may well remove itself during the catchup phase. If this happens, break early.
# This is done in a quite inefficient way. (O(n) where n = #listeners on doc)
break unless listener in doc.eventEmitter.listeners 'op'
else # Version is null / undefined. Just add the listener.
doc.eventEmitter.on 'op', listener
callback? null, doc.v
# Remove a listener for a particular document.
#
# removeListener(docName, listener)
#
# This is synchronous.
@removeListener = (docName, listener) ->
# The document should already be loaded.
doc = docs[docName]
throw new Error 'removeListener called but document not loaded' unless doc
doc.eventEmitter.removeListener 'op', listener
refreshReapingTimeout docName
# Flush saves all snapshot data to the database. I'm not sure whether or not this is actually needed -
# sharejs will happily replay uncommitted ops when documents are re-opened anyway.
@flush = (callback) ->
return callback?() unless db
pendingWrites = 0
for docName, doc of docs
if doc.committedVersion < doc.v
pendingWrites++
# I'm hoping writeSnapshot will always happen in another thread.
tryWriteSnapshot docName, ->
process.nextTick ->
pendingWrites--
callback?() if pendingWrites is 0
# If nothing was queued, terminate immediately.
callback?() if pendingWrites is 0
# Close the database connection. This is needed so nodejs can shut down cleanly.
@closeDb = ->
db?.close?()
db = null
return
# Model inherits from EventEmitter.
Model:: = new EventEmitter

View File

@@ -1,148 +0,0 @@
# A REST-ful frontend to the OT server.
#
# See the docs for details and examples about how the protocol works.
http = require 'http'
url = require 'url'
connect = require 'connect'
send403 = (res, message = 'Forbidden\n') ->
res.writeHead 403, {'Content-Type': 'text/plain'}
res.end message
send404 = (res, message = '404: Your document could not be found.\n') ->
res.writeHead 404, {'Content-Type': 'text/plain'}
res.end message
sendError = (res, message, head = false) ->
if message == 'forbidden'
if head
send403 res, ""
else
send403 res
else if message == 'Document does not exist'
if head
send404 res, ""
else
send404 res
else
console.warn "REST server does not know how to send error: '#{message}'"
if head
res.writeHead 500, {'Content-Type': 'text/plain'}
res.end "Error: #{message}\n"
else
res.writeHead 500, {}
res.end ""
send400 = (res, message) ->
res.writeHead 400, {'Content-Type': 'text/plain'}
res.end message
send200 = (res, message = "OK\n") ->
res.writeHead 200, {'Content-Type': 'text/plain'}
res.end message
sendJSON = (res, obj) ->
res.writeHead 200, {'Content-Type': 'application/json'}
res.end JSON.stringify(obj) + '\n'
# Callback is only called if the object was indeed JSON
expectJSONObject = (req, res, callback) ->
pump req, (data) ->
try
obj = JSON.parse data
catch error
send400 res, 'Supplied JSON invalid'
return
callback(obj)
pump = (req, callback) ->
data = ''
req.on 'data', (chunk) -> data += chunk
req.on 'end', () -> callback(data)
# connect.router will be removed in connect 2.0 - this code will have to be rewritten or
# more libraries pulled in.
# https://github.com/senchalabs/connect/issues/262
router = (app, createClient, options) ->
auth = (req, res, next) ->
data =
headers: req.headers
remoteAddress: req.connection.remoteAddress
createClient data, (error, client) ->
if client
req._client = client
next()
else
sendError res, error
# GET returns the document snapshot. The version and type are sent as headers.
# I'm not sure what to do with document metadata - it is inaccessable for now.
app.get '/doc/:name', auth, (req, res) ->
req._client.getSnapshot req.params.name, (error, doc) ->
if doc
res.setHeader 'X-OT-Type', doc.type.name
res.setHeader 'X-OT-Version', doc.v
if req.method == "HEAD"
send200 res, ""
else
if typeof doc.snapshot == 'string'
send200 res, doc.snapshot
else
sendJSON res, doc.snapshot
else
if req.method == "HEAD"
sendError res, error, true
else
sendError res, error
# Put is used to create a document. The contents are a JSON object with {type:TYPENAME, meta:{...}}
app.put '/doc/:name', auth, (req, res) ->
expectJSONObject req, res, (obj) ->
type = obj?.type
meta = obj?.meta
unless typeof type == 'string' and (meta == undefined or typeof meta == 'object')
send400 res, 'Type invalid'
else
req._client.create req.params.name, type, meta, (error) ->
if error
sendError res, error
else
send200 res
# POST submits an op to the document.
app.post '/doc/:name', auth, (req, res) ->
query = url.parse(req.url, true).query
version = if query?.v?
parseInt query?.v
else
parseInt req.headers['x-ot-version']
unless version? and version >= 0
send400 res, 'Version required - attach query parameter ?v=X on your URL or set the X-OT-Version header'
else
expectJSONObject req, res, (obj) ->
opData = {v:version, op:obj, meta:{source:req.socket.remoteAddress}}
req._client.submitOp req.params.name, opData, (error, newVersion) ->
if error?
sendError res, error
else
sendJSON res, {v:newVersion}
app.delete '/doc/:name', auth, (req, res) ->
req._client.delete req.params.name, (error) ->
if error
sendError res, error
else
send200 res
# Attach the frontend to the supplied http.Server.
#
# As of sharejs 0.4.0, options is ignored. To control the deleting of documents, specify an auth() function.
module.exports = (createClient, options) ->
connect.router (app) -> router(app, createClient, options)

View File

@@ -1,336 +0,0 @@
# This implements the socketio-based network API for ShareJS.
#
# This is the frontend used by the javascript socket implementation.
#
# See documentation for this protocol is in doc/protocol.md
# Tests are in test/socketio.coffee
#
# This code will be removed in a future version of sharejs because socket.io is
# too buggy.
socketio = require 'socket.io'
util = require 'util'
hat = require 'hat'
p = ->#util.debug
i = ->#util.inspect
# Attach the streaming protocol to the supplied http.Server.
#
# Options = {}
exports.attach = (server, createClient, options) ->
io = socketio.listen server
io.configure ->
io.set 'log level', 1
for option of options
io.set option, options[option]
authClient = (handshakeData, callback) ->
data =
headers: handshakeData.headers
remoteAddress: handshakeData.address.address
secure: handshakeData.secure
createClient data, (error, client) ->
if error
# Its important that we don't pass the error message to the client here - leaving it as null
# will ensure the client recieves the normal 'not_authorized' message and thus
# emits 'connect_failed' instead of 'error'
callback null, false
else
handshakeData.client = client
callback null, true
io.of('/sjs').authorization(authClient).on 'connection', (socket) ->
client = socket.handshake.client
# There seems to be a bug in socket.io where socket.request isn't set sometimes.
p "New socket connected from #{socket.request.socket.remoteAddress} with id #{socket.id}" if socket.request?
lastSentDoc = null
lastReceivedDoc = null
# Map from docName -> {listener:fn, queue:[msg], busy:bool}
docState = {}
closed = false
# 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 = (msg) ->
if msg.doc == lastSentDoc
delete msg.doc
else
lastSentDoc = msg.doc
p "Sending #{i msg}"
socket.json.send msg
# Open the given document name, at the requested version.
# callback(error, version)
open = (docName, version, callback) ->
callback 'Doc already opened' if docState[docName].listener?
p "Registering listener on #{docName} by #{socket.id} at #{version}"
# This passes op events to the client
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 == client.id
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
client.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}"
listener = docState[docName].listener
return callback 'Doc already closed' unless listener?
client.removeListener docName
docState[docName].listener = null
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
# Technically, we don't need a snapshot if the user called create but not open or createSnapshot,
# but no clients do that yet anyway.
#
# It might be nice to add a 'createOrGet()' method to model / db manager. But most
# of the time clients are opening an existing document rather than creating a new one anyway.
###
model.clientGetSnapshot client, query.doc, (error, data) ->
maybeCreate = (callback) ->
if query.create and error is 'Document does not exist'
model.clientCreate client, docName, query.type, query.meta or {}, callback
else
callback error, data
maybeCreate (error, data) ->
if query.create
msg.create = !!error
if error is 'Document already exists'
msg.create = false
else if error and (!msg.create or error isnt 'Document already exists')
# This is the real final callback, to say an error has occurred.
return callback error
else if query.create or query.snapshot is null
if query.snapshot isnt null
###
# 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
client.create docName, query.type, query.meta || {}, (error) ->
if error is 'Document already exists'
# We've called getSnapshot (-> null), then createClient (-> already exists). Its possible
# another client has called createClient first.
client.getSnapshot docName, (error, data) ->
return callback error if error
docData = data
msg.create = false
step2Snapshot()
else if error
callback error
else
msg.create = !error
step2Snapshot()
# The socket requested a document snapshot
step2Snapshot = ->
# 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()
if query.snapshot == null or (query.open == true and query.type)
client.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 docName specified' unless query.doc?
throw new Error 'No version specified' unless query.v? or (query.meta?.path? and query.meta?.value?)
op_data = {v:query.v, op:query.op}
op_data.meta = query.meta || {}
op_data.meta.source = socket.id
client.submitOp query.doc, op_data, (error, appliedVersion) ->
msg = if error
p "Sending error to socket: #{error}"
{doc:query.doc, v:null, error:error}
else
{doc:query.doc, v:appliedVersion}
p "sending #{i msg}"
send msg
callback()
flush = (state) ->
return if state.busy || state.queue.length == 0
state.busy = true
query = state.queue.shift()
callback = ->
state.busy = false
flush state
p "processing query #{i query}"
try
if query.open == false
handleClose query, callback
else if query.open != undefined or query.snapshot != undefined or query.create
# You can open, request a snapshot and create all in the same
# request. They're all handled together.
handleOpenCreateSnapshot query, callback
else if query.op? or query.meta? # The socket is applying an op.
handleOp query, callback
else
util.debug "Unknown message received: #{util.inspect query}"
catch error
util.debug error.stack
# ... And disconnect the socket?
callback()
# And now the actual message handler.
messageListener = (query) ->
p "Server recieved message #{i query}"
# There seems to be a bug in socket.io where messages are detected
# after the client disconnects.
if closed
console.warn "WARNING: received query from socket after the socket disconnected."
console.warn socket
return
try
query = JSON.parse query if typeof(query) == 'string'
if query.doc == null
lastReceivedDoc = null
query.doc = hat()
else if query.doc != undefined
lastReceivedDoc = query.doc
else
throw new Error 'msg.doc missing. Probably the client reconnected without telling us - this is a socket.io bug.' unless lastReceivedDoc
query.doc = lastReceivedDoc
catch error
util.debug error.stack
return
docState[query.doc] ||= {listener:null, queue:[], busy:no}
docState[query.doc].queue.push query
flush docState[query.doc]
socket.on 'message', messageListener
socket.on 'disconnect', ->
p "socket #{socket.id} disconnected"
closed = true
for docName, state of docState
state.busy = true
state.queue = []
client.removeListener docName if state.listener?
socket.removeListener 'message', messageListener
docState = {}
server

View File

@@ -1,42 +0,0 @@
# A synchronous processing queue. The queue calls process on the arguments,
# ensuring that process() is only executing once at a time.
#
# process(data, callback) _MUST_ eventually call its callback.
#
# Example:
#
# queue = require 'syncqueue'
#
# fn = queue (data, callback) ->
# asyncthing data, ->
# callback(321)
#
# fn(1)
# fn(2)
# fn(3, (result) -> console.log(result))
#
# ^--- async thing will only be running once at any time.
module.exports = (process) ->
throw new Error('process is not a function') unless typeof process == 'function'
queue = []
enqueue = (data, callback) ->
queue.push [data, callback]
flush()
enqueue.busy = false
flush = ->
return if enqueue.busy or queue.length == 0
enqueue.busy = true
[data, callback] = queue.shift()
process data, (result...) -> # TODO: Make this not use varargs - varargs are really slow.
enqueue.busy = false
# This is called after busy = false so a user can check if enqueue.busy is set in the callback.
callback.apply null, result if callback
flush()
enqueue

View File

@@ -1,146 +0,0 @@
# A useragent is assigned to each client when the client connects. The useragent is responsible for making
# sure all requests are authorized and maintaining document metadata.
#
# This is used by all the client frontends to interact with the server.
hat = require 'hat'
types = require '../types'
# This module exports a function which you can call with the model and options. Calling the function
# returns _another_ function which you can call when clients connect.
module.exports = (model, options) ->
# By default, accept all connections + data submissions.
# Don't let anyone delete documents though.
auth = options.auth or (agent, action) ->
if action.type in ['connect', 'read', 'create', 'update'] then action.accept() else action.reject()
class UserAgent
constructor: (data) ->
@sessionId = hat()
@connectTime = new Date
@headers = data.headers
@remoteAddress = data.remoteAddress
# This is a map from docName -> listener function
@listeners = {}
# Should be manually set by the auth function.
@name = null
# We have access to these with socket.io, but I'm not sure we can support
# these properties on the REST API or sockjs, etc.
#xdomain: data.xdomain
#secure: data.secure
# This is a helper method which wraps auth() above. It creates the action and calls
# auth. If authentication succeeds, acceptCallback() is called if it exists. otherwise
# userCallback(true) is called.
#
# If authentication fails, userCallback('forbidden', null) is called.
#
# If supplied, actionData is turned into the action object passed to auth.
doAuth: (actionData, name, userCallback, acceptCallback) ->
action = actionData || {}
action.name = name
action.type = switch name
when 'connect' then 'connect'
when 'create' then 'create'
when 'get snapshot', 'get ops', 'open' then 'read'
when 'submit op' then 'update'
when 'submit meta' then 'update'
when 'delete' then 'delete'
else throw new Error "Invalid action name #{name}"
responded = false
action.reject = ->
throw new Error 'Multiple accept/reject calls made' if responded
responded = true
userCallback 'forbidden', null
action.accept = ->
throw new Error 'Multiple accept/reject calls made' if responded
responded = true
acceptCallback()
auth this, action
disconnect: ->
model.removeListener docName, listener for docName, listener of @listeners
getOps: (docName, start, end, callback) ->
@doAuth {docName, start, end}, 'get ops', callback, ->
model.getOps docName, start, end, callback
getSnapshot: (docName, callback) ->
@doAuth {docName}, 'get snapshot', callback, ->
model.getSnapshot docName, callback
create: (docName, type, meta, callback) ->
# We don't check that types[type.name] == type. That might be important at some point.
type = types[type] if typeof type == 'string'
# I'm not sure what client-specified metadata should be allowed in the document metadata
# object. For now, I'm going to ignore all create metadata until I know how it should work.
meta = {}
meta.creator = @name if @name
meta.ctime = meta.mtime = Date.now()
# The action object has a 'type' property already. Hence the doc type is renamed to 'docType'
@doAuth {docName, docType:type, meta}, 'create', callback, =>
model.create docName, type, meta, callback
submitOp: (docName, opData, callback) ->
opData.meta ||= {}
opData.meta.source = @sessionId
dupIfSource = opData.dupIfSource or []
# If ops and meta get coalesced, they should be separated here.
if opData.op
@doAuth {docName, op:opData.op, v:opData.v, meta:opData.meta, dupIfSource}, 'submit op', callback, =>
model.applyOp docName, opData, callback
else
@doAuth {docName, meta:opData.meta}, 'submit meta', callback, =>
model.applyMetaOp docName, opData, callback
# Delete the named operation.
# Callback is passed (deleted?, error message)
delete: (docName, callback) ->
@doAuth {docName}, 'delete', callback, =>
model.delete docName, callback
# Open the named document for reading. Just like model.listen, version is optional.
listen: (docName, version, listener, callback) ->
authOps = if version?
# If the specified version is older than the current version, we have to also check that the
# agent is allowed to get ops from the specified version.
#
# We _could_ check the version number of the document and then only check getOps if
# the specified version is old, but an auth check is almost certainly faster than a db roundtrip.
(c) => @doAuth {docName, start:version, end:null}, 'get ops', callback, c
else
(c) -> c()
authOps =>
@doAuth {docName, v:version if version?}, 'open', callback, =>
return callback? 'Document is already open' if @listeners[docName]
@listeners[docName] = listener
model.listen docName, version, listener, (error, v) =>
if error
delete @listeners[docName]
callback? error, v
removeListener: (docName) ->
throw new Error 'Document is not open' unless @listeners[docName]
model.removeListener docName, @listeners[docName]
delete @listeners[docName]
# Finally, return a function which takes client data and returns an authenticated useragent object
# through a callback.
(data, callback) ->
agent = new UserAgent data
agent.doAuth null, 'connect', callback, ->
# Maybe store a set of agents? Is that useful?
callback null, agent

View File

@@ -1,48 +0,0 @@
This directory contains all the operational transform code. Each file defines a type.
Most of the types in here are for testing or demonstration. The only types which are sent to the webclient
are `text` and `json`.
# An OT type
All OT types have the following fields:
`name`: _(string)_ Name of the type. Should match the filename.
`create() -> snapshot`: Function which creates and returns a new document snapshot
`apply(snapshot, op) -> snapshot`: A function which creates a new document snapshot with the op applied
`transform(op1, op2, side) -> op1'`: OT transform function.
Given op1, op2, `apply(s, op2, transform(op1, op2, 'left')) == apply(s, op1, transform(op2, op1, 'right'))`.
Transform and apply must never modify their arguments.
Optional properties:
`tp2`: _(bool)_ True if the transform function supports TP2. This allows p2p architectures to work.
`compose(op1, op2) -> op`: Create and return a new op which has the same effect as op1 + op2.
`serialize(snapshot) -> JSON object`: Serialize a document to something we can JSON.stringify()
`deserialize(object) -> snapshot`: Deserialize a JSON object into the document's internal snapshot format
`prune(op1', op2, side) -> op1`: Inserse transform function. Only required for TP2 types.
`normalize(op) -> op`: Fix up an op to make it valid. Eg, remove skips of size zero.
`api`: _(object)_ Set of helper methods which will be mixed in to the client document object for manipulating documents. See below.
# Examples
`count` and `simple` are two trivial OT type definitions if you want to take a look. JSON defines
the ot-for-JSON type (see the wiki for documentation) and all the text types define different text
implementations. (I still have no idea which one I like the most, and they're fun to write!)
# API
Types can also define API functions. These methods are mixed into the client's Doc object when a document is created.
You can use them to help construct ops programatically (so users don't need to understand how ops are structured).
For example, the three text types defined here (text, text-composable and text-tp2) all provide the text API, supplying
`.insert()`, `.del()`, `.getLength` and `.getText` methods.
See text-api.coffee for an example.

View File

@@ -1,22 +0,0 @@
# This is a simple type used for testing other OT code. Each op is [expectedSnapshot, increment]
exports.name = 'count'
exports.create = -> 1
exports.apply = (snapshot, op) ->
[v, inc] = op
throw new Error "Op #{v} != snapshot #{snapshot}" unless snapshot == v
snapshot + inc
# transform op1 by op2. Return transformed version of op1.
exports.transform = (op1, op2) ->
throw new Error "Op1 #{op1[0]} != op2 #{op2[0]}" unless op1[0] == op2[0]
[op1[0] + op2[1], op1[1]]
exports.compose = (op1, op2) ->
throw new Error "Op1 #{op1} + 1 != op2 #{op2}" unless op1[0] + op1[1] == op2[0]
[op1[0], op1[1] + op2[1]]
exports.generateRandomOp = (doc) ->
[[doc, 1], doc + 1]

View File

@@ -1,65 +0,0 @@
# These methods let you build a transform function from a transformComponent function
# for OT types like text and JSON in which operations are lists of components
# and transforming them requires N^2 work.
# Add transform and transformX functions for an OT type which has transformComponent defined.
# transformComponent(destination array, component, other component, side)
exports['_bt'] = bootstrapTransform = (type, transformComponent, checkValidOp, append) ->
transformComponentX = (left, right, destLeft, destRight) ->
transformComponent destLeft, left, right, 'left'
transformComponent destRight, right, left, 'right'
# Transforms rightOp by leftOp. Returns ['rightOp', clientOp']
type.transformX = type['transformX'] = transformX = (leftOp, rightOp) ->
checkValidOp leftOp
checkValidOp rightOp
newRightOp = []
for rightComponent in rightOp
# Generate newLeftOp by composing leftOp by rightComponent
newLeftOp = []
k = 0
while k < leftOp.length
nextC = []
transformComponentX leftOp[k], rightComponent, newLeftOp, nextC
k++
if nextC.length == 1
rightComponent = nextC[0]
else if nextC.length == 0
append newLeftOp, l for l in leftOp[k..]
rightComponent = null
break
else
# Recurse.
[l_, r_] = transformX leftOp[k..], nextC
append newLeftOp, l for l in l_
append newRightOp, r for r in r_
rightComponent = null
break
append newRightOp, rightComponent if rightComponent?
leftOp = newLeftOp
[leftOp, newRightOp]
# Transforms op with specified type ('left' or 'right') by otherOp.
type.transform = type['transform'] = (op, otherOp, type) ->
throw new Error "type must be 'left' or 'right'" unless type == 'left' or type == 'right'
return op if otherOp.length == 0
# TODO: Benchmark with and without this line. I _think_ it'll make a big difference...?
return transformComponent [], op[0], otherOp[0], type if op.length == 1 and otherOp.length == 1
if type == 'left'
[left, _] = transformX op, otherOp
left
else
[_, right] = transformX otherOp, op
right
if typeof WEB is 'undefined'
exports.bootstrapTransform = bootstrapTransform

View File

@@ -1,15 +0,0 @@
register = (file) ->
type = require file
exports[type.name] = type
try require "#{file}-api"
# Import all the built-in types.
register './simple'
register './count'
register './text'
register './text-composable'
register './text-tp2'
register './json'

View File

@@ -1,180 +0,0 @@
# API for JSON OT
json = require './json' if typeof WEB is 'undefined'
if WEB?
extendDoc = exports.extendDoc
exports.extendDoc = (name, fn) ->
SubDoc::[name] = fn
extendDoc name, fn
depath = (path) ->
if path.length == 1 and path[0].constructor == Array
path[0]
else path
class SubDoc
constructor: (@doc, @path) ->
at: (path...) -> @doc.at @path.concat depath path
get: -> @doc.getAt @path
# for objects and lists
set: (value, cb) -> @doc.setAt @path, value, cb
# for strings and lists.
insert: (pos, value, cb) -> @doc.insertAt @path, pos, value, cb
# for strings
del: (pos, length, cb) -> @doc.deleteTextAt @path, length, pos, cb
# for objects and lists
remove: (cb) -> @doc.removeAt @path, cb
push: (value, cb) -> @insert @get().length, value, cb
move: (from, to, cb) -> @doc.moveAt @path, from, to, cb
add: (amount, cb) -> @doc.addAt @path, amount, cb
on: (event, cb) -> @doc.addListener @path, event, cb
removeListener: (l) -> @doc.removeListener l
# text API compatibility
getLength: -> @get().length
getText: -> @get()
traverse = (snapshot, path) ->
container = data:snapshot
key = 'data'
elem = container
for p in path
elem = elem[key]
key = p
throw new Error 'bad path' if typeof elem == 'undefined'
{elem, key}
pathEquals = (p1, p2) ->
return false if p1.length != p2.length
for e,i in p1
return false if e != p2[i]
true
json.api =
provides: {json:true}
at: (path...) -> new SubDoc this, depath path
get: -> @snapshot
set: (value, cb) -> @setAt [], value, cb
getAt: (path) ->
{elem, key} = traverse @snapshot, path
return elem[key]
setAt: (path, value, cb) ->
{elem, key} = traverse @snapshot, path
op = {p:path}
if elem.constructor == Array
op.li = value
op.ld = elem[key] if typeof elem[key] != 'undefined'
else if typeof elem == 'object'
op.oi = value
op.od = elem[key] if typeof elem[key] != 'undefined'
else throw new Error 'bad path'
@submitOp [op], cb
removeAt: (path, cb) ->
{elem, key} = traverse @snapshot, path
throw new Error 'no element at that path' unless typeof elem[key] != 'undefined'
op = {p:path}
if elem.constructor == Array
op.ld = elem[key]
else if typeof elem == 'object'
op.od = elem[key]
else throw new Error 'bad path'
@submitOp [op], cb
insertAt: (path, pos, value, cb) ->
{elem, key} = traverse @snapshot, path
op = {p:path.concat pos}
if elem[key].constructor == Array
op.li = value
else if typeof elem[key] == 'string'
op.si = value
@submitOp [op], cb
moveAt: (path, from, to, cb) ->
op = [{p:path.concat(from), lm:to}]
@submitOp op, cb
addAt: (path, amount, cb) ->
op = [{p:path, na:amount}]
@submitOp op, cb
deleteTextAt: (path, length, pos, cb) ->
{elem, key} = traverse @snapshot, path
op = [{p:path.concat(pos), sd:elem[key][pos...(pos + length)]}]
@submitOp op, cb
addListener: (path, event, cb) ->
l = {path, event, cb}
@_listeners.push l
l
removeListener: (l) ->
i = @_listeners.indexOf l
return false if i < 0
@_listeners.splice i, 1
return true
_register: ->
@_listeners = []
@on 'change', (op) ->
for c in op
if c.na != undefined or c.si != undefined or c.sd != undefined
# no change to structure
continue
to_remove = []
for l, i in @_listeners
# Transform a dummy op by the incoming op to work out what
# should happen to the listener.
dummy = {p:l.path, na:0}
xformed = @type.transformComponent [], dummy, c, 'left'
if xformed.length == 0
# The op was transformed to noop, so we should delete the listener.
to_remove.push i
else if xformed.length == 1
# The op remained, so grab its new path into the listener.
l.path = xformed[0].p
else
throw new Error "Bad assumption in json-api: xforming an 'si' op will always result in 0 or 1 components."
to_remove.sort (a, b) -> b - a
for i in to_remove
@_listeners.splice i, 1
@on 'remoteop', (op) ->
for c in op
match_path = if c.na == undefined then c.p[...c.p.length-1] else c.p
for {path, event, cb} in @_listeners
if pathEquals path, match_path
switch event
when 'insert'
if c.li != undefined and c.ld == undefined
cb(c.p[c.p.length-1], c.li)
else if c.oi != undefined and c.od == undefined
cb(c.p[c.p.length-1], c.oi)
else if c.si != undefined
cb(c.p[c.p.length-1], c.si)
when 'delete'
if c.li == undefined and c.ld != undefined
cb(c.p[c.p.length-1], c.ld)
else if c.oi == undefined and c.od != undefined
cb(c.p[c.p.length-1], c.od)
else if c.sd != undefined
cb(c.p[c.p.length-1], c.sd)
when 'replace'
if c.li != undefined and c.ld != undefined
cb(c.p[c.p.length-1], c.ld, c.li)
else if c.oi != undefined and c.od != undefined
cb(c.p[c.p.length-1], c.od, c.oi)
when 'move'
if c.lm != undefined
cb(c.p[c.p.length-1], c.lm)
when 'add'
if c.na != undefined
cb(c.na)
else if (common = @type.commonPath match_path, path)?
if event == 'child op'
if match_path.length == path.length == common
throw new Error "paths match length and have commonality, but aren't equal?"
child_path = c.p[common+1..]
cb(child_path, c)

View File

@@ -1,441 +0,0 @@
# This is the implementation of the JSON OT type.
#
# Spec is here: https://github.com/josephg/ShareJS/wiki/JSON-Operations
if WEB?
text = exports.types.text
else
text = require './text'
json = {}
json.name = 'json'
json.create = -> null
json.invertComponent = (c) ->
c_ = {p: c.p}
c_.sd = c.si if c.si != undefined
c_.si = c.sd if c.sd != undefined
c_.od = c.oi if c.oi != undefined
c_.oi = c.od if c.od != undefined
c_.ld = c.li if c.li != undefined
c_.li = c.ld if c.ld != undefined
c_.na = -c.na if c.na != undefined
if c.lm != undefined
c_.lm = c.p[c.p.length-1]
c_.p = c.p[0...c.p.length - 1].concat([c.lm])
c_
json.invert = (op) -> json.invertComponent c for c in op.slice().reverse()
json.checkValidOp = (op) ->
isArray = (o) -> Object.prototype.toString.call(o) == '[object Array]'
json.checkList = (elem) ->
throw new Error 'Referenced element not a list' unless isArray(elem)
json.checkObj = (elem) ->
throw new Error "Referenced element not an object (it was #{JSON.stringify elem})" unless elem.constructor is Object
json.apply = (snapshot, op) ->
json.checkValidOp op
op = clone op
container = {data: clone snapshot}
try
for c, i in op
parent = null
parentkey = null
elem = container
key = 'data'
for p in c.p
parent = elem
parentkey = key
elem = elem[key]
key = p
throw new Error 'Path invalid' unless parent?
if c.na != undefined
# Number add
throw new Error 'Referenced element not a number' unless typeof elem[key] is 'number'
elem[key] += c.na
else if c.si != undefined
# String insert
throw new Error "Referenced element not a string (it was #{JSON.stringify elem})" unless typeof elem is 'string'
parent[parentkey] = elem[...key] + c.si + elem[key..]
else if c.sd != undefined
# String delete
throw new Error 'Referenced element not a string' unless typeof elem is 'string'
throw new Error 'Deleted string does not match' unless elem[key...key + c.sd.length] == c.sd
parent[parentkey] = elem[...key] + elem[key + c.sd.length..]
else if c.li != undefined && c.ld != undefined
# List replace
json.checkList elem
# Should check the list element matches c.ld
elem[key] = c.li
else if c.li != undefined
# List insert
json.checkList elem
elem.splice key, 0, c.li
else if c.ld != undefined
# List delete
json.checkList elem
# Should check the list element matches c.ld here too.
elem.splice key, 1
else if c.lm != undefined
# List move
json.checkList elem
if c.lm != key
e = elem[key]
# Remove it...
elem.splice key, 1
# And insert it back.
elem.splice c.lm, 0, e
else if c.oi != undefined
# Object insert / replace
json.checkObj elem
# Should check that elem[key] == c.od
elem[key] = c.oi
else if c.od != undefined
# Object delete
json.checkObj elem
# Should check that elem[key] == c.od
delete elem[key]
else
throw new Error 'invalid / missing instruction in op'
catch error
# TODO: Roll back all already applied changes. Write tests before implementing this code.
throw error
container.data
# Checks if two paths, p1 and p2 match.
json.pathMatches = (p1, p2, ignoreLast) ->
return false unless p1.length == p2.length
for p, i in p1
return false if p != p2[i] and (!ignoreLast or i != p1.length - 1)
true
json.append = (dest, c) ->
c = clone c
if dest.length != 0 and json.pathMatches c.p, (last = dest[dest.length - 1]).p
if last.na != undefined and c.na != undefined
dest[dest.length - 1] = { p: last.p, na: last.na + c.na }
else if last.li != undefined and c.li == undefined and c.ld == last.li
# insert immediately followed by delete becomes a noop.
if last.ld != undefined
# leave the delete part of the replace
delete last.li
else
dest.pop()
else if last.od != undefined and last.oi == undefined and
c.oi != undefined and c.od == undefined
last.oi = c.oi
else if c.lm != undefined and c.p[c.p.length-1] == c.lm
null # don't do anything
else
dest.push c
else
dest.push c
json.compose = (op1, op2) ->
json.checkValidOp op1
json.checkValidOp op2
newOp = clone op1
json.append newOp, c for c in op2
newOp
json.normalize = (op) ->
newOp = []
op = [op] unless isArray op
for c in op
c.p ?= []
json.append newOp, c
newOp
# hax, copied from test/types/json. Apparently this is still the fastest way to deep clone an object, assuming
# we have browser support for JSON.
# http://jsperf.com/cloning-an-object/12
clone = (o) -> JSON.parse(JSON.stringify o)
json.commonPath = (p1, p2) ->
p1 = p1.slice()
p2 = p2.slice()
p1.unshift('data')
p2.unshift('data')
p1 = p1[...p1.length-1]
p2 = p2[...p2.length-1]
return -1 if p2.length == 0
i = 0
while p1[i] == p2[i] && i < p1.length
i++
if i == p2.length
return i-1
return
# transform c so it applies to a document with otherC applied.
json.transformComponent = (dest, c, otherC, type) ->
c = clone c
c.p.push(0) if c.na != undefined
otherC.p.push(0) if otherC.na != undefined
common = json.commonPath c.p, otherC.p
common2 = json.commonPath otherC.p, c.p
cplength = c.p.length
otherCplength = otherC.p.length
c.p.pop() if c.na != undefined # hax
otherC.p.pop() if otherC.na != undefined
if otherC.na
if common2? && otherCplength >= cplength && otherC.p[common2] == c.p[common2]
if c.ld != undefined
oc = clone otherC
oc.p = oc.p[cplength..]
c.ld = json.apply clone(c.ld), [oc]
else if c.od != undefined
oc = clone otherC
oc.p = oc.p[cplength..]
c.od = json.apply clone(c.od), [oc]
json.append dest, c
return dest
if common2? && otherCplength > cplength && c.p[common2] == otherC.p[common2]
# transform based on c
if c.ld != undefined
oc = clone otherC
oc.p = oc.p[cplength..]
c.ld = json.apply clone(c.ld), [oc]
else if c.od != undefined
oc = clone otherC
oc.p = oc.p[cplength..]
c.od = json.apply clone(c.od), [oc]
if common?
commonOperand = cplength == otherCplength
# transform based on otherC
if otherC.na != undefined
# this case is handled above due to icky path hax
else if otherC.si != undefined || otherC.sd != undefined
# String op vs string op - pass through to text type
if c.si != undefined || c.sd != undefined
throw new Error("must be a string?") unless commonOperand
# Convert an op component to a text op component
convert = (component) ->
newC = p:component.p[component.p.length - 1]
if component.si
newC.i = component.si
else
newC.d = component.sd
newC
tc1 = convert c
tc2 = convert otherC
res = []
text._tc res, tc1, tc2, type
for tc in res
jc = { p: c.p[...common] }
jc.p.push(tc.p)
jc.si = tc.i if tc.i?
jc.sd = tc.d if tc.d?
json.append dest, jc
return dest
else if otherC.li != undefined && otherC.ld != undefined
if otherC.p[common] == c.p[common]
# noop
if !commonOperand
# we're below the deleted element, so -> noop
return dest
else if c.ld != undefined
# we're trying to delete the same element, -> noop
if c.li != undefined and type == 'left'
# we're both replacing one element with another. only one can
# survive!
c.ld = clone otherC.li
else
return dest
else if otherC.li != undefined
if c.li != undefined and c.ld == undefined and commonOperand and c.p[common] == otherC.p[common]
# in li vs. li, left wins.
if type == 'right'
c.p[common]++
else if otherC.p[common] <= c.p[common]
c.p[common]++
if c.lm != undefined
if commonOperand
# otherC edits the same list we edit
if otherC.p[common] <= c.lm
c.lm++
# changing c.from is handled above.
else if otherC.ld != undefined
if c.lm != undefined
if commonOperand
if otherC.p[common] == c.p[common]
# they deleted the thing we're trying to move
return dest
# otherC edits the same list we edit
p = otherC.p[common]
from = c.p[common]
to = c.lm
if p < to || (p == to && from < to)
c.lm--
if otherC.p[common] < c.p[common]
c.p[common]--
else if otherC.p[common] == c.p[common]
if otherCplength < cplength
# we're below the deleted element, so -> noop
return dest
else if c.ld != undefined
if c.li != undefined
# we're replacing, they're deleting. we become an insert.
delete c.ld
else
# we're trying to delete the same element, -> noop
return dest
else if otherC.lm != undefined
if c.lm != undefined and cplength == otherCplength
# lm vs lm, here we go!
from = c.p[common]
to = c.lm
otherFrom = otherC.p[common]
otherTo = otherC.lm
if otherFrom != otherTo
# if otherFrom == otherTo, we don't need to change our op.
# where did my thing go?
if from == otherFrom
# they moved it! tie break.
if type == 'left'
c.p[common] = otherTo
if from == to # ugh
c.lm = otherTo
else
return dest
else
# they moved around it
if from > otherFrom
c.p[common]--
if from > otherTo
c.p[common]++
else if from == otherTo
if otherFrom > otherTo
c.p[common]++
if from == to # ugh, again
c.lm++
# step 2: where am i going to put it?
if to > otherFrom
c.lm--
else if to == otherFrom
if to > from
c.lm--
if to > otherTo
c.lm++
else if to == otherTo
# if we're both moving in the same direction, tie break
if (otherTo > otherFrom and to > from) or
(otherTo < otherFrom and to < from)
if type == 'right'
c.lm++
else
if to > from
c.lm++
else if to == otherFrom
c.lm--
else if c.li != undefined and c.ld == undefined and commonOperand
# li
from = otherC.p[common]
to = otherC.lm
p = c.p[common]
if p > from
c.p[common]--
if p > to
c.p[common]++
else
# ld, ld+li, si, sd, na, oi, od, oi+od, any li on an element beneath
# the lm
#
# i.e. things care about where their item is after the move.
from = otherC.p[common]
to = otherC.lm
p = c.p[common]
if p == from
c.p[common] = to
else
if p > from
c.p[common]--
if p > to
c.p[common]++
else if p == to
if from > to
c.p[common]++
else if otherC.oi != undefined && otherC.od != undefined
if c.p[common] == otherC.p[common]
if c.oi != undefined and commonOperand
# we inserted where someone else replaced
if type == 'right'
# left wins
return dest
else
# we win, make our op replace what they inserted
c.od = otherC.oi
else
# -> noop if the other component is deleting the same object (or any
# parent)
return dest
else if otherC.oi != undefined
if c.oi != undefined and c.p[common] == otherC.p[common]
# left wins if we try to insert at the same place
if type == 'left'
json.append dest, {p:c.p, od:otherC.oi}
else
return dest
else if otherC.od != undefined
if c.p[common] == otherC.p[common]
return dest if !commonOperand
if c.oi != undefined
delete c.od
else
return dest
json.append dest, c
return dest
if WEB?
exports.types ||= {}
# This is kind of awful - come up with a better way to hook this helper code up.
exports._bt(json, json.transformComponent, json.checkValidOp, json.append)
# [] is used to prevent closure from renaming types.text
exports.types.json = json
else
module.exports = json
require('./helpers').bootstrapTransform(json, json.transformComponent, json.checkValidOp, json.append)

View File

@@ -1,38 +0,0 @@
# This is a really simple OT type. Its not compiled with the web client, but it could be.
#
# Its mostly included for demonstration purposes and its used in a lot of unit tests.
#
# This defines a really simple text OT type which only allows inserts. (No deletes).
#
# Ops look like:
# {position:#, text:"asdf"}
#
# Document snapshots look like:
# {str:string}
module.exports =
# The name of the OT type. The type is stored in types[type.name]. The name can be
# used in place of the actual type in all the API methods.
name: 'simple'
# Create a new document snapshot
create: -> {str:""}
# Apply the given op to the document snapshot. Returns the new snapshot.
#
# The original snapshot should not be modified.
apply: (snapshot, op) ->
throw new Error 'Invalid position' unless 0 <= op.position <= snapshot.str.length
str = snapshot.str
str = str.slice(0, op.position) + op.text + str.slice(op.position)
{str}
# transform op1 by op2. Return transformed version of op1.
# sym describes the symmetry of the op. Its 'left' or 'right' depending on whether the
# op being transformed comes from the client or the server.
transform: (op1, op2, sym) ->
pos = op1.position
pos += op2.text.length if op2.position < pos or (op2.position == pos and sym is 'left')
return {position:pos, text:op1.text}

Some files were not shown because too many files have changed in this diff Show More