mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
Merge pull request #1037 from sharelatex/as-decaffeinate
Decaffeinate frontend GitOrigin-RevId: 1c8c53dedecfe55f9936a13408df17b852f996de
This commit is contained in:
committed by
sharelatex
parent
4842a45d8c
commit
659242b457
6
services/web/.babelrc
Normal file
6
services/web/.babelrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"presets": [
|
||||
"react",
|
||||
["env", { "modules": false }]
|
||||
]
|
||||
}
|
||||
@@ -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": [
|
||||
|
||||
1
services/web/.gitignore
vendored
1
services/web/.gitignore
vendored
@@ -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/
|
||||
|
||||
12
services/web/.prettierignore
Normal file
12
services/web/.prettierignore
Normal 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
4
services/web/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
57
services/web/decaffeinate.sh
Executable 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"
|
||||
@@ -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"
|
||||
}
|
||||
412
services/web/npm-shrinkwrap.json
generated
412
services/web/npm-shrinkwrap.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.directive 'onEnter', () ->
|
||||
return (scope, element, attrs) ->
|
||||
element.bind "keydown keypress", (event) ->
|
||||
if event.which == 13
|
||||
scope.$apply () ->
|
||||
scope.$eval(attrs.onEnter, event: event)
|
||||
event.preventDefault()
|
||||
@@ -1,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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.directive "stopPropagation", ($http) ->
|
||||
return {
|
||||
restrict: "A",
|
||||
link: (scope, element, attrs) ->
|
||||
element.bind attrs.stopPropagation, (e) ->
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
App.directive "preventDefault", ($http) ->
|
||||
return {
|
||||
restrict: "A",
|
||||
link: (scope, element, attrs) ->
|
||||
element.bind attrs.preventDefault, (e) ->
|
||||
e.preventDefault()
|
||||
}
|
||||
@@ -1,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()
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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 })"
|
||||
]
|
||||
@@ -1,7 +0,0 @@
|
||||
define [
|
||||
"ide/chat/controllers/ChatButtonController"
|
||||
"ide/chat/controllers/ChatController"
|
||||
"ide/chat/controllers/ChatMessageController"
|
||||
"directives/mathjax"
|
||||
"filters/wrapLongWords"
|
||||
], () ->
|
||||
@@ -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
|
||||
@@ -1,9 +0,0 @@
|
||||
define [
|
||||
"base"
|
||||
], (App) ->
|
||||
App.controller 'CloneProjectController', ($scope, $modal) ->
|
||||
$scope.openCloneProjectModal = () ->
|
||||
$modal.open {
|
||||
templateUrl: "cloneProjectModalTemplate"
|
||||
controller: "CloneProjectModalController"
|
||||
}
|
||||
@@ -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')
|
||||
@@ -1,4 +0,0 @@
|
||||
define [
|
||||
"ide/clone/controllers/CloneProjectController"
|
||||
"ide/clone/controllers/CloneProjectModalController"
|
||||
], () ->
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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>
|
||||
"""
|
||||
}
|
||||
@@ -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."
|
||||
]
|
||||
@@ -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(@)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
WEB = true
|
||||
window.sharejs = exports = {}
|
||||
types = exports.types = {}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# A Connection wraps a persistant BC connection to a sharejs server.
|
||||
#
|
||||
# This class implements the client side of the protocol defined here:
|
||||
# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
|
||||
#
|
||||
# The equivalent server code is in src/server/browserchannel.coffee.
|
||||
#
|
||||
# This file is a bit of a mess. I'm dreadfully sorry about that. It passes all the tests,
|
||||
# so I have hope that its *correct* even if its not clean.
|
||||
#
|
||||
# Most of Connection exists to support the open() method, which creates a new document
|
||||
# reference.
|
||||
|
||||
if WEB?
|
||||
types = exports.types
|
||||
throw new Error 'Must load browserchannel before this library' unless window.BCSocket
|
||||
{BCSocket} = window
|
||||
else
|
||||
types = require '../types'
|
||||
{BCSocket} = require 'browserchannel'
|
||||
Doc = require('./doc').Doc
|
||||
|
||||
class Connection
|
||||
constructor: (host) ->
|
||||
# Map of docname -> doc
|
||||
@docs = {}
|
||||
|
||||
# States:
|
||||
# - 'connecting': The connection is being established
|
||||
# - 'handshaking': The connection has been established, but we don't have the auth ID yet
|
||||
# - 'ok': We have connected and recieved our client ID. Ready for data.
|
||||
# - 'disconnected': The connection is closed, but it will not reconnect automatically.
|
||||
# - 'stopped': The connection is closed, and will not reconnect.
|
||||
@state = 'connecting'
|
||||
@socket = new BCSocket host, reconnect:true
|
||||
|
||||
@socket.onmessage = (msg) =>
|
||||
if msg.auth is null
|
||||
# Auth failed.
|
||||
@lastError = msg.error # 'forbidden'
|
||||
@disconnect()
|
||||
return @emit 'connect failed', msg.error
|
||||
else if msg.auth
|
||||
# Our very own client id.
|
||||
@id = msg.auth
|
||||
@setState 'ok'
|
||||
return
|
||||
|
||||
docName = msg.doc
|
||||
|
||||
if docName isnt undefined
|
||||
@lastReceivedDoc = docName
|
||||
else
|
||||
msg.doc = docName = @lastReceivedDoc
|
||||
|
||||
if @docs[docName]
|
||||
@docs[docName]._onMessage msg
|
||||
else
|
||||
console?.error 'Unhandled message', msg
|
||||
|
||||
@connected = false
|
||||
@socket.onclose = (reason) =>
|
||||
#console.warn 'onclose', reason
|
||||
@setState 'disconnected', reason
|
||||
if reason in ['Closed', 'Stopped by server']
|
||||
@setState 'stopped', @lastError or reason
|
||||
|
||||
@socket.onerror = (e) =>
|
||||
#console.warn 'onerror', e
|
||||
@emit 'error', e
|
||||
|
||||
@socket.onopen = =>
|
||||
#console.warn 'onopen'
|
||||
@lastError = @lastReceivedDoc = @lastSentDoc = null
|
||||
@setState 'handshaking'
|
||||
|
||||
@socket.onconnecting = =>
|
||||
#console.warn 'connecting'
|
||||
@setState 'connecting'
|
||||
|
||||
setState: (state, data) ->
|
||||
return if @state is state
|
||||
@state = state
|
||||
|
||||
delete @id if state is 'disconnected'
|
||||
@emit state, data
|
||||
|
||||
# Documents could just subscribe to the state change events, but there's less state to
|
||||
# clean up when you close a document if I just notify the doucments directly.
|
||||
for docName, doc of @docs
|
||||
doc._connectionStateChanged state, data
|
||||
|
||||
send: (data) ->
|
||||
docName = data.doc
|
||||
|
||||
if docName is @lastSentDoc
|
||||
delete data.doc
|
||||
else
|
||||
@lastSentDoc = docName
|
||||
|
||||
#console.warn 'c->s', data
|
||||
@socket.send data
|
||||
|
||||
disconnect: ->
|
||||
# This will call @socket.onclose(), which in turn will emit the 'disconnected' event.
|
||||
#console.warn 'calling close on the socket'
|
||||
@socket.close()
|
||||
|
||||
# *** Doc management
|
||||
|
||||
makeDoc: (name, data, callback) ->
|
||||
throw new Error("Doc #{name} already open") if @docs[name]
|
||||
doc = new Doc(@, name, data)
|
||||
@docs[name] = doc
|
||||
|
||||
doc.open (error) =>
|
||||
delete @docs[name] if error
|
||||
callback error, (doc unless error)
|
||||
|
||||
# Open a document that already exists
|
||||
# callback(error, doc)
|
||||
openExisting: (docName, callback) ->
|
||||
return callback 'connection closed' if @state is 'stopped'
|
||||
return callback null, @docs[docName] if @docs[docName]
|
||||
doc = @makeDoc docName, {}, callback
|
||||
|
||||
# Open a document. It will be created if it doesn't already exist.
|
||||
# Callback is passed a document or an error
|
||||
# type is either a type name (eg 'text' or 'simple') or the actual type object.
|
||||
# Types must be supported by the server.
|
||||
# callback(error, doc)
|
||||
open: (docName, type, callback) ->
|
||||
return callback 'connection closed' if @state is 'stopped'
|
||||
|
||||
if typeof type is 'function'
|
||||
callback = type
|
||||
type = 'text'
|
||||
|
||||
callback ||= ->
|
||||
|
||||
type = types[type] if typeof type is 'string'
|
||||
|
||||
throw new Error "OT code for document type missing" unless type
|
||||
|
||||
throw new Error 'Server-generated random doc names are not currently supported' unless docName?
|
||||
|
||||
if @docs[docName]
|
||||
doc = @docs[docName]
|
||||
if doc.type == type
|
||||
callback null, doc
|
||||
else
|
||||
callback 'Type mismatch', doc
|
||||
return
|
||||
|
||||
@makeDoc docName, {create:true, type:type.name}, callback
|
||||
|
||||
# Not currently working.
|
||||
# create: (type, callback) ->
|
||||
# open null, type, callback
|
||||
|
||||
# Make connections event emitters.
|
||||
unless WEB?
|
||||
MicroEvent = require './microevent'
|
||||
|
||||
MicroEvent.mixin Connection
|
||||
|
||||
exports.Connection = Connection
|
||||
@@ -1,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
|
||||
@@ -1,73 +0,0 @@
|
||||
# This file implements the sharejs client, as defined here:
|
||||
# https://github.com/josephg/ShareJS/wiki/Client-API
|
||||
#
|
||||
# It works from both a node.js context and a web context (though in the latter case,
|
||||
# it needs to be compiled to work.)
|
||||
#
|
||||
# It should become a little nicer once I start using more of the new RPC features added
|
||||
# in socket.io 0.7.
|
||||
#
|
||||
# Note that anything declared in the global scope here is shared with other files
|
||||
# built by closure. Be careful what you put in this namespace.
|
||||
|
||||
unless WEB?
|
||||
Connection = require('./connection').Connection
|
||||
|
||||
# Open a document with the given name. The connection is created implicitly and reused.
|
||||
#
|
||||
# This function uses a local (private) set of connections to support .open().
|
||||
#
|
||||
# Open returns the connection its using to access the document.
|
||||
exports.open = do ->
|
||||
# This is a private connection pool for implicitly created connections.
|
||||
connections = {}
|
||||
|
||||
getConnection = (origin) ->
|
||||
if WEB?
|
||||
location = window.location
|
||||
origin ?= "#{location.protocol}//#{location.host}/channel"
|
||||
|
||||
unless connections[origin]
|
||||
c = new Connection origin
|
||||
|
||||
del = -> delete connections[origin]
|
||||
c.on 'disconnecting', del
|
||||
c.on 'connect failed', del
|
||||
connections[origin] = c
|
||||
|
||||
connections[origin]
|
||||
|
||||
# If you're using the bare API, connections are cleaned up as soon as there's no
|
||||
# documents using them.
|
||||
maybeClose = (c) ->
|
||||
numDocs = 0
|
||||
for name, doc of c.docs
|
||||
numDocs++ if doc.state isnt 'closed' || doc.autoOpen
|
||||
|
||||
if numDocs == 0
|
||||
c.disconnect()
|
||||
|
||||
(docName, type, origin, callback) ->
|
||||
if typeof origin == 'function'
|
||||
callback = origin
|
||||
origin = null
|
||||
|
||||
c = getConnection origin
|
||||
c.numDocs++
|
||||
c.open docName, type, (error, doc) ->
|
||||
if error
|
||||
callback error
|
||||
maybeClose c
|
||||
else
|
||||
doc.on 'closed', -> maybeClose c
|
||||
|
||||
callback null, doc
|
||||
|
||||
c.on 'connect failed'
|
||||
return c
|
||||
|
||||
|
||||
unless WEB?
|
||||
exports.Doc = require('./doc').Doc
|
||||
exports.Connection = require('./connection').Connection
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
# This is a simple port of microevent.js to Coffeescript. I've changed the
|
||||
# function names to be consistent with node.js EventEmitter.
|
||||
#
|
||||
# microevent.js is copyright Jerome Etienne, and licensed under the MIT license:
|
||||
# https://github.com/jeromeetienne/microevent.js
|
||||
|
||||
nextTick = if WEB? then (fn) -> setTimeout fn, 0 else process['nextTick']
|
||||
|
||||
class MicroEvent
|
||||
on: (event, fct) ->
|
||||
@_events ||= {}
|
||||
@_events[event] ||= []
|
||||
@_events[event].push(fct)
|
||||
this
|
||||
|
||||
removeListener: (event, fct) ->
|
||||
@_events ||= {}
|
||||
listeners = (@_events[event] ||= [])
|
||||
|
||||
# Sadly, there's no IE8- support for indexOf.
|
||||
i = 0
|
||||
while i < listeners.length
|
||||
listeners[i] = undefined if listeners[i] == fct
|
||||
i++
|
||||
|
||||
nextTick => @_events[event] = (x for x in @_events[event] when x)
|
||||
|
||||
this
|
||||
|
||||
emit: (event, args...) ->
|
||||
return this unless @_events?[event]
|
||||
fn.apply this, args for fn in @_events[event] when fn
|
||||
this
|
||||
|
||||
# mixin will delegate all MicroEvent.js function in the destination object
|
||||
MicroEvent.mixin = (obj) ->
|
||||
proto = obj.prototype || obj
|
||||
|
||||
# Damn closure compiler :/
|
||||
proto.on = MicroEvent.prototype.on
|
||||
proto.removeListener = MicroEvent.prototype.removeListener
|
||||
proto.emit = MicroEvent.prototype.emit
|
||||
obj
|
||||
|
||||
module.exports = MicroEvent unless WEB?
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# Create an op which converts oldval -> newval.
|
||||
#
|
||||
# This function should be called every time the text element is changed. Because changes are
|
||||
# always localised, the diffing is quite easy.
|
||||
#
|
||||
# This algorithm is O(N), but I suspect you could speed it up somehow using regular expressions.
|
||||
applyChange = (doc, oldval, newval) ->
|
||||
return if oldval == newval
|
||||
commonStart = 0
|
||||
commonStart++ while oldval.charAt(commonStart) == newval.charAt(commonStart)
|
||||
|
||||
commonEnd = 0
|
||||
commonEnd++ while oldval.charAt(oldval.length - 1 - commonEnd) == newval.charAt(newval.length - 1 - commonEnd) and
|
||||
commonEnd + commonStart < oldval.length and commonEnd + commonStart < newval.length
|
||||
|
||||
doc.del commonStart, oldval.length - commonStart - commonEnd unless oldval.length == commonStart + commonEnd
|
||||
doc.insert commonStart, newval[commonStart ... newval.length - commonEnd] unless newval.length == commonStart + commonEnd
|
||||
|
||||
window.sharejs.extendDoc 'attach_textarea', (elem) ->
|
||||
doc = this
|
||||
elem.value = @getText()
|
||||
prevvalue = elem.value
|
||||
|
||||
replaceText = (newText, transformCursor) ->
|
||||
newSelection = [
|
||||
transformCursor elem.selectionStart
|
||||
transformCursor elem.selectionEnd
|
||||
]
|
||||
|
||||
scrollTop = elem.scrollTop
|
||||
elem.value = newText
|
||||
elem.scrollTop = scrollTop if elem.scrollTop != scrollTop
|
||||
[elem.selectionStart, elem.selectionEnd] = newSelection
|
||||
|
||||
@on 'insert', (pos, text) ->
|
||||
transformCursor = (cursor) ->
|
||||
if pos < cursor
|
||||
cursor + text.length
|
||||
else
|
||||
cursor
|
||||
#for IE8 and Opera that replace \n with \r\n.
|
||||
prevvalue = elem.value.replace /\r\n/g, '\n'
|
||||
replaceText prevvalue[...pos] + text + prevvalue[pos..], transformCursor
|
||||
|
||||
@on 'delete', (pos, text) ->
|
||||
transformCursor = (cursor) ->
|
||||
if pos < cursor
|
||||
cursor - Math.min(text.length, cursor - pos)
|
||||
else
|
||||
cursor
|
||||
#for IE8 and Opera that replace \n with \r\n.
|
||||
prevvalue = elem.value.replace /\r\n/g, '\n'
|
||||
replaceText prevvalue[...pos] + prevvalue[pos + text.length..], transformCursor
|
||||
|
||||
genOp = (event) ->
|
||||
onNextTick = (fn) -> setTimeout fn, 0
|
||||
onNextTick ->
|
||||
if elem.value != prevvalue
|
||||
# IE constantly replaces unix newlines with \r\n. ShareJS docs
|
||||
# should only have unix newlines.
|
||||
prevvalue = elem.value
|
||||
applyChange doc, doc.getText(), elem.value.replace /\r\n/g, '\n'
|
||||
|
||||
for event in ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste']
|
||||
if elem.addEventListener
|
||||
elem.addEventListener event, genOp, false
|
||||
else
|
||||
elem.attachEvent 'on'+event, genOp
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# This file is included at the top of the compiled client-side javascript
|
||||
|
||||
# This way all the modules can add stuff to exports, and for the web client they'll all get exported.
|
||||
window.sharejs = exports =
|
||||
'version': '0.5.0'
|
||||
|
||||
# This is compiled out when compiled with uglifyjs, but its important for the share.uncompressed.js.
|
||||
#
|
||||
# Maybe I should rename WEB to __SHAREJS_WEB or something, but its only relevant for testing
|
||||
# anyway.
|
||||
if typeof WEB == 'undefined'
|
||||
# This will put WEB in the global scope in a browser.
|
||||
window.WEB = true
|
||||
@@ -1,5 +0,0 @@
|
||||
exports.server = require './server'
|
||||
exports.client = require './client'
|
||||
exports.types = require './types'
|
||||
|
||||
exports.version = '0.5.0'
|
||||
@@ -1,333 +0,0 @@
|
||||
# This implements the network API for ShareJS.
|
||||
#
|
||||
# The wire protocol is speccced out here:
|
||||
# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
|
||||
#
|
||||
# When a client connects the server first authenticates it and sends:
|
||||
#
|
||||
# S: {auth:<agent session id>}
|
||||
# or
|
||||
# S: {auth:null, error:'forbidden'}
|
||||
#
|
||||
# After that, the client can open documents:
|
||||
#
|
||||
# C: {doc:'foo', open:true, snapshot:null, create:true, type:'text'}
|
||||
# S: {doc:'foo', open:true, snapshot:{snapshot:'hi there', v:5, meta:{}}, create:false}
|
||||
#
|
||||
# ...
|
||||
#
|
||||
# The client can send open requests as soon as the socket has opened - it doesn't need to
|
||||
# wait for auth.
|
||||
#
|
||||
# The wire protocol is documented here:
|
||||
# https://github.com/josephg/ShareJS/wiki/Wire-Protocol
|
||||
|
||||
browserChannel = require('browserchannel').server
|
||||
util = require 'util'
|
||||
hat = require 'hat'
|
||||
|
||||
syncQueue = require './syncqueue'
|
||||
|
||||
# Attach the streaming protocol to the supplied http.Server.
|
||||
#
|
||||
# Options = {}
|
||||
module.exports = (createAgent, options) ->
|
||||
options or= {}
|
||||
|
||||
browserChannel options, (session) ->
|
||||
#console.log "New BC session from #{session.address} with id #{session.id}"
|
||||
data =
|
||||
headers: session.headers
|
||||
remoteAddress: session.address
|
||||
|
||||
# This is the user agent through which a connecting client acts. It is set when the
|
||||
# session is authenticated. The agent is responsible for making sure client requests are
|
||||
# properly authorized, and metadata is kept up to date.
|
||||
agent = null
|
||||
|
||||
# To save on network traffic, the agent & server can leave out the docName with each message to mean
|
||||
# 'same as the last message'
|
||||
lastSentDoc = null
|
||||
lastReceivedDoc = null
|
||||
|
||||
# Map from docName -> {queue, listener if open}
|
||||
docState = {}
|
||||
|
||||
# We'll only handle one message from each client at a time.
|
||||
handleMessage = (query) ->
|
||||
#console.log "Message from #{session.id}", query
|
||||
|
||||
error = null
|
||||
error = 'Invalid docName' unless query.doc is null or typeof query.doc is 'string' or (query.doc is undefined and lastReceivedDoc)
|
||||
error = "'create' must be true or missing" unless query.create in [true, undefined]
|
||||
error = "'open' must be true, false or missing" unless query.open in [true, false, undefined]
|
||||
error = "'snapshot' must be null or missing" unless query.snapshot in [null, undefined]
|
||||
error = "'type' invalid" unless query.type is undefined or typeof query.type is 'string'
|
||||
error = "'v' invalid" unless query.v is undefined or (typeof query.v is 'number' and query.v >= 0)
|
||||
|
||||
if error
|
||||
console.warn "Invalid query #{JSON.stringify query} from #{agent.sessionId}: #{error}"
|
||||
session.abort()
|
||||
return callback()
|
||||
|
||||
# The agent can specify null as the docName to get a random doc name.
|
||||
if query.doc is null
|
||||
query.doc = lastReceivedDoc = hat()
|
||||
else if query.doc != undefined
|
||||
lastReceivedDoc = query.doc
|
||||
else
|
||||
unless lastReceivedDoc
|
||||
console.warn "msg.doc missing in query #{JSON.stringify query} from #{agent.sessionId}"
|
||||
# The disconnect handler will be called when we do this, which will clean up the open docs.
|
||||
return session.abort()
|
||||
|
||||
query.doc = lastReceivedDoc
|
||||
|
||||
docState[query.doc] or= queue: syncQueue (query, callback) ->
|
||||
# When the session is closed, we'll nuke docState. When that happens, no more messages
|
||||
# should be handled.
|
||||
return callback() unless docState
|
||||
|
||||
# Close messages are {open:false}
|
||||
if query.open == false
|
||||
handleClose query, callback
|
||||
|
||||
# Open messages are {open:true}. There's a lot of shared logic with getting snapshots
|
||||
# and creating documents. These operations can be done together; and I'll handle them
|
||||
# together.
|
||||
else if query.open or query.snapshot is null or query.create
|
||||
# You can open, request a snapshot and create all in the same
|
||||
# request. They're all handled together.
|
||||
handleOpenCreateSnapshot query, callback
|
||||
|
||||
# The socket is submitting an op.
|
||||
else if query.op? or query.meta?.path?
|
||||
handleOp query, callback
|
||||
|
||||
else
|
||||
console.warn "Invalid query #{JSON.stringify query} from #{agent.sessionId}"
|
||||
session.abort()
|
||||
callback()
|
||||
|
||||
# ... And add the message to the queue.
|
||||
docState[query.doc].queue query
|
||||
|
||||
|
||||
# # Some utility methods for message handlers
|
||||
|
||||
# Send a message to the socket.
|
||||
# msg _must_ have the doc:DOCNAME property set. We'll remove it if its the same as lastReceivedDoc.
|
||||
send = (response) ->
|
||||
if response.doc is lastSentDoc
|
||||
delete response.doc
|
||||
else
|
||||
lastSentDoc = response.doc
|
||||
|
||||
# Its invalid to send a message to a closed session. We'll silently drop messages if the
|
||||
# session has closed.
|
||||
if session.state isnt 'closed'
|
||||
#console.log "Sending", response
|
||||
session.send response
|
||||
|
||||
# Open the given document name, at the requested version.
|
||||
# callback(error, version)
|
||||
open = (docName, version, callback) ->
|
||||
return callback 'Session closed' unless docState
|
||||
return callback 'Document already open' if docState[docName].listener
|
||||
#p "Registering listener on #{docName} by #{socket.id} at #{version}"
|
||||
|
||||
docState[docName].listener = listener = (opData) ->
|
||||
throw new Error 'Consistency violation - doc listener invalid' unless docState[docName].listener == listener
|
||||
|
||||
#p "listener doc:#{docName} opdata:#{i opData} v:#{version}"
|
||||
|
||||
# Skip the op if this socket sent it.
|
||||
return if opData.meta.source is agent.sessionId
|
||||
|
||||
opMsg =
|
||||
doc: docName
|
||||
op: opData.op
|
||||
v: opData.v
|
||||
meta: opData.meta
|
||||
|
||||
send opMsg
|
||||
|
||||
# Tell the socket the doc is open at the requested version
|
||||
agent.listen docName, version, listener, (error, v) ->
|
||||
delete docState[docName].listener if error
|
||||
callback error, v
|
||||
|
||||
# Close the named document.
|
||||
# callback([error])
|
||||
close = (docName, callback) ->
|
||||
#p "Closing #{docName}"
|
||||
return callback 'Session closed' unless docState
|
||||
listener = docState[docName].listener
|
||||
return callback 'Doc already closed' unless listener?
|
||||
|
||||
agent.removeListener docName
|
||||
delete docState[docName].listener
|
||||
callback()
|
||||
|
||||
# Handles messages with any combination of the open:true, create:true and snapshot:null parameters
|
||||
handleOpenCreateSnapshot = (query, finished) ->
|
||||
docName = query.doc
|
||||
msg = doc:docName
|
||||
|
||||
callback = (error) ->
|
||||
if error
|
||||
close(docName) if msg.open == true
|
||||
msg.open = false if query.open == true
|
||||
msg.snapshot = null if query.snapshot != undefined
|
||||
delete msg.create
|
||||
|
||||
msg.error = error
|
||||
|
||||
send msg
|
||||
finished()
|
||||
|
||||
return callback 'No docName specified' unless query.doc?
|
||||
|
||||
if query.create == true
|
||||
if typeof query.type != 'string'
|
||||
return callback 'create:true requires type specified'
|
||||
|
||||
if query.meta != undefined
|
||||
unless typeof query.meta == 'object' and Array.isArray(query.meta) == false
|
||||
return callback 'meta must be an object'
|
||||
|
||||
docData = undefined
|
||||
|
||||
# This is implemented with a series of cascading methods for each different type of
|
||||
# thing this method can handle. This would be so much nicer with an async library. Welcome to
|
||||
# callback hell.
|
||||
|
||||
step1Create = ->
|
||||
return step2Snapshot() if query.create != true
|
||||
|
||||
# The document obviously already exists if we have a snapshot.
|
||||
if docData
|
||||
msg.create = false
|
||||
step2Snapshot()
|
||||
else
|
||||
agent.create docName, query.type, query.meta || {}, (error) ->
|
||||
if error is 'Document already exists'
|
||||
# We've called getSnapshot (-> null), then create (-> already exists). Its possible
|
||||
# another agent has called create() between our getSnapshot and create() calls.
|
||||
agent.getSnapshot docName, (error, data) ->
|
||||
return callback error if error
|
||||
|
||||
docData = data
|
||||
msg.create = false
|
||||
step2Snapshot()
|
||||
else if error
|
||||
callback error
|
||||
else
|
||||
msg.create = true
|
||||
step2Snapshot()
|
||||
|
||||
# The socket requested a document snapshot
|
||||
step2Snapshot = ->
|
||||
# if query.create or query.open or query.snapshot == null
|
||||
# msg.meta = docData.meta
|
||||
|
||||
# Skip inserting a snapshot if the document was just created.
|
||||
if query.snapshot != null or msg.create == true
|
||||
step3Open()
|
||||
return
|
||||
|
||||
if docData
|
||||
msg.v = docData.v
|
||||
msg.type = docData.type.name unless query.type == docData.type.name
|
||||
msg.snapshot = docData.snapshot
|
||||
else
|
||||
return callback 'Document does not exist'
|
||||
|
||||
step3Open()
|
||||
|
||||
# Attempt to open a document with a given name. Version is optional.
|
||||
# callback(opened at version) or callback(null, errormessage)
|
||||
step3Open = ->
|
||||
return callback() if query.open != true
|
||||
|
||||
# Verify the type matches
|
||||
return callback 'Type mismatch' if query.type and docData and query.type != docData.type.name
|
||||
|
||||
open docName, query.v, (error, version) ->
|
||||
return callback error if error
|
||||
|
||||
# + Should fail if the type is wrong.
|
||||
|
||||
#p "Opened #{docName} at #{version} by #{socket.id}"
|
||||
msg.open = true
|
||||
msg.v = version
|
||||
callback()
|
||||
|
||||
# Technically, we don't need a snapshot if the user called create but not open or createSnapshot,
|
||||
# but no clients do that yet anyway.
|
||||
if query.snapshot == null or query.open == true #and query.type
|
||||
agent.getSnapshot query.doc, (error, data) ->
|
||||
return callback error if error and error != 'Document does not exist'
|
||||
|
||||
docData = data
|
||||
step1Create()
|
||||
else
|
||||
step1Create()
|
||||
|
||||
# The socket closes a document
|
||||
handleClose = (query, callback) ->
|
||||
close query.doc, (error) ->
|
||||
if error
|
||||
# An error closing still results in the doc being closed.
|
||||
send {doc:query.doc, open:false, error:error}
|
||||
else
|
||||
send {doc:query.doc, open:false}
|
||||
|
||||
callback()
|
||||
|
||||
# We received an op from the socket
|
||||
handleOp = (query, callback) ->
|
||||
# ...
|
||||
#throw new Error 'No version specified' unless query.v?
|
||||
|
||||
opData = {v:query.v, op:query.op, meta:query.meta, dupIfSource:query.dupIfSource}
|
||||
|
||||
# If it's a metaOp don't send a response
|
||||
agent.submitOp query.doc, opData, if (not opData.op? and opData.meta?.path?) then callback else (error, appliedVersion) ->
|
||||
msg = if error
|
||||
#p "Sending error to socket: #{error}"
|
||||
{doc:query.doc, v:null, error:error}
|
||||
else
|
||||
{doc:query.doc, v:appliedVersion}
|
||||
|
||||
send msg
|
||||
callback()
|
||||
|
||||
# We don't process any messages from the agent until they've authorized. Instead,
|
||||
# they are stored in this buffer.
|
||||
buffer = []
|
||||
session.on 'message', bufferMsg = (msg) -> buffer.push msg
|
||||
|
||||
createAgent data, (error, agent_) ->
|
||||
if error
|
||||
# The client is not authorized, so they shouldn't try and reconnect.
|
||||
session.send {auth:null, error}
|
||||
session.stop()
|
||||
else
|
||||
agent = agent_
|
||||
session.send auth:agent.sessionId
|
||||
|
||||
# Ok. Now we can handle all the messages in the buffer. They'll go straight to
|
||||
# handleMessage from now on.
|
||||
session.removeListener 'message', bufferMsg
|
||||
handleMessage msg for msg in buffer
|
||||
buffer = null
|
||||
session.on 'message', handleMessage
|
||||
|
||||
session.on 'close', ->
|
||||
return unless agent
|
||||
#console.log "Client #{agent.sessionId} disconnected"
|
||||
for docName, {listener} of docState
|
||||
agent.removeListener docName if listener
|
||||
docState = null
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
# OT storage for CouchDB
|
||||
# Author: Max Ogden (@maxogden)
|
||||
#
|
||||
# The couchdb database contains two kinds of documents:
|
||||
#
|
||||
# - Document snapshots have a key which is doc:the document name
|
||||
# - Document ops have a random key, but docName: defined.
|
||||
|
||||
request = require('request').defaults json: true
|
||||
|
||||
# Helper method to parse errors out of couchdb. There's way more ways
|
||||
# things can go wrong, but I think this catches all the ones I care about.
|
||||
#
|
||||
# callback(error) or callback()
|
||||
parseError = (err, resp, body, callback) ->
|
||||
body = body[0] if Array.isArray body and body.length >= 1
|
||||
|
||||
if err
|
||||
# This indicates an HTTP error
|
||||
callback err
|
||||
else if resp.statusCode is 404
|
||||
callback 'Document does not exist'
|
||||
else if resp.statusCode is 403
|
||||
callback 'forbidden'
|
||||
else if typeof body is 'object'
|
||||
if body.error is 'conflict'
|
||||
callback 'Document already exists'
|
||||
else if body.error
|
||||
callback "#{body.error} reason: #{body.reason}"
|
||||
else
|
||||
callback()
|
||||
else
|
||||
callback()
|
||||
|
||||
module.exports = (options) ->
|
||||
options ?= {}
|
||||
db = options.uri or "http://localhost:5984/sharejs"
|
||||
|
||||
uriForDoc = (docName) -> "#{db}/doc:#{encodeURIComponent docName}"
|
||||
uriForOps = (docName, start, end, include_docs) ->
|
||||
startkey = encodeURIComponent(JSON.stringify [docName, start])
|
||||
# {} is sorted after all numbers - so this will get all ops in the case that end is null.
|
||||
endkey = encodeURIComponent(JSON.stringify [docName, end ? {}])
|
||||
|
||||
# Another way to write this method would be to use node's builtin uri-encoder.
|
||||
extra = if include_docs then '&include_docs=true' else ''
|
||||
"#{db}/_design/sharejs/_view/operations?startkey=#{startkey}&endkey=#{endkey}&inclusive_end=false#{extra}"
|
||||
|
||||
# Helper method to get the revision of a document snapshot.
|
||||
getRev = (docName, dbMeta, callback) ->
|
||||
if dbMeta?.rev
|
||||
callback null, dbMeta.rev
|
||||
else
|
||||
# JSON defaults to true, and that makes request think I'm trying to sneak a request
|
||||
# body in. Ugh.
|
||||
request.head {uri:uriForDoc(docName), json:false}, (err, resp, body) ->
|
||||
parseError err, resp, body, (error) ->
|
||||
if error
|
||||
callback error
|
||||
else
|
||||
# The etag is the rev in quotes.
|
||||
callback null, JSON.parse(resp.headers.etag)
|
||||
|
||||
writeSnapshotInternal = (docName, data, rev, callback) ->
|
||||
body = data
|
||||
body.fieldType = 'Document'
|
||||
body._rev = rev if rev?
|
||||
|
||||
request.put uri:(uriForDoc docName), body:body, (err, resp, body) ->
|
||||
parseError err, resp, body, (error) ->
|
||||
if error
|
||||
#console.log 'create error'
|
||||
# This will send write conflicts as 'document already exists'. Thats kinda wierd, but
|
||||
# it shouldn't happen anyway
|
||||
callback? error
|
||||
else
|
||||
# We pass the document revision back to the db cache so it can give it back to couchdb on subsequent requests.
|
||||
callback? null, {rev: body.rev}
|
||||
|
||||
# getOps returns all ops between start and end. end can be null.
|
||||
getOps: (docName, start, end, callback) ->
|
||||
return callback null, [] if start == end
|
||||
|
||||
# Its a bit gross having this end parameter here....
|
||||
endkey = if end? then [docName, end - 1]
|
||||
|
||||
request uriForOps(docName, start, end), (err, resp, body) ->
|
||||
# Rows look like this:
|
||||
# {"id":"<uuid>","key":["doc name",0],"value":{"op":[{"p":0,"i":"hi"}],"meta":{}}}
|
||||
data = ({op: row.value.op, meta: row.value.meta} for row in body.rows)
|
||||
callback null, data
|
||||
|
||||
# callback(error, db metadata)
|
||||
create: (docName, data, callback) ->
|
||||
writeSnapshotInternal docName, data, null, callback
|
||||
|
||||
delete: del = (docName, dbMeta, callback) ->
|
||||
getRev docName, dbMeta, (error, rev) ->
|
||||
return callback? error if error
|
||||
|
||||
docs = [{_id:"doc:#{docName}", _rev:rev, _deleted:true}]
|
||||
# Its annoying, but we need to get the revision from the document. I don't think there's a simple way to do this.
|
||||
# This request will get all the ops twice.
|
||||
request uriForOps(docName, 0, null, true), (err, resp, body) ->
|
||||
# Rows look like this:
|
||||
# {"id":"<uuid>","key":["doc name",0],"value":{"op":[{"p":0,"i":"hi"}],"meta":{}},
|
||||
# "doc":{"_id":"<uuid>","_rev":"1-21a40c56ebd5d424ffe56950e77bc847","op":[{"p":0,"i":"hi"}],"v":0,"meta":{},"docName":"doc6"}}
|
||||
for row in body.rows
|
||||
row.doc._deleted = true
|
||||
docs.push row.doc
|
||||
|
||||
request.post url: "#{db}/_bulk_docs", body: {docs}, (err, resp, body) ->
|
||||
if body[0].error is 'conflict'
|
||||
# Somebody has edited the document since we did a GET on the revision information. Recurse.
|
||||
# By passing null to dbMeta I'm forcing the revision information to be reacquired.
|
||||
del docName, null, callback
|
||||
else
|
||||
parseError err, resp, body, (error) -> callback? error
|
||||
|
||||
writeOp: (docName, opData, callback) ->
|
||||
body =
|
||||
docName: docName
|
||||
op: opData.op
|
||||
v: opData.v
|
||||
meta: opData.meta
|
||||
|
||||
request.post url:db, body:body, (err, resp, body) ->
|
||||
parseError err, resp, body, callback
|
||||
|
||||
writeSnapshot: (docName, docData, dbMeta, callback) ->
|
||||
getRev docName, dbMeta, (error, rev) ->
|
||||
return callback? error if error
|
||||
|
||||
writeSnapshotInternal docName, docData, rev, callback
|
||||
|
||||
getSnapshot: (docName, callback) ->
|
||||
request uriForDoc(docName), (err, resp, body) ->
|
||||
parseError err, resp, body, (error) ->
|
||||
if error
|
||||
callback error
|
||||
else
|
||||
callback null,
|
||||
snapshot: body.snapshot
|
||||
type: body.type
|
||||
meta: body.meta
|
||||
v: body.v
|
||||
, {rev: body._rev} # dbMeta
|
||||
|
||||
close: ->
|
||||
@@ -1,28 +0,0 @@
|
||||
# This is a simple switch for the different database implementations.
|
||||
#
|
||||
# The interface is the same as the regular database implementations, except
|
||||
# the options object can have another type:<TYPE> parameter which specifies
|
||||
# which type of database to use.
|
||||
#
|
||||
# Example usage:
|
||||
# require('server/db').create {type:'redis'}
|
||||
|
||||
defaultType = 'redis'
|
||||
|
||||
module.exports = (options) ->
|
||||
options ?= {}
|
||||
type = options.type ? defaultType
|
||||
|
||||
console.warn "Database type: 'memory' detected. This has been deprecated and will
|
||||
be removed in a future version. Use 'none' instead, or just remove the db:{} block
|
||||
from your options." if type is 'memory'
|
||||
|
||||
if type in ['none', 'memory']
|
||||
null
|
||||
else
|
||||
Db = switch type
|
||||
when 'redis' then require './redis'
|
||||
when 'couchdb' then require './couchdb'
|
||||
when 'pg' then require './pg'
|
||||
else throw new Error "Invalid or unsupported database type: '#{type}'"
|
||||
new Db options
|
||||
@@ -1,198 +0,0 @@
|
||||
# This is an implementation of the OT data backend for PostgreSQL. It requires
|
||||
# that you have two tables defined in your schema: one for the snapshots
|
||||
# and one for the operations. You must also install the 'pg' package.
|
||||
#
|
||||
#
|
||||
# Example usage:
|
||||
#
|
||||
# var connect = require('connect');
|
||||
# var share = require('share').server;
|
||||
#
|
||||
# var server = connect(connect.logger());
|
||||
#
|
||||
# var options = {
|
||||
# db: {
|
||||
# type: 'pg',
|
||||
# uri: 'tcp://josh:@localhost/sharejs',
|
||||
# create_tables_automatically: true
|
||||
# }
|
||||
# };
|
||||
#
|
||||
# share.attach(server, options);
|
||||
# server.listen(9000);
|
||||
#
|
||||
# You can run bin/setup_pg to create the SQL tables initially.
|
||||
|
||||
pg = require('pg').native
|
||||
|
||||
defaultOptions =
|
||||
schema: 'sharejs'
|
||||
create_tables_automatically: true
|
||||
operations_table: 'ops'
|
||||
snapshot_table: 'snapshots'
|
||||
|
||||
module.exports = PgDb = (options) ->
|
||||
return new Db if !(this instanceof PgDb)
|
||||
|
||||
options ?= {}
|
||||
options[k] ?= v for k, v of defaultOptions
|
||||
|
||||
client = new pg.Client options.uri
|
||||
client.connect()
|
||||
|
||||
snapshot_table = options.schema and "#{options.schema}.#{options.snapshot_table}" or options.snapshot_table
|
||||
operations_table = options.schema and "#{options.schema}.#{options.operations_table}" or options.operations_table
|
||||
|
||||
@close = ->
|
||||
client.end()
|
||||
|
||||
@initialize = (callback) ->
|
||||
console.warn 'Creating postgresql database tables'
|
||||
|
||||
sql = """
|
||||
CREATE SCHEMA #{options.schema};
|
||||
|
||||
CREATE TABLE #{snapshot_table} (
|
||||
doc text NOT NULL,
|
||||
v int4 NOT NULL,
|
||||
type text NOT NULL,
|
||||
snapshot text NOT NULL,
|
||||
meta text NOT NULL,
|
||||
created_at timestamp(6) NOT NULL,
|
||||
CONSTRAINT snapshots_pkey PRIMARY KEY (doc, v)
|
||||
);
|
||||
|
||||
CREATE TABLE #{operations_table} (
|
||||
doc text NOT NULL,
|
||||
v int4 NOT NULL,
|
||||
op text NOT NULL,
|
||||
meta text NOT NULL,
|
||||
CONSTRAINT operations_pkey PRIMARY KEY (doc, v)
|
||||
);
|
||||
"""
|
||||
client.query sql, (error, result) ->
|
||||
callback? error?.message
|
||||
|
||||
# This will perminantly delete all data in the database.
|
||||
@dropTables = (callback) ->
|
||||
sql = "DROP SCHEMA #{options.schema} CASCADE;"
|
||||
client.query sql, (error, result) ->
|
||||
callback? error.message
|
||||
|
||||
@create = (docName, docData, callback) ->
|
||||
sql = """
|
||||
INSERT INTO #{snapshot_table} ("doc", "v", "snapshot", "meta", "type", "created_at")
|
||||
VALUES ($1, $2, $3, $4, $5, now())
|
||||
"""
|
||||
values = [docName, docData.v, JSON.stringify(docData.snapshot), JSON.stringify(docData.meta), docData.type]
|
||||
client.query sql, values, (error, result) ->
|
||||
if !error?
|
||||
callback?()
|
||||
else if /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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user