├── .nvmrc ├── www ├── .gitignore ├── webpack.dev.js ├── prod.js ├── bootstrap.demo.js ├── bootstrap.benchmark.js ├── package.json ├── webpack.common.js ├── webpack.prod.js ├── benchmark.html ├── index.js ├── benchmark.js └── index.html ├── .cargo └── config ├── .gitignore ├── selenium ├── tsconfig.json ├── package.json ├── index.ts ├── tests.ts └── package-lock.json ├── scripts └── package.sh ├── RELEASING.md ├── LICENSE-MIT ├── tests ├── web.rs └── helpers │ └── mod.rs ├── src ├── utils.rs ├── selection.rs ├── extract.rs └── lib.rs ├── Cargo.toml ├── CHANGELOG.md ├── README.md ├── Cargo.lock ├── LICENSE-APACHE └── .circleci └── config.yml /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 2 | -------------------------------------------------------------------------------- /www/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.cargo/config: -------------------------------------------------------------------------------- 1 | [target.wasm32-unknown-unknown] 2 | runner = 'wasm-bindgen-test-runner' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target/ 2 | /pkg/ 3 | /bin/ 4 | **/*.rs.bk 5 | wasm-pack.log 6 | node_modules/ 7 | -------------------------------------------------------------------------------- /www/webpack.dev.js: -------------------------------------------------------------------------------- 1 | const {merge} = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge(common, { 5 | mode: 'development', 6 | devtool: 'inline-source-map', 7 | }); 8 | -------------------------------------------------------------------------------- /selenium/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "removeComments": true 7 | }, 8 | "exclude": ["node_modules"] 9 | } 10 | -------------------------------------------------------------------------------- /www/prod.js: -------------------------------------------------------------------------------- 1 | var express = require('express'); 2 | var app = express(); 3 | 4 | // Set the MIME type explicitly 5 | express.static.mime.define({'application/wasm': ['wasm']}); 6 | 7 | app.use(express.static('./dist')); 8 | 9 | app.listen(9999); 10 | -------------------------------------------------------------------------------- /www/bootstrap.demo.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import("./index.js") 5 | .catch(e => console.error("Error importing `index.js`:", e)); 6 | -------------------------------------------------------------------------------- /www/bootstrap.benchmark.js: -------------------------------------------------------------------------------- 1 | // A dependency graph that contains any wasm must all be imported 2 | // asynchronously. This `bootstrap.js` file does the single async import, so 3 | // that no one else needs to worry about it again. 4 | import("./benchmark.js") 5 | .catch(e => console.error("Error importing `benchmark.js`:", e)); 6 | -------------------------------------------------------------------------------- /selenium/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compose-area-selenium-tests", 3 | "version": "0.1.0", 4 | "description": "Selenium tests for the compose area project", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "ts-node --skip-project -O '{\"target\": \"ES2015\"}' index.ts $*" 8 | }, 9 | "author": "dbrgn", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@types/node": "^12.12.5", 13 | "@types/selenium-webdriver": "^4.0.5", 14 | "chai": "^4.2.0", 15 | "selenium-webdriver": "^4.0.0-alpha.5", 16 | "term-color": "^1.0.1", 17 | "ts-node": "^8.4.1", 18 | "typescript": "^3.6.4" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /www/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compose-area-demo", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "build": "webpack --config webpack.prod.js", 7 | "start": "webpack-dev-server --config webpack.dev.js --host 0.0.0.0" 8 | }, 9 | "author": "", 10 | "dependencies": { 11 | "compose-area": "file:../pkg" 12 | }, 13 | "devDependencies": { 14 | "benchmark": "^2.1.4", 15 | "copy-webpack-plugin": "^11.0.0", 16 | "hello-wasm-pack": "^0.1.0", 17 | "lodash": "^4.17.21", 18 | "webpack": "^5.66.0", 19 | "webpack-cli": "^5", 20 | "webpack-dev-server": "^4.7.3", 21 | "webpack-merge": "^5.8.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scripts/package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Package library for npm. 4 | # 5 | # This will combine both a bundler target and a web target into a single 6 | # package. 7 | set -euo pipefail 8 | 9 | # Clean up directories 10 | rm -rf pkg pkg-web 11 | 12 | # Build targets 13 | wasm-pack build --release --scope threema --target bundler -d pkg 14 | wasm-pack build --release --scope threema --target web -d pkg-web 15 | 16 | # Combine targets 17 | mkdir pkg/web/ 18 | mv pkg-web/compose_area* pkg-web/package.json pkg/web/ 19 | rm -r pkg-web/ 20 | 21 | # Ensure that package.json includes web files 22 | sed -i 's/"LICENSE-MIT"$/\0,\n "web\/*"/' pkg/package.json 23 | 24 | echo 'Done. Find your package in the pkg/ directory.' 25 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing 2 | 3 | Set variables: 4 | 5 | export VERSION=X.Y.Z 6 | export GPG_KEY=E7ADD9914E260E8B35DFB50665FDE935573ACDA6 7 | 8 | Update version numbers: 9 | 10 | vim -p Cargo.toml 11 | cargo update -p compose-area 12 | cd www && npm install && cd .. 13 | 14 | Update changelog: 15 | 16 | vim CHANGELOG.md 17 | 18 | Commit & tag: 19 | 20 | git commit -S${GPG_KEY} -m "Release v${VERSION}" 21 | git tag -s -u ${GPG_KEY} v${VERSION} -m "Version ${VERSION}" 22 | 23 | Publish: 24 | 25 | # We need to build both bundler and web targets, and combine them 26 | bash scripts/package.sh 27 | 28 | # Ensure that *_bg.js file is included: https://github.com/rustwasm/wasm-pack/issues/837 29 | cd pkg && npm publish --access=public && cd .. 30 | 31 | git push && git push --tags 32 | -------------------------------------------------------------------------------- /www/webpack.common.js: -------------------------------------------------------------------------------- 1 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 2 | const path = require('path'); 3 | 4 | module.exports = { 5 | entry: { 6 | demo: './bootstrap.demo.js', 7 | benchmark: './bootstrap.benchmark.js', 8 | }, 9 | output: { 10 | path: path.resolve(__dirname, 'dist'), 11 | filename: 'bootstrap.[name].bundle.js', 12 | }, 13 | module: { 14 | noParse: [ 15 | /benchmark\/benchmark\.js/, 16 | ], 17 | }, 18 | plugins: [ 19 | new CopyWebpackPlugin({ 20 | patterns: [ 21 | { from: 'index.html', to: 'index.html' }, 22 | { from: 'benchmark.html', to: 'benchmark.html' }, 23 | ], 24 | }), 25 | ], 26 | experiments: { 27 | syncWebAssembly: true, 28 | }, 29 | }; 30 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018–2020 Threema GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/web.rs: -------------------------------------------------------------------------------- 1 | //! Test suite for the Web and headless browsers. 2 | #![cfg(target_arch = "wasm32")] 3 | 4 | use compose_area::ComposeArea; 5 | use wasm_bindgen_test::*; 6 | 7 | mod helpers; 8 | 9 | use helpers::{setup_compose_area_test, setup_test}; 10 | 11 | const WRAPPER_ID: &str = "testwrapper"; 12 | 13 | wasm_bindgen_test_configure!(run_in_browser); 14 | 15 | #[wasm_bindgen_test] 16 | fn test_bind_to() { 17 | setup_test(); 18 | let document = setup_compose_area_test(WRAPPER_ID); 19 | 20 | // Initial empty wrapper 21 | let wrapper_before = helpers::get_wrapper(&document, WRAPPER_ID); 22 | assert_eq!( 23 | wrapper_before.outer_html(), 24 | format!("
", WRAPPER_ID) 25 | ); 26 | 27 | let wrapper = document.get_element_by_id(WRAPPER_ID).unwrap(); 28 | ComposeArea::bind_to(wrapper, Some("trace".into())); 29 | 30 | // Initialized wrapper 31 | let wrapper_after = helpers::get_wrapper(&document, WRAPPER_ID); 32 | assert_eq!(wrapper_after.class_name(), "cawrapper initialized"); 33 | assert_eq!( 34 | wrapper_after.get_attribute("contenteditable").unwrap(), 35 | "true" 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /www/webpack.prod.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const {merge} = require('webpack-merge'); 3 | const common = require('./webpack.common.js'); 4 | 5 | 6 | module.exports = merge(common, { 7 | mode: 'none', 8 | devtool: 'source-map', 9 | performance: { 10 | hints: 'warning' 11 | }, 12 | output: { 13 | pathinfo: false 14 | }, 15 | optimization: { 16 | nodeEnv: 'production', 17 | flagIncludedChunks: true, 18 | chunkIds: 'total-size', 19 | moduleIds: 'size', 20 | sideEffects: true, 21 | usedExports: true, 22 | concatenateModules: true, 23 | splitChunks: { 24 | hidePathInfo: true, 25 | minSize: 30000, 26 | maxAsyncRequests: 5, 27 | maxInitialRequests: 3, 28 | }, 29 | emitOnErrors: false, 30 | checkWasmTypes: true, 31 | minimize: false, 32 | }, 33 | plugins: [ 34 | new webpack.DefinePlugin({ "process.env.NODE_ENV": JSON.stringify("production") }), 35 | new webpack.optimize.ModuleConcatenationPlugin(), 36 | new webpack.NoEmitOnErrorsPlugin() 37 | ] 38 | }); 39 | -------------------------------------------------------------------------------- /www/benchmark.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Wasm Compose Area: Benchmarks 6 | 7 | 32 | 33 | 34 |

Compose Area: Benchmarks

35 |
36 | 37 |
38 |
39 |

40 |

[[VERSION]]

41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | use log::Level; 3 | use web_sys::{Element, Node}; 4 | 5 | cfg_if! { 6 | // When the `console_error_panic_hook` feature is enabled, we can call the 7 | // `set_panic_hook` function at least once during initialization, and then 8 | // we will get better error messages if our code ever panics. 9 | // 10 | // For more details see 11 | // https://github.com/rustwasm/console_error_panic_hook#readme 12 | if #[cfg(feature = "console_error_panic_hook")] { 13 | extern crate console_error_panic_hook; 14 | pub use self::console_error_panic_hook::set_once as set_panic_hook; 15 | } else { 16 | #[inline] 17 | pub fn set_panic_hook() {} 18 | } 19 | } 20 | 21 | cfg_if! { 22 | // When the `console_log` feature is enabled, forward log calls to the 23 | // JS console. 24 | if #[cfg(feature = "console_log")] { 25 | pub fn init_log(level: Level) { 26 | // Best effort, ignore error if initialization fails. 27 | // (This could be the case if the logger is initialized multiple 28 | // times.) 29 | let _ = console_log::init_with_level(level); 30 | } 31 | } else { 32 | pub fn init_log(_level: Level) {} 33 | } 34 | } 35 | 36 | /// Return the last child node of the specified parent element (or `None`). 37 | pub(crate) fn get_last_child(parent: &Element) -> Option { 38 | let child_nodes = parent.child_nodes(); 39 | let child_count = child_nodes.length(); 40 | if child_count == 0 { 41 | return None; 42 | } 43 | Some( 44 | child_nodes 45 | .get(child_count - 1) 46 | .expect("Could not access last child node"), 47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "compose-area" 3 | description = "A compose area with support for Emoji, written with Rust + Webassembly." 4 | version = "0.4.6" 5 | authors = ["Danilo Bargen "] 6 | license = "MIT OR Apache-2.0" 7 | repository = "https://github.com/threema-ch/compose-area" 8 | edition = "2018" 9 | 10 | [lib] 11 | crate-type = ["cdylib", "rlib"] 12 | 13 | [features] 14 | default = ["console_error_panic_hook", "console_log"] 15 | 16 | [dependencies] 17 | cfg-if = "1" 18 | wasm-bindgen = { version = "=0.2.79", features = ["spans", "std"] } 19 | 20 | # The `console_error_panic_hook` crate provides better debugging of panics by 21 | # logging them with `console.error`. This is great for development, but requires 22 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 23 | # code size when deploying. 24 | console_error_panic_hook = { version = "0.1.1", optional = true } 25 | 26 | # `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size 27 | # compared to the default allocator's ~10K. It is slower than the default 28 | # allocator, however. 29 | wee_alloc = { version = "0.4.2", optional = true } 30 | 31 | # Add logging support 32 | log = "0.4" 33 | console_log = { version = "1.0", optional = true } 34 | 35 | [dependencies.web-sys] 36 | version = "0.3" 37 | features = [ 38 | "console", 39 | "CharacterData", 40 | "Document", 41 | "DomTokenList", 42 | "Element", 43 | "HtmlDocument", 44 | "HtmlElement", 45 | "HtmlImageElement", 46 | "Node", 47 | "NodeList", 48 | "Range", 49 | "Selection", 50 | "Window", 51 | ] 52 | 53 | [dev-dependencies] 54 | wasm-bindgen-test = "0.3" 55 | percy-dom = "0.7" 56 | 57 | [profile.release] 58 | # Tell `rustc` to optimize for small code size. 59 | opt-level = "s" 60 | -------------------------------------------------------------------------------- /selenium/index.ts: -------------------------------------------------------------------------------- 1 | import { Builder } from 'selenium-webdriver'; 2 | import * as TermColor from 'term-color'; 3 | 4 | import { TESTS } from './tests'; 5 | 6 | 7 | // Configuration 8 | const TEST_URL = 'http://localhost:8080/'; 9 | 10 | 11 | // Script arguments 12 | const browser = process.argv[2]; 13 | const filterQuery = process.argv[3]; 14 | if (browser === undefined) { 15 | console.error('Error: Missing browser argument'); 16 | process.exit(1); 17 | } 18 | 19 | 20 | // Test runner function 21 | async function main() { 22 | const driver = await new Builder().forBrowser(browser).build(); 23 | let i = 0; 24 | let success = 0; 25 | let failed = 0; 26 | let skipped = 0; 27 | console.info('\n====== COMPOSE AREA SELENIUM TESTS ======\n'); 28 | if (filterQuery !== undefined) { 29 | console.info(`Filter query: "${filterQuery}"\n`); 30 | } 31 | try { 32 | for (const [name, testfunc] of TESTS) { 33 | try { 34 | if (filterQuery === undefined || name.toLowerCase().indexOf(filterQuery.toLowerCase()) !== -1) { 35 | i++; 36 | console.info(TermColor.blue(`» ${i}: Running test: ${name}`)); 37 | await driver.get(TEST_URL); 38 | await testfunc(driver); 39 | success++; 40 | } else { 41 | skipped++; 42 | } 43 | } catch (e) { 44 | console.error(TermColor.red(`\nTest failed:`)); 45 | console.error(e); 46 | failed++; 47 | } 48 | } 49 | } finally { 50 | await driver.quit(); 51 | } 52 | const colorFunc = failed > 0 ? TermColor.red : TermColor.green; 53 | console.info(colorFunc(`\nSummary: ${i} tests run, ${success} succeeded, ${failed} failed, ${skipped} skipped`)); 54 | process.exit(failed > 0 ? 1 : 0); 55 | } 56 | 57 | 58 | // Run! 59 | main(); 60 | -------------------------------------------------------------------------------- /tests/helpers/mod.rs: -------------------------------------------------------------------------------- 1 | use log::Level; 2 | use percy_dom::{event::EventsByNodeIdx, VirtualNode}; 3 | 4 | /// Setup function that should be called by all tests. 5 | pub(crate) fn setup_test() { 6 | // Initialize console logger, ignore errors. (Errors occur if the logger is 7 | // initialized multiple times, we can ignore that.) 8 | let _ = console_log::init_with_level(Level::Trace); 9 | } 10 | 11 | /// Set up the compose area test. Return reference to document. 12 | pub(crate) fn setup_compose_area_test(wrapper_id: &str) -> web_sys::Document { 13 | // Get references to DOM objects 14 | let window = web_sys::window().expect("No global `window` exists"); 15 | let document = window.document().expect("Should have a document on window"); 16 | let body = document.body().expect("Could not find body"); 17 | 18 | // Make sure to remove any existing wrapper elements 19 | if let Some(old_wrapper_element) = document.get_element_by_id(wrapper_id) { 20 | old_wrapper_element.remove(); 21 | } 22 | 23 | // Clear any selections 24 | unset_caret_position(); 25 | 26 | // Insert wrapper element into DOM 27 | let wrapper = { 28 | let mut div = VirtualNode::element("div"); 29 | div.as_velement_mut() 30 | .unwrap() 31 | .attrs 32 | .insert("id".to_string(), wrapper_id.into()); 33 | div 34 | }; 35 | body.append_child(&wrapper.create_dom_node(0, &mut EventsByNodeIdx::new())) 36 | .expect("Could not append node to body"); 37 | 38 | // Ensure that wrapper was created 39 | let wrapper_element = document.get_element_by_id(wrapper_id).unwrap(); 40 | assert_eq!( 41 | wrapper_element.outer_html(), 42 | format!("
", wrapper_id) 43 | ); 44 | 45 | document 46 | } 47 | 48 | /// Return the wrapper DOM element. 49 | pub(crate) fn get_wrapper(document: &web_sys::Document, wrapper_id: &str) -> web_sys::Element { 50 | document 51 | .get_element_by_id(wrapper_id) 52 | .expect("Could not find wrapper element") 53 | } 54 | 55 | /// Remove all selection ranges from the DOM. 56 | pub(crate) fn unset_caret_position() { 57 | let window = web_sys::window().expect("No global `window` exists"); 58 | let sel = window.get_selection().unwrap().unwrap(); 59 | sel.remove_all_ranges().unwrap(); 60 | } 61 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This project follows semantic versioning. 4 | 5 | Possible log types: 6 | 7 | - `[added]` for new features. 8 | - `[changed]` for changes in existing functionality. 9 | - `[deprecated]` for once-stable features removed in upcoming releases. 10 | - `[removed]` for deprecated features removed in this release. 11 | - `[fixed]` for any bug fixes. 12 | - `[security]` to invite users to upgrade in case of vulnerabilities. 13 | - `[maintenance]` for maintenance work like dependency updates. 14 | 15 | 16 | ### v0.4.6 (2022-04-04) 17 | 18 | - [fixed] Fix another double-newline issue (#96) 19 | - [maintenance] Update dependencies 20 | 21 | ### v0.4.5 (2022-02-14) 22 | 23 | - [added] Add web target to package in addition to bundler target (#91) 24 | - [added] Add `is_empty` method (#93) 25 | - [maintenance] Update dependencies 26 | 27 | ### v0.4.4 (2020-12-24) 28 | 29 | - [fixed] Fix packaging bug caused by wasm-pack (https://github.com/rustwasm/wasm-pack/issues/837) 30 | 31 | ### v0.4.3 (2020-12-24) 32 | 33 | - [fixed] Avoid duplicated newlines when pressing enter (#72, #74) 34 | - [maintenance] Update dependencies (#73) 35 | 36 | ### v0.4.2 (2020-06-09) 37 | 38 | - [fixed] Fix packaging bug caused by wasm-pack (https://github.com/rustwasm/wasm-pack/issues/837) 39 | 40 | ### v0.4.1 (2020-06-09) 41 | 42 | - [added] ComposeArea.insert_image: Downcast Element to HtmlElement (#63) 43 | 44 | ### v0.4.0 (2020-06-09) 45 | 46 | - [maintenance] Upgrade wasm-pack. This upgrade generates more strict 47 | TypeScript declaration files. 48 | 49 | ### v0.3.7 (2020-06-09) 50 | 51 | - [fixed] Downgrade wasm-pack again, because the newer version generated 52 | potentially semver-incommpatible type declarations (#62) 53 | 54 | ### v0.3.6 (2020-06-02) 55 | 56 | - [maintenance] Update dependencies (#58, #59, #60) 57 | 58 | ### v0.3.5 (2020-01-21) 59 | 60 | - [maintenance] Update dependencies (#56) 61 | 62 | ### v0.3.4 (2019-10-28) 63 | 64 | - [maintenance] Upgrade wasm-bindgen to 0.2.50 65 | 66 | ### v0.3.3 (2019-06-27) 67 | 68 | - [fixed] Never trim text nodes (#47) 69 | 70 | ### v0.3.2 (2019-06-27) 71 | 72 | - [fixed] Handle newlines in Chromium with `white-space: pre` (#44) 73 | - [fixed] Fix undo stack (#45) 74 | - [maintenance] Upgrade wasm-bindgen to 0.2.47 (#46) 75 | 76 | ### v0.3.1 (2019-05-22) 77 | 78 | - [fixed] Fix offset bug in "word at caret" methods (#41) 79 | 80 | ### v0.3.0 (2019-05-22) 81 | 82 | - [added] Add `ComposeArea::get_word_at_caret` (#39) 83 | - [added] Add `ComposeArea::select_word_at_caret` (#39) 84 | - [fixed] Thanks to a fix in wasm-bindgen, optional parameters in the 85 | TypeScript declaration files should now be marked as omittable 86 | - [maintenance] Upgrade wasm-bindgen to 0.2.45 (#40) 87 | 88 | ### v0.2.2 (2019-05-13) 89 | 90 | - [added] More logging, especially on trace level 91 | 92 | ### v0.2.1 (2019-05-13) 93 | 94 | - [added] Configurable log level (#37) 95 | - [maintenance] Upgrade wasm-bindgen to 0.2.42 (#33) 96 | 97 | ### v0.2.0 (2019-04-25) 98 | 99 | - [added] Add `ComposeArea::focus` (#29) 100 | - [added] Add `ComposeArea::clear` (#30) 101 | - [added] Expose `ComposeArea::insert_node` (#31) 102 | - [changed] `ComposeArea::insert_image` now returns reference to the img element 103 | - [changed] `ComposeArea::get_text`: Make `no_trim` parameter optional 104 | - [changed] `ComposeArea::bind_to`: Stop inserting `
` element 105 | - [maintenance] Upgrade wasm-bindgen to 0.2.42 106 | 107 | ### v0.1.1 (2019-04-23) 108 | 109 | - [changed] The standalone `bind_to` function was moved to `ComposeArea.bind_to` 110 | - [changed] The `bind_to` method now accepts an element reference instead of an 111 | ID string 112 | 113 | ### v0.1.0 (2019-04-17) 114 | 115 | Initial release. Might still be a bit buggy. 116 | -------------------------------------------------------------------------------- /www/index.js: -------------------------------------------------------------------------------- 1 | import {ComposeArea} from 'compose-area'; 2 | 3 | // Elements 4 | const wrapper = document.getElementById('wrapper'); 5 | const logDiv = document.querySelector('#log div'); 6 | const extractedDiv = document.querySelector('#extracted div'); 7 | const selectionDiv = document.querySelector('#selection div'); 8 | const wordcontextDiv = document.querySelector('#wordcontext div'); 9 | const rawDiv = document.querySelector('#raw div'); 10 | 11 | // Initialize compose area 12 | const composeArea = ComposeArea.bind_to(wrapper, "trace"); 13 | window.composeArea = composeArea; 14 | 15 | // Helper functions 16 | 17 | let startTime = null; 18 | 19 | function log() { 20 | if (startTime === null) { 21 | startTime = new Date(); 22 | } 23 | console.log(...arguments); 24 | const ms = (new Date() - startTime).toString(); 25 | const pad = ' '; 26 | const timestamp = `${pad.substring(0, pad.length - ms.length) + ms}`; 27 | logDiv.innerHTML += `${timestamp} ${arguments[0]}
`; 28 | } 29 | 30 | function updateSelectionRange(e) { 31 | log('⚙️ store_selection_range'); 32 | let range_result = composeArea.store_selection_range(); 33 | log('⚙️ ⤷ ' + range_result.to_string_compact()); 34 | showState(); 35 | } 36 | 37 | function showState() { 38 | // Extract text 39 | const text = composeArea.get_text(); 40 | extractedDiv.innerText = text.replace(/\n/g, '↵\n'); 41 | 42 | // Get range 43 | const range_result = composeArea.fetch_range(); 44 | selectionDiv.innerText = range_result.to_string(); 45 | 46 | // Get word context 47 | const wac = composeArea.get_word_at_caret(); 48 | if (wac) { 49 | wordcontextDiv.innerText = `${wac.before()}|${wac.after()}\nOffsets: (${wac.start_offset()}, ${wac.end_offset()})`; 50 | } else { 51 | wordcontextDiv.innerText = ''; 52 | } 53 | 54 | // Get raw HTML 55 | rawDiv.innerText = wrapper.innerHTML; 56 | } 57 | 58 | 59 | // Add event listeners 60 | 61 | wrapper.addEventListener('keydown', (e) => { 62 | log('⚡ keydown', e); 63 | }); 64 | wrapper.addEventListener('keyup', (e) => { 65 | log('⚡ keyup', e); 66 | }); 67 | wrapper.addEventListener('mouseup', (e) => { 68 | log('⚡ mouseup', e); 69 | }); 70 | wrapper.addEventListener('paste', (e) => { 71 | log('⚡ paste', e); 72 | e.preventDefault(); 73 | const clipboardData = e.clipboardData.getData('text/plain'); 74 | if (clipboardData) { 75 | log('⚙️ insert_text'); 76 | composeArea.insert_text(clipboardData); 77 | } 78 | }); 79 | 80 | // Note: Unfortunately the selectionchange listener can only be set on document 81 | // level, not on the wrapper itself. 82 | document.addEventListener('selectionchange', (e) => { 83 | log('⚡ selectionchange', e); 84 | updateSelectionRange(); 85 | }); 86 | 87 | // Emoji handling 88 | 89 | function insertEmoji(e) { 90 | const img = e.target.nodeName === 'IMG' ? e.target : e.target.children[0]; 91 | log(`⚙️ insert_image`); 92 | const elem = composeArea.insert_image(img.src, img.alt, 'emoji'); 93 | 94 | // Ensure that emoji cannot be dragged 95 | elem.draggable = false; 96 | elem.ondragstart = (e) => e.preventDefault(); 97 | 98 | showState(); 99 | } 100 | document.getElementById('tongue').addEventListener('click', insertEmoji); 101 | document.getElementById('beers').addEventListener('click', insertEmoji); 102 | document.getElementById('facepalm').addEventListener('click', insertEmoji); 103 | 104 | // Other buttons 105 | document.getElementById('clearselection').addEventListener('click', (e) => { 106 | const sel = getSelection(); 107 | sel.removeAllRanges(); 108 | showState(); 109 | }); 110 | document.getElementById('focus').addEventListener('click', (e) => { 111 | composeArea.focus(); 112 | }); 113 | document.getElementById('selectword').addEventListener('click', (e) => { 114 | composeArea.select_word_at_caret(); 115 | }); 116 | -------------------------------------------------------------------------------- /www/benchmark.js: -------------------------------------------------------------------------------- 1 | // benchmark.js 2 | import _ from 'lodash'; 3 | import * as Benchmark from 'benchmark'; 4 | 5 | // compose-area 6 | import {ComposeArea} from 'compose-area'; 7 | 8 | // Assign modules to window object for testing purposes. 9 | window.Benchmark = Benchmark; 10 | 11 | // Create benchmark suite 12 | const suite = new Benchmark.Suite; 13 | 14 | // Setup and teardown helpers 15 | window.setupTest = function() { 16 | const rand = () => Math.random().toString(36).substring(7); 17 | const divId = 'test-' + rand() + rand(); 18 | const baseWrapper = document.getElementById('wrapper'); 19 | const testDiv = document.createElement('div'); 20 | testDiv.id = divId; 21 | baseWrapper.appendChild(testDiv) 22 | const composeArea = ComposeArea.bind_to(testDiv, "warn"); 23 | return { 24 | divId: divId, 25 | testDiv: testDiv, 26 | composeArea: composeArea, 27 | } 28 | } 29 | window.teardownTest = function(divId) { 30 | const baseWrapper = document.getElementById('wrapper'); 31 | baseWrapper.removeChild(document.getElementById(divId)); 32 | } 33 | 34 | // Add benchmark tests 35 | suite.add('1. Insert text "hello world"', { 36 | setup: () => { 37 | const ctx = setupTest(); 38 | }, 39 | fn: () => { 40 | ctx.composeArea.insert_text('hello world'); 41 | }, 42 | teardown: () => { 43 | teardownTest(ctx.divId); 44 | }, 45 | }); 46 | suite.add('2. Insert image', { 47 | setup: () => { 48 | const ctx = setupTest(); 49 | }, 50 | fn: () => { 51 | ctx.composeArea.insert_image('emoji.png', 'smile', 'emoji'); 52 | }, 53 | teardown: () => { 54 | teardownTest(ctx.divId); 55 | }, 56 | minSamples: 25, 57 | }); 58 | suite.add('3. Extract text from compose area', { 59 | setup: () => { 60 | const ctx = setupTest(); 61 | ctx.composeArea.insert_text('hello world '); 62 | ctx.composeArea.insert_image('emoji.png', ':smile:', 'emoji'); 63 | ctx.testDiv.appendChild(document.createElement('br')); 64 | ctx.composeArea.insert_text('This is a new line and some emoji: '); 65 | ctx.composeArea.insert_image('emoji1.png', ':smile:', 'emoji'); 66 | ctx.composeArea.insert_image('emoji2.png', ':smil:', 'emoji'); 67 | ctx.composeArea.insert_image('emoji3.png', ':smi:', 'emoji'); 68 | ctx.composeArea.insert_text(' end emoji'); 69 | }, 70 | fn: () => { 71 | window.lastText = ctx.composeArea.get_text(); 72 | }, 73 | teardown: () => { 74 | teardownTest(ctx.divId); 75 | }, 76 | }); 77 | suite.add('4. Fetch selection range', { 78 | setup: () => { 79 | const ctx = setupTest(); 80 | ctx.composeArea.insert_text('hello world'); 81 | }, 82 | fn: () => { 83 | window.lastPos = ctx.composeArea.fetch_range(); 84 | }, 85 | teardown: () => { 86 | teardownTest(ctx.divId); 87 | }, 88 | }); 89 | 90 | // Add listeners 91 | suite.on('start', function() { 92 | document.getElementById('results').innerHTML += 'Starting benchmark...

'; 93 | }); 94 | suite.on('cycle', function(e) { 95 | const t = e.target; 96 | const s = t.stats; 97 | const mean = (s.mean * 1000).toFixed(3); 98 | const rme = s.rme.toFixed(2); 99 | const samples = s.sample.length; 100 | const min = (Math.min(...s.sample) * 1000).toFixed(3); 101 | const max = (Math.max(...s.sample) * 1000).toFixed(3); 102 | document.getElementById('results').innerHTML += 103 | `${t.name}
mean ${mean} ms ±${rme}% (${samples} samples, min=${min} max=${max})
`; 104 | }); 105 | suite.on('complete', function() { 106 | document.getElementById('results').innerHTML += '
Benchmark complete!
'; 107 | }); 108 | suite.on('error', function(e) { 109 | console.error('Benchmark error:', e.target.error); 110 | }); 111 | 112 | // Add start button event listener 113 | document.getElementById('start').addEventListener('click', (e) => { 114 | suite.run({async: true}); 115 | }); 116 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Wasm Compose Area: Demo 6 | 7 | 90 | 91 | 92 |

Crazy Wasm Compose Area

93 |

Compose some content, if you dare!

94 |
95 |
96 | 99 | 102 | 105 |
106 | 107 | 108 | 109 |
110 |
111 |
112 |
113 |

Selection

114 |
115 |
116 |
117 |

Word at Caret

118 |
119 |
120 |
121 |

Extracted Text

122 |
123 |
124 |
125 |

Raw HTML

126 |
127 |
128 |
129 |
130 |

Log

131 |
132 |
133 |
134 |

[[VERSION]]

135 |

Links: Benchmarks | GitHub

136 | 137 | 138 | 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # compose-area 2 | 3 | [![CircleCI][circle-ci-badge]][circle-ci] 4 | [![License][license-badge]][license] 5 | 6 | A compose area with support for Emoji, written with Rust + Webassembly. 7 | 8 | Demo: https://threema-ch.github.io/compose-area/ 9 | 10 | 11 | ## Concepts 12 | 13 | This project provides a simple text editor with support for media content (e.g. 14 | emoji), implemented on top of a content editable div. 15 | 16 | The input handling is done entirely by the browser. The library should be 17 | notified every time the caret position is changed, so it can update its 18 | internal state. It provides methods to insert text, images or other block 19 | elements. Selection and caret position are handled automatically. 20 | 21 | 22 | ## Package on npmjs.com 23 | 24 | This project is published to npmjs.com: 25 | 26 | 27 | 28 | The published package contains files for two different [wasm-pack build 29 | targets](https://rustwasm.github.io/wasm-pack/book/commands/build.html#target): 30 | 31 | - The root directory contains files for the wasm-pack `bundler` target. You 32 | will need a bundler like webpack in order to use the library this way. 33 | - In the `web` subdirectory (i.e. `node_modules/@threema/compose-area/web/`) 34 | you will find files built for the wasm-pack `web` target. 35 | 36 | 37 | ## Setup 38 | 39 | Note: A dependency graph that contains any wasm must all be imported 40 | asynchronously. This can be done using 41 | [dynamic imports](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/import#Dynamic_Imports). 42 | 43 | ### Bootstrapping JS 44 | 45 | The simplest way is to use a bootstrapping js as the entry point to your entire application: 46 | 47 | ```js 48 | // bootstrap.js 49 | import('./index.js') 50 | .catch(e => console.error('Error importing `index.js`:', e)); 51 | ``` 52 | 53 | ```js 54 | // index.js 55 | import * as ca from '@threema/compose-area'; 56 | ``` 57 | 58 | ### Dynamic Import (Promise) 59 | 60 | Alternatively, import the library asynchronously: 61 | 62 | ```js 63 | import('@threema/compose-area') 64 | .then((ca) => { 65 | // Use the library 66 | }); 67 | ``` 68 | 69 | If you're in an asynchronous context, you can also use the `await` keyword. 70 | 71 | ```js 72 | const ca = await import('@threema/compose-area'); 73 | ``` 74 | 75 | 76 | ## Usage 77 | 78 | ### Initialization 79 | 80 | This library requires a wrapper element with `white-space` set to `pre` or 81 | `pre-wrap` in order to work properly. 82 | 83 | ```html 84 |
85 | ``` 86 | 87 | First, bind to the wrapper element: 88 | 89 | ```js 90 | const area = ca.ComposeArea.bind_to(document.getElementById('wrapper')); 91 | ``` 92 | 93 | Because the insertion should work even when there is no selection / focus 94 | inside the compose area, the library needs to know about all selection change 95 | events. Register them using an event listener: 96 | 97 | ```js 98 | document.addEventListener('selectionchange', (e) => { 99 | area.store_selection_range(); 100 | }); 101 | ``` 102 | 103 | ### Inserting 104 | 105 | Now you can start typing inside the compose area. It behaves like a regular 106 | content editable div. 107 | 108 | To insert text or images through code, use the following two functions: 109 | 110 | ```js 111 | // src alt class 112 | area.insert_image("emoji.jpg", "😀", "emoji"); 113 | 114 | // text 115 | area.insert_text("hello"); 116 | ``` 117 | 118 | You can also insert HTML or a DOM node directly: 119 | 120 | ```js 121 | area.insert_html("
"); 122 | area.insert_node(document.createElement("span")); 123 | ``` 124 | 125 | *(Note: Due to browser limitations, inserting a node directly will not result 126 | in a new entry in the browser's internal undo stack. This means that the node 127 | insertion cannot be undone using Ctrl+Z. If you need that, use `insert_html` 128 | instead.)* 129 | 130 | The `insert_image` method returns a reference to the inserted element, so that 131 | you can set custom attributes on it. 132 | 133 | ```js 134 | const img = area.insert_image(...); 135 | img.draggable = false; 136 | img.ondragstart = (e) => e.preventDefault(); 137 | ``` 138 | 139 | If you want to properly handle pasting of formatted text, intercept the `paste` 140 | event: 141 | 142 | ```js 143 | wrapper.addEventListener('paste', (e) => { 144 | e.preventDefault(); 145 | const clipboardData = e.clipboardData.getData('text/plain'); 146 | if (clipboardData) { 147 | area.insert_text(clipboardData); 148 | } 149 | }); 150 | ``` 151 | 152 | ### Extracting Text 153 | 154 | To extract the text from the area, there's also a method: 155 | 156 | ```js 157 | area.get_text(); 158 | ``` 159 | 160 | By default, leading and trailing white-space is trimmed from the text. To 161 | disable this, pass `true` to the `get_text` method. 162 | 163 | ```js 164 | area.get_text(true /* no_trim */); 165 | ``` 166 | 167 | ### Other helpers 168 | 169 | To test whether the compose area is empty: 170 | 171 | ```js 172 | area.is_empty(); 173 | ``` 174 | 175 | By default, if the compose area contains purely white-space, this method still 176 | considers the compose area to be empty. If you want a compose area containing 177 | white-space to be treated as non-empty, pass `true` to the `is_empty` method. 178 | 179 | ```js 180 | area.is_empty(true /* no_trim */); 181 | ``` 182 | 183 | To focus the compose area programmatically: 184 | 185 | ```js 186 | area.focus(); 187 | ``` 188 | 189 | To clear the contents of the compose area: 190 | 191 | ```js 192 | area.clear(); 193 | ``` 194 | 195 | 196 | ## Dev Setup 197 | 198 | cargo install wasm-pack 199 | 200 | 201 | ## Building 202 | 203 | # Debug build 204 | wasm-pack build 205 | 206 | # Release build 207 | wasm-pack build --release -- --no-default-features 208 | 209 | 210 | ## Running the testproject 211 | 212 | # Setup npm 213 | cd www 214 | npm install 215 | 216 | # Run server 217 | npm run start 218 | 219 | 220 | ## Testing 221 | 222 | # Unit tests 223 | cargo test 224 | 225 | # Browser tests (headless) 226 | wasm-pack test --headless --firefox 227 | # ...or if you want to filter tests by name 228 | wasm-pack test --headless --firefox . -- 229 | 230 | # Selenium tests (test server must be started) 231 | cd selenium 232 | npm test firefox 233 | 234 | 235 | ## Linting 236 | 237 | # Setup 238 | rustup component add clippy 239 | 240 | # Run linting checks 241 | cargo clean && cargo clippy --all-targets --all-features 242 | 243 | 244 | ## License 245 | 246 | Licensed under either of 247 | 248 | * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or 249 | http://www.apache.org/licenses/LICENSE-2.0) 250 | * MIT license ([LICENSE-MIT](LICENSE-MIT) or 251 | http://opensource.org/licenses/MIT) 252 | 253 | at your option. 254 | 255 | 256 | 257 | [circle-ci]: https://circleci.com/gh/threema-ch/compose-area/tree/master 258 | [circle-ci-badge]: https://circleci.com/gh/threema-ch/compose-area/tree/master.svg?style=shield 259 | [license]: https://github.com/threema-ch/compose-area#license 260 | [license-badge]: https://img.shields.io/badge/License-Apache%202.0%20%2f%20MIT-blue.svg 261 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "bumpalo" 7 | version = "3.12.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" 10 | 11 | [[package]] 12 | name = "cfg-if" 13 | version = "0.1.10" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 22 | 23 | [[package]] 24 | name = "compose-area" 25 | version = "0.4.6" 26 | dependencies = [ 27 | "cfg-if 1.0.0", 28 | "console_error_panic_hook", 29 | "console_log", 30 | "log", 31 | "percy-dom", 32 | "wasm-bindgen", 33 | "wasm-bindgen-test", 34 | "web-sys", 35 | "wee_alloc", 36 | ] 37 | 38 | [[package]] 39 | name = "console_error_panic_hook" 40 | version = "0.1.7" 41 | source = "registry+https://github.com/rust-lang/crates.io-index" 42 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 43 | dependencies = [ 44 | "cfg-if 1.0.0", 45 | "wasm-bindgen", 46 | ] 47 | 48 | [[package]] 49 | name = "console_log" 50 | version = "1.0.0" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" 53 | dependencies = [ 54 | "log", 55 | "web-sys", 56 | ] 57 | 58 | [[package]] 59 | name = "html-macro" 60 | version = "0.2.2" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "32d9e492b1b5c71a7953fe63e5cce78af01619aa055520a0d6c3ce8613a8cf83" 63 | dependencies = [ 64 | "html-validation", 65 | "proc-macro2", 66 | "quote", 67 | "syn", 68 | ] 69 | 70 | [[package]] 71 | name = "html-validation" 72 | version = "0.1.2" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "1db2f65f15fd0ad8b2f07e8ef73c05fa5d7f231395c2f8083aaee794932464b1" 75 | dependencies = [ 76 | "lazy_static", 77 | ] 78 | 79 | [[package]] 80 | name = "js-sys" 81 | version = "0.3.56" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "a38fc24e30fd564ce974c02bf1d337caddff65be6cc4735a1f7eab22a7440f04" 84 | dependencies = [ 85 | "wasm-bindgen", 86 | ] 87 | 88 | [[package]] 89 | name = "lazy_static" 90 | version = "1.4.0" 91 | source = "registry+https://github.com/rust-lang/crates.io-index" 92 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 93 | 94 | [[package]] 95 | name = "libc" 96 | version = "0.2.140" 97 | source = "registry+https://github.com/rust-lang/crates.io-index" 98 | checksum = "99227334921fae1a979cf0bfdfcc6b3e5ce376ef57e16fb6fb3ea2ed6095f80c" 99 | 100 | [[package]] 101 | name = "log" 102 | version = "0.4.17" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 105 | dependencies = [ 106 | "cfg-if 1.0.0", 107 | ] 108 | 109 | [[package]] 110 | name = "memory_units" 111 | version = "0.4.0" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "8452105ba047068f40ff7093dd1d9da90898e63dd61736462e9cdda6a90ad3c3" 114 | 115 | [[package]] 116 | name = "percy-dom" 117 | version = "0.7.5" 118 | source = "registry+https://github.com/rust-lang/crates.io-index" 119 | checksum = "e5f0a239d87209f1a391ef8dabcebde493c0b2bb91978ff89dfd65f34dd06eae" 120 | dependencies = [ 121 | "html-macro", 122 | "js-sys", 123 | "virtual-node", 124 | "wasm-bindgen", 125 | "web-sys", 126 | ] 127 | 128 | [[package]] 129 | name = "proc-macro2" 130 | version = "1.0.52" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "1d0e1ae9e836cc3beddd63db0df682593d7e2d3d891ae8c9083d2113e1744224" 133 | dependencies = [ 134 | "unicode-ident", 135 | ] 136 | 137 | [[package]] 138 | name = "quote" 139 | version = "1.0.26" 140 | source = "registry+https://github.com/rust-lang/crates.io-index" 141 | checksum = "4424af4bf778aae2051a77b60283332f386554255d722233d09fbfc7e30da2fc" 142 | dependencies = [ 143 | "proc-macro2", 144 | ] 145 | 146 | [[package]] 147 | name = "scoped-tls" 148 | version = "1.0.1" 149 | source = "registry+https://github.com/rust-lang/crates.io-index" 150 | checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" 151 | 152 | [[package]] 153 | name = "syn" 154 | version = "1.0.109" 155 | source = "registry+https://github.com/rust-lang/crates.io-index" 156 | checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" 157 | dependencies = [ 158 | "proc-macro2", 159 | "quote", 160 | "unicode-ident", 161 | ] 162 | 163 | [[package]] 164 | name = "unicode-ident" 165 | version = "1.0.8" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "e5464a87b239f13a63a501f2701565754bae92d243d4bb7eb12f6d57d2269bf4" 168 | 169 | [[package]] 170 | name = "virtual-node" 171 | version = "0.3.2" 172 | source = "registry+https://github.com/rust-lang/crates.io-index" 173 | checksum = "012016d9ecac4c2f3fe36c7b0518dbdce24e9861afe2ed8eecced50cf5038ae0" 174 | dependencies = [ 175 | "html-validation", 176 | "js-sys", 177 | "wasm-bindgen", 178 | "web-sys", 179 | ] 180 | 181 | [[package]] 182 | name = "wasm-bindgen" 183 | version = "0.2.79" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "25f1af7423d8588a3d840681122e72e6a24ddbcb3f0ec385cac0d12d24256c06" 186 | dependencies = [ 187 | "cfg-if 1.0.0", 188 | "wasm-bindgen-macro", 189 | ] 190 | 191 | [[package]] 192 | name = "wasm-bindgen-backend" 193 | version = "0.2.79" 194 | source = "registry+https://github.com/rust-lang/crates.io-index" 195 | checksum = "8b21c0df030f5a177f3cba22e9bc4322695ec43e7257d865302900290bcdedca" 196 | dependencies = [ 197 | "bumpalo", 198 | "lazy_static", 199 | "log", 200 | "proc-macro2", 201 | "quote", 202 | "syn", 203 | "wasm-bindgen-shared", 204 | ] 205 | 206 | [[package]] 207 | name = "wasm-bindgen-futures" 208 | version = "0.4.29" 209 | source = "registry+https://github.com/rust-lang/crates.io-index" 210 | checksum = "2eb6ec270a31b1d3c7e266b999739109abce8b6c87e4b31fcfcd788b65267395" 211 | dependencies = [ 212 | "cfg-if 1.0.0", 213 | "js-sys", 214 | "wasm-bindgen", 215 | "web-sys", 216 | ] 217 | 218 | [[package]] 219 | name = "wasm-bindgen-macro" 220 | version = "0.2.79" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "2f4203d69e40a52ee523b2529a773d5ffc1dc0071801c87b3d270b471b80ed01" 223 | dependencies = [ 224 | "quote", 225 | "wasm-bindgen-macro-support", 226 | ] 227 | 228 | [[package]] 229 | name = "wasm-bindgen-macro-support" 230 | version = "0.2.79" 231 | source = "registry+https://github.com/rust-lang/crates.io-index" 232 | checksum = "bfa8a30d46208db204854cadbb5d4baf5fcf8071ba5bf48190c3e59937962ebc" 233 | dependencies = [ 234 | "proc-macro2", 235 | "quote", 236 | "syn", 237 | "wasm-bindgen-backend", 238 | "wasm-bindgen-shared", 239 | ] 240 | 241 | [[package]] 242 | name = "wasm-bindgen-shared" 243 | version = "0.2.79" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "3d958d035c4438e28c70e4321a2911302f10135ce78a9c7834c0cab4123d06a2" 246 | 247 | [[package]] 248 | name = "wasm-bindgen-test" 249 | version = "0.3.29" 250 | source = "registry+https://github.com/rust-lang/crates.io-index" 251 | checksum = "45c8d417d87eefa0087e62e3c75ad086be39433449e2961add9a5d9ce5acc2f1" 252 | dependencies = [ 253 | "console_error_panic_hook", 254 | "js-sys", 255 | "scoped-tls", 256 | "wasm-bindgen", 257 | "wasm-bindgen-futures", 258 | "wasm-bindgen-test-macro", 259 | ] 260 | 261 | [[package]] 262 | name = "wasm-bindgen-test-macro" 263 | version = "0.3.29" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "d0e560d44db5e73b69a9757a15512fe7e1ef93ed2061c928871a4025798293dd" 266 | dependencies = [ 267 | "proc-macro2", 268 | "quote", 269 | ] 270 | 271 | [[package]] 272 | name = "web-sys" 273 | version = "0.3.56" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "c060b319f29dd25724f09a2ba1418f142f539b2be99fbf4d2d5a8f7330afb8eb" 276 | dependencies = [ 277 | "js-sys", 278 | "wasm-bindgen", 279 | ] 280 | 281 | [[package]] 282 | name = "wee_alloc" 283 | version = "0.4.5" 284 | source = "registry+https://github.com/rust-lang/crates.io-index" 285 | checksum = "dbb3b5a6b2bb17cb6ad44a2e68a43e8d2722c997da10e928665c72ec6c0a0b8e" 286 | dependencies = [ 287 | "cfg-if 0.1.10", 288 | "libc", 289 | "memory_units", 290 | "winapi", 291 | ] 292 | 293 | [[package]] 294 | name = "winapi" 295 | version = "0.3.9" 296 | source = "registry+https://github.com/rust-lang/crates.io-index" 297 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 298 | dependencies = [ 299 | "winapi-i686-pc-windows-gnu", 300 | "winapi-x86_64-pc-windows-gnu", 301 | ] 302 | 303 | [[package]] 304 | name = "winapi-i686-pc-windows-gnu" 305 | version = "0.4.0" 306 | source = "registry+https://github.com/rust-lang/crates.io-index" 307 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 308 | 309 | [[package]] 310 | name = "winapi-x86_64-pc-windows-gnu" 311 | version = "0.4.0" 312 | source = "registry+https://github.com/rust-lang/crates.io-index" 313 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 314 | -------------------------------------------------------------------------------- /src/selection.rs: -------------------------------------------------------------------------------- 1 | /// Everything related to the caret position and DOM selection ranges. 2 | use wasm_bindgen::JsCast; 3 | use web_sys::{Node, Range, Selection, Text}; 4 | 5 | /// A position relative to a node. 6 | #[derive(Debug)] 7 | pub enum Position<'a> { 8 | /// Caret position is before the selected node. 9 | Before(&'a Node), 10 | 11 | /// Caret position is after the selected node. 12 | After(&'a Node), 13 | 14 | /// Caret position is at the specified offset from the start of the node. 15 | #[allow(dead_code)] 16 | Offset(&'a Node, u32), 17 | } 18 | 19 | /// Update the current selection range to match the specified `Position`. 20 | /// 21 | /// If the `end` parameter is `None`, then the selection range is collapsed. 22 | /// 23 | /// Return a reference to the created / updated range. 24 | pub fn set_selection_range(start: &Position, end: Option<&Position>) -> Option { 25 | let window = web_sys::window().expect("no global `window` exists"); 26 | let document = window.document().expect("should have a document on window"); 27 | 28 | // Get selection 29 | let selection = match window 30 | .get_selection() 31 | .expect("Could not get selection from window") 32 | { 33 | Some(sel) => sel, 34 | None => { 35 | error!("Could not get window selection"); 36 | return None; 37 | } 38 | }; 39 | 40 | // Get the current selection range. Create a new range if necessary. 41 | let range = if selection.range_count() == 0 { 42 | document.create_range().expect("Could not create range") 43 | } else { 44 | selection 45 | .get_range_at(0) 46 | .expect("Could not get range at index 0") 47 | }; 48 | 49 | // Set range start 50 | match start { 51 | Position::After(node) => { 52 | range 53 | .set_start_after(node) 54 | .expect("Could not set_start_after"); 55 | } 56 | Position::Before(node) => { 57 | range 58 | .set_start_before(node) 59 | .expect("Could not set_start_before"); 60 | } 61 | Position::Offset(node, offset) => { 62 | range.set_start(node, *offset).expect("Could not set_start"); 63 | } 64 | } 65 | 66 | // Set range end 67 | match end { 68 | Some(Position::After(node)) => { 69 | range.set_end_after(node).expect("Could not set_end_after"); 70 | } 71 | Some(Position::Before(node)) => { 72 | range 73 | .set_end_before(node) 74 | .expect("Could not set_end_before"); 75 | } 76 | Some(Position::Offset(node, offset)) => { 77 | range.set_end(node, *offset).expect("Could not set_start"); 78 | } 79 | None => range.collapse_with_to_start(true), 80 | } 81 | 82 | activate_selection_range(&selection, &range); 83 | Some(range) 84 | } 85 | 86 | /// Activate the specified selection range in the DOM. Remove all previous 87 | /// ranges. 88 | pub fn activate_selection_range(selection: &Selection, range: &Range) { 89 | // Note: In theory we don't need to re-add the range to the document if 90 | // it's already there. Unfortunately, Safari is not spec-compliant 91 | // and returns a copy of the range instead of a reference when using 92 | // selection.getRangeAt(). Thus, we need to remove the existing 93 | // ranges and (re-)add our range to the DOM. 94 | // 95 | // See https://bugs.webkit.org/show_bug.cgi?id=145212 96 | selection.remove_all_ranges().unwrap(); 97 | selection.add_range(range).expect("Could not add range"); 98 | } 99 | 100 | #[cfg(test)] 101 | pub fn unset_selection_range() { 102 | let window = web_sys::window().expect("No global `window` exists"); 103 | let sel = window.get_selection().unwrap().unwrap(); 104 | sel.remove_all_ranges().unwrap(); 105 | } 106 | 107 | /// This function will check whether the current selection range is adjacent to 108 | /// a text node. It will then modify the range so that the `startContainer` is 109 | /// that text node. 110 | /// 111 | /// If the selection is not directly preceding a text node or within a text 112 | /// node with offset 0, `false` will be returned and the range will not be 113 | /// modified. 114 | /// 115 | /// Non-collapsed ranges will be immediately rejected. 116 | /// 117 | /// Examples of ranges (denoted with the `|`) that are successfully modified: 118 | /// 119 | /// - `"|abc"` -> `"|abc"` 120 | /// - `"ab|c"` -> `"ab|c"` 121 | /// - `"abc|"` -> `"abc|"` 122 | /// - `"abc"|` -> `"abc|"` 123 | /// - `"abc"|` -> `"abc|"` 124 | /// 125 | /// Examples of ranges where `false` will be returned: 126 | /// 127 | /// - `|"abc"` 128 | /// - `"abc"|` 129 | /// - `"abc"|` 130 | /// - `"abc"|` 131 | /// 132 | pub fn glue_range_to_text(range: &mut Range) -> bool { 133 | // Reject non-collapsed ranges 134 | if !range.collapsed() { 135 | return false; 136 | } 137 | 138 | // Determine node type of container 139 | let container = range 140 | .start_container() 141 | .expect("Could not get start container"); 142 | match container.node_type() { 143 | Node::TEXT_NODE => true, 144 | Node::ELEMENT_NODE => { 145 | let offset: u32 = range.start_offset().expect("Could not get start offset"); 146 | if offset == 0 { 147 | false 148 | } else if let Some(prev_sibling) = container.child_nodes().get(offset - 1) { 149 | if prev_sibling.node_type() == Node::TEXT_NODE { 150 | let length = prev_sibling.dyn_ref::().unwrap().length(); 151 | range 152 | .set_start(&prev_sibling, length) 153 | .expect("Could not set_start"); 154 | range.collapse_with_to_start(true); 155 | true 156 | } else { 157 | false 158 | } 159 | } else { 160 | true 161 | } 162 | } 163 | _ => false, 164 | } 165 | } 166 | 167 | #[cfg(test)] 168 | mod tests { 169 | use super::*; 170 | 171 | use wasm_bindgen_test::wasm_bindgen_test; 172 | use web_sys::Document; 173 | 174 | fn document() -> Document { 175 | // Get references 176 | let window = web_sys::window().expect("No global `window` exists"); 177 | window.document().expect("Should have a document on window") 178 | } 179 | 180 | mod glue_range_to_text { 181 | use super::*; 182 | 183 | /// "hell|o" 184 | #[wasm_bindgen_test] 185 | fn in_text_node() { 186 | let node = document().create_text_node("hello"); 187 | let mut range = document().create_range().unwrap(); 188 | range.set_start(&node, 4).unwrap(); 189 | range.collapse_with_to_start(true); 190 | 191 | assert!(glue_range_to_text(&mut range)); 192 | assert_eq!(range.start_offset().unwrap(), 4); 193 | } 194 | 195 | /// "|hello" 196 | #[wasm_bindgen_test] 197 | fn at_start_of_text_node() { 198 | let node = document().create_text_node("hello"); 199 | let mut range = document().create_range().unwrap(); 200 | range.set_start(&node, 0).unwrap(); 201 | range.collapse_with_to_start(true); 202 | 203 | assert!(glue_range_to_text(&mut range)); 204 | assert_eq!(range.start_offset().unwrap(), 0); 205 | } 206 | 207 | ///
"hello"|
208 | #[wasm_bindgen_test] 209 | fn after_text_node() { 210 | let div = document().create_element("div").unwrap(); 211 | let node = document().create_text_node("hello"); 212 | div.append_child(&node).unwrap(); 213 | let mut range = document().create_range().unwrap(); 214 | range.set_start_after(&node).unwrap(); 215 | range.collapse_with_to_start(true); 216 | assert_eq!(range.start_offset().unwrap(), 1); 217 | assert_eq!(range.end_offset().unwrap(), 1); 218 | 219 | assert!(glue_range_to_text(&mut range)); 220 | assert_eq!(range.start_offset().unwrap(), 5); 221 | assert_eq!(range.end_offset().unwrap(), 5); 222 | } 223 | 224 | #[wasm_bindgen_test] 225 | fn not_collapsed() { 226 | let node = document().create_text_node("hello"); 227 | let mut range = document().create_range().unwrap(); 228 | range.set_start(&node, 2).unwrap(); 229 | range.set_end(&node, 3).unwrap(); 230 | 231 | // Fails 232 | assert!(!glue_range_to_text(&mut range)); 233 | 234 | // Unmodified 235 | assert_eq!(range.start_offset().unwrap(), 2); 236 | assert_eq!(range.end_offset().unwrap(), 3); 237 | } 238 | 239 | ///
|"hello"
240 | #[wasm_bindgen_test] 241 | fn before_text() { 242 | let div = document().create_element("div").unwrap(); 243 | let text = document().create_text_node("hello"); 244 | div.append_child(&text).unwrap(); 245 | 246 | let mut range = document().create_range().unwrap(); 247 | range.set_start_before(&text).unwrap(); 248 | range.collapse_with_to_start(true); 249 | 250 | // Fails 251 | assert!(!glue_range_to_text(&mut range)); 252 | } 253 | 254 | ///
"hello"|
255 | #[wasm_bindgen_test] 256 | fn after_inner_element() { 257 | let div = document().create_element("div").unwrap(); 258 | let img = document().create_element("img").unwrap(); 259 | let text = document().create_text_node("hello"); 260 | div.append_child(&text).unwrap(); 261 | div.append_child(&img).unwrap(); 262 | 263 | let mut range = document().create_range().unwrap(); 264 | range.set_start_after(&img).unwrap(); 265 | range.collapse_with_to_start(true); 266 | 267 | // Fails 268 | assert!(!glue_range_to_text(&mut range)); 269 | } 270 | 271 | ///
"hello"|
272 | #[wasm_bindgen_test] 273 | fn after_outer_element() { 274 | let div = document().create_element("div").unwrap(); 275 | let span = document().create_element("span").unwrap(); 276 | let text = document().create_text_node("hello"); 277 | span.append_child(&text).unwrap(); 278 | div.append_child(&span).unwrap(); 279 | 280 | let mut range = document().create_range().unwrap(); 281 | range.set_start_after(&span).unwrap(); 282 | range.collapse_with_to_start(true); 283 | 284 | // Fails 285 | assert!(!glue_range_to_text(&mut range)); 286 | } 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /src/extract.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::JsCast; 2 | use web_sys::{Element, HtmlImageElement, Node}; 3 | 4 | /// Process a DOM node recursively and extract text. 5 | /// 6 | /// Convert elements like images to alt text. 7 | pub fn extract_text(root_element: &Element, no_trim: bool) -> String { 8 | let mut text = String::new(); 9 | visit_child_nodes(root_element, &mut text); 10 | if no_trim { 11 | text 12 | } else { 13 | text.trim().to_string() 14 | } 15 | } 16 | 17 | /// Used by `extract_text`. 18 | /// 19 | /// TODO: This could be optimized by avoiding copies and re-allocations. 20 | fn visit_child_nodes(parent_node: &Element, text: &mut String) { 21 | let mut prev_node_type = String::new(); 22 | let children = parent_node.child_nodes(); 23 | for i in 0..children.length() { 24 | let node = match children.item(i) { 25 | Some(n) => n, 26 | None => { 27 | warn!("visit_child_nodes: Index out of bounds"); 28 | return; 29 | } 30 | }; 31 | match node.node_type() { 32 | Node::TEXT_NODE => { 33 | if prev_node_type == "div" { 34 | // A text node following a div should go on a new line 35 | text.push('\n'); 36 | } 37 | prev_node_type = "text".to_string(); 38 | // Append text, but strip leading and trailing newlines 39 | if let Some(ref val) = node.node_value() { 40 | text.push_str(val); 41 | } 42 | } 43 | Node::ELEMENT_NODE => { 44 | let element: &Element = node.unchecked_ref(); 45 | let tag = element.tag_name().to_lowercase(); 46 | let parent_tag = parent_node.tag_name().to_lowercase(); 47 | let prev_node_type_clone = prev_node_type.clone(); 48 | prev_node_type = tag.clone(); 49 | // Please note: Browser rendering of a contenteditable div is the worst thing ever. 50 | match &*tag { 51 | "span" => { 52 | visit_child_nodes(element, text); 53 | } 54 | "div" => { 55 | #[allow(clippy::if_same_then_else)] 56 | if parent_tag == "div" && i == 0 { 57 | // No newline, in order to handle things like
hello
58 | } else if prev_node_type_clone == "br" && i > 1 { 59 | // A
directly following a
should not result in an 60 | // *additional* newline (a newline is already added with the
). 61 | // 62 | // There is an exception though: When the markup is like this (i=1): 63 | // 64 | //

a
65 | // 66 | // ...then the newline should not be ignored, to avoid an 67 | // interaction with the nested-div handling rule above. 68 | } else { 69 | text.push('\n'); 70 | } 71 | visit_child_nodes(element, text); 72 | } 73 | "img" => { 74 | if prev_node_type_clone == "div" { 75 | // An image following a div should go on a new line 76 | text.push('\n'); 77 | } 78 | text.push_str(&node.unchecked_ref::().alt()); 79 | } 80 | "br" => { 81 | if parent_tag == "div" && i == 0 { 82 | // A
as the first child of a
should not result in an 83 | // *additional* newline (a newline is already added when the div 84 | // started). 85 | // 86 | // Example markup: 87 | // 88 | // hello 89 | //

90 | //
world
91 | // 92 | // Another example: 93 | // 94 | // hello 95 | //

world
96 | // 97 | // See https://github.com/threema-ch/compose-area/issues/72 98 | // for details. 99 | } else { 100 | text.push('\n'); 101 | } 102 | } 103 | _other => {} 104 | } 105 | } 106 | other => warn!("visit_child_nodes: Unhandled node type: {}", other), 107 | } 108 | } 109 | } 110 | 111 | #[cfg(test)] 112 | mod tests { 113 | use super::*; 114 | 115 | use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; 116 | 117 | wasm_bindgen_test_configure!(run_in_browser); 118 | 119 | mod extract_text { 120 | use super::*; 121 | 122 | use percy_dom::{ 123 | event::EventsByNodeIdx, 124 | prelude::{html, IterableNodes, VirtualNode}, 125 | }; 126 | 127 | struct ExtractTextTest { 128 | html: VirtualNode, 129 | expected: &'static str, 130 | } 131 | 132 | impl ExtractTextTest { 133 | fn test(&self) { 134 | // Get references to DOM objects 135 | let window = web_sys::window().expect("No global `window` exists"); 136 | let document = window.document().expect("Should have a document on window"); 137 | 138 | // Create wrapper element 139 | let test_wrapper = document 140 | .create_element("div") 141 | .expect("Could not create test wrapper"); 142 | 143 | // Write HTML to DOM 144 | let node: Node = self.html.create_dom_node(0, &mut EventsByNodeIdx::new()); 145 | test_wrapper 146 | .append_child(&node) 147 | .expect("Could not append node to test wrapper"); 148 | 149 | // Extract and validate text 150 | let text: String = extract_text(&test_wrapper, false); 151 | assert_eq!(&text, self.expected); 152 | } 153 | } 154 | 155 | #[wasm_bindgen_test] 156 | fn simple() { 157 | ExtractTextTest { 158 | html: html! { { "Hello World" } }, 159 | expected: "Hello World", 160 | } 161 | .test(); 162 | } 163 | 164 | #[wasm_bindgen_test] 165 | fn single_div() { 166 | ExtractTextTest { 167 | html: html! {
{ "Hello World" }
}, 168 | expected: "Hello World", 169 | } 170 | .test(); 171 | } 172 | 173 | #[wasm_bindgen_test] 174 | fn single_span() { 175 | ExtractTextTest { 176 | html: html! { { "Hello World" } }, 177 | expected: "Hello World", 178 | } 179 | .test(); 180 | } 181 | 182 | #[wasm_bindgen_test] 183 | fn image() { 184 | ExtractTextTest { 185 | html: html! {
{ "Hello " }BigWorld
}, 186 | expected: "Hello BigWorld", 187 | } 188 | .test(); 189 | } 190 | 191 | #[wasm_bindgen_test] 192 | fn newline_br() { 193 | ExtractTextTest { 194 | html: html! {
Hello
World
}, 195 | expected: "Hello\nWorld", 196 | } 197 | .test(); 198 | } 199 | 200 | #[wasm_bindgen_test] 201 | fn newline_single_div_first() { 202 | ExtractTextTest { 203 | html: html! {
Hello
World
}, 204 | expected: "Hello\nWorld", 205 | } 206 | .test(); 207 | } 208 | 209 | #[wasm_bindgen_test] 210 | fn newline_single_div_second() { 211 | ExtractTextTest { 212 | html: html! {
Hello
World
}, 213 | expected: "Hello\nWorld", 214 | } 215 | .test(); 216 | } 217 | 218 | #[wasm_bindgen_test] 219 | fn newline_double_div() { 220 | ExtractTextTest { 221 | html: html! {
Hello
World
}, 222 | expected: "Hello\nWorld", 223 | } 224 | .test(); 225 | } 226 | 227 | /// Regression test for https://github.com/threema-ch/compose-area/issues/72. 228 | #[wasm_bindgen_test] 229 | fn br_in_div() { 230 | ExtractTextTest { 231 | html: html! {
Hello

World
}, 232 | expected: "Hello\n\nWorld", 233 | } 234 | .test(); 235 | } 236 | 237 | /// Regression test for https://github.com/threema-ch/compose-area/issues/72. 238 | #[wasm_bindgen_test] 239 | fn br_in_nested_div() { 240 | ExtractTextTest { 241 | html: html! {
Hello

World
}, 242 | expected: "Hello\n\nWorld", 243 | } 244 | .test(); 245 | } 246 | 247 | #[wasm_bindgen_test] 248 | fn two_nested_divs() { 249 | ExtractTextTest { 250 | html: html! {
Hello
World
}, 251 | expected: "Hello\nWorld", 252 | } 253 | .test(); 254 | } 255 | 256 | #[wasm_bindgen_test] 257 | fn double_text_node() { 258 | let mut node = VirtualNode::element("span"); 259 | node.as_velement_mut() 260 | .unwrap() 261 | .children 262 | .push(VirtualNode::text("Hello\n")); 263 | node.as_velement_mut() 264 | .unwrap() 265 | .children 266 | .push(VirtualNode::text("World")); 267 | ExtractTextTest { 268 | html: node, 269 | expected: "Hello\nWorld", 270 | } 271 | .test(); 272 | } 273 | 274 | /// Regression test for https://github.com/threema-ch/compose-area/issues/75 275 | #[wasm_bindgen_test] 276 | fn newline_regression_75() { 277 | ExtractTextTest { 278 | html: html! {
a
b
c
}, 279 | expected: "a\nb\nc", 280 | } 281 | .test(); 282 | } 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | references: 4 | steps-integrationtests: &steps-integrationtests 5 | - attach_workspace: 6 | at: . 7 | 8 | # Load npm cache if possible. 9 | # Multiple caches are used to increase the chance of a cache hit. 10 | - restore_cache: 11 | keys: 12 | - v1-npm-cache-integrationtest-{{ arch }}-{{ .Branch }} 13 | - v1-npm-cache-integrationtest-{{ arch }} 14 | 15 | - run: 16 | name: Prepare non-global npm directory 17 | command: mkdir ~/.npm-global && npm config set prefix '~/.npm-global' 18 | - run: 19 | name: Set up test server 20 | command: > 21 | PATH=~/.npm-global/bin:$PATH 22 | && cd pkg 23 | && npm link 24 | && cd ../www 25 | && npm ci 26 | && npm link compose-area 27 | - run: 28 | name: Start test server 29 | command: cd www && npm run start 30 | background: true 31 | - run: 32 | name: Set up selenium tests 33 | command: cd selenium && npm ci 34 | - run: 35 | name: Run selenium tests 36 | command: cd selenium && npm test $BROWSER 37 | 38 | # Save cache 39 | - save_cache: 40 | key: v1-npm-cache-integrationtest-{{ arch }}-{{ .Branch }} 41 | paths: 42 | - www/node_modules 43 | - selenium/node_modules 44 | - save_cache: 45 | key: v1-npm-cache-integrationtest-{{ arch }} 46 | paths: 47 | - www/node_modules 48 | - selenium/node_modules 49 | 50 | jobs: 51 | 52 | build: 53 | docker: 54 | - image: rust:latest 55 | steps: 56 | - checkout 57 | 58 | # Load cargo target from cache if possible. 59 | # Multiple caches are used to increase the chance of a cache hit. 60 | - restore_cache: 61 | keys: 62 | - v2-cargo-cache-build-{{ arch }}-{{ .Branch }} 63 | - v2-cargo-cache-build-{{ arch }} 64 | 65 | # Install wasm 66 | - run: 67 | name: Add wasm32 target 68 | command: rustup target add wasm32-unknown-unknown 69 | 70 | # Install wasm tools 71 | - run: 72 | name: Install wasm-pack 73 | command: > 74 | curl -L https://github.com/rustwasm/wasm-pack/releases/download/v0.8.1/wasm-pack-v0.8.1-x86_64-unknown-linux-musl.tar.gz 75 | | tar --strip-components=1 --wildcards -xzf - "*/wasm-pack" 76 | && chmod +x wasm-pack 77 | && mv wasm-pack $CARGO_HOME/bin/ 78 | 79 | # Show versions 80 | - run: 81 | name: Show versions 82 | command: rustc --version && cargo --version && wasm-pack --version 83 | 84 | # Build 85 | - run: 86 | name: Build compose-area 87 | command: wasm-pack build --release -t browser 88 | - persist_to_workspace: 89 | root: . 90 | paths: 91 | - pkg 92 | - selenium 93 | - www 94 | 95 | # Save cache 96 | - save_cache: 97 | key: v2-cargo-cache-build-{{ arch }}-{{ .Branch }} 98 | paths: 99 | - target 100 | - /usr/local/cargo 101 | - save_cache: 102 | key: v2-cargo-cache-build-{{ arch }} 103 | paths: 104 | - /usr/local/cargo 105 | 106 | lint: 107 | docker: 108 | - image: rust:latest 109 | steps: 110 | - checkout 111 | 112 | # Load cargo target from cache if possible. 113 | # Multiple caches are used to increase the chance of a cache hit. 114 | - restore_cache: 115 | keys: 116 | - v2-cargo-cache-lint-{{ arch }}-{{ .Branch }} 117 | - v2-cargo-cache-lint-{{ arch }} 118 | 119 | # Install clippy 120 | - run: 121 | name: Install clippy 122 | command: rustup component add clippy 123 | 124 | # Show versions 125 | - run: 126 | name: Show versions 127 | command: rustc --version && cargo --version && cargo clippy --version 128 | 129 | # Run linting checks 130 | - run: 131 | name: Run clippy 132 | command: cargo clean && cargo clippy --all-targets --all-features 133 | 134 | # Save cache 135 | - save_cache: 136 | key: v2-cargo-cache-lint-{{ arch }}-{{ .Branch }} 137 | paths: 138 | - /usr/local/cargo 139 | - save_cache: 140 | key: v2-cargo-cache-lint-{{ arch }} 141 | paths: 142 | - /usr/local/cargo 143 | 144 | fmt: 145 | docker: 146 | - image: rust:latest 147 | steps: 148 | - checkout 149 | 150 | # Load cargo target from cache if possible. 151 | # Multiple caches are used to increase the chance of a cache hit. 152 | - restore_cache: 153 | keys: 154 | - v2-cargo-cache-fmt-{{ arch }}-{{ .Branch }} 155 | - v2-cargo-cache-fmt-{{ arch }} 156 | 157 | # Install rustfmt 158 | - run: 159 | name: Install rustfmt 160 | command: rustup component add rustfmt 161 | 162 | # Show versions 163 | - run: 164 | name: Show versions 165 | command: rustc --version && cargo --version && cargo fmt --version 166 | 167 | # Run format checks 168 | - run: 169 | name: Run rustfmt 170 | command: cargo fmt -- --check 171 | 172 | # Save cache 173 | - save_cache: 174 | key: v2-cargo-cache-fmt-{{ arch }}-{{ .Branch }} 175 | paths: 176 | - /usr/local/cargo 177 | - save_cache: 178 | key: v2-cargo-cache-fmt-{{ arch }} 179 | paths: 180 | - /usr/local/cargo 181 | 182 | test-unit: 183 | docker: 184 | - image: rust:latest 185 | steps: 186 | - checkout 187 | 188 | # Load cargo target from cache if possible. 189 | # Multiple caches are used to increase the chance of a cache hit. 190 | - restore_cache: 191 | keys: 192 | - v2-cargo-cache-unittest-{{ arch }}-{{ .Branch }} 193 | - v2-cargo-cache-unittest-{{ arch }} 194 | 195 | # Show versions 196 | - run: 197 | name: Show versions 198 | command: rustc --version && cargo --version 199 | 200 | # Run tests 201 | - run: 202 | name: Run unit tests 203 | command: cargo test 204 | 205 | # Save cache 206 | - save_cache: 207 | key: v2-cargo-cache-unittest-{{ arch }}-{{ .Branch }} 208 | paths: 209 | - target 210 | - /usr/local/cargo 211 | - save_cache: 212 | key: v2-cargo-cache-unittest-{{ arch }} 213 | paths: 214 | - /usr/local/cargo 215 | 216 | test-browser: 217 | docker: 218 | - image: rust:latest 219 | steps: 220 | - checkout 221 | 222 | # Load cargo target from cache if possible. 223 | # Multiple caches are used to increase the chance of a cache hit. 224 | - restore_cache: 225 | keys: 226 | - v2-cargo-cache-browsertest-{{ arch }}-{{ .Branch }} 227 | - v2-cargo-cache-browsertest-{{ arch }} 228 | 229 | # Install wasm 230 | - run: 231 | name: Add wasm32 target 232 | command: rustup target add wasm32-unknown-unknown 233 | 234 | # Install wasm tools 235 | - run: 236 | name: Install wasm-pack 237 | command: > 238 | curl -L https://github.com/rustwasm/wasm-pack/releases/download/v0.8.1/wasm-pack-v0.8.1-x86_64-unknown-linux-musl.tar.gz 239 | | tar --strip-components=1 --wildcards -xzf - "*/wasm-pack" 240 | && chmod +x wasm-pack 241 | && mv wasm-pack $CARGO_HOME/bin/ 242 | 243 | # Install browsers 244 | - run: 245 | name: Install latest firefox 246 | command: > 247 | apt-get update 248 | && apt-get install -y libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 libasound2 249 | && wget -q -O - "https://download.mozilla.org/?product=firefox-latest-ssl&os=linux64&lang=en-US" 250 | | tar xj 251 | 252 | # Show versions 253 | - run: 254 | name: Show versions 255 | command: rustc --version && cargo --version && wasm-pack --version && firefox/firefox --version 256 | 257 | # Run tests 258 | - run: 259 | name: Run browser unit tests 260 | command: PATH=$(pwd)/firefox:$PATH wasm-pack test --headless --firefox 261 | 262 | # Save cache 263 | - save_cache: 264 | key: v2-cargo-cache-browsertest-{{ arch }}-{{ .Branch }} 265 | paths: 266 | - target 267 | - /usr/local/cargo 268 | - save_cache: 269 | key: v2-cargo-cache-browsertest-{{ arch }} 270 | paths: 271 | - /usr/local/cargo 272 | 273 | test-integration-firefox: 274 | docker: 275 | - image: circleci/node:16-browsers 276 | steps: *steps-integrationtests 277 | environment: 278 | BROWSER: firefox 279 | 280 | test-integration-chrome: 281 | docker: 282 | - image: circleci/node:16-browsers 283 | steps: *steps-integrationtests 284 | environment: 285 | BROWSER: chrome 286 | 287 | audit: 288 | docker: 289 | - image: dbrgn/cargo-audit:latest 290 | steps: 291 | - checkout 292 | - restore_cache: 293 | keys: 294 | - v2-cargo-audit-cache 295 | - run: 296 | name: Show versions 297 | command: rustc --version && cargo --version && cargo audit --version 298 | - run: 299 | name: Run cargo audit 300 | command: cargo audit 301 | - save_cache: 302 | key: v2-cargo-audit-cache 303 | paths: 304 | - /usr/local/cargo 305 | 306 | build-demo: 307 | docker: 308 | - image: circleci/node:16 309 | steps: 310 | - checkout 311 | - attach_workspace: 312 | at: . 313 | 314 | # Load npm cache if possible. 315 | # Multiple caches are used to increase the chance of a cache hit. 316 | - restore_cache: 317 | keys: 318 | - v1-npm-cache-demo-{{ arch }}-{{ .Branch }} 319 | - v1-npm-cache-demo-{{ arch }} 320 | 321 | # Build demo 322 | - run: 323 | name: Build demo page 324 | command: > 325 | cd www 326 | && echo "Installing dependencies..." 327 | && npm ci 328 | && echo "Copying npm package..." 329 | && rm -r node_modules/compose-area 330 | && cp -Rv ../pkg node_modules/compose-area 331 | && echo "Build..." 332 | && npm run build 333 | && cd .. 334 | - run: 335 | name: Prepare dist files 336 | command: > 337 | mkdir html 338 | && touch html/.nojekyll 339 | && cp -Rv www/dist/* html/ 340 | && export VERSION=$(git show -s --format="Version: %h (%ci)") 341 | && sed -i "s/\[\[VERSION\]\]/${VERSION}/" html/index.html 342 | && sed -i "s/\[\[VERSION\]\]/${VERSION}/" html/benchmark.html 343 | - persist_to_workspace: 344 | root: . 345 | paths: 346 | - html 347 | 348 | # Save cache 349 | - save_cache: 350 | key: v1-npm-cache-demo-{{ arch }}-{{ .Branch }} 351 | paths: 352 | - www/node_modules 353 | - selenium/node_modules 354 | - save_cache: 355 | key: v1-npm-cache-demo-{{ arch }} 356 | paths: 357 | - www/node_modules 358 | - selenium/node_modules 359 | 360 | deploy-demo: 361 | docker: 362 | - image: circleci/node:16 363 | steps: 364 | - checkout 365 | - attach_workspace: 366 | at: . 367 | 368 | # Deploy 369 | - run: 370 | name: Install and configure deployment dependencies 371 | command: > 372 | npm install gh-pages@2 373 | && git config user.email "ci-build@circleci" 374 | && git config user.name "ci-build" 375 | - add_ssh_keys: 376 | fingerprints: 377 | - "32:c5:e4:2f:85:f2:6b:3e:ae:fa:60:9d:15:66:0e:55" 378 | - run: 379 | name: Deploy demo to gh-pages branch 380 | command: node_modules/.bin/gh-pages --dotfiles --message "[skip ci] Updates" --dist html 381 | 382 | workflows: 383 | version: 2 384 | 385 | build-and-test: 386 | jobs: 387 | - build 388 | - lint 389 | - fmt 390 | - test-unit 391 | - test-browser 392 | - test-integration-firefox: 393 | requires: 394 | - build 395 | - test-integration-chrome: 396 | requires: 397 | - build 398 | - build-demo: 399 | requires: 400 | - build 401 | - test-integration-firefox 402 | - test-integration-chrome 403 | - deploy-demo: 404 | requires: 405 | - build-demo 406 | filters: 407 | branches: 408 | only: master 409 | 410 | # Build master every week on Monday at 03:00 am 411 | weekly: 412 | triggers: 413 | - schedule: 414 | cron: "0 3 * * 1" 415 | filters: 416 | branches: 417 | only: 418 | - master 419 | jobs: 420 | - build 421 | - lint 422 | - fmt 423 | - test-unit 424 | - test-browser 425 | - test-integration-firefox: 426 | requires: 427 | - build 428 | - test-integration-chrome: 429 | requires: 430 | - build 431 | - build-demo: 432 | requires: 433 | - build 434 | - test-integration-firefox 435 | - test-integration-chrome 436 | - audit 437 | -------------------------------------------------------------------------------- /selenium/tests.ts: -------------------------------------------------------------------------------- 1 | // Selenium docs: 2 | // https://seleniumhq.github.io/selenium/docs/api/javascript/module/selenium-webdriver/ 3 | import { expect } from 'chai'; 4 | import { By, Key, WebDriver } from 'selenium-webdriver'; 5 | 6 | interface CaretPosition { 7 | start: number; 8 | end: number; 9 | } 10 | 11 | // Type aliases 12 | type Testfunc = (driver: WebDriver) => void; 13 | 14 | // Shared selectors 15 | const wrapper = By.id('wrapper'); 16 | const emojiTongue = By.id('tongue'); 17 | const emojiBeers = By.id('beers'); 18 | const emojiFacepalm = By.id('facepalm'); 19 | const headline = By.css('h2'); 20 | 21 | // Emoji unicode 22 | const emojiStrTongue = '\ud83d\ude1c'; 23 | const emojiStrBeers = '\ud83c\udf7b'; 24 | const emojiStrFacepalm = '\ud83e\udd26\u200d\u2640\ufe0f'; 25 | 26 | 27 | async function extractText(driver: WebDriver): Promise { 28 | const text: string = await driver.executeScript(` 29 | return window.composeArea.get_text(); 30 | `); 31 | return text; 32 | } 33 | 34 | async function isEmpty(driver: WebDriver): Promise { 35 | const isEmpty: boolean = await driver.executeScript(` 36 | return window.composeArea.is_empty(); 37 | `); 38 | return isEmpty; 39 | } 40 | 41 | async function clearSelectionRange(driver: WebDriver): Promise { 42 | const clearBtn = await driver.findElement(By.id('clearselection')); 43 | await clearBtn.click(); 44 | } 45 | 46 | async function skipInBrowser(driver: WebDriver, browser: string): Promise { 47 | const cap = await driver.getCapabilities() 48 | if (cap.get('browserName') === browser) { 49 | // Test skipped due to buggy webdriver behavior in Chrome. 50 | console.warn(`Warning: Skipping test in ${browser}`); 51 | return true; 52 | } 53 | return false; 54 | } 55 | 56 | /** 57 | * The wrapper element should be found on the test page. 58 | */ 59 | async function wrapperFound(driver: WebDriver) { 60 | const wrapperElement = await driver.findElement(wrapper); 61 | expect(wrapperElement).to.exist; 62 | } 63 | 64 | /** 65 | * Text insertion and the `is_empty` method should work as intended. 66 | */ 67 | async function insertText(driver: WebDriver) { 68 | await driver.sleep(100); // Wait for compose area init 69 | const wrapperElement = await driver.findElement(wrapper); 70 | 71 | expect(await extractText(driver)).to.equal(''); 72 | expect(await isEmpty(driver)).to.be.true; 73 | 74 | await wrapperElement.click(); 75 | await wrapperElement.sendKeys('abcde'); 76 | 77 | expect(await extractText(driver)).to.equal('abcde'); 78 | expect(await isEmpty(driver)).to.be.false; 79 | } 80 | 81 | /** 82 | * The emoji should be inserted in the proper order. 83 | */ 84 | async function insertThreeEmoji(driver: WebDriver) { 85 | await driver.sleep(100); // Wait for compose area init 86 | const wrapperElement = await driver.findElement(wrapper); 87 | const e1 = await driver.findElement(emojiTongue); 88 | const e2 = await driver.findElement(emojiBeers); 89 | const e3 = await driver.findElement(emojiFacepalm); 90 | 91 | await wrapperElement.click(); 92 | 93 | await e1.click(); 94 | await e2.click(); 95 | await e3.click(); 96 | 97 | const text = await extractText(driver); 98 | expect(text).to.equal(emojiStrTongue + emojiStrBeers + emojiStrFacepalm); 99 | } 100 | 101 | /** 102 | * Insert text between two emoji. 103 | */ 104 | async function insertTextBetweenEmoji(driver: WebDriver) { 105 | await driver.sleep(100); // Wait for compose area init 106 | const wrapperElement = await driver.findElement(wrapper); 107 | const e1 = await driver.findElement(emojiTongue); 108 | const e2 = await driver.findElement(emojiBeers); 109 | 110 | await wrapperElement.click(); 111 | 112 | await e1.click(); 113 | await e2.click(); 114 | 115 | await wrapperElement.sendKeys(Key.ARROW_LEFT, 'X'); 116 | 117 | const text = await extractText(driver); 118 | expect(text).to.equal(emojiStrTongue + 'X' + emojiStrBeers); 119 | } 120 | 121 | /** 122 | * Replace selected text with text. 123 | */ 124 | async function replaceSelectedTextWithText(driver: WebDriver) { 125 | if (await skipInBrowser(driver, 'chrome')) { return; } 126 | 127 | await driver.sleep(100); // Wait for compose area init 128 | const wrapperElement = await driver.findElement(wrapper); 129 | 130 | await wrapperElement.click(); 131 | 132 | await wrapperElement.sendKeys('abcde'); 133 | await wrapperElement.sendKeys(Key.ARROW_LEFT); 134 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 135 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 136 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 137 | await wrapperElement.sendKeys('X'); 138 | 139 | const text = await extractText(driver); 140 | expect(text).to.equal('aXe'); 141 | } 142 | 143 | /** 144 | * Replace selected text with emoji. 145 | */ 146 | async function replaceSelectedTextWithEmoji(driver: WebDriver) { 147 | if (await skipInBrowser(driver, 'chrome')) { return; } 148 | 149 | await driver.sleep(100); // Wait for compose area init 150 | const wrapperElement = await driver.findElement(wrapper); 151 | const emoji = await driver.findElement(emojiTongue); 152 | 153 | await wrapperElement.click(); 154 | 155 | await wrapperElement.sendKeys('abcde'); 156 | await wrapperElement.sendKeys(Key.ARROW_LEFT); 157 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 158 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 159 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 160 | await emoji.click(); 161 | 162 | const text = await extractText(driver); 163 | expect(text).to.equal('a' + emojiStrTongue + 'e'); 164 | } 165 | 166 | /** 167 | * Replace selected text and emoji. 168 | */ 169 | async function replaceSelectedTextAndEmoji(driver: WebDriver) { 170 | if (await skipInBrowser(driver, 'chrome')) { return; } 171 | 172 | await driver.sleep(100); // Wait for compose area init 173 | 174 | const wrapperElement = await driver.findElement(wrapper); 175 | const emoji = await driver.findElement(emojiTongue); 176 | 177 | await wrapperElement.click(); 178 | 179 | await wrapperElement.sendKeys('abc'); 180 | emoji.click(); 181 | await wrapperElement.sendKeys('de'); 182 | await wrapperElement.sendKeys(Key.ARROW_LEFT); 183 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 184 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 185 | await wrapperElement.sendKeys(Key.SHIFT + Key.ARROW_LEFT); 186 | await wrapperElement.sendKeys('X'); 187 | 188 | const text = await extractText(driver); 189 | expect(text).to.equal('abXe'); 190 | } 191 | 192 | /** 193 | * Cursor position after replacing emoji. 194 | */ 195 | async function replaceEmojiWithText(driver: WebDriver) { 196 | await driver.sleep(100); // Wait for compose area init 197 | 198 | const wrapperElement = await driver.findElement(wrapper); 199 | const emoji = await driver.findElement(emojiTongue); 200 | 201 | await wrapperElement.click(); 202 | 203 | await wrapperElement.sendKeys('a'); 204 | emoji.click(); 205 | await wrapperElement.sendKeys( 206 | 'b', 207 | Key.ARROW_LEFT, 208 | Key.SHIFT + Key.ARROW_LEFT, 209 | 'A', 210 | 'B', 211 | ); 212 | 213 | const text = await extractText(driver); 214 | expect(text).to.equal('aABb'); 215 | } 216 | 217 | /** 218 | * Replace all text. 219 | */ 220 | async function replaceAllText(driver: WebDriver) { 221 | // Doesn't work in Firefox. Disabled until 222 | // https://bugzilla.mozilla.org/show_bug.cgi?id=1529540 is resolved. 223 | if (await skipInBrowser(driver, 'firefox')) { return; } 224 | 225 | // Doesn't work in Chrome because every `sendKeys` call resets the focus. 226 | if (await skipInBrowser(driver, 'chrome')) { return; } 227 | 228 | await driver.sleep(100); // Wait for compose area init 229 | const wrapperElement = await driver.findElement(wrapper); 230 | 231 | await wrapperElement.click(); 232 | 233 | await wrapperElement.sendKeys('abcde'); 234 | await wrapperElement.sendKeys(Key.CONTROL + 'a'); 235 | await wrapperElement.sendKeys('X'); 236 | 237 | const text = await extractText(driver); 238 | expect(text).to.equal('X'); 239 | } 240 | 241 | /** 242 | * Using the delete key. 243 | */ 244 | async function deleteKey(driver: WebDriver) { 245 | await driver.sleep(100); // Wait for compose area init 246 | const wrapperElement = await driver.findElement(wrapper); 247 | const emoji = await driver.findElement(emojiTongue); 248 | 249 | await wrapperElement.click(); 250 | 251 | await wrapperElement.sendKeys('abcd', Key.ENTER); 252 | await emoji.click(); 253 | 254 | expect(await extractText(driver)).to.equal('abcd\n' + emojiStrTongue); 255 | 256 | await wrapperElement.sendKeys( 257 | Key.ARROW_LEFT, 258 | Key.ARROW_LEFT, 259 | Key.ARROW_LEFT, // Between c and d 260 | Key.DELETE, 261 | Key.DELETE, 262 | 'x', 263 | ); 264 | 265 | expect(await extractText(driver)).to.equal('abcx' + emojiStrTongue); 266 | } 267 | 268 | /** 269 | * Cutting and pasting 270 | */ 271 | async function cutAndPaste(driver: WebDriver) { 272 | if (await skipInBrowser(driver, 'chrome')) { return; } 273 | 274 | await driver.sleep(100); // Wait for compose area init 275 | const wrapperElement = await driver.findElement(wrapper); 276 | const emoji = await driver.findElement(emojiTongue); 277 | 278 | await wrapperElement.click(); 279 | 280 | // Add text 281 | await wrapperElement.sendKeys('1234'); 282 | 283 | // Highlight "23" 284 | await wrapperElement.sendKeys(Key.ARROW_LEFT); 285 | await wrapperElement.sendKeys(Key.SHIFT, Key.ARROW_LEFT); 286 | await wrapperElement.sendKeys(Key.SHIFT, Key.ARROW_LEFT); 287 | 288 | // Cut 289 | await wrapperElement.sendKeys(Key.CONTROL, 'x'); 290 | 291 | // Paste at end 292 | await wrapperElement.sendKeys(Key.ARROW_RIGHT); 293 | await wrapperElement.sendKeys(Key.CONTROL, 'v'); 294 | 295 | expect(await extractText(driver)).to.equal('1423'); 296 | } 297 | 298 | /** 299 | * No contents should be inserted outside the wrapper (e.g. if the selection is 300 | * outside). 301 | */ 302 | async function noInsertOutsideWrapper(driver: WebDriver) { 303 | await driver.sleep(100); // Wait for compose area init 304 | const wrapperElement = await driver.findElement(wrapper); 305 | const headlineElement = await driver.findElement(headline); 306 | const e = await driver.findElement(emojiBeers); 307 | 308 | await headlineElement.click(); 309 | await e.click(); 310 | await wrapperElement.sendKeys(' yeah'); 311 | 312 | const text = await extractText(driver); 313 | expect(text).to.equal(`${emojiStrBeers} yeah`); 314 | } 315 | 316 | /** 317 | * When no selection range is present, insert at end. If a selection range is 318 | * outside the compose area, use the last known range. 319 | */ 320 | async function handleSelectionChanges(driver: WebDriver) { 321 | await driver.sleep(100); // Wait for compose area init 322 | const wrapperElement = await driver.findElement(wrapper); 323 | const headlineElement = await driver.findElement(headline); 324 | const e1 = await driver.findElement(emojiBeers); 325 | const e2 = await driver.findElement(emojiTongue); 326 | const e3 = await driver.findElement(emojiFacepalm); 327 | 328 | // Add initial text 329 | await wrapperElement.click(); 330 | await wrapperElement.sendKeys('1234'); 331 | expect(await extractText(driver)).to.equal(`1234`); 332 | 333 | // Insert emoji 334 | await wrapperElement.sendKeys(Key.ARROW_LEFT, Key.ARROW_LEFT); 335 | await e1.click(); 336 | expect(await extractText(driver)).to.equal( 337 | `12${emojiStrBeers}34` 338 | ); 339 | 340 | // Clear selection range and insert emoji 341 | await clearSelectionRange(driver); 342 | await e2.click(); 343 | expect(await extractText(driver)).to.equal( 344 | `12${emojiStrBeers}34${emojiStrTongue}` 345 | ); 346 | 347 | // Change selection range 348 | await wrapperElement.click(); 349 | await wrapperElement.sendKeys(Key.ARROW_LEFT, Key.ARROW_LEFT); 350 | 351 | // Click outside wrapper, then insert another emoji 352 | await headlineElement.click(); 353 | await e3.click(); 354 | expect(await extractText(driver)).to.equal( 355 | `12${emojiStrBeers}3${emojiStrFacepalm}4${emojiStrTongue}` 356 | ); 357 | } 358 | 359 | /** 360 | * When inserting an empty line, the newlines should not be duplicated. 361 | * Regression test for https://github.com/threema-ch/compose-area/issues/72. 362 | */ 363 | async function noDuplicatedNewlines1(driver: WebDriver) { 364 | await driver.sleep(100); // Wait for compose area init 365 | const wrapperElement = await driver.findElement(wrapper); 366 | 367 | await wrapperElement.click(); 368 | 369 | await wrapperElement.sendKeys('Hello'); 370 | await wrapperElement.sendKeys(Key.ENTER); 371 | await wrapperElement.sendKeys(Key.ENTER); 372 | await wrapperElement.sendKeys('World'); 373 | 374 | const text = await extractText(driver); 375 | expect(text).to.equal('Hello\n\nWorld'); 376 | } 377 | 378 | /** 379 | * When inserting an empty line, the newlines should not be duplicated. 380 | * Regression test for https://github.com/threema-ch/compose-area/issues/72. 381 | * This one only seems to apply to Chrome, not to Firefox. 382 | */ 383 | async function noDuplicatedNewlines2(driver: WebDriver) { 384 | await driver.sleep(100); // Wait for compose area init 385 | const wrapperElement = await driver.findElement(wrapper); 386 | 387 | await wrapperElement.click(); 388 | 389 | await wrapperElement.sendKeys('Hello'); 390 | await wrapperElement.sendKeys(Key.ENTER); 391 | await wrapperElement.sendKeys('World'); 392 | 393 | const text1 = await extractText(driver); 394 | expect(text1).to.equal('Hello\nWorld'); 395 | 396 | await wrapperElement.sendKeys(Key.ARROW_UP); 397 | await wrapperElement.sendKeys(Key.ENTER); 398 | 399 | const text2 = await extractText(driver); 400 | expect(text2).to.equal('Hello\n\nWorld'); 401 | } 402 | 403 | /** 404 | * When inserting an empty line, the newlines should not be duplicated. 405 | * Regression test for https://github.com/threema-ch/compose-area/issues/75. 406 | * This one only seems to apply to Firefox, not to Chrome. 407 | */ 408 | async function noDuplicatedNewlines3(driver: WebDriver) { 409 | await driver.sleep(100); // Wait for compose area init 410 | const wrapperElement = await driver.findElement(wrapper); 411 | 412 | await wrapperElement.click(); 413 | 414 | await wrapperElement.sendKeys('Hello'); 415 | await wrapperElement.sendKeys(Key.SHIFT + Key.ENTER); 416 | await wrapperElement.sendKeys('Cruel'); 417 | await wrapperElement.sendKeys(Key.ENTER); 418 | await wrapperElement.sendKeys('World'); 419 | 420 | const text = await extractText(driver); 421 | expect(text).to.equal('Hello\nCruel\nWorld'); 422 | } 423 | 424 | export const TESTS: Array<[string, Testfunc]> = [ 425 | ['Make sure that the wrapper element can be found', wrapperFound], 426 | ['Insert text', insertText], 427 | ['Insert three emoji', insertThreeEmoji], 428 | ['Insert text between emoji', insertTextBetweenEmoji], 429 | ['Replace selected text with text', replaceSelectedTextWithText], 430 | ['Replace selected text with emoji', replaceSelectedTextWithEmoji], 431 | ['Replace selected text and emoji', replaceSelectedTextAndEmoji], 432 | ['Replace emoji with text', replaceEmojiWithText], 433 | ['Replace all text', replaceAllText], 434 | ['Use the delete key', deleteKey], 435 | ['Cut and paste', cutAndPaste], 436 | ['Don\'t insert outside wrapper', noInsertOutsideWrapper], 437 | ['Handle selection changes', handleSelectionChanges], 438 | ['Ensure that empty lines are not duplicated (variant 1)', noDuplicatedNewlines1], 439 | ['Ensure that empty lines are not duplicated (variant 2)', noDuplicatedNewlines2], 440 | ['Ensure that empty lines are not duplicated (variant 3)', noDuplicatedNewlines3], 441 | ]; 442 | -------------------------------------------------------------------------------- /selenium/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "compose-area-selenium-tests", 3 | "version": "0.1.0", 4 | "lockfileVersion": 2, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "compose-area-selenium-tests", 9 | "version": "0.1.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@types/node": "^12.12.5", 13 | "@types/selenium-webdriver": "^4.0.5", 14 | "chai": "^4.2.0", 15 | "selenium-webdriver": "^4.0.0-alpha.5", 16 | "term-color": "^1.0.1", 17 | "ts-node": "^8.4.1", 18 | "typescript": "^3.6.4" 19 | } 20 | }, 21 | "node_modules/@types/node": { 22 | "version": "12.20.55", 23 | "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", 24 | "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" 25 | }, 26 | "node_modules/@types/selenium-webdriver": { 27 | "version": "4.1.13", 28 | "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.13.tgz", 29 | "integrity": "sha512-kGpIh7bvu4HGCJXl4PEJ53kzpG4iXlRDd66SNNCfJ58QhFuk9skOm57lVffZap5ChEOJwbge/LJ9IVGVC8EEOg==", 30 | "dependencies": { 31 | "@types/ws": "*" 32 | } 33 | }, 34 | "node_modules/@types/ws": { 35 | "version": "8.5.4", 36 | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", 37 | "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", 38 | "dependencies": { 39 | "@types/node": "*" 40 | } 41 | }, 42 | "node_modules/ansi-styles": { 43 | "version": "2.0.1", 44 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.0.1.tgz", 45 | "integrity": "sha512-0zjsXMhnTibRx8YrLgLKb5NvWEcHN/OZEe1NzR8VVrEM6xr7/NyLsoMVelAhaoJhOtpuexaeRGD8MF8Z64+9LQ==", 46 | "engines": { 47 | "node": ">=0.10.0" 48 | } 49 | }, 50 | "node_modules/arg": { 51 | "version": "4.1.3", 52 | "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", 53 | "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" 54 | }, 55 | "node_modules/assertion-error": { 56 | "version": "1.1.0", 57 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", 58 | "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", 59 | "engines": { 60 | "node": "*" 61 | } 62 | }, 63 | "node_modules/balanced-match": { 64 | "version": "1.0.2", 65 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 66 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 67 | }, 68 | "node_modules/brace-expansion": { 69 | "version": "1.1.11", 70 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 71 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 72 | "dependencies": { 73 | "balanced-match": "^1.0.0", 74 | "concat-map": "0.0.1" 75 | } 76 | }, 77 | "node_modules/buffer-from": { 78 | "version": "1.1.2", 79 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 80 | "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" 81 | }, 82 | "node_modules/chai": { 83 | "version": "4.3.7", 84 | "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", 85 | "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", 86 | "dependencies": { 87 | "assertion-error": "^1.1.0", 88 | "check-error": "^1.0.2", 89 | "deep-eql": "^4.1.2", 90 | "get-func-name": "^2.0.0", 91 | "loupe": "^2.3.1", 92 | "pathval": "^1.1.1", 93 | "type-detect": "^4.0.5" 94 | }, 95 | "engines": { 96 | "node": ">=4" 97 | } 98 | }, 99 | "node_modules/check-error": { 100 | "version": "1.0.2", 101 | "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", 102 | "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==", 103 | "engines": { 104 | "node": "*" 105 | } 106 | }, 107 | "node_modules/concat-map": { 108 | "version": "0.0.1", 109 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 110 | "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" 111 | }, 112 | "node_modules/core-util-is": { 113 | "version": "1.0.3", 114 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", 115 | "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" 116 | }, 117 | "node_modules/deep-eql": { 118 | "version": "4.1.3", 119 | "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", 120 | "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", 121 | "dependencies": { 122 | "type-detect": "^4.0.0" 123 | }, 124 | "engines": { 125 | "node": ">=6" 126 | } 127 | }, 128 | "node_modules/diff": { 129 | "version": "4.0.2", 130 | "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", 131 | "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", 132 | "engines": { 133 | "node": ">=0.3.1" 134 | } 135 | }, 136 | "node_modules/fs.realpath": { 137 | "version": "1.0.0", 138 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 139 | "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" 140 | }, 141 | "node_modules/get-func-name": { 142 | "version": "2.0.0", 143 | "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", 144 | "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==", 145 | "engines": { 146 | "node": "*" 147 | } 148 | }, 149 | "node_modules/glob": { 150 | "version": "7.2.3", 151 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", 152 | "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", 153 | "dependencies": { 154 | "fs.realpath": "^1.0.0", 155 | "inflight": "^1.0.4", 156 | "inherits": "2", 157 | "minimatch": "^3.1.1", 158 | "once": "^1.3.0", 159 | "path-is-absolute": "^1.0.0" 160 | }, 161 | "engines": { 162 | "node": "*" 163 | }, 164 | "funding": { 165 | "url": "https://github.com/sponsors/isaacs" 166 | } 167 | }, 168 | "node_modules/immediate": { 169 | "version": "3.0.6", 170 | "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", 171 | "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" 172 | }, 173 | "node_modules/inflight": { 174 | "version": "1.0.6", 175 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 176 | "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", 177 | "dependencies": { 178 | "once": "^1.3.0", 179 | "wrappy": "1" 180 | } 181 | }, 182 | "node_modules/inherits": { 183 | "version": "2.0.4", 184 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 185 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 186 | }, 187 | "node_modules/isarray": { 188 | "version": "1.0.0", 189 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 190 | "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" 191 | }, 192 | "node_modules/jszip": { 193 | "version": "3.10.1", 194 | "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", 195 | "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", 196 | "dependencies": { 197 | "lie": "~3.3.0", 198 | "pako": "~1.0.2", 199 | "readable-stream": "~2.3.6", 200 | "setimmediate": "^1.0.5" 201 | } 202 | }, 203 | "node_modules/lie": { 204 | "version": "3.3.0", 205 | "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", 206 | "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", 207 | "dependencies": { 208 | "immediate": "~3.0.5" 209 | } 210 | }, 211 | "node_modules/loupe": { 212 | "version": "2.3.6", 213 | "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", 214 | "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", 215 | "dependencies": { 216 | "get-func-name": "^2.0.0" 217 | } 218 | }, 219 | "node_modules/make-error": { 220 | "version": "1.3.6", 221 | "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", 222 | "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" 223 | }, 224 | "node_modules/minimatch": { 225 | "version": "3.1.2", 226 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 227 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 228 | "dependencies": { 229 | "brace-expansion": "^1.1.7" 230 | }, 231 | "engines": { 232 | "node": "*" 233 | } 234 | }, 235 | "node_modules/once": { 236 | "version": "1.4.0", 237 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 238 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 239 | "dependencies": { 240 | "wrappy": "1" 241 | } 242 | }, 243 | "node_modules/pako": { 244 | "version": "1.0.11", 245 | "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", 246 | "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" 247 | }, 248 | "node_modules/path-is-absolute": { 249 | "version": "1.0.1", 250 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 251 | "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", 252 | "engines": { 253 | "node": ">=0.10.0" 254 | } 255 | }, 256 | "node_modules/pathval": { 257 | "version": "1.1.1", 258 | "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", 259 | "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", 260 | "engines": { 261 | "node": "*" 262 | } 263 | }, 264 | "node_modules/process-nextick-args": { 265 | "version": "2.0.1", 266 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", 267 | "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" 268 | }, 269 | "node_modules/readable-stream": { 270 | "version": "2.3.8", 271 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", 272 | "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", 273 | "dependencies": { 274 | "core-util-is": "~1.0.0", 275 | "inherits": "~2.0.3", 276 | "isarray": "~1.0.0", 277 | "process-nextick-args": "~2.0.0", 278 | "safe-buffer": "~5.1.1", 279 | "string_decoder": "~1.1.1", 280 | "util-deprecate": "~1.0.1" 281 | } 282 | }, 283 | "node_modules/rimraf": { 284 | "version": "3.0.2", 285 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 286 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 287 | "dependencies": { 288 | "glob": "^7.1.3" 289 | }, 290 | "bin": { 291 | "rimraf": "bin.js" 292 | }, 293 | "funding": { 294 | "url": "https://github.com/sponsors/isaacs" 295 | } 296 | }, 297 | "node_modules/safe-buffer": { 298 | "version": "5.1.2", 299 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 300 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 301 | }, 302 | "node_modules/selenium-webdriver": { 303 | "version": "4.8.1", 304 | "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.8.1.tgz", 305 | "integrity": "sha512-p4MtfhCQdcV6xxkS7eI0tQN6+WNReRULLCAuT4RDGkrjfObBNXMJ3WT8XdK+aXTr5nnBKuh+PxIevM0EjJgkxA==", 306 | "dependencies": { 307 | "jszip": "^3.10.0", 308 | "tmp": "^0.2.1", 309 | "ws": ">=8.11.0" 310 | }, 311 | "engines": { 312 | "node": ">= 14.20.0" 313 | } 314 | }, 315 | "node_modules/setimmediate": { 316 | "version": "1.0.5", 317 | "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", 318 | "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" 319 | }, 320 | "node_modules/source-map": { 321 | "version": "0.6.1", 322 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 323 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 324 | "engines": { 325 | "node": ">=0.10.0" 326 | } 327 | }, 328 | "node_modules/source-map-support": { 329 | "version": "0.5.21", 330 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", 331 | "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", 332 | "dependencies": { 333 | "buffer-from": "^1.0.0", 334 | "source-map": "^0.6.0" 335 | } 336 | }, 337 | "node_modules/string_decoder": { 338 | "version": "1.1.1", 339 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", 340 | "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", 341 | "dependencies": { 342 | "safe-buffer": "~5.1.0" 343 | } 344 | }, 345 | "node_modules/supports-color": { 346 | "version": "1.3.1", 347 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.3.1.tgz", 348 | "integrity": "sha512-OHbMkscHFRcNWEcW80fYhCrzAjheSIBwJChpFaBqA6zEz53nxumqi6ukciRb/UA0/v2nDNMk28ce/uBbYRDsng==", 349 | "bin": { 350 | "supports-color": "cli.js" 351 | }, 352 | "engines": { 353 | "node": ">=0.8.0" 354 | } 355 | }, 356 | "node_modules/term-color": { 357 | "version": "1.0.1", 358 | "resolved": "https://registry.npmjs.org/term-color/-/term-color-1.0.1.tgz", 359 | "integrity": "sha512-4Ld+sFlAdziaaMabvBU215dxyMotGoz7yN+9GtPE7RhKvzXAmg8tD/nKohJp4v2bMdSsNO3FEIBxFDsXu0Pf8w==", 360 | "dependencies": { 361 | "ansi-styles": "2.0.1", 362 | "supports-color": "1.3.1" 363 | } 364 | }, 365 | "node_modules/tmp": { 366 | "version": "0.2.1", 367 | "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", 368 | "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", 369 | "dependencies": { 370 | "rimraf": "^3.0.0" 371 | }, 372 | "engines": { 373 | "node": ">=8.17.0" 374 | } 375 | }, 376 | "node_modules/ts-node": { 377 | "version": "8.10.2", 378 | "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz", 379 | "integrity": "sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==", 380 | "dependencies": { 381 | "arg": "^4.1.0", 382 | "diff": "^4.0.1", 383 | "make-error": "^1.1.1", 384 | "source-map-support": "^0.5.17", 385 | "yn": "3.1.1" 386 | }, 387 | "bin": { 388 | "ts-node": "dist/bin.js", 389 | "ts-node-script": "dist/bin-script.js", 390 | "ts-node-transpile-only": "dist/bin-transpile.js", 391 | "ts-script": "dist/bin-script-deprecated.js" 392 | }, 393 | "engines": { 394 | "node": ">=6.0.0" 395 | }, 396 | "peerDependencies": { 397 | "typescript": ">=2.7" 398 | } 399 | }, 400 | "node_modules/type-detect": { 401 | "version": "4.0.8", 402 | "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", 403 | "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", 404 | "engines": { 405 | "node": ">=4" 406 | } 407 | }, 408 | "node_modules/typescript": { 409 | "version": "3.9.10", 410 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", 411 | "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==", 412 | "bin": { 413 | "tsc": "bin/tsc", 414 | "tsserver": "bin/tsserver" 415 | }, 416 | "engines": { 417 | "node": ">=4.2.0" 418 | } 419 | }, 420 | "node_modules/util-deprecate": { 421 | "version": "1.0.2", 422 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 423 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" 424 | }, 425 | "node_modules/wrappy": { 426 | "version": "1.0.2", 427 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 428 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 429 | }, 430 | "node_modules/ws": { 431 | "version": "8.13.0", 432 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", 433 | "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", 434 | "engines": { 435 | "node": ">=10.0.0" 436 | }, 437 | "peerDependencies": { 438 | "bufferutil": "^4.0.1", 439 | "utf-8-validate": ">=5.0.2" 440 | }, 441 | "peerDependenciesMeta": { 442 | "bufferutil": { 443 | "optional": true 444 | }, 445 | "utf-8-validate": { 446 | "optional": true 447 | } 448 | } 449 | }, 450 | "node_modules/yn": { 451 | "version": "3.1.1", 452 | "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", 453 | "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", 454 | "engines": { 455 | "node": ">=6" 456 | } 457 | } 458 | }, 459 | "dependencies": { 460 | "@types/node": { 461 | "version": "12.20.55", 462 | "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", 463 | "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==" 464 | }, 465 | "@types/selenium-webdriver": { 466 | "version": "4.1.13", 467 | "resolved": "https://registry.npmjs.org/@types/selenium-webdriver/-/selenium-webdriver-4.1.13.tgz", 468 | "integrity": "sha512-kGpIh7bvu4HGCJXl4PEJ53kzpG4iXlRDd66SNNCfJ58QhFuk9skOm57lVffZap5ChEOJwbge/LJ9IVGVC8EEOg==", 469 | "requires": { 470 | "@types/ws": "*" 471 | } 472 | }, 473 | "@types/ws": { 474 | "version": "8.5.4", 475 | "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.4.tgz", 476 | "integrity": "sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==", 477 | "requires": { 478 | "@types/node": "*" 479 | } 480 | }, 481 | "ansi-styles": { 482 | "version": "2.0.1", 483 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.0.1.tgz", 484 | "integrity": "sha512-0zjsXMhnTibRx8YrLgLKb5NvWEcHN/OZEe1NzR8VVrEM6xr7/NyLsoMVelAhaoJhOtpuexaeRGD8MF8Z64+9LQ==" 485 | }, 486 | "arg": { 487 | "version": "4.1.3", 488 | "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", 489 | "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==" 490 | }, 491 | "assertion-error": { 492 | "version": "1.1.0", 493 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", 494 | "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==" 495 | }, 496 | "balanced-match": { 497 | "version": "1.0.2", 498 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 499 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" 500 | }, 501 | "brace-expansion": { 502 | "version": "1.1.11", 503 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 504 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 505 | "requires": { 506 | "balanced-match": "^1.0.0", 507 | "concat-map": "0.0.1" 508 | } 509 | }, 510 | "buffer-from": { 511 | "version": "1.1.2", 512 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 513 | "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" 514 | }, 515 | "chai": { 516 | "version": "4.3.7", 517 | "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz", 518 | "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==", 519 | "requires": { 520 | "assertion-error": "^1.1.0", 521 | "check-error": "^1.0.2", 522 | "deep-eql": "^4.1.2", 523 | "get-func-name": "^2.0.0", 524 | "loupe": "^2.3.1", 525 | "pathval": "^1.1.1", 526 | "type-detect": "^4.0.5" 527 | } 528 | }, 529 | "check-error": { 530 | "version": "1.0.2", 531 | "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", 532 | "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==" 533 | }, 534 | "concat-map": { 535 | "version": "0.0.1", 536 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 537 | "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" 538 | }, 539 | "core-util-is": { 540 | "version": "1.0.3", 541 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", 542 | "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" 543 | }, 544 | "deep-eql": { 545 | "version": "4.1.3", 546 | "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", 547 | "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", 548 | "requires": { 549 | "type-detect": "^4.0.0" 550 | } 551 | }, 552 | "diff": { 553 | "version": "4.0.2", 554 | "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", 555 | "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==" 556 | }, 557 | "fs.realpath": { 558 | "version": "1.0.0", 559 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 560 | "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" 561 | }, 562 | "get-func-name": { 563 | "version": "2.0.0", 564 | "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", 565 | "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==" 566 | }, 567 | "glob": { 568 | "version": "7.2.3", 569 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", 570 | "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", 571 | "requires": { 572 | "fs.realpath": "^1.0.0", 573 | "inflight": "^1.0.4", 574 | "inherits": "2", 575 | "minimatch": "^3.1.1", 576 | "once": "^1.3.0", 577 | "path-is-absolute": "^1.0.0" 578 | } 579 | }, 580 | "immediate": { 581 | "version": "3.0.6", 582 | "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", 583 | "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" 584 | }, 585 | "inflight": { 586 | "version": "1.0.6", 587 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 588 | "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", 589 | "requires": { 590 | "once": "^1.3.0", 591 | "wrappy": "1" 592 | } 593 | }, 594 | "inherits": { 595 | "version": "2.0.4", 596 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 597 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 598 | }, 599 | "isarray": { 600 | "version": "1.0.0", 601 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 602 | "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" 603 | }, 604 | "jszip": { 605 | "version": "3.10.1", 606 | "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", 607 | "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", 608 | "requires": { 609 | "lie": "~3.3.0", 610 | "pako": "~1.0.2", 611 | "readable-stream": "~2.3.6", 612 | "setimmediate": "^1.0.5" 613 | } 614 | }, 615 | "lie": { 616 | "version": "3.3.0", 617 | "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", 618 | "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", 619 | "requires": { 620 | "immediate": "~3.0.5" 621 | } 622 | }, 623 | "loupe": { 624 | "version": "2.3.6", 625 | "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", 626 | "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", 627 | "requires": { 628 | "get-func-name": "^2.0.0" 629 | } 630 | }, 631 | "make-error": { 632 | "version": "1.3.6", 633 | "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", 634 | "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" 635 | }, 636 | "minimatch": { 637 | "version": "3.1.2", 638 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 639 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 640 | "requires": { 641 | "brace-expansion": "^1.1.7" 642 | } 643 | }, 644 | "once": { 645 | "version": "1.4.0", 646 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 647 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 648 | "requires": { 649 | "wrappy": "1" 650 | } 651 | }, 652 | "pako": { 653 | "version": "1.0.11", 654 | "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", 655 | "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" 656 | }, 657 | "path-is-absolute": { 658 | "version": "1.0.1", 659 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 660 | "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==" 661 | }, 662 | "pathval": { 663 | "version": "1.1.1", 664 | "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", 665 | "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==" 666 | }, 667 | "process-nextick-args": { 668 | "version": "2.0.1", 669 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", 670 | "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" 671 | }, 672 | "readable-stream": { 673 | "version": "2.3.8", 674 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", 675 | "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", 676 | "requires": { 677 | "core-util-is": "~1.0.0", 678 | "inherits": "~2.0.3", 679 | "isarray": "~1.0.0", 680 | "process-nextick-args": "~2.0.0", 681 | "safe-buffer": "~5.1.1", 682 | "string_decoder": "~1.1.1", 683 | "util-deprecate": "~1.0.1" 684 | } 685 | }, 686 | "rimraf": { 687 | "version": "3.0.2", 688 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 689 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 690 | "requires": { 691 | "glob": "^7.1.3" 692 | } 693 | }, 694 | "safe-buffer": { 695 | "version": "5.1.2", 696 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 697 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" 698 | }, 699 | "selenium-webdriver": { 700 | "version": "4.8.1", 701 | "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.8.1.tgz", 702 | "integrity": "sha512-p4MtfhCQdcV6xxkS7eI0tQN6+WNReRULLCAuT4RDGkrjfObBNXMJ3WT8XdK+aXTr5nnBKuh+PxIevM0EjJgkxA==", 703 | "requires": { 704 | "jszip": "^3.10.0", 705 | "tmp": "^0.2.1", 706 | "ws": ">=8.11.0" 707 | } 708 | }, 709 | "setimmediate": { 710 | "version": "1.0.5", 711 | "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", 712 | "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" 713 | }, 714 | "source-map": { 715 | "version": "0.6.1", 716 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 717 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" 718 | }, 719 | "source-map-support": { 720 | "version": "0.5.21", 721 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", 722 | "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", 723 | "requires": { 724 | "buffer-from": "^1.0.0", 725 | "source-map": "^0.6.0" 726 | } 727 | }, 728 | "string_decoder": { 729 | "version": "1.1.1", 730 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", 731 | "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", 732 | "requires": { 733 | "safe-buffer": "~5.1.0" 734 | } 735 | }, 736 | "supports-color": { 737 | "version": "1.3.1", 738 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-1.3.1.tgz", 739 | "integrity": "sha512-OHbMkscHFRcNWEcW80fYhCrzAjheSIBwJChpFaBqA6zEz53nxumqi6ukciRb/UA0/v2nDNMk28ce/uBbYRDsng==" 740 | }, 741 | "term-color": { 742 | "version": "1.0.1", 743 | "resolved": "https://registry.npmjs.org/term-color/-/term-color-1.0.1.tgz", 744 | "integrity": "sha512-4Ld+sFlAdziaaMabvBU215dxyMotGoz7yN+9GtPE7RhKvzXAmg8tD/nKohJp4v2bMdSsNO3FEIBxFDsXu0Pf8w==", 745 | "requires": { 746 | "ansi-styles": "2.0.1", 747 | "supports-color": "1.3.1" 748 | } 749 | }, 750 | "tmp": { 751 | "version": "0.2.1", 752 | "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", 753 | "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", 754 | "requires": { 755 | "rimraf": "^3.0.0" 756 | } 757 | }, 758 | "ts-node": { 759 | "version": "8.10.2", 760 | "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.10.2.tgz", 761 | "integrity": "sha512-ISJJGgkIpDdBhWVu3jufsWpK3Rzo7bdiIXJjQc0ynKxVOVcg2oIrf2H2cejminGrptVc6q6/uynAHNCuWGbpVA==", 762 | "requires": { 763 | "arg": "^4.1.0", 764 | "diff": "^4.0.1", 765 | "make-error": "^1.1.1", 766 | "source-map-support": "^0.5.17", 767 | "yn": "3.1.1" 768 | } 769 | }, 770 | "type-detect": { 771 | "version": "4.0.8", 772 | "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", 773 | "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" 774 | }, 775 | "typescript": { 776 | "version": "3.9.10", 777 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.9.10.tgz", 778 | "integrity": "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==" 779 | }, 780 | "util-deprecate": { 781 | "version": "1.0.2", 782 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 783 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" 784 | }, 785 | "wrappy": { 786 | "version": "1.0.2", 787 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 788 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 789 | }, 790 | "ws": { 791 | "version": "8.13.0", 792 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", 793 | "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", 794 | "requires": {} 795 | }, 796 | "yn": { 797 | "version": "3.1.1", 798 | "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", 799 | "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==" 800 | } 801 | } 802 | } 803 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(clippy::all)] 2 | #![warn(clippy::pedantic)] 3 | #![allow( 4 | clippy::non_ascii_literal, 5 | clippy::single_match_else, 6 | clippy::if_not_else, 7 | clippy::similar_names, 8 | clippy::module_name_repetitions, 9 | clippy::must_use_candidate, 10 | clippy::unused_unit, // TODO: Remove once https://github.com/rustwasm/wasm-bindgen/issues/2774 is released 11 | clippy::manual_let_else, 12 | )] 13 | 14 | #[macro_use] 15 | extern crate log; 16 | 17 | mod extract; 18 | mod selection; 19 | mod utils; 20 | 21 | use cfg_if::cfg_if; 22 | use log::Level; 23 | use wasm_bindgen::{prelude::*, JsCast}; 24 | use web_sys::{self, Element, HtmlDocument, HtmlElement, Node, Range, Selection, Text}; 25 | 26 | use crate::{ 27 | extract::extract_text, 28 | selection::{activate_selection_range, glue_range_to_text, set_selection_range, Position}, 29 | }; 30 | 31 | cfg_if! { 32 | // When the `wee_alloc` feature is enabled, use `wee_alloc` as the global 33 | // allocator. 34 | if #[cfg(feature = "wee_alloc")] { 35 | extern crate wee_alloc; 36 | #[global_allocator] 37 | static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; 38 | } 39 | } 40 | 41 | /// The context object containing the state. 42 | #[wasm_bindgen] 43 | pub struct ComposeArea { 44 | window: web_sys::Window, 45 | document: web_sys::Document, 46 | wrapper: Element, 47 | /// The selection range. This will always be a selection within the compose 48 | /// area wrapper, if set. 49 | /// 50 | /// NOTE: When setting this value to a range, make sure that the range was 51 | /// cloned, so that updates to the range in the browser aren't reflected in 52 | /// this instance. 53 | selection_range: Option, 54 | /// Counter used for creating unique element IDs. 55 | counter: u32, 56 | } 57 | 58 | /// This enum is relevant when determining the current node while the caret is 59 | /// exactly between two nodes. 60 | /// 61 | /// Depending on this enum value, the node before or after the cursor is returned. 62 | #[derive(Debug, PartialEq, Eq, Copy, Clone)] 63 | pub enum Direction { 64 | Before, 65 | After, 66 | } 67 | 68 | #[wasm_bindgen] 69 | #[derive(Debug, Clone)] 70 | pub struct RangeResult { 71 | /// The selection range, if any. 72 | range: Option, 73 | /// Whether the selection range is not fully contained in the wrapper. 74 | /// This is set to `false` if no range could be found. 75 | outside: bool, 76 | } 77 | 78 | impl RangeResult { 79 | fn contained(range: Range) -> Self { 80 | Self { 81 | range: Some(range), 82 | outside: false, 83 | } 84 | } 85 | 86 | fn outside(range: Range) -> Self { 87 | Self { 88 | range: Some(range), 89 | outside: true, 90 | } 91 | } 92 | 93 | fn none() -> Self { 94 | Self { 95 | range: None, 96 | outside: false, 97 | } 98 | } 99 | } 100 | 101 | #[wasm_bindgen] 102 | impl RangeResult { 103 | fn format_node(node: &Node) -> String { 104 | let name = node.node_name(); 105 | match node.dyn_ref::().map(Element::id) { 106 | Some(id) => format!("{}#{}", name.trim_matches('#'), id), 107 | None => name.trim_matches('#').to_string(), 108 | } 109 | } 110 | 111 | /// Return a compact or non-compact string representation of the range. 112 | fn to_string_impl(&self, compact: bool) -> String { 113 | match (&self.range, self.outside) { 114 | (_, true) => "Outside".to_string(), 115 | (None, _) => "None".to_string(), 116 | (Some(range), false) => { 117 | let sc = range.start_container().ok(); 118 | let so = range.start_offset().ok(); 119 | let ec = range.end_container().ok(); 120 | let eo = range.end_offset().ok(); 121 | match (sc, so, ec, eo, compact) { 122 | (Some(sc), Some(so), Some(ec), Some(eo), true) => { 123 | format!( 124 | "Range({}~{}, {}~{})", 125 | Self::format_node(&sc), 126 | &so, 127 | Self::format_node(&ec), 128 | &eo, 129 | ) 130 | } 131 | (Some(sc), Some(so), Some(ec), Some(eo), false) => { 132 | format!( 133 | "Range {{\n \ 134 | start: {} ~ {}\n \ 135 | end: {} ~ {}\n\ 136 | }}", 137 | Self::format_node(&sc), 138 | &so, 139 | Self::format_node(&ec), 140 | &eo, 141 | ) 142 | } 143 | _ => "Incomplete Range".to_string(), 144 | } 145 | } 146 | } 147 | } 148 | 149 | /// Used by JS code to show a string representation of the range. 150 | #[allow(clippy::inherent_to_string)] 151 | pub fn to_string(&self) -> String { 152 | self.to_string_impl(false) 153 | } 154 | 155 | /// Used by JS code to show a string representation of the range. 156 | pub fn to_string_compact(&self) -> String { 157 | self.to_string_impl(true) 158 | } 159 | } 160 | 161 | #[wasm_bindgen] 162 | #[derive(Debug, Clone)] 163 | pub struct WordAtCaret { 164 | node: Node, 165 | before: String, 166 | after: String, 167 | offsets: (u32, u32), 168 | } 169 | 170 | #[wasm_bindgen] 171 | impl WordAtCaret { 172 | pub fn node(&self) -> Node { 173 | self.node.clone() 174 | } 175 | 176 | pub fn before(&self) -> String { 177 | self.before.clone() 178 | } 179 | 180 | pub fn after(&self) -> String { 181 | self.after.clone() 182 | } 183 | 184 | /// Return the UTF16 offset from the start node where the current word starts (inclusive). 185 | pub fn start_offset(&self) -> u32 { 186 | self.offsets.0 187 | } 188 | 189 | /// Return the UTF16 offset from the start node where the current word ends (exclusive). 190 | pub fn end_offset(&self) -> u32 { 191 | self.offsets.1 192 | } 193 | } 194 | 195 | #[wasm_bindgen] 196 | impl ComposeArea { 197 | /// Initialize a new compose area wrapper. 198 | /// 199 | /// If the `log_level` argument is supplied, the console logger is 200 | /// initialized. Valid log levels: `trace`, `debug`, `info`, `warn` or 201 | /// `error`. 202 | pub fn bind_to(wrapper: Element, log_level: Option) -> Self { 203 | utils::set_panic_hook(); 204 | 205 | // Set log level 206 | if let Some(level) = log_level { 207 | match &*level { 208 | "trace" => utils::init_log(Level::Trace), 209 | "debug" => utils::init_log(Level::Debug), 210 | "info" => utils::init_log(Level::Info), 211 | "warn" => utils::init_log(Level::Warn), 212 | "error" => utils::init_log(Level::Error), 213 | other => { 214 | web_sys::console::warn_1( 215 | &format!("bind_to: Invalid log level: {other}").into(), 216 | ); 217 | } 218 | } 219 | } 220 | trace!("[compose_area] bind_to"); 221 | 222 | let window = web_sys::window().expect("No global `window` exists"); 223 | let document = window.document().expect("Should have a document on window"); 224 | 225 | // Initialize the wrapper element 226 | wrapper 227 | .class_list() 228 | .add_2("cawrapper", "initialized") 229 | .expect("Could not add wrapper classes"); 230 | wrapper 231 | .set_attribute("contenteditable", "true") 232 | .expect("Could not set contenteditable attr"); 233 | 234 | info!("[compose_area] Initialized"); 235 | 236 | Self { 237 | window, 238 | document, 239 | wrapper, 240 | selection_range: None, 241 | counter: 0, 242 | } 243 | } 244 | 245 | /// Store the current selection range. 246 | /// Return the stored range. 247 | pub fn store_selection_range(&mut self) -> RangeResult { 248 | trace!("[compose_area] store_selection_range"); 249 | let range_result = self.fetch_range(); 250 | trace!( 251 | "[compose_area] Range: {}", 252 | range_result.to_string().replace('\n', "") 253 | ); 254 | 255 | // Ignore selections outside the wrapper 256 | if !range_result.outside { 257 | // Note: We need to clone the range object. Otherwise, changes to the 258 | // range in the DOM will be reflected in our stored reference. 259 | self.selection_range = range_result.clone().range.map(|range| range.clone_range()); 260 | } 261 | 262 | range_result 263 | } 264 | 265 | /// Restore the stored selection range. 266 | /// 267 | /// Return a boolean indicating whether a selection range was stored (and 268 | /// thus restored). 269 | pub fn restore_selection_range(&self) -> bool { 270 | trace!("[compose_area] restore_selection_range"); 271 | if let Some(ref range) = self.selection_range { 272 | // Get the current selection 273 | let selection = match self.fetch_selection() { 274 | Some(selection) => selection, 275 | None => { 276 | error!("[compose_area] No selection found"); 277 | return false; 278 | } 279 | }; 280 | 281 | // Restore the range 282 | if selection.remove_all_ranges().is_err() { 283 | error!("[compose_area] Removing all ranges failed"); 284 | } 285 | match selection.add_range(range) { 286 | Ok(_) => true, 287 | Err(_) => { 288 | error!("[compose_area] Adding range failed"); 289 | false 290 | } 291 | } 292 | } else { 293 | trace!("[compose_area] No stored range"); 294 | false 295 | } 296 | } 297 | 298 | /// Ensure that there's an active selection inside the compose are. Then 299 | /// exec the specified command, normalize the compose area and store the 300 | /// new selection range. 301 | fn exec_command(&mut self, command_id: &str, value: &str) { 302 | // Ensure that there's an active selection inside the compose area. 303 | let active_range = self.fetch_range(); 304 | if active_range.range.is_none() || active_range.outside { 305 | // No active selection range inside the compose area. 306 | match self.selection_range { 307 | Some(ref range) => { 308 | activate_selection_range( 309 | &self 310 | .fetch_selection() 311 | .expect("Could not get window selection"), 312 | range, 313 | ); 314 | } 315 | None => { 316 | // No stored selection range. Create a new selection at the end end. 317 | let last_child_node = utils::get_last_child(&self.wrapper); 318 | self.selection_range = match last_child_node { 319 | Some(ref node) => { 320 | // Insert at the very end, unless the last element in the 321 | // area is a `
` node. This is needed because Firefox 322 | // always adds a trailing newline that isn't rendered 323 | let mut insert_before = false; 324 | if let Some(element) = node.dyn_ref::() { 325 | if element.tag_name() == "BR" { 326 | insert_before = true; 327 | } 328 | } 329 | if insert_before { 330 | set_selection_range(&Position::Before(node), None) 331 | } else { 332 | set_selection_range(&Position::After(node), None) 333 | } 334 | } 335 | None => set_selection_range(&Position::Offset(&self.wrapper, 0), None), 336 | } 337 | .map(|range| range.clone_range()); 338 | } 339 | } 340 | } 341 | 342 | // Execute command 343 | self.document 344 | .dyn_ref::() 345 | .expect("Document is not a HtmlDocument") 346 | .exec_command_with_show_ui_and_value(command_id, false, value) 347 | .expect("Could not exec command"); 348 | self.normalize(); 349 | self.store_selection_range(); 350 | } 351 | 352 | /// Return and increment the counter variable. 353 | fn get_counter(&mut self) -> u32 { 354 | let val = self.counter; 355 | self.counter += 1; 356 | val 357 | } 358 | 359 | /// Insert an image at the current caret position. 360 | /// 361 | /// Return a reference to the inserted image element. 362 | pub fn insert_image(&mut self, src: &str, alt: &str, cls: &str) -> HtmlElement { 363 | debug!("[compose_area] insert_image ({})", &alt); 364 | 365 | // NOTE: Ideally we'd create an image node here and would then use 366 | // `insert_node`. But unfortunately that will not modify the undo 367 | // stack of the browser (see https://stackoverflow.com/a/15895618). 368 | // Thus, we need to resort to an ugly `execCommand` with a HTML 369 | // string. Furthermore, we need to create a random ID in order 370 | // to be able to find the image again in the DOM. 371 | 372 | let img_id = format!("__$$compose_area_img_{}", self.get_counter()); 373 | let html = format!( 374 | "\"{}\"", 375 | img_id, 376 | src.replace('"', ""), 377 | alt.replace('"', ""), 378 | cls.replace('"', ""), 379 | ); 380 | self.insert_html(&html); 381 | 382 | self.document 383 | .get_element_by_id(&img_id) 384 | .expect("Could not find inserted image node") 385 | .dyn_into::() 386 | .expect("Could not cast image element into HtmlElement") 387 | } 388 | 389 | /// Insert plain text at the current caret position. 390 | pub fn insert_text(&mut self, text: &str) { 391 | debug!("[compose_area] insert_text ({})", text); 392 | self.exec_command("insertText", text); 393 | } 394 | 395 | /// Insert HTML at the current caret position. 396 | /// 397 | /// Note: This is potentially dangerous, make sure that you only insert 398 | /// HTML from trusted sources! 399 | pub fn insert_html(&mut self, html: &str) { 400 | debug!("[compose_area] insert_html ({})", html); 401 | self.exec_command("insertHTML", html); 402 | } 403 | 404 | /// Insert the specified node at the previously stored selection range. 405 | /// Set the caret position to right after the newly inserted node. 406 | /// 407 | /// **NOTE:** Due to browser limitations, this will not result in a new 408 | /// entry in the browser's internal undo stack. This means that the node 409 | /// insertion cannot be undone using Ctrl+Z. 410 | pub fn insert_node(&mut self, node_ref: &Node) { 411 | debug!("[compose_area] insert_node"); 412 | 413 | // Insert the node 414 | if let Some(ref range) = self.selection_range { 415 | range 416 | .delete_contents() 417 | .expect("Could not remove selection contents"); 418 | range.insert_node(node_ref).expect("Could not insert node"); 419 | } else { 420 | // No current selection. Append at end, unless the last element in 421 | // the area is a `
` node. This is needed because Firefox always 422 | // adds a trailing newline that isn't rendered. 423 | let last_child_node = utils::get_last_child(&self.wrapper); 424 | match last_child_node.and_then(|n| n.dyn_into::().ok()) { 425 | Some(ref element) if element.tag_name() == "BR" => { 426 | self.wrapper 427 | .insert_before(node_ref, Some(element)) 428 | .expect("Could not insert child"); 429 | } 430 | Some(_) | None => { 431 | self.wrapper 432 | .append_child(node_ref) 433 | .expect("Could not append child"); 434 | } 435 | }; 436 | } 437 | 438 | // Update selection 439 | self.selection_range = 440 | set_selection_range(&Position::After(node_ref), None).map(|range| range.clone_range()); 441 | 442 | // Normalize elements 443 | self.normalize(); 444 | } 445 | 446 | /// Normalize the contents of the wrapper element. 447 | /// 448 | /// See 449 | fn normalize(&self) { 450 | trace!("[compose_area] normalize"); 451 | self.wrapper.normalize(); 452 | } 453 | 454 | /// Return the DOM selection. 455 | fn fetch_selection(&self) -> Option { 456 | trace!("[compose_area] fetch_selection"); 457 | self.window 458 | .get_selection() 459 | .expect("Could not get selection from window") 460 | } 461 | 462 | /// Return the last range of the selection that is within the wrapper 463 | /// element. 464 | pub fn fetch_range(&self) -> RangeResult { 465 | trace!("[compose_area] fetch_range"); 466 | let selection = match self.fetch_selection() { 467 | Some(sel) => sel, 468 | None => { 469 | error!("[compose_area] Could not find selection"); 470 | return RangeResult::none(); 471 | } 472 | }; 473 | let mut candidate: Option = None; 474 | for i in 0..selection.range_count() { 475 | let range = selection 476 | .get_range_at(i) 477 | .expect("Could not get range from selection"); 478 | candidate = Some(range.clone()); 479 | let container = range 480 | .common_ancestor_container() 481 | .expect("Could not get common ancestor container for range"); 482 | if self.wrapper.contains(Some(&container)) { 483 | return RangeResult::contained(range); 484 | } 485 | } 486 | match candidate { 487 | Some(range) => RangeResult::outside(range), 488 | None => RangeResult::none(), 489 | } 490 | } 491 | 492 | /// Extract the text in the compose area. 493 | /// 494 | /// Convert elements like images to alt text. 495 | /// 496 | /// Args: 497 | /// - `no_trim`: If set to `true`, don't trim leading / trailing whitespace 498 | /// from returned text. Default: `false`. 499 | pub fn get_text(&self, no_trim: Option) -> String { 500 | debug!("[compose_area] get_text"); 501 | extract_text(&self.wrapper, no_trim.unwrap_or(false)) 502 | } 503 | 504 | /// Return whether the compose area is empty. 505 | /// 506 | /// Note: Right now this is a convenience wrapper around 507 | /// `get_text(no_trim).length === 0`, but it might get optimized in the 508 | /// future. 509 | /// 510 | /// Args: 511 | /// - `no_trim`: If set to `true`, don't trim leading / trailing whitespace 512 | /// from returned text. Default: `false`. 513 | pub fn is_empty(&self, no_trim: Option) -> bool { 514 | debug!("[compose_area] is_empty"); 515 | extract_text(&self.wrapper, no_trim.unwrap_or(false)).is_empty() 516 | } 517 | 518 | /// Focus the compose area. 519 | pub fn focus(&self) { 520 | debug!("[compose_area] focus"); 521 | self.restore_selection_range(); 522 | if let Some(e) = self.wrapper.dyn_ref::() { 523 | e.focus() 524 | .unwrap_or_else(|_| error!("[compose_area] Could not focus compose area")); 525 | } 526 | } 527 | 528 | /// Clear the contents of the compose area. 529 | pub fn clear(&mut self) { 530 | debug!("[compose_area] clear"); 531 | while self.wrapper.has_child_nodes() { 532 | let last_child = self 533 | .wrapper 534 | .last_child() 535 | .expect("Could not find last child"); 536 | self.wrapper 537 | .remove_child(&last_child) 538 | .expect("Could not remove last child"); 539 | } 540 | self.selection_range = None; 541 | } 542 | 543 | /// Return the word (whitespace delimited) at the current caret position. 544 | /// 545 | /// Note: This methods uses the range that was last set with 546 | /// `store_selection_range`. 547 | pub fn get_word_at_caret(&mut self) -> Option { 548 | debug!("[compose_area] get_word_at_caret"); 549 | 550 | if let Some(ref range) = self.selection_range { 551 | // Clone the current range so we don't modify any existing selection 552 | let mut range = range.clone_range(); 553 | 554 | // Ensure that range is relative to a text node 555 | if !glue_range_to_text(&mut range) { 556 | return None; 557 | } 558 | 559 | // Get the container element (which is the same for start and end 560 | // since the range is collapsed) and offset. After having called 561 | // the `glue_range_to_text` function, this will be a text node. 562 | let node: Text = range 563 | .start_container() 564 | .expect("Could not get start container") 565 | .dyn_into::() 566 | .expect("Node is not a text node"); 567 | let offset: u32 = range.start_offset().expect("Could not get start offset"); 568 | 569 | // Note that the offset refers to JS characters, not bytes. 570 | let text: String = node.data(); 571 | let mut before: Vec = vec![]; 572 | let mut after: Vec = vec![]; 573 | let mut start = 0; 574 | let mut end = 0; 575 | let is_word_boundary = |c: u16| c == 0x20 /* space */ || c == 0x09 /* tab */; 576 | for (i, c) in text.encode_utf16().enumerate() { 577 | if i < offset as usize { 578 | if is_word_boundary(c) { 579 | before.clear(); 580 | start = i + 1; 581 | } else { 582 | before.push(c); 583 | } 584 | } else { 585 | if is_word_boundary(c) { 586 | end = i; 587 | break; 588 | } 589 | after.push(c); 590 | } 591 | } 592 | if end <= start { 593 | end = text.encode_utf16().count(); 594 | } 595 | 596 | // Note: Decoding should not be able to fail since it was 597 | // previously encoded from a string. 598 | #[allow(clippy::cast_possible_truncation)] 599 | Some(WordAtCaret { 600 | node: node 601 | .dyn_into::() 602 | .expect("Could not turn Text into Node"), 603 | before: String::from_utf16(&before).expect("Could not decode UTF16 value"), 604 | after: String::from_utf16(&after).expect("Could not decode UTF16 value"), 605 | offsets: (start as u32, end as u32), 606 | }) 607 | } else { 608 | None 609 | } 610 | } 611 | 612 | /// Select the word (whitespace delimited) at the current caret position. 613 | /// 614 | /// Note: This methods uses the range that was last set with 615 | /// `store_selection_range`. 616 | pub fn select_word_at_caret(&mut self) -> bool { 617 | debug!("[compose_area] select_word_at_caret"); 618 | 619 | if let Some(wac) = self.get_word_at_caret() { 620 | let node = wac.node(); 621 | set_selection_range( 622 | &Position::Offset(&node, wac.start_offset()), 623 | Some(&Position::Offset(&node, wac.end_offset())), 624 | ) 625 | .is_some() 626 | } else { 627 | false 628 | } 629 | } 630 | } 631 | 632 | #[cfg(test)] 633 | mod tests { 634 | use super::*; 635 | 636 | use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; 637 | 638 | wasm_bindgen_test_configure!(run_in_browser); 639 | 640 | fn init() -> ComposeArea { 641 | // Get references 642 | let window = web_sys::window().expect("No global `window` exists"); 643 | let document = window.document().expect("Should have a document on window"); 644 | 645 | // Create wrapper element 646 | let wrapper = document 647 | .create_element("div") 648 | .expect("Could not create wrapper div"); 649 | wrapper 650 | .set_attribute("style", "white-space: pre-wrap;") 651 | .expect("Could not set style on wrapper div"); 652 | document.body().unwrap().append_child(&wrapper).unwrap(); 653 | 654 | // Bind to wrapper 655 | ComposeArea::bind_to(wrapper, Some("trace".into())) 656 | } 657 | 658 | /// Create and return a text node. 659 | fn text_node(ca: &ComposeArea, text: &str) -> Node { 660 | ca.document.create_text_node(text).unchecked_into() 661 | } 662 | 663 | /// Create and return a newline node. 664 | fn element_node(ca: &ComposeArea, name: &str) -> Node { 665 | ca.document.create_element(name).unwrap().unchecked_into() 666 | } 667 | 668 | #[derive(Copy, Clone, Debug)] 669 | struct Img { 670 | src: &'static str, 671 | alt: &'static str, 672 | cls: &'static str, 673 | } 674 | 675 | impl Img { 676 | fn html(&self, counter: u32) -> String { 677 | format!( 678 | r#"{}"#, 679 | counter, self.src, self.alt, self.cls, 680 | ) 681 | } 682 | 683 | fn as_node(&self, ca: &ComposeArea) -> Node { 684 | let img = ca.document.create_element("img").unwrap(); 685 | img.set_attribute("src", self.src).unwrap(); 686 | img.set_attribute("alt", self.alt).unwrap(); 687 | img.set_attribute("class", self.cls).unwrap(); 688 | img.unchecked_into() 689 | } 690 | } 691 | 692 | mod insert_node { 693 | use super::*; 694 | 695 | use std::convert::TryFrom; 696 | 697 | struct PositionByIndex { 698 | /// The index of the child nodes. 699 | /// 700 | /// For example, `[1]` means "the second child node". `[1, 0]` 701 | /// means the first child node of the first child node. 702 | node_index: Vec, 703 | offset: Option, 704 | } 705 | 706 | impl PositionByIndex { 707 | fn offset(node_index: usize, offset: u32) -> Self { 708 | Self { 709 | node_index: vec![node_index], 710 | offset: Some(offset), 711 | } 712 | } 713 | 714 | fn after(node_index: usize) -> Self { 715 | Self { 716 | node_index: vec![node_index], 717 | offset: None, 718 | } 719 | } 720 | 721 | fn after_nested(node_index: Vec) -> Self { 722 | Self { 723 | node_index, 724 | offset: None, 725 | } 726 | } 727 | } 728 | 729 | struct InsertNodeTest { 730 | children: Vec, 731 | selection_start: PositionByIndex, 732 | selection_end: Option, 733 | node: N, 734 | final_html: String, 735 | } 736 | 737 | impl InsertNodeTest { 738 | fn get_node(&self, indices: &[usize]) -> Node { 739 | assert!(!indices.is_empty()); 740 | let mut node: Node = self.children.as_slice().get(indices[0]).unwrap().clone(); 741 | for i in indices.iter().skip(1) { 742 | node = node 743 | .unchecked_ref::() 744 | .child_nodes() 745 | .item(u32::try_from(*i).unwrap()) 746 | .expect("Child node not found"); 747 | } 748 | node 749 | } 750 | 751 | fn do_test(&self, ca: &mut ComposeArea, insert_func: F) 752 | where 753 | F: FnOnce(&mut ComposeArea, &N), 754 | { 755 | // Add child nodes 756 | for child in &self.children { 757 | ca.wrapper.append_child(child).unwrap(); 758 | } 759 | 760 | // Add selection 761 | let node_start = self.get_node(&self.selection_start.node_index); 762 | let pos_start = { 763 | match self.selection_start.offset { 764 | Some(offset) => Position::Offset(&node_start, offset), 765 | None => Position::After(&node_start), 766 | } 767 | }; 768 | match self.selection_end { 769 | Some(ref sel) => { 770 | let node_end = self.get_node(&sel.node_index); 771 | set_selection_range( 772 | &pos_start, 773 | Some(&match sel.offset { 774 | Some(offset) => Position::Offset(&node_end, offset), 775 | None => Position::After(&node_end), 776 | }), 777 | ) 778 | } 779 | None => set_selection_range(&pos_start, None), 780 | }; 781 | 782 | // Insert node and verify 783 | ca.store_selection_range(); 784 | insert_func(ca, &self.node); 785 | assert_eq!(ca.wrapper.inner_html(), self.final_html); 786 | } 787 | } 788 | 789 | impl InsertNodeTest<&'static str> { 790 | fn test(&self, ca: &mut ComposeArea) { 791 | self.do_test(ca, |ca, node| { 792 | ca.insert_text(node); 793 | }); 794 | } 795 | } 796 | 797 | impl InsertNodeTest { 798 | fn test(&self, ca: &mut ComposeArea) { 799 | self.do_test(ca, |ca, node| { 800 | ca.insert_image(node.src, node.alt, node.cls); 801 | }); 802 | } 803 | } 804 | 805 | mod text { 806 | use super::*; 807 | 808 | #[wasm_bindgen_test] 809 | fn at_end() { 810 | let mut ca = init(); 811 | InsertNodeTest { 812 | children: vec![text_node(&ca, "hello ")], 813 | selection_start: PositionByIndex::after(0), 814 | selection_end: None, 815 | node: "world", 816 | final_html: "hello world".into(), 817 | } 818 | .test(&mut ca); 819 | } 820 | 821 | #[wasm_bindgen_test] 822 | fn in_the_middle() { 823 | let mut ca = init(); 824 | InsertNodeTest { 825 | children: vec![text_node(&ca, "ab")], 826 | selection_start: PositionByIndex::offset(0, 1), 827 | selection_end: None, 828 | node: "XY", 829 | final_html: "aXYb".into(), 830 | } 831 | .test(&mut ca); 832 | } 833 | 834 | #[wasm_bindgen_test] 835 | fn replace_text() { 836 | let mut ca = init(); 837 | InsertNodeTest { 838 | children: vec![text_node(&ca, "abcd")], 839 | selection_start: PositionByIndex::offset(0, 1), 840 | selection_end: Some(PositionByIndex::offset(0, 3)), 841 | node: "X", 842 | final_html: "aXd".into(), 843 | } 844 | .test(&mut ca); 845 | } 846 | 847 | #[wasm_bindgen_test] 848 | fn replace_nodes() { 849 | let mut ca = init(); 850 | let img = Img { 851 | src: "img.jpg", 852 | alt: "😀", 853 | cls: "em", 854 | }; 855 | InsertNodeTest { 856 | children: vec![text_node(&ca, "ab"), img.as_node(&ca)], 857 | selection_start: PositionByIndex::offset(0, 1), 858 | selection_end: Some(PositionByIndex::after(1)), 859 | node: "z", 860 | final_html: "az".into(), 861 | } 862 | .test(&mut ca); 863 | } 864 | } 865 | 866 | mod image { 867 | use super::*; 868 | 869 | #[wasm_bindgen_test] 870 | fn at_end() { 871 | let mut ca = init(); 872 | let img = Img { 873 | src: "img.jpg", 874 | alt: "😀", 875 | cls: "em", 876 | }; 877 | InsertNodeTest { 878 | children: vec![text_node(&ca, "hi ")], 879 | selection_start: PositionByIndex::after(0), 880 | selection_end: None, 881 | node: img, 882 | final_html: format!("hi {}", img.html(0)), 883 | } 884 | .test(&mut ca); 885 | } 886 | 887 | /// If there is no selection but a trailing newline, element 888 | /// will replace that trailing newline due to the way how the 889 | /// `insertHTML` command works. 890 | #[wasm_bindgen_test] 891 | fn at_end_with_br() { 892 | let mut ca = init(); 893 | let img = Img { 894 | src: "img.jpg", 895 | alt: "😀", 896 | cls: "em", 897 | }; 898 | 899 | // Prepare wrapper 900 | ca.wrapper.set_inner_html("
"); 901 | 902 | // Ensure that there's no selection left in the DOM 903 | selection::unset_selection_range(); 904 | 905 | // Insert node and verify 906 | ca.insert_image(img.src, img.alt, img.cls); 907 | assert_eq!(ca.wrapper.inner_html(), img.html(0)); 908 | } 909 | 910 | #[wasm_bindgen_test] 911 | fn split_text() { 912 | let mut ca = init(); 913 | let img = Img { 914 | src: "img.jpg", 915 | alt: "😀", 916 | cls: "em", 917 | }; 918 | InsertNodeTest { 919 | children: vec![text_node(&ca, "bonjour")], 920 | selection_start: PositionByIndex::offset(0, 3), 921 | selection_end: None, 922 | node: img, 923 | final_html: format!("bon{}jour", img.html(0)), 924 | } 925 | .test(&mut ca); 926 | } 927 | 928 | #[wasm_bindgen_test] 929 | fn between_nodes_br() { 930 | let mut ca = init(); 931 | let img = Img { 932 | src: "img.jpg", 933 | alt: "😀", 934 | cls: "em", 935 | }; 936 | InsertNodeTest { 937 | children: vec![ 938 | text_node(&ca, "a"), 939 | element_node(&ca, "br"), 940 | text_node(&ca, "b"), 941 | ], 942 | selection_start: PositionByIndex::after(0), 943 | selection_end: None, 944 | node: img, 945 | final_html: format!("a{}
b", img.html(0)), 946 | } 947 | .test(&mut ca); 948 | } 949 | 950 | #[wasm_bindgen_test] 951 | fn between_nodes_div() { 952 | let mut ca = init(); 953 | let img = Img { 954 | src: "img.jpg", 955 | alt: "😀", 956 | cls: "em", 957 | }; 958 | let div_a = { 959 | let div = element_node(&ca, "div"); 960 | div.append_child(&text_node(&ca, "a")).unwrap(); 961 | div 962 | }; 963 | let div_b = { 964 | let div = element_node(&ca, "div"); 965 | div.append_child(&text_node(&ca, "b")).unwrap(); 966 | div.append_child(&element_node(&ca, "br")).unwrap(); 967 | div 968 | }; 969 | InsertNodeTest { 970 | children: vec![div_a, div_b], 971 | selection_start: PositionByIndex::after_nested(vec![0, 0]), 972 | selection_end: None, 973 | node: img, 974 | final_html: format!("
a{}
b
", img.html(0)), 975 | } 976 | .test(&mut ca); 977 | } 978 | } 979 | } 980 | 981 | mod selection_range { 982 | use super::*; 983 | 984 | #[wasm_bindgen_test] 985 | fn restore_selection_range() { 986 | let mut ca = init(); 987 | let node = text_node(&ca, "abc"); 988 | ca.wrapper.append_child(&node).unwrap(); 989 | 990 | // Highlight "b" 991 | set_selection_range( 992 | &Position::Offset(&node, 1), 993 | Some(&Position::Offset(&node, 2)), 994 | ); 995 | let range_result = ca.fetch_range(); 996 | assert!(!range_result.outside); 997 | let range = range_result.range.expect("Could not get range"); 998 | assert_eq!(range.start_offset().unwrap(), 1); 999 | assert_eq!(range.end_offset().unwrap(), 2); 1000 | 1001 | // Store range 1002 | ca.store_selection_range(); 1003 | 1004 | // Change range, highlight "a" 1005 | set_selection_range( 1006 | &Position::Offset(&node, 0), 1007 | Some(&Position::Offset(&node, 1)), 1008 | ); 1009 | let range_result = ca.fetch_range(); 1010 | assert!(!range_result.outside); 1011 | let range = range_result.range.expect("Could not get range"); 1012 | assert_eq!(range.start_offset().unwrap(), 0); 1013 | assert_eq!(range.end_offset().unwrap(), 1); 1014 | 1015 | // Retore range 1016 | ca.restore_selection_range(); 1017 | let range_result = ca.fetch_range(); 1018 | assert!(!range_result.outside); 1019 | let range = range_result.range.expect("Could not get range"); 1020 | assert_eq!(range.start_offset().unwrap(), 1); 1021 | assert_eq!(range.end_offset().unwrap(), 2); 1022 | } 1023 | 1024 | #[wasm_bindgen_test] 1025 | fn get_range_result() { 1026 | let ca = init(); 1027 | let inner_text_node = text_node(&ca, "abc"); 1028 | ca.wrapper.append_child(&inner_text_node).unwrap(); 1029 | 1030 | // No range set 1031 | selection::unset_selection_range(); 1032 | let range_result = ca.fetch_range(); 1033 | assert!(range_result.range.is_none()); 1034 | assert!(!range_result.outside); 1035 | 1036 | // Range is outside 1037 | let outer_text_node = ca.document.create_text_node("hello"); 1038 | ca.document 1039 | .body() 1040 | .unwrap() 1041 | .append_child(&outer_text_node) 1042 | .unwrap(); 1043 | set_selection_range(&Position::Offset(&outer_text_node, 0), None); 1044 | let range_result = ca.fetch_range(); 1045 | assert!(range_result.range.is_some()); 1046 | assert!(range_result.outside); 1047 | 1048 | // Inside wrapper 1049 | set_selection_range(&Position::Offset(&inner_text_node, 0), None); 1050 | let range_result = ca.fetch_range(); 1051 | assert!(range_result.range.is_some()); 1052 | assert!(!range_result.outside); 1053 | } 1054 | } 1055 | 1056 | mod clear { 1057 | use super::*; 1058 | 1059 | #[wasm_bindgen_test] 1060 | fn clear_contents() { 1061 | // Init, no child nodes 1062 | let mut ca = init(); 1063 | assert_eq!(ca.wrapper.child_nodes().length(), 0); 1064 | 1065 | // Append some child nodes 1066 | ca.wrapper.append_child(&text_node(&ca, "abc")).unwrap(); 1067 | ca.wrapper.append_child(&element_node(&ca, "br")).unwrap(); 1068 | assert_eq!(ca.wrapper.child_nodes().length(), 2); 1069 | 1070 | // Clear 1071 | ca.clear(); 1072 | assert_eq!(ca.wrapper.child_nodes().length(), 0); 1073 | } 1074 | } 1075 | 1076 | mod word_at_caret { 1077 | use super::*; 1078 | 1079 | #[wasm_bindgen_test] 1080 | fn empty() { 1081 | let mut ca = init(); 1082 | let wac = ca.get_word_at_caret(); 1083 | assert!(wac.is_none()); 1084 | } 1085 | 1086 | #[wasm_bindgen_test] 1087 | fn in_text() { 1088 | let mut ca = init(); 1089 | 1090 | let text = ca.document.create_text_node("hello world!\tgoodbye."); 1091 | ca.wrapper.append_child(&text).unwrap(); 1092 | set_selection_range(&Position::Offset(&text, 9), None); 1093 | ca.store_selection_range(); 1094 | 1095 | let wac = ca 1096 | .get_word_at_caret() 1097 | .expect("get_word_at_caret returned None"); 1098 | assert_eq!(&wac.before(), "wor"); 1099 | assert_eq!(&wac.after(), "ld!"); 1100 | assert_eq!(wac.start_offset(), 6); 1101 | assert_eq!(wac.end_offset(), 12); 1102 | } 1103 | 1104 | #[wasm_bindgen_test] 1105 | fn after_text() { 1106 | let mut ca = init(); 1107 | 1108 | let text = ca.document.create_text_node("hello world"); 1109 | ca.wrapper.append_child(&text).unwrap(); 1110 | set_selection_range(&Position::After(&text), None); 1111 | ca.store_selection_range(); 1112 | 1113 | let wac = ca 1114 | .get_word_at_caret() 1115 | .expect("get_word_at_caret returned None"); 1116 | assert_eq!(&wac.before(), "world"); 1117 | assert_eq!(&wac.after(), ""); 1118 | assert_eq!(wac.start_offset(), 6); 1119 | assert_eq!(wac.end_offset(), 11); 1120 | } 1121 | 1122 | #[wasm_bindgen_test] 1123 | fn before_word() { 1124 | let mut ca = init(); 1125 | 1126 | let text = ca.document.create_text_node("hello world"); 1127 | ca.wrapper.append_child(&text).unwrap(); 1128 | set_selection_range(&Position::Offset(&text, 0), None); 1129 | ca.store_selection_range(); 1130 | 1131 | let wac = ca 1132 | .get_word_at_caret() 1133 | .expect("get_word_at_caret returned None"); 1134 | assert_eq!(&wac.before(), ""); 1135 | assert_eq!(&wac.after(), "hello"); 1136 | assert_eq!(wac.start_offset(), 0); 1137 | assert_eq!(wac.end_offset(), 5); 1138 | } 1139 | 1140 | #[wasm_bindgen_test] 1141 | fn single_word() { 1142 | let mut ca = init(); 1143 | 1144 | let text = ca.document.create_text_node(":ok:"); 1145 | ca.wrapper.append_child(&text).unwrap(); 1146 | set_selection_range(&Position::Offset(&text, 4), None); 1147 | ca.store_selection_range(); 1148 | 1149 | let wac = ca 1150 | .get_word_at_caret() 1151 | .expect("get_word_at_caret returned None"); 1152 | assert_eq!(&wac.before(), ":ok:"); 1153 | assert_eq!(&wac.after(), ""); 1154 | assert_eq!(wac.start_offset(), 0); 1155 | assert_eq!(wac.end_offset(), 4); 1156 | } 1157 | } 1158 | } 1159 | --------------------------------------------------------------------------------