diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..d8d6229 --- /dev/null +++ b/.babelrc @@ -0,0 +1,18 @@ +{ + "presets": [ + ["env", { + "targets": { + "browsers": [ + "last 2 versions", + "> 4%", + "ie 11", + "safari 8", + "android 4.4" + ] + } + }], + ["minify", { + "mergeVars": false + }] + ] +} diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..76fd6af --- /dev/null +++ b/.eslintignore @@ -0,0 +1,8 @@ +# possibly minified output +out/**/* + +# libraries +js/lib/* + +# php generated file +js/lang.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..25f5194 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,191 @@ +{ + "parserOptions": { + "ecmaVersion": 8, + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + "jsx": true + }, + "sourceType": "module" + }, + + "env": { + "es6": true, + "node": true + }, + + "plugins": [ + "import", + "node", + "promise", + "standard" + ], + + "globals": { + "document": false, + "navigator": false, + "window": false + }, + + "rules": { + "accessor-pairs": "error", + "arrow-spacing": ["error", { "before": true, "after": true }], + "block-spacing": ["error", "always"], + "brace-style": ["warn", "1tbs", { "allowSingleLine": true }], + "camelcase": ["off", { "properties": "never" }], + "comma-dangle": ["error", { + "arrays": "never", + "objects": "never", + "imports": "never", + "exports": "never", + "functions": "never" + }], + "comma-spacing": ["error", { "before": false, "after": true }], + "comma-style": ["error", "last"], + "constructor-super": "error", + "curly": ["error", "multi-line"], + "dot-location": ["error", "property"], + "eol-last": "error", + "eqeqeq": ["error", "smart"], + "func-call-spacing": ["error", "never"], + "generator-star-spacing": ["error", { "before": true, "after": true }], + "handle-callback-err": ["error", "^(err|error)$" ], + "indent": ["error", 2, { "SwitchCase": 1 }], + "key-spacing": ["error", { "beforeColon": false, "afterColon": true }], + "keyword-spacing": ["error", { "before": true, "after": true }], + "new-cap": ["error", { "newIsCap": true, "capIsNew": false }], + "new-parens": "error", + "no-array-constructor": "error", + "no-caller": "error", + "no-class-assign": "error", + "no-compare-neg-zero": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-condition": ["error", { "checkLoops": false }], + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-args": "error", + "no-dupe-class-members": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-empty-character-class": "error", + "no-empty-pattern": "error", + "no-eval": "error", + "no-ex-assign": "error", + "no-extend-native": "warn", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-parens": ["error", "functions"], + "no-fallthrough": "error", + "no-floating-decimal": "error", + "no-func-assign": "error", + "no-global-assign": "error", + "no-implied-eval": "error", + "no-inner-declarations": ["error", "functions"], + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-iterator": "error", + "no-label-var": "error", + "no-labels": ["error", { "allowLoop": false, "allowSwitch": false }], + "no-lone-blocks": "warn", + "no-mixed-operators": ["error", { + "groups": [ + ["==", "!=", "===", "!==", ">", ">=", "<", "<="], + ["&&", "||"], + ["in", "instanceof"] + ], + "allowSamePrecedence": true + }], + "no-mixed-spaces-and-tabs": "error", + "no-multi-spaces": "warn", + "no-multi-str": "error", + "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0 }], + "no-negated-in-lhs": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-object": "error", + "no-new-require": "error", + "no-new-symbol": "error", + "no-new-wrappers": "error", + "no-obj-calls": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-path-concat": "error", + "no-proto": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-return-assign": ["error", "except-parens"], + "no-return-await": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-tabs": "error", + "no-template-curly-in-string": "error", + "no-this-before-super": "error", + "no-throw-literal": "error", + "no-trailing-spaces": "off", + "no-undef": "off", + "no-undef-init": "error", + "no-unexpected-multiline": "error", + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": ["error", { "defaultAssignment": false }], + "no-unreachable": "error", + "no-unsafe-finally": "error", + "no-unsafe-negation": "error", + "no-unused-expressions": ["warn", { "allowShortCircuit": true, "allowTernary": true, "allowTaggedTemplates": true }], + "no-unused-vars": ["off", { "vars": "local", "args": "none", "ignoreRestSiblings": true }], + "no-use-before-define": ["error", { "functions": false, "classes": false, "variables": false }], + "no-useless-call": "error", + "no-useless-computed-key": "error", + "no-useless-constructor": "error", + "no-useless-escape": "error", + "no-useless-rename": "error", + "no-useless-return": "error", + "no-whitespace-before-property": "error", + "no-with": "error", + "object-property-newline": ["error", { "allowMultiplePropertiesPerLine": true }], + "one-var": ["error", { "initialized": "never" }], + "operator-linebreak": ["error", "after", { "overrides": { "?": "before", ":": "before" } }], + "padded-blocks": ["error", { "blocks": "never", "switches": "never", "classes": "never" }], + "prefer-promise-reject-errors": "error", + "quotes": ["error", "single", { "avoidEscape": true, "allowTemplateLiterals": true }], + "rest-spread-spacing": ["error", "never"], + "semi": ["error", "never"], + "semi-spacing": ["error", { "before": false, "after": true }], + "space-before-blocks": ["error", "always"], + "space-before-function-paren": ["error", "always"], + "space-in-parens": ["error", "never"], + "space-infix-ops": "error", + "space-unary-ops": ["error", { "words": true, "nonwords": false }], + "spaced-comment": ["error", "always", { + "line": { "markers": ["*package", "!", "/", ","] }, + "block": { "balanced": true, "markers": ["*package", "!", ",", ":", "::", "flow-include"], "exceptions": ["*"] } + }], + "symbol-description": "error", + "template-curly-spacing": ["error", "never"], + "template-tag-spacing": ["error", "never"], + "unicode-bom": ["error", "never"], + "use-isnan": "error", + "valid-typeof": ["error", { "requireStringLiterals": true }], + "wrap-iife": ["error", "any", { "functionPrototypeMethods": true }], + "yield-star-spacing": ["error", "both"], + "yoda": ["error", "never"], + + "import/export": "error", + "import/first": "error", + "import/no-duplicates": "error", + "import/no-webpack-loader-syntax": "error", + + "node/no-deprecated-api": "error", + "node/process-exit-as-throw": "error", + + "promise/param-names": "error", + + "standard/array-bracket-even-spacing": ["error", "either"], + "standard/computed-property-even-spacing": ["error", "even"], + "standard/no-callback-literal": "error", + "standard/object-curly-even-spacing": ["error", "either"] + } +} diff --git a/.gitignore b/.gitignore index 744d0a9..0de9aaf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,4 @@ _env.php -js/* -css/* out/* !.gitkeep node_modules/ diff --git a/README.md b/README.md index 83861f9..a0e5ebf 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ PHP is used to build the HTML files and apply substitutions. ## Development -JavaScript source files can be found in the `jssrc/` folder, SASS +JavaScript source files can be found in the `js/` folder, SASS files in the `sass/` folder. Fontello (icon font) is maintained in the `fontello/` folder. To update Fontello, replace @@ -20,7 +20,7 @@ the `fontello.zip` file and run `unpack.sh` in the same folder. This will extrac from the zip file and put them into the `sass/` folder to be included with the other styles. To test you changes (after building JS and CSS), run a PHP local server in the project -directory using the `server.sh` script or by a command like `php -S 0.0.0.0:2000`. +directory using the `server.sh` script or by a command like `php -S 0.0.0.0:2000 _dev_router.php`. Template substitutions (that are normally done by the ESPTerm's webserver) applied to the files fior testing can be defined in `_debug_replacements.php`. diff --git a/_build_assets.sh b/_build_assets.sh new file mode 100755 index 0000000..2056499 --- /dev/null +++ b/_build_assets.sh @@ -0,0 +1,16 @@ +#!/bin/bash +source "_build_common.sh" + +echo 'Copying resources...' + +cp -r img out/img +cp favicon.ico out/ + +if [[ $ESP_PROD ]]; then + echo 'Cleaning junk files...' + find out/ -name "*.orig" -delete + find out/ -name "*.xcf" -delete + find out/ -name "*~" -delete + find out/ -name "*.bak" -delete + find out/ -name "*.map" -delete +fi diff --git a/_build_common.sh b/_build_common.sh new file mode 100755 index 0000000..833611b --- /dev/null +++ b/_build_common.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +export FRONT_END_HASH=$(git rev-parse --short HEAD) diff --git a/_build_css.sh b/_build_css.sh new file mode 100755 index 0000000..8679569 --- /dev/null +++ b/_build_css.sh @@ -0,0 +1,13 @@ +#!/bin/bash +source "_build_common.sh" + +echo 'Building CSS...' + +if [[ $ESP_PROD ]]; then + stylearg=compressed +else + stylearg=expanded +fi + +mkdir -p out/css +npm run sass -- --output-style ${stylearg} sass/app.scss "out/css/app.$FRONT_END_HASH.css" diff --git a/_build_html.sh b/_build_html.sh new file mode 100755 index 0000000..a4f869d --- /dev/null +++ b/_build_html.sh @@ -0,0 +1,6 @@ +#!/bin/bash +source "_build_common.sh" + +echo 'Building HTML...' + +php ./compile_html.php diff --git a/_build_js.sh b/_build_js.sh new file mode 100755 index 0000000..5ee8ee5 --- /dev/null +++ b/_build_js.sh @@ -0,0 +1,35 @@ +#!/bin/bash +source "_build_common.sh" + +mkdir -p out/js +echo 'Generating lang.js...' +php ./dump_js_lang.php + +if [[ $ESP_DEMO ]]; then + demofile=js/demo.js +else + demofile= +fi + +echo 'Processing JS...' +if [[ $ESP_PROD ]]; then + smarg= +else + smarg=--source-maps +fi + +npm run babel -- -o "out/js/app.$FRONT_END_HASH.js" ${smarg} \ + js/lib/chibi.js \ + js/lib/keymaster.js \ + js/lib/polyfills.js \ + js/utils.js \ + js/modal.js \ + js/notif.js \ + js/appcommon.js \ + $demofile \ + js/lang.js \ + js/wifi.js \ + js/term_* \ + js/debug_screen.js \ + js/soft_keyboard.js \ + js/term.js diff --git a/_debug_replacements.php b/_debug_replacements.php index 1aca8dc..3ae64da 100644 --- a/_debug_replacements.php +++ b/_debug_replacements.php @@ -7,13 +7,17 @@ */ $vers = '???'; -$f = file_get_contents(__DIR__ . '/../user/version.h'); -preg_match_all('/#define FW_V_.*? (\d+)/', $f, $vm); +$versfn = __DIR__ . '/../user/version.h'; +$fwHash = '00000000'; +if (file_exists($versfn)) { + $f = file_get_contents($versfn); + preg_match_all('/#define FW_V_.*? (\d+)/', $f, $vm); #define FW_V_MAJOR 1 #define FW_V_MINOR 0 #define FW_V_PATCH 0 - -$vers = $vm[1][0].'.'.$vm[1][1].'.'.$vm[1][2]; + $vers = $vm[1][0] . '.' . $vm[1][1] . '.' . $vm[1][2]; + $fwHash = trim(shell_exec('cd .. && git rev-parse --short HEAD')); +} return [ 'term_title' => ESP_DEMO ? 'ESPTerm Web UI Demo' : 'ESPTerm local debug', @@ -29,6 +33,7 @@ return [ 'bm4' => '', 'bm5' => '05', 'labels_seq' => ESP_DEMO ? 'TESPTerm Web UI DemoOKCancelHelp' : 'TESPTerm local debugOKCancelHelp', + 'want_all_fn' => '0', 'parser_tout_ms' => 10, 'display_tout_ms' => 15, @@ -55,7 +60,10 @@ return [ 'time' => date('G:i'), 'vers_httpd' => '0.4', 'vers_sdk' => '010502', - 'githubrepo' => 'https://github.com/MightyPork/esp-vt100-firmware', + 'githubrepo' => 'https://github.com/espterm/espterm-firmware', + 'githubrepo_front' => 'https://github.com/espterm/espterm-front-end', + 'hash_backend' => $fwHash, + 'hash_frontend' => GIT_HASH, // TODO actual versions? 'ap_dhcp_time' => '120', 'ap_dhcp_start' => '192.168.4.100', @@ -75,7 +83,7 @@ return [ 'term_height' => '25', 'default_bg' => '0', 'default_fg' => '7', - 'show_buttons' => '1', + 'show_buttons' => '0', 'show_config_links' => '1', 'uart_baud' => 115200, diff --git a/_dev_router.php b/_dev_router.php new file mode 100644 index 0000000..641c482 --- /dev/null +++ b/_dev_router.php @@ -0,0 +1,13 @@ +_env.php.example to _env.php and check the settings inside!"); } +define('GIT_HASH', trim(shell_exec('git rev-parse --short HEAD'))); + require_once __DIR__ . '/_env.php'; $prod = defined('STDIN'); @@ -25,7 +27,6 @@ define('JS_WEB_ROOT', $root); define('ESP_DEMO', (bool)getenv('ESP_DEMO')); if (ESP_DEMO) { - define('DEMO_SCREEN', '"S\u0019\u0001Q\u0001\u0017\u0001K\u0001\u0015\u0004\u0003\b\u0001 \u0002P\u0001┌ESPTerm─Demo─\u0002\u0002\u0001\u0003\u0002\u000131\u0003\u0003\u000132\u0003\u0004\u00013\u0002\u0002\u0001\u0003\u0005\u000134\u0003\u0006\u000135\u0003\u0007\u000136\u0003\b\u000137\u0003\t\u000190\u0003\n\u000191\u0003\u000b\u000192\u0003\f\u000193\u0003\r\u000194\u0003\u000e\u000195\u0003\u000f\u000196\u0003\u0010\u000197\u0003\b\u0001─\u0002\r\u0001┐ \u0002\u0015\u0001│ \u00029\u0001│ \u0002\u0004\u0001│\u0002\t\u0001 \u0002\b\u0001│\u0004\u0002\u0001Bold \u0004\u0003\u0001F\u0004\u0003\u0001a\u0004\u0003\u0001i\u0004\u0003\u0001n\u0004\u0003\u0001t\u0004\u0003\u0001 \u0004\u0005\u0001I\u0004\u0005\u0001t\u0004\u0005\u0001a\u0004\u0005\u0001l\u0004\u0005\u0001i\u0004\u0005\u0001c\u0004\u0005\u0001 \u0004\t\u0001U\u0004\t\u0001n\u0004\t\u0001d\u0004\t\u0001e\u0004\t\u0001r\u0004\t\u0001l\u0004\t\u0001i\u0004\t\u0001n\u0004\t\u0001e\u0004\u0001\u0001 \u0004\u0011\u0001B\u0004\u0011\u0001l\u0004\u0011\u0001i\u0004\u0011\u0001n\u0004\u0011\u0001k\u0004\u0011\u0001 \u0001q\u0001\u0001Inverse\u0003\b\u0001 \u0004A\u0001S\u0004A\u0001t\u0004A\u0001r\u0004A\u0001i\u0004A\u0001k\u0004A\u0001e\u0004\u0001\u0001 \u0004!\u0001F\u0004!\u0001r\u0004!\u0001a\u0004!\u0001k\u0004!\u0001t\u0004!\u0001u\u0004!\u0001r\u0004\u0001\u0001 │ \u0002\u0002\u0001─\u0002\u0002\u0001\u0003\n\u0002 \u0002\t\u0001\u0003\b\u0001─\u0002\u0002\u0001 \u0002\u0006\u0001│ \u00029\u0001│ \u0002\u0002\u0001─\u0002\u0002\u0001\u0003\n\u0002 \u0003\u0002\u0002ESP826\u0002\u0002\u0001\u0003\n\u0002 \u0003\b\u0001─\u0002\u0002\u0001 \u0002\u0006\u0001└─\u00029\u0001┤ \u0002\u0002\u0001─\u0002\u0002\u0001\u0003\n\u0002 \u0002\t\u0001\u0003\b\u0001─\u0002\u0002\u0001 \u0002@\u0001│ \u0002\u0002\u0001─\u0002\u0002\u0001\u0003\n\u0002 \u0003\u0002\u0002(@)#\u0002\u0004\u0001\u0003\n\u0002 \u0003\b\u0001─\u0002\u0002\u0001 \u0002\u0007\u0001\u0003O\u0001 This is a static demo of the ESPTerm Web Interface \u0002\u0004\u0001\u0003\b\u0001 \u0002\u0002\u0001│ \u0002\u0002\u0001─\u0002\u0002\u0001\u0003\n\u0002 \u0002\t\u0001\u0003\b\u0001─\u0002\u0002\u0001 \u0002\u0007\u0001\u0003O\u0001 \u00027\u0001\u0003\b\u0001 \u0002\u0002\u0001│ \u0002\u0004\u0001│\u0002\t\u0001 \u0002\t\u0001\u0003O\u0001 Try the links beneath this scre\u0002\u0002\u0001n to browse the menu. \u0003\b\u0001 \u0002\u0002\u0001♦ \u0002\u0016\u0001\u0003O\u0001 \u00027\u0001\u0003\b\u0001 \u0002\u0019\u0001\u0003O\u0001 <°)\u0002\u0003\u0001>< ESPTerm ful\u0002\u0002\u0001y sup\u0002\u0002\u0001orts UTF-8 お は よ ー \u0002\u0002\u0001><(\u0002\u0003\u0001°> \u0003\b\u0001 \u0002\u0019\u0001\u0003O\u0001 \u00027\u0001\u0003\b\u0001 \u0002i\u0001\u0003\u000b\u0001Other interesting features:\u0003\b\u0001 \u0002\u0018\u0001↓ \u0002n\u0001\u0003\u0003\u0001- Almost ful\u0002\u0002\u0001 VT10\u0002\u0002\u0001 emulation \u0003\b\u0001 \u0003\u0006\u0001()\u0003\b\u0001 \u0003\u0006\u0001()\u0003\b\u0001 \u0002\b\u0001Funguje tu čeština! \u0002\u0011\u0001\u0003\u0005\u0001- Xterm-like mouse tracking\u0003\b\u0001 \u0002\u0003\u0001=\u0002\u0002\u0001\u0003\t\u0002°.°\u0003\b\u0001=\u0002\u0002\u0001 \u0003\u0006\u0001<-\u0002\u0003\u0001, \u0003\b\u0001 \u0002$\u0001\u0003\u0004\u0001- File upload utility\u0003\b\u0001 \u0002\n\u0001\'\u0002\u0002\u0001 \'\u0002\u0002\u0001 \u0002\u0002\u0001\u0003\u0006\u0001 \u0002\u0004\u0001mouse\u0003\b\u0001 \u0002!\u0001\u0003\u0002\u0001- User-friendly config interface\u0003\b\u0001 \u00020\u0001\u0003\u000e\u0001-\u0003\u0002\u0001 \u0003\u000e\u0001Advanced WiFi & network set\u0002\u0002\u0001ings\u0003\b\u0001 \u0002\u0011\u0001\u0003\f\u0001Try ESPTerm today!\u0003\b\u0001 \u0002\u000b\u0001- Built-in help page \u0002\u001a\u0001\u0003\u0007\u0001-\u0002\u0002\u0001>\u0003\b\u0001 \u0002\u0002\u0001\u0003\f\u0001Pre-built binaries\u0003\b\u0001 \u0003\f\u0001are\u0003\b\u0001 \u0002\"\u0001\u0003\u0007\u0001link on the About page \u0002\u0002\u0001\u0003\f\u0001available on GitHub! \u0003\b\u0001 \u0002U\u0001"'); define('DEMO_APS', << [ @@ -123,7 +124,7 @@ if (!function_exists('load_esp_charsets')) { ], ]; } - + $re_table = '/\/\/ %%BEGIN:(.)%%\s*(.*?)\s*\/\/ %%END:\1%%/s'; preg_match_all($re_table, file_get_contents($chsf), $m_tbl); diff --git a/build.sh b/build.sh index 71131c6..75ac53f 100755 --- a/build.sh +++ b/build.sh @@ -1,25 +1,14 @@ #!/bin/bash -echo "Packing JS..." +cd $(dirname $0) -cat jssrc/chibi.js \ - jssrc/keymaster.js \ - jssrc/utils.js \ - jssrc/modal.js \ - jssrc/notif.js \ - jssrc/appcommon.js \ - jssrc/lang.js \ - jssrc/wifi.js \ - jssrc/term_* \ - jssrc/term.js > js/app-full.js +source "_build_common.sh" -yuicompressor js/app-full.js > js/app.js +rm -fr out/* -echo "Building CSS..." +./_build_css.sh +./_build_js.sh +./_build_html.sh +./_build_assets.sh -sass --style=compressed sass/app.scss css/app.css - -echo "Building HTML..." -php ./build_html.php - -echo "ESPTerm front-end ready" +echo 'ESPTerm front-end ready' diff --git a/build_html.php b/compile_html.php similarity index 100% rename from build_html.php rename to compile_html.php diff --git a/css/.gitkeep b/css/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/dump_js_lang.php b/dump_js_lang.php index ab2f976..2639eea 100755 --- a/dump_js_lang.php +++ b/dump_js_lang.php @@ -8,7 +8,6 @@ $selected = [ 'wifi.connected_ip_is', 'wifi.not_conn', 'wifi.enter_passwd', - 'wifi.passwd_saved', ]; $out = []; @@ -16,8 +15,8 @@ foreach ($selected as $key) { $out[$key] = $_messages[$key]; } -file_put_contents(__DIR__. '/jssrc/lang.js', +file_put_contents(__DIR__. '/js/lang.js', "// Generated from PHP locale file\n" . - 'var _tr = ' . json_encode($out, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) . ";\n\n" . - "function tr(key) { return _tr[key] || '?'+key+'?'; }\n" + 'let _tr = ' . json_encode($out, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) . ";\n\n" . + "function tr (key) { return _tr[key] || '?' + key + '?' }\n" ); diff --git a/fontello/fontello.zip b/fontello/fontello.zip index f0ff528..65aa70d 100644 Binary files a/fontello/fontello.zip and b/fontello/fontello.zip differ diff --git a/js/.gitkeep b/js/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/js/appcommon.js b/js/appcommon.js new file mode 100644 index 0000000..3b03ba7 --- /dev/null +++ b/js/appcommon.js @@ -0,0 +1,131 @@ +/** Global generic init */ +$.ready(function () { + // Checkbox UI (checkbox CSS and hidden input with int value) + $('.Row.checkbox').forEach(function (x) { + let inp = x.querySelector('input') + let box = x.querySelector('.box') + + $(box).toggleClass('checked', inp.value) + + let hdl = function () { + inp.value = 1 - inp.value + $(box).toggleClass('checked', inp.value) + } + + $(x).on('click', hdl).on('keypress', cr(hdl)) + }) + + // Expanding boxes on mobile + $('.Box.mobcol,.Box.fold').forEach(function (x) { + let h = x.querySelector('h2') + + let hdl = function () { + $(x).toggleClass('expanded') + } + $(h).on('click', hdl).on('keypress', cr(hdl)) + }) + + $('form').forEach(function (x) { + $(x).on('keypress', function (e) { + if ((e.keyCode === 10 || e.keyCode === 13) && e.ctrlKey) { + x.submit() + } + }) + }) + + // loader dots... + setInterval(function () { + $('.anim-dots').each(function (x) { + let $x = $(x) + let dots = $x.html() + '.' + if (dots.length === 5) dots = '.' + $x.html(dots) + }) + }, 1000) + + // flipping number boxes with the mouse wheel + $('input[type=number]').on('mousewheel', function (e) { + let $this = $(this) + let val = +$this.val() + if (isNaN(val)) val = 1 + + const step = +($this.attr('step') || 1) + const min = +$this.attr('min') + const max = +$this.attr('max') + if (e.wheelDelta > 0) { + val += step + } else { + val -= step + } + + if (undef(min)) val = Math.max(val, +min) + if (undef(max)) val = Math.min(val, +max) + $this.val(val) + + if ('createEvent' in document) { + let evt = document.createEvent('HTMLEvents') + evt.initEvent('change', false, true) + $this[0].dispatchEvent(evt) + } else { + $this[0].fireEvent('onchange') + } + + e.preventDefault() + }) + + // populate the form errors box from GET arg ?err=... + // (a way to pass errors back from server via redirect) + let errAt = location.search.indexOf('err=') + if (errAt !== -1 && qs('.Box.errors')) { + let errs = location.search.substr(errAt + 4).split(',') + let humanReadableErrors = [] + errs.forEach(function (er) { + let lbl = qs('label[for="' + er + '"]') + if (lbl) { + lbl.classList.add('error') + humanReadableErrors.push(lbl.childNodes[0].textContent.trim().replace(/: ?$/, '')) + } + // else { + // hres.push(er) + // } + }) + + qs('.Box.errors .list').innerHTML = humanReadableErrors.join(', ') + qs('.Box.errors').classList.remove('hidden') + } + + Modal.init() + Notify.init() + + // remove tabindixes from h2 if wide + if (window.innerWidth > 550) { + $('.Box h2').forEach(function (x) { + x.removeAttribute('tabindex') + }) + + // brand works as a link back to term in widescreen mode + let br = qs('#brand') + br && br.addEventListener('click', function () { + location.href = '/' // go to terminal + }) + } +}) + +// setup the ajax loader +$._loader = function (vis) { + $('#loader').toggleClass('show', vis) +} + +// reveal content on load +function showPage () { + $('#content').addClass('load') +} + +// Auto reveal pages other than the terminal (sets window.noAutoShow) +$.ready(function () { + if (window.noAutoShow !== true) { + setTimeout(function () { + showPage() + }, 1) + } +}) diff --git a/js/debug_screen.js b/js/debug_screen.js new file mode 100644 index 0000000..97e2444 --- /dev/null +++ b/js/debug_screen.js @@ -0,0 +1,106 @@ +window.attachDebugScreen = function (screen) { + const debugCanvas = mk('canvas') + const ctx = debugCanvas.getContext('2d') + + debugCanvas.style.position = 'absolute' + // hackity hack should probably set this in CSS + debugCanvas.style.top = '6px' + debugCanvas.style.left = '6px' + debugCanvas.style.pointerEvents = 'none' + + let addCanvas = function () { + if (!debugCanvas.parentNode) screen.canvas.parentNode.appendChild(debugCanvas) + } + let removeCanvas = function () { + if (debugCanvas.parentNode) debugCanvas.parentNode.removeChild(debugCanvas) + } + let updateCanvasSize = function () { + let { width, height, devicePixelRatio } = screen.window + let cellSize = screen.getCellSize() + debugCanvas.width = width * cellSize.width * devicePixelRatio + debugCanvas.height = height * cellSize.height * devicePixelRatio + debugCanvas.style.width = `${width * cellSize.width}px` + debugCanvas.style.height = `${height * cellSize.height}px` + } + + let startTime, endTime, lastReason + let cells = new Map() + + let startDrawing + + screen._debug = { + drawStart (reason) { + lastReason = reason + startTime = Date.now() + }, + drawEnd () { + endTime = Date.now() + console.log(`Draw: ${lastReason} (${(endTime - startTime)} ms) with fancy graphics: ${screen.window.graphics}`) + startDrawing() + }, + setCell (cell, flags) { + cells.set(cell, [flags, Date.now()]) + } + } + + let isDrawing = false + + let drawLoop = function () { + if (isDrawing) requestAnimationFrame(drawLoop) + + let { devicePixelRatio, width, height } = screen.window + let { width: cellWidth, height: cellHeight } = screen.getCellSize() + let screenLength = width * height + let now = Date.now() + + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) + ctx.clearRect(0, 0, width * cellWidth, height * cellHeight) + + let activeCells = 0 + for (let cell = 0; cell < screenLength; cell++) { + if (!cells.has(cell) || cells.get(cell)[0] === 0) continue + + let [flags, timestamp] = cells.get(cell) + let elapsedTime = (now - timestamp) / 1000 + + if (elapsedTime > 1) continue + + activeCells++ + ctx.globalAlpha = 0.5 * Math.max(0, 1 - elapsedTime) + + let x = cell % width + let y = Math.floor(cell / width) + + if (flags & 1) { + // redrawn + ctx.fillStyle = '#f0f' + } + if (flags & 2) { + // updated + ctx.fillStyle = '#0f0' + } + + ctx.fillRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) + + if (flags & 4) { + // wide cell + ctx.lineWidth = 2 + ctx.strokeStyle = '#f00' + ctx.strokeRect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) + } + } + + if (activeCells === 0) { + isDrawing = false + removeCanvas() + } + } + + startDrawing = function () { + if (isDrawing) return + addCanvas() + updateCanvasSize() + isDrawing = true + drawLoop() + } +} diff --git a/js/demo.js b/js/demo.js new file mode 100644 index 0000000..48946ec --- /dev/null +++ b/js/demo.js @@ -0,0 +1,758 @@ +class ANSIParser { + constructor (handler) { + this.reset() + this.handler = handler + this.joinChunks = true + } + reset () { + this.currentSequence = 0 + this.sequence = '' + } + parseSequence (sequence) { + if (sequence[0] === '[') { + let type = sequence[sequence.length - 1] + let content = sequence.substring(1, sequence.length - 1) + + let numbers = content ? content.split(';').map(i => +i.replace(/\D/g, '')) : [] + let numOr1 = numbers.length ? numbers[0] : 1 + if (type === 'H') { + this.handler('set-cursor', (numbers[0] | 0) - 1, (numbers[1] | 0) - 1) + } else if (type >= 'A' && type <= 'D') { + this.handler(`move-cursor-${type <= 'B' ? 'y' : 'x'}`, ((type === 'B' || type === 'C') ? 1 : -1) * numOr1) + } else if (type === 'E' || type === 'F') { + this.handler('move-cursor-line', (type === 'E' ? 1 : -1) * numOr1) + } else if (type === 'G') { + this.handler('set-cursor-x', numOr1 - 1) + } else if (type === 'J') { + let number = numbers.length ? numbers[0] : 2 + if (number === 2) this.handler('clear') + } else if (type === 'P') { + this.handler('delete', numOr1) + } else if (type === '@') { + this.handler('insert-blanks', numOr1) + } else if (type === 'q') this.handler('set-cursor-style', numOr1) + else if (type === 'm') { + if (!numbers.length || numbers[0] === 0) { + this.handler('reset-style') + return + } + let type = numbers[0] + if (type === 1) this.handler('add-attrs', 1) // bold + else if (type === 2) this.handler('add-attrs', 1 << 1) // faint + else if (type === 3) this.handler('add-attrs', 1 << 2) // italic + else if (type === 4) this.handler('add-attrs', 1 << 3) // underline + else if (type === 5 || type === 6) this.handler('add-attrs', 1 << 4) // blink + else if (type === 7) this.handler('add-attrs', -1) // invert + else if (type === 9) this.handler('add-attrs', 1 << 6) // strike + else if (type === 20) this.handler('add-attrs', 1 << 5) // fraktur + else if (type >= 30 && type <= 37) this.handler('set-color-fg', type % 10) + else if (type >= 40 && type <= 47) this.handler('set-color-bg', type % 10) + else if (type === 39) this.handler('set-color-fg', 7) + else if (type === 49) this.handler('set-color-bg', 0) + else if (type >= 90 && type <= 98) this.handler('set-color-fg', (type % 10) + 8) + else if (type >= 100 && type <= 108) this.handler('set-color-bg', (type % 10) + 8) + else if (type === 38 || type === 48) { + if (numbers[1] === 5) { + let color = (numbers[2] | 0) & 0xFF + if (type === 38) this.handler('set-color-fg', color) + if (type === 48) this.handler('set-color-bg', color) + } + } + } else if (type === 'h' || type === 'l') { + if (content === '?25') { + if (type === 'h') this.handler('show-cursor') + else if (type === 'l') this.handler('hide-cursor') + } + } + } + } + write (text) { + for (let character of text.toString()) { + let code = character.codePointAt(0) + if (code === 0x1b) this.currentSequence = 1 + else if (this.currentSequence === 1 && character === '[') { + this.currentSequence = 2 + this.sequence += '[' + } else if (this.currentSequence && character.match(/[\x40-\x7e]/)) { + this.parseSequence(this.sequence + character) + this.currentSequence = 0 + this.sequence = '' + } else if (this.currentSequence > 1) this.sequence += character + else if (this.currentSequence === 1) { + // something something nothing + this.currentSequence = 0 + this.handler('write', character) + } else if (code === 0x07) this.handler('bell') + else if (code === 0x08) this.handler('back') + else if (code === 0x0a) this.handler('new-line') + else if (code === 0x0d) this.handler('return') + else if (code === 0x15) this.handler('delete-line') + else if (code === 0x17) this.handler('delete-word') + else this.handler('write', character) + } + if (!this.joinChunks) this.reset() + } +} +const TERM_DEFAULT_STYLE = 7 +const TERM_MIN_DRAW_DELAY = 10 + +let getRainbowColor = t => { + let r = Math.floor(Math.sin(t) * 2.5 + 2.5) + let g = Math.floor(Math.sin(t + 2 / 3 * Math.PI) * 2.5 + 2.5) + let b = Math.floor(Math.sin(t + 4 / 3 * Math.PI) * 2.5 + 2.5) + return 16 + 36 * r + 6 * g + b +} + +class ScrollingTerminal { + constructor (screen) { + this.width = 80 + this.height = 25 + this.termScreen = screen + this.parser = new ANSIParser((...args) => this.handleParsed(...args)) + + this.reset() + + this._lastLoad = Date.now() + this.termScreen.load(this.serialize(), 0) + } + reset () { + this.style = TERM_DEFAULT_STYLE + this.cursor = { x: 0, y: 0, style: 1, visible: true } + this.trackMouse = false + this.theme = 0 + this.rainbow = false + this.parser.reset() + this.clear() + } + clear () { + this.screen = [] + for (let i = 0; i < this.width * this.height; i++) { + this.screen.push([' ', this.style]) + } + } + scroll () { + this.screen.splice(0, this.width) + for (let i = 0; i < this.width; i++) { + this.screen.push([' ', TERM_DEFAULT_STYLE]) + } + this.cursor.y-- + } + newLine () { + this.cursor.y++ + if (this.cursor.y >= this.height) this.scroll() + } + writeChar (character) { + this.screen[this.cursor.y * this.width + this.cursor.x] = [character, this.style] + this.cursor.x++ + if (this.cursor.x >= this.width) { + this.cursor.x = 0 + this.newLine() + } + } + moveBack (n = 1) { + for (let i = 0; i < n; i++) { + this.cursor.x-- + if (this.cursor.x < 0) { + if (this.cursor.y > 0) this.cursor.x = this.width - 1 + else this.cursor.x = 0 + this.cursor.y = Math.max(0, this.cursor.y - 1) + } + } + } + moveForward (n = 1) { + for (let i = 0; i < n; i++) { + this.cursor.x++ + if (this.cursor.x >= this.width) { + this.cursor.x = 0 + this.cursor.y++ + if (this.cursor.y >= this.height) this.scroll() + } + } + } + deleteChar () { + this.moveBack() + this.screen.splice((this.cursor.y + 1) * this.width, 0, [' ', TERM_DEFAULT_STYLE]) + this.screen.splice(this.cursor.y * this.width + this.cursor.x, 1) + } + deleteForward (n) { + n = Math.min(this.width, n) + for (let i = 0; i < n; i++) this.screen.splice((this.cursor.y + 1) * this.width, 0, [' ', TERM_DEFAULT_STYLE]) + this.screen.splice(this.cursor.y * this.width + this.cursor.x, n) + } + clampCursor () { + if (this.cursor.x < 0) this.cursor.x = 0 + if (this.cursor.y < 0) this.cursor.y = 0 + if (this.cursor.x > this.width - 1) this.cursor.x = this.width - 1 + if (this.cursor.y > this.height - 1) this.cursor.y = this.height - 1 + } + handleParsed (action, ...args) { + if (action === 'write') { + this.writeChar(args[0]) + } else if (action === 'delete') { + this.deleteForward(args[0]) + } else if (action === 'insert-blanks') { + this.insertBlanks(args[0]) + } else if (action === 'clear') { + this.clear() + } else if (action === 'bell') { + this.terminal.load('B') + } else if (action === 'back') { + this.moveBack() + } else if (action === 'new-line') { + this.newLine() + } else if (action === 'return') { + this.cursor.x = 0 + } else if (action === 'set-cursor') { + this.cursor.x = args[0] + this.cursor.y = args[1] + this.clampCursor() + } else if (action === 'move-cursor-y') { + this.cursor.y += args[0] + this.clampCursor() + } else if (action === 'move-cursor-x') { + this.cursor.x += args[0] + this.clampCursor() + } else if (action === 'move-cursor-line') { + this.cursor.x = 0 + this.cursor.y += args[0] + this.clampCursor() + } else if (action === 'set-cursor-x') { + this.cursor.x = args[0] + } else if (action === 'set-cursor-style') { + this.cursor.style = Math.max(0, Math.min(6, args[0])) + } else if (action === 'reset-style') { + this.style = TERM_DEFAULT_STYLE + } else if (action === 'add-attrs') { + if (args[0] === -1) { + this.style = (this.style & 0xFF0000) | ((this.style >> 8) & 0xFF) | ((this.style & 0xFF) << 8) + } else { + this.style |= (args[0] << 16) + } + } else if (action === 'set-color-fg') { + this.style = (this.style & 0xFFFF00) | args[0] + } else if (action === 'set-color-bg') { + this.style = (this.style & 0xFF00FF) | (args[0] << 8) + } else if (action === 'hide-cursor') { + this.cursor.visible = false + } else if (action === 'show-cursor') { + this.cursor.visible = true + } + } + write (text) { + this.parser.write(text) + this.scheduleLoad() + } + serialize () { + let serialized = 'S' + serialized += encode2B(this.height) + encode2B(this.width) + serialized += encode2B(this.cursor.y) + encode2B(this.cursor.x) + + let attributes = +this.cursor.visible + attributes |= (3 << 5) * +this.trackMouse // track mouse controls both + attributes |= 3 << 7 // buttons/links always visible + attributes |= (this.cursor.style << 9) + serialized += encode3B(attributes) + + let lastStyle = null + let index = 0 + for (let cell of this.screen) { + let style = cell[1] + if (this.rainbow) { + let x = index % this.width + let y = Math.floor(index / this.width) + style = (style & 0xFF0000) | getRainbowColor((x + y) / 10 + Date.now() / 1000) + index++ + } + if (style !== lastStyle) { + let foreground = style & 0xFF + let background = (style >> 8) & 0xFF + let attributes = (style >> 16) & 0xFF + let setForeground = foreground !== (lastStyle & 0xFF) + let setBackground = background !== ((lastStyle >> 8) & 0xFF) + let setAttributes = attributes !== ((lastStyle >> 16) & 0xFF) + + if (setForeground && setBackground) serialized += '\x03' + encode3B(style & 0xFFFF) + else if (setForeground) serialized += '\x05' + encode2B(foreground) + else if (setBackground) serialized += '\x06' + encode2B(background) + if (setAttributes) serialized += '\x04' + encode2B(attributes) + + lastStyle = style + } + serialized += cell[0] + } + return serialized + } + scheduleLoad () { + clearTimeout(this._scheduledLoad) + if (this._lastLoad < Date.now() - TERM_MIN_DRAW_DELAY) { + this.termScreen.load(this.serialize(), this.theme) + } else { + this._scheduledLoad = setTimeout(() => { + this.termScreen.load(this.serialize()) + }, TERM_MIN_DRAW_DELAY - this._lastLoad) + } + } + rainbowTimer () { + if (!this.rainbow) return + clearInterval(this._rainbowTimer) + this._rainbowTimer = setInterval(() => { + if (this.rainbow) this.scheduleLoad() + }, 50) + } +} + +class Process { + constructor (args) { + // event listeners + this._listeners = {} + } + on (event, listener) { + if (!this._listeners[event]) this._listeners[event] = [] + this._listeners[event].push({ listener }) + } + once (event, listener) { + if (!this._listeners[event]) this._listeners[event] = [] + this._listeners[event].push({ listener, once: true }) + } + off (event, listener) { + let listeners = this._listeners[event] + if (listeners) { + for (let i in listeners) { + if (listeners[i].listener === listener) { + listeners.splice(i, 1) + break + } + } + } + } + emit (event, ...args) { + let listeners = this._listeners[event] + if (listeners) { + let remove = [] + for (let listener of listeners) { + try { + listener.listener(...args) + if (listener.once) remove.push(listener) + } catch (err) { + console.error(err) + } + } + for (let listener of remove) { + listeners.splice(listeners.indexOf(listener), 1) + } + } + } + write (data) { + this.emit('in', data) + } + destroy () { + // death. + this.emit('exit', 0) + } + run () { + // noop + } +} + +let demoData = { + buttons: { + 1: '', + 2: '', + 3: '', + 4: '', + 5: function (terminal, shell) { + if (shell.child) shell.child.destroy() + let chars = 'info\r' + let loop = function () { + shell.write(chars[0]) + chars = chars.substr(1) + if (chars) setTimeout(loop, 100) + } + setTimeout(loop, 200) + } + } +} + +let demoshIndex = { + clear: class Clear extends Process { + run () { + this.emit('write', '\x1b[2J\x1b[1;1H') + this.destroy() + } + }, + screenfetch: class Screenfetch extends Process { + run () { + let image = ` + ###. ESPTerm Demo + '###. Hostname: ${window.location.hostname} + '###. Shell: ESPTerm Demo Shell + '###. Resolution: 80x25@${window.devicePixelRatio}x + :###- + .###' + .###' + .###' ############### + ###' ############### + `.split('\n').filter(line => line.trim()) + + let chars = '' + for (let y = 0; y < image.length; y++) { + for (let x = 0; x < 80; x++) { + if (image[y][x]) { + chars += `\x1b[38;5;${getRainbowColor((x + y) / 10)}m${image[y][x]}` + } else chars += ' ' + } + } + + this.emit('write', '\r\n\x1b[?25l') + let loop = () => { + this.emit('write', chars.substr(0, 80)) + chars = chars.substr(80) + if (chars.length) setTimeout(loop, 50) + else { + this.emit('write', '\r\n\x1b[?25h') + this.destroy() + } + } + loop() + } + }, + 'local-echo': class LocalEcho extends Process { + run (...args) { + if (!args.includes('--suppress-note')) { + this.emit('write', '\x1b[38;5;239mNote: not all terminal features are supported or and may not work as expected in this demo\x1b[0m\r\n') + } + } + write (data) { + this.emit('write', data) + } + }, + 'info': class Info extends Process { + run (...args) { + let fast = args.includes('--fast') + this.showSplash().then(() => { + this.printText(fast) + }) + } + showSplash () { + let splash = ` + -#####- -###*..#####- ######- + -#* -#- .## .##. *#- + -##### .-###*..#####- *#- -*##*- #*-#--#**#-*##- + -#* -#-.##. *#- *##@#* ##. -#* *# .#* + -#####--####- .##. *#- -*#@@- ##. -#* *# .#* + `.split('\n').filter(line => line.trim()) + let levels = { + ' ': -231, + '.': 4, + '-': 8, + '*': 17, + '#': 24 + } + for (let i in splash) { + if (splash[i].length < 79) splash[i] += ' '.repeat(79 - splash[i].length) + } + this.emit('write', '\r\n'.repeat(splash.length + 1)) + this.emit('write', '\x1b[A'.repeat(splash.length)) + this.emit('write', '\x1b[?25l') + + let cursorX = 0 + let cursorY = 0 + let moveTo = (x, y) => { + let moveX = x - cursorX + let moveY = y - cursorY + this.emit('write', `\x1b[${Math.abs(moveX)}${moveX > 0 ? 'C' : 'D'}`) + this.emit('write', `\x1b[${Math.abs(moveY)}${moveY > 0 ? 'B' : 'A'}`) + cursorX = x + cursorY = y + } + let drawCell = (x, y) => { + moveTo(x, y) + if (splash[y][x] === '@') { + this.emit('write', '\x1b[48;5;8m\x1b[38;5;255m▄\b') + } else { + this.emit('write', `\x1b[48;5;${231 + levels[splash[y][x]]}m \b`) + } + } + return new Promise((resolve, reject) => { + const self = this + let x = 14 + let cycles = 0 + let loop = function () { + for (let y = 0; y < splash.length; y++) { + let dx = x - y + if (dx > 0) drawCell(dx, y) + } + + if (++x < 79) { + if (++cycles >= 3) { + setTimeout(loop, 20) + cycles = 0 + } else loop() + } else { + moveTo(0, splash.length) + self.emit('write', '\x1b[m\x1b[?25h') + resolve() + } + } + loop() + }) + } + printText (fast = false) { + // lots of printing + let parts = [ + '', + ' ESPTerm is a VT100-like terminal emulator running on the ESP8266 WiFi chip.', + '', + ' \x1b[93mThis is an online demo of the web user interface, simulating a simple ', + ' terminal in your browser.\x1b[m', + '', + ' Type \x1b[92mls\x1b[m to list available commands.', + ' Use the \x1b[94mlinks\x1b[m below this screen for a demo of the options and more info.', + '' + ] + + if (fast) { + this.emit('write', parts.join('\r\n') + '\r\n') + this.destroy() + } else { + const self = this + let loop = function () { + self.emit('write', parts.shift() + '\r\n') + if (parts.length) setTimeout(loop, 17) + else self.destroy() + } + loop() + } + } + }, + colors: class PrintColors extends Process { + run () { + this.emit('write', '\r\n') + let fgtext = 'foreground-color' + this.emit('write', ' ') + for (let i = 0; i < 16; i++) { + this.emit('write', '\x1b[' + (i < 8 ? `3${i}` : `9${i - 8}`) + 'm') + this.emit('write', fgtext[i] + ' ') + } + this.emit('write', '\r\n ') + for (let i = 0; i < 16; i++) { + this.emit('write', '\x1b[' + (i < 8 ? `4${i}` : `10${i - 8}`) + 'm ') + } + this.emit('write', '\x1b[m\r\n') + for (let r = 0; r < 6; r++) { + this.emit('write', ' ') + for (let g = 0; g < 6; g++) { + for (let b = 0; b < 6; b++) { + this.emit('write', `\x1b[48;5;${16 + r * 36 + g * 6 + b}m `) + } + this.emit('write', '\x1b[m') + } + this.emit('write', '\r\n') + } + this.emit('write', ' ') + for (let g = 0; g < 24; g++) { + this.emit('write', `\x1b[48;5;${232 + g}m `) + } + this.emit('write', '\x1b[m\r\n\n') + this.destroy() + } + }, + ls: class ListCommands extends Process { + run () { + this.emit('write', '\x1b[92mList of demo commands\x1b[m\r\n') + for (let i in demoshIndex) { + if (typeof demoshIndex[i] === 'string') continue + this.emit('write', i + '\r\n') + } + this.destroy() + } + }, + theme: class SetTheme extends Process { + constructor (shell) { + super() + this.shell = shell + } + run (...args) { + let theme = args[0] | 0 + if (!args.length || !Number.isFinite(theme) || theme < 0 || theme > 5) { + this.emit('write', '\x1b[31mUsage: theme [0–5]\r\n') + this.destroy() + return + } + this.shell.terminal.theme = theme + // HACK: reset drawn screen to prevent only partly redrawn screen + this.shell.terminal.termScreen.drawnScreenFG = [] + this.emit('write', '') + this.destroy() + } + }, + cursor: class SetCursor extends Process { + run (...args) { + let steady = args.includes('--steady') + if (args.includes('block')) { + this.emit('write', `\x1b[${0 + 2 * steady} q`) + } else if (args.includes('line')) { + this.emit('write', `\x1b[${3 + steady} q`) + } else if (args.includes('bar') || args.includes('beam')) { + this.emit('write', `\x1b[${5 + steady} q`) + } else { + this.emit('write', '\x1b[31mUsage: cursor [block|line|bar] [--steady]\r\n') + } + this.destroy() + } + }, + rainbow: class ToggleRainbow extends Process { + constructor (shell) { + super() + this.shell = shell + } + run () { + this.shell.terminal.rainbow = !this.shell.terminal.rainbow + this.shell.terminal.rainbowTimer() + this.emit('write', '') + this.destroy() + } + }, + pwd: '/this/is/a/demo\r\n', + cd: '\x1b[38;5;239mNo directories to change to\r\n', + whoami: `${window.navigator.userAgent}\r\n`, + hostname: `${window.location.hostname}`, + uname: 'ESPTerm Demo\r\n', + mkdir: '\x1b[38;5;239mDid not create a directory because this is a demo.\r\n', + rm: '\x1b[38;5;239mDid not delete anything because this is a demo.\r\n', + cp: '\x1b[38;5;239mNothing to copy because this is a demo.\r\n', + mv: '\x1b[38;5;239mNothing to move because this is a demo.\r\n', + ln: '\x1b[38;5;239mNothing to link because this is a demo.\r\n', + touch: '\x1b[38;5;239mNothing to touch\r\n', + exit: '\x1b[38;5;239mNowhere to go\r\n' +} + +class DemoShell { + constructor (terminal, printInfo) { + this.terminal = terminal + this.terminal.reset() + this.parser = new ANSIParser((...args) => this.handleParsed(...args)) + this.input = '' + this.cursorPos = 0 + this.child = null + this.index = demoshIndex + + if (printInfo) this.run('info') + else this.prompt() + } + write (text) { + if (this.child) { + if (text.codePointAt(0) === 3) this.child.destroy() + else this.child.write(text) + } else this.parser.write(text) + } + prompt (success = true) { + if (this.terminal.cursor.x !== 0) this.terminal.write('\x1b[m\x1b[38;5;238m⏎\r\n') + this.terminal.write('\x1b[34;1mdemosh \x1b[m') + if (!success) this.terminal.write('\x1b[31m') + this.terminal.write('$ \x1b[m') + this.input = '' + this.cursorPos = 0 + } + handleParsed (action, ...args) { + this.terminal.write('\b\x1b[P'.repeat(this.cursorPos)) + if (action === 'write') { + this.input = this.input.substr(0, this.cursorPos) + args[0] + this.input.substr(this.cursorPos) + this.cursorPos++ + } else if (action === 'back') { + this.input = this.input.substr(0, this.cursorPos - 1) + this.input.substr(this.cursorPos) + this.cursorPos-- + if (this.cursorPos < 0) this.cursorPos = 0 + } else if (action === 'move-cursor-x') { + this.cursorPos = Math.max(0, Math.min(this.input.length, this.cursorPos + args[0])) + } else if (action === 'delete-line') { + this.input = '' + this.cursorPos = 0 + } else if (action === 'delete-word') { + let words = this.input.substr(0, this.cursorPos).split(' ') + words.pop() + this.input = words.join(' ') + this.input.substr(this.cursorPos) + this.cursorPos = words.join(' ').length + } + + this.terminal.write(this.input) + this.terminal.write('\b'.repeat(this.input.length)) + this.terminal.moveForward(this.cursorPos) + this.terminal.write('') // dummy. Apply the moveFoward + + if (action === 'return') { + this.terminal.write('\r\n') + this.parse(this.input) + } + } + parse (input) { + if (input === 'help') input = 'info' + // TODO: basic chaining (i.e. semicolon) + this.run(input) + } + run (command) { + let parts = [''] + + let inQuote = false + for (let character of command.trim()) { + if (inQuote && character !== inQuote) { + parts[parts.length - 1] += character + } else if (inQuote) { + inQuote = false + } else if (character === '"' || character === "'") { + inQuote = character + } else if (character.match(/\s/)) { + if (parts[parts.length - 1]) parts.push('') + } else parts[parts.length - 1] += character + } + + let name = parts.shift() + if (name in this.index) { + this.spawn(name, parts) + } else { + this.terminal.write(`demosh: Unknown command: ${name}\r\n`) + this.prompt(false) + } + } + spawn (name, args = []) { + let Process = this.index[name] + if (Process instanceof Function) { + this.child = new Process(this) + let write = data => this.terminal.write(data) + this.child.on('write', write) + this.child.on('exit', code => { + if (this.child) this.child.off('write', write) + this.child = null + this.prompt(!code) + }) + this.child.run(...args) + } else { + this.terminal.write(Process) + this.prompt() + } + } +} + +window.demoInterface = { + input (data) { + let type = data[0] + let content = data.substr(1) + + if (type === 's') { + this.shell.write(content) + } else if (type === 'b') { + let button = content.charCodeAt(0) + let action = demoData.buttons[button] + if (action) { + if (typeof action === 'string') this.shell.write(action) + else if (action instanceof Function) action(this.terminal, this.shell) + } + } else if (type === 'm' || type === 'p' || type === 'r') { + console.log(JSON.stringify(data)) + } + }, + init (screen) { + this.terminal = new ScrollingTerminal(screen) + this.shell = new DemoShell(this.terminal, true) + } +} diff --git a/jssrc/lang.js b/js/lang.js similarity index 72% rename from jssrc/lang.js rename to js/lang.js index 327dae9..bce4adb 100644 --- a/jssrc/lang.js +++ b/js/lang.js @@ -1,8 +1,8 @@ // Generated from PHP locale file -var _tr = { +let _tr = { "wifi.connected_ip_is": "Connected, IP is ", "wifi.not_conn": "Not connected.", "wifi.enter_passwd": "Enter password for \":ssid:\"" }; -function tr(key) { return _tr[key] || '?'+key+'?'; } +function tr (key) { return _tr[key] || '?' + key + '?' } diff --git a/jssrc/chibi.js b/js/lib/chibi.js similarity index 99% rename from jssrc/chibi.js rename to js/lib/chibi.js index ae975ca..4d1d95e 100755 --- a/jssrc/chibi.js +++ b/js/lib/chibi.js @@ -575,7 +575,7 @@ // Basic XHR chibi.ajax = function (options) { // if options is a number, it's timeout in ms - var opts = extend({ + var opts = Object.assign({ method: 'GET', nocache: true, timeout: 5000, diff --git a/jssrc/keymaster.js b/js/lib/keymaster.js similarity index 99% rename from jssrc/keymaster.js rename to js/lib/keymaster.js index 88b9629..0f33d44 100644 --- a/jssrc/keymaster.js +++ b/js/lib/keymaster.js @@ -307,4 +307,5 @@ if(typeof module !== 'undefined') module.exports = assignKey; -})(this); +})(window); + diff --git a/js/lib/polyfills.js b/js/lib/polyfills.js new file mode 100644 index 0000000..bfd2b31 --- /dev/null +++ b/js/lib/polyfills.js @@ -0,0 +1,63 @@ +/*! http://mths.be/fromcodepoint v0.1.0 by @mathias */ +if (!String.fromCodePoint) { + (function () { + var defineProperty = (function () { + // IE 8 only supports `Object.defineProperty` on DOM elements + try { + var object = {}; + var $defineProperty = Object.defineProperty; + var result = $defineProperty(object, object, object) && $defineProperty; + } catch (error) { + } + return result; + }()); + var stringFromCharCode = String.fromCharCode; + var floor = Math.floor; + var fromCodePoint = function () { + var MAX_SIZE = 0x4000; + var codeUnits = []; + var highSurrogate; + var lowSurrogate; + var index = -1; + var length = arguments.length; + if (!length) { + return ''; + } + var result = ''; + while (++index < length) { + var codePoint = Number(arguments[index]); + if ( + !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity` + codePoint < 0 || // not a valid Unicode code point + codePoint > 0x10FFFF || // not a valid Unicode code point + floor(codePoint) != codePoint // not an integer + ) { + throw RangeError('Invalid code point: ' + codePoint); + } + if (codePoint <= 0xFFFF) { // BMP code point + codeUnits.push(codePoint); + } else { // Astral code point; split in surrogate halves + // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae + codePoint -= 0x10000; + highSurrogate = (codePoint >> 10) + 0xD800; + lowSurrogate = (codePoint % 0x400) + 0xDC00; + codeUnits.push(highSurrogate, lowSurrogate); + } + if (index + 1 == length || codeUnits.length > MAX_SIZE) { + result += stringFromCharCode.apply(null, codeUnits); + codeUnits.length = 0; + } + } + return result; + }; + if (defineProperty) { + defineProperty(String, 'fromCodePoint', { + 'value': fromCodePoint, + 'configurable': true, + 'writable': true + }); + } else { + String.fromCodePoint = fromCodePoint; + } + }()); +} diff --git a/js/modal.js b/js/modal.js new file mode 100644 index 0000000..fabc1a7 --- /dev/null +++ b/js/modal.js @@ -0,0 +1,44 @@ +/** Module for toggling a modal overlay */ +(function () { + let modal = {} + let curCloseCb = null + + modal.show = function (sel, closeCb) { + let $m = $(sel) + $m.removeClass('hidden visible') + setTimeout(function () { + $m.addClass('visible') + }, 1) + curCloseCb = closeCb + } + + modal.hide = function (sel) { + let $m = $(sel) + $m.removeClass('visible') + setTimeout(function () { + $m.addClass('hidden') + if (curCloseCb) curCloseCb() + }, 500) // transition time + } + + modal.init = function () { + // close modal by click outside the dialog + $('.Modal').on('click', function () { + if ($(this).hasClass('no-close')) return // this is a no-close modal + modal.hide(this) + }) + + $('.Dialog').on('click', function (e) { + e.stopImmediatePropagation() + }) + + // Hide all modals on esc + $(window).on('keydown', function (e) { + if (e.which === 27) { + modal.hide('.Modal') + } + }) + } + + window.Modal = modal +})() diff --git a/js/notif.js b/js/notif.js new file mode 100644 index 0000000..38cbd4e --- /dev/null +++ b/js/notif.js @@ -0,0 +1,65 @@ +window.Notify = (function () { + let nt = {} + const sel = '#notif' + let $balloon + + let timerHideBegin // timeout to start hiding (transition) + let timerHideEnd // timeout to add the hidden class + let timerCanCancel + let canCancel = false + + let stopTimeouts = function () { + clearTimeout(timerHideBegin) + clearTimeout(timerHideEnd) + } + + nt.show = function (message, timeout, isError) { + $balloon.toggleClass('error', isError === true) + $balloon.html(message) + Modal.show($balloon) + stopTimeouts() + + if (undef(timeout) || timeout === null || timeout <= 0) { + timeout = 2500 + } + + timerHideBegin = setTimeout(nt.hide, timeout) + + canCancel = false + timerCanCancel = setTimeout(function () { + canCancel = true + }, 500) + } + + nt.hide = function () { + let $m = $(sel) + $m.removeClass('visible') + timerHideEnd = setTimeout(function () { + $m.addClass('hidden') + }, 250) // transition time + } + + nt.init = function () { + $balloon = $(sel) + + // close by click outside + $(document).on('click', function () { + if (!canCancel) return + nt.hide(this) + }) + + // click caused by selecting, prevent it from bubbling + $balloon.on('click', function (e) { + e.stopImmediatePropagation() + return false + }) + + // stop fading if moused + $balloon.on('mouseenter', function () { + stopTimeouts() + $balloon.removeClass('hidden').addClass('visible') + }) + } + + return nt +})() diff --git a/js/soft_keyboard.js b/js/soft_keyboard.js new file mode 100644 index 0000000..5829033 --- /dev/null +++ b/js/soft_keyboard.js @@ -0,0 +1,113 @@ +window.initSoftKeyboard = function (screen, input) { + const keyInput = qs('#softkb-input') + if (!keyInput) return // abort, we're not on the terminal page + + let keyboardOpen = false + + let updateInputPosition = function () { + if (!keyboardOpen) return + + let [x, y] = screen.gridToScreen(screen.cursor.x, screen.cursor.y, true) + keyInput.style.transform = `translate(${x}px, ${y}px)` + } + + keyInput.addEventListener('focus', () => { + keyboardOpen = true + updateInputPosition() + }) + + keyInput.addEventListener('blur', () => (keyboardOpen = false)) + + screen.on('cursor-moved', updateInputPosition) + + let kbOpen = function (open) { + keyboardOpen = open + updateInputPosition() + if (open) keyInput.focus() + else keyInput.blur() + } + + qs('#term-kb-open').addEventListener('click', function () { + kbOpen(true) + return false + }) + + // Chrome for Android doesn't send proper keydown/keypress events with + // real key values instead of 229 “Unidentified,” so here's a workaround + // that deals with the input composition events. + + let lastCompositionString = '' + let compositing = false + + // sends the difference between the last and the new composition string + let sendInputDelta = function (newValue) { + let resend = false + if (newValue.length > lastCompositionString.length) { + if (newValue.startsWith(lastCompositionString)) { + // characters have been added at the end + input.sendString(newValue.substr(lastCompositionString.length)) + } else resend = true + } else if (newValue.length < lastCompositionString.length) { + if (lastCompositionString.startsWith(newValue)) { + // characters have been removed at the end + input.sendString('\b'.repeat(lastCompositionString.length - + newValue.length)) + } else resend = true + } else if (newValue !== lastCompositionString) resend = true + + if (resend) { + // the entire string changed; resend everything + input.sendString('\b'.repeat(lastCompositionString.length) + + newValue) + } + lastCompositionString = newValue + } + + keyInput.addEventListener('keydown', e => { + if (e.key === 'Unidentified') return + + keyInput.value = '' + + if (e.key === 'Backspace') { + e.preventDefault() + input.sendString('\b') + } else if (e.key === 'Enter') { + e.preventDefault() + input.sendString('\x0d') + } + }) + + keyInput.addEventListener('keypress', e => { + // prevent key duplication on iOS (because Safari *does* send proper events) + e.stopPropagation() + }) + + keyInput.addEventListener('input', e => { + e.stopPropagation() + + if (e.isComposing) { + sendInputDelta(e.data) + } else { + if (e.inputType === 'insertCompositionText') input.sendString(e.data) + else if (e.inputType === 'deleteContentBackward') { + lastCompositionString = '' + sendInputDelta('') + } else if (e.inputType === 'insertText') { + input.sendString(e.data) + } + } + }) + + keyInput.addEventListener('compositionstart', e => { + lastCompositionString = '' + compositing = true + }) + + keyInput.addEventListener('compositionend', e => { + lastCompositionString = '' + compositing = false + keyInput.value = '' + }) + + screen.on('open-soft-keyboard', () => keyInput.focus()) +} diff --git a/js/td/WebAudio.d.ts b/js/td/WebAudio.d.ts new file mode 100644 index 0000000..b923676 --- /dev/null +++ b/js/td/WebAudio.d.ts @@ -0,0 +1,1144 @@ +// Type definitions for Web Audio API +// Project: http://www.w3.org/TR/webaudio/ +// Definitions by: Baruch Berger , Kon +// Definitions: https://github.com/borisyankov/DefinitelyTyped + +/** + * This interface represents a set of AudioNode objects and their connections. It allows for arbitrary routing of signals to the AudioDestinationNode (what the user ultimately hears). Nodes are created from the context and are then connected together. In most use cases, only a single AudioContext is used per document. An AudioContext is constructed as follows: + * + * var context = new AudioContext(); + */ +interface AudioContext { + /** + * An AudioDestinationNode with a single input representing the final destination for all audio (to be rendered to the audio hardware). All AudioNodes actively rendering audio will directly or indirectly connect to destination. + */ + destination: AudioDestinationNode; + + /** + * The sample rate (in sample-frames per second) at which the AudioContext handles audio. It is assumed that all AudioNodes in the context run at this rate. In making this assumption, sample-rate converters or "varispeed" processors are not supported in real-time processing. + */ + sampleRate: number; + + /** + * This is a time in seconds which starts at zero when the context is created and increases in real-time. All scheduled times are relative to it. This is not a "transport" time which can be started, paused, and re-positioned. It is always moving forward. A GarageBand-like timeline transport system can be very easily built on top of this (in JavaScript). This time corresponds to an ever-increasing hardware timestamp. + */ + currentTime: number; + + /** + * An AudioListener which is used for 3D spatialization. + */ + listener: AudioListener; + + /** + * The number of AudioBufferSourceNodes that are currently playing. + */ + activeSourceCount: number; + + /** + * Creates an AudioBuffer of the given size. The audio data in the buffer will be zero-initialized (silent). An exception will be thrown if the numberOfChannels or sampleRate are out-of-bounds. + * @param numberOfChannels how many channels the buffer will have. An implementation must support at least 32 channels. + * @param length the size of the buffer in sample-frames. + * @param sampleRate the sample-rate of the linear PCM audio data in the buffer in sample-frames per second. An implementation must support sample-rates in at least the range 22050 to 96000. + */ + createBuffer(numberOfChannels: number, length: number, sampleRate: number): AudioBuffer; + + /** + * Creates an AudioBuffer given the audio file data contained in the ArrayBuffer. The ArrayBuffer can, for example, be loaded from an XMLHttpRequest's response attribute after setting the responseType to "arraybuffer". Audio file data can be in any of the formats supported by the audio element. + * The following steps must be performed: + * 1. Decode the encoded buffer from the AudioBuffer into linear PCM. If a decoding error is encountered due to the audio format not being recognized or supported, or because of corrupted/unexpected/inconsistent data then return NULL (and these steps will be terminated). + * 2. If mixToMono is true, then mixdown the decoded linear PCM data to mono. + * 3. Take the decoded (possibly mixed-down) linear PCM audio data, and resample it to the sample-rate of the AudioContext if it is different from the sample-rate of buffer. The final result will be stored in an AudioBuffer and returned as the result of this method. + * @param buffer the audio file data (for example from a .wav file). + * @param mixToMono if a mixdown to mono will be performed. Normally, this would not be set. + */ + createBuffer(buffer: ArrayBuffer, mixToMono: boolean): AudioBuffer; + + /** + * Asynchronously decodes the audio file data contained in the ArrayBuffer. The ArrayBuffer can, for example, be loaded from an XMLHttpRequest's response attribute after setting the responseType to "arraybuffer". Audio file data can be in any of the formats supported by the audio element. + * The decodeAudioData() method is preferred over the createBuffer() from ArrayBuffer method because it is asynchronous and does not block the main JavaScript thread. + * + * The following steps must be performed: + * 1. Temporarily neuter the audioData ArrayBuffer in such a way that JavaScript code may not access or modify the data. + * 2. Queue a decoding operation to be performed on another thread. + * 3. The decoding thread will attempt to decode the encoded audioData into linear PCM. If a decoding error is encountered due to the audio format not being recognized or supported, or because of corrupted/unexpected/inconsistent data then the audioData neutered state will be restored to normal and the errorCallback will be scheduled to run on the main thread's event loop and these steps will be terminated. + * 4. The decoding thread will take the result, representing the decoded linear PCM audio data, and resample it to the sample-rate of the AudioContext if it is different from the sample-rate of audioData. The final result (after possibly sample-rate converting) will be stored in an AudioBuffer. + * 5. The audioData neutered state will be restored to normal + * 6. The successCallback function will be scheduled to run on the main thread's event loop given the AudioBuffer from step (4) as an argument. + * + * @param ArrayBuffer containing audio file data. + * @param callback function which will be invoked when the decoding is finished. The single argument to this callback is an AudioBuffer representing the decoded PCM audio data. + * @param callback function which will be invoked if there is an error decoding the audio file data. + */ + decodeAudioData(audioData: ArrayBuffer, successCallback: any, errorCallback?: any): void; + + /** + * Creates an AudioBufferSourceNode. + */ + createBufferSource(): AudioBufferSourceNode; + + /** + * Creates a MediaElementAudioSourceNode given an HTMLMediaElement. As a consequence of calling this method, audio playback from the HTMLMediaElement will be re-routed into the processing graph of the AudioContext. + */ + createMediaElementSource(mediaElement: HTMLMediaElement): MediaElementAudioSourceNode; + + /** + * Creates a MediaStreamAudioSourceNode given a MediaStream. As a consequence of calling this method, audio playback from the MediaStream will be re-routed into the processing graph of the AudioContext. + */ + createMediaStreamSource(mediaStream: any): MediaStreamAudioSourceNode; + + /** + * Creates a ScriptProcessorNode for direct audio processing using JavaScript. An exception will be thrown if bufferSize or numberOfInputChannels or numberOfOutputChannels are outside the valid range. + * It is invalid for both numberOfInputChannels and numberOfOutputChannels to be zero. + * @param bufferSize the buffer size in units of sample-frames. It must be one of the following values: 256, 512, 1024, 2048, 4096, 8192, 16384. This value controls how frequently the onaudioprocess event handler is called and how many sample-frames need to be processed each call. Lower values for bufferSize will result in a lower (better) latency. Higher values will be necessary to avoid audio breakup and glitches. The value chosen must carefully balance between latency and audio quality. + * @param numberOfInputChannels (defaults to 2) the number of channels for this node's input. Values of up to 32 must be supported. + * @param numberOfOutputChannels (defaults to 2) the number of channels for this node's output. Values of up to 32 must be supported. + */ + createScriptProcessor(bufferSize: number, numberOfInputChannels?: number, numberOfOutputChannels?: number): ScriptProcessorNode; + + /** + * Creates a AnalyserNode. + */ + createAnalyser(): AnalyserNode; + + /** + * Creates a GainNode. + */ + createGain(): GainNode; + + /** + * Creates a DelayNode representing a variable delay line. The initial default delay time will be 0 seconds. + * @param maxDelayTime the maximum delay time in seconds allowed for the delay line. If specified, this value must be greater than zero and less than three minutes or a NOT_SUPPORTED_ERR exception will be thrown. + */ + createDelay(maxDelayTime?: number): DelayNode; + //createDelayNode(maxDelayTime?: number): DelayNode; + + /** + * Creates a BiquadFilterNode representing a second order filter which can be configured as one of several common filter types. + */ + createBiquadFilter(): BiquadFilterNode; + + /** + * Creates a WaveShaperNode representing a non-linear distortion. + */ + createWaveShaper(): WaveShaperNode; + + /** + * Creates an PannerNode. + */ + createPanner(): PannerNode; + + /** + * Creates a ConvolverNode. + */ + createConvolver(): ConvolverNode; + + /** + * Creates an ChannelSplitterNode representing a channel splitter. An exception will be thrown for invalid parameter values. + * @param numberOfOutputs the number of outputs. Values of up to 32 must be supported. If not specified, then 6 will be used. + */ + createChannelSplitter(numberOfOutputs?: number): ChannelSplitterNode; + + /** + * Creates an ChannelMergerNode representing a channel merger. An exception will be thrown for invalid parameter values. + * @param numberOfInputs the number of inputs. Values of up to 32 must be supported. If not specified, then 6 will be used. + */ + createChannelMerger(numberOfInputs?: number): ChannelMergerNode; + + /** + * Creates a DynamicsCompressorNode. + */ + createDynamicsCompressor(): DynamicsCompressorNode; + + /** + * Creates an OscillatorNode. + */ + createOscillator(): OscillatorNode; + + /** + * Creates a WaveTable representing a waveform containing arbitrary harmonic content. The real and imag parameters must be of type Float32Array of equal lengths greater than zero and less than or equal to 4096 or an exception will be thrown. These parameters specify the Fourier coefficients of a Fourier series representing the partials of a periodic waveform. The created WaveTable will be used with an OscillatorNode and will represent a normalized time-domain waveform having maximum absolute peak value of 1. Another way of saying this is that the generated waveform of an OscillatorNode will have maximum peak value at 0dBFS. Conveniently, this corresponds to the full-range of the signal values used by the Web Audio API. Because the WaveTable will be normalized on creation, the real and imag parameters represent relative values. + * @param real an array of cosine terms (traditionally the A terms). In audio terminology, the first element (index 0) is the DC-offset of the periodic waveform and is usually set to zero. The second element (index 1) represents the fundamental frequency. The third element represents the first overtone, and so on. + * @param imag an array of sine terms (traditionally the B terms). The first element (index 0) should be set to zero (and will be ignored) since this term does not exist in the Fourier series. The second element (index 1) represents the fundamental frequency. The third element represents the first overtone, and so on. + */ + createWaveTable(real: any,imag: any): WaveTable; +} + +declare var AudioContext: { + new (): AudioContext; +} + +declare var webkitAudioContext: { + new (): AudioContext; +} + +interface OfflineRenderSuccessCallback{ + (renderedData: AudioBuffer): void; +} + +/** + * OfflineAudioContext is a particular type of AudioContext for rendering/mixing-down (potentially) faster than real-time. It does not render to the audio hardware, but instead renders as quickly as possible, calling a render callback function upon completion with the result provided as an AudioBuffer. It is constructed by specifying the numberOfChannels, length, and sampleRate as follows: + * + * var offlineContext = new OfflineAudioContext(unsigned long numberOfChannels, unsigned long length, float sampleRate); + */ +interface OfflineAudioContext extends AudioContext{ + startRendering(): void; + oncomplete: OfflineRenderSuccessCallback; +} + +declare var webkitOfflineAudioContext: { + new (numberOfChannels: number, length: number, sampleRate: number): OfflineAudioContext; +} + +/** + * AudioNodes are the building blocks of an AudioContext. This interface represents audio sources, the audio destination, and intermediate processing modules. These modules can be connected together to form processing graphs for rendering audio to the audio hardware. Each node can have inputs and/or outputs. An AudioSourceNode has no inputs and a single output. An AudioDestinationNode has one input and no outputs and represents the final destination to the audio hardware. Most processing nodes such as filters will have one input and one output. Each type of AudioNode differs in the details of how it processes or synthesizes audio. But, in general, AudioNodes will process its inputs (if it has any), and generate audio for its outputs (if it has any). + * + * An output may connect to one or more AudioNode inputs, thus fanout is supported. An input may be connected from one or more AudioNode outputs, thus fanin is supported. + * + * In order to handle this fanin, any AudioNode with inputs performs an up-mixing of all connections for each input: + * + * 1. Calculate N: the maximum number of channels of all the connections to the input. For example, if an input has a mono connection and a stereo connection then this number will be 2. + * 2. For each connection to the input, up-mix to N channels. + * 3. Mix together all the up-mixed streams from (2). This is a straight-forward mixing together of each of the corresponding channels from each connection. + * + * Please see Mixer Gain Structure for more informative details. + * + * For performance reasons, practical implementations will need to use block processing, with each AudioNode processing a fixed number of sample-frames of size block-size. In order to get uniform behavior across implementations, we will define this value explicitly. block-size is defined to be 128 sample-frames which corresponds to roughly 3ms at a sample-rate of 44.1KHz. + */ +interface AudioNode { + /** + * Connects the AudioNode to another AudioNode. + * + * It is possible to connect an AudioNode output to more than one input with multiple calls to connect(). Thus, "fanout" is supported. + * + * It is possible to connect an AudioNode to another AudioNode which creates a cycle. In other words, an AudioNode may connect to another AudioNode, which in turn connects back to the first AudioNode. This is allowed only if there is at least one DelayNode in the cycle or an exception will be thrown. + * + * There can only be one connection between a given output of one specific node and a given input of another specific node. Multiple connections with the same termini are ignored. For example: + * + * nodeA.connect(nodeB); + * nodeA.connect(nodeB); + * + * will have the same effect as + * + * nodeA.connect(nodeB); + * + * @param destination the AudioNode to connect to. + * @param output an index describing which output of the AudioNode from which to connect. An out-of-bound value throws an exception. + * @param input an index describing which input of the destination AudioNode to connect to. An out-of-bound value throws an exception. + */ + connect(destination: AudioNode, output?: number, input?: number): void; + + /** + * Connects the AudioNode to an AudioParam, controlling the parameter value with an audio-rate signal. + * + * It is possible to connect an AudioNode output to more than one AudioParam with multiple calls to connect(). Thus, "fanout" is supported. + * + * It is possible to connect more than one AudioNode output to a single AudioParam with multiple calls to connect(). Thus, "fanin" is supported. + * + * An AudioParam will take the rendered audio data from any AudioNode output connected to it and convert it to mono by down-mixing if it is not already mono, then mix it together with other such outputs and finally will mix with the intrinsic parameter value (the value the AudioParam would normally have without any audio connections), including any timeline changes scheduled for the parameter. + * + * There can only be one connection between a given output of one specific node and a specific AudioParam. Multiple connections with the same termini are ignored. For example: + * + * nodeA.connect(param); + * nodeA.connect(param); + * + * will have the same effect as + * + * nodeA.connect(param); + * + * @param destination the AudioParam to connect to. + * @param output an index describing which output of the AudioNode from which to connect. An out-of-bound value throws an exception. + */ + connect(destination: AudioParam, output?: number): void; + + /** + * Disconnects an AudioNode's output. + * @param output an index describing which output of the AudioNode to disconnect. An out-of-bound value throws an exception. + */ + disconnect(output?: number): void; + + /** + * The AudioContext which owns this AudioNode. + */ + context: AudioContext; + + /** + * The number of inputs feeding into the AudioNode. This will be 0 for an AudioSourceNode. + */ + numberOfInputs: number; + + /** + * The number of outputs coming out of the AudioNode. This will be 0 for an AudioDestinationNode. + */ + numberOfOutputs: number; +} + + +/** + * This is an abstract interface representing an audio source, an AudioNode which has no inputs and a single output: + * + * numberOfInputs : 0 + * numberOfOutputs : 1 + * + * Subclasses of AudioSourceNode will implement specific types of audio sources. + */ +interface AudioSourceNode extends AudioNode { + +} + +/** + * This is an AudioNode representing the final audio destination and is what the user will ultimately hear. It can be considered as an audio output device which is connected to speakers. All rendered audio to be heard will be routed to this node, a "terminal" node in the AudioContext's routing graph. There is only a single AudioDestinationNode per AudioContext, provided through the destination attribute of AudioContext. + * + * numberOfInputs : 1 + * numberOfOutputs : 0 + */ +interface AudioDestinationNode extends AudioNode { + /** + * The maximum number of channels that the numberOfChannels attribute can be set to. An AudioDestinationNode representing the audio hardware end-point (the normal case) can potentially output more than 2 channels of audio if the audio hardware is multi-channel. maxNumberOfChannels is the maximum number of channels that this hardware is capable of supporting. If this value is 0, then this indicates that maxNumberOfChannels may not be changed. This will be the case for an AudioDestinationNode in an OfflineAudioContext. + * @readonly + */ + maxNumberOfChannels: number; + + /** + * The number of channels of the destination's input. This value will default to 2, and may be set to any non-zero value less than or equal to maxNumberOfChannels. An exception will be thrown if this value is not within the valid range. Giving a concrete example, if the audio hardware supports 8-channel output, then we may set numberOfChannels to 8, and render 8-channels of output. + */ + numberOfChannels: number; +} + +/** + * AudioParam controls an individual aspect of an AudioNode's functioning, such as volume. The parameter can be set immediately to a particular value using the "value" attribute. Or, value changes can be scheduled to happen at very precise times (in the coordinate system of AudioContext.currentTime), for envelopes, volume fades, LFOs, filter sweeps, grain windows, etc. In this way, arbitrary timeline-based automation curves can be set on any AudioParam. Additionally, audio signals from the outputs of AudioNodes can be connected to an AudioParam, summing with the intrinsic parameter value. + * + * Some synthesis and processing AudioNodes have AudioParams as attributes whose values must be taken into account on a per-audio-sample basis. For other AudioParams, sample-accuracy is not important and the value changes can be sampled more coarsely. Each individual AudioParam will specify that it is either an a-rate parameter which means that its values must be taken into account on a per-audio-sample basis, or it is a k-rate parameter. + * + * Implementations must use block processing, with each AudioNode processing 128 sample-frames in each block. + * + * For each 128 sample-frame block, the value of a k-rate parameter must be sampled at the time of the very first sample-frame, and that value must be used for the entire block. a-rate parameters must be sampled for each sample-frame of the block. + */ +interface AudioParam { + /** + * The parameter's floating-point value. This attribute is initialized to the defaultValue. If a value is set outside the allowable range described by minValue and maxValue no exception is thrown, because these limits are just nominal and may be exceeded. If a value is set during a time when there are any automation events scheduled then it will be ignored and no exception will be thrown. + */ + value: number; + + /** + * Nominal minimum value. This attribute is informational and value may be set lower than this value. + */ + minValue: number; + + /** + * Nominal maximum value. This attribute is informational and value may be set higher than this value. + */ + maxValue: number; + + /** + * Initial value for the value attribute + */ + defaultValue: number; + + /** + * Schedules a parameter value change at the given time. + * + * If there are no more events after this SetValue event, then for t >= startTime, v(t) = value. In other words, the value will remain constant. + * + * If the next event (having time T1) after this SetValue event is not of type LinearRampToValue or ExponentialRampToValue, then, for t: startTime <= t < T1, v(t) = value. In other words, the value will remain constant during this time interval, allowing the creation of "step" functions. + * + * If the next event after this SetValue event is of type LinearRampToValue or ExponentialRampToValue then please see details below. + * + * @param value the value the parameter will change to at the given time + * @param startTime parameter is the time in the same time coordinate system as AudioContext.currentTime. + */ + setValueAtTime(value: number, startTime: number): void; + + /** + * Schedules a linear continuous change in parameter value from the previous scheduled parameter value to the given value. + * + * The value during the time interval T0 <= t < T1 (where T0 is the time of the previous event and T1 is the endTime parameter passed into this method) will be calculated as: + * + * v(t) = V0 + (V1 - V0) * ((t - T0) / (T1 - T0)) + * + * Where V0 is the value at the time T0 and V1 is the value parameter passed into this method. + * + * If there are no more events after this LinearRampToValue event then for t >= T1, v(t) = V1 + * + * @param value the value the parameter will linearly ramp to at the given time. + * @param endTime the time in the same time coordinate system as AudioContext.currentTime. + */ + linearRampToValueAtTime(value: number, time: number): void; + + /** + * Schedules an exponential continuous change in parameter value from the previous scheduled parameter value to the given value. Parameters representing filter frequencies and playback rate are best changed exponentially because of the way humans perceive sound. + * + * The value during the time interval T0 <= t < T1 (where T0 is the time of the previous event and T1 is the endTime parameter passed into this method) will be calculated as: + * + * v(t) = V0 * (V1 / V0) ^ ((t - T0) / (T1 - T0)) + * + * Where V0 is the value at the time T0 and V1 is the value parameter passed into this method. + * + * If there are no more events after this ExponentialRampToValue event then for t >= T1, v(t) = V1 + * + * @param value the value the parameter will exponentially ramp to at the given time. An exception will be thrown if this value is less than or equal to 0, or if the value at the time of the previous event is less than or equal to 0. + * @param endTime the time in the same time coordinate system as AudioContext.currentTime. + */ + exponentialRampToValueAtTime(value: number, endTime: number): void; + + /** + * Start exponentially approaching the target value at the given time with a rate having the given time constant. Among other uses, this is useful for implementing the "decay" and "release" portions of an ADSR envelope. Please note that the parameter value does not immediately change to the target value at the given time, but instead gradually changes to the target value. + * + * More precisely, timeConstant is the time it takes a first-order linear continuous time-invariant system to reach the value 1 - 1/e (around 63.2%) given a step input response (transition from 0 to 1 value). + * + * During the time interval: T0 <= t < T1, where T0 is the startTime parameter and T1 represents the time of the event following this event (or infinity if there are no following events): + * + * v(t) = V1 + (V0 - V1) * exp(-(t - T0) / timeConstant) + * + * Where V0 is the initial value (the .value attribute) at T0 (the startTime parameter) and V1 is equal to the target parameter. + * + * @param target the value the parameter will start changing to at the given time. + * @param startTime the time in the same time coordinate system as AudioContext.currentTime. + * @param timeConstant the time-constant value of first-order filter (exponential) approach to the target value. The larger this value is, the slower the transition will be. + */ + setTargetValueAtTime(target: number, startTime: number, timeConstant: number): void; + + /** + * Sets an array of arbitrary parameter values starting at the given time for the given duration. The number of values will be scaled to fit into the desired duration. + * + * During the time interval: startTime <= t < startTime + duration, values will be calculated: + * + * v(t) = values[N * (t - startTime) / duration], where N is the length of the values array. + * + * After the end of the curve time interval (t >= startTime + duration), the value will remain constant at the final curve value, until there is another automation event (if any). + * + * @param values a Float32Array representing a parameter value curve. These values will apply starting at the given time and lasting for the given duration. + * @param startTime the time in the same time coordinate system as AudioContext.currentTime. + * @param duration the amount of time in seconds (after the time parameter) where values will be calculated according to the values parameter.. + * + */ + setValueCurveAtTime(values: Float32Array, time: number, duration: number): void; + + /** + * Cancels all scheduled parameter changes with times greater than or equal to startTime. + * + * @param startTime the starting time at and after which any previously scheduled parameter changes will be cancelled. It is a time in the same time coordinate system as AudioContext.currentTime. + */ + cancelScheduledValues(startTime: number): void; +} + +/** + * Changing the gain of an audio signal is a fundamental operation in audio applications. The GainNode is one of the building blocks for creating mixers. This interface is an AudioNode with a single input and single output: + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + * + * which multiplies the input audio signal by the (possibly time-varying) gain attribute, copying the result to the output. By default, it will take the input and pass it through to the output unchanged, which represents a constant gain change of 1. + * + * As with other AudioParams, the gain parameter represents a mapping from time (in the coordinate system of AudioContext.currentTime) to floating-point value. Every PCM audio sample in the input is multiplied by the gain parameter's value for the specific time corresponding to that audio sample. This multiplied value represents the PCM audio sample for the output. + * + * The number of channels of the output will always equal the number of channels of the input, with each channel of the input being multiplied by the gain values and being copied into the corresponding channel of the output. + * + * The implementation must make gain changes to the audio stream smoothly, without introducing noticeable clicks or glitches. This process is called "de-zippering". + */ +interface GainNode extends AudioNode { + /** + * Represents the amount of gain to apply. Its default value is 1 (no gain change). The nominal minValue is 0, but may be set negative for phase inversion. The nominal maxValue is 1, but higher values are allowed (no exception thrown).This parameter is a-rate + */ + gain: AudioParam; +} + +/** + * A delay-line is a fundamental building block in audio applications. This interface is an AudioNode with a single input and single output: + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + * + * which delays the incoming audio signal by a certain amount. The default amount is 0 seconds (no delay). When the delay time is changed, the implementation must make the transition smoothly, without introducing noticeable clicks or glitches to the audio stream. + */ +interface DelayNode extends AudioNode { + /** + * An AudioParam object representing the amount of delay (in seconds) to apply. The default value (delayTime.value) is 0 (no delay). The minimum value is 0 and the maximum value is determined by the maxDelayTime argument to the AudioContext method createDelay. This parameter is k-rate + */ + delayTime: AudioParam; +} + +/** + * This interface represents a memory-resident audio asset (for one-shot sounds and other short audio clips). Its format is non-interleaved IEEE 32-bit linear PCM with a nominal range of -1 -> +1. It can contain one or more channels. It is analogous to a WebGL texture. Typically, it would be expected that the length of the PCM data would be fairly short (usually somewhat less than a minute). For longer sounds, such as music soundtracks, streaming should be used with the audio element and MediaElementAudioSourceNode. + * + * An AudioBuffer may be used by one or more AudioContexts. + */ +interface AudioBuffer { + /** + * The sample-rate for the PCM audio data in samples per second. + * @readonly + */ + sampleRate: number; + + /** + * Length of the PCM audio data in sample-frames. + * @readonly + */ + length: number; + + /** + * Duration of the PCM audio data in seconds. + * @readonly + */ + duration: number; + + /** + * The number of discrete audio channels. + * @readonly + */ + numberOfChannels: number; + + /** + * Returns the Float32Array representing the PCM audio data for the specific channel. + * + * The channel parameter is an index representing the particular channel to get data for. An index value of 0 represents the first channel. This index value MUST be less than numberOfChannels or an exception will be thrown. + */ + getChannelData(channel: number): Float32Array; + +} + +/** + * This interface represents an audio source from an in-memory audio asset in an AudioBuffer. It generally will be used for short audio assets which require a high degree of scheduling flexibility (can playback in rhythmically perfect ways). The playback state of an AudioBufferSourceNode goes through distinct stages during its lifetime in this order: UNSCHEDULED_STATE, SCHEDULED_STATE, PLAYING_STATE, FINISHED_STATE. The start() method causes a transition from the UNSCHEDULED_STATE to SCHEDULED_STATE. Depending on the time argument passed to start(), a transition is made from the SCHEDULED_STATE to PLAYING_STATE, at which time sound is first generated. Following this, a transition from the PLAYING_STATE to FINISHED_STATE happens when either the buffer's audio data has been completely played (if the loop attribute is false), or when the stop() method has been called and the specified time has been reached. Please see more details in the start() and stop() description. Once an AudioBufferSourceNode has reached the FINISHED state it will no longer emit any sound. Thus start() and stop() may not be issued multiple times for a given AudioBufferSourceNode. + * + * numberOfInputs : 0 + * numberOfOutputs : 1 + */ +interface AudioBufferSourceNode extends AudioSourceNode { + + /** + * The playback state, initialized to UNSCHEDULED_STATE. + */ + playbackState: number; + + /** + * Represents the audio asset to be played. + */ + buffer: AudioBuffer; + + /** + * The speed at which to render the audio stream. The default playbackRate.value is 1. This parameter is a-rate + */ + playbackRate: AudioParam; + + /** + * Indicates if the audio data should play in a loop. The default value is false. + */ + loop: boolean; + + /** + * An optional value in seconds where looping should begin if the loop attribute is true. Its default value is 0, and it may usefully be set to any value between 0 and the duration of the buffer. + */ + loopStart: number; + + /** + * An optional value in seconds where looping should end if the loop attribute is true. Its default value is 0, and it may usefully be set to any value between 0 and the duration of the buffer. + */ + loopEnd: number; + + /** + * A property used to set the EventHandler for the ended event that is dispatched to AudioBufferSourceNode node types. When the playback of the buffer for an AudioBufferSourceNode is finished, an event of type Event will be dispatched to the event handler. + */ + onended: EventListener; + + /** + * Schedules a sound to playback at an exact time. + * + * @param when time (in seconds) the sound should start playing. It is in the same time coordinate system as AudioContext.currentTime. If 0 is passed in for this value or if the value is less than currentTime, then the sound will start playing immediately. start may only be called one time and must be called before stop is called or an exception will be thrown. + * @param offset the offset time in the buffer (in seconds) where playback will begin. This parameter is optional with a default value of 0 (playing back from the beginning of the buffer). + * @param duration the duration of the portion (in seconds) to be played. This parameter is optional, with the default value equal to the total duration of the AudioBuffer minus the offset parameter. Thus if neither offset nor duration are specified then the implied duration is the total duration of the AudioBuffer. + */ + start(when: number, offset?: number, duration?: number): void; + + /** + * Schedules a sound to stop playback at an exact time. Please see deprecation section for the old method name. + * + * The when parameter describes at what time (in seconds) the sound should stop playing. It is in the same time coordinate system as AudioContext.currentTime. If 0 is passed in for this value or if the value is less than currentTime, then the sound will stop playing immediately. stop must only be called one time and only after a call to start or stop, or an exception will be thrown. + */ + stop(when: number): void; +} + +/* + * This interface represents an audio source from an audio or video element. + * + * numberOfInputs : 0 + * numberOfOutputs : 1 + */ +interface MediaElementAudioSourceNode extends AudioSourceNode { +} + +/** + * This interface is an AudioNode which can generate, process, or analyse audio directly using JavaScript. + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + * + * The ScriptProcessorNode is constructed with a bufferSize which must be one of the following values: 256, 512, 1024, 2048, 4096, 8192, 16384. This value controls how frequently the onaudioprocess event handler is called and how many sample-frames need to be processed each call. Lower numbers for bufferSize will result in a lower (better) latency. Higher numbers will be necessary to avoid audio breakup and glitches. The value chosen must carefully balance between latency and audio quality. + * + * numberOfInputChannels and numberOfOutputChannels determine the number of input and output channels. It is invalid for both numberOfInputChannels and numberOfOutputChannels to be zero. + * + * var node = context.createScriptProcessor(bufferSize, numberOfInputChannels, numberOfOutputChannels); + */ +interface ScriptProcessorNode extends AudioNode { + /** + * An event listener which is called periodically for audio processing. An event of type AudioProcessingEvent will be passed to the event handler. + */ + onaudioprocess: EventListener; + + /** + * The size of the buffer (in sample-frames) which needs to be processed each time onprocessaudio is called. Legal values are (256, 512, 1024, 2048, 4096, 8192, 16384). + */ + bufferSize: number; +} + +/** + * This interface is a type of Event which is passed to the onaudioprocess event handler used by ScriptProcessorNode. + * + * The event handler processes audio from the input (if any) by accessing the audio data from the inputBuffer attribute. The audio data which is the result of the processing (or the synthesized data if there are no inputs) is then placed into the outputBuffer. + */ +interface AudioProcessingEvent extends Event { + /** + * The ScriptProcessorNode associated with this processing event. + */ + node: ScriptProcessorNode; + + /** + * The time when the audio will be played in the same time coordinate system as AudioContext.currentTime. playbackTime allows for very tight synchronization between processing directly in JavaScript with the other events in the context's rendering graph. + */ + playbackTime: number; + + /** + * An AudioBuffer containing the input audio data. It will have a number of channels equal to the numberOfInputChannels parameter of the createScriptProcessor() method. This AudioBuffer is only valid while in the scope of the onaudioprocess function. Its values will be meaningless outside of this scope. + */ + inputBuffer: AudioBuffer; + + /** + * An AudioBuffer where the output audio data should be written. It will have a number of channels equal to the numberOfOutputChannels parameter of the createScriptProcessor() method. Script code within the scope of the onaudioprocess function is expected to modify the Float32Array arrays representing channel data in this AudioBuffer. Any script modifications to this AudioBuffer outside of this scope will not produce any audible effects. + */ + outputBuffer: AudioBuffer; +} + +declare enum PanningModelType { + /** + * A simple and efficient spatialization algorithm using equal-power panning. + */ + equalpower, + + /** + * A higher quality spatialization algorithm using a convolution with measured impulse responses from human subjects. This panning method renders stereo output. + */ + HRTF, + + /** + * An algorithm which spatializes multi-channel audio using sound field algorithms. + */ + soundfield +} + +declare enum DistanceModelType { + /** + * A linear distance model which calculates distanceGain according to: + * 1 - rolloffFactor * (distance - refDistance) / (maxDistance - refDistance) + */ + linear, + + /** + * An inverse distance model which calculates distanceGain according to: + * refDistance / (refDistance + rolloffFactor * (distance - refDistance)) + */ + inverse, + + /** + * An exponential distance model which calculates distanceGain according to: + * pow(distance / refDistance, -rolloffFactor) + */ + exponential +} + +/** + * This interface represents a processing node which positions / spatializes an incoming audio stream in three-dimensional space. The spatialization is in relation to the AudioContext's AudioListener (listener attribute). + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + * + * The audio stream from the input will be either mono or stereo, depending on the connection(s) to the input. + * + * The output of this node is hard-coded to stereo (2 channels) and currently cannot be configured. + */ +interface PannerNode extends AudioNode { + /** + * Determines which spatialization algorithm will be used to position the audio in 3D space. The default is "HRTF". + */ + panningModel: PanningModelType; + + /** + * Sets the position of the audio source relative to the listener attribute. A 3D cartesian coordinate system is used. + * + * The default value is (0,0,0) + * + * @param x the x coordinates in 3D space. + * @param y the y coordinates in 3D space. + * @param z the z coordinates in 3D space. + */ + setPosition(x: number, y: number, z: number): void; + + /** + * Describes which direction the audio source is pointing in the 3D cartesian coordinate space. Depending on how directional the sound is (controlled by the cone attributes), a sound pointing away from the listener can be very quiet or completely silent. + * + * The default value is (1,0,0) + * + * @param x + * @param y + * @param z + */ + setOrientation(x: number, y: number, z: number): void; + + /** + * Sets the velocity vector of the audio source. This vector controls both the direction of travel and the speed in 3D space. This velocity relative to the listener's velocity is used to determine how much doppler shift (pitch change) to apply. The units used for this vector is meters / second and is independent of the units used for position and orientation vectors. + * + * The default value is (0,0,0) + * + * @param x a direction vector indicating direction of travel and intensity. + * @param y + * @param z + */ + setVelocity(x: number, y: number, z: number): void; + + /** + * Determines which algorithm will be used to reduce the volume of an audio source as it moves away from the listener. The default is "inverse". + */ + distanceModel: DistanceModelType; + + /** + * A reference distance for reducing volume as source move further from the listener. The default value is 1. + */ + refDistance: number; + + /** + * The maximum distance between source and listener, after which the volume will not be reduced any further. The default value is 10000. + */ + maxDistance: number; + + /** + * Describes how quickly the volume is reduced as source moves away from listener. The default value is 1. + */ + rolloffFactor: number; + + /** + * A parameter for directional audio sources, this is an angle, inside of which there will be no volume reduction. The default value is 360. + */ + coneInnerAngle: number; + + /** + * A parameter for directional audio sources, this is an angle, outside of which the volume will be reduced to a constant value of coneOuterGain. The default value is 360. + */ + coneOuterAngle: number; + + /** + * A parameter for directional audio sources, this is the amount of volume reduction outside of the coneOuterAngle. The default value is 0. + */ + coneOuterGain: number; +} + +/** + * This interface represents the position and orientation of the person listening to the audio scene. All PannerNode objects spatialize in relation to the AudioContext's listener. See this section for more details about spatialization. + */ +interface AudioListener { + /** + * A constant used to determine the amount of pitch shift to use when rendering a doppler effect. The default value is 1. + */ + dopplerFactor: number; + + /** + * The speed of sound used for calculating doppler shift. The default value is 343.3 meters / second. + */ + speedOfSound: number; + + /** + * Sets the position of the listener in a 3D cartesian coordinate space. PannerNode objects use this position relative to individual audio sources for spatialization. + * + * The default value is (0,0,0) + * + * @param x + * @param y + * @param z + */ + setPosition(x: number, y: number, z: number): void; + + /** + * Describes which direction the listener is pointing in the 3D cartesian coordinate space. Both a front vector and an up vector are provided. In simple human terms, the front vector represents which direction the person's nose is pointing. The up vector represents the direction the top of a person's head is pointing. These values are expected to be linearly independent (at right angles to each other). For normative requirements of how these values are to be interpreted, see the spatialization section. + * + * @param x x coordinate of a front direction vector in 3D space, with the default value being 0 + * @param y y coordinate of a front direction vector in 3D space, with the default value being 0 + * @param z z coordinate of a front direction vector in 3D space, with the default value being -1 + * @param xUp x coodinate of an up direction vector in 3D space, with the default value being 0 + * @param yUp y coodinate of an up direction vector in 3D space, with the default value being 1 + * @param zUp z coodinate of an up direction vector in 3D space, with the default value being 0 + */ + setOrientation(x: number, y: number, z: number, xUp: number, yUp: number, zUp: number): void; + + /** + * Sets the velocity vector of the listener. This vector controls both the direction of travel and the speed in 3D space. This velocity relative to an audio source's velocity is used to determine how much doppler shift (pitch change) to apply. The units used for this vector is meters / second and is independent of the units used for position and orientation vectors. + * + * @param x x coordinate of a direction vector indicating direction of travel and intensity. The default value is 0 + * @param y y coordinate of a direction vector indicating direction of travel and intensity. The default value is 0 + * @param z z coordinate of a direction vector indicating direction of travel and intensity. The default value is 0 + */ + setVelocity(x: number, y: number, z: number): void; +} + + +/** + * This interface represents a processing node which applies a linear convolution effect given an impulse response. Normative requirements for multi-channel convolution matrixing are described [here](http://www.w3.org/TR/2012/WD-webaudio-20121213/#Convolution-reverb-effect). + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + */ +interface ConvolverNode extends AudioNode { + /** + * A mono, stereo, or 4-channel AudioBuffer containing the (possibly multi-channel) impulse response used by the ConvolverNode. At the time when this attribute is set, the buffer and the state of the normalize attribute will be used to configure the ConvolverNode with this impulse response having the given normalization. + */ + buffer: AudioBuffer; + + /** + * Controls whether the impulse response from the buffer will be scaled by an equal-power normalization when the buffer atttribute is set. Its default value is true in order to achieve a more uniform output level from the convolver when loaded with diverse impulse responses. If normalize is set to false, then the convolution will be rendered with no pre-processing/scaling of the impulse response. Changes to this value do not take effect until the next time the buffer attribute is set. + */ + normalize: boolean; +} + +/** + * This interface represents a node which is able to provide real-time frequency and time-domain analysis information. The audio stream will be passed un-processed from input to output. + * + * numberOfInputs : 1 + * numberOfOutputs : 1 Note that this output may be left unconnected. + */ +interface AnalyserNode extends AudioNode { + /** + * Copies the current frequency data into the passed floating-point array. If the array has fewer elements than the frequencyBinCount, the excess elements will be dropped. + * @param array where frequency-domain analysis data will be copied. + */ + getFloatFrequencyData(array: any): void; + + /** + * Copies the current frequency data into the passed unsigned byte array. If the array has fewer elements than the frequencyBinCount, the excess elements will be dropped. + * @param Tarray where frequency-domain analysis data will be copied. + */ + getByteFrequencyData(array: any): void; + + /** + * Copies the current time-domain (waveform) data into the passed unsigned byte array. If the array has fewer elements than the frequencyBinCount, the excess elements will be dropped. + * @param array where time-domain analysis data will be copied. + */ + getByteTimeDomainData(array: any): void; + + /** + * The size of the FFT used for frequency-domain analysis. This must be a power of two. + */ + fftSize: number; + + /** + * Half the FFT size. + */ + frequencyBinCount: number; + + /** + * The minimum power value in the scaling range for the FFT analysis data for conversion to unsigned byte values. + */ + minDecibels: number; + + /** + * The maximum power value in the scaling range for the FFT analysis data for conversion to unsigned byte values. + */ + maxDecibels: number; + + /** + * A value from 0 -> 1 where 0 represents no time averaging with the last analysis frame. + */ + smoothingTimeConstant: number; +} + +/** + * The ChannelSplitterNode is for use in more advanced applications and would often be used in conjunction with ChannelMergerNode. + * + * numberOfInputs : 1 + * numberOfOutputs : Variable N (defaults to 6) // number of "active" (non-silent) outputs is determined by number of channels in the input + */ +interface ChannelSplitterNode extends AudioNode { +} + +/** + * The ChannelMergerNode is for use in more advanced applications and would often be used in conjunction with ChannelSplitterNode. + * + * numberOfInputs : Variable N (default to 6) // number of connected inputs may be less than this + * numberOfOutputs : 1 + */ +interface ChannelMergerNode extends AudioNode { +} + +/** + * DynamicsCompressorNode is an AudioNode processor implementing a dynamics compression effect. + * + * Dynamics compression is very commonly used in musical production and game audio. It lowers the volume of the loudest parts of the signal and raises the volume of the softest parts. Overall, a louder, richer, and fuller sound can be achieved. It is especially important in games and musical applications where large numbers of individual sounds are played simultaneous to control the overall signal level and help avoid clipping (distorting) the audio output to the speakers. + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + */ +interface DynamicsCompressorNode extends AudioNode { + /** + * The decibel value above which the compression will start taking effect. Its default value is -24, with a nominal range of -100 to 0. + */ + threshold: AudioParam; + + /** + * A decibel value representing the range above the threshold where the curve smoothly transitions to the "ratio" portion. Its default value is 30, with a nominal range of 0 to 40. + */ + knee: AudioParam; + + /** + * The amount of dB change in input for a 1 dB change in output. Its default value is 12, with a nominal range of 1 to 20. + */ + ratio: AudioParam; + + /** + * A read-only decibel value for metering purposes, representing the current amount of gain reduction that the compressor is applying to the signal. If fed no signal the value will be 0 (no gain reduction). The nominal range is -20 to 0. + */ + reduction: AudioParam; + + /** + * The amount of time (in seconds) to reduce the gain by 10dB. Its default value is 0.003, with a nominal range of 0 to 1. + */ + attack: AudioParam; + + /** + * The amount of time (in seconds) to increase the gain by 10dB. Its default value is 0.250, with a nominal range of 0 to 1. + */ + release: AudioParam; + +} + +declare enum BiquadFilterType { + /** + * A lowpass filter allows frequencies below the cutoff frequency to pass through and attenuates frequencies above the cutoff. It implements a standard second-order resonant lowpass filter with 12dB/octave rolloff. + * + * ## frequency + * The cutoff frequency + * ## Q + * Controls how peaked the response will be at the cutoff frequency. A large value makes the response more peaked. Please note that for this filter type, this value is not a traditional Q, but is a resonance value in decibels. + * ## gain + * Not used in this filter type + */ + lowpass, + + /** + * A highpass filter is the opposite of a lowpass filter. Frequencies above the cutoff frequency are passed through, but frequencies below the cutoff are attenuated. It implements a standard second-order resonant highpass filter with 12dB/octave rolloff. + * + * ## frequency + * The cutoff frequency below which the frequencies are attenuated + * ## Q + * Controls how peaked the response will be at the cutoff frequency. A large value makes the response more peaked. Please note that for this filter type, this value is not a traditional Q, but is a resonance value in decibels. + * ## gain + * Not used in this filter type + */ + highpass, + + /** + * A bandpass filter allows a range of frequencies to pass through and attenuates the frequencies below and above this frequency range. It implements a second-order bandpass filter. + * + * ## frequency + * The center of the frequency band + * ## Q + * Controls the width of the band. The width becomes narrower as the Q value increases. + * ## gain + * Not used in this filter type + */ + bandpass, + + /** + * The lowshelf filter allows all frequencies through, but adds a boost (or attenuation) to the lower frequencies. It implements a second-order lowshelf filter. + * + * ## frequency + * The upper limit of the frequences where the boost (or attenuation) is applied. + * ## Q + * Not used in this filter type. + * ## gain + * The boost, in dB, to be applied. If the value is negative, the frequencies are attenuated. + */ + lowshelf, + + /** + * The highshelf filter is the opposite of the lowshelf filter and allows all frequencies through, but adds a boost to the higher frequencies. It implements a second-order highshelf filter + * + * ## frequency + * The lower limit of the frequences where the boost (or attenuation) is applied. + * ## Q + * Not used in this filter type. + * ## gain + * The boost, in dB, to be applied. If the value is negative, the frequencies are attenuated. + */ + highshelf, + + /** + * The peaking filter allows all frequencies through, but adds a boost (or attenuation) to a range of frequencies. + * + * ## frequency + * The center frequency of where the boost is applied. + * ## Q + * Controls the width of the band of frequencies that are boosted. A large value implies a narrow width. + * ## gain + * The boost, in dB, to be applied. If the value is negative, the frequencies are attenuated. + */ + peaking, + + /** + * The notch filter (also known as a band-stop or band-rejection filter) is the opposite of a bandpass filter. It allows all frequencies through, except for a set of frequencies. + * + * ## frequency + * The center frequency of where the notch is applied. + * ## Q + * Controls the width of the band of frequencies that are attenuated. A large value implies a narrow width. + * ## gain + * Not used in this filter type. + */ + notch, + + /** + * An allpass filter allows all frequencies through, but changes the phase relationship between the various frequencies. It implements a second-order allpass filter + * + * ## frequency + * The frequency where the center of the phase transition occurs. Viewed another way, this is the frequency with maximal group delay. + * ## Q + * Controls how sharp the phase transition is at the center frequency. A larger value implies a sharper transition and a larger group delay. + * ## gain + * Not used in this filter type. + */ + allpass +} + +/** + * BiquadFilterNode is an AudioNode processor implementing very common low-order filters. + * + * Low-order filters are the building blocks of basic tone controls (bass, mid, treble), graphic equalizers, and more advanced filters. Multiple BiquadFilterNode filters can be combined to form more complex filters. The filter parameters such as "frequency" can be changed over time for filter sweeps, etc. Each BiquadFilterNode can be configured as one of a number of common filter types as shown in the IDL below. The default filter type is "lowpass" + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + * + * The filter types are briefly described below. We note that all of these filters are very commonly used in audio processing. In terms of implementation, they have all been derived from standard analog filter prototypes. For more technical details, we refer the reader to the excellent reference by Robert Bristow-Johnson. + * + * All parameters are k-rate with the following default parameter values: + * + * ## frequency + * 350Hz, with a nominal range of 10 to the Nyquist frequency (half the sample-rate). + * ## Q + * 1, with a nominal range of 0.0001 to 1000. + * ## gain + * 0, with a nominal range of -40 to 40. + */ +interface BiquadFilterNode extends AudioNode { + + type: BiquadFilterType; + frequency: AudioParam; + Q: AudioParam; + gain: AudioParam; + + /** + * Given the current filter parameter settings, calculates the frequency response for the specified frequencies. + * @param frequencyHz an array of frequencies at which the response values will be calculated. + * @param magResponse an output array receiving the linear magnitude response values. + * @param phaseResponse an output array receiving the phase response values in radians. + */ + getFrequencyResponse(frequencyHz: any, magResponse: any, phaseResponse: any): void; +} + +/** + * WaveShaperNode is an AudioNode processor implementing non-linear distortion effects. + * + * Non-linear waveshaping distortion is commonly used for both subtle non-linear warming, or more obvious distortion effects. Arbitrary non-linear shaping curves may be specified. + * + * numberOfInputs : 1 + * numberOfOutputs : 1 + */ +interface WaveShaperNode extends AudioNode { + /** + * The shaping curve used for the waveshaping effect. The input signal is nominally within the range -1 -> +1. Each input sample within this range will index into the shaping curve with a signal level of zero corresponding to the center value of the curve array. Any sample value less than -1 will correspond to the first value in the curve array. Any sample value less greater than +1 will correspond to the last value in the curve array. + */ + curve: Float32Array; +} + +declare enum OscillatorType { + sine, + square, + sawtooth, + triangle, + custom +} + +/** + * OscillatorNode represents an audio source generating a periodic waveform. It can be set to a few commonly used waveforms. Additionally, it can be set to an arbitrary periodic waveform through the use of a WaveTable object. + * + * Oscillators are common foundational building blocks in audio synthesis. An OscillatorNode will start emitting sound at the time specified by the start() method. + * + * Mathematically speaking, a continuous-time periodic waveform can have very high (or infinitely high) frequency information when considered in the frequency domain. When this waveform is sampled as a discrete-time digital audio signal at a particular sample-rate, then care must be taken to discard (filter out) the high-frequency information higher than the Nyquist frequency (half the sample-rate) before converting the waveform to a digital form. If this is not done, then aliasing of higher frequencies (than the Nyquist frequency) will fold back as mirror images into frequencies lower than the Nyquist frequency. In many cases this will cause audibly objectionable artifacts. This is a basic and well understood principle of audio DSP. + * + * There are several practical approaches that an implementation may take to avoid this aliasing. But regardless of approach, the idealized discrete-time digital audio signal is well defined mathematically. The trade-off for the implementation is a matter of implementation cost (in terms of CPU usage) versus fidelity to achieving this ideal. + * + * It is expected that an implementation will take some care in achieving this ideal, but it is reasonable to consider lower-quality, less-costly approaches on lower-end hardware. + * + * Both .frequency and .detune are a-rate parameters and are used together to determine a computedFrequency value: + * + * computedFrequency(t) = frequency(t) * pow(2, detune(t) / 1200) + * + * The OscillatorNode's instantaneous phase at each time is the time integral of computedFrequency. + * + * numberOfInputs : 0 + * numberOfOutputs : 1 (mono output) + */ +interface OscillatorNode extends AudioSourceNode { + /** + * The shape of the periodic waveform. It may directly be set to any of the type constant values except for "custom". The setWaveTable() method can be used to set a custom waveform, which results in this attribute being set to "custom". The default value is "sine". + */ + type: OscillatorType; + + /** + * defined as in AudioBufferSourceNode. + * @readonly + */ + playbackState: number; + + /** + * The frequency (in Hertz) of the periodic waveform. This parameter is a-rate + * @readonly + */ + frequency: AudioParam; + + /** + * A detuning value (in Cents) which will offset the frequency by the given amount. This parameter is a-rate + */ + detune: AudioParam; // in Cents + + /** + * defined as in AudioBufferSourceNode. + */ + start(when: number): void; + + /** + * defined as in AudioBufferSourceNode. + */ + stop(when: number): void; + + /** + * Sets an arbitrary custom periodic waveform given a WaveTable. + */ + setWaveTable(waveTable: WaveTable): void; +} + +/** + * WaveTable represents an arbitrary periodic waveform to be used with an OscillatorNode. Please see createWaveTable() and setWaveTable() and for more details. + */ +interface WaveTable { +} + +/** + * This interface represents an audio source from a MediaStream. The first AudioMediaStreamTrack from the MediaStream will be used as a source of audio. + * + * numberOfInputs : 0 + * numberOfOutputs : 1 + */ +interface MediaStreamAudioSourceNode extends AudioSourceNode { +} diff --git a/js/term.js b/js/term.js new file mode 100644 index 0000000..ddd05af --- /dev/null +++ b/js/term.js @@ -0,0 +1,97 @@ +/** Init the terminal sub-module - called from HTML */ +window.termInit = function (opts) { + let { labels, theme, allFn } = opts + + const screen = new TermScreen() + const conn = Conn(screen) + const input = Input(conn) + const termUpload = TermUpl(conn, input, screen) + screen.input = input + + conn.init() + input.init({ allFn }) + termUpload.init() + Notify.init() + + window.onerror = function (errorMsg, file, line, col) { + Notify.show(`JS ERROR!
${errorMsg}
at ${file}:${line}:${col}`, 10000, true) + return false + } + + qs('#screen').appendChild(screen.canvas) + screen.load(labels, theme) // load labels and theme + + window.initSoftKeyboard(screen, input) + if (window.attachDebugScreen) window.attachDebugScreen(screen) + + let isFullscreen = false + let fitScreen = false + let fitScreenIfNeeded = function fitScreenIfNeeded () { + if (isFullscreen) { + screen.window.fitIntoWidth = window.screen.width + screen.window.fitIntoHeight = window.screen.height + } else { + screen.window.fitIntoWidth = fitScreen ? window.innerWidth - 20 : 0 + screen.window.fitIntoHeight = fitScreen ? window.innerHeight : 0 + } + } + fitScreenIfNeeded() + window.addEventListener('resize', fitScreenIfNeeded) + + let toggleFitScreen = function () { + fitScreen = !fitScreen + const resizeButtonIcon = qs('#resize-button-icon') + if (fitScreen) { + resizeButtonIcon.classList.remove('icn-resize-small') + resizeButtonIcon.classList.add('icn-resize-full') + } else { + resizeButtonIcon.classList.remove('icn-resize-full') + resizeButtonIcon.classList.add('icn-resize-small') + } + fitScreenIfNeeded() + } + + qs('#term-fit-screen').addEventListener('click', function () { + toggleFitScreen() + return false + }) + + // add fullscreen mode & button + if (Element.prototype.requestFullscreen || Element.prototype.webkitRequestFullscreen) { + let checkForFullscreen = function () { + // document.fullscreenElement is not really supported yet, so here's a hack + if (isFullscreen && (innerWidth !== window.screen.width || innerHeight !== window.screen.height)) { + isFullscreen = false + fitScreenIfNeeded() + } + } + setInterval(checkForFullscreen, 500) + + // (why are the buttons anchors?) + let button = mk('a') + button.href = '#' + button.addEventListener('click', e => { + e.preventDefault() + + isFullscreen = true + fitScreenIfNeeded() + screen.updateSize() + + if (screen.canvas.requestFullscreen) screen.canvas.requestFullscreen() + else screen.canvas.webkitRequestFullscreen() + }) + let icon = mk('i') + icon.classList.add('icn-resize-full') // TODO: less confusing icons + button.appendChild(icon) + let span = mk('span') + span.textContent = 'Fullscreen' + button.appendChild(span) + qs('#term-nav').insertBefore(button, qs('#term-nav').firstChild) + } + + // for debugging + window.termScreen = screen + window.conn = conn + window.input = input + window.termUpl = termUpload +} diff --git a/js/term_conn.js b/js/term_conn.js new file mode 100644 index 0000000..955fee1 --- /dev/null +++ b/js/term_conn.js @@ -0,0 +1,144 @@ +/** Handle connections */ +window.Conn = function (screen) { + let ws + let heartbeatTout + let pingIv + let xoff = false + let autoXoffTout + let reconTout + + let pageShown = false + + function onOpen (evt) { + console.log('CONNECTED') + heartbeat() + doSend('i') + } + + function onClose (evt) { + console.warn('SOCKET CLOSED, code ' + evt.code + '. Reconnecting...') + clearTimeout(reconTout) + reconTout = setTimeout(function () { + init() + }, 2000) + // this happens when the buffer gets fucked up via invalid unicode. + // we basically use polling instead of socket then + } + + function onMessage (evt) { + try { + // . = heartbeat + switch (evt.data.charAt(0)) { + case '.': + // heartbeat, no-op message + break + + case '-': + // console.log('xoff'); + xoff = true + autoXoffTout = setTimeout(function () { + xoff = false + }, 250) + break + + case '+': + // console.log('xon'); + xoff = false + clearTimeout(autoXoffTout) + break + + default: + screen.load(evt.data) + if (!pageShown) { + showPage() + pageShown = true + } + break + } + heartbeat() + } catch (e) { + console.error(e) + } + } + + function canSend () { + return !xoff + } + + function doSend (message) { + if (_demo) { + if (typeof demoInterface !== 'undefined') { + demoInterface.input(message) + } else { + console.log(`TX: ${JSON.stringify(message)}`) + } + return true // Simulate success + } + if (xoff) { + // TODO queue + console.log("Can't send, flood control.") + return false + } + + if (!ws) return false // for dry testing + if (ws.readyState !== 1) { + console.error('Socket not ready') + return false + } + if (typeof message != 'string') { + message = JSON.stringify(message) + } + ws.send(message) + return true + } + + function init () { + if (window._demo) { + if (typeof demoInterface === 'undefined') { + alert('Demoing non-demo demo!') // this will catch mistakes when deploying to the website + } else { + demoInterface.init(screen) + showPage() + } + return + } + + clearTimeout(reconTout) + clearTimeout(heartbeatTout) + + ws = new WebSocket('ws://' + _root + '/term/update.ws') + ws.onopen = onOpen + ws.onclose = onClose + ws.onmessage = onMessage + console.log('Opening socket.') + heartbeat() + } + + function heartbeat () { + clearTimeout(heartbeatTout) + heartbeatTout = setTimeout(heartbeatFail, 2000) + } + + function heartbeatFail () { + console.error('Heartbeat lost, probing server...') + pingIv = setInterval(function () { + console.log('> ping') + $.get('http://' + _root + '/system/ping', function (resp, status) { + if (status === 200) { + clearInterval(pingIv) + console.info('Server ready, reloading page...') + location.reload() + } + }, { + timeout: 100 + }) + }, 1000) + } + + return { + ws: null, + init: init, + send: doSend, + canSend: canSend // check flood control + } +} diff --git a/js/term_input.js b/js/term_input.js new file mode 100644 index 0000000..e42ee35 --- /dev/null +++ b/js/term_input.js @@ -0,0 +1,303 @@ +/** + * User input + * + * --- Rx messages: --- + * S - screen content (binary encoding of the entire screen with simple compression) + * T - text labels - Title and buttons, \0x01-separated + * B - beep + * . - heartbeat + * + * --- Tx messages --- + * s - string + * b - action button + * p - mb press + * r - mb release + * m - mouse move + */ +window.Input = function (conn) { + let cfg = { + np_alt: false, + cu_alt: false, + fn_alt: false, + mt_click: false, + mt_move: false, + no_keys: false, + crlf_mode: false + } + + /** Send a literal message */ + function sendStrMsg (str) { + return conn.send('s' + str) + } + + /** Send a button event */ + function sendBtnMsg (n) { + conn.send('b' + Chr(n)) + } + + /** Fn alt choice for key message */ + function fa (alt, normal) { + return cfg.fn_alt ? alt : normal + } + + /** Cursor alt choice for key message */ + function ca (alt, normal) { + return cfg.cu_alt ? alt : normal + } + + /** Numpad alt choice for key message */ + function na (alt, normal) { + return cfg.np_alt ? alt : normal + } + + function _bindFnKeys (allFn) { + const keymap = { + 'tab': '\x09', + 'backspace': '\x08', + 'enter': cfg.crlf_mode ? '\x0d\x0a' : '\x0d', + 'ctrl+enter': '\x0a', + 'esc': '\x1b', + 'up': ca('\x1bOA', '\x1b[A'), + 'down': ca('\x1bOB', '\x1b[B'), + 'right': ca('\x1bOC', '\x1b[C'), + 'left': ca('\x1bOD', '\x1b[D'), + 'home': ca('\x1bOH', fa('\x1b[H', '\x1b[1~')), + 'insert': '\x1b[2~', + 'delete': '\x1b[3~', + 'end': ca('\x1bOF', fa('\x1b[F', '\x1b[4~')), + 'pageup': '\x1b[5~', + 'pagedown': '\x1b[6~', + 'f1': fa('\x1bOP', '\x1b[11~'), + 'f2': fa('\x1bOQ', '\x1b[12~'), + 'f3': fa('\x1bOR', '\x1b[13~'), + 'f4': fa('\x1bOS', '\x1b[14~'), + 'f5': '\x1b[15~', // note the disconnect + 'f6': '\x1b[17~', + 'f7': '\x1b[18~', + 'f8': '\x1b[19~', + 'f9': '\x1b[20~', + 'f10': '\x1b[21~', // note the disconnect + 'f11': '\x1b[23~', + 'f12': '\x1b[24~', + 'shift+f1': fa('\x1bO1;2P', '\x1b[25~'), + 'shift+f2': fa('\x1bO1;2Q', '\x1b[26~'), // note the disconnect + 'shift+f3': fa('\x1bO1;2R', '\x1b[28~'), + 'shift+f4': fa('\x1bO1;2S', '\x1b[29~'), // note the disconnect + 'shift+f5': fa('\x1b[15;2~', '\x1b[31~'), + 'shift+f6': fa('\x1b[17;2~', '\x1b[32~'), + 'shift+f7': fa('\x1b[18;2~', '\x1b[33~'), + 'shift+f8': fa('\x1b[19;2~', '\x1b[34~'), + 'shift+f9': fa('\x1b[20;2~', '\x1b[35~'), // 35-38 are not standard - but what is? + 'shift+f10': fa('\x1b[21;2~', '\x1b[36~'), + 'shift+f11': fa('\x1b[22;2~', '\x1b[37~'), + 'shift+f12': fa('\x1b[23;2~', '\x1b[38~'), + 'np_0': na('\x1bOp', '0'), + 'np_1': na('\x1bOq', '1'), + 'np_2': na('\x1bOr', '2'), + 'np_3': na('\x1bOs', '3'), + 'np_4': na('\x1bOt', '4'), + 'np_5': na('\x1bOu', '5'), + 'np_6': na('\x1bOv', '6'), + 'np_7': na('\x1bOw', '7'), + 'np_8': na('\x1bOx', '8'), + 'np_9': na('\x1bOy', '9'), + 'np_mul': na('\x1bOR', '*'), + 'np_add': na('\x1bOl', '+'), + 'np_sub': na('\x1bOS', '-'), + 'np_point': na('\x1bOn', '.'), + 'np_div': na('\x1bOQ', '/') + // we don't implement numlock key (should change in numpad_alt mode, but it's even more useless than the rest) + } + + const blacklist = [ + 'f5', 'f11', 'f12', 'shift+f5' + ] + + for (let k in keymap) { + if (!allFn && blacklist.includes(k)) continue + if (keymap.hasOwnProperty(k)) { + bind(k, keymap[k]) + } + } + } + + /** Bind a keystroke to message */ + function bind (combo, str) { + // mac fix - allow also cmd + if (combo.indexOf('ctrl+') !== -1) { + combo += ',' + combo.replace('ctrl', 'command') + } + + // unbind possible old binding + key.unbind(combo) + + key(combo, function (e) { + if (cfg.no_keys) return + e.preventDefault() + sendStrMsg(str) + }) + } + + /** Bind/rebind key messages */ + function _initKeys (opts) { + let { allFn } = opts + + // This takes care of text characters typed + window.addEventListener('keypress', function (evt) { + if (cfg.no_keys) return + let str = '' + if (evt.key) str = evt.key + else if (evt.which) str = String.fromCodePoint(evt.which) + if (str.length > 0 && str.charCodeAt(0) >= 32) { + // console.log("Typed ", str); + // prevent space from scrolling + if (evt.which === 32) evt.preventDefault() + sendStrMsg(str) + } + }) + + // ctrl-letter codes are sent as simple low ASCII codes + for (let i = 1; i <= 26; i++) { + bind('ctrl+' + String.fromCharCode(96 + i), String.fromCharCode(i)) + } + /* eslint-disable */ + bind('ctrl+]', '\x1b') // alternate way to enter ESC + bind('ctrl+\\', '\x1c') + bind('ctrl+[', '\x1d') + bind('ctrl+^', '\x1e') + bind('ctrl+_', '\x1f') + + // extra ctrl- + bind('ctrl+left', '\x1f[1;5D') + bind('ctrl+right', '\x1f[1;5C') + bind('ctrl+up', '\x1f[1;5A') + bind('ctrl+down', '\x1f[1;5B') + bind('ctrl+home', '\x1f[1;5H') + bind('ctrl+end', '\x1f[1;5F') + + // extra shift- + bind('shift+left', '\x1f[1;2D') + bind('shift+right', '\x1f[1;2C') + bind('shift+up', '\x1f[1;2A') + bind('shift+down', '\x1f[1;2B') + bind('shift+home', '\x1f[1;2H') + bind('shift+end', '\x1f[1;2F') + + // macOS editing commands + bind('⌥+left', '\x1bb') // ⌥← to go back a word (^[b) + bind('⌥+right', '\x1bf') // ⌥→ to go forward one word (^[f) + bind('⌘+left', '\x01') // ⌘← to go to the beginning of a line (^A) + bind('⌘+right', '\x05') // ⌘→ to go to the end of a line (^E) + bind('⌥+backspace', '\x17') // ⌥⌫ to delete a word (^W, I think) + bind('⌘+backspace', '\x15') // ⌘⌫ to delete to the beginning of a line (possibly ^U) + /* eslint-enable */ + + _bindFnKeys(allFn) + } + + // mouse button states + let mb1 = 0 + let mb2 = 0 + let mb3 = 0 + + /** Init the Input module */ + function init (opts) { + _initKeys(opts) + + // Button presses + $('#action-buttons button').forEach(function (s) { + s.addEventListener('click', function () { + sendBtnMsg(+this.dataset['n']) + }) + }) + + // global mouse state tracking - for motion reporting + window.addEventListener('mousedown', function (evt) { + if (evt.button === 0) mb1 = 1 + if (evt.button === 1) mb2 = 1 + if (evt.button === 2) mb3 = 1 + }) + + window.addEventListener('mouseup', function (evt) { + if (evt.button === 0) mb1 = 0 + if (evt.button === 1) mb2 = 0 + if (evt.button === 2) mb3 = 0 + }) + } + + /** Prepare modifiers byte for mouse message */ + function packModifiersForMouse () { + return (key.isModifier('ctrl') ? 1 : 0) | + (key.isModifier('shift') ? 2 : 0) | + (key.isModifier('alt') ? 4 : 0) | + (key.isModifier('meta') ? 8 : 0) + } + + return { + /** Init the Input module */ + init: init, + + /** Send a literal string message */ + sendString: sendStrMsg, + + /** Enable alternate key modes (cursors, numpad, fn) */ + setAlts: function (cu, np, fn, crlf) { + if (cfg.cu_alt !== cu || cfg.np_alt !== np || cfg.fn_alt !== fn || cfg.crlf_mode !== crlf) { + cfg.cu_alt = cu + cfg.np_alt = np + cfg.fn_alt = fn + cfg.crlf_mode = crlf + + // rebind keys - codes have changed + _bindFnKeys() + } + }, + + setMouseMode: function (click, move) { + cfg.mt_click = click + cfg.mt_move = move + }, + + // Mouse events + onMouseMove: function (x, y) { + if (!cfg.mt_move) return + const b = mb1 ? 1 : mb2 ? 2 : mb3 ? 3 : 0 + const m = packModifiersForMouse() + conn.send('m' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) + }, + + onMouseDown: function (x, y, b) { + if (!cfg.mt_click) return + if (b > 3 || b < 1) return + const m = packModifiersForMouse() + conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) + // console.log("B ",b," M ",m); + }, + + onMouseUp: function (x, y, b) { + if (!cfg.mt_click) return + if (b > 3 || b < 1) return + const m = packModifiersForMouse() + conn.send('r' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) + // console.log("B ",b," M ",m); + }, + + onMouseWheel: function (x, y, dir) { + if (!cfg.mt_click) return + // -1 ... btn 4 (away from user) + // +1 ... btn 5 (towards user) + const m = packModifiersForMouse() + const b = (dir < 0 ? 4 : 5) + conn.send('p' + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)) + // console.log("B ",b," M ",m); + }, + + mouseTracksClicks: function () { + return cfg.mt_click + }, + + blockKeys: function (yes) { + cfg.no_keys = yes + } + } +} diff --git a/js/term_screen.js b/js/term_screen.js new file mode 100644 index 0000000..9573df4 --- /dev/null +++ b/js/term_screen.js @@ -0,0 +1,1369 @@ +// Some non-bold Fraktur symbols are outside the contiguous block +const frakturExceptions = { + 'C': '\u212d', + 'H': '\u210c', + 'I': '\u2111', + 'R': '\u211c', + 'Z': '\u2128' +} + +// constants for decoding the update blob +const SEQ_REPEAT = 2 +const SEQ_SET_COLORS = 3 +const SEQ_SET_ATTRS = 4 +const SEQ_SET_FG = 5 +const SEQ_SET_BG = 6 + +const SELECTION_BG = '#b2d7fe' +const SELECTION_FG = '#333' + +const themes = [ + [ // Tango + '#111213', '#CC0000', '#4E9A06', '#C4A000', '#3465A4', '#75507B', '#06989A', '#D3D7CF', + '#555753', '#EF2929', '#8AE234', '#FCE94F', '#729FCF', '#AD7FA8', '#34E2E2', '#EEEEEC' + ], + [ // Linux + '#000000', '#aa0000', '#00aa00', '#aa5500', '#0000aa', '#aa00aa', '#00aaaa', '#aaaaaa', + '#555555', '#ff5555', '#55ff55', '#ffff55', '#5555ff', '#ff55ff', '#55ffff', '#ffffff' + ], + [ // xterm + '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000ee', '#cd00cd', '#00cdcd', '#e5e5e5', + '#7f7f7f', '#ff0000', '#00ff00', '#ffff00', '#5c5cff', '#ff00ff', '#00ffff', '#ffffff' + ], + [ // rxvt + '#000000', '#cd0000', '#00cd00', '#cdcd00', '#0000cd', '#cd00cd', '#00cdcd', '#faebd7', + '#404040', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff' + ], + [ // Ambience + '#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf', + '#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec' + ], + [ // Solarized + '#073642', '#dc322f', '#859900', '#b58900', '#268bd2', '#d33682', '#2aa198', '#eee8d5', + '#002b36', '#cb4b16', '#586e75', '#657b83', '#839496', '#6c71c4', '#93a1a1', '#fdf6e3' + ] +] + +// TODO move this to the initializer so it's not run on non-terminal pages + +// 256color lookup table +// should not be used to look up 0-15 (will return transparent) +const colorTable256 = new Array(16).fill('rgba(0, 0, 0, 0)') + +// fill color table +// colors 16-231 are a 6x6x6 color cube +for (let red = 0; red < 6; red++) { + for (let green = 0; green < 6; green++) { + for (let blue = 0; blue < 6; blue++) { + let redValue = red * 40 + (red ? 55 : 0) + let greenValue = green * 40 + (green ? 55 : 0) + let blueValue = blue * 40 + (blue ? 55 : 0) + colorTable256.push(`rgb(${redValue}, ${greenValue}, ${blueValue})`) + } + } +} +// colors 232-255 are a grayscale ramp, sans black and white +for (let gray = 0; gray < 24; gray++) { + let value = gray * 10 + 8 + colorTable256.push(`rgb(${value}, ${value}, ${value})`) +} + +window.TermScreen = class TermScreen { + constructor () { + this.canvas = mk('canvas') + this.ctx = this.canvas.getContext('2d') + + if ('AudioContext' in window || 'webkitAudioContext' in window) { + this.audioCtx = new (window.AudioContext || window.webkitAudioContext)() + } else { + console.warn('No AudioContext!') + } + + // dummy + this.input = new Proxy({}, { + get () { + return () => console.warn('TermScreen#input not set!') + } + }) + + this.cursor = { + x: 0, + y: 0, + blinkOn: false, + blinking: true, + visible: true, + hanging: false, + style: 'block', + blinkInterval: null + } + + this._palette = null + + this._window = { + width: 0, + height: 0, + devicePixelRatio: 1, + fontFamily: '"DejaVu Sans Mono", "Liberation Mono", "Inconsolata", "Menlo", monospace', + fontSize: 20, + gridScaleX: 1.0, + gridScaleY: 1.2, + blinkStyleOn: true, + blinkInterval: null, + fitIntoWidth: 0, + fitIntoHeight: 0, + debug: false, + graphics: 0 + } + + // scaling caused by fitIntoWidth/fitIntoHeight + this._windowScale = 1 + + // properties of this.window that require updating size and redrawing + this.windowState = { + width: 0, + height: 0, + devicePixelRatio: 0, + gridScaleX: 0, + gridScaleY: 0, + fontFamily: '', + fontSize: 0, + fitIntoWidth: 0, + fitIntoHeight: 0 + } + + // current selection + this.selection = { + // when false, this will prevent selection in favor of mouse events, + // though alt can be held to override it + selectable: true, + + // selection start and end (x, y) tuples + start: [0, 0], + end: [0, 0] + } + + // mouse features + this.mouseMode = { clicks: false, movement: false } + + // event listeners + this._listeners = {} + + // make writing to window update size and draw + const self = this + this.window = new Proxy(this._window, { + set (target, key, value, receiver) { + target[key] = value + self.scheduleSizeUpdate() + self.scheduleDraw(`window:${key}=${value}`) + return true + } + }) + + this.bracketedPaste = false + this.blinkingCellCount = 0 + + this.screen = [] + this.screenFG = [] + this.screenBG = [] + this.screenAttrs = [] + + // used to determine if a cell should be redrawn; storing the current state + // as it is on screen + this.drawnScreen = [] + this.drawnScreenFG = [] + this.drawnScreenBG = [] + this.drawnScreenAttrs = [] + this.drawnCursor = [-1, -1, ''] + + // start blink timers + this.resetBlink() + this.resetCursorBlink() + + let selecting = false + + let selectStart = (x, y) => { + if (selecting) return + selecting = true + this.selection.start = this.selection.end = this.screenToGrid(x, y) + this.scheduleDraw('select-start') + } + + let selectMove = (x, y) => { + if (!selecting) return + this.selection.end = this.screenToGrid(x, y) + this.scheduleDraw('select-move') + } + + let selectEnd = (x, y) => { + if (!selecting) return + selecting = false + this.selection.end = this.screenToGrid(x, y) + this.scheduleDraw('select-end') + Object.assign(this.selection, this.getNormalizedSelection()) + } + + // bind event listeners + + this.canvas.addEventListener('mousedown', e => { + if ((this.selection.selectable || e.altKey) && e.button === 0) { + selectStart(e.offsetX, e.offsetY) + } else { + this.input.onMouseDown(...this.screenToGrid(e.offsetX, e.offsetY), + e.button + 1) + } + }) + + window.addEventListener('mousemove', e => { + selectMove(e.offsetX, e.offsetY) + }) + + window.addEventListener('mouseup', e => { + selectEnd(e.offsetX, e.offsetY) + }) + + // touch event listeners + + let touchPosition = null + let touchDownTime = 0 + let touchSelectMinTime = 500 + let touchDidMove = false + + let getTouchPositionOffset = touch => { + let rect = this.canvas.getBoundingClientRect() + return [touch.clientX - rect.left, touch.clientY - rect.top] + } + + this.canvas.addEventListener('touchstart', e => { + touchPosition = getTouchPositionOffset(e.touches[0]) + touchDidMove = false + touchDownTime = Date.now() + }) + + this.canvas.addEventListener('touchmove', e => { + touchPosition = getTouchPositionOffset(e.touches[0]) + + if (!selecting && touchDidMove === false) { + if (touchDownTime < Date.now() - touchSelectMinTime) { + selectStart(...touchPosition) + } + } else if (selecting) { + e.preventDefault() + selectMove(...touchPosition) + } + + touchDidMove = true + }) + + this.canvas.addEventListener('touchend', e => { + if (e.touches[0]) { + touchPosition = getTouchPositionOffset(e.touches[0]) + } + + if (selecting) { + e.preventDefault() + selectEnd(...touchPosition) + + // selection ended; show touch select menu + let touchSelectMenu = qs('#touch-select-menu') + touchSelectMenu.classList.add('open') + let rect = touchSelectMenu.getBoundingClientRect() + + // use middle position for x and one line above for y + let selectionPos = this.gridToScreen( + (this.selection.start[0] + this.selection.end[0]) / 2, + this.selection.start[1] - 1 + ) + selectionPos[0] -= rect.width / 2 + selectionPos[1] -= rect.height / 2 + touchSelectMenu.style.transform = `translate(${selectionPos[0]}px, ${ + selectionPos[1]}px)` + } + + if (!touchDidMove) { + this.emit('tap', Object.assign(e, { + x: touchPosition[0], + y: touchPosition[1] + })) + } + + touchPosition = null + }) + + this.on('tap', e => { + if (this.selection.start[0] !== this.selection.end[0] || + this.selection.start[1] !== this.selection.end[1]) { + // selection is not empty + // reset selection + this.selection.start = this.selection.end = [0, 0] + qs('#touch-select-menu').classList.remove('open') + this.scheduleDraw('select-reset') + } else { + e.preventDefault() + this.emit('open-soft-keyboard') + } + }) + + $.ready(() => { + let copyButton = qs('#touch-select-copy-btn') + if (copyButton) { + copyButton.addEventListener('click', () => { + this.copySelectionToClipboard() + }) + } + }) + + this.canvas.addEventListener('mousemove', e => { + if (!selecting) { + this.input.onMouseMove(...this.screenToGrid(e.offsetX, e.offsetY)) + } + }) + + this.canvas.addEventListener('mouseup', e => { + if (!selecting) { + this.input.onMouseUp(...this.screenToGrid(e.offsetX, e.offsetY), + e.button + 1) + } + }) + + this.canvas.addEventListener('wheel', e => { + if (this.mouseMode.clicks) { + this.input.onMouseWheel(...this.screenToGrid(e.offsetX, e.offsetY), + e.deltaY > 0 ? 1 : -1) + + // prevent page scrolling + e.preventDefault() + } + }) + + this.canvas.addEventListener('contextmenu', e => { + if (this.mouseMode.clicks) { + // prevent mouse keys getting stuck + e.preventDefault() + } + selectEnd(e.offsetX, e.offsetY) + }) + + // bind ctrl+shift+c to copy + key('⌃+⇧+c', e => { + e.preventDefault() + this.copySelectionToClipboard() + }) + } + + /** + * Bind an event listener to an event + * @param {string} event - the event name + * @param {Function} listener - the event listener + */ + on (event, listener) { + if (!this._listeners[event]) this._listeners[event] = [] + this._listeners[event].push({ listener }) + } + + /** + * Bind an event listener to be run only once the next time the event fires + * @param {string} event - the event name + * @param {Function} listener - the event listener + */ + once (event, listener) { + if (!this._listeners[event]) this._listeners[event] = [] + this._listeners[event].push({ listener, once: true }) + } + + /** + * Remove an event listener + * @param {string} event - the event name + * @param {Function} listener - the event listener + */ + off (event, listener) { + let listeners = this._listeners[event] + if (listeners) { + for (let i in listeners) { + if (listeners[i].listener === listener) { + listeners.splice(i, 1) + break + } + } + } + } + + /** + * Emits an event + * @param {string} event - the event name + * @param {...any} args - arguments passed to all listeners + */ + emit (event, ...args) { + let listeners = this._listeners[event] + if (listeners) { + let remove = [] + for (let listener of listeners) { + try { + listener.listener(...args) + if (listener.once) remove.push(listener) + } catch (err) { + console.error(err) + } + } + + // this needs to be done in this roundabout way because for loops + // do not like arrays with changing lengths + for (let listener of remove) { + listeners.splice(listeners.indexOf(listener), 1) + } + } + } + + /** + * The color palette. Should define 16 colors in an array. + * @type {number[]} + */ + get palette () { + return this._palette || themes[0] + } + /** @type {number[]} */ + set palette (palette) { + if (this._palette !== palette) { + this._palette = palette + this.scheduleDraw('palette') + } + } + + /** + * Returns the specified color. If `i` is in the palette, it will return the + * palette color. If `i` is between 16 and 255, it will return the 256color + * value. If `i` is larger than 255, it will return an RGB color value. If `i` + * is -1 (foreground) or -2 (background), it will return the selection colors. + * @param {number} i - the color + * @returns {string} the CSS color + */ + getColor (i) { + // return palette color if it exists + if (this.palette[i]) return this.palette[i] + + // -1 for selection foreground, -2 for selection background + if (i === -1) return SELECTION_FG + if (i === -2) return SELECTION_BG + + // 256 color + if (i > 15 && i < 256) return colorTable256[i] + + // true color, encoded as (hex) + 256 (such that #000 == 256) + if (i > 255) { + i -= 256 + let red = (i >> 16) & 0xFF + let green = (i >> 8) & 0xFF + let blue = i & 0xFF + return `rgb(${red}, ${green}, ${blue})` + } + + // default to transparent + return 'rgba(0, 0, 0, 0)' + } + + /** + * Schedule a size update in the next millisecond + */ + scheduleSizeUpdate () { + clearTimeout(this._scheduledSizeUpdate) + this._scheduledSizeUpdate = setTimeout(() => this.updateSize(), 1) + } + + /** + * Schedule a draw in the next millisecond + * @param {string} why - the reason why the draw occured (for debugging) + * @param {number} [aggregateTime] - time to wait for more scheduleDraw calls + * to occur. 1 ms by default. + */ + scheduleDraw (why, aggregateTime = 1) { + clearTimeout(this._scheduledDraw) + this._scheduledDraw = setTimeout(() => this.draw(why), aggregateTime) + } + + /** + * Returns a CSS font string with this TermScreen's font settings and the + * font modifiers. + * @param {Object} modifiers + * @param {string} [modifiers.style] - the font style + * @param {string} [modifiers.weight] - the font weight + * @returns {string} a CSS font string + */ + getFont (modifiers = {}) { + let fontStyle = modifiers.style || 'normal' + let fontWeight = modifiers.weight || 'normal' + return `${fontStyle} normal ${fontWeight} ${this.window.fontSize}px ${this.window.fontFamily}` + } + + /** + * The character size, used for calculating the cell size. The space character + * is used for measuring. + * @returns {Object} the character size with `width` and `height` in pixels + */ + getCharSize () { + this.ctx.font = this.getFont() + + return { + width: Math.floor(this.ctx.measureText(' ').width), + height: this.window.fontSize + } + } + + /** + * The cell size, which is the character size multiplied by the grid scale. + * @returns {Object} the cell size with `width` and `height` in pixels + */ + getCellSize () { + let charSize = this.getCharSize() + + return { + width: Math.ceil(charSize.width * this.window.gridScaleX), + height: Math.ceil(charSize.height * this.window.gridScaleY) + } + } + + /** + * Updates the canvas size if it changed + */ + updateSize () { + this._window.devicePixelRatio = this._windowScale * (window.devicePixelRatio || 1) + + let didChange = false + for (let key in this.windowState) { + if (this.windowState.hasOwnProperty(key) && this.windowState[key] !== this.window[key]) { + didChange = true + this.windowState[key] = this.window[key] + } + } + + if (didChange) { + const { + width, + height, + gridScaleX, + gridScaleY, + fitIntoWidth, + fitIntoHeight + } = this.window + const cellSize = this.getCellSize() + + // real height of the canvas element in pixels + let realWidth = width * cellSize.width + let realHeight = height * cellSize.height + + if (fitIntoWidth && fitIntoHeight) { + let terminalAspect = realWidth / realHeight + let fitAspect = fitIntoWidth / fitIntoHeight + + if (terminalAspect < fitAspect) { + // align heights + realHeight = fitIntoHeight + realWidth = realHeight * terminalAspect + } else { + // align widths + realWidth = fitIntoWidth + realHeight = realWidth / terminalAspect + } + } else if (fitIntoWidth) { + realHeight = fitIntoWidth / (realWidth / realHeight) + realWidth = fitIntoWidth + } else if (fitIntoHeight) { + realWidth = fitIntoHeight * (realWidth / realHeight) + realHeight = fitIntoHeight + } + + // store new window scale + this._windowScale = realWidth / (width * cellSize.width) + + let devicePixelRatio = this._window.devicePixelRatio = this._windowScale * window.devicePixelRatio + + this.canvas.width = width * devicePixelRatio * cellSize.width + this.canvas.style.width = `${realWidth}px` + this.canvas.height = height * devicePixelRatio * cellSize.height + this.canvas.style.height = `${realHeight}px` + + // the screen has been cleared (by changing canvas width) + this.drawnScreen = [] + this.drawnScreenFG = [] + this.drawnScreenBG = [] + this.drawnScreenAttrs = [] + + // draw immediately; the canvas shouldn't flash + this.draw('init') + } + } + + /** + * Resets the cursor blink to on and restarts the timer + */ + resetCursorBlink () { + this.cursor.blinkOn = true + clearInterval(this.cursor.blinkInterval) + this.cursor.blinkInterval = setInterval(() => { + this.cursor.blinkOn = this.cursor.blinking + ? !this.cursor.blinkOn + : true + if (this.cursor.blinking) this.scheduleDraw('cursor-blink') + }, 500) + } + + /** + * Resets the blink style to on and restarts the timer + */ + resetBlink () { + this.window.blinkStyleOn = true + clearInterval(this.window.blinkInterval) + let intervals = 0 + this.window.blinkInterval = setInterval(() => { + if (this.blinkingCellCount <= 0) return + + intervals++ + if (intervals >= 4 && this.window.blinkStyleOn) { + this.window.blinkStyleOn = false + intervals = 0 + } else if (intervals >= 1 && !this.window.blinkStyleOn) { + this.window.blinkStyleOn = true + intervals = 0 + } + }, 200) + } + + /** + * Returns a normalized version of the current selection, such that `start` + * is always before `end`. + * @returns {Object} the normalized selection, with `start` and `end` + */ + getNormalizedSelection () { + let { start, end } = this.selection + // if the start line is after the end line, or if they're both on the same + // line but the start column comes after the end column, swap + if (start[1] > end[1] || (start[1] === end[1] && start[0] > end[0])) { + [start, end] = [end, start] + } + return { start, end } + } + + /** + * Returns whether or not a given cell is in the current selection. + * @param {number} col - the column (x) + * @param {number} line - the line (y) + * @returns {boolean} + */ + isInSelection (col, line) { + let { start, end } = this.getNormalizedSelection() + let colAfterStart = start[0] <= col + let colBeforeEnd = col < end[0] + let onStartLine = line === start[1] + let onEndLine = line === end[1] + + if (onStartLine && onEndLine) return colAfterStart && colBeforeEnd + else if (onStartLine) return colAfterStart + else if (onEndLine) return colBeforeEnd + else return start[1] < line && line < end[1] + } + + /** + * Sweeps for selected cells and joins them in a multiline string. + * @returns {string} the selection + */ + getSelectedText () { + const screenLength = this.window.width * this.window.height + let lines = [] + let previousLineIndex = -1 + + for (let cell = 0; cell < screenLength; cell++) { + let x = cell % this.window.width + let y = Math.floor(cell / this.window.width) + + if (this.isInSelection(x, y)) { + if (previousLineIndex !== y) { + previousLineIndex = y + lines.push('') + } + lines[lines.length - 1] += this.screen[cell] + } + } + + return lines.join('\n') + } + + /** + * Copies the selection to clipboard and creates a notification balloon. + */ + copySelectionToClipboard () { + let selectedText = this.getSelectedText() + // don't copy anything if nothing is selected + if (!selectedText) return + let textarea = mk('textarea') + document.body.appendChild(textarea) + textarea.value = selectedText + textarea.select() + if (document.execCommand('copy')) { + Notify.show('Copied to clipboard') + } else { + Notify.show('Failed to copy') + } + document.body.removeChild(textarea) + } + + /** + * Converts screen coordinates to grid coordinates. + * @param {number} x - x in pixels + * @param {number} y - y in pixels + * @returns {number[]} a tuple of (x, y) in cells + */ + screenToGrid (x, y) { + let cellSize = this.getCellSize() + + return [ + Math.floor((x + cellSize.width / 2) / cellSize.width), + Math.floor(y / cellSize.height) + ] + } + + /** + * Converts grid coordinates to screen coordinates. + * @param {number} x - x in cells + * @param {number} y - y in cells + * @param {boolean} [withScale] - when true, will apply window scale + * @returns {number[]} a tuple of (x, y) in pixels + */ + gridToScreen (x, y, withScale = false) { + let cellSize = this.getCellSize() + + return [x * cellSize.width, y * cellSize.height].map(v => withScale ? v * this._windowScale : v) + } + + /** + * Draws a cell's background with the given parameters. + * @param {Object} options + * @param {number} options.x - x in cells + * @param {number} options.y - y in cells + * @param {number} options.cellWidth - cell width in pixels + * @param {number} options.cellHeight - cell height in pixels + * @param {number} options.bg - the background color + */ + drawCellBackground ({ x, y, cellWidth, cellHeight, bg }) { + const ctx = this.ctx + ctx.fillStyle = this.getColor(bg) + ctx.clearRect(x * cellWidth, y * cellHeight, Math.ceil(cellWidth), Math.ceil(cellHeight)) + ctx.fillRect(x * cellWidth, y * cellHeight, Math.ceil(cellWidth), Math.ceil(cellHeight)) + } + + /** + * Draws a cell's character with the given parameters. Won't do anything if + * text is an empty string. + * @param {Object} options + * @param {number} options.x - x in cells + * @param {number} options.y - y in cells + * @param {Object} options.charSize - the character size, an object with + * `width` and `height` in pixels + * @param {number} options.cellWidth - cell width in pixels + * @param {number} options.cellHeight - cell height in pixels + * @param {string} options.text - the cell content + * @param {number} options.fg - the foreground color + * @param {number} options.attrs - the cell's attributes + */ + drawCell ({ x, y, charSize, cellWidth, cellHeight, text, fg, attrs }) { + if (!text) return + + const ctx = this.ctx + + let underline = false + let strike = false + let overline = false + if (attrs & (1 << 1)) ctx.globalAlpha = 0.5 + if (attrs & (1 << 3)) underline = true + if (attrs & (1 << 5)) text = TermScreen.alphaToFraktur(text) + if (attrs & (1 << 6)) strike = true + if (attrs & (1 << 7)) overline = true + + ctx.fillStyle = this.getColor(fg) + ctx.fillText(text, (x + 0.5) * cellWidth, (y + 0.5) * cellHeight) + + if (underline || strike || overline) { + ctx.strokeStyle = this.getColor(fg) + ctx.lineWidth = 1 + ctx.lineCap = 'round' + ctx.beginPath() + + if (underline) { + let lineY = Math.round(y * cellHeight + charSize.height) + 0.5 + ctx.moveTo(x * cellWidth, lineY) + ctx.lineTo((x + 1) * cellWidth, lineY) + } + + if (strike) { + let lineY = Math.round((y + 0.5) * cellHeight) + 0.5 + ctx.moveTo(x * cellWidth, lineY) + ctx.lineTo((x + 1) * cellWidth, lineY) + } + + if (overline) { + let lineY = Math.round(y * cellHeight) + 0.5 + ctx.moveTo(x * cellWidth, lineY) + ctx.lineTo((x + 1) * cellWidth, lineY) + } + + ctx.stroke() + } + + ctx.globalAlpha = 1 + } + + /** + * Returns all adjacent cell indices given a radius. + * @param {number} cell - the center cell index + * @param {number} [radius] - the radius. 1 by default + * @returns {number[]} an array of cell indices + */ + getAdjacentCells (cell, radius = 1) { + const { width, height } = this.window + const screenLength = width * height + + let cells = [] + + for (let x = -radius; x <= radius; x++) { + for (let y = -radius; y <= radius; y++) { + if (x === 0 && y === 0) continue + cells.push(cell + x + y * width) + } + } + + return cells.filter(cell => cell >= 0 && cell < screenLength) + } + + /** + * Updates the screen. + * @param {string} why - the draw reason (for debugging) + */ + draw (why) { + const ctx = this.ctx + const { + width, + height, + devicePixelRatio, + gridScaleX, + gridScaleY + } = this.window + + const charSize = this.getCharSize() + const { width: cellWidth, height: cellHeight } = this.getCellSize() + const screenWidth = width * cellWidth + const screenHeight = height * cellHeight + const screenLength = width * height + + ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0) + + if (this.window.debug && this._debug) this._debug.drawStart(why) + + ctx.font = this.getFont() + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + + // bits in the attr value that affect the font + const FONT_MASK = 0b101 + + // Map of (attrs & FONT_MASK) -> Array of cell indices + let fontGroups = new Map() + + // Map of (cell index) -> boolean, whether or not a cell has updated + let updateMap = new Map() + + for (let cell = 0; cell < screenLength; cell++) { + let x = cell % width + let y = Math.floor(cell / width) + let isCursor = !this.cursor.hanging && + this.cursor.x === x && + this.cursor.y === y && + this.cursor.blinkOn && + this.cursor.visible + + let wasCursor = x === this.drawnCursor[0] && y === this.drawnCursor[1] + + let inSelection = this.isInSelection(x, y) + + let text = this.screen[cell] + let fg = this.screenFG[cell] + let bg = this.screenBG[cell] + let attrs = this.screenAttrs[cell] + + if (attrs & (1 << 4) && !this.window.blinkStyleOn) { + // blinking is enabled and blink style is off + // set text to nothing so drawCell doesn't draw anything + text = '' + } + + if (inSelection) { + fg = -1 + bg = -2 + } + + let didUpdate = text !== this.drawnScreen[cell] || + fg !== this.drawnScreenFG[cell] || + bg !== this.drawnScreenBG[cell] || + attrs !== this.drawnScreenAttrs[cell] || + isCursor !== wasCursor || + (isCursor && this.cursor.style !== this.drawnCursor[2]) + + let font = attrs & FONT_MASK + if (!fontGroups.has(font)) fontGroups.set(font, []) + + fontGroups.get(font).push([cell, x, y, text, fg, bg, attrs, isCursor, inSelection]) + updateMap.set(cell, didUpdate) + } + + // Map of (cell index) -> boolean, whether or not a cell should be redrawn + const redrawMap = new Map() + + let isTextWide = text => + text !== ' ' && ctx.measureText(text).width >= (cellWidth + 0.05) + + // decide for each cell if it should be redrawn + let updateRedrawMapAt = cell => { + let shouldUpdate = updateMap.get(cell) || redrawMap.get(cell) + + // TODO: fonts (necessary?) + let text = this.screen[cell] + let isWideCell = isTextWide(text) + let checkRadius = isWideCell ? 2 : 1 + + if (!shouldUpdate) { + // check adjacent cells + let adjacentDidUpdate = false + + for (let adjacentCell of this.getAdjacentCells(cell, checkRadius)) { + if (updateMap.get(adjacentCell)) { + adjacentDidUpdate = true + break + } + } + + if (adjacentDidUpdate) shouldUpdate = true + } + + redrawMap.set(cell, shouldUpdate) + } + + for (let cell of updateMap.keys()) updateRedrawMapAt(cell) + + // mask to redrawing regions only + if (this.window.graphics >= 1) { + ctx.save() + ctx.beginPath() + for (let y = 0; y < height; y++) { + let regionStart = null + for (let x = 0; x < width; x++) { + let cell = y * width + x + let redrawing = redrawMap.get(cell) + if (redrawing && regionStart === null) regionStart = x + if (!redrawing && regionStart !== null) { + ctx.rect(regionStart * cellWidth, y * cellHeight, (x - regionStart) * cellWidth, cellHeight) + regionStart = null + } + } + if (regionStart !== null) { + ctx.rect(regionStart * cellWidth, y * cellHeight, (width - regionStart) * cellWidth, cellHeight) + } + } + ctx.clip() + } + + // pass 1: backgrounds + for (let font of fontGroups.keys()) { + for (let data of fontGroups.get(font)) { + let [cell, x, y, text, fg, bg, attrs, isCursor] = data + + if (redrawMap.get(cell)) { + this.drawCellBackground({ x, y, cellWidth, cellHeight, bg }) + } + } + } + + // pass 2: characters + for (let font of fontGroups.keys()) { + // set font once because in Firefox, this is a really slow action for some + // reason + let modifiers = {} + if (font & 1) modifiers.weight = 'bold' + if (font & 1 << 2) modifiers.style = 'italic' + ctx.font = this.getFont(modifiers) + + for (let data of fontGroups.get(font)) { + let [cell, x, y, text, fg, bg, attrs, isCursor, inSelection] = data + + if (redrawMap.get(cell)) { + this.drawCell({ + x, y, charSize, cellWidth, cellHeight, text, fg, attrs + }) + + this.drawnScreen[cell] = text + this.drawnScreenFG[cell] = fg + this.drawnScreenBG[cell] = bg + this.drawnScreenAttrs[cell] = attrs + + if (isCursor) this.drawnCursor = [x, y, this.cursor.style] + + if (this.window.debug && this._debug) { + // set cell flags + let flags = 1 // always redrawn + flags |= (+updateMap.get(cell)) << 1 + flags |= (+isTextWide(text)) << 2 + this._debug.setCell(cell, flags) + } + } + + if (isCursor && !inSelection) { + ctx.save() + ctx.beginPath() + if (this.cursor.style === 'block') { + // block + ctx.rect(x * cellWidth, y * cellHeight, cellWidth, cellHeight) + } else if (this.cursor.style === 'bar') { + // vertical bar + let barWidth = 2 + ctx.rect(x * cellWidth, y * cellHeight, barWidth, cellHeight) + } else if (this.cursor.style === 'line') { + // underline + let lineHeight = 2 + ctx.rect(x * cellWidth, y * cellHeight + charSize.height, cellWidth, lineHeight) + } + ctx.clip() + + // swap foreground/background + ;[fg, bg] = [bg, fg] + + // HACK: ensure cursor is visible + if (fg === bg) bg = fg === 0 ? 7 : 0 + + this.drawCellBackground({ x, y, cellWidth, cellHeight, bg }) + this.drawCell({ + x, y, charSize, cellWidth, cellHeight, text, fg, attrs + }) + ctx.restore() + } + } + } + + if (this.window.graphics >= 1) ctx.restore() + + if (this.window.debug && this._debug) this._debug.drawEnd() + } + + /** + * Parses the content of an `S` message and schedules a draw + * @param {string} str - the message content + */ + loadContent (str) { + // current index + let i = 0 + // Uncomment to capture screen content for the demo page + // console.log(JSON.stringify(`S${str}`)) + + // window size + const newHeight = parse2B(str, i) + const newWidth = parse2B(str, i + 2) + const resized = (this.window.height !== newHeight) || (this.window.width !== newWidth) + this.window.height = newHeight + this.window.width = newWidth + i += 4 + + // cursor position + let [cursorY, cursorX] = [parse2B(str, i), parse2B(str, i + 2)] + i += 4 + let cursorMoved = (cursorX !== this.cursor.x || cursorY !== this.cursor.y) + this.cursor.x = cursorX + this.cursor.y = cursorY + + if (cursorMoved) { + this.resetCursorBlink() + this.emit('cursor-moved') + } + + // attributes + let attributes = parse3B(str, i) + i += 3 + + this.cursor.visible = !!(attributes & 1) + this.cursor.hanging = !!(attributes & (1 << 1)) + + this.input.setAlts( + !!(attributes & (1 << 2)), // cursors alt + !!(attributes & (1 << 3)), // numpad alt + !!(attributes & (1 << 4)), // fn keys alt + !!(attributes & (1 << 12)) // crlf mode + ) + + let trackMouseClicks = !!(attributes & (1 << 5)) + let trackMouseMovement = !!(attributes & (1 << 6)) + + // 0 - Block blink 2 - Block steady (1 is unused) + // 3 - Underline blink 4 - Underline steady + // 5 - I-bar blink 6 - I-bar steady + let cursorShape = (attributes >> 9) & 0x07 + + // if it's not zero, decrement such that the two most significant bits + // are the type and the least significant bit is the blink state + if (cursorShape > 0) cursorShape-- + + let cursorStyle = cursorShape >> 1 + let cursorBlinking = !(cursorShape & 1) + + if (cursorStyle === 0) this.cursor.style = 'block' + else if (cursorStyle === 1) this.cursor.style = 'line' + else if (cursorStyle === 2) this.cursor.style = 'bar' + + if (this.cursor.blinking !== cursorBlinking) { + this.cursor.blinking = cursorBlinking + this.resetCursorBlink() + } + + this.input.setMouseMode(trackMouseClicks, trackMouseMovement) + this.selection.selectable = !trackMouseMovement + $(this.canvas).toggleClass('selectable', !trackMouseMovement) + this.mouseMode = { + clicks: trackMouseClicks, + movement: trackMouseMovement + } + + let showButtons = !!(attributes & (1 << 7)) + let showConfigLinks = !!(attributes & (1 << 8)) + + $('.x-term-conf-btn').toggleClass('hidden', !showConfigLinks) + $('#action-buttons').toggleClass('hidden', !showButtons) + + this.bracketedPaste = !!(attributes & (1 << 13)) + + // content + let fg = 7 + let bg = 0 + let attrs = 0 + let cell = 0 // cell index + let lastChar = ' ' + let screenLength = this.window.width * this.window.height + + if (resized) { + this.updateSize() + this.blinkingCellCount = 0 + this.screen = new Array(screenLength).fill(' ') + this.screenFG = new Array(screenLength).fill(' ') + this.screenBG = new Array(screenLength).fill(' ') + this.screenAttrs = new Array(screenLength).fill(' ') + } + + let bgcount = new Array(256).fill(0) + + let strArray = !undef(Array.from) ? Array.from(str) : str.split('') + + const MASK_LINE_ATTR = 0xC8 + const MASK_BLINK = 1 << 4 + + let setCellContent = () => { + // Remove blink attribute if it wouldn't have any effect + let myAttrs = attrs + if ((myAttrs & MASK_BLINK) !== 0 && + ((lastChar === ' ' && ((myAttrs & MASK_LINE_ATTR) === 0)) || // no line styles + fg === bg // invisible text + ) + ) { + myAttrs ^= MASK_BLINK + } + // update blinking cells counter if blink state changed + if ((this.screenAttrs[cell] & MASK_BLINK) !== (myAttrs & MASK_BLINK)) { + if (myAttrs & MASK_BLINK) this.blinkingCellCount++ + else this.blinkingCellCount-- + } + + bgcount[bg]++ + this.screen[cell] = lastChar + this.screenFG[cell] = fg + this.screenBG[cell] = bg + this.screenAttrs[cell] = myAttrs + } + + while (i < strArray.length && cell < screenLength) { + let character = strArray[i++] + let charCode = character.codePointAt(0) + + let data + switch (charCode) { + case SEQ_REPEAT: + let count = parse2B(strArray[i] + strArray[i + 1]) + i += 2 + for (let j = 0; j < count; j++) { + setCellContent(cell) + if (++cell > screenLength) break + } + break + + case SEQ_SET_COLORS: + data = parse3B(strArray[i] + strArray[i + 1] + strArray[i + 2]) + i += 3 + fg = data & 0xFF + bg = (data >> 8) & 0xFF + break + + case SEQ_SET_ATTRS: + data = parse2B(strArray[i] + strArray[i + 1]) + i += 2 + attrs = data & 0xFF + break + + case SEQ_SET_FG: + data = parse2B(strArray[i] + strArray[i + 1]) + i += 2 + fg = data & 0xFF + break + + case SEQ_SET_BG: + data = parse2B(strArray[i] + strArray[i + 1]) + i += 2 + bg = data & 0xFF + break + + default: + if (charCode < 32) character = '\ufffd' + lastChar = character + setCellContent(cell) + cell++ + } + } + + if (this.window.debug) console.log(`Blinky cells = ${this.blinkingCellCount}`) + + // work-around for the grid gaps bug + // will mask the glitch if most of the screen uses the same background + let mostCommonBg = 0 + let mcbIndex = 0 + for (let i = 255; i >= 0; i--) { + if (bgcount[i] > mostCommonBg) { + mostCommonBg = bgcount[i] + mcbIndex = i + } + } + this.canvas.style.backgroundColor = this.getColor(mcbIndex) + + this.scheduleDraw('load', 16) + this.emit('load') + } + + /** + * Parses the content of a `T` message and updates the screen title and button + * labels. + * @param {string} str - the message content + */ + loadLabels (str) { + let pieces = str.split('\x01') + qs('#screen-title').textContent = pieces[0] + $('#action-buttons button').forEach((button, i) => { + let label = pieces[i + 1].trim() + // if empty string, use the "dim" effect and put nbsp instead to + // stretch the button vertically + button.innerHTML = label ? esc(label) : ' ' + button.style.opacity = label ? 1 : 0.2 + }) + } + + /** + * Shows an actual notification (if possible) or a notification balloon. + * @param {string} text - the notification content + */ + showNotification (text) { + console.info(`Notification: ${text}`) + if (Notification && Notification.permission === 'granted') { + let notification = new Notification('ESPTerm', { + body: text + }) + notification.addEventListener('click', () => window.focus()) + } else { + if (Notification && Notification.permission !== 'denied') { + Notification.requestPermission() + } else { + // Fallback using the built-in notification balloon + Notify.show(text) + } + } + } + + /** + * Loads a message from the server, and optionally a theme. + * @param {string} str - the message + * @param {number} [theme] - the new theme index + */ + load (str, theme = -1) { + const content = str.substr(1) + if (theme >= 0 && theme < themes.length) { + this.palette = themes[theme] + } + + switch (str[0]) { + case 'S': + this.loadContent(content) + break + + case 'T': + this.loadLabels(content) + break + + case 'B': + this.beep() + break + + case 'G': + this.showNotification(content) + break + + default: + console.warn(`Bad data message type; ignoring.\n${JSON.stringify(str)}`) + } + } + + /** + * Creates a beep sound. + */ + beep () { + const audioCtx = this.audioCtx + if (!audioCtx) return + + // prevent screeching + if (this._lastBeep && this._lastBeep > Date.now() - 50) return + this._lastBeep = Date.now() + + let osc, gain + + // main beep + osc = audioCtx.createOscillator() + gain = audioCtx.createGain() + osc.connect(gain) + gain.connect(audioCtx.destination) + gain.gain.value = 0.5 + osc.frequency.value = 750 + osc.type = 'sine' + osc.start() + osc.stop(audioCtx.currentTime + 0.05) + + // surrogate beep (making it sound like 'oops') + osc = audioCtx.createOscillator() + gain = audioCtx.createGain() + osc.connect(gain) + gain.connect(audioCtx.destination) + gain.gain.value = 0.2 + osc.frequency.value = 400 + osc.type = 'sine' + osc.start(audioCtx.currentTime + 0.05) + osc.stop(audioCtx.currentTime + 0.08) + } + + /** + * Converts an alphabetic character to its fraktur variant. + * @param {string} character - the character + * @returns {string} the converted character + */ + static alphaToFraktur (character) { + if (character >= 'a' && character <= 'z') { + character = String.fromCodePoint(0x1d51e - 0x61 + character.charCodeAt(0)) + } else if (character >= 'A' && character <= 'Z') { + character = frakturExceptions[character] || String.fromCodePoint( + 0x1d504 - 0x41 + character.charCodeAt(0)) + } + return character + } +} diff --git a/js/term_upload.js b/js/term_upload.js new file mode 100644 index 0000000..e399c4b --- /dev/null +++ b/js/term_upload.js @@ -0,0 +1,169 @@ +/** File upload utility */ +window.TermUpl = function (conn, input, screen) { + let lines, // array of lines without newlines + line_i, // current line index + fuTout, // timeout handle for line sending + send_delay_ms, // delay between lines (ms) + nl_str, // newline string to use + curLine, // current line (when using fuOil) + inline_pos // Offset in line (for long lines) + + // lines longer than this are split to chunks + // sending a super-ling string through the socket is not a good idea + const MAX_LINE_LEN = 128 + + function openUploadDialog () { + updateStatus('Ready...') + Modal.show('#fu_modal', onDialogClose) + $('#fu_form').toggleClass('busy', false) + input.blockKeys(true) + } + + function onDialogClose () { + console.log('Upload modal closed.') + clearTimeout(fuTout) + line_i = 0 + input.blockKeys(false) + } + + function updateStatus (msg) { + qs('#fu_prog').textContent = msg + } + + function startUpload () { + let v = qs('#fu_text').value + if (!v.length) { + fuClose() + return + } + + lines = v.split('\n') + line_i = 0 + inline_pos = 0 // offset in line + send_delay_ms = qs('#fu_delay').value + + // sanitize - 0 causes overflows + if (send_delay_ms < 0) { + send_delay_ms = 0 + qs('#fu_delay').value = send_delay_ms + } + + nl_str = { + 'CR': '\r', + 'LF': '\n', + 'CRLF': '\r\n' + }[qs('#fu_crlf').value] + + $('#fu_form').toggleClass('busy', true) + updateStatus('Starting...') + uploadLine() + } + + function uploadLine () { + if (!$('#fu_modal').hasClass('visible')) { + // Modal is closed, cancel + return + } + + if (!conn.canSend()) { + // postpone + fuTout = setTimeout(uploadLine, 1) + return + } + + if (inline_pos === 0) { + curLine = '' + if (line_i === 0) { + if (screen.bracketedPaste) { + curLine = '\x1b[200~' + } + } + + curLine += lines[line_i++] + nl_str + + if (line_i === lines.length) { + if (screen.bracketedPaste) { + curLine += '\x1b[201~' + } + } + } + + let chunk + if ((curLine.length - inline_pos) <= MAX_LINE_LEN) { + chunk = curLine.substr(inline_pos, MAX_LINE_LEN) + inline_pos = 0 + } else { + chunk = curLine.substr(inline_pos, MAX_LINE_LEN) + inline_pos += MAX_LINE_LEN + } + + console.log(chunk) + if (!input.sendString(chunk)) { + updateStatus('FAILED!') + return + } + + let pt = Math.round((line_i / lines.length) * 1000) / 10 + updateStatus(`${line_i} / ${lines.length} (${pt}%)`) + + if (lines.length > line_i || inline_pos > 0) { + fuTout = setTimeout(uploadLine, send_delay_ms) + } else { + closeWhenReady() + } + } + + function closeWhenReady () { + if (!conn.canSend()) { + // stuck in XOFF still, wait to process... + updateStatus('Waiting for Tx buffer...') + setTimeout(closeWhenReady, 100) + } else { + updateStatus('Done.') + // delay to show it + fuClose() + } + } + + function fuClose () { + Modal.hide('#fu_modal') + } + + return { + init: function () { + qs('#fu_file').addEventListener('change', function (evt) { + let reader = new FileReader() + let file = evt.target.files[0] + console.log('Selected file type: ' + file.type) + if (!file.type.match(/text\/.*|application\/(json|csv|.*xml.*|.*script.*)/)) { + // Deny load of blobs like img - can crash browser and will get corrupted anyway + if (!confirm('This does not look like a text file: ' + file.type + '\nReally load?')) { + qs('#fu_file').value = '' + return + } + } + reader.onload = function (e) { + const txt = e.target.result.replace(/[\r\n]+/, '\n') + qs('#fu_text').value = txt + } + console.log('Loading file...') + reader.readAsText(file) + }, false) + + qs('#term-fu-open').addEventListener('click', function () { + openUploadDialog() + return false + }) + + qs('#term-fu-start').addEventListener('click', function () { + startUpload() + return false + }) + + qs('#term-fu-close').addEventListener('click', function () { + fuClose() + return false + }) + } + } +} diff --git a/js/utils.js b/js/utils.js new file mode 100755 index 0000000..9a5049c --- /dev/null +++ b/js/utils.js @@ -0,0 +1,90 @@ +/** Make a node */ +function mk (e) { + return document.createElement(e) +} + +/** Find one by query */ +function qs (s) { + return document.querySelector(s) +} + +/** Find all by query */ +function qsa (s) { + return document.querySelectorAll(s) +} + +/** Convert any to bool safely */ +function bool (x) { + return (x === 1 || x === '1' || x === true || x === 'true') +} + +/** + * Filter 'spacebar' and 'return' from keypress handler, + * and when they're pressed, fire the callback. + * use $(...).on('keypress', cr(handler)) + */ +function cr (hdl) { + return function (e) { + if (e.which === 10 || e.which === 13 || e.which === 32) { + hdl() + } + } +} + +/** HTML escape */ +function esc (str) { + return $.htmlEscape(str) +} + +/** Check for undefined */ +function undef (x) { + return typeof x == 'undefined' +} + +/** Safe json parse */ +function jsp (str) { + try { + return JSON.parse(str) + } catch (e) { + console.error(e) + return null + } +} + +/** Create a character from ASCII code */ +function Chr (n) { + return String.fromCharCode(n) +} + +/** Decode number from 2B encoding */ +function parse2B (s, i = 0) { + return (s.charCodeAt(i++) - 1) + (s.charCodeAt(i) - 1) * 127 +} + +/** Decode number from 3B encoding */ +function parse3B (s, i = 0) { + return (s.charCodeAt(i) - 1) + (s.charCodeAt(i + 1) - 1) * 127 + (s.charCodeAt(i + 2) - 1) * 127 * 127 +} + +/** Encode using 2B encoding, returns string. */ +function encode2B (n) { + let lsb, msb + lsb = (n % 127) + n = ((n - lsb) / 127) + lsb += 1 + msb = (n + 1) + return Chr(lsb) + Chr(msb) +} + +/** Encode using 3B encoding, returns string. */ +function encode3B (n) { + let lsb, msb, xsb + lsb = (n % 127) + n = (n - lsb) / 127 + lsb += 1 + msb = (n % 127) + n = (n - msb) / 127 + msb += 1 + xsb = (n + 1) + return Chr(lsb) + Chr(msb) + Chr(xsb) +} diff --git a/js/wifi.js b/js/wifi.js new file mode 100644 index 0000000..bd35fe4 --- /dev/null +++ b/js/wifi.js @@ -0,0 +1,163 @@ +(function (w) { + const authStr = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA/WPA2'] + let curSSID + + // Get XX % for a slider input + function rangePt (inp) { + return Math.round(((inp.value / inp.max) * 100)) + '%' + } + + // Display selected STA SSID etc + function selectSta (name, password, ip) { + $('#sta_ssid').val(name) + $('#sta_password').val(password) + + $('#sta-nw').toggleClass('hidden', name.length === 0) + $('#sta-nw-nil').toggleClass('hidden', name.length > 0) + + $('#sta-nw .essid').html(esc(name)) + const nopw = undef(password) || password.length === 0 + $('#sta-nw .passwd').toggleClass('hidden', nopw) + $('#sta-nw .nopasswd').toggleClass('hidden', !nopw) + $('#sta-nw .ip').html(ip.length > 0 ? tr('wifi.connected_ip_is') + ip : tr('wifi.not_conn')) + } + + /** Update display for received response */ + function onScan (resp, status) { + // var ap_json = { + // "result": { + // "inProgress": "0", + // "APs": [ + // {"essid": "Chlivek", "bssid": "88:f7:c7:52:b3:99", "rssi": "204", "enc": "4", "channel": "1"}, + // {"essid": "TyNikdy", "bssid": "5c:f4:ab:0d:f1:1b", "rssi": "164", "enc": "3", "channel": "1"}, + // ] + // } + // }; + + if (status !== 200) { + // bad response + rescan(5000) // wait 5sm then retry + return + } + + try { + resp = JSON.parse(resp) + } catch (e) { + console.log(e) + rescan(5000) + return + } + + const done = !bool(resp.result.inProgress) && (resp.result.APs.length > 0) + rescan(done ? 15000 : 1000) + if (!done) return // no redraw yet + + // clear the AP list + let $list = $('#ap-list') + // remove old APs + $('#ap-list .AP').remove() + + $list.toggleClass('hidden', !done) + $('#ap-loader').toggleClass('hidden', done) + + // scan done + resp.result.APs.sort(function (a, b) { + return b.rssi - a.rssi + }).forEach(function (ap) { + ap.enc = parseInt(ap.enc) + + if (ap.enc > 4) return // hide unsupported auths + + let item = mk('div') + + let $item = $(item) + .data('ssid', ap.essid) + .data('pwd', ap.enc) + .attr('tabindex', 0) + .addClass('AP') + + // mark current SSID + if (ap.essid === curSSID) { + $item.addClass('selected') + } + + let inner = mk('div') + let escapedSSID = $.htmlEscape(ap.essid) + $(inner).addClass('inner') + .htmlAppend(`
${ap.rssi_perc}
`) + .htmlAppend(`
${escapedSSID}
`) + .htmlAppend(`
${authStr[ap.enc]}
`) + + $item.on('click', function () { + let $th = $(this) + + const conn_ssid = $th.data('ssid') + let conn_pass = '' + + if (+$th.data('pwd')) { + // this AP needs a password + conn_pass = prompt(tr('wifi.enter_passwd').replace(':ssid:', conn_ssid)) + if (!conn_pass) return + } + + $('#sta_password').val(conn_pass) + $('#sta_ssid').val(conn_ssid) + selectSta(conn_ssid, conn_pass, '') + }) + + item.appendChild(inner) + $list[0].appendChild(item) + }) + } + + function startScanning () { + $('#ap-loader').removeClass('hidden') + $('#ap-scan').addClass('hidden') + $('#ap-loader .anim-dots').html('.') + + scanAPs() + } + + /** Ask the CGI what APs are visible (async) */ + function scanAPs () { + if (_demo) { + onScan(_demo_aps, 200) + } else { + $.get('http://' + _root + '/cfg/wifi/scan', onScan) + } + } + + function rescan (time) { + setTimeout(scanAPs, time) + } + + /** Set up the WiFi page */ + function wifiInit (cfg) { + // Update slider value displays + $('.Row.range').forEach(function (x) { + let inp = x.querySelector('input') + let disp1 = x.querySelector('.x-disp1') + let disp2 = x.querySelector('.x-disp2') + let t = rangePt(inp) + $(disp1).html(t) + $(disp2).html(t) + $(inp).on('input', function () { + t = rangePt(inp) + $(disp1).html(t) + $(disp2).html(t) + }) + }) + + // Forget STA credentials + $('#forget-sta').on('click', function () { + selectSta('', '', '') + return false + }) + + selectSta(cfg.sta_ssid, cfg.sta_password, cfg.sta_active_ip) + curSSID = cfg.sta_active_ssid + } + + w.init = wifiInit + w.startScanning = startScanning +})(window.WiFi = {}) diff --git a/jssrc/appcommon.js b/jssrc/appcommon.js deleted file mode 100644 index 6b1fad6..0000000 --- a/jssrc/appcommon.js +++ /dev/null @@ -1,189 +0,0 @@ -/** Global generic init */ -$.ready(function () { - // Checkbox UI (checkbox CSS and hidden input with int value) - $('.Row.checkbox').forEach(function(x) { - var inp = x.querySelector('input'); - var box = x.querySelector('.box'); - - $(box).toggleClass('checked', inp.value); - - var hdl = function() { - inp.value = 1 - inp.value; - $(box).toggleClass('checked', inp.value) - }; - - $(x).on('click', hdl).on('keypress', cr(hdl)); - }); - - // Expanding boxes on mobile - $('.Box.mobcol,.Box.fold').forEach(function(x) { - var h = x.querySelector('h2'); - - var hdl = function() { - $(x).toggleClass('expanded'); - }; - $(h).on('click', hdl).on('keypress', cr(hdl)); - }); - - $('form').forEach(function(x) { - $(x).on('keypress', function(e) { - if ((e.keyCode == 10 || e.keyCode == 13) && e.ctrlKey) { - x.submit(); - } - }) - }); - - // loader dots... - setInterval(function () { - $('.anim-dots').each(function (x) { - var $x = $(x); - var dots = $x.html() + '.'; - if (dots.length == 5) dots = '.'; - $x.html(dots); - }); - }, 1000); - - // flipping number boxes with the mouse wheel - $('input[type=number]').on('mousewheel', function(e) { - var $this = $(this); - var val = +$this.val(); - if (isNaN(val)) val = 1; - - var step = +($this.attr('step') || 1); - var min = +$this.attr('min'); - var max = +$this.attr('max'); - if(e.wheelDelta > 0) { - val += step; - } else { - val -= step; - } - - if (typeof min != 'undefined') val = Math.max(val, +min); - if (typeof max != 'undefined') val = Math.min(val, +max); - $this.val(val); - - if ("createEvent" in document) { - var evt = document.createEvent("HTMLEvents"); - evt.initEvent("change", false, true); - $this[0].dispatchEvent(evt); - } else { - $this[0].fireEvent("onchange"); - } - - e.preventDefault(); - }); - - var errAt = location.search.indexOf('err='); - if (errAt !== -1 && qs('.Box.errors')) { - var errs = location.search.substr(errAt+4).split(','); - var hres = []; - errs.forEach(function(er) { - var lbl = qs('label[for="'+er+'"]'); - if (lbl) { - lbl.classList.add('error'); - hres.push(lbl.childNodes[0].textContent.trim().replace(/: ?$/, '')); - } else { - hres.push(er); - } - }); - - qs('.Box.errors .list').innerHTML = hres.join(', '); - qs('.Box.errors').classList.remove('hidden'); - } - - Modal.init(); - Notify.init(); - - // remove tabindixes from h2 if wide - if (window.innerWidth > 550) { - $('.Box h2').forEach(function (x) { - x.removeAttribute('tabindex'); - }); - - // brand works as a link back to term in widescreen mode - var br = qs('#brand'); - br && br.addEventListener('click', function() { - location.href='/'; // go to terminal - }); - } -}); - -$._loader = function(vis) { - $('#loader').toggleClass('show', vis); -}; - -function showPage() { - $('#content').addClass('load'); -} - -$.ready(function() { - if (window.noAutoShow !== true) { - setTimeout(function () { - showPage(); - }, 1); - } -}); - - -/*! http://mths.be/fromcodepoint v0.1.0 by @mathias */ -if (!String.fromCodePoint) { - (function() { - var defineProperty = (function() { - // IE 8 only supports `Object.defineProperty` on DOM elements - try { - var object = {}; - var $defineProperty = Object.defineProperty; - var result = $defineProperty(object, object, object) && $defineProperty; - } catch(error) {} - return result; - }()); - var stringFromCharCode = String.fromCharCode; - var floor = Math.floor; - var fromCodePoint = function() { - var MAX_SIZE = 0x4000; - var codeUnits = []; - var highSurrogate; - var lowSurrogate; - var index = -1; - var length = arguments.length; - if (!length) { - return ''; - } - var result = ''; - while (++index < length) { - var codePoint = Number(arguments[index]); - if ( - !isFinite(codePoint) || // `NaN`, `+Infinity`, or `-Infinity` - codePoint < 0 || // not a valid Unicode code point - codePoint > 0x10FFFF || // not a valid Unicode code point - floor(codePoint) != codePoint // not an integer - ) { - throw RangeError('Invalid code point: ' + codePoint); - } - if (codePoint <= 0xFFFF) { // BMP code point - codeUnits.push(codePoint); - } else { // Astral code point; split in surrogate halves - // http://mathiasbynens.be/notes/javascript-encoding#surrogate-formulae - codePoint -= 0x10000; - highSurrogate = (codePoint >> 10) + 0xD800; - lowSurrogate = (codePoint % 0x400) + 0xDC00; - codeUnits.push(highSurrogate, lowSurrogate); - } - if (index + 1 == length || codeUnits.length > MAX_SIZE) { - result += stringFromCharCode.apply(null, codeUnits); - codeUnits.length = 0; - } - } - return result; - }; - if (defineProperty) { - defineProperty(String, 'fromCodePoint', { - 'value': fromCodePoint, - 'configurable': true, - 'writable': true - }); - } else { - String.fromCodePoint = fromCodePoint; - } - }()); -} diff --git a/jssrc/modal.js b/jssrc/modal.js deleted file mode 100644 index ce623cd..0000000 --- a/jssrc/modal.js +++ /dev/null @@ -1,44 +0,0 @@ -/** Module for toggling a modal overlay */ -(function () { - var modal = {}; - var curCloseCb = null; - - modal.show = function (sel, closeCb) { - var $m = $(sel); - $m.removeClass('hidden visible'); - setTimeout(function () { - $m.addClass('visible'); - }, 1); - curCloseCb = closeCb; - }; - - modal.hide = function (sel) { - var $m = $(sel); - $m.removeClass('visible'); - setTimeout(function () { - $m.addClass('hidden'); - if (curCloseCb) curCloseCb(); - }, 500); // transition time - }; - - modal.init = function () { - // close modal by click outside the dialog - $('.Modal').on('click', function () { - if ($(this).hasClass('no-close')) return; // this is a no-close modal - modal.hide(this); - }); - - $('.Dialog').on('click', function (e) { - e.stopImmediatePropagation(); - }); - - // Hide all modals on esc - $(window).on('keydown', function (e) { - if (e.which == 27) { - modal.hide('.Modal'); - } - }); - }; - - window.Modal = modal; -})(); diff --git a/jssrc/notif.js b/jssrc/notif.js deleted file mode 100644 index ad08e51..0000000 --- a/jssrc/notif.js +++ /dev/null @@ -1,32 +0,0 @@ -(function (nt) { - var sel = '#notif'; - - var hideTmeo1; // timeout to start hiding (transition) - var hideTmeo2; // timeout to add the hidden class - - nt.show = function (message, timeout) { - $(sel).html(message); - Modal.show(sel); - - clearTimeout(hideTmeo1); - clearTimeout(hideTmeo2); - - if (undef(timeout)) timeout = 2500; - - hideTmeo1 = setTimeout(nt.hide, timeout); - }; - - nt.hide = function () { - var $m = $(sel); - $m.removeClass('visible'); - hideTmeo2 = setTimeout(function () { - $m.addClass('hidden'); - }, 250); // transition time - }; - - nt.init = function() { - $(sel).on('click', function() { - nt.hide(this); - }); - }; -})(window.Notify = {}); diff --git a/jssrc/term.js b/jssrc/term.js deleted file mode 100644 index 2211c60..0000000 --- a/jssrc/term.js +++ /dev/null @@ -1,6 +0,0 @@ -/** Init the terminal sub-module - called from HTML */ -window.termInit = function () { - Conn.init(); - Input.init(); - TermUpl.init(); -}; diff --git a/jssrc/term_conn.js b/jssrc/term_conn.js deleted file mode 100644 index d9bbd57..0000000 --- a/jssrc/term_conn.js +++ /dev/null @@ -1,134 +0,0 @@ -/** Handle connections */ -var Conn = (function () { - var ws; - var heartbeatTout; - var pingIv; - var xoff = false; - var autoXoffTout; - var reconTout; - - var pageShown = false; - - function onOpen(evt) { - console.log("CONNECTED"); - doSend("i"); - } - - function onClose(evt) { - console.warn("SOCKET CLOSED, code " + evt.code + ". Reconnecting..."); - clearTimeout(reconTout); - reconTout = setTimeout(function () { - init(); - }, 2000); - // this happens when the buffer gets fucked up via invalid unicode. - // we basically use polling instead of socket then - } - - function onMessage(evt) { - try { - // . = heartbeat - switch (evt.data.charAt(0)) { - case 'B': - case 'T': - case 'S': - Screen.load(evt.data); - if(!pageShown) { - showPage(); - pageShown = true; - } - break; - - case '-': - //console.log('xoff'); - xoff = true; - autoXoffTout = setTimeout(function () { - xoff = false; - }, 250); - break; - - case '+': - //console.log('xon'); - xoff = false; - clearTimeout(autoXoffTout); - break; - } - heartbeat(); - } catch (e) { - console.error(e); - } - } - - function canSend() { - return !xoff; - } - - function doSend(message) { - if (_demo) { - console.log("TX: ", message); - return true; // Simulate success - } - if (xoff) { - // TODO queue - console.log("Can't send, flood control."); - return false; - } - - if (!ws) return false; // for dry testing - if (ws.readyState != 1) { - console.error("Socket not ready"); - return false; - } - if (typeof message != "string") { - message = JSON.stringify(message); - } - ws.send(message); - return true; - } - - function init() { - if (_demo) { - console.log("Demo mode!"); - Screen.load(_demo_screen); - showPage(); - return; - } - - clearTimeout(reconTout); - clearTimeout(heartbeatTout); - - ws = new WebSocket("ws://" + _root + "/term/update.ws"); - ws.onopen = onOpen; - ws.onclose = onClose; - ws.onmessage = onMessage; - console.log("Opening socket."); - heartbeat(); - } - - function heartbeat() { - clearTimeout(heartbeatTout); - heartbeatTout = setTimeout(heartbeatFail, 2000); - } - - function heartbeatFail() { - console.error("Heartbeat lost, probing server..."); - pingIv = setInterval(function () { - console.log("> ping"); - $.get('http://' + _root + '/system/ping', function (resp, status) { - if (status == 200) { - clearInterval(pingIv); - console.info("Server ready, reloading page..."); - location.reload(); - } - }, { - timeout: 100, - }); - }, 1000); - } - - return { - ws: null, - init: init, - send: doSend, - canSend: canSend, // check flood control - }; -})(); diff --git a/jssrc/term_input.js b/jssrc/term_input.js deleted file mode 100644 index 2224a32..0000000 --- a/jssrc/term_input.js +++ /dev/null @@ -1,264 +0,0 @@ -/** - * User input - * - * --- Rx messages: --- - * S - screen content (binary encoding of the entire screen with simple compression) - * T - text labels - Title and buttons, \0x01-separated - * B - beep - * . - heartbeat - * - * --- Tx messages --- - * s - string - * b - action button - * p - mb press - * r - mb release - * m - mouse move - */ -var Input = (function() { - var opts = { - np_alt: false, - cu_alt: false, - fn_alt: false, - mt_click: false, - mt_move: false, - no_keys: false, - crlf_mode: false, - }; - - /** Send a literal message */ - function sendStrMsg(str) { - return Conn.send("s"+str); - } - - /** Send a button event */ - function sendBtnMsg(n) { - Conn.send("b"+Chr(n)); - } - - /** Fn alt choice for key message */ - function fa(alt, normal) { - return opts.fn_alt ? alt : normal; - } - - /** Cursor alt choice for key message */ - function ca(alt, normal) { - return opts.cu_alt ? alt : normal; - } - - /** Numpad alt choice for key message */ - function na(alt, normal) { - return opts.np_alt ? alt : normal; - } - - function _bindFnKeys() { - var keymap = { - 'tab': '\x09', - 'backspace': '\x08', - 'enter': opts.crlf_mode ? '\x0d\x0a' : '\x0d', - 'ctrl+enter': '\x0a', - 'esc': '\x1b', - 'up': ca('\x1bOA', '\x1b[A'), - 'down': ca('\x1bOB', '\x1b[B'), - 'right': ca('\x1bOC', '\x1b[C'), - 'left': ca('\x1bOD', '\x1b[D'), - 'home': ca('\x1bOH', fa('\x1b[H', '\x1b[1~')), - 'insert': '\x1b[2~', - 'delete': '\x1b[3~', - 'end': ca('\x1bOF', fa('\x1b[F', '\x1b[4~')), - 'pageup': '\x1b[5~', - 'pagedown': '\x1b[6~', - 'f1': fa('\x1bOP', '\x1b[11~'), - 'f2': fa('\x1bOQ', '\x1b[12~'), - 'f3': fa('\x1bOR', '\x1b[13~'), - 'f4': fa('\x1bOS', '\x1b[14~'), - 'f5': '\x1b[15~', // note the disconnect - 'f6': '\x1b[17~', - 'f7': '\x1b[18~', - 'f8': '\x1b[19~', - 'f9': '\x1b[20~', - 'f10': '\x1b[21~', // note the disconnect - 'f11': '\x1b[23~', - 'f12': '\x1b[24~', - 'shift+f1': fa('\x1bO1;2P', '\x1b[25~'), - 'shift+f2': fa('\x1bO1;2Q', '\x1b[26~'), // note the disconnect - 'shift+f3': fa('\x1bO1;2R', '\x1b[28~'), - 'shift+f4': fa('\x1bO1;2S', '\x1b[29~'), // note the disconnect - 'shift+f5': fa('\x1b[15;2~', '\x1b[31~'), - 'shift+f6': fa('\x1b[17;2~', '\x1b[32~'), - 'shift+f7': fa('\x1b[18;2~', '\x1b[33~'), - 'shift+f8': fa('\x1b[19;2~', '\x1b[34~'), - 'shift+f9': fa('\x1b[20;2~', '\x1b[35~'), // 35-38 are not standard - but what is? - 'shift+f10': fa('\x1b[21;2~', '\x1b[36~'), - 'shift+f11': fa('\x1b[22;2~', '\x1b[37~'), - 'shift+f12': fa('\x1b[23;2~', '\x1b[38~'), - 'np_0': na('\x1bOp', '0'), - 'np_1': na('\x1bOq', '1'), - 'np_2': na('\x1bOr', '2'), - 'np_3': na('\x1bOs', '3'), - 'np_4': na('\x1bOt', '4'), - 'np_5': na('\x1bOu', '5'), - 'np_6': na('\x1bOv', '6'), - 'np_7': na('\x1bOw', '7'), - 'np_8': na('\x1bOx', '8'), - 'np_9': na('\x1bOy', '9'), - 'np_mul': na('\x1bOR', '*'), - 'np_add': na('\x1bOl', '+'), - 'np_sub': na('\x1bOS', '-'), - 'np_point': na('\x1bOn', '.'), - 'np_div': na('\x1bOQ', '/'), - // we don't implement numlock key (should change in numpad_alt mode, but it's even more useless than the rest) - }; - - for (var k in keymap) { - if (keymap.hasOwnProperty(k)) { - bind(k, keymap[k]); - } - } - } - - /** Bind a keystroke to message */ - function bind(combo, str) { - // mac fix - allow also cmd - if (combo.indexOf('ctrl+') !== -1) { - combo += ',' + combo.replace('ctrl', 'command'); - } - - // unbind possible old binding - key.unbind(combo); - - key(combo, function (e) { - if (opts.no_keys) return; - e.preventDefault(); - sendStrMsg(str) - }); - } - - /** Bind/rebind key messages */ - function _initKeys() { - // This takes care of text characters typed - window.addEventListener('keypress', function(evt) { - if (opts.no_keys) return; - var str = ''; - if (evt.key) str = evt.key; - else if (evt.which) str = String.fromCodePoint(evt.which); - if (str.length>0 && str.charCodeAt(0) >= 32) { -// console.log("Typed ", str); - sendStrMsg(str); - } - }); - - // ctrl-letter codes are sent as simple low ASCII codes - for (var i = 1; i<=26;i++) { - bind('ctrl+' + String.fromCharCode(96+i), String.fromCharCode(i)); - } - bind('ctrl+]', '\x1b'); // alternate way to enter ESC - bind('ctrl+\\', '\x1c'); - bind('ctrl+[', '\x1d'); - bind('ctrl+^', '\x1e'); - bind('ctrl+_', '\x1f'); - - _bindFnKeys(); - } - - // mouse button states - var mb1 = 0; - var mb2 = 0; - var mb3 = 0; - - /** Init the Input module */ - function init() { - _initKeys(); - - // Button presses - $('#action-buttons button').forEach(function(s) { - s.addEventListener('click', function() { - sendBtnMsg(+this.dataset['n']); - }); - }); - - // global mouse state tracking - for motion reporting - window.addEventListener('mousedown', function(evt) { - if (evt.button == 0) mb1 = 1; - if (evt.button == 1) mb2 = 1; - if (evt.button == 2) mb3 = 1; - }); - - window.addEventListener('mouseup', function(evt) { - if (evt.button == 0) mb1 = 0; - if (evt.button == 1) mb2 = 0; - if (evt.button == 2) mb3 = 0; - }); - } - - /** Prepare modifiers byte for mouse message */ - function packModifiersForMouse() { - return (key.isModifier('ctrl')?1:0) | - (key.isModifier('shift')?2:0) | - (key.isModifier('alt')?4:0) | - (key.isModifier('meta')?8:0); - } - - return { - /** Init the Input module */ - init: init, - - /** Send a literal string message */ - sendString: sendStrMsg, - - /** Enable alternate key modes (cursors, numpad, fn) */ - setAlts: function(cu, np, fn, crlf) { - if (opts.cu_alt != cu || opts.np_alt != np || opts.fn_alt != fn || opts.crlf_mode != crlf) { - opts.cu_alt = cu; - opts.np_alt = np; - opts.fn_alt = fn; - opts.crlf_mode = crlf; - - // rebind keys - codes have changed - _bindFnKeys(); - } - }, - - setMouseMode: function(click, move) { - opts.mt_click = click; - opts.mt_move = move; - }, - - // Mouse events - onMouseMove: function (x, y) { - if (!opts.mt_move) return; - var b = mb1 ? 1 : mb2 ? 2 : mb3 ? 3 : 0; - var m = packModifiersForMouse(); - Conn.send("m" + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)); - }, - onMouseDown: function (x, y, b) { - if (!opts.mt_click) return; - if (b > 3 || b < 1) return; - var m = packModifiersForMouse(); - Conn.send("p" + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)); - // console.log("B ",b," M ",m); - }, - onMouseUp: function (x, y, b) { - if (!opts.mt_click) return; - if (b > 3 || b < 1) return; - var m = packModifiersForMouse(); - Conn.send("r" + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)); - // console.log("B ",b," M ",m); - }, - onMouseWheel: function (x, y, dir) { - if (!opts.mt_click) return; - // -1 ... btn 4 (away from user) - // +1 ... btn 5 (towards user) - var m = packModifiersForMouse(); - var b = (dir < 0 ? 4 : 5); - Conn.send("p" + encode2B(y) + encode2B(x) + encode2B(b) + encode2B(m)); - // console.log("B ",b," M ",m); - }, - mouseTracksClicks: function() { - return opts.mt_click; - }, - blockKeys: function(yes) { - opts.no_keys = yes; - } - }; -})(); - diff --git a/jssrc/term_screen.js b/jssrc/term_screen.js deleted file mode 100644 index bfbc4ad..0000000 --- a/jssrc/term_screen.js +++ /dev/null @@ -1,380 +0,0 @@ -var Screen = (function () { - var W = 0, H = 0; // dimensions - var inited = false; - - var cursor = { - a: false, // active (blink state) - x: 0, // 0-based coordinates - y: 0, - fg: 7, // colors 0-15 - bg: 0, - attrs: 0, - suppress: false, // do not turn on in blink interval (for safe moving) - forceOn: false, // force on unless hanging: used to keep cursor visible during move - hidden: false, // do not show (DEC opt) - hanging: false, // cursor at column "W+1" - not visible - }; - - var screen = []; - var blinkIval; - var cursorFlashStartIval; - - // Some non-bold Fraktur symbols are outside the contiguous block - var frakturExceptions = { - 'C': '\u212d', - 'H': '\u210c', - 'I': '\u2111', - 'R': '\u211c', - 'Z': '\u2128', - }; - - // for BEL - var audioCtx = null; - try { - audioCtx = new (window.AudioContext || window.audioContext || window.webkitAudioContext)(); - } catch (er) { - console.error("No AudioContext!", er); - } - - /** Get cell under cursor */ - function _curCell() { - return screen[cursor.y*W + cursor.x]; - } - - /** Safely move cursor */ - function cursorSet(y, x) { - // Hide and prevent from showing up during the move - cursor.suppress = true; - _draw(_curCell(), false); - cursor.x = x; - cursor.y = y; - // Show again - cursor.suppress = false; - _draw(_curCell()); - } - - function alpha2fraktur(t) { - // perform substitution - if (t >= 'a' && t <= 'z') { - t = String.fromCodePoint(0x1d51e - 97 + t.charCodeAt(0)); - } - else if (t >= 'A' && t <= 'Z') { - // this set is incomplete, some exceptions are needed - if (frakturExceptions.hasOwnProperty(t)) { - t = frakturExceptions[t]; - } else { - t = String.fromCodePoint(0x1d504 - 65 + t.charCodeAt(0)); - } - } - return t; - } - - /** Update cell on display. inv = invert (for cursor) */ - function _draw(cell, inv) { - if (!cell) return; - if (typeof inv == 'undefined') { - inv = cursor.a && cursor.x == cell.x && cursor.y == cell.y; - } - - var fg, bg, cn, t; - - fg = inv ? cell.bg : cell.fg; - bg = inv ? cell.fg : cell.bg; - - t = cell.t; - if (!t.length) t = ' '; - - cn = 'fg' + fg + ' bg' + bg; - if (cell.attrs & (1<<0)) cn += ' bold'; - if (cell.attrs & (1<<1)) cn += ' faint'; - if (cell.attrs & (1<<2)) cn += ' italic'; - if (cell.attrs & (1<<3)) cn += ' under'; - if (cell.attrs & (1<<4)) cn += ' blink'; - if (cell.attrs & (1<<5)) { - cn += ' fraktur'; - t = alpha2fraktur(t); - } - if (cell.attrs & (1<<6)) cn += ' strike'; - - cell.slot.textContent = t; - cell.elem.className = cn; - } - - /** Show entire screen */ - function _drawAll() { - for (var i = W*H-1; i>=0; i--) { - _draw(screen[i]); - } - } - - function _rebuild(rows, cols) { - W = cols; - H = rows; - - /* Build screen & show */ - var cOuter, cInner, cell, screenDiv = qs('#screen'); - - // Empty the screen node - while (screenDiv.firstChild) screenDiv.removeChild(screenDiv.firstChild); - - screen = []; - - for(var i = 0; i < W*H; i++) { - cOuter = mk('span'); - cInner = mk('span'); - - /* Mouse tracking */ - (function() { - var x = i % W; - var y = Math.floor(i / W); - cOuter.addEventListener('mouseenter', function (evt) { - Input.onMouseMove(x, y); - }); - cOuter.addEventListener('mousedown', function (evt) { - Input.onMouseDown(x, y, evt.button+1); - }); - cOuter.addEventListener('mouseup', function (evt) { - Input.onMouseUp(x, y, evt.button+1); - }); - cOuter.addEventListener('contextmenu', function (evt) { - if (Input.mouseTracksClicks()) { - evt.preventDefault(); - } - }); - cOuter.addEventListener('mousewheel', function (evt) { - Input.onMouseWheel(x, y, evt.deltaY>0?1:-1); - return false; - }); - })(); - - /* End of line */ - if ((i > 0) && (i % W == 0)) { - screenDiv.appendChild(mk('br')); - } - /* The cell */ - cOuter.appendChild(cInner); - screenDiv.appendChild(cOuter); - - cell = { - t: ' ', - fg: 7, - bg: 0, // the colors will be replaced immediately as we receive data (user won't see this) - attrs: 0, - elem: cOuter, - slot: cInner, - x: i % W, - y: Math.floor(i / W), - }; - screen.push(cell); - _draw(cell); - } - } - - /** Init the terminal */ - function _init() { - /* Cursor blinking */ - clearInterval(blinkIval); - blinkIval = setInterval(function () { - cursor.a = !cursor.a; - if (cursor.hidden || cursor.hanging) { - cursor.a = false; - } - - if (!cursor.suppress) { - _draw(_curCell(), cursor.forceOn || cursor.a); - } - }, 500); - - /* blink attribute animation */ - setInterval(function () { - $('#screen').removeClass('blink-hide'); - setTimeout(function () { - $('#screen').addClass('blink-hide'); - }, 800); // 200 ms ON - }, 1000); - - inited = true; - } - - // constants for decoding the update blob - var SEQ_SET_COLOR_ATTR = 1; - var SEQ_REPEAT = 2; - var SEQ_SET_COLOR = 3; - var SEQ_SET_ATTR = 4; - - /** Parse received screen update object (leading S removed already) */ - function _load_content(str) { - var i = 0, ci = 0, j, jc, num, num2, t = ' ', fg, bg, attrs, cell; - - if (!inited) _init(); - - var cursorMoved; - - // Set size - num = parse2B(str, i); i += 2; // height - num2 = parse2B(str, i); i += 2; // width - if (num != H || num2 != W) { - _rebuild(num, num2); - } - // console.log("Size ",num, num2); - - // Cursor position - num = parse2B(str, i); i += 2; // row - num2 = parse2B(str, i); i += 2; // col - cursorMoved = (cursor.x != num2 || cursor.y != num); - cursorSet(num, num2); - // console.log("Cursor at ",num, num2); - - // Attributes - num = parse3B(str, i); i += 3; - cursor.hidden = !(num & (1<<0)); // DEC opt "visible" - cursor.hanging = !!(num & (1<<1)); - - Input.setAlts( - !!(num & (1<<2)), // cursors alt - !!(num & (1<<3)), // numpad alt - !!(num & (1<<4)), // fn keys alt - !!(num & (1<<12)) // crlf mode - ); - - var mt_click = !!(num & (1<<5)); - var mt_move = !!(num & (1<<6)); - Input.setMouseMode( - mt_click, - mt_move - ); - $('#screen').toggleClass('noselect', mt_move); - - var show_buttons = !!(num & (1<<7)); - var show_config_links = !!(num & (1<<8)); - $('.x-term-conf-btn').toggleClass('hidden', !show_config_links); - $('#action-buttons').toggleClass('hidden', !show_buttons); - - // bits 9-11 are cursor shape (not implemented) - - fg = 7; - bg = 0; - attrs = 0; - - // Here come the content - while(i < str.length && ci> 4; - attrs = (num & 0xFF00)>>8; - } - else if (jc == SEQ_SET_COLOR) { - num = parse2B(str, i); i += 2; - fg = num & 0x0F; - bg = (num & 0xF0) >> 4; - } - else if (jc == SEQ_SET_ATTR) { - num = parse2B(str, i); i += 2; - attrs = num & 0xFF; - } - else if (jc == SEQ_REPEAT) { - num = parse2B(str, i); i += 2; - // console.log("Repeat x ",num); - for (; num>0 && ci 0 ? e(s) : " "; - x.style.opacity = s.length > 0 ? 1 : 0.2; - }) - } - - /** Audible beep for ASCII 7 */ - function _beep() { - var osc, gain; - if (!audioCtx) return; - - // Main beep - osc = audioCtx.createOscillator(); - gain = audioCtx.createGain(); - osc.connect(gain); - gain.connect(audioCtx.destination); - gain.gain.value = 0.5; - osc.frequency.value = 750; - osc.type = 'sine'; - osc.start(); - osc.stop(audioCtx.currentTime+0.05); - - // Surrogate beep (making it sound like 'oops') - osc = audioCtx.createOscillator(); - gain = audioCtx.createGain(); - osc.connect(gain); - gain.connect(audioCtx.destination); - gain.gain.value = 0.2; - osc.frequency.value = 400; - osc.type = 'sine'; - osc.start(audioCtx.currentTime+0.05); - osc.stop(audioCtx.currentTime+0.08); - } - - /** Load screen content from a binary sequence (new) */ - function load(str) { - //console.log(JSON.stringify(str)); - var content = str.substr(1); - switch(str.charAt(0)) { - case 'S': - _load_content(content); - break; - case 'T': - _load_labels(content); - break; - case 'B': - _beep(); - break; - default: - console.warn("Bad data message type, ignoring."); - console.log(str); - } - } - - return { - load: load, // full load (string) - }; -})(); diff --git a/jssrc/term_upload.js b/jssrc/term_upload.js deleted file mode 100644 index 2c92110..0000000 --- a/jssrc/term_upload.js +++ /dev/null @@ -1,146 +0,0 @@ -/** File upload utility */ -var TermUpl = (function() { - var lines, // array of lines without newlines - line_i, // current line index - fuTout, // timeout handle for line sending - send_delay_ms, // delay between lines (ms) - nl_str, // newline string to use - curLine, // current line (when using fuOil) - inline_pos; // Offset in line (for long lines) - - // lines longer than this are split to chunks - // sending a super-ling string through the socket is not a good idea - var MAX_LINE_LEN = 128; - - function fuOpen() { - fuStatus("Ready..."); - Modal.show('#fu_modal', onClose); - $('#fu_form').toggleClass('busy', false); - Input.blockKeys(true); - } - - function onClose() { - console.log("Upload modal closed."); - clearTimeout(fuTout); - line_i = 0; - Input.blockKeys(false); - } - - function fuStatus(msg) { - qs('#fu_prog').textContent = msg; - } - - function fuSend() { - var v = qs('#fu_text').value; - if (!v.length) { - fuClose(); - return; - } - - lines = v.split('\n'); - line_i = 0; - inline_pos = 0; // offset in line - send_delay_ms = qs('#fu_delay').value; - - // sanitize - 0 causes overflows - if (send_delay_ms < 0) { - send_delay_ms = 0; - qs('#fu_delay').value = send_delay_ms; - } - - nl_str = { - 'CR': '\r', - 'LF': '\n', - 'CRLF': '\r\n', - }[qs('#fu_crlf').value]; - - $('#fu_form').toggleClass('busy', true); - fuStatus("Starting..."); - fuSendLine(); - } - - function fuSendLine() { - if (!$('#fu_modal').hasClass('visible')) { - // Modal is closed, cancel - return; - } - - if (!Conn.canSend()) { - // postpone - fuTout = setTimeout(fuSendLine, 1); - return; - } - - if (inline_pos == 0) { - curLine = lines[line_i++] + nl_str; - } - - var chunk; - if ((curLine.length - inline_pos) <= MAX_LINE_LEN) { - chunk = curLine.substr(inline_pos, MAX_LINE_LEN); - inline_pos = 0; - } else { - chunk = curLine.substr(inline_pos, MAX_LINE_LEN); - inline_pos += MAX_LINE_LEN; - } - - if (!Input.sendString(chunk)) { - fuStatus("FAILED!"); - return; - } - - var all = lines.length; - - fuStatus(line_i+" / "+all+ " ("+(Math.round((line_i/all)*1000)/10)+"%)"); - - if (lines.length > line_i || inline_pos > 0) { - fuTout = setTimeout(fuSendLine, send_delay_ms); - } else { - closeWhenReady(); - } - } - - function closeWhenReady() { - if (!Conn.canSend()) { - // stuck in XOFF still, wait to process... - fuStatus("Waiting for Tx buffer..."); - setTimeout(closeWhenReady, 100); - } else { - fuStatus("Done."); - // delay to show it - setTimeout(function() { - fuClose(); - }, 100); - } - } - - function fuClose() { - Modal.hide('#fu_modal'); - } - - return { - init: function() { - qs('#fu_file').addEventListener('change', function (evt) { - var reader = new FileReader(); - var file = evt.target.files[0]; - console.log("Selected file type: "+file.type); - if (!file.type.match(/text\/.*|application\/(json|csv|.*xml.*|.*script.*)/)) { - // Deny load of blobs like img - can crash browser and will get corrupted anyway - if (!confirm("This does not look like a text file: "+file.type+"\nReally load?")) { - qs('#fu_file').value = ''; - return; - } - } - reader.onload = function(e) { - var txt = e.target.result.replace(/[\r\n]+/,'\n'); - qs('#fu_text').value = txt; - }; - console.log("Loading file..."); - reader.readAsText(file); - }, false); - }, - close: fuClose, - start: fuSend, - open: fuOpen, - } -})(); diff --git a/jssrc/utils.js b/jssrc/utils.js deleted file mode 100755 index d95c86c..0000000 --- a/jssrc/utils.js +++ /dev/null @@ -1,161 +0,0 @@ -/** Make a node */ -function mk(e) {return document.createElement(e)} - -/** Find one by query */ -function qs(s) {return document.querySelector(s)} - -/** Find all by query */ -function qsa(s) {return document.querySelectorAll(s)} - -/** Convert any to bool safely */ -function bool(x) { - return (x === 1 || x === '1' || x === true || x === 'true'); -} - -/** - * Filter 'spacebar' and 'return' from keypress handler, - * and when they're pressed, fire the callback. - * use $(...).on('keypress', cr(handler)) - */ -function cr(hdl) { - return function(e) { - if (e.which == 10 || e.which == 13 || e.which == 32) { - hdl(); - } - }; -} - -/** Extend an objects with options */ -function extend(defaults, options) { - var target = {}; - - Object.keys(defaults).forEach(function(k){ - target[k] = defaults[k]; - }); - - Object.keys(options).forEach(function(k){ - target[k] = options[k]; - }); - - return target; -} - -/** Escape string for use as literal in RegExp */ -function rgxe(str) { - return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); -} - -/** Format number to N decimal places, output as string */ -function numfmt(x, places) { - var pow = Math.pow(10, places); - return Math.round(x*pow) / pow; -} - -/** Get millisecond timestamp */ -function msNow() { - return +(new Date); -} - -/** Get ms elapsed since msNow() */ -function msElapsed(start) { - return msNow() - start; -} - -/** Shim for log base 10 */ -Math.log10 = Math.log10 || function(x) { - return Math.log(x) / Math.LN10; -}; - -/** - * Perform a substitution in the given string. - * - * Arguments - array or list of replacements. - * Arguments numeric keys will replace {0}, {1} etc. - * Named keys also work, ie. {foo: "bar"} -> replaces {foo} with bar. - * - * Braces are added to keys if missing. - * - * @returns {String} result - */ -String.prototype.format = function () { - var out = this; - var repl = arguments; - - if (arguments.length == 1 && (Array.isArray(arguments[0]) || typeof arguments[0] == 'object')) { - repl = arguments[0]; - } - - for (var ph in repl) { - if (repl.hasOwnProperty(ph)) { - var ph_orig = ph; - - if (!ph.match(/^\{.*\}$/)) { - ph = '{' + ph + '}'; - } - - // replace all occurrences - var pattern = new RegExp(rgxe(ph), "g"); - out = out.replace(pattern, repl[ph_orig]); - } - } - - return out; -}; - -/** HTML escape */ -function e(str) { - return $.htmlEscape(str); -} - -/** Check for undefined */ -function undef(x) { - return typeof x == 'undefined'; -} - -/** Safe json parse */ -function jsp(str) { - try { - return JSON.parse(str); - } catch(e) { - console.error(e); - return null; - } -} - -/** Create a character from ASCII code */ -function Chr(n) { - return String.fromCharCode(n); -} - -/** Decode number from 2B encoding */ -function parse2B(s, i) { - return (s.charCodeAt(i++) - 1) + (s.charCodeAt(i) - 1) * 127; -} - -/** Decode number from 3B encoding */ -function parse3B(s, i) { - return (s.charCodeAt(i) - 1) + (s.charCodeAt(i+1) - 1) * 127 + (s.charCodeAt(i+2) - 1) * 127 * 127; -} - -/** Encode using 2B encoding, returns string. */ -function encode2B(n) { - var lsb, msb; - lsb = (n % 127); - n = ((n - lsb) / 127); - lsb += 1; - msb = (n + 1); - return Chr(lsb) + Chr(msb); -} - -/** Encode using 3B encoding, returns string. */ -function encode3B(n) { - var lsb, msb, xsb; - lsb = (n % 127); - n = (n - lsb) / 127; - lsb += 1; - msb = (n % 127); - n = (n - msb) / 127; - msb += 1; - xsb = (n + 1); - return Chr(lsb) + Chr(msb) + Chr(xsb); -} diff --git a/jssrc/wifi.js b/jssrc/wifi.js deleted file mode 100644 index ebed158..0000000 --- a/jssrc/wifi.js +++ /dev/null @@ -1,163 +0,0 @@ -(function(w) { - var authStr = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA/WPA2']; - var curSSID; - - // Get XX % for a slider input - function rangePt(inp) { - return Math.round(((inp.value / inp.max)*100)) + '%'; - } - - // Display selected STA SSID etc - function selectSta(name, password, ip) { - $('#sta_ssid').val(name); - $('#sta_password').val(password); - - $('#sta-nw').toggleClass('hidden', name.length == 0); - $('#sta-nw-nil').toggleClass('hidden', name.length > 0); - - $('#sta-nw .essid').html(e(name)); - var nopw = undef(password) || password.length == 0; - $('#sta-nw .passwd').toggleClass('hidden', nopw); - $('#sta-nw .nopasswd').toggleClass('hidden', !nopw); - $('#sta-nw .ip').html(ip.length>0 ? tr('wifi.connected_ip_is')+ip : tr('wifi.not_conn')); - } - - /** Update display for received response */ - function onScan(resp, status) { - //var ap_json = { - // "result": { - // "inProgress": "0", - // "APs": [ - // {"essid": "Chlivek", "bssid": "88:f7:c7:52:b3:99", "rssi": "204", "enc": "4", "channel": "1"}, - // {"essid": "TyNikdy", "bssid": "5c:f4:ab:0d:f1:1b", "rssi": "164", "enc": "3", "channel": "1"}, - // ] - // } - //}; - - if (status != 200) { - // bad response - rescan(5000); // wait 5sm then retry - return; - } - - try { - resp = JSON.parse(resp); - } catch (e) { - console.log(e); - rescan(5000); - return; - } - - var done = !bool(resp.result.inProgress) && (resp.result.APs.length > 0); - rescan(done ? 15000 : 1000); - if (!done) return; // no redraw yet - - // clear the AP list - var $list = $('#ap-list'); - // remove old APs - $('#ap-list .AP').remove(); - - $list.toggleClass('hidden', !done); - $('#ap-loader').toggleClass('hidden', done); - - // scan done - resp.result.APs.sort(function (a, b) { - return b.rssi - a.rssi; - }).forEach(function (ap) { - ap.enc = parseInt(ap.enc); - - if (ap.enc > 4) return; // hide unsupported auths - - var item = mk('div'); - - var $item = $(item) - .data('ssid', ap.essid) - .data('pwd', ap.enc) - .attr('tabindex', 0) - .addClass('AP'); - - // mark current SSID - if (ap.essid == curSSID) { - $item.addClass('selected'); - } - - var inner = mk('div'); - $(inner).addClass('inner') - .htmlAppend('
{0}
'.format(ap.rssi_perc)) - .htmlAppend('
{0}
'.format($.htmlEscape(ap.essid))) - .htmlAppend('
{0}
'.format(authStr[ap.enc])); - - $item.on('click', function () { - var $th = $(this); - - var conn_ssid = $th.data('ssid'); - var conn_pass = ''; - - if (+$th.data('pwd')) { - // this AP needs a password - conn_pass = prompt(tr("wifi.enter_passwd").replace(":ssid:", conn_ssid)); - if (!conn_pass) return; - } - - $('#sta_password').val(conn_pass); - $('#sta_ssid').val(conn_ssid); - selectSta(conn_ssid, conn_pass, ''); - }); - - - item.appendChild(inner); - $list[0].appendChild(item); - }); - } - - function startScanning() { - $('#ap-loader').removeClass('hidden'); - $('#ap-scan').addClass('hidden'); - $('#ap-loader .anim-dots').html('.'); - - scanAPs(); - } - - /** Ask the CGI what APs are visible (async) */ - function scanAPs() { - if (_demo) { - onScan(_demo_aps, 200); - } else { - $.get('http://' + _root + '/cfg/wifi/scan', onScan); - } - } - - function rescan(time) { - setTimeout(scanAPs, time); - } - - /** Set up the WiFi page */ - function wifiInit(cfg) { - // Update slider value displays - $('.Row.range').forEach(function(x) { - var inp = x.querySelector('input'); - var disp1 = x.querySelector('.x-disp1'); - var disp2 = x.querySelector('.x-disp2'); - var t = rangePt(inp); - $(disp1).html(t); - $(disp2).html(t); - $(inp).on('input', function() { - t = rangePt(inp); - $(disp1).html(t); - $(disp2).html(t); - }); - }); - - // Forget STA credentials - $('#forget-sta').on('click', function() { - selectSta('', '', ''); - return false; - }); - - selectSta(cfg.sta_ssid, cfg.sta_password, cfg.sta_active_ip); - curSSID = cfg.sta_active_ssid; - } - - w.init = wifiInit; - w.startScanning = startScanning; -})(window.WiFi = {}); diff --git a/lang/en.php b/lang/en.php index e099ce2..9c57a65 100644 --- a/lang/en.php +++ b/lang/en.php @@ -60,6 +60,7 @@ return [ 'term.show_buttons' => 'Show buttons', 'term.loopback' => 'Local Echo', 'term.crlf_mode' => 'Enter sends CR+LF', + 'term.want_all_fn' => 'Capture all keys
(F5, F11, F12…)', 'term.button_msgs' => 'Button codes
(ASCII, dec, CSV)', 'cursor.block_blink' => 'Block, blinking', diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd9b9e5 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "espterm-front-end", + "version": "1.0.0", + "description": "ESPTerm web interface", + "license": "MPL-2.0", + "devDependencies": { + "babel-cli": "^6.26.0", + "babel-minify": "^0.2.0", + "babel-preset-env": "^1.6.0", + "node-sass": "^4.5.3", + "standard": "^10.0.3" + }, + "scripts": { + "babel": "babel $@", + "minify": "babel-minify $@", + "sass": "node-sass $@" + } +} diff --git a/pages/_head.php b/pages/_head.php index 37e5862..a5d3cd7 100644 --- a/pages/_head.php +++ b/pages/_head.php @@ -5,13 +5,12 @@ <?= $_GET['PAGE_TITLE'] ?> - - + + diff --git a/pages/about.php b/pages/about.php index a40f181..6e7d4c5 100644 --- a/pages/about.php +++ b/pages/about.php @@ -2,7 +2,7 @@

ESP8266 Remote Terminal

- +

© Ondřej Hruška, 2016-2017 @@ -17,10 +17,16 @@

Version

+
- + + + + + @@ -31,6 +37,7 @@
ESPTermv%vers_fw%, built %date% at %time%v%vers_fw%, built %date% at~%time%
Git hashback-end: %hash_backend%, + front-end: %hash_frontend%
libesphttpdv%vers_sdk%
+
diff --git a/pages/cfg_term.php b/pages/cfg_term.php index 10ed719..e4dba6b 100644 --- a/pages/cfg_term.php +++ b/pages/cfg_term.php @@ -165,6 +165,12 @@
+
+ + +
+
diff --git a/pages/help.php b/pages/help.php index 575d4d5..12bbb45 100644 --- a/pages/help.php +++ b/pages/help.php @@ -1,5 +1,10 @@
- Expand all | Collapse all + + Note: This list of commands is not exhaustive. \\ + There's a more detailed and technical + document available online.
diff --git a/pages/help/cmd_cursor.php b/pages/help/cmd_cursor.php index 4dd0d13..9ede2f2 100644 --- a/pages/help/cmd_cursor.php +++ b/pages/help/cmd_cursor.php @@ -184,6 +184,15 @@ Enable (`h`) or disable (`l`) cursor auto-wrap and screen auto-scroll + + + + \e[?12h \\ + \e[?12l + + + Toggle cursor blinking (`h` on, `l` off) + @@ -193,6 +202,15 @@ Show (`h`) or hide (`l`) the cursor + + + + \e[?45h \\ + \e[?45l + + + Enable (`h`) or disable (`l`) reverse wrap-around (when using "move left" or backspace) +
diff --git a/pages/help/cmd_screen.php b/pages/help/cmd_screen.php index 948b8a8..d19c0b3 100644 --- a/pages/help/cmd_screen.php +++ b/pages/help/cmd_screen.php @@ -34,6 +34,13 @@ Erase _n_ characters in line. + + + `\e[nb` + + Repeat last printed characters _n_ times (moving cursor and using the current style). + + diff --git a/pages/help/cmd_system.php b/pages/help/cmd_system.php index d8956fc..f7b19f0 100644 --- a/pages/help/cmd_system.php +++ b/pages/help/cmd_system.php @@ -18,6 +18,10 @@ The screen size, title and button labels remain unchanged. + + `\e[8;r;ct` + Set screen size to _r_ rows and _c_ columns (this is a command borrowed from Xterm) + `\e[5n` @@ -33,6 +37,15 @@ spontaneous restarts which require a full screen repaint. + + `\e[n q` + + Set cursor style: eg. `\e[3 q` (the space is part of the command!). + 0~-~block~(blink), 1~-~default, 2~-~block~(steady), 3~-~underline~(blink), + 4~-~underline~(steady), 5~-~I-bar~(blink), 6~-~I-bar~(steady). The default style (number 1) + can be configured in Terminal Settings + + `\e]0;t\a` Set screen title to _t_ (this is a standard OSC command) @@ -40,23 +53,48 @@ - \e]80+n;t\a + \e]8x;t\a - Set label for button _n_ = 1-5 (code 81-85) to _t_ - e.g.`\e]81;Yes\a` + Set label for button 1-5 (code 81-85) to _t_ - e.g.`\e]81;Yes\a` sets the first button text to "Yes". - \e]90+n;m\a + \e]9x;m\a + + + + Set message for button 1-5 (code 91-95) to _m_ - e.g.`\e]94;*\a` + sets the 3rd button to send "*" when pressed. The message can be up to + 10 bytes long. + + + + + + \e]9;t\a + + + + Show a notification with text _t_. This will be either a desktop notification + or a pop-up balloon. + + + + + + \e[?ns \\ + \e[?nr - Set message for button _n_ = 1-5 (code 81-85) to _m_ - e.g.`\e]94;iv\a` - sets the 3rd button to send string "iv" when pressed. + Save (`s`) and restore (`r`) any option set using `CSI ? n h`. + This is used by some applications to back up the original state before + making changes. @@ -67,7 +105,7 @@ - Show (`h`) or hide (`l`) action buttons (the blue buttons under the screen). + Show (`h`) or hide (`l`) the action buttons (the blue buttons under the screen). @@ -81,6 +119,33 @@ Show (`h`) or hide (`l`) menu/help links under the screen. + + + + \e[?2004h \\ + \e[?2004l + + + + Enable (`h`) or disable (`l`) Bracketed Paste mode. + This mode makes any text sent using the Upload Tool be preceded by `\e[200\~` + and terminated by `\e[201\~`. This is useful for distinguishing keyboard input + from uploads. + + + + + + \e[?1049h \\ + \e[?1049l + + + + Switch to (`h`) or from (`l`) an alternate screen. + ESPTerm can't implement this fully, so the original screen content is not saved, + but it will remember the cursor, screen size, terminal title, button labels and messages. + + @@ -93,10 +158,6 @@ SRM is the opposite of Local Echo, meaning `\e[12h` disables and `\e[12l` enables Local Echo. - - `\e[8;r;ct` - Set screen size to _r_ rows and _c_ columns (this is a command borrowed from Xterm) - diff --git a/pages/help/screen_behavior.php b/pages/help/screen_behavior.php index 68e3641..96fbd8d 100644 --- a/pages/help/screen_behavior.php +++ b/pages/help/screen_behavior.php @@ -3,7 +3,8 @@

- The initial screen size, title text and button labels can be configured in Terminal Settings. + The initial screen size, title text and button labels can be configured + in Terminal Settings.

@@ -13,5 +14,17 @@ repaint. If you experience issues (broken image due to dropped bytes), try adjusting those config options. It may also be useful to try different baud rates.

+ +

UTF-8 support

+ +

+ ESPTerm supports all UTF-8 characters, but to reduce the screen buffer RAM size, + only a small amount of unique multi-byte characters can be used at the same time + (up to 160, depending on compile flags). Unique multi-byte characters are stored in a + look-up table and are removed when they are no longer used on the screen. In + rare cases it can happen that a character stays in the table after no longer + being used (this can be noticed when the table fills up and new characters + are not shown correctly). This is fixed by clearing the screen (`\e[2J` or `\ec`). +

diff --git a/pages/help/sgr_colors.php b/pages/help/sgr_colors.php index 85a1047..b9a0e55 100644 --- a/pages/help/sgr_colors.php +++ b/pages/help/sgr_colors.php @@ -1,16 +1,18 @@ -
+

Commands: Color SGR

- Colors are set using SGR commands (like `\e[10;20;30m`). The following tables list the SGR codes to use. - Selected colors are used for any new text entered, as well as for empty space when using line and screen clearing commands. - The configured default colors can be restored using SGR 39 for foreground and SGR 49 for background. + Colors are set using SGR commands (like `\e[30;47m`). The following tables list the SGR + codes to use. Selected colors are used for any new text entered, as well as for empty + space when using clearing commands (except screen reset `\ec`, which first clears all + style attriutes. The configured default colors can be restored using `SGR 39` for + foreground and `SGR 49` for background.

- The actual color representation depends on a color theme which + The actual color representation of the basic 16 colors depends on a color theme which can be selected in Terminal Settings.

@@ -61,5 +63,19 @@ 106 107
+ +

256-color palette

+ +

+ ESPTerm supports in total 256 standard colors. The dark and bright basic colors are + numbered 0-7 and 8-15. To use colors higher than 15 (or 0-15 using this simpler numbering), + send `CSI 38 ; 5 ; n m`, where `n` is the color to set. Use 48 for background colors. +

+ +

+ For a fererence of all 256 shades please refer to + jonasjacek.github.io/colors + or look it up elsewhere. +

diff --git a/pages/help/sgr_styles.php b/pages/help/sgr_styles.php index a1e5237..bfa788a 100644 --- a/pages/help/sgr_styles.php +++ b/pages/help/sgr_styles.php @@ -3,7 +3,7 @@

- All text attributes are set using SGR commands like `\e[10;20;30m`, with up to 10 numbers separated by semicolons. + All text attributes are set using SGR commands like `\e[1;4m`, with up to 10 numbers separated by semicolons. To restore all attributes to their default states, use SGR 0: `\e[0m` or `\e[m`.

@@ -16,11 +16,23 @@ Faint222 Italic323 Underlined424 - Blink525 - Inverse727 Striked929 + Overline5355 + Blink525 + Inverse727 𝔉𝔯𝔞𝔨𝔱𝔲𝔯2023 + Conceal1828 +

1Conceal turns all characters invisible.

+ + diff --git a/pages/term.php b/pages/term.php index ea84d53..bbf97cd 100644 --- a/pages/term.php +++ b/pages/term.php @@ -1,3 +1,4 @@ + + -

+

-
+
+ +
+ +
+