├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .jsdoc.json ├── README.md ├── cypress.json ├── docs ├── README.md └── webpack.config.js ├── karma.conf.js ├── package-lock.json ├── package.json ├── src ├── scripts │ ├── config │ │ ├── config.js │ │ ├── styles.js │ │ └── toolbar.js │ ├── containers │ │ ├── AppContainer.js │ │ ├── CanvasContainer.js │ │ ├── FormatterContainer.js │ │ └── UIContainer.js │ ├── core │ │ ├── Container.js │ │ ├── Context.js │ │ ├── Mediator.js │ │ └── Module.js │ ├── index.js │ ├── modules │ │ ├── BaseFormatter.js │ │ ├── BlockFormatter.js │ │ ├── Canvas.js │ │ ├── Commands.js │ │ ├── Config.js │ │ ├── ContentEditable.js │ │ ├── Flyout.js │ │ ├── LinkFormatter.js │ │ ├── ListFormatter.js │ │ ├── Mouse.js │ │ ├── Paste.js │ │ ├── Selection.js │ │ ├── Styles.js │ │ ├── TextFormatter.js │ │ ├── Toolbar.js │ │ └── Undo.js │ ├── polyfills │ │ ├── array │ │ │ └── forEach.js │ │ ├── index.js │ │ └── object │ │ │ └── assign.js │ └── utils │ │ ├── DOM.js │ │ ├── browser.js │ │ ├── commands.js │ │ ├── func.js │ │ ├── guid.js │ │ ├── keycodes.js │ │ ├── paste.js │ │ ├── string.js │ │ └── zeroWidthSpace.js ├── styles │ ├── canvas.scss │ ├── contentEditable.scss │ ├── flyout.scss │ ├── inputForm.scss │ ├── linkDisplay.scss │ └── toolbar.scss └── templates │ ├── flyout.html │ ├── icons │ ├── link.html │ ├── orderedlist.html │ ├── quote.html │ └── unorderedlist.html │ ├── inputForm.html │ ├── linkDisplay.html │ └── toolbar.html ├── test ├── integration │ ├── fixtures │ │ └── sampleContent.json │ ├── plugins │ │ └── index.js │ ├── specs │ │ ├── core │ │ │ └── core_spec.js │ │ ├── list │ │ │ └── list_spec.js │ │ └── paragraph │ │ │ └── paragraph_spec.js │ └── support │ │ ├── commands │ │ ├── components.js │ │ ├── content.js │ │ ├── index.js │ │ └── text-selection.js │ │ └── index.js ├── server │ └── index.html └── unit │ ├── core │ ├── Container.spec.js │ ├── Context.spec.js │ ├── Mediator.spec.js │ └── Module.spec.js │ ├── e2e │ ├── line.spec.js │ └── paragraph.spec.js │ ├── fixtures │ └── index.html │ ├── helpers │ ├── e2eSampleContent.js │ ├── e2eSetup.js │ ├── fixtures.js │ ├── formatterSetup.js │ ├── mockEvents.js │ ├── selection.js │ └── userInput.js │ ├── modules │ ├── BaseFormatter.spec.js │ ├── BlockFormatter.spec.js │ ├── Canvas.spec.js │ ├── Commands.spec.js │ ├── Config.spec.js │ ├── ContentEditable.spec.js │ ├── Flyout.spec.js │ ├── LinkFormatter.spec.js │ ├── ListFormatter.spec.js │ ├── Selection.spec.js │ ├── TextFormatter.spec.js │ └── Toolbar.spec.js │ ├── run.sh │ ├── setup.js │ ├── support │ └── jasmine.json │ ├── tmp │ └── insertHTML.spec.js │ └── utils │ ├── func.spec.js │ └── guid.spec.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [["@babel/preset-env", { "modules": false }]], 3 | "plugins": ["add-module-exports"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.js] 2 | indent_style = space 3 | indent_size = 4 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jasmine": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "sourceType": "module" 10 | }, 11 | "globals" : { 12 | 13 | }, 14 | "rules": { 15 | "indent": [ 16 | "error", 17 | 4 18 | ], 19 | "linebreak-style": [ 20 | "error", 21 | "unix" 22 | ], 23 | "quotes": [ 24 | "error", 25 | "single" 26 | ], 27 | "semi": [ 28 | "error", 29 | "always" 30 | ] 31 | }, 32 | "ecmaFeatures": { 33 | "impliedStrict": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | coverage/ 3 | node_modules/ 4 | _notes_/ 5 | docs/site/ 6 | docs/src/ 7 | 8 | test/integration/screenshots 9 | test/integration/videos 10 | 11 | yarn-error.log 12 | npm-debug.log 13 | -------------------------------------------------------------------------------- /.jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include" : ["src/", "docs/"], 4 | "exclude" : ["docs/site/"] 5 | }, 6 | "opts": { 7 | "recurse": true, 8 | "destination": "./docs/site/", 9 | "readme": "./docs/README.md" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typester ([typecode.github.io/typester](https://typecode.github.io/typester)) 2 | A simple to use WYSIWYG text editor inspired by Medium and Medium Editor that gives you clean and predictable HTML from your user's input. 3 | 4 | - **Single file import (batteries included):** 5 | No need to import separate stylesheets and additional dependencies. Typester comes with everything it needs rolled into one JS file. 6 | - **Engineered for modern JS modules** 7 | Typester has been created using ES6+ JavaScript module patterns which means you need only import it and instantiate it. If you still prefer <script> imports that's fine too Typester will bind itself to the global scope allowing you to `new window.Typester({ /* options */ })`. 8 | - **Minimal DOM footprint** 9 | It wont clutter your beautifully laid out markup with multiple DOM injections for each editor instance. Need multiple editors on a single page? No problem, typester will inject single instances of its DOM requirements which will be shared between the editors. 10 | - **Pure XSS-free DOM powered by [DOMPurify](https://github.com/cure53/DOMPurify)** 11 | Typester, thanks to the awesome power of DOMPurify, will make sure that the HTML you receive is sanitized against XSS exploits. 12 | 13 | --- 14 | #### Try out the [DEMO](https://typecode.github.io/typester/#demo) 15 | --- 16 | 17 | ### Installation 18 | Right now Typester is only available via npm. We may look into offering CDN hosted options and/or downloadable and self-hostable options. But, for now, you can: 19 | ``` 20 | npm install typester-editor 21 | ``` 22 | or for the yarn folks: 23 | ``` 24 | yarn add typester-editor --save 25 | ``` 26 | 27 | ### Usage 28 | Setting up Typester on your page is as easy as: 29 | ``` 30 | import Typester from 'typester-editor' 31 | 32 | const typesterInstance = new Typester({ el: document.querySelector('[contenteditable]') }) // Where document.querySelector(...) is a single DOM element. 33 | 34 | // If you need to tear down for any reason: 35 | typesterInstance.destroy(); 36 | ``` 37 | 38 | ### Configuration 39 | You can configure the formatters available for a specific typester instance in two ways: 40 | 41 | 1. When you instatiate a Typester instance via the custom configs option: 42 | 43 | ``` 44 | new Typester({ 45 | el: document.querySelector('[contenteditable]'), 46 | configs: { 47 | toolbar: { 48 | buttons: ['bold', 'italic', 'h1', 'h2', 'orderedlist', 'unorderedlist', 'quote', 'link'] 49 | }, 50 | 51 | styles: { 52 | colors: { 53 | flyoutBg: 'rgb(32, 31, 32)', 54 | menuItemIcon: 'rgb(255, 255, 255)', 55 | menuItemHover: 'rgb(0, 174, 239)', 56 | menuItemActive: 'rgb(0, 156, 215)' 57 | } 58 | } 59 | } 60 | }); 61 | ``` 62 | 63 | 2. By using a data attribute on the editable container 64 | ``` 65 |
66 | ``` 67 | 68 | The options available for the toolbar buttons are: 69 | - Inline formatters: `bold`, `italic` 70 | - Headers: `h1`, `h2`, `h3`, `h4`, `h5`, `h6` 71 | - Lists: `orderedlist`, `unorderedlist` 72 | - Blockquotes: `quote` 73 | - Links: `link` 74 | 75 | ### License 76 | Typester is released under the MIT license. 77 | 78 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 79 | 80 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 81 | 82 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 83 | 84 | 85 | 86 | --- 87 | 88 | 89 | 90 | # Contributing 91 | If you want to contribute to this project we welcome your input. 92 | 93 | You can either: 94 | - Submit an issue / feature request which would help us greatly in figuring out what all this package needs to do 95 | - Resolve an already listed issue 96 | - Submit a tweak or new feature 97 | 98 | ### Submit an issue / feature request 99 | Please feel free to head over the our [Issues page](https://github.com/typecode/typester/issues) 100 | and submit your issue / feature request. 101 | 102 | ### Resolve an issue / submit a tweak or new feature 103 | 1. Fork this repo 104 | 1. See below for instructions on how to setup local dev 105 | 2. Create your feature branch (`git checkout -b new-awesome-feature`) 106 | 3. Make sure all tests pass 107 | 1. If you have added a new feature, make sure there is a test for it 108 | 2. Run the following: 109 | `~> yarn test_unit` (for unit tests) & `~>yarn test_e2e` (end-to-end tests) 110 | 3. If you have changed the codebase in a way that requires the tests to be updated 111 | please do so. 112 | 4. Update the documentation if you've added any publicly accessible methods or options. 113 | 5. Commit your changes as you would usually (`git add -i`, add changes, `git commit -m "Succinct description of change"`) 114 | 6. Push to your feature branch (`git push origin new-awesome-feature`) 115 | 7. Create a new pull request. 116 | 117 | ### Setup local dev environment 118 | Install all the node modules 119 | ``` 120 | ~> yarn 121 | ``` 122 | 123 | ### Run build scripts 124 | For a one time build 125 | ``` 126 | ~> yarn build 127 | ``` 128 | For a continuous reactive build that watches for changes 129 | ``` 130 | ~> yarn watch 131 | ``` 132 | 133 | ### Run the dev server 134 | ``` 135 | ~> yarn dev-server 136 | ``` 137 | You should then be able to navigate your browser to: 138 | ``` 139 | http://localhost:9000 140 | ``` 141 | 142 | ### Run the tests 143 | **Make sure you build first** 144 | 145 | Unit tests (Karma & Jasmine) 146 | ``` 147 | ~> yarn test_unit 148 | ``` 149 | 150 | e2e tests (nightwatch) 151 | ``` 152 | ~> yarn test_e2e 153 | ``` 154 | 155 | all tests (unit & e2e) 156 | ``` 157 | ~> yarn test 158 | ``` 159 | 160 | ### Build and read the developer docs 161 | For a once off build: 162 | ``` 163 | ~> yarn docs 164 | ``` 165 | 166 | For a continuous file change reactive build 167 | ``` 168 | ~> yarn docs_watch 169 | ``` 170 | 171 | Then, to read the docs: 172 | ``` 173 | ~> yarn docs_server 174 | ``` 175 | 176 | And point you browser to: 177 | ``` 178 | http://localhost:9001 179 | ``` 180 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "fixturesFolder": "test/integration/fixtures", 3 | "integrationFolder": "test/integration/specs", 4 | "pluginsFile": "test/integration/plugins/index.js", 5 | "screenshotsFolder": "test/integration/screenshots", 6 | "supportFile": "test/integration/support/index.js", 7 | "videosFolder": "test/integration/videos" 8 | } 9 | -------------------------------------------------------------------------------- /docs/webpack.config.js: -------------------------------------------------------------------------------- 1 | /*global require __dirname module*/ 2 | 3 | const path = require('path'); 4 | console.log(path.resolve(__dirname, 'src/index.js')); 5 | module.exports = { 6 | entry: path.resolve(__dirname, 'src/index.js'), 7 | output: { 8 | path: path.resolve(__dirname, 'src/'), 9 | filename: 'build.js' 10 | }, 11 | devServer: { 12 | contentBase: path.resolve(__dirname, 'site/'), 13 | host: '0.0.0.0', 14 | port: 9001, 15 | disableHostCheck: true, 16 | index: 'index.html' 17 | } 18 | }; -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | const webpackConfig = require('./webpack.config'); 3 | delete webpackConfig.entry; 4 | delete webpackConfig.output; 5 | 6 | module.exports = function (config) { 7 | config.set({ 8 | basePath: '', 9 | 10 | frameworks: ['jquery-2.0.0', 'jasmine'], 11 | 12 | files: [ 13 | './test/unit/setup.js', 14 | {pattern: './test/unit/fixtures/**/*.html', included: false, served: true}, 15 | 'test/unit/**/*.spec.js' 16 | ], 17 | 18 | preprocessors: { 19 | './test/unit/setup.js': ['webpack'], 20 | './src/scripts/**/*.js': ['webpack'], 21 | 'test/unit/**/*.spec.js': ['webpack'] 22 | }, 23 | 24 | webpack: Object.assign({}, webpackConfig, { 25 | output: { 26 | 27 | } 28 | }), 29 | 30 | webpackMiddleware: { 31 | noInfo: true 32 | }, 33 | 34 | logLevel: config.LOG_DEBUG, 35 | reporters: ['spec'], 36 | coverageReporter: { 37 | type: 'html', 38 | dir: 'coverage/' 39 | }, 40 | browsers: ['PhantomJS'], 41 | autoWatchBatchDelay: 3000 42 | }); 43 | }; 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typester-editor", 3 | "version": "0.0.3", 4 | "description": "A simple to use wysiwyg text editor inspired by Medium and Medium Editor that gives you clean and predictable html from your user's input.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/typecode/typester" 8 | }, 9 | "homepage": "https://typecode.github.io/typester/", 10 | "keywords": [ 11 | "wysiwyg", 12 | "typester", 13 | "rich text editor", 14 | "typester-editor", 15 | "medium", 16 | "medium-editor", 17 | "wysiwyg-editor", 18 | "html editor", 19 | "html text editor", 20 | "contenteditable" 21 | ], 22 | "main": "build/js/typester.js", 23 | "scripts": { 24 | "test": "yarn test_unit && yarn test_e2e", 25 | "test_unit": "./node_modules/karma/bin/karma start karma.conf.js", 26 | "test_e2e": "concurrently \"yarn dev-server\" \"cypress run\"", 27 | "build": "webpack --config webpack.config.js", 28 | "build_prod": "BUILD=production npm run build", 29 | "watch": "./node_modules/.bin/watch \"npm run build\" src/", 30 | "docs": "./node_modules/.bin/jsdoc -c .jsdoc.json", 31 | "docs_watch": "./node_modules/.bin/watch \"yarn docs\" src/ docs/src/", 32 | "docs_server": "cd ./docs && webpack-dev-server --config webpack.config.js", 33 | "publish": "npm publish", 34 | "dev-server": "webpack-dev-server", 35 | "cypress:open": "cypress open" 36 | }, 37 | "author": "Fred Every ", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/typecode/typester/issues" 41 | }, 42 | "devDependencies": { 43 | "@babel/core": "^7.2.2", 44 | "@babel/preset-env": "^7.4.5", 45 | "babel-loader": "^8.0.5", 46 | "babel-plugin-add-module-exports": "^1.0.2", 47 | "concurrently": "^4.1.0", 48 | "css-loader": "^2.1.0", 49 | "cypress": "^3.3.1", 50 | "handlebars": "^4.1.2", 51 | "handlebars-loader": "^1.7.1", 52 | "jsdoc": "^3.6.2", 53 | "karma": "^4.1.0", 54 | "karma-jasmine": "^2.0.1", 55 | "karma-jquery": "^0.2.4", 56 | "karma-phantomjs-launcher": "^1.0.4", 57 | "karma-spec-reporter": "0.0.32", 58 | "karma-webpack": "^3.0.5", 59 | "node-sass": "^4.11.0", 60 | "sass-loader": "^7.1.0", 61 | "style-loader": "^0.23.1", 62 | "watch": "^1.0.2", 63 | "webpack": "^4.29.3", 64 | "webpack-cli": "^3.2.3", 65 | "webpack-dev-server": "^3.1.14" 66 | }, 67 | "dependencies": { 68 | "dompurify": "^1.0.9" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/scripts/config/config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The main config. 3 | * @access protected 4 | * @module config/config 5 | * 6 | * @example 7 | * config.defaultBlock = "P" // the defaultBlock formatting to use when creating a new line etc. 8 | * config.blockElementName = [ ... ] // a list of all the expected block level element names. 9 | */ 10 | 11 | export default { 12 | defaultBlock: 'P', 13 | blockElementNames: [ 14 | 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ol', 'ul', 'li', 'p', 'pre', 15 | 'address', 'article', 'aside', 'canvas', 'dd', 'div', 'dl', 'dt', 16 | 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'header', 'hgroup', 17 | 'hr', 'main', 'nav', 'noscript', 'output', 'section', 'table', 'tfoot', 18 | 'video' 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /src/scripts/config/styles.js: -------------------------------------------------------------------------------- 1 | export default { 2 | colors: { 3 | flyoutBg: 'rgb(32, 31, 32)', 4 | menuItemIcon: 'rgb(255, 255, 255)', 5 | menuItemHover: 'rgb(0, 174, 239)', 6 | menuItemActive: 'rgb(0, 156, 215)' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/scripts/config/toolbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The toolbar config. NB could do with a revision, some of the props in here could 3 | * be moved to the main config. 4 | * @access protected 5 | * @module config/toolbar 6 | */ 7 | import linkIcon from '../../templates/icons/link.html'; 8 | import orderedlistIcon from '../../templates/icons/orderedlist.html'; 9 | import unorderedlistIcon from '../../templates/icons/unorderedlist.html'; 10 | import quoteIcon from '../../templates/icons/quote.html'; 11 | 12 | const Toolbar = { 13 | buttons: ['bold', 'italic', 'h1', 'h2', 'orderedlist', 'unorderedlist', 'quote', 'link'], 14 | preventNewlineDefault: ['ul', 'ol'], 15 | blockTags: ['P'], 16 | validTags: ['P'], 17 | listTags: [], 18 | getValidTags () { 19 | let { validTags } = Toolbar; 20 | 21 | if ( validTags.length === 1 ) { 22 | Toolbar.parseForTagLists(); 23 | } 24 | 25 | return Toolbar.validTags; 26 | }, 27 | getBlockTags () { 28 | let { blockTags } = Toolbar; 29 | 30 | if ( blockTags.length === 1 ) { 31 | Toolbar.parseForTagLists(); 32 | } 33 | 34 | return Toolbar.blockTags; 35 | 36 | }, 37 | getListTags () { 38 | let { listTags } = Toolbar; 39 | 40 | if ( listTags.length === 0 ) { 41 | Toolbar.parseForTagLists(); 42 | } 43 | 44 | return Toolbar.listTags; 45 | }, 46 | parseForTagLists () { 47 | let { 48 | validTags, 49 | blockTags, 50 | listTags 51 | } = Toolbar; 52 | 53 | Toolbar.buttons.forEach((buttonKey) => { 54 | let buttonConfig = Toolbar.buttonConfigs[buttonKey]; 55 | let configValidTags = buttonConfig.opts.validTags; 56 | 57 | validTags = validTags.concat(configValidTags); 58 | 59 | switch (buttonConfig.formatter) { 60 | case 'block': 61 | blockTags = blockTags.concat(configValidTags); 62 | break; 63 | case 'list': 64 | listTags = listTags.concat(configValidTags); 65 | break; 66 | } 67 | }); 68 | 69 | Toolbar.validTags = validTags; 70 | Toolbar.blockTags = blockTags; 71 | Toolbar.listTags = listTags; 72 | }, 73 | buttonConfigs: { 74 | // Text styles 75 | bold: { 76 | formatter: 'text', 77 | opts: { 78 | style: 'bold', 79 | rootEl: 'b', 80 | validTags: ['B', 'STRONG'] 81 | }, 82 | content: 'B', 83 | disabledIn: ['H1', 'H2', 'BLOCKQUOTE'], 84 | activeIn: ['B'], 85 | toggles: true 86 | }, 87 | 88 | italic: { 89 | formatter: 'text', 90 | opts: { 91 | style: 'italic', 92 | rootEl: 'i', 93 | validTags: ['I'] 94 | }, 95 | content: 'I', 96 | activeIn: ['I'], 97 | toggles: true 98 | }, 99 | 100 | underline: { 101 | formatter: 'text:underline', 102 | content: 'U' 103 | }, 104 | 105 | strikethrough: { 106 | formatter: 'text:strikethrough', 107 | content: 'Abc' 108 | }, 109 | 110 | superscript: { 111 | formatter: 'text:superscript', 112 | content: '1' 113 | }, 114 | 115 | subscript: { 116 | formatter: 'text:subscript', 117 | content: '1' 118 | }, 119 | 120 | // Paragraph styles 121 | justifyCenter: { 122 | formatter: 'paragraph:justifyCenter' 123 | }, 124 | 125 | justifyFull: { 126 | formatter: 'paragraph:justifyFull' 127 | }, 128 | 129 | justifyLeft: { 130 | formatter: 'paragraph:justifyLeft' 131 | }, 132 | 133 | justifyRight: { 134 | formatter: 'paragraph:justifyRight' 135 | }, 136 | 137 | indent: { 138 | formatter: 'paragraph:indent' 139 | }, 140 | 141 | outdent: { 142 | formatter: 'paragraph:outdent' 143 | }, 144 | 145 | // Lists 146 | orderedlist: { 147 | formatter: 'list', 148 | content: orderedlistIcon({}, {}, true), 149 | opts: { 150 | style: 'ordered', 151 | validTags: ['OL', 'LI'] 152 | }, 153 | activeIn: ['OL'] 154 | }, 155 | 156 | unorderedlist: { 157 | formatter: 'list', 158 | content: unorderedlistIcon({}, {}, true), 159 | opts: { 160 | style: 'unordered', 161 | validTags: ['UL', 'LI'] 162 | }, 163 | activeIn: ['UL'] 164 | }, 165 | 166 | // Block level elements 167 | quote: { 168 | formatter: 'block', 169 | content: quoteIcon({}, {}, true), 170 | opts: { 171 | style: 'BLOCKQUOTE', 172 | validTags: ['BLOCKQUOTE'] 173 | }, 174 | activeIn: ['BLOCKQUOTE'], 175 | disabledIn (mediator) { 176 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 177 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 178 | return disabled; 179 | }, 180 | toggles: true 181 | }, 182 | 183 | pre: { 184 | formatter: 'block', 185 | opts: { 186 | style: 'PRE' 187 | }, 188 | disabledIn (mediator) { 189 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 190 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 191 | return disabled; 192 | }, 193 | content: 'PRE' 194 | }, 195 | 196 | h1: { 197 | formatter: 'block', 198 | opts: { 199 | style: 'H1', 200 | validTags: ['H1'] 201 | }, 202 | content: 'H1', 203 | activeIn: ['H1'], 204 | disabledIn (mediator) { 205 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 206 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 207 | return disabled; 208 | }, 209 | toggles: true 210 | }, 211 | 212 | h2: { 213 | formatter: 'block', 214 | opts: { 215 | style: 'H2', 216 | validTags: ['H2'] 217 | }, 218 | content: 'H2', 219 | activeIn: ['H2'], 220 | disabledIn (mediator) { 221 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 222 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 223 | return disabled; 224 | }, 225 | toggles: true 226 | }, 227 | 228 | h3: { 229 | formatter: 'block', 230 | opts: { 231 | style: 'H3', 232 | validTags: ['H3'] 233 | }, 234 | content: 'H3', 235 | activeIn: ['H3'], 236 | disabledIn (mediator) { 237 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 238 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 239 | return disabled; 240 | }, 241 | toggles: true 242 | }, 243 | 244 | h4: { 245 | formatter: 'block', 246 | opts: { 247 | style: 'H4', 248 | validTags: ['H4'] 249 | }, 250 | content: 'H4', 251 | activeIn: ['H4'], 252 | disabledIn (mediator) { 253 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 254 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 255 | return disabled; 256 | }, 257 | toggles: true 258 | }, 259 | 260 | h5: { 261 | formatter: 'block', 262 | opts: { 263 | style: 'H5', 264 | validTags: ['H5'] 265 | }, 266 | content: 'H5', 267 | activeIn: ['H5'], 268 | disabledIn (mediator) { 269 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 270 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 271 | return disabled; 272 | }, 273 | toggles: true 274 | }, 275 | 276 | h6: { 277 | formatter: 'block', 278 | opts: { 279 | style: 'H6', 280 | validTags: ['H6'] 281 | }, 282 | content: 'H6', 283 | activeIn: ['H6'], 284 | disabledIn (mediator) { 285 | let disabled = mediator.get('selection:in:or:contains', ['OL', 'UL']); 286 | disabled = disabled || mediator.get('selection:spans:multiple:blocks'); 287 | return disabled; 288 | }, 289 | toggles: true 290 | }, 291 | 292 | // Link 293 | link: { 294 | formatter: 'link', 295 | opts: { 296 | validTags: ['A'] 297 | }, 298 | content: linkIcon({}, {}, true), 299 | activeIn: ['A'], 300 | disabledIn (mediator) { 301 | let disabled = mediator.get('selection:spans:multiple:blocks'); 302 | return disabled; 303 | } 304 | } 305 | } 306 | }; 307 | 308 | export default Toolbar; 309 | -------------------------------------------------------------------------------- /src/scripts/containers/AppContainer.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * AppContainer - The top most container for the Typester app stack. This 5 | * container sets up the {@link FormatterContainer}, {@link UIContainer}, 6 | * and {@link CanvasContainer} containers which are treated as singletons. 7 | * 8 | * @access protected 9 | * @module containers/AppContainer 10 | * 11 | * @requires core/Container 12 | * @requires containers/UIContainer 13 | * @requires containers/FormatterContainer 14 | * @requires containers/CanvasContainer 15 | * @requires modules/ContentEditable 16 | * @requires modules/Selection 17 | * 18 | * @example 19 | * new AppContainer({ 20 | * dom: { 21 | * el: domElement 22 | * } 23 | * }); 24 | */ 25 | 26 | 27 | import Container from '../core/Container'; 28 | import UIContainer from '../containers/UIContainer'; 29 | import FormatterContainer from '../containers/FormatterContainer'; 30 | import CanvasContainer from '../containers/CanvasContainer'; 31 | import ContentEditable from '../modules/ContentEditable'; 32 | import Selection from '../modules/Selection'; 33 | import Config from '../modules/Config'; 34 | 35 | let uiContainer, formatterContainer, canvasContainer; 36 | 37 | /** 38 | * @constructor AppContainer 39 | * @param {object} opts={} - instance options 40 | * @param {object} opts.dom - The dom components used by Typester 41 | * @param {element} opts.dom.el - The dom element to be the canvas for Typester 42 | * @return {container} AppContainer instance 43 | */ 44 | const AppContainer = Container({ 45 | name: 'AppContainer', 46 | 47 | /** 48 | * Child modules: [{@link modules/ContentEditable}, {@link modules/Selection}] 49 | * @enum {Array<{class:Module}>} modules 50 | */ 51 | modules: [ 52 | { 53 | class: ContentEditable 54 | }, 55 | { 56 | class: Selection 57 | }, 58 | { 59 | class: Config 60 | } 61 | ], 62 | 63 | 64 | /** 65 | * @prop {Object} handlers 66 | * @prop {Object} handlers.events - AppContainer listens to events from {@link ContentEditable} 67 | */ 68 | handlers: { 69 | events: { 70 | 'contenteditable:focus': 'handleFocus', 71 | 'contenteditable:blur': 'handleBlur' 72 | } 73 | }, 74 | methods: { 75 | /** 76 | * @func setup 77 | * @desc Initializes the {@link FormatterContainer} and provides a mediator 78 | * to attach to. 79 | * @protected 80 | */ 81 | setup: function () { 82 | }, 83 | 84 | /** 85 | * Nothing to see here. 86 | * @func init 87 | * @ignore 88 | */ 89 | init () { 90 | // Current nothing to init for this container. Method left here for ref. 91 | const { mediator } = this; 92 | formatterContainer = formatterContainer || new FormatterContainer({ mediator }); 93 | uiContainer = uiContainer || new UIContainer({ mediator }); 94 | canvasContainer = canvasContainer || new CanvasContainer({ mediator }); 95 | }, 96 | 97 | /** 98 | * Because the {@link FormatterContainer}, {@link UIContainer}, 99 | * and {@link CanvasContainer} containers are intended to be singletons 100 | * they need to communicate through the current active mediator instance. 101 | * 102 | * @method handleFocus 103 | * @listens contenteditable:focus 104 | */ 105 | handleFocus () { 106 | const { mediator } = this; 107 | uiContainer.setMediatorParent(mediator); 108 | formatterContainer.setMediatorParent(mediator); 109 | canvasContainer.setMediatorParent(mediator); 110 | }, 111 | 112 | /** 113 | * Nothing to see here. 114 | * @func handleBlur 115 | * @ignore 116 | */ 117 | handleBlur () { 118 | // Should the container require to do anything in particular here 119 | }, 120 | 121 | /** 122 | * Destroy the entire Typeset instance 123 | * @func destroy 124 | */ 125 | destroy () { 126 | const { mediator } = this; 127 | mediator.emit('app:destroy'); 128 | } 129 | } 130 | }); 131 | 132 | export default AppContainer; 133 | -------------------------------------------------------------------------------- /src/scripts/containers/CanvasContainer.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | 4 | /** 5 | * CanvasContainer - This container bootstraps the Selection and Canvas modules. 6 | * It requires only a mediator instance to delegate events to. 7 | * 8 | * @access protected 9 | * @module containers/CanvasContainer 10 | * 11 | * @requires core/Container 12 | * @requires modules/Selection 13 | * @requires modules/Canvas 14 | * 15 | * @example 16 | * new CanvasContainer({ 17 | * mediator: mediatorInstance 18 | * }); 19 | */ 20 | 21 | import Container from '../core/Container'; 22 | import Selection from '../modules/Selection'; 23 | import Canvas from '../modules/Canvas'; 24 | 25 | /** 26 | * @constructor CanvasContainer 27 | * @param {object} opts={} - instance options 28 | * @param {object} opts.mediator - The mediator to delegate events up to 29 | * @return {container} CanvasContainer instance 30 | */ 31 | const CanvasContainer = Container({ 32 | name: 'CanvasContainer', 33 | 34 | /** 35 | * Child Modules: [{@link modules/Selection}, {@link modules/Canvas}] 36 | * @enum {Array<{class:Module}>} modules 37 | */ 38 | modules: [ 39 | { class: Selection }, 40 | { class: Canvas } 41 | ], 42 | 43 | 44 | /** 45 | * @prop {object} mediatorOpts - Container specific mediator options. For the 46 | * CanvasContainer the mediator is set to conceal, and not propagate, any messages 47 | * from the selection module. This is to avoid cross contamination with the selection 48 | * module used on the page. 49 | */ 50 | mediatorOpts: { 51 | conceal: [ 52 | /selection:.*?/ 53 | ] 54 | }, 55 | 56 | /** 57 | * @prop {object} handlers 58 | * @prop {object} handlers.events - canvas:created -> handleCanvasCreated 59 | */ 60 | handlers: { 61 | events: { 62 | 'canvas:created' : 'handleCanvasCreated' 63 | } 64 | }, 65 | methods: { 66 | init () { 67 | }, 68 | 69 | /** 70 | * @func handleCanvasCreated 71 | * @desc Listens for the canvas:create event to do some bootstrapping between 72 | * the canvas and selection module instances 73 | * @listens canvas:created 74 | */ 75 | handleCanvasCreated () { 76 | const { mediator } = this; 77 | const canvasWin = mediator.get('canvas:window'); 78 | const canvasDoc = mediator.get('canvas:document'); 79 | const canvasBody = mediator.get('canvas:body'); 80 | 81 | mediator.exec('selection:set:contextWindow', canvasWin); 82 | mediator.exec('selection:set:contextDocument', canvasDoc); 83 | mediator.exec('selection:set:el', canvasBody); 84 | } 85 | } 86 | }); 87 | 88 | export default CanvasContainer; 89 | -------------------------------------------------------------------------------- /src/scripts/containers/FormatterContainer.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * FormatterContainer - Initializes and bootstraps all the formatter modules. 5 | * It requires only a mediator instance to delegate events to. 6 | * 7 | * @access protected 8 | * @module containers/FormatterContainer 9 | * 10 | * @requires core/Container 11 | * @requires modules/BaseFormatter 12 | * @requires modules/BlockFormatter 13 | * @requires modules/TextFormatter 14 | * @requires modules/ListFormatter 15 | * @requires modules/LinkFormatter 16 | * @requires modules/Paste 17 | * 18 | * @example 19 | * new FormatterContainer({ mediator: mediatorInstance }); 20 | */ 21 | import Container from '../core/Container'; 22 | import BaseFormatter from '../modules/BaseFormatter'; 23 | import BlockFormatter from '../modules/BlockFormatter'; 24 | import TextFormatter from '../modules/TextFormatter'; 25 | import ListFormatter from '../modules/ListFormatter'; 26 | import LinkFormatter from '../modules/LinkFormatter'; 27 | import Commands from '../modules/Commands'; 28 | import Paste from '../modules/Paste'; 29 | import Undo from '../modules/Undo'; 30 | 31 | /** 32 | * @constructor FormatterContainer 33 | * @param {object} opts={} - container options 34 | * @param {mediator} opts.mediator - The mediator to delegate events up to 35 | * @return {container} CanvasContainer instance 36 | */ 37 | const FormatterContainer = Container({ 38 | name: 'FormatterContainer', 39 | 40 | /** 41 | * Child Modules: [{@link modules/BaseFormatter}, {@link modules/BlockFormatter}, 42 | * {@link modules/TextFormatter}, {@link modules/TextFormatter}, {@link modules/LinkFormatter}, 43 | * {@link modules/Paste}] 44 | * @enum {Array<{class:Module}>} modules 45 | */ 46 | modules: [ 47 | { 48 | class: Commands 49 | }, 50 | { 51 | class: BaseFormatter 52 | }, 53 | { 54 | class: BlockFormatter 55 | }, 56 | { 57 | class: TextFormatter 58 | }, 59 | { 60 | class: ListFormatter 61 | }, 62 | { 63 | class: LinkFormatter 64 | }, 65 | { 66 | class: Paste 67 | }, 68 | { 69 | class: Undo 70 | } 71 | ] 72 | }); 73 | 74 | export default FormatterContainer; 75 | -------------------------------------------------------------------------------- /src/scripts/containers/UIContainer.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * UIContainer - Initializes and bootstraps the UI modules. It requires only a 5 | * mediator instance to delegate events to. 6 | * 7 | * @access protected 8 | * @module containers/UIContainer 9 | * 10 | * @requires core/Container 11 | * @requires modules/Toolbar 12 | * @requires modules/Flyout 13 | * @requires modulesMouse 14 | * 15 | * @example 16 | * new UIContainer({ mediator: mediatorInstance }); 17 | */ 18 | import Container from '../core/Container'; 19 | import Toolbar from '../modules/Toolbar'; 20 | import Flyout from '../modules/Flyout'; 21 | import Mouse from '../modules/Mouse'; 22 | import Styles from '../modules/Styles'; 23 | 24 | /** 25 | * @constructor UIContainer 26 | * @param {object} opts={} - container options 27 | * @param {mediator} opts.mediator - The mediator to delegate events to 28 | * @return {container} UIContainer instance 29 | */ 30 | const UIContainer = Container({ 31 | name: 'UIContainer', 32 | 33 | /** 34 | * Child Modules: [{@link modules/Flyout}, {@link modules/Toolbar}] 35 | * Note: The Toobar is instantiated with the document body set as it's dom.el. 36 | * @enum {Array<{class:Module}>} modules 37 | */ 38 | modules: [ 39 | { 40 | class: Flyout 41 | }, 42 | { 43 | class: Toolbar, 44 | opts: { 45 | dom: { 46 | el: document.body 47 | } 48 | } 49 | }, 50 | { 51 | class: Mouse 52 | }, 53 | { 54 | class: Styles 55 | } 56 | ] 57 | }); 58 | 59 | export default UIContainer; 60 | -------------------------------------------------------------------------------- /src/scripts/core/Container.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * Container - 5 | * A factory for building container classes. 6 | * Containers are built up of a mediator instance that is shared with its child 7 | * modules. 8 | * 9 | * @module core/Container 10 | * @access protected 11 | * 12 | * @param {object} containerObj - **(Required)** A descriptor of the container 13 | * @param {string} containerObj.name - **(Required)** The name of the container 14 | * @param {object} containerObj.handlers - A map of mediator event strings and method names: 'event:string' : 'methodName'. 15 | * Event strings are then mapped to the named method. 16 | * @param {object} containerObj.methods - A map of named methods. 17 | * @param {array} containerObj.modules - An array of modules to be instantiated by the container. [{ class: ModuleClass }] 18 | * @param {object} containerObj.mediatorOpts - A map of options to be passed on to the {@link Mediator}. 19 | * @param {mediator} containerObj.mediator - A mediator instance to act as a parent for the mediator instance created in this container. 20 | * 21 | * @return {function} - A container class that can be instantiated. 22 | * 23 | * @example 24 | * import MyModule from '../modules/MyModule.js'; 25 | * 26 | * const MyContainer = Container({ 27 | * name: 'MyContainer', 28 | * modules: [ 29 | * { class: MyModule } 30 | * ], 31 | * handlers: { 32 | * requests: { // Request handlers mapped to methods & registered with the mediator 33 | * 'mycontainer:request:something' : 'getSomething' 34 | * }, 35 | * commands: { // Command handlers mapped to methods & registered with the mediator 36 | * 'mycontainer:do:something' : 'doSomething' 37 | * }, 38 | * events: { // Event handlers mapped to methods & registered with the mediator 39 | * 'module:event' : 'handlerMethod' 40 | * } 41 | * }, 42 | * methods: { 43 | * setup () {}, // A setup hook called before modules and child containers are initialized. 44 | * init () {} // An init hook called after all modules and child containers have been initialized. 45 | * 46 | * // The rest of the methods required for this container. 47 | * getSomething () {}, 48 | * doSomething () {}, 49 | * handlerMethod () {} 50 | * } 51 | * }); 52 | * 53 | * const myContainer = new MyContainer(containerOpts); 54 | * 55 | * // myContainer has a method you can use to update the parent mediator using: 56 | * myContainer.setMediatorParent(mediatorInstance); 57 | */ 58 | 59 | import Mediator from './Mediator'; 60 | import Context from './Context'; 61 | import func from '../utils/func'; 62 | 63 | const Container = function Container(containerObj) { 64 | 65 | const { 66 | name: containerName, 67 | handlers: containerHandlers, 68 | methods: containerMethods, 69 | modules: containerModules, 70 | containers: containerChildContainers, 71 | mediatorOpts 72 | } = containerObj; 73 | 74 | if (!containerName) { 75 | throw new Error('No name given for container'); 76 | } 77 | 78 | const containerUtils = { 79 | createContext (...contexts) { 80 | return new Context(...contexts); 81 | }, 82 | 83 | bindMethods (methods, context) { 84 | methods = methods || {}; 85 | return func.bindObj(methods, context); 86 | }, 87 | 88 | initModules (modules=[], opts={}) { 89 | modules.forEach((module) => { 90 | const moduleOpts = Object.assign({}, opts, (module.opts || {})); 91 | module.instance = new module.class(moduleOpts); 92 | }); 93 | }, 94 | 95 | initChildContainers (childContainers=[], opts={}) { 96 | childContainers.forEach((containerObj) => { 97 | const containerOpts = Object.assign({}, opts, (containerObj.opts || {})); 98 | containerObj.instance = new containerObj.class(containerOpts); 99 | }); 100 | }, 101 | 102 | registerHandlers (mediator, handlers, context) { 103 | Object.keys(handlers).forEach((handlerKey) => { 104 | const handlerMap = handlers[handlerKey]; 105 | let handlerMethods = containerUtils.getHandlerMethods(handlerMap, context); 106 | 107 | switch (handlerKey) { 108 | case 'requests': 109 | mediator.registerRequestHandlers(handlerMethods); 110 | break; 111 | case 'commands': 112 | mediator.registerCommandHandlers(handlerMethods); 113 | break; 114 | case 'events': 115 | mediator.registerEventHandlers(handlerMethods); 116 | break; 117 | } 118 | }); 119 | }, 120 | 121 | getHandlerMethods (handlerMap, context) { 122 | let routedHandlers = {}; 123 | 124 | Object.keys(handlerMap).forEach((commandStr) => { 125 | const methodKey = handlerMap[commandStr]; 126 | const handlerMethod = context[methodKey]; 127 | routedHandlers[commandStr] = handlerMethod; 128 | }); 129 | 130 | return routedHandlers; 131 | } 132 | }; 133 | 134 | const containerProto = { 135 | containerConstructor: function (opts={}) { 136 | const context = containerUtils.createContext(); 137 | const boundMethods = containerUtils.bindMethods(containerMethods, context); 138 | context.extendWith(boundMethods); 139 | const mediator = new Mediator(Object.assign({ parent: opts.mediator }, mediatorOpts)); 140 | context.extendWith({ mediator }); 141 | 142 | if (containerHandlers) { 143 | containerUtils.registerHandlers(mediator, containerHandlers, context); 144 | } 145 | 146 | if (boundMethods.setup) { 147 | boundMethods.setup(); 148 | } 149 | 150 | containerUtils.initModules(containerModules, { 151 | dom: opts.dom, 152 | configs: opts.configs, 153 | mediator 154 | }); 155 | 156 | containerUtils.initChildContainers(containerChildContainers, { 157 | dom: opts.dom, 158 | configs: opts.configs, 159 | mediator 160 | }); 161 | 162 | if (boundMethods.init) { 163 | boundMethods.init(); 164 | } 165 | 166 | return { 167 | setMediatorParent (parentMediator) { 168 | mediator.setParent(parentMediator); 169 | }, 170 | 171 | destroy: boundMethods.destroy 172 | }; 173 | } 174 | }; 175 | 176 | return containerProto.containerConstructor; 177 | }; 178 | 179 | export default Container; 180 | -------------------------------------------------------------------------------- /src/scripts/core/Context.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | 4 | /** 5 | * Context - 6 | * Instance safe context builder that can mixin multiple additional objects as 7 | * contexts. These can then be used to bind methods into a shared context. 8 | * 9 | * @module core/Context 10 | * @access protected 11 | * 12 | * @example 13 | * let context = new Context({ ping: 'pong' }); 14 | * // context.ping = 'pong' 15 | * 16 | * context.mixin({ foo: 'bar' }, { jim: 'jam' }) 17 | * // context.foo = 'bar' 18 | * // context.jim = 'jam' 19 | * 20 | * context.extendWith({ bing: 'bong', bang: 'boom' }, { keys: ['bing'] }) 21 | * // context.bing = 'bong' 22 | * // context.bang = undefined 23 | */ 24 | 25 | /** @constructor Context */ 26 | const Context = function (...contexts) { 27 | this.mixin(...contexts); 28 | }; 29 | 30 | Object.assign(Context.prototype, { 31 | /** 32 | * mixin - accepts additional contexts to mixin into itself 33 | * @param {Array} ...contexts description 34 | */ 35 | mixin (...contexts) { 36 | contexts.forEach((context) => { 37 | Object.assign(this, context); 38 | }); 39 | }, 40 | 41 | /** 42 | * extendWith - extend the current context with a single object. Allows for 43 | * the specification of specific keys to be cherry picked of the passed in 44 | * object. 45 | * 46 | * @param {object} mixinContext the additional context to mix into the current context. 47 | * @param {object} opts={} options to allow for fine grained mixing in. 48 | * @param {Array} opts.keys a list of keys to cherry pick from the additional context. 49 | */ 50 | extendWith (mixinContext, opts={}) { 51 | if (opts.keys) { 52 | opts.keys.forEach((key) => { 53 | this[key] = mixinContext[key]; 54 | }); 55 | } else { 56 | this.mixin(mixinContext); 57 | } 58 | } 59 | }); 60 | 61 | export default Context; 62 | -------------------------------------------------------------------------------- /src/scripts/core/Module.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * Module - 5 | * A factory for building module classes. 6 | * Modules are built up from a module dictionary/object. 7 | * 8 | * @module core/Module 9 | * @access protected 10 | * 11 | * @example 12 | * import Module from '../core/Module' 13 | * const MyModule = Module({ 14 | * name: 'MyModule', // Required 15 | * 16 | * // An object that is mixed into the module context that is bound to the methods 17 | * props: { 18 | * foo: "bar" // -> this.foo inside methods 19 | * }, 20 | * 21 | * requiredProps: ['foo'], // An array listing the keys of props that are required 22 | * 23 | * // An object map used to declare handler strings ~> method names: @see core/Mediator 24 | * handlers: { 25 | * requests: {}, 26 | * commands: {}, 27 | * events: {}, 28 | * domEvents: {} 29 | * }, 30 | * 31 | * // An object map used to get dom elements and cache them to keys on the contect 32 | * dom: { 33 | * 'myModuleElem': '.my-module-elem' // Accessable via this.dome.myModule 34 | * }, 35 | * 36 | * // An object of methods to be bound to the module's context 37 | * methods: { 38 | * setup () {}, // The module setup hook. Called before the module initialized 39 | * init () {} // The init hook. Called once the modules has been initialized, 40 | * ... // The rest is up to the developer 41 | * } 42 | * }) 43 | */ 44 | 45 | import Context from './Context'; 46 | import func from '../utils/func'; 47 | import DOM from '../utils/DOM'; 48 | 49 | const Module = function (moduleObj) { 50 | const { 51 | name: moduleName, 52 | props: moduleProps, 53 | handlers: moduleHandlers, 54 | dom: moduleDom, 55 | methods: moduleMethods, 56 | requiredProps: moduleRequiredProps, 57 | acceptsConfigs: moduleAcceptsConfigs 58 | } = moduleObj; 59 | 60 | if (!moduleName) { 61 | throw new Error('No name given for module', moduleObj); 62 | } 63 | 64 | const moduleUtils = { 65 | createContext (...contexts) { 66 | return new Context(...contexts); 67 | }, 68 | 69 | bindMethods (methods, context) { 70 | methods = methods || {}; 71 | return func.bindObj(methods, context); 72 | }, 73 | 74 | wrapRenderMethod (renderMethod, opts) { 75 | const wrappedRenderMethod = function (...args) { 76 | let { context } = opts; 77 | let mergedDom; 78 | 79 | mergedDom = moduleUtils.mergeDom(moduleDom, opts.dom); 80 | mergedDom.el = renderMethod(...args); 81 | 82 | moduleUtils.getDom(mergedDom); 83 | context.extendWith({ dom: mergedDom }); 84 | 85 | if (moduleHandlers.domEvents) { 86 | moduleUtils.registerDomHandlers(moduleHandlers.domEvents, context); 87 | } 88 | }; 89 | return wrappedRenderMethod; 90 | }, 91 | 92 | registerHandlers (mediator, handlers, context) { 93 | Object.keys(handlers).forEach((handlerKey) => { 94 | const handlerMap = handlers[handlerKey]; 95 | let handlerMethods = moduleUtils.getHandlerMethods(handlerMap, context); 96 | switch (handlerKey) { 97 | case 'requests': 98 | mediator.registerRequestHandlers(handlerMethods); 99 | break; 100 | case 'commands': 101 | mediator.registerCommandHandlers(handlerMethods); 102 | break; 103 | case 'events': 104 | mediator.registerEventHandlers(handlerMethods); 105 | break; 106 | } 107 | }); 108 | }, 109 | 110 | registerDomHandlers (domHandlersMap, context) { 111 | let handlerMethods = moduleUtils.getHandlerMethods(domHandlersMap, context); 112 | moduleUtils.bindDomEvents(handlerMethods, context); 113 | }, 114 | 115 | deregisterDomHandlers (domHandlersMap, context) { 116 | let handlerMethods = moduleUtils.getHandlerMethods(domHandlersMap, context); 117 | moduleUtils.unbindDomEvents(handlerMethods, context); 118 | }, 119 | 120 | getHandlerMethods (handlerMap, context) { 121 | let routedHandlers = {}; 122 | 123 | Object.keys(handlerMap).forEach((commandStr) => { 124 | const methodKey = handlerMap[commandStr]; 125 | const handlerMethod = context[methodKey]; 126 | routedHandlers[commandStr] = handlerMethod; 127 | }); 128 | 129 | return routedHandlers; 130 | }, 131 | 132 | mergeDom (defaultDom, dom={}) { 133 | let mergedDom = {}; 134 | 135 | Object.keys(defaultDom).forEach((domKey) => { 136 | mergedDom[domKey] = defaultDom[domKey]; 137 | }); 138 | 139 | Object.keys(dom).forEach((domKey) => { 140 | mergedDom[domKey] = dom[domKey]; 141 | mergedDom[domKey].selector = dom[domKey]; 142 | }); 143 | 144 | return mergedDom; 145 | }, 146 | 147 | getDom (dom) { 148 | const rootEl = dom.el || document.body; 149 | 150 | Object.keys(dom).forEach((domKey) => { 151 | let selector, domEl; 152 | 153 | selector = dom[domKey]; 154 | if (selector === null) { 155 | return; 156 | } else if (typeof selector === 'object') { 157 | selector = selector.selector || selector; 158 | } 159 | 160 | domEl = DOM.get(selector, rootEl); 161 | domEl.selector = selector; 162 | 163 | dom[domKey] = domEl; 164 | }); 165 | }, 166 | 167 | bindDomEvents (handlers, context) { 168 | const { dom } = context; 169 | 170 | Object.keys(handlers).forEach((eventElKey) => { 171 | const [eventKey, elemKey] = eventElKey.split(' @'); 172 | const elem = elemKey ? dom[elemKey][0] : dom.el[0]; 173 | const eventHandler = handlers[eventElKey]; 174 | 175 | elem.addEventListener(eventKey, eventHandler); 176 | }); 177 | }, 178 | 179 | unbindDomEvents (handlers, context) { 180 | const { dom } = context; 181 | 182 | if (!dom) { 183 | return; 184 | } 185 | 186 | Object.keys(handlers).forEach((eventElKey) => { 187 | const [eventKey, elemKey] = eventElKey.split(' @'); 188 | const elem = elemKey ? dom[elemKey][0] : dom.el[0]; 189 | const eventHandler = handlers[eventElKey]; 190 | 191 | elem.removeEventListener(eventKey, eventHandler); 192 | }); 193 | }, 194 | 195 | mergeProps (defaultProps, props={}) { 196 | let mergedProps = {}; 197 | 198 | Object.keys(defaultProps).forEach((propKey) => { 199 | const propValue = props[propKey] || defaultProps[propKey]; 200 | mergedProps[propKey] = propValue; 201 | }); 202 | 203 | return mergedProps; 204 | }, 205 | 206 | validateProps (props, requiredProps) { 207 | Object.keys(props).forEach((propKey) => { 208 | if (requiredProps.indexOf(propKey) > -1 && !props[propKey]) { 209 | throw new Error(`${moduleName} requires prop: ${propKey}`); 210 | } 211 | }); 212 | } 213 | }; 214 | 215 | const moduleProto = { 216 | moduleConstructor: function (opts) { 217 | moduleProto.prepModule(opts); 218 | moduleProto.bindConfigs(opts); 219 | moduleProto.buildModule(opts); 220 | moduleProto.setupModule(opts); 221 | moduleProto.renderModule(opts); 222 | moduleProto.initModule(opts); 223 | }, 224 | 225 | prepModule (opts) { 226 | const context = moduleUtils.createContext(); 227 | 228 | if (moduleProps) { 229 | const mergedProps = moduleUtils.mergeProps(moduleProps, opts.props); 230 | context.extendWith({ props: mergedProps}); 231 | 232 | if (moduleRequiredProps) { 233 | moduleUtils.validateProps(mergedProps, moduleRequiredProps); 234 | } 235 | } 236 | 237 | opts.context = context; 238 | }, 239 | 240 | bindConfigs (opts) { 241 | if (!moduleAcceptsConfigs) { return; } 242 | 243 | const { context } = opts; 244 | const optsConfigs = opts.configs || {}; 245 | let moduleConfigs = {}; 246 | 247 | moduleAcceptsConfigs.forEach((configKey) => { 248 | moduleConfigs[configKey] = optsConfigs[configKey] || {}; 249 | }); 250 | 251 | context.extendWith({ configs: moduleConfigs }); 252 | }, 253 | 254 | buildModule (opts) { 255 | const { context } = opts; 256 | const boundMethods = moduleUtils.bindMethods(moduleMethods, context); 257 | 258 | if (boundMethods.render) { 259 | boundMethods.render = moduleUtils.wrapRenderMethod(boundMethods.render, opts); 260 | } 261 | 262 | context.extendWith(boundMethods); 263 | context.extendWith({mediator: opts.mediator}); 264 | }, 265 | 266 | setupModule (opts) { 267 | const { context } = opts; 268 | if (context.setup) { 269 | context.setup(); 270 | } 271 | 272 | context.mediator.registerHandler('event', 'app:destroy', function () { 273 | moduleProto.destroyModule(opts); 274 | }); 275 | 276 | if (moduleHandlers) { 277 | moduleUtils.registerHandlers(opts.mediator, moduleHandlers, context); 278 | } 279 | }, 280 | 281 | renderModule (opts) { 282 | let { context } = opts; 283 | let mergedDom; 284 | 285 | if (context.render) { 286 | return; 287 | } 288 | 289 | if (moduleDom) { 290 | mergedDom = moduleUtils.mergeDom(moduleDom, opts.dom); 291 | moduleUtils.getDom(mergedDom); 292 | context.extendWith({dom: mergedDom}); 293 | } 294 | 295 | if (moduleHandlers.domEvents) { 296 | moduleUtils.registerDomHandlers(moduleHandlers.domEvents, context); 297 | } 298 | }, 299 | 300 | initModule (opts) { 301 | const { context } = opts; 302 | 303 | if (context.init) { 304 | context.init(); 305 | } 306 | }, 307 | 308 | destroyModule (opts) { 309 | const { context } = opts; 310 | 311 | if (moduleHandlers.domEvents) { 312 | moduleUtils.deregisterDomHandlers(moduleHandlers.domEvents, context); 313 | } 314 | } 315 | }; 316 | 317 | return moduleProto.moduleConstructor; 318 | }; 319 | 320 | export default Module; 321 | -------------------------------------------------------------------------------- /src/scripts/index.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | /* eslint-disable no-alert, no-console */ 3 | 4 | import './polyfills'; 5 | import AppContainer from './containers/AppContainer'; 6 | 7 | /** 8 | * Tyester - Public interface to instatiate a Typester instance bound to a 9 | * dom element 10 | * 11 | * @access public 12 | * @param {object} opts={} - instance options 13 | * @param {object} opts.dom - The dom components used by Typester 14 | * @param {element} opts.dom.el - The dom element to be the canvas for Typester 15 | * @param {object} opts.config - Additional instanced config 16 | * @return {appContainer} AppContainer instance 17 | * 18 | * @example 19 | * new Typester({ 20 | * dom: { 21 | * el: domElement 22 | * } 23 | * }); 24 | */ 25 | const Typester = function (opts={}) { 26 | return new AppContainer({ 27 | dom: {el: opts.el }, 28 | configs: opts.configs 29 | }); 30 | }; 31 | 32 | export default Typester; -------------------------------------------------------------------------------- /src/scripts/modules/BlockFormatter.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * BlockFormatter - 5 | * Formatter responsible for handling block level formatting for: P, Blockquote 6 | * H1, H2, H3, etc. 7 | * 8 | * @access protected 9 | * @module modules/BlockFormatter 10 | * 11 | * @example 12 | * // Available commands 13 | * commands: { 14 | * 'format:block': 'formatBlock' 15 | * } 16 | * 17 | * mediator.exec('format:block', { style: 'H1' }); // Format selection to a H1 heading 18 | * mediator.exec('format:block', { style: 'BLOCKQUOTE' }); // Format selection to a blockquote 19 | * 20 | * // Other options include 21 | * { style: 'H1' } // H2, H3...H6 22 | * { style: 'P' } 23 | * { style: 'BLOCKQUOTE' } 24 | * { style: 'PRE' } 25 | */ 26 | 27 | import Module from '../core/Module'; 28 | import DOM from '../utils/DOM'; 29 | 30 | /** 31 | * @access protected 32 | */ 33 | const BlockFormatter = Module({ 34 | name: 'BlockFormatter', 35 | props: { 36 | selectionRootEl: null 37 | }, 38 | handlers: { 39 | commands: { 40 | 'format:block': 'formatBlock' 41 | }, 42 | events: {} 43 | }, 44 | methods: { 45 | init () {}, 46 | 47 | formatBlock (opts) { 48 | this.preProcess(opts); 49 | this.process(opts); 50 | this.commit(opts); 51 | }, 52 | 53 | preProcess () { 54 | const { mediator } = this; 55 | mediator.exec('format:export:to:canvas'); 56 | }, 57 | 58 | process (opts) { 59 | const { mediator } = this; 60 | const canvasDoc = mediator.get('canvas:document'); 61 | 62 | if (opts.toggle) { 63 | if (opts.style === 'BLOCKQUOTE') { 64 | mediator.exec('commands:exec', { 65 | command: 'outdent', 66 | contextDocument: canvasDoc 67 | }); 68 | } 69 | mediator.exec('commands:format:default', { 70 | contextDocument: canvasDoc 71 | }); 72 | } else { 73 | mediator.exec('commands:format:block', { 74 | style: opts.style, 75 | contextDocument: canvasDoc 76 | }); 77 | } 78 | }, 79 | 80 | commit (opts) { 81 | const { mediator, cleanupBlockquote } = this; 82 | const importFilter = opts.style === 'BLOCKQUOTE' ? cleanupBlockquote : null; 83 | mediator.exec('format:import:from:canvas', { importFilter }); 84 | }, 85 | 86 | cleanupBlockquote (rootElem) { 87 | const blockquoteParagraphs = rootElem.querySelectorAll('blockquote p'); 88 | blockquoteParagraphs.forEach((paragraph) => { 89 | DOM.unwrap(paragraph); 90 | }); 91 | } 92 | } 93 | }); 94 | 95 | export default BlockFormatter; 96 | -------------------------------------------------------------------------------- /src/scripts/modules/Commands.js: -------------------------------------------------------------------------------- 1 | import Module from '../core/Module'; 2 | import browser from '../utils/browser'; 3 | 4 | const Commands = Module({ 5 | name: 'Commands', 6 | props: {}, 7 | 8 | handlers: { 9 | commands: { 10 | 'commands:exec' : 'exec', 11 | 'commands:format:default' : 'defaultBlockFormat', 12 | 'commands:format:block' : 'formatBlock' 13 | } 14 | }, 15 | 16 | methods: { 17 | setup () {}, 18 | init () {}, 19 | 20 | exec ({command, value = null, contextDocument = document}) { 21 | if (command === 'formatBlock') { 22 | value = this.prepBlockValue(value); 23 | } 24 | contextDocument.execCommand(command, false, value); 25 | }, 26 | 27 | formatBlock ({ style, contextDocument=document }) { 28 | this.exec({ 29 | command: 'formatBlock', 30 | value: style, 31 | contextDocument 32 | }); 33 | }, 34 | 35 | defaultBlockFormat ({ contextDocument=document }) { 36 | const { mediator } = this; 37 | const defaultBlock = mediator.get('config:defaultBlock'); 38 | this.formatBlock({ 39 | style: defaultBlock, 40 | contextDocument 41 | }); 42 | }, 43 | 44 | prepBlockValue (value) { 45 | const ieVersion = browser.ieVersion(); 46 | value = value.toUpperCase(); 47 | return ieVersion && ieVersion < 12 ? `<${value}>` : value; 48 | } 49 | } 50 | }); 51 | 52 | export default Commands; 53 | -------------------------------------------------------------------------------- /src/scripts/modules/Config.js: -------------------------------------------------------------------------------- 1 | import Module from '../core/Module'; 2 | import toolbarConfig from '../config/toolbar'; 3 | import config from '../config/config'; 4 | import stylesConfig from '../config/styles'; 5 | 6 | const Config = Module({ 7 | name: 'Config', 8 | props: {}, 9 | acceptsConfigs: ['toolbar', 'styles'], 10 | 11 | handlers: { 12 | requests: { 13 | 'config:toolbar:buttons' : 'getToolbarButtons', 14 | 'config:toolbar:buttonConfig' : 'getToolbarButtonConfig', 15 | 'config:toolbar:validTags' : 'getToolbarValidTags', 16 | 'config:toolbar:blockTags' : 'getToolbarBlockTags', 17 | 'config:toolbar:listTags' : 'getToolbarListTags', 18 | 'config:toolbar:preventNewlineDefault' : 'getToolbarPreventNewlineDefault', 19 | 'config:blockElementNames' : 'getConfigBlockElementNames', 20 | 'config:defaultBlock' : 'getDefaultBlock', 21 | 'config:styles': 'getStyles' 22 | } 23 | }, 24 | 25 | methods: { 26 | setup () {}, 27 | init () {}, 28 | 29 | getToolbarButtons () { 30 | const { mediator, configs } = this; 31 | const contentEditableButtons = mediator.get('contenteditable:toolbar:buttons') || []; 32 | const configButtons = contentEditableButtons.length 33 | ? contentEditableButtons 34 | : configs.toolbar.buttons 35 | ? configs.toolbar.buttons 36 | : toolbarConfig.buttons; 37 | 38 | let buttons = []; 39 | configButtons.forEach((configKey) => { 40 | // NB This needs to be looked at 41 | if (configKey === 'anchor') { 42 | configKey = 'link'; 43 | } 44 | const buttonConfig = Object.assign({ configKey }, toolbarConfig.buttonConfigs[configKey]); 45 | buttons.push(buttonConfig); 46 | }); 47 | 48 | return { buttons }; 49 | }, 50 | 51 | getToolbarButtonConfig (buttonConfigKey) { 52 | return toolbarConfig.buttonConfigs[buttonConfigKey]; 53 | }, 54 | 55 | getToolbarValidTags () { 56 | return toolbarConfig.getValidTags(); 57 | }, 58 | 59 | getToolbarBlockTags () { 60 | return toolbarConfig.getBlockTags(); 61 | }, 62 | 63 | getToolbarListTags () { 64 | return toolbarConfig.getListTags(); 65 | }, 66 | 67 | getToolbarPreventNewlineDefault () { 68 | return toolbarConfig.preventNewlineDefault; 69 | }, 70 | 71 | getConfigBlockElementNames () { 72 | return config.blockElementNames; 73 | }, 74 | 75 | getDefaultBlock () { 76 | return config.defaultBlock; 77 | }, 78 | 79 | getStyles () { 80 | const { configs } = this; 81 | return { 82 | colors: Object.assign({}, stylesConfig.colors, configs.styles.colors) 83 | }; 84 | } 85 | } 86 | }); 87 | 88 | export default Config; 89 | -------------------------------------------------------------------------------- /src/scripts/modules/Flyout.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * Flyout - 5 | * A utility module to control the flyout DOM element that is used to wrap tooltips 6 | * and floating toolbars. 7 | * @access protected 8 | * @module modules/Flyout 9 | * 10 | * @example 11 | * const flyout = mediator.get('flyout:new'); 12 | * flyout.clearContent(); 13 | * flyout.show(); 14 | * flyout.appendContent(domElement); 15 | * flyout.position({ left: '100px', top: '100px' }); 16 | * flyout.setPlacement('above'); // or 'below' 17 | * 18 | * // ... later ... 19 | * flyout.hide(); 20 | * flyout.remove(); 21 | */ 22 | import Module from '../core/Module'; 23 | import DOM from '../utils/DOM'; 24 | 25 | import flyoutTemplate from '../../templates/flyout.html'; 26 | import flyoutStyles from '../../styles/flyout.scss'; 27 | 28 | const Flyout = Module({ 29 | name: 'Flyout', 30 | dom: {}, 31 | props: { 32 | minZIndex: 90, 33 | styles: null, 34 | flyouts: [] 35 | }, 36 | handlers: { 37 | requests: { 38 | 'flyout:new' : 'newFlyout' 39 | }, 40 | commands: {}, 41 | events: { 42 | 'app:destroy' : 'destroy' 43 | } 44 | }, 45 | methods: { 46 | setup () { 47 | this.appendStyles(); 48 | }, 49 | 50 | init () {}, 51 | 52 | appendStyles () { 53 | const { props } = this; 54 | props.styles = DOM.addStyles(flyoutStyles); 55 | }, 56 | 57 | newFlyout () { 58 | const { props } = this; 59 | const flyout = this.buildFlyout(); 60 | props.flyouts.push(flyout); 61 | return flyout; 62 | }, 63 | 64 | buildFlyout () { 65 | const flyout = { 66 | el: this.buildTemplate(), 67 | appended: null 68 | }; 69 | 70 | flyout.contentEl = flyout.el.querySelector('.typester-flyout-content'); 71 | 72 | flyout.clearContent = () => { 73 | flyout.contentEl.innerHTML = ''; 74 | }; 75 | 76 | flyout.appendContent = (content) => { 77 | return DOM.appendTo(flyout.contentEl, content); 78 | }; 79 | flyout.show = () => { 80 | this.showFlyout(flyout); 81 | }; 82 | flyout.remove = () => { 83 | this.removeFlyout(flyout); 84 | }; 85 | flyout.hide = () => { 86 | this.hideFlyout(flyout); 87 | }; 88 | flyout.position = (coordinates) => { 89 | this.positionFlyout(flyout, coordinates); 90 | }; 91 | flyout.setPlacement = (placement) => { 92 | this.setPlacement(flyout, placement); 93 | }; 94 | 95 | return flyout; 96 | }, 97 | 98 | buildTemplate () { 99 | const wrapperEl = document.createElement('div'); 100 | let flyoutHTML, flyoutEl; 101 | 102 | flyoutHTML = flyoutTemplate(); 103 | if (typeof flyoutHTML === 'string') { 104 | wrapperEl.innerHTML = flyoutHTML; 105 | } else { 106 | wrapperEl.appendChild(flyoutHTML[0]); 107 | } 108 | flyoutEl = wrapperEl.childNodes[0]; 109 | 110 | return flyoutEl; 111 | }, 112 | 113 | appendFlyout (flyout) { 114 | DOM.appendTo(document.body, flyout.el); 115 | }, 116 | 117 | removeFlyout (flyout) { 118 | if (flyout.appended) { 119 | DOM.removeNode(flyout.el); 120 | flyout.appended = false; 121 | } 122 | }, 123 | 124 | positionFlyout (flyout, coordinates) { 125 | const { mediator, props } = this; 126 | const contentEditableEl = mediator.get('contenteditable:element'); 127 | const containerZIndex = Math.max(props.minZIndex, DOM.getContainerZIndex(contentEditableEl)); 128 | 129 | Object.keys(coordinates).forEach((coordinateKey) => { 130 | flyout.el.style[coordinateKey] = coordinates[coordinateKey]; 131 | }); 132 | 133 | flyout.el.style.zIndex = containerZIndex + 1; 134 | }, 135 | 136 | setPlacement (flyout, placement='above') { 137 | flyout.el.classList.remove('place-below'); 138 | flyout.el.classList.remove('place-above'); 139 | flyout.el.classList.add(`place-${placement}`); 140 | }, 141 | 142 | showFlyout (flyout) { 143 | if (!flyout.appended) { 144 | this.appendFlyout(flyout); 145 | flyout.appended = true; 146 | } else { 147 | flyout.el.style.display = 'block'; 148 | } 149 | 150 | setTimeout(() => { 151 | this.ensureFlyoutInView(flyout); 152 | }, 17); 153 | }, 154 | 155 | ensureFlyoutInView (flyout) { 156 | const flyoutBounds = flyout.el.getBoundingClientRect(); 157 | const flyoutArrow = flyout.el.querySelector('.typester-flyout-arrow'); 158 | 159 | if (flyoutBounds.left < 0) { 160 | flyout.el.style.left = flyoutBounds.width / 2 + 10 + 'px'; 161 | flyoutArrow.style.left = flyoutBounds.width / 2 - Math.abs(flyoutBounds.left) - 10 + 'px'; 162 | } else if (flyoutBounds.right > window.innerWidth) { 163 | const rightOffset = window.innerWidth - flyoutBounds.right; 164 | flyout.el.style.left = window.innerWidth - (flyoutBounds.width / 2) - 10 + 'px'; 165 | flyoutArrow.style.left = flyoutBounds.width / 2 + Math.abs(rightOffset) + 10 + 'px'; 166 | } else { 167 | flyoutArrow.style.left = null; 168 | } 169 | }, 170 | 171 | hideFlyout (flyout) { 172 | flyout.el.style.display = 'none'; 173 | }, 174 | 175 | destroy () { 176 | const { props } = this; 177 | props.flyouts.forEach((flyout) => { 178 | flyout.remove(); 179 | }); 180 | } 181 | 182 | } 183 | }); 184 | 185 | export default Flyout; 186 | -------------------------------------------------------------------------------- /src/scripts/modules/ListFormatter.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | 4 | /** 5 | * ListFormatter - 6 | * Responsible for the creation, cleanup, and removal of lists. 7 | * @access protected 8 | * @module modules/ListFormatter 9 | * 10 | * @example 11 | * mediator.exec('format:list', { style: 'ordered'}); // Toggle ordered list on current selection 12 | * mediator.exec('format:list', { style: 'unordered'}); // Toggle unordered list on current selection 13 | * mediator.exec('format:list:cleanup', domElement); // Find all lists and clean them up 14 | */ 15 | import Module from '../core/Module'; 16 | import DOM from '../utils/DOM'; 17 | 18 | const ListFormatter = Module({ 19 | name: 'ListFormatter', 20 | props: {}, 21 | dom: {}, 22 | handlers: { 23 | requests: {}, 24 | commands: { 25 | 'format:list': 'formatList', 26 | 'format:list:cleanup': 'cleanupListDOM' 27 | }, 28 | events: { 29 | 'contenteditable:tab:down': 'handleTabDown', 30 | 'contenteditable:tab:up': 'handleTabUp' 31 | } 32 | }, 33 | methods: { 34 | init () {}, 35 | formatList (opts) { 36 | this.preProcess(opts); 37 | this.process(opts); 38 | this.commit(opts); 39 | }, 40 | 41 | preProcess () { 42 | const { mediator } = this; 43 | mediator.exec('format:export:to:canvas'); 44 | }, 45 | 46 | process (opts) { 47 | const { mediator } = this; 48 | const canvasDoc = mediator.get('canvas:document'); 49 | 50 | mediator.exec('canvas:cache:selection'); 51 | 52 | switch (opts.style) { 53 | case 'ordered': 54 | if (mediator.get('selection:in:or:contains', ['UL'])) { 55 | mediator.exec('commands:exec', { 56 | command: 'insertUnorderedList', 57 | contextDocument: canvasDoc 58 | }); 59 | } 60 | mediator.exec('commands:exec', { 61 | command: 'insertOrderedList', 62 | contextDocument: canvasDoc 63 | }); 64 | break; 65 | 66 | case 'unordered': 67 | if (mediator.get('selection:in:or:contains', ['OL'])) { 68 | mediator.exec('commands:exec', { 69 | command: 'insertOrderedList', 70 | contextDocument: canvasDoc 71 | }); 72 | } 73 | mediator.exec('commands:exec', { 74 | command: 'insertUnorderedList', 75 | contextDocument: canvasDoc 76 | }); 77 | break; 78 | 79 | case 'outdent': 80 | mediator.exec('commands:exec', { 81 | command: 'outdent', 82 | contextDocument: canvasDoc 83 | }); 84 | break; 85 | 86 | case 'indent': 87 | mediator.exec('commands:exec', { 88 | command: 'indent', 89 | contextDocument: canvasDoc 90 | }); 91 | break; 92 | } 93 | 94 | mediator.exec('canvas:select:ensure:offsets'); 95 | }, 96 | 97 | commit () { 98 | const { mediator, cleanupListDOM } = this; 99 | mediator.exec('format:import:from:canvas', { 100 | importFilter: cleanupListDOM 101 | }); 102 | }, 103 | 104 | handleTabDown (evnt) { 105 | const { mediator } = this; 106 | const isInList = mediator.get('selection:in:or:contains', ['UL', 'OL']); 107 | 108 | if (isInList) { 109 | evnt.preventDefault(); 110 | } 111 | }, 112 | 113 | handleTabUp (evnt) { 114 | const { mediator } = this; 115 | const isInList = mediator.get('selection:in:or:contains', ['UL', 'OL']); 116 | 117 | 118 | if (isInList) { 119 | evnt.preventDefault(); 120 | 121 | if (evnt.shiftKey) { 122 | this.formatList({ style: 'outdent' }); 123 | } else { 124 | this.formatList({ style: 'indent' }); 125 | } 126 | } 127 | }, 128 | 129 | cleanupListDOM (rootElem) { 130 | const listContainers = rootElem.querySelectorAll('OL, UL'); 131 | 132 | for (let i = listContainers.length - 1; i >= 0; i--) { 133 | let listContainer = listContainers[i]; 134 | if (['OL', 'UL'].indexOf(listContainer.parentNode.nodeName) > -1) { 135 | if (listContainer.previousSibling) { 136 | if (listContainer.previousSibling.nodeName === 'LI') { 137 | listContainer.previousSibling.appendChild(listContainer); 138 | } 139 | 140 | if (['OL', 'UL'].indexOf(listContainer.previousSibling.nodeName) > -1) { 141 | for (let j = 0; j <= listContainer.childNodes.length; j++) { 142 | listContainer.previousSibling.appendChild(listContainer.childNodes[j]); 143 | } 144 | DOM.removeNode(listContainer); 145 | } 146 | } else { 147 | DOM.unwrap(listContainer); 148 | } 149 | } else { 150 | while (listContainer.parentNode && listContainer.parentNode !== rootElem && ['LI'].indexOf(listContainer.parentNode.nodeName) < 0) { 151 | DOM.insertBefore(listContainer, listContainer.parentNode); 152 | } 153 | } 154 | } 155 | 156 | const nestedListItems = rootElem.querySelectorAll('LI > LI'); 157 | for (let i = nestedListItems.length - 1; i >= 0; i--) { 158 | let nestedListItem = nestedListItems[i]; 159 | DOM.insertAfter(nestedListItem, nestedListItem.parentNode); 160 | } 161 | } 162 | } 163 | }); 164 | 165 | export default ListFormatter; 166 | -------------------------------------------------------------------------------- /src/scripts/modules/Mouse.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * Mouse - 5 | * Responsible for tracking the up/down state of the mouse button 6 | * @access protected 7 | * @module modules/Mouse 8 | * 9 | * @example 10 | * mediator.get('mouse:is:down'); // Returns true if mouse button is down. 11 | */ 12 | 13 | import Module from '../core/Module'; 14 | 15 | const Mouse = Module({ 16 | name: 'Mouse', 17 | props: { 18 | mousedown: 0 19 | }, 20 | dom: {}, 21 | handlers: { 22 | requests: { 23 | 'mouse:is:down': 'mouseIsDown' 24 | }, 25 | commands: {}, 26 | events: { 27 | 'contenteditable:blur': 'handleContentEditableBlur' 28 | } 29 | }, 30 | methods: { 31 | init () { 32 | const { mediator } = this; 33 | document.body.onmousedown = () => { 34 | this.setMousedown(); 35 | mediator.emit('mouse:down'); 36 | }; 37 | document.body.onmouseup = () => { 38 | this.unsetMousedown(); 39 | mediator.emit('mouse:up'); 40 | }; 41 | }, 42 | 43 | setMousedown () { 44 | const { props } = this; 45 | props.mousedown += 1; 46 | props.mousedown = Math.min(1, props.mousedown); 47 | }, 48 | 49 | unsetMousedown () { 50 | const { props } = this; 51 | props.mousedown -= 1; 52 | props.mousedown = Math.max(0, props.mousedown); 53 | }, 54 | 55 | mouseIsDown () { 56 | const { props } = this; 57 | return !!props.mousedown; 58 | }, 59 | 60 | handleContentEditableBlur () { 61 | const { props } = this; 62 | props.mousedown = 0; 63 | } 64 | } 65 | }); 66 | 67 | export default Mouse; 68 | -------------------------------------------------------------------------------- /src/scripts/modules/Paste.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * Paste - 5 | * Handle paste event. Capture paste data, clean it and sanitize it in canvas 6 | * before importing it into the editor. 7 | * @access protected 8 | * @module modules/Paste 9 | */ 10 | import DOMPurify from 'dompurify'; 11 | 12 | import Module from '../core/Module'; 13 | import pasteUtils from '../utils/paste'; 14 | import DOM from '../utils/DOM'; 15 | 16 | const Paste = Module({ 17 | name: 'Paste', 18 | props: {}, 19 | handlers: { 20 | commands: {}, 21 | requests: {}, 22 | events: { 23 | 'contenteditable:paste': 'handlePaste' 24 | } 25 | }, 26 | methods: { 27 | init () { 28 | 29 | }, 30 | 31 | handlePaste (evnt) { 32 | evnt.preventDefault(); 33 | 34 | const { mediator } = this; 35 | let { 36 | 'text/html': pastedHTML, 37 | 'text/plain': pastedPlain 38 | } = this.getClipboardContent(evnt, window, document); 39 | 40 | if (!pastedHTML) { 41 | pastedHTML = pastedPlain.replace(/(?:\r\n|\r|\n)/g, '
'); 42 | } 43 | 44 | pastedHTML = this.cleanPastedHTML(pastedHTML); 45 | pastedHTML = DOMPurify.sanitize(pastedHTML); 46 | 47 | mediator.exec('contenteditable:inserthtml', pastedHTML); 48 | }, 49 | 50 | getClipboardContent (evnt, contextWindow, contextDocument) { 51 | const dataTransfer = evnt.clipboardData || contextWindow.clipboardData || contextDocument.dataTransfer; 52 | let data = { 53 | pastedHTML: '', 54 | pastedPlain: '' 55 | }; 56 | 57 | if (!dataTransfer) { 58 | return data; 59 | } 60 | 61 | if (dataTransfer.getData) { 62 | let legacyText = dataTransfer.getData('text'); 63 | if (legacyText && legacyText.length > 0) { 64 | data['text/plain'] = legacyText; 65 | } 66 | } 67 | 68 | if (dataTransfer.types) { 69 | for (let i = 0; i < dataTransfer.types.length; i++) { 70 | let contentType = dataTransfer.types[i]; 71 | data[contentType] = dataTransfer.getData(contentType); 72 | } 73 | } 74 | 75 | return data; 76 | }, 77 | 78 | cleanPastedHTML (pastedHTML) { 79 | const { mediator } = this; 80 | const canvasDoc = mediator.get('canvas:document'); 81 | const canvasBody = mediator.get('canvas:body'); 82 | const replacements = pasteUtils.createReplacements(); 83 | 84 | for (let i = 0; i < replacements.length; i++) { 85 | let replacement = replacements[i]; 86 | pastedHTML = pastedHTML.replace(replacement[0], replacement[1]); 87 | } 88 | 89 | canvasBody.innerHTML = '

' + pastedHTML.split('

').join('

') + '

'; 90 | 91 | let elList = canvasBody.querySelectorAll('a,p,div,br'); 92 | for (let i = 0; i < elList.length; i++) { 93 | let workEl = elList[i]; 94 | 95 | workEl.innerHTML = workEl.innerHTML.replace(/\n/gi, ' '); 96 | } 97 | 98 | const pasteBlock = canvasDoc.createDocumentFragment(); 99 | const pasteBlockBody = canvasDoc.createElement('body'); 100 | pasteBlock.appendChild(pasteBlockBody); 101 | pasteBlockBody.innerHTML = canvasBody.innerHTML; 102 | 103 | this.cleanupSpans(pasteBlockBody); 104 | this.cleanupDivs(pasteBlockBody); 105 | 106 | elList = pasteBlockBody.querySelectorAll('*'); 107 | for (let i = 0; i < elList.length; i++) { 108 | let workEl = elList[i]; 109 | let elAttrs = []; 110 | 111 | for (let j = 0; j < workEl.attributes.length; j++) { 112 | elAttrs.push(workEl.attributes[j].name); 113 | } 114 | 115 | for (let k = 0; k < elAttrs.length; k++) { 116 | let attrName = elAttrs[k]; 117 | if (!(workEl.nodeName === 'A' && attrName === 'href')) { 118 | workEl.removeAttribute(attrName); 119 | } 120 | } 121 | } 122 | 123 | canvasBody.innerHTML = pasteBlockBody.innerHTML; 124 | mediator.exec('format:list:cleanup', canvasBody); 125 | mediator.exec('format:clean', canvasBody); 126 | 127 | pastedHTML = canvasBody.innerHTML; 128 | return pastedHTML; 129 | }, 130 | 131 | cleanupSpans (containerEl) { 132 | let spans = containerEl.querySelectorAll('.replace-with'); 133 | 134 | for (let i = 0; i < spans.length; i++) { 135 | let span = spans[i]; 136 | let replaceBold = span.classList.contains('bold'); 137 | let replaceItalic = span.classList.contains('italic'); 138 | let replacement = document.createElement(replaceBold ? 'b' : 'i'); 139 | 140 | if (replaceBold && replaceItalic) { 141 | replacement.innerHTML = '' + span.innerHTML + ''; 142 | } else { 143 | replacement.innerHTML = span.innerHTML; 144 | } 145 | 146 | span.parentNode.replaceChild(replacement, span); 147 | } 148 | 149 | spans = containerEl.querySelectorAll('span'); 150 | for (let i = 0; i < spans.length; i++) { 151 | let span = spans[i]; 152 | DOM.unwrap(span); 153 | } 154 | }, 155 | 156 | cleanupDivs (containerEl) { 157 | let divs = containerEl.querySelectorAll('div'); 158 | for (let i = divs.length - 1; i >=0; i--) { 159 | DOM.unwrap(divs[i]); 160 | } 161 | } 162 | } 163 | }); 164 | 165 | export default Paste; 166 | -------------------------------------------------------------------------------- /src/scripts/modules/Styles.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * Styles - 5 | * Handle the creation and embedding of custom styles. 6 | * @access protected 7 | * @module modules/Styles 8 | */ 9 | 10 | import Module from '../core/Module'; 11 | 12 | const Styles = Module({ 13 | name: 'Styles', 14 | 15 | props: { 16 | stylesheet: null, 17 | }, 18 | 19 | handlers: { 20 | events: { 21 | 'app:destroy': 'destroy' 22 | } 23 | }, 24 | 25 | methods: { 26 | setup () { 27 | this.createStylesheet(); 28 | }, 29 | 30 | init () { 31 | const { mediator } = this; 32 | const config = mediator.get('config:styles'); 33 | let stylesheetContent = this.stylesTemplate(config); 34 | 35 | this.appendStylesheet(); 36 | this.updateStylesheet(stylesheetContent); 37 | }, 38 | 39 | stylesTemplate (config) { 40 | return ` 41 | .typester-toolbar .typester-menu-item, 42 | .typester-input-form input[type=text], 43 | .typester-link-display a, 44 | .typester-input-form button { 45 | color: ${config.colors.menuItemIcon}; 46 | } 47 | 48 | .typester-toolbar .typester-menu-item svg, 49 | .typester-link-display .typester-link-edit svg, 50 | .typester-input-form button svg { 51 | fill: ${config.colors.menuItemIcon}; 52 | } 53 | 54 | .typester-input-form button svg { 55 | stroke: ${config.colors.menuItemIcon}; 56 | } 57 | 58 | .typester-toolbar .typester-menu-item:hover, 59 | .typester-link-display .typester-link-edit:hover 60 | .typester-input-form button:hover { 61 | background: ${config.colors.menuItemHover}; 62 | } 63 | 64 | .typester-toolbar .typester-menu-item.s--active { 65 | background: ${config.colors.menuItemActive}; 66 | } 67 | 68 | .typester-flyout .typester-flyout-content { 69 | background: ${config.colors.flyoutBg}; 70 | } 71 | 72 | .typester-flyout.place-above .typester-flyout-arrow { 73 | border-top-color: ${config.colors.flyoutBg}; 74 | } 75 | 76 | .typester-flyout.place-below .typester-flyout-arrow { 77 | border-bottom-color: ${config.colors.flyoutBg}; 78 | } 79 | `; 80 | }, 81 | 82 | createStylesheet () { 83 | this.stylesheet = document.createElement('style'); 84 | }, 85 | 86 | appendStylesheet () { 87 | document.head.appendChild(this.stylesheet); 88 | }, 89 | 90 | updateStylesheet (stylesheetContent) { 91 | this.stylesheet.textContent = stylesheetContent; 92 | }, 93 | 94 | removeStylesheet () { 95 | document.head.removeChild(this.stylesheet); 96 | }, 97 | 98 | destroy () { 99 | this.removeStylesheet(); 100 | } 101 | } 102 | }); 103 | 104 | export default Styles; 105 | -------------------------------------------------------------------------------- /src/scripts/modules/TextFormatter.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * TextFormatter - 5 | * Responsible for handling formatting for inline text. Bold. Italic. 6 | * @access protected 7 | * @module modules/TextFormatter 8 | * 9 | * @example 10 | * mediator.exec('format:text', { style: 'bold' }); 11 | * mediator.exec('format:text', { style: 'italic' }); 12 | */ 13 | import Module from '../core/Module'; 14 | 15 | const TextFormatter = Module({ 16 | name: 'TextFormatter', 17 | props: { 18 | cachedRange: null 19 | }, 20 | handlers: { 21 | requests: {}, 22 | commands: { 23 | 'format:text' : 'formatText' 24 | }, 25 | events: {} 26 | }, 27 | methods: { 28 | formatText (opts) { 29 | this.preProcess(); 30 | this.process(opts); 31 | this.postProcess(opts); 32 | }, 33 | 34 | preProcess () { 35 | const { mediator } = this; 36 | mediator.exec('contenteditable:refocus'); 37 | mediator.exec('selection:reselect'); 38 | }, 39 | 40 | process (opts) { 41 | const { mediator } = this; 42 | mediator.exec('commands:exec', { 43 | command: opts.style 44 | }); 45 | }, 46 | 47 | postProcess (opts) { 48 | const { mediator } = this; 49 | 50 | mediator.exec('contenteditable:refocus'); 51 | 52 | if (opts.toggle) { 53 | this.normalize(); 54 | } 55 | }, 56 | 57 | normalize () { 58 | const { mediator } = this; 59 | const currentSelection = mediator.get('selection:current'); 60 | const parentElement = currentSelection.anchorNode.parentElement; 61 | parentElement.normalize(); 62 | } 63 | } 64 | }); 65 | 66 | export default TextFormatter; 67 | -------------------------------------------------------------------------------- /src/scripts/modules/Toolbar.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * Toolbar - 5 | * Handle the display of the toolbar and its controls and wire interactions to the 6 | * correct formatter using the toolbar config. 7 | * @access protected 8 | * @module modules/Toolbar 9 | * 10 | * @example 11 | * // Available commands 12 | * commands: { 13 | * 'toolbar:hide' : 'hideToolbar' 14 | * } 15 | */ 16 | import Module from '../core/Module'; 17 | import DOM from '../utils/DOM'; 18 | 19 | import toolbarTemplate from '../../templates/toolbar.html'; 20 | import toolbarStyles from '../../styles/toolbar.scss'; 21 | 22 | const Toolbar = Module({ 23 | name: 'Toolbar', 24 | dom: { 25 | 'toolbarMenuItems': '.typester-menu-item' 26 | }, 27 | props: { 28 | el: null, 29 | styles: null, 30 | mouseover: 0, 31 | classNames: { 32 | MENU_ITEM: 'typester-menu-item', 33 | ACTIVE: 's--active' 34 | } 35 | }, 36 | handlers: { 37 | commands: { 38 | 'toolbar:hide' : 'hideToolbar' 39 | }, 40 | events: { 41 | 'app:destroy' : 'destroy', 42 | 'selection:update' : 'handleSelectionChange', 43 | 'selection:change' : 'handleSelectionChange', 44 | 'mouse:down': 'handleMouseDown', 45 | 'mouse:up': 'handleMouseUp', 46 | 'import:from:canvas:complete' : 'handleCanvasImport' 47 | }, 48 | domEvents: { 49 | 'click' : 'handleToolbarClick', 50 | 'mouseover' : 'handleMouseOver', 51 | 'mouseout' : 'handleMouseOut' 52 | } 53 | }, 54 | methods: { 55 | setup () { 56 | this.appendStyles(); 57 | this.render(); 58 | }, 59 | 60 | init () { 61 | this.updateToolbarState(); 62 | }, 63 | 64 | appendStyles () { 65 | const { props } = this; 66 | props.styles = DOM.addStyles(toolbarStyles); 67 | }, 68 | 69 | render () { 70 | const { mediator, props } = this; 71 | const buttonConfigs = this.getButtonConfigs(); 72 | const wrapperEl = document.createElement('div'); 73 | 74 | let toolbarHTML = toolbarTemplate(buttonConfigs); 75 | 76 | if (typeof toolbarHTML === 'string') { 77 | wrapperEl.innerHTML = toolbarHTML; 78 | } else { 79 | wrapperEl.appendChild(toolbarHTML[0]); 80 | } 81 | 82 | const toolbarEl = wrapperEl.childNodes[0]; 83 | props.flyout = props.flyout || mediator.get('flyout:new'); 84 | props.flyout.clearContent(); 85 | props.flyout.show(); 86 | props.flyout.appendContent(wrapperEl.childNodes[0]); 87 | 88 | return toolbarEl; 89 | }, 90 | 91 | getButtonConfigs () { 92 | const { mediator } = this; 93 | return mediator.get('config:toolbar:buttons'); 94 | }, 95 | 96 | handleToolbarClick (evnt) { 97 | const { mediator, props } = this; 98 | mediator.exec('contenteditable:refocus'); 99 | mediator.exec('selection:reselect'); 100 | 101 | const menuItemEl = DOM.getClosest(evnt.target, `.${props.classNames.MENU_ITEM}`); 102 | const { dataset } = menuItemEl; 103 | const { configKey } = dataset; 104 | const buttonConfig = mediator.get('config:toolbar:buttonConfig', configKey); 105 | const { formatter, opts } = buttonConfig; 106 | const toolbarMenuItemState = this.getMenuItemState(menuItemEl); 107 | 108 | opts.toggle = buttonConfig.toggles && toolbarMenuItemState.isActive; 109 | mediator.exec(`format:${formatter}`, opts); 110 | }, 111 | 112 | handleSelectionChange () { 113 | const { props } = this; 114 | if (props.selectionChangeTimeout) { 115 | clearTimeout(props.selectionChangeTimeout); 116 | } 117 | props.selectionChangeTimeout = setTimeout(() => { 118 | this.updateToolbarState(); 119 | }, 10); 120 | }, 121 | 122 | handleMouseDown () { 123 | this.updateToolbarState(); 124 | }, 125 | 126 | handleMouseUp () { 127 | this.updateToolbarState(); 128 | }, 129 | 130 | handleMouseOver () { 131 | const { props } = this; 132 | props.mouseover += 1; 133 | props.mouseover = Math.min(1, props.mouseover); 134 | }, 135 | 136 | handleMouseOut () { 137 | const { props } = this; 138 | props.mouseover -= 1; 139 | props.mouseover = Math.max(0, props.mouseover); 140 | }, 141 | 142 | handleCanvasImport () { 143 | this.updateToolbarState(); 144 | }, 145 | 146 | hideToolbar () { 147 | const { props } = this; 148 | props.flyout.hide(); 149 | }, 150 | 151 | showToolbar () { 152 | this.render(); 153 | }, 154 | 155 | positionToolbar () { 156 | const { mediator, props } = this; 157 | const selectionBounds = mediator.get('selection:bounds'); 158 | 159 | if (selectionBounds.initialWidth > 0) { 160 | const scrollOffset = DOM.getScrollOffset(); 161 | const docRelTop = selectionBounds.top + scrollOffset.y; 162 | const docRelLeft = selectionBounds.initialLeft + scrollOffset.x; 163 | const docRelCenter = selectionBounds.initialWidth / 2 + docRelLeft; 164 | 165 | props.flyout.position({ 166 | left: docRelCenter + 'px', 167 | top: docRelTop + 'px' 168 | }); 169 | } 170 | }, 171 | 172 | updateToolbarState () { 173 | const { mediator, props } = this; 174 | const currentSelection = mediator.get('selection:current'); 175 | const linkFormatterActive = mediator.get('format:link:active'); 176 | const mouseIsDown = mediator.get('mouse:is:down'); 177 | 178 | if ( 179 | !currentSelection || 180 | currentSelection.isCollapsed || 181 | !currentSelection.toString().trim().length || 182 | linkFormatterActive || 183 | !document.activeElement.hasAttribute('contenteditable') || 184 | mouseIsDown 185 | ) { 186 | if (props.mouseover) { 187 | return; 188 | } 189 | this.hideToolbar(); 190 | } else { 191 | this.positionToolbar(); 192 | this.showToolbar(); 193 | this.updateMenuItems(); 194 | } 195 | }, 196 | 197 | updateMenuItems () { 198 | const { dom, mediator } = this; 199 | mediator.exec('selection:ensure:text:only'); 200 | for (let i = 0; i < dom.toolbarMenuItems.length; i++) { 201 | let toolbarMenuItem = dom.toolbarMenuItems[i]; 202 | this.updateMenuItemState(toolbarMenuItem); 203 | } 204 | }, 205 | 206 | updateMenuItemState (toolbarMenuItem) { 207 | const { props } = this; 208 | const toolbarMenuItemState = this.getMenuItemState(toolbarMenuItem); 209 | 210 | if (toolbarMenuItemState.isDisabled) { 211 | toolbarMenuItem.setAttribute('disabled', ''); 212 | } else { 213 | toolbarMenuItem.removeAttribute('disabled'); 214 | } 215 | 216 | DOM.toggleClass(toolbarMenuItem, props.classNames.ACTIVE, toolbarMenuItemState.isActive); 217 | }, 218 | 219 | getMenuItemState (toolbarMenuItem) { 220 | const { mediator } = this; 221 | const { configKey } = toolbarMenuItem.dataset; 222 | 223 | const config = mediator.get('config:toolbar:buttonConfig', configKey); 224 | 225 | const activeIn = config.activeIn || []; 226 | const disabledIn = config.disabledIn || []; 227 | const isActive = mediator.get('selection:in:or:contains', activeIn); 228 | 229 | let isDisabled = false; 230 | if (typeof disabledIn === 'function') { 231 | isDisabled = disabledIn.call(config, mediator); 232 | } else { 233 | isDisabled = mediator.get('selection:in:or:contains', disabledIn); 234 | } 235 | 236 | return { 237 | isActive, 238 | isDisabled 239 | }; 240 | }, 241 | 242 | destroy () { 243 | const { props } = this; 244 | props.flyout.remove(); 245 | } 246 | } 247 | }); 248 | 249 | export default Toolbar; 250 | -------------------------------------------------------------------------------- /src/scripts/modules/Undo.js: -------------------------------------------------------------------------------- 1 | import Module from '../core/Module'; 2 | import DOM from '../utils/DOM'; 3 | 4 | const Undo = Module({ 5 | name: 'Undo', 6 | props: { 7 | contentEditableElem: null, 8 | currentHistoryIndex: -1, 9 | history: [], 10 | ignoreSelectionChanges: false 11 | }, 12 | 13 | handlers: { 14 | events: { 15 | 'contenteditable:mutation:observed': 'handleMutation', 16 | 'contenteditable:focus': 'handleFocus', 17 | 'import:from:canvas:start': 'handleImportStart', 18 | 'import:from:canvas:complete': 'handleImportComplete', 19 | 'selection:change': 'handleSelectionChange', 20 | 'export:to:canvas:start': 'handleExportStart' 21 | } 22 | }, 23 | 24 | methods: { 25 | setup () {}, 26 | init () {}, 27 | 28 | handleMutation () { 29 | const { props, mediator } = this; 30 | const { history, currentHistoryIndex } = props; 31 | const states = { 32 | currentHistoryIndex, 33 | current: this.createHistoryState(), 34 | previous: history[currentHistoryIndex], 35 | beforePrevious: history[currentHistoryIndex - 1], 36 | next: history[currentHistoryIndex + 1], 37 | afterNext: history[currentHistoryIndex + 2] 38 | }; 39 | 40 | const { 41 | isUndo, 42 | isRedo, 43 | noChange 44 | } = this.analyzeStates(states); 45 | 46 | if (noChange) { 47 | return; 48 | } else if (!isUndo && !isRedo) { 49 | props.history.length = currentHistoryIndex + 1; 50 | props.history.push(states.current); 51 | props.currentHistoryIndex += 1; 52 | } else if (isUndo) { 53 | props.currentHistoryIndex -= 1; 54 | mediator.exec('format:clean', props.contentEditableElem); 55 | mediator.exec('selection:select:coordinates', states.beforePrevious.selectionRangeCoordinates); 56 | } else if (isRedo) { 57 | props.currentHistoryIndex += 1; 58 | mediator.exec('format:clean', props.contentEditableElem); 59 | mediator.exec('selection:select:coordinates', states.next.selectionRangeCoordinates); 60 | } 61 | }, 62 | 63 | handleFocus () { 64 | const { mediator, props } = this; 65 | const contentEditableElem = mediator.get('contenteditable:element'); 66 | 67 | if (props.contentEditableElem !== contentEditableElem) { 68 | setTimeout(() => { 69 | props.contentEditableElem = contentEditableElem; 70 | props.history = [this.createHistoryState()]; 71 | props.currentHistoryIndex = 0; 72 | }, 150); 73 | } 74 | }, 75 | 76 | handleImportStart () { 77 | const { props } = this; 78 | props.ignoreSelectionChanges = true; 79 | }, 80 | 81 | handleImportComplete () { 82 | const { props } = this; 83 | props.ignoreSelectionChanges = false; 84 | }, 85 | 86 | handleExportStart () { 87 | this.updateCurrentHistoryState(); 88 | }, 89 | 90 | handleSelectionChange () { 91 | const { props } = this; 92 | if (!props.ignoreSelectionChanges) { 93 | this.updateCurrentHistoryState(); 94 | } 95 | }, 96 | 97 | updateCurrentHistoryState () { 98 | const { props } = this; 99 | const { history, currentHistoryIndex } = props; 100 | const currentHistoryState = history[currentHistoryIndex]; 101 | 102 | if (currentHistoryState) { 103 | this.cacheSelectionRangeOnState(currentHistoryState); 104 | } 105 | }, 106 | 107 | createHistoryState () { 108 | const { props } = this; 109 | 110 | if (!props.contentEditableElem) { return; } 111 | 112 | const editableContentString = DOM.nodesToHTMLString(DOM.cloneNodes(props.contentEditableElem, { trim: true })).replace(/\u200B/g, ''); 113 | const historyState = { 114 | editableContentString, 115 | }; 116 | 117 | this.cacheSelectionRangeOnState(historyState); 118 | 119 | return historyState; 120 | }, 121 | 122 | cacheSelectionRangeOnState (state) { 123 | const { mediator } = this; 124 | state.selectionRangeCoordinates = mediator.get('selection:range:coordinates'); 125 | }, 126 | 127 | analyzeStates (states) { 128 | const { 129 | current, 130 | previous, 131 | beforePrevious, 132 | next 133 | } = states; 134 | let isUndo = beforePrevious && current.editableContentString === beforePrevious.editableContentString; 135 | let isRedo = next && current.editableContentString === next.editableContentString; 136 | let noChange = previous && current.editableContentString === previous.editableContentString; 137 | 138 | isUndo = isUndo || false; 139 | isRedo = isRedo || false; 140 | noChange = noChange || false; 141 | 142 | return { 143 | isUndo, 144 | isRedo, 145 | noChange 146 | }; 147 | } 148 | } 149 | }); 150 | 151 | export default Undo; 152 | -------------------------------------------------------------------------------- /src/scripts/polyfills/array/forEach.js: -------------------------------------------------------------------------------- 1 | // Production steps of ECMA-262, Edition 5, 15.4.4.18 2 | // Reference: http://es5.github.io/#x15.4.4.18 3 | if (!Array.prototype.forEach) { 4 | 5 | Array.prototype.forEach = function(callback/*, thisArg*/) { 6 | 7 | var T, k; 8 | 9 | if (this == null) { 10 | throw new TypeError('this is null or not defined'); 11 | } 12 | 13 | // 1. Let O be the result of calling toObject() passing the 14 | // |this| value as the argument. 15 | var O = Object(this); 16 | 17 | // 2. Let lenValue be the result of calling the Get() internal 18 | // method of O with the argument "length". 19 | // 3. Let len be toUint32(lenValue). 20 | var len = O.length >>> 0; 21 | 22 | // 4. If isCallable(callback) is false, throw a TypeError exception. 23 | // See: http://es5.github.com/#x9.11 24 | if (typeof callback !== 'function') { 25 | throw new TypeError(callback + ' is not a function'); 26 | } 27 | 28 | // 5. If thisArg was supplied, let T be thisArg; else let 29 | // T be undefined. 30 | if (arguments.length > 1) { 31 | T = arguments[1]; 32 | } 33 | 34 | // 6. Let k be 0 35 | k = 0; 36 | 37 | // 7. Repeat, while k < len 38 | while (k < len) { 39 | 40 | var kValue; 41 | 42 | // a. Let Pk be ToString(k). 43 | // This is implicit for LHS operands of the in operator 44 | // b. Let kPresent be the result of calling the HasProperty 45 | // internal method of O with argument Pk. 46 | // This step can be combined with c 47 | // c. If kPresent is true, then 48 | if (k in O) { 49 | 50 | // i. Let kValue be the result of calling the Get internal 51 | // method of O with argument Pk. 52 | kValue = O[k]; 53 | 54 | // ii. Call the Call internal method of callback with T as 55 | // the this value and argument list containing kValue, k, and O. 56 | callback.call(T, kValue, k, O); 57 | } 58 | // d. Increase k by 1. 59 | k++; 60 | } 61 | // 8. return undefined 62 | }; 63 | } 64 | 65 | if (window.NodeList && !NodeList.prototype.forEach) { 66 | NodeList.prototype.forEach = function (callback, thisArg) { 67 | thisArg = thisArg || window; 68 | for (var i = 0; i < this.length; i++) { 69 | callback.call(thisArg, this[i], i, this); 70 | } 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /src/scripts/polyfills/index.js: -------------------------------------------------------------------------------- 1 | import './array/forEach'; 2 | import './object/assign'; 3 | -------------------------------------------------------------------------------- /src/scripts/polyfills/object/assign.js: -------------------------------------------------------------------------------- 1 | if (typeof Object.assign !== 'function') { 2 | Object.assign = function (target) { // .length of function is 2 3 | 'use strict'; 4 | var to, index, nextSource, nextKey; 5 | 6 | if (target === null) { // TypeError if undefined or null 7 | throw new TypeError('Cannot convert undefined or null to object'); 8 | } 9 | 10 | to = Object(target); 11 | 12 | for (index = 1; index < arguments.length; index++) { 13 | nextSource = arguments[index]; 14 | 15 | if (nextSource !== null) { // Skip over if undefined or null 16 | for (nextKey in nextSource) { 17 | // Avoid bugs when hasOwnProperty is shadowed 18 | if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) { 19 | to[nextKey] = nextSource[nextKey]; 20 | } 21 | } 22 | } 23 | } 24 | return to; 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/scripts/utils/browser.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * browser - 5 | * a utility to check browser version. 6 | * @access protected 7 | */ 8 | const browser = { 9 | // From https://codepen.io/gapcode/pen/vEJNZN 10 | ieVersion () { 11 | const ua = window.navigator.userAgent; 12 | 13 | const msie = ua.indexOf('MSIE '); 14 | if (msie > 0) { 15 | // IE 10 or older => return version number 16 | return parseInt(ua.substring(msie + 5, ua.indexOf('.', msie)), 10); 17 | } 18 | 19 | const trident = ua.indexOf('Trident/'); 20 | if (trident > 0) { 21 | // IE 11 => return version number 22 | const rv = ua.indexOf('rv:'); 23 | return parseInt(ua.substring(rv + 3, ua.indexOf('.', rv)), 10); 24 | } 25 | 26 | const edge = ua.indexOf('Edge/'); 27 | if (edge > 0) { 28 | // Edge (IE 12+) => return version number 29 | return parseInt(ua.substring(edge + 5, ua.indexOf('.', edge)), 10); 30 | } 31 | 32 | // other browser 33 | return false; 34 | }, 35 | 36 | isIE () { 37 | const ieVersion = browser.ieVersion(); 38 | return ieVersion && ieVersion < 12; 39 | }, 40 | 41 | isFirefox () { 42 | return window.navigator.userAgent.indexOf('Firefox') > -1; 43 | } 44 | }; 45 | 46 | export default browser; 47 | -------------------------------------------------------------------------------- /src/scripts/utils/commands.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * commands - 5 | * utility to abstract the interface for document.execCommand 6 | * @access protected 7 | */ 8 | import conf from '../config/config'; 9 | import browser from './browser'; 10 | 11 | const commands = { 12 | exec (command, value=null, contextDocument=document) { 13 | if (command === 'formatBlock') { 14 | value = commands.prepBlockValue(value); 15 | } 16 | contextDocument.execCommand(command, false, value); 17 | }, 18 | 19 | formatBlock (style, contextDocument=document) { 20 | commands.exec('formatBlock', style, contextDocument); 21 | }, 22 | 23 | defaultBlockFormat (contextDocument=document) { 24 | commands.formatBlock(conf.defaultBlock, contextDocument); 25 | }, 26 | 27 | prepBlockValue (value) { 28 | const ieVersion = browser.ieVersion(); 29 | value = value.toUpperCase(); 30 | return ieVersion && ieVersion < 12 ? `<${value}>` : value; 31 | } 32 | }; 33 | 34 | export default commands; 35 | -------------------------------------------------------------------------------- /src/scripts/utils/func.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * func - 5 | * namespaced collection of utililty methods for binding function contexts. 6 | * @access protected 7 | */ 8 | const func = { 9 | bind (func, context) { 10 | return (...args) => { 11 | return func.apply(context, args); 12 | }; 13 | }, 14 | 15 | bindObj (funcObj, context) { 16 | let boundFuncObj = {}; 17 | 18 | Object.keys(funcObj).forEach((funcKey) => { 19 | boundFuncObj[funcKey] = func.bind(funcObj[funcKey], context); 20 | }); 21 | 22 | return boundFuncObj; 23 | } 24 | }; 25 | 26 | export default func; 27 | -------------------------------------------------------------------------------- /src/scripts/utils/guid.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * guid - generates guids. 5 | * @access protected 6 | * @return {string} guid 7 | */ 8 | const guid = function guid() { 9 | function s4() { 10 | return Math.floor((1 + Math.random()) * 0x10000) 11 | .toString(16) 12 | .substring(1); 13 | } 14 | return s4() + s4() + '-' + s4() + '-' + s4() + '-' + 15 | s4() + '-' + s4() + s4() + s4(); 16 | }; 17 | 18 | export default guid; 19 | -------------------------------------------------------------------------------- /src/scripts/utils/keycodes.js: -------------------------------------------------------------------------------- 1 | /** 2 | * keycodes - 3 | * utility map to make keycode matching more human readable. 4 | * @access protected 5 | */ 6 | const keycodes = { 7 | ENTER: 13, 8 | BACKSPACE: 8, 9 | TAB: 9 10 | }; 11 | 12 | export default keycodes; 13 | -------------------------------------------------------------------------------- /src/scripts/utils/paste.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | // Taken from medium editor: https://github.com/yabwe/medium-editor/blob/master/src/js/extensions/paste.js 4 | 5 | /** 6 | * pasteUtils - 7 | * namespaced utility methods for cleaning paste data 8 | * @access protected 9 | */ 10 | const pasteUtils = { 11 | createReplacements () { 12 | return [ 13 | // Remove anything but the contents within the BODY element 14 | [new RegExp(/^[\s\S]*]*>\s*|\s*<\/body[^>]*>[\s\S]*$/g), ''], 15 | 16 | // cleanup comments added by Chrome when pasting html 17 | [new RegExp(/|/g), ''], 18 | 19 | // Trailing BR elements 20 | [new RegExp(/
$/i), ''], 21 | 22 | // replace two bogus tags that begin pastes from google docs 23 | [new RegExp(/<[^>]*docs-internal-guid[^>]*>/gi), ''], 24 | [new RegExp(/<\/b>(]*>)?$/gi), ''], 25 | 26 | // un-html spaces and newlines inserted by OS X 27 | [new RegExp(/\s+<\/span>/g), ' '], 28 | [new RegExp(/
/g), '
'], 29 | 30 | // replace google docs italics+bold with a span to be replaced once the html is inserted 31 | [new RegExp(/]*(font-style:italic;font-weight:(bold|700)|font-weight:(bold|700);font-style:italic)[^>]*>/gi), ''], 32 | 33 | // replace google docs italics with a span to be replaced once the html is inserted 34 | [new RegExp(/]*font-style:italic[^>]*>/gi), ''], 35 | 36 | //[replace google docs bolds with a span to be replaced once the html is inserted 37 | [new RegExp(/]*font-weight:(bold|700)[^>]*>/gi), ''], 38 | 39 | // replace manually entered b/i/a tags with real ones 40 | [new RegExp(/<(\/?)(i|b|a)>/gi), '<$1$2>'], 41 | 42 | // replace manually a tags with real ones, converting smart-quotes from google docs 43 | [new RegExp(/<a(?:(?!href).)+href=(?:"|”|“|"|“|”)(((?!"|”|“|"|“|”).)*)(?:"|”|“|"|“|”)(?:(?!>).)*>/gi), ''], 44 | 45 | // Newlines between paragraphs in html have no syntactic value, 46 | // but then have a tendency to accidentally become additional paragraphs down the line 47 | [new RegExp(/<\/p>\n+/gi), '

'], 48 | [new RegExp(/\n+

51 | [new RegExp(/<\/?o:[a-z]*>/gi), ''], 52 | 53 | // Microsoft Word adds some special elements around list items 54 | [new RegExp(/(((?!/gi), '$1'] 55 | ]; 56 | } 57 | }; 58 | 59 | export default pasteUtils; 60 | -------------------------------------------------------------------------------- /src/scripts/utils/string.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | /** 4 | * string utilities 5 | * @access protected 6 | */ 7 | export default { 8 | capitalize (string) { 9 | return string.charAt(0).toUpperCase() + string.slice(1); 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /src/scripts/utils/zeroWidthSpace.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | const zeroWidthSpaceEntity = '​'; 4 | 5 | /** 6 | * zeroWidthSpace - 7 | * utililties for generating and asserting zeroWidthSpace entities used as bookend 8 | * hooks when dynamically setting selection range around content. 9 | * @access protected 10 | */ 11 | const zeroWidthSpace = { 12 | generate () { 13 | let tmpEl = document.createElement('span'); 14 | tmpEl.innerHTML = zeroWidthSpaceEntity; 15 | return tmpEl; 16 | }, 17 | 18 | get () { 19 | const tmpEl = zeroWidthSpace.generate(); 20 | return tmpEl.firstChild; 21 | }, 22 | 23 | assert (node) { 24 | const tmpEl = zeroWidthSpace.generate(); 25 | if (node.nodeType === Node.ELEMENT_NODE) { 26 | return node.innerHTML === tmpEl.innerHTML; 27 | } else if (node.nodeType === Node.TEXT_NODE) { 28 | return node.nodeValue === tmpEl.firstChild.nodeValue; 29 | } 30 | } 31 | }; 32 | 33 | export default zeroWidthSpace; 34 | -------------------------------------------------------------------------------- /src/styles/canvas.scss: -------------------------------------------------------------------------------- 1 | .typester-canvas { 2 | position: fixed; 3 | top: 0; 4 | left: 0; 5 | height: 0; 6 | width: 0; 7 | opacity: 0; 8 | //debug styles 9 | // right: 0; 10 | // left: auto; 11 | // z-index: 100; 12 | // width: 33vw; 13 | // height: 100vh; 14 | // background: #DDD; 15 | // opacity: 2; 16 | } 17 | -------------------------------------------------------------------------------- /src/styles/contentEditable.scss: -------------------------------------------------------------------------------- 1 | .typester-content-editable { 2 | &[data-placeholder] { 3 | &:before { 4 | content: attr(data-placeholder); 5 | display: none; 6 | color: rgb(160, 160, 160); 7 | position: absolute; 8 | } 9 | 10 | &.show-placeholder { 11 | &:before { 12 | display: block; 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/styles/flyout.scss: -------------------------------------------------------------------------------- 1 | .typester-flyout { 2 | transition: top 200ms, left 200ms; 3 | position: absolute; 4 | z-index: 1600; // Bump up to ensure this displays above modals 5 | top: 50%; 6 | left: 50%; 7 | 8 | .typester-flyout-content { 9 | background: rgb(32, 31, 32); 10 | height: 40px; 11 | width: auto; 12 | } 13 | 14 | .typester-flyout-arrow { 15 | position: absolute; 16 | left: 50%; 17 | height: 0; 18 | width: 0; 19 | border-left: 10px solid transparent; 20 | border-right: 10px solid transparent; 21 | } 22 | 23 | &.place-above { 24 | transform: translate3d(-50%, -100%, 0); 25 | padding-bottom: 12px; 26 | 27 | .typester-flyout-content {} 28 | 29 | .typester-flyout-arrow { 30 | top: 100%; 31 | border-top: 10px solid rgb(32, 31, 32); 32 | transform: translate3d(-50%, -13px, 0); 33 | } 34 | } 35 | 36 | &.place-below { 37 | transform: translate3d(-50%, 0, 0); 38 | padding-top: 12px; 39 | 40 | .typester-flyout-content {} 41 | 42 | .typester-flyout-arrow { 43 | bottom: 100%; 44 | border-bottom: 10px solid rgb(32, 31, 32); 45 | transform: translate3d(-50%, 13px, 0); 46 | } 47 | } 48 | 49 | a { 50 | color: #FFF; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/styles/inputForm.scss: -------------------------------------------------------------------------------- 1 | .typester-input-form { 2 | input[type=text] { 3 | background: none; 4 | border: none; 5 | padding: 5px 15px; 6 | height: 30px; 7 | color: #FFF; 8 | width: 250px; 9 | outline: none; 10 | vertical-align: top; 11 | } 12 | 13 | button { 14 | height: 40px; 15 | width: 40px; 16 | line-height: 40px; 17 | background: none; 18 | border: none; 19 | color: #FFF; 20 | cursor: pointer; 21 | outline: none; 22 | text-align: center; 23 | padding: 0; 24 | margin: 0; 25 | vertical-align: top; 26 | 27 | &:hover { 28 | background: rgb(0, 174, 239); 29 | } 30 | 31 | svg { 32 | display: block; 33 | height: 16px; 34 | width: 16px; 35 | margin: 12px; 36 | fill: #FFF; 37 | stroke: #FFF; 38 | text-align: center; 39 | } 40 | } 41 | } 42 | 43 | .typester-pseudo-selection { 44 | background: #CCC; 45 | } 46 | -------------------------------------------------------------------------------- /src/styles/linkDisplay.scss: -------------------------------------------------------------------------------- 1 | .typester-link-display { 2 | display: flex; 3 | 4 | a { 5 | display: block; 6 | cursor: pointer; 7 | line-height: 20px; 8 | padding: 10px; 9 | } 10 | 11 | a[href] { 12 | text-decoration: none; 13 | 14 | &:hover { 15 | text-decoration: underline; 16 | } 17 | } 18 | 19 | .typester-link-edit { 20 | display: block; 21 | height: 20px; 22 | text-align: center; 23 | 24 | svg { 25 | display: block; 26 | width: 20px; 27 | height: 20px; 28 | fill: #FFF; 29 | } 30 | 31 | &:hover { 32 | background: rgb(0, 174, 239); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/styles/toolbar.scss: -------------------------------------------------------------------------------- 1 | .typester-toolbar { 2 | .buttons-wrapper, 3 | .inputs-wrapper { 4 | transition: opacity 200ms, transform 200ms; 5 | transform: translateY(-40px); 6 | opacity: 0; 7 | 8 | &.s--active { 9 | transform: translateY(0px); 10 | opacity: 1; 11 | } 12 | } 13 | 14 | .inputs-wrapper { 15 | position: absolute; 16 | top: 0; 17 | left: 0; 18 | width: 100%; 19 | } 20 | 21 | ul { 22 | overflow: hidden; 23 | list-style: none; 24 | display: flex; 25 | padding: 0; 26 | margin: 0; 27 | } 28 | 29 | li { 30 | list-style: none; 31 | } 32 | 33 | .typester-menu-item { 34 | // transition: width 200ms; 35 | color: #FFF; 36 | font-family: sans-serif; 37 | font-size: 16px; 38 | width: 40px; 39 | height: 40px; 40 | display: block; 41 | line-height: 40px; 42 | font-weight: bold; 43 | text-align: center; 44 | cursor: pointer; 45 | user-select: none; 46 | 47 | svg { 48 | display: block; 49 | fill: #FFF; 50 | height: 16px; 51 | width: 16px; 52 | padding: 12px; 53 | } 54 | 55 | b { 56 | font-weight: bold; 57 | } 58 | 59 | i { 60 | font-style: italic; 61 | } 62 | 63 | &:hover { 64 | background: rgb(0, 174, 239); 65 | } 66 | 67 | &.s--active { 68 | background: darken(rgb(0, 174, 239), 10%); 69 | } 70 | 71 | &[disabled] { 72 | width: 0; 73 | overflow: hidden; 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/templates/flyout.html: -------------------------------------------------------------------------------- 1 |

2 |
3 | {{{ content }}} 4 |
5 |
6 |
7 | -------------------------------------------------------------------------------- /src/templates/icons/link.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/templates/icons/orderedlist.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/templates/icons/quote.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/templates/icons/unorderedlist.html: -------------------------------------------------------------------------------- 1 | 2 | 4 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/templates/inputForm.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 9 | 17 |
18 | -------------------------------------------------------------------------------- /src/templates/linkDisplay.html: -------------------------------------------------------------------------------- 1 |
9 | -------------------------------------------------------------------------------- /src/templates/toolbar.html: -------------------------------------------------------------------------------- 1 |
2 | 11 |
12 | 13 |
14 |
15 | -------------------------------------------------------------------------------- /test/integration/plugins/index.js: -------------------------------------------------------------------------------- 1 | /*global module*/ 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | module.exports = (on, config) => { 16 | // `on` is used to hook into various events Cypress emits 17 | // `config` is the resolved Cypress config 18 | } 19 | -------------------------------------------------------------------------------- /test/integration/specs/core/core_spec.js: -------------------------------------------------------------------------------- 1 | /*global cy*/ 2 | describe('Core specs', function () { 3 | it('should have setup correctly', function () { 4 | cy.get('.typester-content-editable').should('be.visible').then(($el) => { 5 | expect($el).to.have.attr('contenteditable', 'true'); 6 | }); 7 | cy.get('.typester-toolbar').should('exist'); 8 | cy.get('.typester-canvas').should('exist'); 9 | }); 10 | 11 | it('should destroy correctly', function () { 12 | cy.window().then(win => { 13 | win.typesterInstance.destroy(); 14 | cy.get('.typester-toolbar').should('not.exist'); 15 | cy.get('.typester-canvas').should('not.exist'); 16 | }); 17 | }); 18 | }); -------------------------------------------------------------------------------- /test/integration/specs/list/list_spec.js: -------------------------------------------------------------------------------- 1 | /*global cy*/ 2 | describe('List specs', function () { 3 | beforeEach(function () { 4 | cy.contentEditable().setSampleContent('paragraph'); 5 | cy.toolbar().should('not.be.visible'); 6 | cy.contentEditable().selectAll(); 7 | }); 8 | 9 | it('should create/toggle OL', function () { 10 | cy.toolbarClick('orderedlist'); 11 | cy.contentEditable().assertContent('orderedList'); 12 | cy.toolbarClick('orderedlist'); 13 | cy.contentEditable().assertContent('paragraph', true); 14 | cy.toolbarClick('orderedlist'); 15 | cy.contentEditable().assertContent('orderedList'); 16 | 17 | cy.contentEditable().type('{rightarrow}{enter}'); 18 | cy.contentEditable().typeSampleContent('line'); 19 | cy.contentEditable().assertContent('orderedListParagraphLine'); 20 | }); 21 | 22 | it('should create/toggle UL', function () { 23 | cy.toolbarClick('unorderedlist'); 24 | cy.contentEditable().assertContent('unorderedList'); 25 | cy.toolbarClick('unorderedlist'); 26 | cy.contentEditable().assertContent('paragraph', true); 27 | cy.toolbarClick('unorderedlist'); 28 | cy.contentEditable().assertContent('unorderedList'); 29 | 30 | cy.contentEditable().type('{rightarrow}{enter}'); 31 | cy.contentEditable().typeSampleContent('line'); 32 | cy.contentEditable().assertContent('unorderedListParagraphLine'); 33 | }); 34 | 35 | it('should create/toggle OL on new line', function () { 36 | cy.contentEditable().type('{rightarrow}{enter}'); 37 | cy.contentEditable().typeSampleContent('line'); 38 | cy.contentEditable().selectElement('p:last-child'); 39 | 40 | cy.toolbarClick('orderedlist'); 41 | cy.contentEditable().assertContent('paragraphOrderedList'); 42 | cy.toolbarClick('orderedlist'); 43 | cy.contentEditable().assertContent('paragraphLine'); 44 | }); 45 | 46 | it('should create/toggle UL on new line', function () { 47 | cy.contentEditable().type('{rightarrow}{enter}'); 48 | cy.contentEditable().typeSampleContent('line'); 49 | cy.contentEditable().selectElement('p:last-child'); 50 | 51 | cy.toolbarClick('unorderedlist'); 52 | cy.contentEditable().assertContent('paragraphUnorderedList'); 53 | cy.toolbarClick('unorderedlist'); 54 | cy.contentEditable().assertContent('paragraphLine'); 55 | }); 56 | 57 | it('should toggle off a single ordered list item', function () { 58 | cy.contentEditable().setSampleContent('orderedlist'); 59 | cy.toolbar().should('not.be.visible'); 60 | cy.contentEditable().selectAll(); 61 | 62 | cy.contentEditable().type('{rightarrow}{enter}'); 63 | cy.contentEditable().typeSampleContent('line'); 64 | cy.contentEditable().assertContent('orderedListThreeItems'); 65 | 66 | cy.contentEditable().selectElement('li:last-child'); 67 | cy.toolbarClick('orderedlist'); 68 | cy.contentEditable().assertContent('orderedListTwoItemsLine'); 69 | cy.toolbarClick('h1'); 70 | cy.contentEditable().assertContent('orderedListTwoItemsH1'); 71 | cy.toolbarClick('orderedlist'); 72 | cy.contentEditable().assertContent('orderedListThreeItems'); 73 | cy.toolbarClick('orderedlist'); 74 | cy.contentEditable().assertContent('orderedListTwoItemsLine'); 75 | cy.toolbarClick('h2'); 76 | cy.contentEditable().assertContent('orderedListTwoItemsH2'); 77 | }); 78 | 79 | it('should toggle off a single unordered list item', function () { 80 | cy.contentEditable().setSampleContent('unorderedlist'); 81 | cy.toolbar().should('not.be.visible'); 82 | cy.contentEditable().selectAll(); 83 | 84 | cy.contentEditable().type('{rightarrow}{enter}'); 85 | cy.contentEditable().typeSampleContent('line'); 86 | cy.contentEditable().assertContent('unorderedListThreeItems'); 87 | 88 | cy.contentEditable().selectElement('li:last-child'); 89 | cy.toolbarClick('unorderedlist'); 90 | cy.contentEditable().assertContent('unorderedListTwoItemsLine'); 91 | cy.toolbarClick('h1'); 92 | cy.contentEditable().assertContent('unorderedListTwoItemsH1'); 93 | cy.toolbarClick('unorderedlist'); 94 | cy.contentEditable().assertContent('unorderedListThreeItems'); 95 | cy.toolbarClick('unorderedlist'); 96 | cy.contentEditable().assertContent('unorderedListTwoItemsLine'); 97 | cy.toolbarClick('h2'); 98 | cy.contentEditable().assertContent('unorderedListTwoItemsH2'); 99 | }); 100 | }); -------------------------------------------------------------------------------- /test/integration/specs/paragraph/paragraph_spec.js: -------------------------------------------------------------------------------- 1 | /*global cy*/ 2 | describe('Paragraph specs', function () { 3 | beforeEach(function () { 4 | cy.contentEditable().setSampleContent('paragraph'); 5 | cy.toolbar().should('not.be.visible'); 6 | cy.contentEditable().selectAll(); 7 | }); 8 | 9 | it('should toggle bold', function () { 10 | cy.toolbarClick('bold'); 11 | cy.contentEditable().assertContent('paragraphBold'); 12 | cy.toolbarClick('bold'); 13 | cy.contentEditable().assertContent('paragraph', true); 14 | cy.toolbarClick('bold'); 15 | cy.contentEditable().assertContent('paragraphBold'); 16 | }); 17 | 18 | it('should toggle italic', function () { 19 | cy.toolbarClick('italic'); 20 | cy.contentEditable().assertContent('paragraphItalic'); 21 | cy.toolbarClick('italic'); 22 | cy.contentEditable().assertContent('paragraph', true); 23 | cy.toolbarClick('italic'); 24 | cy.contentEditable().assertContent('paragraphItalic'); 25 | }); 26 | 27 | it('should toggle H1', function () { 28 | cy.contentEditable().selectElement('p:first-child').type('{leftarrow}{enter}'); 29 | cy.contentEditable().selectElement('p:first-child').type('{leftarrow}'); 30 | cy.contentEditable().typeSampleContent('line'); 31 | cy.contentEditable().assertContent('lineParagraph'); 32 | 33 | cy.contentEditable().selectElement('p:first-child'); 34 | cy.toolbarClick('h1'); 35 | cy.contentEditable().assertContent('h1Paragraph'); 36 | cy.toolbarClick('h1'); 37 | cy.contentEditable().assertContent('lineParagraph'); 38 | cy.toolbarClick('h1'); 39 | cy.contentEditable().assertContent('h1Paragraph'); 40 | cy.toolbarClick('h2'); 41 | cy.contentEditable().assertContent('h2Paragraph'); 42 | }); 43 | 44 | it('should toggle H2', function () { 45 | cy.contentEditable().selectElement('p:first-child').type('{leftarrow}{enter}'); 46 | cy.contentEditable().selectElement('p:first-child').type('{leftarrow}'); 47 | cy.contentEditable().typeSampleContent('line'); 48 | cy.contentEditable().assertContent('lineParagraph'); 49 | 50 | cy.contentEditable().selectElement('p:first-child'); 51 | cy.toolbarClick('h2'); 52 | cy.contentEditable().assertContent('h2Paragraph'); 53 | cy.toolbarClick('h2'); 54 | cy.contentEditable().assertContent('lineParagraph'); 55 | cy.toolbarClick('h2'); 56 | cy.contentEditable().assertContent('h2Paragraph'); 57 | cy.toolbarClick('h1'); 58 | cy.contentEditable().assertContent('h1Paragraph'); 59 | }); 60 | 61 | it('should toggle blockquote', function () { 62 | cy.toolbarClick('quote'); 63 | cy.contentEditable().assertContent('paragraphBlockquote'); 64 | cy.toolbarClick('quote'); 65 | cy.contentEditable().assertContent('paragraph', true); 66 | cy.toolbarClick('quote'); 67 | cy.contentEditable().assertContent('paragraphBlockquote'); 68 | }); 69 | 70 | it('should create a link', function () { 71 | cy.contentEditable().selectElement('p:first-child').type('{leftarrow}{enter}'); 72 | cy.contentEditable().selectElement('p:first-child').type('{leftarrow}'); 73 | cy.contentEditable().typeSampleContent('line'); 74 | cy.contentEditable().assertContent('lineParagraph'); 75 | 76 | cy.contentEditable().get('p:first-child').setSelection('ipsum dolor sit amet'); 77 | cy.toolbarClick('link'); 78 | cy.wait(100); 79 | cy.get('.typester-input-form .user-input').click().type('http://link.test{enter}'); 80 | cy.contentEditable().assertContent('lineLinkParagraph'); 81 | cy.toolbarClick('link'); 82 | cy.contentEditable().assertContent('lineParagraph'); 83 | }); 84 | 85 | it('should toggle bold/italic', function () { 86 | cy.contentEditable().setSelection('iaculis mi scelerisque'); 87 | cy.toolbarClick('bold'); 88 | cy.contentEditable().assertContent('paragraphBoldSubstring'); 89 | cy.toolbarClick('bold'); 90 | cy.contentEditable().assertContent('paragraph', true); 91 | cy.toolbarClick('italic'); 92 | cy.contentEditable().assertContent('paragraphItalicSubstring'); 93 | cy.toolbarClick('italic'); 94 | cy.contentEditable().assertContent('paragraph', true); 95 | }); 96 | }); -------------------------------------------------------------------------------- /test/integration/support/commands/components.js: -------------------------------------------------------------------------------- 1 | /*global cy Cypress*/ 2 | const CLASSNAMES = { 3 | contentEditable: 'typester-content-editable', 4 | toolbar: 'typester-toolbar', 5 | menuItem: 'typester-menu-item' 6 | }; 7 | 8 | Cypress.Commands.add('contentEditable', function () { 9 | return cy.get(`.${CLASSNAMES.contentEditable}`); 10 | }); 11 | 12 | Cypress.Commands.add('toolbar', function () { 13 | return cy.get(`.${CLASSNAMES.toolbar}`); 14 | }); 15 | 16 | Cypress.Commands.add('toolbarClick', function (configKey) { 17 | return cy.get(`.${CLASSNAMES.menuItem}[data-config-key="${configKey}"]`).click({ force: true }); 18 | }); -------------------------------------------------------------------------------- /test/integration/support/commands/content.js: -------------------------------------------------------------------------------- 1 | /*global cy Cypress*/ 2 | Cypress.Commands.add('setContent', { prevSubject: true }, (subject, content) => { 3 | return cy.wrap(subject).then($el => { 4 | $el.html(content); 5 | }); 6 | }); 7 | 8 | Cypress.Commands.add('setSampleContent', { prevSubject: true }, (subject, contentKey) => { 9 | cy.fixture('sampleContent').then(sampleContent => { 10 | cy.wrap(subject).setContent(sampleContent.input[contentKey]); 11 | }); 12 | 13 | return cy.wrap(subject); 14 | }); 15 | 16 | Cypress.Commands.add('assertContent', { prevSubject: true }, (subject, contentKey, useInput = false) => { 17 | cy.fixture('sampleContent').then(sampleContent => { 18 | const testContent = useInput ? sampleContent.input[contentKey] : sampleContent.output[contentKey]; 19 | cy.wrap(subject).should('have.html', testContent); 20 | }); 21 | }); 22 | 23 | Cypress.Commands.add('typeSampleContent', { prevSubject: true }, (subject, contentKey) => { 24 | cy.fixture('sampleContent').then(sampleContent => { 25 | cy.wrap(subject).type(sampleContent.input[contentKey].match(/

(.*?)<\/p>/)[1]); 26 | }); 27 | return cy.wrap(subject); 28 | }); -------------------------------------------------------------------------------- /test/integration/support/commands/index.js: -------------------------------------------------------------------------------- 1 | import './text-selection'; 2 | import './content'; 3 | import './components'; -------------------------------------------------------------------------------- /test/integration/support/commands/text-selection.js: -------------------------------------------------------------------------------- 1 | /*global cy Cypress*/ 2 | /** 3 | * Credits 4 | * @Bkucera: https://github.com/cypress-io/cypress/issues/2839#issuecomment-447012818 5 | * @Phrogz: https://stackoverflow.com/a/10730777/1556245 6 | * 7 | * Usage 8 | * ``` 9 | * // Types "foo" and then selects "fo" 10 | * cy.get('input') 11 | * .type('foo') 12 | * .setSelection('fo') 13 | * 14 | * // Types "foo", "bar", "baz", and "qux" on separate lines, then selects "foo", "bar", and "baz" 15 | * cy.get('textarea') 16 | * .type('foo{enter}bar{enter}baz{enter}qux{enter}') 17 | * .setSelection('foo', 'baz') 18 | * 19 | * // Types "foo" and then sets the cursor before the last letter 20 | * cy.get('input') 21 | * .type('foo') 22 | * .setCursorAfter('fo') 23 | * 24 | * // Types "foo" and then sets the cursor at the beginning of the word 25 | * cy.get('input') 26 | * .type('foo') 27 | * .setCursorBefore('foo') 28 | * 29 | * // `setSelection` can alternatively target starting and ending nodes using query strings, 30 | * // plus specific offsets. The queries are processed via `Element.querySelector`. 31 | * cy.get('body') 32 | * .setSelection({ 33 | * anchorQuery: 'ul > li > p', // required 34 | * anchorOffset: 2 // default: 0 35 | * focusQuery: 'ul > li > p:last-child', // default: anchorQuery 36 | * focusOffset: 0 // default: 0 37 | * }) 38 | */ 39 | 40 | // Low level command reused by `setSelection` and low level command `setCursor` 41 | Cypress.Commands.add('selection', { prevSubject: true }, (subject, fn) => { 42 | cy.wrap(subject) 43 | .trigger('mousedown') 44 | .then(fn) 45 | .trigger('mouseup') 46 | .then($el => { 47 | $el.focus(); 48 | cy.document().trigger('selectionchange'); 49 | }); 50 | return cy.wrap(subject); 51 | }); 52 | 53 | Cypress.Commands.add('setSelection', { prevSubject: true }, (subject, query, endQuery) => { 54 | return cy.wrap(subject) 55 | .selection($el => { 56 | if (typeof query === 'string') { 57 | const anchorNode = getTextNode($el[0], query); 58 | const focusNode = endQuery ? getTextNode($el[0], endQuery) : anchorNode; 59 | const anchorOffset = anchorNode.wholeText.indexOf(query); 60 | const focusOffset = endQuery ? 61 | focusNode.wholeText.indexOf(endQuery) + endQuery.length : 62 | anchorOffset + query.length; 63 | setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); 64 | } else if (typeof query === 'object') { 65 | const el = $el[0]; 66 | const anchorNode = getTextNode(el.querySelector(query.anchorQuery)); 67 | const anchorOffset = query.anchorOffset || 0; 68 | const focusNode = query.focusQuery ? getTextNode(el.querySelector(query.focusQuery)) : anchorNode; 69 | const focusOffset = query.focusOffset || 0; 70 | setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset); 71 | } 72 | }); 73 | }); 74 | 75 | Cypress.Commands.add('selectAll', { prevSubject: true }, (subject) => { 76 | cy.wrap(subject) 77 | .selection($el => { 78 | const rootElem = $el[0]; 79 | 80 | cy.document().then((contextDocument) => { 81 | const currentSelection = contextDocument.getSelection(); 82 | const range = contextDocument.createRange(); 83 | 84 | range.setStart(rootElem, 0); 85 | range.setEndAfter(rootElem.lastChild); 86 | 87 | currentSelection.removeAllRanges(); 88 | currentSelection.addRange(range); 89 | }); 90 | 91 | return cy.wrap($el); 92 | }); 93 | 94 | return cy.wrap(subject); 95 | }); 96 | 97 | Cypress.Commands.add('selectElement', { prevSubject: true }, (subject, selector) => { 98 | cy.wrap(subject) 99 | .selection($el => { 100 | const rootElem = $el[0]; 101 | 102 | cy.document().then(contextDocument => { 103 | const selection = contextDocument.getSelection(); 104 | const newRange = new Range(); 105 | const elem = rootElem.querySelector(selector); 106 | 107 | newRange.selectNode(elem); 108 | selection.removeAllRanges(); 109 | selection.addRange(newRange); 110 | }); 111 | 112 | return cy.wrap($el); 113 | }); 114 | 115 | return cy.wrap(subject); 116 | }); 117 | 118 | // Low level command reused by `setCursorBefore` and `setCursorAfter`, equal to `setCursorAfter` 119 | Cypress.Commands.add('setCursor', { prevSubject: true }, (subject, query, atStart) => { 120 | return cy.wrap(subject) 121 | .selection($el => { 122 | const node = getTextNode($el[0], query); 123 | const offset = node.wholeText.indexOf(query) + (atStart ? 0 : query.length); 124 | const document = node.ownerDocument; 125 | document.getSelection().removeAllRanges(); 126 | document.getSelection().collapse(node, offset); 127 | }); 128 | // Depending on what you're testing, you may need to chain a `.click()` here to ensure 129 | // further commands are picked up by whatever you're testing (this was required for Slate, for example). 130 | }); 131 | 132 | Cypress.Commands.add('setCursorBefore', { prevSubject: true }, (subject, query) => { 133 | cy.wrap(subject).setCursor(query, true); 134 | }); 135 | 136 | Cypress.Commands.add('setCursorAfter', { prevSubject: true }, (subject, query) => { 137 | cy.wrap(subject).setCursor(query); 138 | }); 139 | 140 | // Helper functions 141 | function getTextNode(el, match) { 142 | const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false); 143 | if (!match) { 144 | return walk.nextNode(); 145 | } 146 | 147 | let node = walk.nextNode(); 148 | while (node) { 149 | if (node.wholeText.includes(match)) { 150 | return node; 151 | } 152 | node = walk.nextNode(); 153 | } 154 | } 155 | 156 | function setBaseAndExtent(...args) { 157 | const document = args[0].ownerDocument; 158 | document.getSelection().removeAllRanges(); 159 | document.getSelection().setBaseAndExtent(...args); 160 | } -------------------------------------------------------------------------------- /test/integration/support/index.js: -------------------------------------------------------------------------------- 1 | /*global cy*/ 2 | // *********************************************************** 3 | // This example support/index.js is processed and 4 | // loaded automatically before your test files. 5 | // 6 | // This is a great place to put global configuration and 7 | // behavior that modifies Cypress. 8 | // 9 | // You can change the location of this file or turn off 10 | // automatically serving support files with the 11 | // 'supportFile' configuration option. 12 | // 13 | // You can read more here: 14 | // https://on.cypress.io/configuration 15 | // *********************************************************** 16 | 17 | // Import commands.js using ES2015 syntax: 18 | import './commands'; 19 | 20 | // Alternatively you can use CommonJS syntax: 21 | // require('./commands') 22 | 23 | beforeEach(function () { 24 | // Clear the canvas 25 | cy.visit('http://localhost:9000'); 26 | cy.get('.typester-content-editable').then($el => $el.html('')); 27 | }); 28 | -------------------------------------------------------------------------------- /test/server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 60 | 61 | 62 | 63 |

64 |

65 | Typester test server 66 | 67 |

68 | 72 |
73 | 74 |
75 |
76 |

77 | Quisque velit nisi, pretium ut lacinia in, elementum id enim. Donec rutrum congue leo eget malesuada. Cras ultricies ligula sed magna dictum porta. Quisque velit nisi, pretium ut lacinia in, elementum id enim. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. 78 |

79 |

80 | Sed porttitor lectus nibh. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Pellentesque in ipsum id orci porta dapibus. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Donec velit neque, auctor sit amet aliquam vel, ullamcorper sit amet ligula. 81 |

82 |

83 | Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. Mauris blandit aliquet elit, eget tincidunt nibh pulvinar a. Vestibulum ac diam sit amet quam vehicula elementum sed sit amet dui. Vivamus suscipit tortor eget felis porttitor volutpat. Lorem ipsum dolor sit amet, consectetur adipiscing elit. 84 |

85 |

86 | 87 | Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Cras ultricies ligula sed magna dictum porta. Donec sollicitudin molestie malesuada. Pellentesque in ipsum id orci porta dapibus. Praesent sapien massa, convallis a pellentesque nec, egestas non nisi. 88 | 89 |

90 |

91 | Nulla quis lorem ut libero malesuada feugiat. Donec sollicitudin molestie malesuada. Curabitur arcu erat, accumsan id imperdiet et, porttitor at sem. Sed porttitor lectus nibh. Donec sollicitudin molestie malesuada. 92 |

93 |
    94 |
  • 95 | Sed porttitor lectus nibh. 96 |
  • 97 |
  • 98 | Donec rutrum congue leo eget malesuada. 99 |
  • 100 |
  • 101 | Curabitur non nulla sit amet nisl tempus convallis quis ac lectus. 102 |
  • 103 |
  • 104 | Vivamus suscipit tortor eget felis porttitor volutpat. 105 |
  • 106 |
  • 107 | Donec sollicitudin molestie malesuada. 108 |
  • 109 |
110 |
111 | 112 |
113 | 114 |
115 | 116 | 117 | 118 | 139 | 140 | 245 | 246 | 247 | -------------------------------------------------------------------------------- /test/unit/core/Container.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Container from '../../../src/scripts/core/Container.js'; 4 | 5 | describe('core/Container', () => { 6 | let container, moduleInstances, ContainerClass; 7 | 8 | const mockModule = function (id) { 9 | return function (opts) { 10 | const requestResponse = `Request response module${id}`; 11 | 12 | moduleInstances[`module${id}`] = this; 13 | opts.mediator.registerHandler('request', `request:module${id}`, () => { 14 | return requestResponse; 15 | }); 16 | 17 | Object.assign(this, { 18 | name: `module${id}`, 19 | getMediator () { 20 | return opts.mediator; 21 | }, 22 | doRequest (id) { 23 | return opts.mediator.request(`request:module${id}`); 24 | }, 25 | getRequestResponse () { 26 | return requestResponse; 27 | } 28 | }); 29 | }; 30 | }; 31 | 32 | const mockContainerObj = function (id) { 33 | return { 34 | name: `Container-${id}`, 35 | modules: [], 36 | containers: [], 37 | init () {} 38 | }; 39 | }; 40 | 41 | beforeEach(() => { 42 | let ModuleA, ModuleB, containerObj, nestedContainerObj, NestedModule; 43 | 44 | moduleInstances = {}; 45 | ModuleA = mockModule('A'); 46 | ModuleB = mockModule('B'); 47 | containerObj = mockContainerObj('A'); 48 | 49 | nestedContainerObj = mockContainerObj('Nested'); 50 | NestedModule = mockModule('Nested'); 51 | 52 | containerObj.modules.push({ 53 | class: ModuleA, 54 | opts: { 55 | optA: true 56 | } 57 | }); 58 | containerObj.modules.push({ 59 | class: ModuleB, 60 | opts: { 61 | optB: true 62 | } 63 | }); 64 | containerObj.containers.push({ 65 | class: Container(nestedContainerObj), 66 | opts: { 67 | 68 | } 69 | }); 70 | nestedContainerObj.modules.push({ 71 | class: NestedModule, 72 | opts: { 73 | optNested: true 74 | } 75 | }); 76 | 77 | ContainerClass = Container(containerObj); 78 | container = new ContainerClass(); 79 | }); 80 | 81 | xit('should be an instance of its constructor', () => { 82 | expect(container instanceof ContainerClass).toBe(true); 83 | }); 84 | 85 | it('should instantiate its modules', () => { 86 | expect(moduleInstances.moduleA).toBeDefined(); 87 | expect(moduleInstances.moduleB).toBeDefined(); 88 | }); 89 | 90 | it('should instantiate a mediator and share it with its modules', () => { 91 | const moduleAMediator = moduleInstances.moduleA.getMediator(); 92 | const moduleBMediator = moduleInstances.moduleB.getMediator(); 93 | 94 | expect(moduleAMediator).toBe(moduleBMediator); 95 | expect(typeof moduleAMediator.request).toBe('function'); 96 | expect(typeof moduleAMediator.exec).toBe('function'); 97 | expect(typeof moduleAMediator.emit).toBe('function'); 98 | }); 99 | 100 | it('should facilitate cross module communication through its shared mediator', () => { 101 | const moduleARequestResponse = moduleInstances.moduleA.doRequest('B'); 102 | const moduleBRequestResponse = moduleInstances.moduleB.getRequestResponse(); 103 | 104 | expect(moduleARequestResponse).toBe(moduleBRequestResponse); 105 | }); 106 | 107 | it('should instantiate nested containers', () => { 108 | expect(moduleInstances.moduleNested).toBeDefined(); 109 | }); 110 | 111 | it('should facilityate cross module communication across the tree of nested containers', () => { 112 | const moduleARequestResponse = moduleInstances.moduleA.doRequest('Nested'); 113 | const moduleNestedRequestResponse = moduleInstances.moduleNested.getRequestResponse(); 114 | 115 | expect(moduleARequestResponse).toBe(moduleNestedRequestResponse); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/unit/core/Context.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Context from '../../../src/scripts/core/Context'; 4 | 5 | describe('core/Context', () => { 6 | let methodA, methodB; 7 | let contextA, contextB; 8 | let newContext; 9 | 10 | beforeEach(() => { 11 | methodA = function () { 12 | return 'methodA'; 13 | }; 14 | methodB = function () { 15 | return 'methodB'; 16 | }; 17 | contextA = { 18 | keyA: 'valA', 19 | methodA 20 | }; 21 | contextB = { 22 | keyB: 'valB', 23 | methodB 24 | }; 25 | newContext = new Context(contextA, contextB); 26 | }); 27 | 28 | it('should create a new context from provided contexts', () => { 29 | expect(newContext.keyA).toBe(contextA.keyA); 30 | expect(newContext.keyB).toBe(contextB.keyB); 31 | expect(newContext.methodA()).toBe(contextA.methodA()); 32 | expect(newContext.methodB()).toBe(contextB.methodB()); 33 | }); 34 | 35 | it('should be extendable', () => { 36 | const methodC = function () { 37 | return 'methodC'; 38 | }; 39 | const contextC = { 40 | keyC: 'valC', 41 | methodC 42 | }; 43 | const contextD = { 44 | keyD: 'valD', 45 | omittedKey: 'omittedVal' 46 | }; 47 | 48 | newContext.extendWith(contextC); 49 | newContext.extendWith(contextD, {keys: ['keyD']}); 50 | 51 | expect(newContext.keyC).toBe(contextC.keyC); 52 | expect(newContext.methodC()).toBe(contextC.methodC()); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/unit/core/Mediator.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator.js'; 4 | 5 | describe('core/Mediator', () => { 6 | let mediator; 7 | let requestHandlers, commandHandlers, eventHandlers, eventHandlers2; 8 | let requestResponse = 'Test request response'; 9 | 10 | beforeEach(() => { 11 | mediator = new Mediator(); 12 | 13 | requestHandlers = { 14 | testRequest: (suffix) => { 15 | return requestResponse + ':' + suffix; 16 | } 17 | }; 18 | commandHandlers = { 19 | testCommand: () => { 20 | return null; 21 | } 22 | }; 23 | eventHandlers = { 24 | testEvent: () => { 25 | return null; 26 | } 27 | }; 28 | eventHandlers2 = { 29 | testEvent: () => { 30 | return null; 31 | } 32 | }; 33 | 34 | spyOn(requestHandlers, 'testRequest').and.callThrough(); 35 | spyOn(commandHandlers, 'testCommand').and.callThrough(); 36 | spyOn(eventHandlers, 'testEvent').and.callThrough(); 37 | spyOn(eventHandlers2, 'testEvent').and.callThrough(); 38 | }); 39 | 40 | it("should be defined", () => { 41 | expect(mediator).toBeDefined(); 42 | }); 43 | 44 | it("should handle request registrations", () => { 45 | mediator.registerRequestHandlers(requestHandlers); 46 | let mediatorResponse = mediator.request('testRequest', 'ping'); 47 | expect(requestHandlers.testRequest).toHaveBeenCalled(); 48 | expect(mediatorResponse).toEqual(requestResponse + ':ping'); 49 | }); 50 | 51 | it("should block duplicate request registrations", () => { 52 | mediator.registerRequestHandlers(requestHandlers); 53 | expect(() => { 54 | mediator.registerRequestHandlers(requestHandlers); 55 | }).toThrowError(); 56 | }); 57 | 58 | it("should handle command registrations", () => { 59 | mediator.registerCommandHandlers(commandHandlers); 60 | mediator.exec('testCommand'); 61 | expect(commandHandlers.testCommand).toHaveBeenCalled(); 62 | }); 63 | 64 | it("should block duplicate command registrations", () => { 65 | mediator.registerCommandHandlers(commandHandlers); 66 | expect(() => { 67 | mediator.registerCommandHandlers(commandHandlers); 68 | }).toThrowError(); 69 | }); 70 | 71 | it("should handle event registrations", () => { 72 | mediator.registerEventHandlers(eventHandlers); 73 | mediator.registerEventHandlers(eventHandlers2); 74 | mediator.emit('testEvent'); 75 | expect(eventHandlers.testEvent).toHaveBeenCalled(); 76 | expect(eventHandlers2.testEvent).toHaveBeenCalled(); 77 | }); 78 | 79 | it("should allow for children mediators", () => { 80 | 81 | }); 82 | 83 | it("should allow for a parent mediator", () => { 84 | 85 | }); 86 | 87 | it("should delegate to parent if no handlers found", () => { 88 | 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /test/unit/core/Module.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | import Mediator from '../../../src/scripts/core/Mediator.js'; 3 | import Module from '../../../src/scripts/core/Module.js'; 4 | 5 | import { loadFixtures } from '../helpers/fixtures.js'; 6 | 7 | describe('core/Module', () => { 8 | let mediator, ModuleClass, module; 9 | let requestHandlers, commandHandlers, eventHandlers; 10 | let moduleDefinition, moduleMethods, instanceProps; 11 | let domCache, $editableEl; 12 | let moduleRequestResponse = 'Module request response'; 13 | 14 | beforeEach(() => { 15 | loadFixtures(); 16 | 17 | requestHandlers = { 18 | moduleRequestHandler () { 19 | return this.moduleMethod(); 20 | } 21 | }; 22 | commandHandlers = { 23 | moduleCommandHandler () { 24 | return null; 25 | } 26 | }; 27 | eventHandlers = { 28 | moduleEventHandler () { 29 | return null; 30 | } 31 | }; 32 | moduleMethods = { 33 | init () { 34 | const { dom, props } = this; 35 | domCache = dom; 36 | instanceProps = props; 37 | }, 38 | moduleMethod () { 39 | return moduleRequestResponse; 40 | }, 41 | domEventHandler () {} 42 | }; 43 | 44 | spyOn(requestHandlers, 'moduleRequestHandler').and.callThrough(); 45 | spyOn(commandHandlers, 'moduleCommandHandler').and.callThrough(); 46 | spyOn(eventHandlers, 'moduleEventHandler').and.callThrough(); 47 | spyOn(moduleMethods, 'moduleMethod').and.callThrough(); 48 | spyOn(moduleMethods, 'init').and.callThrough(); 49 | spyOn(moduleMethods, 'domEventHandler').and.callThrough(); 50 | 51 | Object.assign(moduleMethods, requestHandlers, commandHandlers, eventHandlers); 52 | 53 | moduleDefinition = { 54 | name: 'testModule', 55 | props: { 56 | moduleProp: null 57 | }, 58 | dom: { 59 | 'editableEl' : '.content-editable' 60 | }, 61 | handlers: { 62 | requests: { 63 | 'module:request' : 'moduleRequestHandler' 64 | }, 65 | commands: { 66 | 'module:command' : 'moduleCommandHandler' 67 | }, 68 | events: { 69 | 'module:event' : 'moduleEventHandler' 70 | }, 71 | domEvents: { 72 | 'click @editableEl' : 'domEventHandler' 73 | } 74 | }, 75 | methods: moduleMethods 76 | }; 77 | 78 | ModuleClass = Module(moduleDefinition); 79 | mediator = new Mediator(); 80 | module = new ModuleClass({ 81 | mediator, 82 | props: { 83 | moduleProp: true 84 | } 85 | }); 86 | 87 | $editableEl = jQuery('.content-editable'); 88 | }); 89 | 90 | it('should create a module constructor', () => { 91 | expect(ModuleClass).toBeDefined(); 92 | }); 93 | 94 | it('should call the defined init method', () => { 95 | expect(moduleMethods.init).toHaveBeenCalled(); 96 | }); 97 | 98 | it('should register request handlers with the mediator', () => { 99 | let mediatorResponse = mediator.request('module:request'); 100 | expect(requestHandlers.moduleRequestHandler).toHaveBeenCalled(); 101 | expect(mediatorResponse).toEqual(moduleRequestResponse); 102 | }); 103 | 104 | it('should register command handlers with the mediator', () => { 105 | mediator.exec('module:command'); 106 | expect(commandHandlers.moduleCommandHandler).toHaveBeenCalled(); 107 | }); 108 | 109 | it('should register event handlers with the mediator', () => { 110 | mediator.emit('module:event'); 111 | expect(eventHandlers.moduleEventHandler).toHaveBeenCalled(); 112 | }); 113 | 114 | it('should find and cache DOM elements', () => { 115 | expect(domCache).toBeDefined(); 116 | expect(domCache.editableEl[0]).toBe($editableEl[0]); 117 | }); 118 | 119 | it('should biind dom event handlers to methods', () => { 120 | $editableEl.trigger('click'); 121 | expect(moduleMethods.domEventHandler).toHaveBeenCalled(); 122 | }); 123 | 124 | it('should merge props', () => { 125 | expect(instanceProps.moduleProp).toBe(true); 126 | }); 127 | 128 | it('should ensure require props are provided', () => { 129 | const ErrorModule = Module({ 130 | name: 'ErrorModule', 131 | requiredProps: ['requiredProp'], 132 | props: { 133 | requiredProp: null 134 | } 135 | }); 136 | 137 | const newErrorModule = function () { 138 | new ErrorModule({ mediator }); 139 | }; 140 | 141 | expect(newErrorModule).toThrowError('ErrorModule requires prop: requiredProp'); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /test/unit/e2e/line.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import { e2eSetup, e2eCleanOutput, e2eClickToolbarButton } from '../helpers/e2eSetup'; 4 | import e2eContent from '../helpers/e2eSampleContent'; 5 | import selectionHelper from '../helpers/selection'; 6 | import { loadFixtures } from '../helpers/fixtures.js'; 7 | 8 | import toolbarConfig from '../../../src/scripts/config/toolbar'; 9 | 10 | describe('e2e/line', function () { 11 | let mediator, editableEl, buttonConfigs; 12 | let { input, output } = e2eContent; 13 | 14 | beforeEach((done) => { 15 | loadFixtures(); 16 | 17 | const setupComponents = e2eSetup(); 18 | mediator = setupComponents.mediator; 19 | editableEl = setupComponents.editableEl; 20 | 21 | buttonConfigs = toolbarConfig.buttonConfigs; 22 | 23 | setTimeout(done, 250); 24 | }); 25 | 26 | afterEach(() => { 27 | mediator.emit('app:destroy'); 28 | mediator = null; 29 | }); 30 | 31 | it('should handle multiple H1 toggling', () => { 32 | let inputContent = input.line; 33 | let outputContent = output.lineH1; 34 | let selectionString; 35 | 36 | editableEl.innerHTML = inputContent; 37 | expect(editableEl.innerHTML).toBe(inputContent); 38 | 39 | selectionHelper.selectAll(editableEl); 40 | e2eClickToolbarButton('h1'); 41 | expect(e2eCleanOutput(editableEl)).toBe(outputContent); 42 | selectionString = selectionHelper.getCurrent().toString(); 43 | expect(selectionString).toBe(editableEl.textContent); 44 | 45 | selectionHelper.selectAll(editableEl); 46 | e2eClickToolbarButton('h1'); 47 | expect(e2eCleanOutput(editableEl)).toBe(inputContent); 48 | selectionString = selectionHelper.getCurrent().toString(); 49 | expect(selectionString).toBe(editableEl.textContent); 50 | 51 | selectionHelper.selectAll(editableEl); 52 | e2eClickToolbarButton('h1'); 53 | expect(e2eCleanOutput(editableEl)).toBe(outputContent); 54 | selectionString = selectionHelper.getCurrent().toString(); 55 | expect(selectionString).toBe(editableEl.textContent); 56 | }); 57 | 58 | it('should handle multiple H2 toggling', () => { 59 | let inputContent = input.line; 60 | let outputContent = output.lineH2; 61 | let selectionString; 62 | 63 | editableEl.innerHTML = inputContent; 64 | expect(editableEl.innerHTML).toBe(inputContent); 65 | 66 | // selectionHelper.selectAll(editableEl); 67 | // e2eClickToolbarButton('h2'); 68 | // expect(e2eCleanOutput(editableEl)).toBe(outputContent); 69 | // selectionString = selectionHelper.getCurrent().toString(); 70 | // expect(selectionString).toBe(editableEl.textContent); 71 | // 72 | // selectionHelper.selectAll(editableEl); 73 | // e2eClickToolbarButton('h2'); 74 | // expect(e2eCleanOutput(editableEl)).toBe(inputContent); 75 | // selectionString = selectionHelper.getCurrent().toString(); 76 | // expect(selectionString).toBe(editableEl.textContent); 77 | // 78 | // selectionHelper.selectAll(editableEl); 79 | // e2eClickToolbarButton('h2'); 80 | // expect(e2eCleanOutput(editableEl)).toBe(outputContent); 81 | // selectionString = selectionHelper.getCurrent().toString(); 82 | // expect(selectionString).toBe(editableEl.textContent); 83 | }); 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /test/unit/fixtures/index.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /test/unit/helpers/e2eSetup.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | 5 | import UIContainer from '../../../src/scripts/containers/UIContainer'; 6 | import FormatterContainer from '../../../src/scripts/containers/FormatterContainer'; 7 | import CanvasContainer from '../../../src/scripts/containers/CanvasContainer'; 8 | 9 | import ContentEditable from '../../../src/scripts/modules/ContentEditable'; 10 | import Selection from '../../../src/scripts/modules/Selection'; 11 | import Config from '../../../src/scripts/modules/Config'; 12 | 13 | import mockEvents from './mockEvents'; 14 | 15 | const e2eSetup = function () { 16 | let $editableEl = jQuery('.content-editable'); 17 | let editableEl = $editableEl[0]; 18 | let mediator = new Mediator(); 19 | 20 | new ContentEditable({ 21 | mediator, 22 | dom: { el: editableEl } 23 | }); 24 | new Selection({ 25 | mediator, 26 | dom: { el: editableEl } 27 | }); 28 | new Config({ mediator }); 29 | 30 | new FormatterContainer({ mediator }); 31 | new UIContainer({ mediator }); 32 | new CanvasContainer({ mediator }); 33 | 34 | return { 35 | mediator, 36 | editableEl 37 | }; 38 | }; 39 | 40 | const e2eCleanOutput = function (editableEl) { 41 | let cleanOutput = ''; 42 | 43 | if (!/\w+/.test(editableEl.firstChild.textContent)) { 44 | editableEl.removeChild(editableEl.firstChild); 45 | } 46 | if (!/\w+/.test(editableEl.lastChild.textContent)) { 47 | editableEl.removeChild(editableEl.lastChild); 48 | } 49 | 50 | cleanOutput = editableEl.innerHTML; 51 | 52 | // cleanOutput.match(/<(.*?)>/gi).forEach((tag) => { 53 | // cleanOutput = cleanOutput.replace(tag, tag.toLowerCase()); 54 | // }); 55 | 56 | return cleanOutput; 57 | }; 58 | 59 | const e2eClickToolbarButton = function (configKey) { 60 | jQuery('.typester-toolbar .typester-menu-item[data-config-key="' + configKey + '"]')[0].click(); 61 | }; 62 | 63 | const e2eSubmitInputForm = function (userInputValue) { 64 | const $form = jQuery('.typester-input-form'); 65 | const $userInput = $form.find('.user-input'); 66 | $userInput.val(userInputValue); 67 | mockEvents.submit($form[0]); 68 | }; 69 | 70 | const e2eFirstTextNode = function (rootElem) { 71 | let firstTextNode = rootElem.firstChild; 72 | while (firstTextNode.nodeType !== Node.TEXT_NODE) { 73 | firstTextNode = firstTextNode.firstChild; 74 | } 75 | return firstTextNode; 76 | }; 77 | 78 | export default e2eSetup; 79 | export { 80 | e2eSetup, 81 | e2eCleanOutput, 82 | e2eClickToolbarButton, 83 | e2eSubmitInputForm, 84 | e2eFirstTextNode 85 | }; 86 | -------------------------------------------------------------------------------- /test/unit/helpers/fixtures.js: -------------------------------------------------------------------------------- 1 | export const loadFixtures = function () { 2 | document.body.innerHTML = ''; 3 | jQuery("
").appendTo(document.body); 4 | }; 5 | 6 | export default { 7 | loadFixtures 8 | }; -------------------------------------------------------------------------------- /test/unit/helpers/formatterSetup.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | import BaseFormatter from '../../../src/scripts/modules/BaseFormatter'; 5 | import Selection from '../../../src/scripts/modules/Selection'; 6 | import ContentEditable from '../../../src/scripts/modules/ContentEditable'; 7 | import CanvasContainer from '../../../src/scripts/containers/CanvasContainer'; 8 | import Config from '../../../src/scripts/modules/Config'; 9 | import Commands from '../../../src/scripts/modules/Commands'; 10 | 11 | const formatterSetup = function (Formatter, opts={}) { 12 | let $editableEl = jQuery('.content-editable'); 13 | let editableEl = $editableEl[0]; 14 | 15 | let mediator = new Mediator(); 16 | 17 | new Selection({ mediator, dom: {el: editableEl}}); 18 | new Config({ mediator }); 19 | new Commands({ mediator }); 20 | new ContentEditable({ mediator, dom: {el: editableEl}}); 21 | new CanvasContainer({ mediator }); 22 | if (!opts.skipBaseFormatter) { 23 | new BaseFormatter({ mediator }); 24 | } 25 | 26 | new Formatter({ mediator }); 27 | 28 | return { 29 | editableEl, 30 | mediator 31 | }; 32 | }; 33 | 34 | export default formatterSetup; 35 | -------------------------------------------------------------------------------- /test/unit/helpers/mockEvents.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import keycodes from '../../../src/scripts/utils/keycodes'; 4 | 5 | (function () { 6 | if (typeof window.CustomEvent === "function") { 7 | return false; //If not IE 8 | } 9 | 10 | function CustomEvent(event, params) { 11 | var evt; 12 | params = params || { bubbles: true, cancelable: true, detail: undefined }; 13 | evt = document.createEvent('CustomEvent'); 14 | evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail); 15 | return evt; 16 | } 17 | 18 | CustomEvent.prototype = window.Event.prototype; 19 | 20 | window.CustomEvent = CustomEvent; 21 | })(); 22 | 23 | const mockEvents = { 24 | keyup (key, eventTarget=document) { 25 | let event = new CustomEvent('keyup'); 26 | event.keyCode = keycodes[key]; 27 | eventTarget.dispatchEvent(event); 28 | }, 29 | 30 | focus (eventTarget) { 31 | let event = new CustomEvent('focus'); 32 | eventTarget.dispatchEvent(event); 33 | }, 34 | 35 | click (eventTarget) { 36 | let event = new CustomEvent('click', { bubbles: true }); 37 | eventTarget.dispatchEvent(event); 38 | }, 39 | 40 | emit (eventTarget, eventStr) { 41 | let event = new CustomEvent(eventStr, { bubbles: true }); 42 | eventTarget.dispatchEvent(event); 43 | }, 44 | 45 | submit (eventTarget) { 46 | let event = new CustomEvent('submit', { bubbles: true }); 47 | eventTarget.dispatchEvent(event); 48 | } 49 | }; 50 | 51 | export default mockEvents; 52 | -------------------------------------------------------------------------------- /test/unit/helpers/selection.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | import mockEvents from './mockEvents'; 3 | 4 | const selectionHelper = { 5 | selectAll (elem) { 6 | const contextDocument = elem.ownerDocument; 7 | const range = contextDocument.createRange(); 8 | 9 | range.selectNodeContents(elem); 10 | 11 | selectionHelper.setSelectionRange(range); 12 | }, 13 | 14 | selectTextPortion (textNode, start, end) { 15 | const contextDocument = textNode.ownerDocument; 16 | const range = contextDocument.createRange(); 17 | 18 | range.setStart(textNode, start); 19 | range.setEnd(textNode, end); 20 | 21 | selectionHelper.setSelectionRange(range); 22 | }, 23 | 24 | selectFromTo (startNode, startOffset, endNode, endOffset) { 25 | const contextDocument = startNode.ownerDocument; 26 | const range = contextDocument.createRange(); 27 | 28 | range.setStart(startNode, startOffset); 29 | range.setEnd(endNode, endOffset); 30 | 31 | selectionHelper.setSelectionRange(range); 32 | }, 33 | 34 | selectFirstAndLastTextNodes (rootElem) { 35 | const rootDoc = rootElem.ownerDocument; 36 | const walker = rootDoc.createTreeWalker( 37 | rootElem, 38 | NodeFilter.SHOW_TEXT, 39 | null, 40 | false 41 | ); 42 | 43 | let textNodes = []; 44 | while(walker.nextNode()) { 45 | textNodes.push(walker.currentNode); 46 | } 47 | const firstTextNode = textNodes.shift(); 48 | const lastTextNode = textNodes.length ? textNodes.pop() : firstTextNode; 49 | 50 | selectionHelper.selectFromTo(firstTextNode, 0, lastTextNode, lastTextNode.textContent.length); 51 | }, 52 | 53 | getCurrent (contextDocument=document) { 54 | return contextDocument.getSelection(); 55 | }, 56 | 57 | setSelectionRange (range) { 58 | const contextDocument = range.startContainer.ownerDocument; 59 | const contextWindow = contextDocument.defaultView || contextDocument.parentWindow; 60 | const windowSelection = contextWindow.getSelection(); 61 | 62 | windowSelection.removeAllRanges(); 63 | windowSelection.addRange(range); 64 | 65 | mockEvents.emit(contextDocument, 'selectionchange'); 66 | }, 67 | 68 | getSelectionRange () { 69 | return selectionHelper.getCurrent().getRangeAt(0); 70 | }, 71 | 72 | selectNone (contextDocument=document) { 73 | const currentSelection = contextDocument.getSelection(); 74 | currentSelection.removeAllRanges(); 75 | mockEvents.emit(contextDocument, 'selectionchange'); 76 | } 77 | }; 78 | 79 | export default selectionHelper; 80 | -------------------------------------------------------------------------------- /test/unit/helpers/userInput.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import selectionHelper from './selection'; 4 | import mockEvents from './mockEvents'; 5 | 6 | const userInputHelper = { 7 | focus (elem) { 8 | elem.focus(); 9 | selectionHelper.selectAll(elem); 10 | mockEvents.focus(elem); 11 | } 12 | }; 13 | 14 | export default userInputHelper; 15 | -------------------------------------------------------------------------------- /test/unit/modules/BaseFormatter.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | import BaseFormatter from '../../../src/scripts/modules/BaseFormatter'; 5 | import zeroWidthSpace from '../../../src/scripts/utils/zeroWidthSpace'; 6 | 7 | import formatterSetup from '../helpers/formatterSetup'; 8 | import userInputHelper from '../helpers/userInput'; 9 | import selectionHelper from '../helpers/selection'; 10 | import { loadFixtures } from '../helpers/fixtures.js'; 11 | 12 | describe('modules/BaseFormatter', function () { 13 | let mediator, editableEl; 14 | 15 | beforeEach((done) => { 16 | loadFixtures(); 17 | 18 | const setupComponents = formatterSetup(BaseFormatter, { 19 | skipBaseFormatter: true 20 | }); 21 | mediator = setupComponents.mediator; 22 | editableEl = setupComponents.editableEl; 23 | 24 | userInputHelper.focus(editableEl); 25 | 26 | setTimeout(done, 250); 27 | }); 28 | 29 | afterEach(() => { 30 | 31 | }); 32 | 33 | it('should copy editable content to the canvas', () => { 34 | editableEl.innerHTML = '

Basic test

'; 35 | const canvasBody = mediator.get('canvas:body'); 36 | selectionHelper.selectAll(editableEl); 37 | mediator.exec('format:export:to:canvas'); 38 | expect(zeroWidthSpace.assert(editableEl.firstChild)).toBe(true); 39 | expect(zeroWidthSpace.assert(editableEl.lastChild)).toBe(true); 40 | editableEl.removeChild(editableEl.firstChild); 41 | editableEl.removeChild(editableEl.lastChild); 42 | expect(canvasBody.innerHTML).toBe(editableEl.innerHTML); 43 | }); 44 | 45 | it('should import content from the canvas to the editable element', () => { 46 | const canvasDoc = mediator.get('canvas:document'); 47 | const canvasBody = mediator.get('canvas:body'); 48 | canvasBody.innerHTML = '

Basic test

'; 49 | canvasBody.contentEditable = true; 50 | selectionHelper.selectAll(canvasBody.firstChild); 51 | mediator.exec('format:import:from:canvas'); 52 | expect(editableEl.innerHTML).toBe(canvasBody.innerHTML); 53 | }); 54 | 55 | it('should clean html on export', () => { 56 | const canvasBody = mediator.get('canvas:body'); 57 | canvasBody.innerHTML = `

Heading copy

58 |

Sub heading copy

59 |
60 |

First paragraph

61 | After first paragraph 62 |

Second paragraph

63 |
64 |
  • List item 1
`; 65 | selectionHelper.selectFromTo(canvasBody.firstChild, 0, canvasBody.firstChild, 1); 66 | mediator.exec('format:import:from:canvas'); 67 | if (!/\w+/.test(canvasBody.firstChild.textContent) && !/\w+/.test(editableEl.firstChild.textContent)) { 68 | canvasBody.removeChild(canvasBody.firstChild); 69 | editableEl.removeChild(editableEl.firstChild); 70 | } 71 | expect(editableEl.innerHTML).toBe('

Heading copy

Sub heading copy

First paragraph

After first paragraph

Second paragraph

  • List item 1
'); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /test/unit/modules/BlockFormatter.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import BlockFormatter from '../../../src/scripts/modules/BlockFormatter'; 4 | import toolbarConfig from '../../../src/scripts/config/toolbar'; 5 | 6 | import formatterSetup from '../helpers/formatterSetup'; 7 | import userInputHelper from '../helpers/userInput'; 8 | import selectionHelper from '../helpers/selection'; 9 | import { loadFixtures } from '../helpers/fixtures.js'; 10 | 11 | describe('modules/BlockFormatter', function () { 12 | let mediator; 13 | let editableEl; 14 | let headerText, buttonConfigs; 15 | 16 | headerText = 'header text'; 17 | 18 | beforeEach((done) => { 19 | loadFixtures(); 20 | 21 | const setupComponents = formatterSetup(BlockFormatter); 22 | editableEl = setupComponents.editableEl; 23 | mediator = setupComponents.mediator; 24 | 25 | buttonConfigs = toolbarConfig.buttonConfigs; 26 | userInputHelper.focus(editableEl); 27 | 28 | setTimeout(done, 250) 29 | }); 30 | 31 | afterEach(() => { 32 | editableEl.innerHTML = ''; 33 | mediator.emit('app:destroy'); 34 | }); 35 | 36 | xit('should default the block if contenteditable triggers newline', () => { 37 | const defaultFormatRegex = /

()?<\/p>/; 38 | selectionHelper.selectAll(editableEl); 39 | mediator.emit('contenteditable:newline'); 40 | 41 | expect(defaultFormatRegex.test(editableEl.innerHTML)).toBe(true); 42 | }); 43 | 44 | it('should not try default if newline is already in a block', () => { 45 | const contentBlock = document.createElement('h1'); 46 | const contentBlockText = 'text inside an existing block'; 47 | 48 | contentBlock.innerHTML = contentBlockText; 49 | editableEl.innerHTML = ''; 50 | editableEl.appendChild(contentBlock); 51 | 52 | selectionHelper.selectAll(contentBlock.childNodes[0]); 53 | mediator.emit('contenteditable:newline'); 54 | 55 | expect(editableEl.innerHTML).toBe(`

${contentBlockText}

`); 56 | }); 57 | 58 | it('should be able to format headers', () => { 59 | editableEl.innerHTML = headerText; 60 | 61 | ['H1', 'H2'/*, 'H3', 'H4', 'H5', 'H6'*/].forEach((headerStyle) => { 62 | const headerTag = headerStyle.toLowerCase(); 63 | selectionHelper.selectAll(editableEl.childNodes[0]); 64 | mediator.exec('format:block', buttonConfigs[headerTag].opts); 65 | expect(editableEl.innerHTML).toBe(`<${headerTag}>${headerText}`); 66 | }); 67 | }); 68 | 69 | it('should clear previous block formatting before performing new block format', () => { 70 | const blockOuter = document.createElement('div'); 71 | const blockInner = document.createElement('div'); 72 | 73 | blockInner.innerHTML = headerText; 74 | blockOuter.appendChild(blockInner); 75 | editableEl.innerHTML = ''; 76 | editableEl.appendChild(blockOuter); 77 | 78 | selectionHelper.selectAll(blockInner); 79 | mediator.exec('format:block', { style: 'P', validTags: ['P'] }); 80 | expect(editableEl.innerHTML).toBe(`

${headerText}

`); 81 | 82 | selectionHelper.selectAll(editableEl.childNodes[0]); 83 | mediator.exec('format:block', buttonConfigs.h1.opts); 84 | expect(editableEl.innerHTML).toBe(`

${headerText}

`); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /test/unit/modules/Canvas.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | import Canvas from '../../../src/scripts/modules/Canvas'; 5 | 6 | import selectionHelper from '../helpers/selection'; 7 | import { loadFixtures } from '../helpers/fixtures.js'; 8 | 9 | describe('modules/Canvas', function () { 10 | let canvas, mediator, iframe; 11 | let $editableEl, editableEl, editableElInnerHTML; 12 | 13 | editableElInnerHTML = '

Test title

Test paragraph, with bold text

'; 14 | 15 | beforeEach((done) => { 16 | loadFixtures(); 17 | 18 | $editableEl = jQuery('.content-editable'); 19 | editableEl = $editableEl[0]; 20 | 21 | editableEl.contentEditable = true; 22 | editableEl.innerHTML = editableElInnerHTML; 23 | 24 | mediator = new Mediator(); 25 | canvas = new Canvas({ mediator }); 26 | iframe = document.getElementsByClassName('typester-canvas'); 27 | 28 | setTimeout(done, 250); 29 | }); 30 | 31 | it('should append an iframe to the document body', () => { 32 | // expect(iframe.length).toBe(1); 33 | expect(iframe.length).toBeGreaterThan(0); 34 | }); 35 | 36 | it('should get the canvas document', () => { 37 | const canvasDoc = mediator.get('canvas:document'); 38 | expect(canvasDoc.body).toBeDefined(); 39 | }); 40 | 41 | it('should get the canvas window', () => { 42 | const canvasWin = mediator.get('canvas:window'); 43 | expect(canvasWin.document).toBeDefined(); 44 | }); 45 | 46 | it('should get the canvas body', () => { 47 | const canvasBody = mediator.get('canvas:body'); 48 | expect(canvasBody.tagName).toBe('BODY'); 49 | }); 50 | 51 | it('should set the canvas body to be editable', () => { 52 | const canvasBody = mediator.get('canvas:body'); 53 | expect(canvasBody.hasAttribute('contenteditable')).toBe(true); 54 | }); 55 | 56 | it('should set the canvas content', () => { 57 | const testContentHTML = '

test content

'; 58 | const canvasDoc = mediator.get('canvas:document'); 59 | mediator.exec('canvas:content', testContentHTML); 60 | expect(canvasDoc.body.innerHTML).toBe(testContentHTML); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/unit/modules/Commands.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | import Commands from '../../../src/scripts/modules/Commands'; 5 | import Config from '../../../src/scripts/modules/Config'; 6 | import selectionHelper from '../helpers/selection'; 7 | import DOM from '../../../src/scripts/utils/DOM'; 8 | import { loadFixtures } from '../helpers/fixtures.js'; 9 | 10 | describe('modules/Commands', function () { 11 | let mediator, $editableEl, editableEl; 12 | 13 | beforeEach((done) => { 14 | loadFixtures(); 15 | 16 | mediator = new Mediator(); 17 | new Commands({ mediator }); 18 | new Config({ mediator }); 19 | 20 | $editableEl = jQuery('.content-editable'); 21 | editableEl = $editableEl[0]; 22 | editableEl.contentEditable = true; 23 | 24 | setTimeout(done, 250); 25 | }); 26 | 27 | it('should execute a given command', () => { 28 | const contentBlock = document.createElement('p'); 29 | const contentBlockText = 'text inside an exisiting block'; 30 | 31 | contentBlock.innerHTML = contentBlockText; 32 | editableEl.innerHTML = ''; 33 | editableEl.appendChild(contentBlock); 34 | 35 | selectionHelper.selectAll(contentBlock.childNodes[0]); 36 | mediator.exec('commands:exec', { 37 | command: 'formatBlock', 38 | value: 'H1' 39 | }); 40 | 41 | expect(editableEl.innerHTML).toBe(`

${contentBlockText}

`); 42 | }); 43 | 44 | it('should execute a default command', () => { 45 | const contentBlock = document.createElement('h1'); 46 | const contentBlockText = 'text inside an exisiting block'; 47 | 48 | contentBlock.innerHTML = contentBlockText; 49 | editableEl.innerHTML = ''; 50 | editableEl.appendChild(contentBlock); 51 | 52 | selectionHelper.selectAll(contentBlock.childNodes[0]); 53 | mediator.exec('commands:format:default'); 54 | 55 | expect(editableEl.innerHTML).toBe(`

${contentBlockText}

`); 56 | }); 57 | 58 | it('should execute a formatBlock command', () => { 59 | const contentBlock = document.createElement('p'); 60 | const contentBlockText = 'text inside an exisiting block'; 61 | 62 | contentBlock.innerHTML = contentBlockText; 63 | editableEl.innerHTML = ''; 64 | editableEl.appendChild(contentBlock); 65 | 66 | selectionHelper.selectAll(contentBlock.childNodes[0]); 67 | mediator.exec('commands:format:block', { 68 | style: 'BLOCKQUOTE' 69 | }); 70 | 71 | 72 | const blockquoteParagraphs = editableEl.querySelectorAll('blockquote p'); 73 | blockquoteParagraphs.forEach((paragraph) => { 74 | DOM.unwrap(paragraph); 75 | }); 76 | 77 | expect(editableEl.innerHTML).toBe(`
${contentBlockText}
`); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/unit/modules/Config.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | import Config from '../../../src/scripts/modules/Config'; 5 | 6 | import toolbarConfig from '../../../src/scripts/config/toolbar'; 7 | 8 | describe('modules/Config', function () { 9 | let mediator; 10 | const toolbarConfigsKeysStrings = function (config, source) { 11 | const configKeysString = JSON.stringify(config.buttons.map((buttonConfig) => buttonConfig.configKey)); 12 | const sourceKeysString = JSON.stringify(source); 13 | return [ 14 | config.buttons.length, 15 | source.length, 16 | configKeysString, 17 | sourceKeysString 18 | ]; 19 | }; 20 | 21 | beforeEach(() => { 22 | mediator = new Mediator(); 23 | }); 24 | 25 | afterEach(() => { 26 | mediator = null; 27 | }); 28 | 29 | it('should return toolbar buttons from default toolbar config', () => { 30 | new Config({ mediator }); 31 | const toolbarButtons = mediator.get('config:toolbar:buttons'); 32 | const [ 33 | configLength, 34 | sourceLength, 35 | configKeysString, 36 | sourceKeysString 37 | ] = toolbarConfigsKeysStrings(toolbarButtons, toolbarConfig.buttons); 38 | 39 | expect(configLength).toBe(sourceLength); 40 | expect(configKeysString).toBe(sourceKeysString); 41 | }); 42 | 43 | it('should return toolbar buttons from given custom config', () => { 44 | const customToolbarConfig = ['bold', 'h1', 'link']; 45 | new Config({ 46 | mediator, 47 | configs: { 48 | toolbar: { 49 | buttons: customToolbarConfig 50 | } 51 | } 52 | }); 53 | const toolbarButtons = mediator.get('config:toolbar:buttons'); 54 | const [ 55 | configLength, 56 | sourceLength, 57 | configKeysString, 58 | sourceKeysString 59 | ] = toolbarConfigsKeysStrings(toolbarButtons, customToolbarConfig); 60 | 61 | expect(configLength).toBe(sourceLength); 62 | expect(configKeysString).toBe(sourceKeysString); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/unit/modules/ContentEditable.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | import ContentEditable from '../../../src/scripts/modules/ContentEditable'; 5 | import mockEvents from '../helpers/mockEvents'; 6 | import { loadFixtures } from '../helpers/fixtures.js'; 7 | 8 | describe('modules/ContentEditable', () => { 9 | let contentEditable, eventHandlers, mediator, $editableEl; 10 | 11 | beforeEach(() => { 12 | loadFixtures(); 13 | mediator = new Mediator(); 14 | 15 | eventHandlers = { 16 | 'contenteditable:focus': function () {}, 17 | 'contenteditable:newline': function () {} 18 | }; 19 | spyOn(eventHandlers, 'contenteditable:focus'); 20 | spyOn(eventHandlers, 'contenteditable:newline'); 21 | 22 | mediator.registerEventHandlers(eventHandlers); 23 | 24 | $editableEl = jQuery('.content-editable'); 25 | 26 | contentEditable = new ContentEditable({ 27 | mediator, 28 | dom: { el: $editableEl[0] } 29 | }); 30 | }); 31 | 32 | it('should ensure that its root element is editable', () => { 33 | expect($editableEl[0].hasAttribute('contenteditable')).toBe(true); 34 | }); 35 | 36 | it('should delegate focus event', () => { 37 | $editableEl.focus(); 38 | mockEvents.focus($editableEl[0]); 39 | expect(eventHandlers['contenteditable:focus']).toHaveBeenCalled(); 40 | }); 41 | 42 | it('should handle and delegate keyup events', (done) => { 43 | mockEvents.keyup('ENTER', $editableEl[0]); 44 | setTimeout(() => { 45 | expect(eventHandlers['contenteditable:newline']).toHaveBeenCalled(); 46 | done(); 47 | }, 120); 48 | }, 200); 49 | 50 | it('should handle and delegate paste events', () => { 51 | 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/unit/modules/Flyout.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator.js'; 4 | import Flyout from '../../../src/scripts/modules/Flyout.js'; 5 | 6 | describe('modules/Flyout', function () { 7 | let mediator; 8 | 9 | beforeEach(() => { 10 | mediator = new Mediator(); 11 | new Flyout({ mediator }); 12 | }); 13 | 14 | afterEach(() => { 15 | mediator.emit('app:destroy'); 16 | mediator = null; 17 | }); 18 | 19 | it('should return a new dom instance of itself', () => { 20 | let flyoutDocInstance, flyout; 21 | 22 | flyout = mediator.get('flyout:new'); 23 | flyout.show(); 24 | flyoutDocInstance = jQuery('.typester-flyout'); 25 | 26 | expect(flyout).toBeDefined(); 27 | expect(flyout.el.nodeType).toBe(Node.ELEMENT_NODE); 28 | expect(flyoutDocInstance.length).toBe(1); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/unit/modules/LinkFormatter.spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typecode/typester/6e81233d4046474105c2d238643fba3f00a1606e/test/unit/modules/LinkFormatter.spec.js -------------------------------------------------------------------------------- /test/unit/modules/ListFormatter.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import ListFormatter from '../../../src/scripts/modules/ListFormatter'; 4 | import toolbarConfig from '../../../src/scripts/config/toolbar'; 5 | 6 | import formatterSetup from '../helpers/formatterSetup'; 7 | import userInputHelper from '../helpers/userInput'; 8 | import selectionHelper from '../helpers/selection'; 9 | import { loadFixtures } from '../helpers/fixtures.js'; 10 | 11 | describe('modules/ListFormatter', function () { 12 | let mediator; 13 | let orderedListOpts, unorderedListOpts; 14 | let editableEl; 15 | 16 | const setEditableElHTML = function () { 17 | const tmpDiv = document.createElement('div'); 18 | for (let i = 0; i < 5; i++) { 19 | let pTag = document.createElement('p'); 20 | pTag.innerHTML = `List item (${i})`; 21 | tmpDiv.appendChild(pTag); 22 | } 23 | editableEl.innerHTML = tmpDiv.innerHTML; 24 | userInputHelper.focus(editableEl); 25 | selectionHelper.selectFirstAndLastTextNodes(editableEl); 26 | }; 27 | 28 | beforeEach((done) => { 29 | loadFixtures(); 30 | 31 | const setupComponents = formatterSetup(ListFormatter); 32 | editableEl = setupComponents.editableEl; 33 | mediator = setupComponents.mediator; 34 | 35 | orderedListOpts = toolbarConfig.buttonConfigs.orderedlist.opts; 36 | unorderedListOpts = toolbarConfig.buttonConfigs.unorderedlist.opts; 37 | 38 | setTimeout(done, 250); 39 | }); 40 | 41 | afterEach(() => { 42 | editableEl.innerHTML = ''; 43 | mediator.emit('app:destroy'); 44 | }); 45 | 46 | it('should toggle ordered lists', () => { 47 | setEditableElHTML(); 48 | expect(editableEl.getElementsByTagName('ol').length).toBe(0); 49 | expect(editableEl.getElementsByTagName('li').length).toBe(0); 50 | 51 | selectionHelper.selectFirstAndLastTextNodes(editableEl); 52 | mediator.exec('format:list', orderedListOpts); 53 | expect(editableEl.getElementsByTagName('ol').length).toBe(1); 54 | expect(editableEl.getElementsByTagName('li').length).toBe(5); 55 | 56 | selectionHelper.selectFirstAndLastTextNodes(editableEl); 57 | mediator.exec('format:list', orderedListOpts); 58 | expect(editableEl.getElementsByTagName('ol').length).toBe(0); 59 | expect(editableEl.getElementsByTagName('li').length).toBe(0); 60 | }); 61 | 62 | it('should toggle unordered lists', () => { 63 | setEditableElHTML(); 64 | expect(editableEl.getElementsByTagName('ul').length).toBe(0); 65 | expect(editableEl.getElementsByTagName('li').length).toBe(0); 66 | 67 | mediator.exec('format:list', unorderedListOpts); 68 | expect(editableEl.getElementsByTagName('ul').length).toBe(1); 69 | expect(editableEl.getElementsByTagName('li').length).toBe(5); 70 | 71 | selectionHelper.selectAll(editableEl.getElementsByTagName('ul')[0]); 72 | mediator.exec('format:list', unorderedListOpts); 73 | expect(editableEl.getElementsByTagName('ul').length).toBe(0); 74 | expect(editableEl.getElementsByTagName('li').length).toBe(0); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/unit/modules/Selection.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator'; 4 | import Selection from '../../../src/scripts/modules/Selection'; 5 | import Config from '../../../src/scripts/modules/Config'; 6 | import selectionHelper from '../helpers/selection'; 7 | import { loadFixtures } from '../helpers/fixtures.js'; 8 | 9 | describe('modules/Selection', () => { 10 | let $editableEl, editableEl, editableElHTML; 11 | let mediator; 12 | 13 | beforeEach(() => { 14 | loadFixtures(); 15 | 16 | $editableEl = jQuery('.content-editable'); 17 | editableEl = $editableEl[0]; 18 | editableElHTML = 'test selection'; 19 | 20 | $editableEl.html(editableElHTML); 21 | $editableEl.attr('contenteditable', true); 22 | $editableEl.focus(); 23 | 24 | mediator = new Mediator(); 25 | new Selection({ mediator, 26 | dom: { 27 | el: editableEl 28 | } 29 | }); 30 | new Config({ mediator }); 31 | }); 32 | 33 | it('should return the current selection', () => { 34 | selectionHelper.selectAll(editableEl); 35 | const currentSelection = mediator.get('selection:current').toString(); 36 | expect(currentSelection).toBe(editableElHTML); 37 | }); 38 | 39 | it('should return the anchorNode of the current selection', () => { 40 | selectionHelper.selectTextPortion(editableEl.childNodes[0], 3, editableEl.childNodes[0].length - 3); 41 | const anchorNode = mediator.get('selection:anchornode'); 42 | expect(anchorNode).toBe(editableEl.childNodes[0]); 43 | }); 44 | 45 | it('should return the root element of the selection', () => { 46 | const firstDiv = document.createElement('div'); 47 | const secondDiv = document.createElement('div'); 48 | const thirdDiv = document.createElement('div'); 49 | 50 | thirdDiv.innerHTML = 'some text'; 51 | secondDiv.appendChild(thirdDiv); 52 | firstDiv.appendChild(secondDiv); 53 | 54 | editableEl.contentEditable = false; 55 | editableEl.innerHTML = ''; 56 | editableEl.appendChild(firstDiv); 57 | editableEl.contentEditable = true; 58 | editableEl.focus(); 59 | 60 | selectionHelper.selectAll(thirdDiv); 61 | 62 | const selectionRootEl = mediator.get('selection:rootelement'); 63 | expect(selectionRootEl).toBe(editableEl); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/unit/modules/TextFormatter.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import TextFormatter from '../../../src/scripts/modules/TextFormatter'; 4 | import toolbarConfig from '../../../src/scripts/config/toolbar'; 5 | 6 | import formatterSetup from '../helpers/formatterSetup'; 7 | import userInputHelper from '../helpers/userInput'; 8 | import selectionHelper from '../helpers/selection'; 9 | import { loadFixtures } from '../helpers/fixtures.js'; 10 | 11 | describe('modules/TextFormatter', function () { 12 | let mediator; 13 | let editableEl; 14 | 15 | beforeEach(function () { 16 | loadFixtures(); 17 | 18 | const setupComponents = formatterSetup(TextFormatter); 19 | editableEl = setupComponents.editableEl; 20 | mediator = setupComponents.mediator; 21 | 22 | userInputHelper.focus(editableEl); 23 | }); 24 | 25 | it('should toggle bold selection', function () { 26 | const textToBold = 'text to bold'; 27 | const pTag = document.createElement('p'); 28 | const config = toolbarConfig.buttonConfigs.bold; 29 | let textToBoldIndex; 30 | let isBold = false; 31 | let unBolded = false; 32 | 33 | pTag.innerHTML = 'Some text to bold!'; 34 | editableEl.innerHTML = ''; 35 | 36 | editableEl.appendChild(pTag); 37 | textToBoldIndex = pTag.innerHTML.indexOf(textToBold); 38 | selectionHelper.selectTextPortion(pTag.childNodes[0], textToBoldIndex, textToBoldIndex + textToBold.length); 39 | 40 | mediator.exec('format:text', config.opts); 41 | isBold = editableEl.innerHTML === '

Some text to bold!

' || editableEl.innerHTML === '

Some text to bold!

'; 42 | expect(isBold).toBe(true); 43 | 44 | selectionHelper.selectAll(pTag.childNodes[1]); 45 | mediator.exec('format:text', config.opts); 46 | unBolded = editableEl.innerHTML === '

Some text to bold!

'; 47 | expect(unBolded).toBe(true); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/unit/modules/Toolbar.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import Mediator from '../../../src/scripts/core/Mediator.js'; 4 | import Toolbar from '../../../src/scripts/modules/Toolbar.js'; 5 | import Selection from '../../../src/scripts/modules/Selection.js'; 6 | import Flyout from '../../../src/scripts/modules/Flyout.js'; 7 | import Config from '../../../src/scripts/modules/Config.js'; 8 | 9 | import mockEvents from '../helpers/mockEvents'; 10 | import selectionHelper from '../helpers/selection'; 11 | import { loadFixtures } from '../helpers/fixtures.js'; 12 | 13 | describe('modules/Toolbar', function () { 14 | let mediator, commands; 15 | let toolbarEl, elStyle, editableEl, flyoutEl; 16 | 17 | beforeEach(() => { 18 | loadFixtures(); 19 | 20 | editableEl = document.getElementsByClassName('content-editable')[0]; 21 | 22 | mediator = new Mediator(); 23 | commands = { 24 | 'format:block' : () => {} 25 | }; 26 | spyOn(commands, 'format:block').and.callThrough(); 27 | mediator.registerCommandHandlers(commands); 28 | 29 | new Selection({ mediator, 30 | dom: { el: editableEl }, 31 | props: { contextDocument: document } 32 | }); 33 | new Config({ mediator }); 34 | new Flyout({ mediator }); 35 | new Toolbar({ mediator, opts: { 36 | dom: { 37 | el: document.body 38 | } 39 | }}); 40 | 41 | flyoutEl = document.getElementsByClassName('typester-flyout'); 42 | toolbarEl = document.getElementsByClassName('typester-toolbar'); 43 | 44 | editableEl.innerHTML = '

Test text

'; 45 | editableEl.contentEditable = true; 46 | 47 | selectionHelper.selectAll(editableEl.childNodes[0]); 48 | 49 | jasmine.clock().install(); 50 | }); 51 | 52 | afterEach(() => { 53 | mediator.emit('app:destroy'); 54 | mediator = null; 55 | jasmine.clock().uninstall(); 56 | }); 57 | 58 | it('should append styles', () => { 59 | const stylesEl = document.getElementById('typester-styles'); 60 | expect(stylesEl).toBeDefined(); 61 | }); 62 | 63 | it('should inject its template', () => { 64 | expect(flyoutEl.length).toBe(1); 65 | expect(toolbarEl.length).toBe(1); 66 | selectionHelper.selectNone(); 67 | mediator.emit('selection:change'); 68 | expect(flyoutEl[0].style.display).toBe('none'); 69 | }); 70 | 71 | it('should handle toolbar clicks', () => { 72 | const menuItems = document.getElementsByClassName('typester-menu-item'); 73 | selectionHelper.selectAll(editableEl.childNodes[0]); 74 | 75 | mockEvents.click(menuItems[2]); 76 | expect(commands['format:block']).toHaveBeenCalledWith({ style: 'H1', validTags: ['H1'], toggle: false }); 77 | mockEvents.click(menuItems[3]); 78 | expect(commands['format:block']).toHaveBeenCalledWith({ style: 'H2', validTags: ['H2'], toggle: false }); 79 | }); 80 | 81 | it('should show when a range has been selected', () => { 82 | editableEl.focus(); 83 | selectionHelper.selectAll(editableEl.childNodes[0]); 84 | mediator.emit('selection:change'); 85 | jasmine.clock().tick(100); 86 | expect(flyoutEl[0].style.display).toBe('block'); 87 | }); 88 | 89 | it('should handle document selectstart', () => { 90 | // NB expect('test').toBe('written'); 91 | }); 92 | 93 | it('should handle document selectionchange', () => { 94 | // NB expect('test').toBe('written'); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /test/unit/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | case "$1" in 4 | 5 | test) 6 | xvfb-run -l npm run test 2> /dev/null || : 7 | ;; 8 | 9 | test_ci) 10 | xvfb-run -l npm run test_ci 11 | ;; 12 | 13 | *) 14 | echo $"Usage: $0 {test|test_ci}" 15 | exit 1 16 | esac -------------------------------------------------------------------------------- /test/unit/setup.js: -------------------------------------------------------------------------------- 1 | import '../../src/scripts/polyfills'; 2 | -------------------------------------------------------------------------------- /test/unit/support/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "spec", 3 | "spec_files": [ 4 | "**/*[sS]pec.js" 5 | ], 6 | "helpers": [ 7 | "helpers/**/*.js" 8 | ], 9 | "stopSpecOnExpectationFailure": false, 10 | "random": false 11 | } 12 | -------------------------------------------------------------------------------- /test/unit/tmp/insertHTML.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | xdescribe('insertHTML', function () { 4 | let editableDiv; 5 | 6 | beforeEach(() => { 7 | editableDiv = document.createElement('div'); 8 | document.body.appendChild(editableDiv); 9 | editableDiv.contentEditable = true; 10 | editableDiv.focus(); 11 | }); 12 | 13 | it('should detect if enabled', () => { 14 | expect(document.activeElement).toBe(editableDiv); 15 | expect(document.execCommand('insertHTML', null, '

123

')).toBe(true); 16 | expect(editableDiv.innerHTML).toBe('

123

'); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/unit/utils/func.spec.js: -------------------------------------------------------------------------------- 1 | // jshint strict: false 2 | 3 | import func from '../../../src/scripts/utils/func'; 4 | 5 | describe('utils/func', () => { 6 | let testFunc, testContext; 7 | 8 | beforeEach(() => { 9 | testFunc = function () { 10 | return this; 11 | }; 12 | testContext = { 13 | context: 'test' 14 | }; 15 | }); 16 | 17 | it('should bind a function to a context', function () { 18 | const boundFunc = func.bind(testFunc, testContext); 19 | expect(boundFunc()).toBe(testContext); 20 | }); 21 | 22 | it('should bind an object of functions to a context', function () { 23 | const boundFuncObj = func.bindObj({testFunc}, testContext); 24 | expect(boundFuncObj.testFunc()).toBe(testContext); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/unit/utils/guid.spec.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/typecode/typester/6e81233d4046474105c2d238643fba3f00a1606e/test/unit/utils/guid.spec.js -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | const path = require('path'); 4 | const webpack = require('webpack'); 5 | 6 | module.exports = { 7 | mode: process.env.BUILD || "development", // "production" | "development" | "none" 8 | 9 | entry: './src/scripts/index.js', 10 | 11 | output: { 12 | path: path.resolve(__dirname, 'build/js/'), 13 | filename: process.env.BUILD === 'production' ? 'typester.min.js' : 'typester.js', 14 | library: 'Typester', 15 | libraryExport: "default", 16 | libraryTarget: 'umd' 17 | }, 18 | 19 | resolve: { 20 | extensions: ['.js', '.html'] 21 | }, 22 | 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.js/, 27 | loader: 'babel-loader' 28 | }, 29 | { 30 | test: /\.html/, 31 | loader: 'handlebars-loader' 32 | }, 33 | { 34 | test: /\.scss/, 35 | use: [ 36 | 'style-loader', 37 | 'css-loader', 38 | 'sass-loader' 39 | ] 40 | } 41 | ] 42 | }, 43 | 44 | plugins: [ 45 | new webpack.ProvidePlugin({ 46 | Typester: ['Typester', 'default'] 47 | }) 48 | ], 49 | 50 | devServer: { 51 | contentBase: [path.resolve(__dirname, 'build/'), path.resolve(__dirname, 'test/server/')], 52 | host: '0.0.0.0', 53 | port: 9000, 54 | disableHostCheck: true, 55 | index: 'index.html', 56 | publicPath: '/js/' 57 | } 58 | } --------------------------------------------------------------------------------