├── .editorconfig ├── .firebaserc ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── docs.md │ └── feature.md └── pull_request_template.md ├── .gitignore ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── firebase.json ├── package-lock.json ├── package.json ├── scripts └── copy.site.js ├── site ├── assets │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── favicon.svg │ ├── mogwai.jpg │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ ├── safari-pinned-tab.svg │ └── social-image.jpg ├── robots.txt ├── site.webmanifest └── sitemap.xml ├── src ├── assets │ └── i18n │ │ ├── de.ts │ │ ├── en.ts │ │ ├── es.ts │ │ ├── fr.ts │ │ ├── ja.ts │ │ ├── nl.ts │ │ └── zh-cn.ts ├── components.d.ts ├── components │ ├── color │ │ ├── color │ │ │ ├── color.scss │ │ │ ├── color.tsx │ │ │ └── readme.md │ │ └── input │ │ │ ├── input.scss │ │ │ ├── input.tsx │ │ │ └── readme.md │ ├── editor │ │ ├── editor.e2e.ts │ │ ├── editor.scss │ │ ├── editor.spec.ts │ │ ├── editor.tsx │ │ └── readme.md │ ├── icons │ │ ├── add.tsx │ │ ├── align-center.tsx │ │ ├── align-left.tsx │ │ ├── align-right.tsx │ │ ├── blockquote.tsx │ │ ├── code.tsx │ │ ├── color.tsx │ │ ├── image.tsx │ │ ├── link.tsx │ │ ├── more.tsx │ │ ├── ol.tsx │ │ ├── palette.tsx │ │ └── ul.tsx │ ├── plugins │ │ ├── add │ │ │ ├── add.scss │ │ │ ├── add.spec.ts │ │ │ ├── add.tsx │ │ │ └── readme.md │ │ ├── list │ │ │ ├── list.scss │ │ │ ├── list.spec.ts │ │ │ ├── list.tsx │ │ │ └── readme.md │ │ └── plugins │ │ │ ├── plugins.scss │ │ │ ├── plugins.spec.ts │ │ │ ├── plugins.tsx │ │ │ └── readme.md │ └── toolbars │ │ ├── button │ │ ├── button.scss │ │ ├── button.tsx │ │ └── readme.md │ │ ├── menu │ │ ├── menus.scss │ │ ├── menus.tsx │ │ └── readme.md │ │ ├── toolbar │ │ ├── actions │ │ │ ├── align │ │ │ │ ├── align.scss │ │ │ │ ├── align.tsx │ │ │ │ └── readme.md │ │ │ ├── color │ │ │ │ ├── color.scss │ │ │ │ ├── color.tsx │ │ │ │ └── readme.md │ │ │ ├── font-size │ │ │ │ ├── font-size.scss │ │ │ │ ├── font-size.tsx │ │ │ │ └── readme.md │ │ │ ├── link │ │ │ │ ├── link.scss │ │ │ │ ├── link.tsx │ │ │ │ └── readme.md │ │ │ ├── list │ │ │ │ ├── list.scss │ │ │ │ ├── list.tsx │ │ │ │ └── readme.md │ │ │ ├── style │ │ │ │ └── style.tsx │ │ │ └── text │ │ │ │ ├── readme.md │ │ │ │ ├── text.scss │ │ │ │ └── text.tsx │ │ ├── separator │ │ │ ├── readme.md │ │ │ ├── separator.scss │ │ │ └── separator.tsx │ │ └── toolbar │ │ │ ├── readme.md │ │ │ ├── toolbar.scss │ │ │ └── toolbar.tsx │ │ └── triangle │ │ ├── readme.md │ │ ├── triangle.scss │ │ └── triangle.tsx ├── events │ ├── data.events.ts │ ├── enter.events.ts │ ├── input.events.ts │ ├── paste.events.ts │ ├── placeholder.events.ts │ ├── tab.events.ts │ └── undo-redo.events.ts ├── index.html ├── index.ts ├── interface.d.ts ├── jest-setup.ts ├── menus │ └── img.menu.ts ├── plugins │ ├── blockquote.plugin.ts │ ├── code.plugin.ts │ ├── h1.plugin.ts │ ├── h2.plugin.ts │ ├── h3.plugin.ts │ ├── hr.plugin.ts │ ├── img.plugin.ts │ ├── list.plugin.ts │ └── plugin.spec.ts ├── stores │ ├── config.store.ts │ ├── container.store.ts │ ├── i18n.store.ts │ └── undo-redo.store.ts ├── themes │ ├── _button.scss │ ├── _overlay.scss │ └── _variables.scss ├── types │ ├── attributes.ts │ ├── config.ts │ ├── execcommand.ts │ ├── i18n.ts │ ├── icon.ts │ ├── input.d.ts │ ├── menu.ts │ ├── palette.ts │ ├── plugin.ts │ ├── toolbar.ts │ └── undo-redo.ts └── utils │ ├── create-element.utils.spec.ts │ ├── create-element.utils.ts │ ├── css.utils.spec.ts │ ├── css.utils.ts │ ├── events.utils.spec.ts │ ├── events.utils.ts │ ├── execcomand-text.utils.ts │ ├── execcommand-align.utils.ts │ ├── execcommand-list.utils.ts │ ├── execcommand-style.utils.ts │ ├── execcommand.utils.ts │ ├── execcommnad-native.utils.ts │ ├── icon.utils.tsx │ ├── keyboard.utils.ts │ ├── link.utils.ts │ ├── mobile.utils.ts │ ├── node.utils.spec.ts │ ├── node.utils.ts │ ├── paragraph.utils.spec.ts │ ├── paragraph.utils.ts │ ├── paragraphs.utils.spec.ts │ ├── paragraphs.utils.ts │ ├── selection.utils.ts │ ├── toolbar.utils.spec.ts │ ├── toolbar.utils.ts │ ├── transform.utils.ts │ ├── undo-redo-selection.utils.ts │ └── undo-redo.utils.ts ├── stencil.config.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "stylo-js" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report a bug 4 | --- 5 | 6 | ### Expected Behavior 7 | 8 | 9 | 10 | ### Actual Behavior 11 | 12 | 13 | 14 | ### Reproduction 15 | 16 | Steps to reproduce the issue: 17 | 18 | 1. 19 | 2. 20 | 21 | ### Screenshots 22 | 23 | 24 | 25 | ### Environment 26 | 27 | 28 | 29 | - Browser(s): 30 | - Operating System (e.g. Windows, macOS, Ubuntu): 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/docs.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Suggest an improvement to our documentation 4 | labels: docs 5 | --- 6 | 7 | ### Documentation Feedback 8 | 9 | 10 | 11 | Feel free to provide any suggestions of content or examples you’d like us to include. 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature 3 | about: Propose a new feature 4 | labels: feature 5 | --- 6 | 7 | ### Feature Description 8 | 9 | 10 | 11 | ### Use Case 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## PR Checklist 2 | 3 | Please check if your PR fulfills the following requirements: 4 | 5 | - [ ] Tests for the changes have been added (for bug fixes / features) 6 | - [ ] Docs have been added / updated (for bug fixes / features) 7 | 8 | ## PR Type 9 | 10 | What kind of change does this PR introduce? 11 | 12 | 13 | 14 | - [ ] Bugfix 15 | - [ ] Feature 16 | - [ ] Code style update (formatting) 17 | - [ ] Refactoring (no functional changes) 18 | - [ ] Documentation content changes 19 | 20 | ## Other information 21 | 22 | 23 | 24 | Issue Number: N/A 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | www/ 3 | loader/ 4 | 5 | *~ 6 | *.sw[mnpcod] 7 | *.log 8 | *.lock 9 | *.tmp 10 | *.tmp.* 11 | log.txt 12 | *.sublime-project 13 | *.sublime-workspace 14 | 15 | .stencil/ 16 | .idea/ 17 | .vscode/ 18 | .sass-cache/ 19 | .versions/ 20 | node_modules/ 21 | $RECYCLE.BIN/ 22 | 23 | .DS_Store 24 | Thumbs.db 25 | UserInterfaceState.xcuserstate 26 | .env 27 | 28 | .firebase 29 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/src/components.d.ts 2 | dist 3 | www 4 | loader 5 | .stencil 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "arrowParens": "always", 5 | "bracketSpacing": false, 6 | "bracketSameLine": true, 7 | "trailingComma": "none", 8 | "overrides": [ 9 | { 10 | "files": ["*.scss", "*.css"], 11 | "options": { 12 | "singleQuote": false 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Dal Busco and Nicolas Mattia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "www", 4 | "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], 5 | "rewrites": [ 6 | { 7 | "source": "**", 8 | "destination": "/index.html" 9 | } 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@papyrs/stylo", 3 | "version": "0.0.45", 4 | "description": "Another kind of rich text editor", 5 | "author": "David Dal Busco", 6 | "license": "MIT", 7 | "homepage": "https://stylojs.com", 8 | "main": "dist/index.cjs.js", 9 | "module": "dist/index.js", 10 | "es2015": "dist/esm/index.js", 11 | "es2017": "dist/esm/index.js", 12 | "jsnext:main": "dist/esm/index.js", 13 | "types": "dist/types/interface.d.ts", 14 | "collection": "dist/collection/collection-manifest.json", 15 | "collection:main": "dist/collection/index.js", 16 | "unpkg": "dist/stylo/stylo.esm.js", 17 | "files": [ 18 | "dist/", 19 | "README.md", 20 | "LICENSE" 21 | ], 22 | "scripts": { 23 | "build": "stencil build", 24 | "build:site": "npm run build && npm run copy:site", 25 | "start": "stencil build --dev --watch --serve", 26 | "test": "stencil test --spec --e2e", 27 | "test:watch": "stencil test --spec --e2e --watchAll", 28 | "generate": "stencil generate", 29 | "format": "prettier . --write", 30 | "format:md": "prettier \"src/**/*.md\" --write", 31 | "postbuild": "npm run format:md", 32 | "copy:site": "node ./scripts/copy.site.js" 33 | }, 34 | "devDependencies": { 35 | "@stencil/core": "^3.2.0", 36 | "@stencil/postcss": "^2.1.0", 37 | "@stencil/sass": "^3.0.0", 38 | "@stencil/store": "^2.0.4", 39 | "@types/jest": "^27.0.3", 40 | "autoprefixer": "^10.4.14", 41 | "blob-polyfill": "^7.0.20220408", 42 | "jest": "^27.0.3", 43 | "jest-cli": "^27.4.5", 44 | "mutation-observer": "^1.0.3", 45 | "prettier": "^2.8.7", 46 | "prettier-plugin-organize-imports": "^3.2.2", 47 | "pretty-quick": "^3.1.3", 48 | "puppeteer": "^19.8.0" 49 | }, 50 | "repository": { 51 | "type": "git", 52 | "url": "git+https://github.com/papyrs/stylo.git" 53 | }, 54 | "bugs": { 55 | "url": "https://github.com/papyrs/stylo" 56 | }, 57 | "keywords": [ 58 | "editor", 59 | "rich text", 60 | "wysiwyg" 61 | ], 62 | "husky": { 63 | "hooks": { 64 | "pre-commit": "pretty-quick --staged" 65 | } 66 | }, 67 | "dependencies": { 68 | "@deckdeckgo/utils": "^5.1.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /scripts/copy.site.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {join} = require('path'); 4 | const {readdirSync, lstatSync, copyFileSync} = require('fs'); 5 | 6 | const copySite = async ({src, dest}) => { 7 | const promises = readdirSync(src).map( 8 | (dirent) => 9 | new Promise(async (resolve) => { 10 | const [srcPath, destPath] = [src, dest].map((dirPath) => join(dirPath, dirent)); 11 | 12 | const stat = lstatSync(srcPath); 13 | 14 | if (stat.isDirectory()) { 15 | await copySite({src: srcPath, dest: destPath}); 16 | } else if (stat.isFile()) { 17 | await copyFile({srcPath, destPath}); 18 | } 19 | 20 | resolve(); 21 | }) 22 | ); 23 | 24 | await Promise.all(promises); 25 | }; 26 | 27 | const copyFile = async ({srcPath, destPath}) => copyFileSync(srcPath, destPath); 28 | 29 | (async () => { 30 | try { 31 | await copySite({src: `${process.cwd()}/site`, dest: `${process.cwd()}/www`}); 32 | 33 | console.log(`Site static data copied to www.`); 34 | } catch (err) { 35 | console.error(`Error while copying static data for website.`, err); 36 | } 37 | })(); 38 | -------------------------------------------------------------------------------- /site/assets/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/android-chrome-192x192.png -------------------------------------------------------------------------------- /site/assets/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/android-chrome-512x512.png -------------------------------------------------------------------------------- /site/assets/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/apple-touch-icon.png -------------------------------------------------------------------------------- /site/assets/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #ff65a9 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /site/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/favicon-16x16.png -------------------------------------------------------------------------------- /site/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/favicon-32x32.png -------------------------------------------------------------------------------- /site/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/favicon.ico -------------------------------------------------------------------------------- /site/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /site/assets/mogwai.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/mogwai.jpg -------------------------------------------------------------------------------- /site/assets/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/mstile-144x144.png -------------------------------------------------------------------------------- /site/assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/mstile-150x150.png -------------------------------------------------------------------------------- /site/assets/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/mstile-310x150.png -------------------------------------------------------------------------------- /site/assets/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/mstile-310x310.png -------------------------------------------------------------------------------- /site/assets/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/mstile-70x70.png -------------------------------------------------------------------------------- /site/assets/social-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peterpeterparker/stylo/065944bb782a383d49ccb9dc7c81e1eafcc914fd/site/assets/social-image.jpg -------------------------------------------------------------------------------- /site/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | Sitemap: https://stylojs.com/sitemap.xml 4 | Host: https://stylojs.com 5 | -------------------------------------------------------------------------------- /site/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Stylo", 3 | "short_name": "Stylo", 4 | "icons": [ 5 | { 6 | "src": "/assets/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/assets/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /site/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | https://stylojs.com/ 11 | daily 12 | 0.7 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/assets/i18n/de.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '../../types/i18n'; 2 | 3 | export const de: I18n = { 4 | lang: 'de', 5 | plugins: { 6 | huge_title: 'Riesige Überschrift', 7 | large_title: 'Große Überschrift', 8 | small_title: 'Kleine Überschrift', 9 | paragraph: 'Abschnitt', 10 | separator: 'Separator', 11 | unordered_list: 'Liste', 12 | ordered_list: 'Liste (Nummern)', 13 | image: 'Bild', 14 | code: 'Code', 15 | no_matches: 'Keine Treffer', 16 | blockquote: 'Blockzitat' 17 | }, 18 | add: { 19 | placeholder: 'Drücken Sie "/" für Plugins', 20 | add_element: 'Fügen Sie ein neues Element hinzu' 21 | }, 22 | toolbar: { 23 | align_left: 'Links ausrichten', 24 | align_center: 'Zentriert ausrichten', 25 | align_right: 'Rechts ausrichten', 26 | font_size: 'Schriftgrösse', 27 | list_ol: 'Geordnete Liste', 28 | list_ul: 'Ungeordnete Liste', 29 | style_list: 'Stilliste', 30 | style_align: 'Stilausrichtung', 31 | style_font_size: 'Schriftgrösse des Stils', 32 | style_color: 'Stilfarbe', 33 | style_background: 'Stilhintergrund', 34 | link: 'Link erstellen oder entfernen', 35 | bold: 'Fett', 36 | italic: 'Kursiv', 37 | underline: 'Unterstreichen', 38 | strikethrough: 'Durchgestrichen' 39 | }, 40 | menus: { 41 | img_width_original: 'Originalbildbreite', 42 | img_width_large: 'Grosse Bildbreite', 43 | img_width_medium: 'Mittlere Bildbreite', 44 | img_width_small: 'Kleine Bildbreite', 45 | img_delete: 'Bild löschen' 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/assets/i18n/en.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '../../types/i18n'; 2 | 3 | export const en: I18n = { 4 | lang: 'en', 5 | plugins: { 6 | huge_title: 'Huge title', 7 | large_title: 'Large title', 8 | small_title: 'Small title', 9 | paragraph: 'Paragraph', 10 | separator: 'Separator', 11 | unordered_list: 'Bulleted list', 12 | ordered_list: 'Numbered list', 13 | image: 'Image', 14 | code: 'Code', 15 | no_matches: 'No matches', 16 | blockquote: 'Blockquote' 17 | }, 18 | add: { 19 | placeholder: 'Press "/" for plugins', 20 | add_element: 'Add a new part' 21 | }, 22 | toolbar: { 23 | align_left: 'Align left', 24 | align_center: 'Align center', 25 | align_right: 'Align right', 26 | font_size: 'Font size', 27 | list_ol: 'Ordered list', 28 | list_ul: 'Unordered list', 29 | style_list: 'Style list', 30 | style_align: 'Style alignment', 31 | style_font_size: 'Style font size', 32 | style_color: 'Style color', 33 | style_background: 'Style background', 34 | link: 'Create or remove link', 35 | bold: 'Bold', 36 | italic: 'Italic', 37 | underline: 'Underline', 38 | strikethrough: 'Strikethrough' 39 | }, 40 | menus: { 41 | img_width_original: 'Original image width', 42 | img_width_large: 'Large image width', 43 | img_width_medium: 'Medium image width', 44 | img_width_small: 'Small image width', 45 | img_delete: 'Delete image' 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/assets/i18n/es.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '../../types/i18n'; 2 | 3 | export const es: I18n = { 4 | lang: 'es', 5 | plugins: { 6 | huge_title: 'Título enorme', 7 | large_title: 'Título grande', 8 | small_title: 'Título pequeño', 9 | paragraph: 'Párrafo', 10 | separator: 'Separador', 11 | unordered_list: 'Lista con viñetas', 12 | ordered_list: 'Lista numerada', 13 | image: 'Imagen', 14 | code: 'Código', 15 | no_matches: 'Sin resultados', 16 | blockquote: 'Cita en bloque' 17 | }, 18 | add: { 19 | placeholder: 'Presione "/" para complementos', 20 | add_element: 'Agregar un nuevo elemento' 21 | }, 22 | toolbar: { 23 | align_left: 'Alinear a la izquierda', 24 | align_center: 'Alinear al centro', 25 | align_right: 'Alinear a la derecha', 26 | font_size: 'Tamaño de fuente', 27 | list_ol: 'Lista ordenada', 28 | list_ul: 'Lista desordenada', 29 | style_list: 'Lista de estilos', 30 | style_align: 'Alineación de estilo', 31 | style_font_size: 'Tamaño de fuente de estilo', 32 | style_color: 'Color de estilo', 33 | style_background: 'Estilo de fondo', 34 | link: 'Crear o eliminar enlace', 35 | bold: 'Negrita', 36 | italic: 'Cursiva', 37 | underline: 'Subrayado', 38 | strikethrough: 'Tachado' 39 | }, 40 | menus: { 41 | img_width_original: 'Ancho de la imagen original', 42 | img_width_large: 'Ancho de imagen grande', 43 | img_width_medium: 'Ancho de imagen medio', 44 | img_width_small: 'Ancho de imagen pequeño', 45 | img_delete: 'Borrar imagen' 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/assets/i18n/fr.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '../../types/i18n'; 2 | 3 | export const fr: I18n = { 4 | lang: 'fr', 5 | plugins: { 6 | huge_title: 'Très grand titre', 7 | large_title: 'Grand titre', 8 | small_title: 'Petit titre', 9 | paragraph: 'Paragraphe', 10 | separator: 'Séparateur', 11 | unordered_list: 'Liste', 12 | ordered_list: 'Liste numérotée', 13 | image: 'Image', 14 | code: 'Code', 15 | no_matches: 'Aucun résultat', 16 | blockquote: 'Citation' 17 | }, 18 | add: { 19 | placeholder: 'Appuyez sur "/" pour le menu', 20 | add_element: 'Ajouter cet élément' 21 | }, 22 | toolbar: { 23 | align_left: 'Justifié à gauche', 24 | align_center: 'Centré', 25 | align_right: 'Justifié à droite', 26 | font_size: 'Taille de police', 27 | list_ol: 'Liste ordonée', 28 | list_ul: 'Liste', 29 | style_list: 'Liste de styles', 30 | style_align: 'Alignment du style', 31 | style_font_size: 'Taille du style', 32 | style_color: 'Couleur du style', 33 | style_background: 'Couleur de fond du style', 34 | link: 'Créer ou supprimer un lien', 35 | bold: 'Gras', 36 | italic: 'Italique', 37 | underline: 'Souligné', 38 | strikethrough: 'Barré' 39 | }, 40 | menus: { 41 | img_width_original: 'Largeur originale', 42 | img_width_large: 'Grande largeur', 43 | img_width_medium: 'Moyenne largeur', 44 | img_width_small: 'Petite largeur', 45 | img_delete: "supprimer l'image" 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/assets/i18n/ja.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '../../types/i18n'; 2 | 3 | export const ja: I18n = { 4 | lang: 'ja', 5 | plugins: { 6 | huge_title: '大見出し', 7 | large_title: '中見出し', 8 | small_title: '小見出し', 9 | paragraph: '段落', 10 | separator: '区切り文字', 11 | unordered_list: '箇条書きリスト', 12 | ordered_list: '番号付きリスト', 13 | image: '画像', 14 | code: 'コード', 15 | no_matches: '該当なし', 16 | blockquote: '引用符' 17 | }, 18 | add: { 19 | placeholder: '「/」を押してプラグインを表示', 20 | add_element: '新しいパーツを追加' 21 | }, 22 | toolbar: { 23 | align_left: '左寄せ', 24 | align_center: '中央揃え', 25 | align_right: '右寄せ', 26 | font_size: '文字サイズ', 27 | list_ol: '番号付きリスト', 28 | list_ul: '箇条書きリスト', 29 | style_list: 'スタイルの一覧', 30 | style_align: 'スタイルの整列', 31 | style_font_size: 'スタイルの文字サイズ', 32 | style_color: 'スタイルの色', 33 | style_background: 'スタイルの背景', 34 | link: 'リンクを作成または削除', 35 | bold: '太字', 36 | italic: '斜体', 37 | underline: '下線', 38 | strikethrough: '打ち消し線' 39 | }, 40 | menus: { 41 | img_width_original: '元の画像幅', 42 | img_width_large: '大きい画像幅', 43 | img_width_medium: '中くらいの画像幅', 44 | img_width_small: '小さい画像幅', 45 | img_delete: '画像を削除' 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/assets/i18n/nl.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '../../types/i18n'; 2 | 3 | export const nl: I18n = { 4 | lang: 'nl', 5 | plugins: { 6 | huge_title: 'Enorme titel', 7 | large_title: 'Grote titel', 8 | small_title: 'Kleine titel', 9 | paragraph: 'Paragraaf', 10 | separator: 'cheidingsteken', 11 | unordered_list: 'Lijst', 12 | ordered_list: 'Lijst (nummers)', 13 | image: 'Afbeelding', 14 | code: 'Code', 15 | no_matches: 'No matches', 16 | blockquote: 'Blokcitaat' 17 | }, 18 | add: { 19 | placeholder: 'Druk op "/" voor plug-ins', 20 | add_element: 'Een nieuw element toevoegen' 21 | }, 22 | toolbar: { 23 | align_left: 'Links uitlijnen', 24 | align_center: 'Lijn midden uit', 25 | align_right: 'Rechts uitlijnen', 26 | font_size: 'Lettergrootte', 27 | list_ol: 'Geordende lijst', 28 | list_ul: 'Ongeordende lijst', 29 | style_list: 'Stijllijst', 30 | style_align: 'Stijluitlijning', 31 | style_font_size: 'Stijl lettergrootte', 32 | style_color: 'Stijl kleur', 33 | style_background: 'Stijl achtergrond', 34 | link: 'Link maken of verwijderen', 35 | bold: 'Vet', 36 | italic: 'Cursief', 37 | underline: 'Onderstrepen', 38 | strikethrough: 'Doorstrepen' 39 | }, 40 | menus: { 41 | img_width_original: 'Oorspronkelijke afbeeldingsbreedte', 42 | img_width_large: 'Grote afbeeldingsbreedte', 43 | img_width_medium: 'Gemiddelde afbeeldingsbreedte', 44 | img_width_small: 'Kleine afbeeldingsbreedte', 45 | img_delete: 'Verwijder afbeelding' 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/assets/i18n/zh-cn.ts: -------------------------------------------------------------------------------- 1 | import {I18n} from '../../types/i18n'; 2 | 3 | export const zhCn: I18n = { 4 | lang: 'zh-cn', 5 | plugins: { 6 | huge_title: '特大标题', 7 | large_title: '大标题', 8 | small_title: '小标题', 9 | paragraph: '段落', 10 | separator: '分割符', 11 | unordered_list: '符号列表', 12 | ordered_list: '数字列表', 13 | image: '图片', 14 | code: '代码', 15 | no_matches: '无匹配', 16 | blockquote: '块级引用' 17 | }, 18 | add: { 19 | placeholder: '按 "/" 增加插件', 20 | add_element: '增加一个新部分' 21 | }, 22 | toolbar: { 23 | align_left: '左对齐', 24 | align_center: '居中', 25 | align_right: '右对齐', 26 | font_size: '字体大小', 27 | list_ol: '排序列表', 28 | list_ul: '无需列表', 29 | style_list: '风格列表', 30 | style_align: '风格对准', 31 | style_font_size: '风格字体大小', 32 | style_color: '风格颜色', 33 | style_background: '风格背景', 34 | link: '创建或删除链接', 35 | bold: '粗体', 36 | italic: '斜体', 37 | underline: '下划线', 38 | strikethrough: '删除线' 39 | }, 40 | menus: { 41 | img_width_original: '原始图片宽度', 42 | img_width_large: '大图片宽度', 43 | img_width_medium: '中图片宽度', 44 | img_width_small: '小图片宽度', 45 | img_delete: '删除图片' 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/color/color/color.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | /** 6 | * @prop --stylo-color-flex-wrap: Component flex-wrap 7 | * @default wrap 8 | */ 9 | flex-wrap: var(--stylo-color-flex-wrap, wrap); 10 | /** 11 | * @prop --stylo-color-overflow: Component overflow 12 | * @default visible 13 | */ 14 | overflow: var(--stylo-color-overflow, visible); 15 | /** 16 | * @prop --stylo-color-padding: Component padding 17 | * @default 8px 18 | */ 19 | padding: var(--stylo-color-padding, 8px); 20 | /** 21 | * @prop --stylo-color-padding: Component width 22 | * @default 288px 23 | */ 24 | width: var(--stylo-color-width, 298px); 25 | } 26 | 27 | button { 28 | /** 29 | * @prop --stylo-color-button-width: Button width 30 | * @default 28px 31 | */ 32 | width: var(--stylo-color-button-width, 28px); 33 | /** 34 | * @prop --stylo-color-button-height: Button height 35 | * @default 28px 36 | */ 37 | height: var(--stylo-color-button-height, 28px); 38 | 39 | /** 40 | * @prop --stylo-color-button-margin: Button margin 41 | * @default 4px 42 | */ 43 | margin: var(--stylo-color-button-margin, 4px); 44 | padding: 0; 45 | 46 | /** 47 | * @prop --stylo-color-button-outline: Button outline 48 | * @default none 49 | */ 50 | outline: var(--stylo-color-button-outline, none); 51 | 52 | cursor: pointer; 53 | 54 | border: 1px solid var(--stylo-palette-border-color, var(--stylo-palette-color-hex)); 55 | 56 | /** 57 | * @prop --stylo-color-button-border-radius: Button border-radius 58 | * @default 1px 59 | */ 60 | border-radius: var(--stylo-color-button-border-radius, 1px); 61 | 62 | background: var(--stylo-palette-color-hex); 63 | 64 | &.selected { 65 | box-shadow: 0 0 4px 4px 66 | rgba(var(--stylo-palette-box-shadow-color, var(--stylo-palette-color-rgb)), 0.4); 67 | } 68 | 69 | &:not(.selected):hover, 70 | &:not(.selected):focus { 71 | box-shadow: 0 0 2px 2px 72 | rgba(var(--stylo-palette-box-shadow-color, var(--stylo-palette-color-rgb)), 0.4); 73 | } 74 | } 75 | 76 | /** 77 | * stylo-color-input: Host 78 | */ 79 | /** 80 | * @prop --stylo-color-input-margin: Input field margin 81 | * @default 4px 82 | */ 83 | /** 84 | * @prop --stylo-color-input-container-border-radius: The border-radius property of the input and hash container 85 | */ 86 | /** 87 | * @prop --stylo-color-input-container-border: The border property of the input and hash container 88 | */ 89 | 90 | /** 91 | * stylo-color-input: Input 92 | */ 93 | /** 94 | * @prop --stylo-color-input-color: Input color 95 | * @default rgb(102, 102, 102) 96 | */ 97 | /** 98 | * @prop --stylo-color-input-background: Input background 99 | * @default inherit 100 | */ 101 | /** 102 | * @prop --stylo-color-input-height: Input height 103 | * @default 28px 104 | */ 105 | /** 106 | * @prop --stylo-color-input-box-shadow: Input box-shadow 107 | * @default rgb(240, 240, 240) 0 0 0 1px inset 108 | */ 109 | /** 110 | * @prop --stylo-color-input-border-radius: Input border-radius 111 | * @default 0 4px 4px 0 112 | */ 113 | /** 114 | * @prop --stylo-color-input-padding: Input padding 115 | * @default 0 4px 116 | */ 117 | /** 118 | * @prop --stylo-color-input-max-width: Input max-width 119 | * @default 136px 120 | */ 121 | /** 122 | * @prop --stylo-color-input-font-family: Input font-family 123 | * @default inherit 124 | */ 125 | 126 | /** 127 | * stylo-color-input: Hash (#) 128 | */ 129 | /** 130 | * @prop --stylo-color-hash-width: Hash (#) width 131 | * @default 28px 132 | */ 133 | /** 134 | * @prop --stylo-color-hash-height: Hash (#) height 135 | * @default 28px 136 | */ 137 | /** 138 | * @prop --stylo-color-hash-background: Hash (#) background 139 | * @default rgb(240, 240, 240) none repeat scroll 0 0 140 | */ 141 | /** 142 | * @prop --stylo-color-hash-color: Hash (#) color 143 | * @default rgb(152, 161, 164) 144 | */ 145 | /** 146 | * @prop --stylo-color-hash-border-radius: Hash (#) border-radius 147 | * @default 4px 0 0 4px 148 | */ 149 | -------------------------------------------------------------------------------- /src/components/color/input/input.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: flex; 3 | position: relative; 4 | 5 | margin: var(--stylo-color-input-margin, 4px); 6 | 7 | border-radius: var(--stylo-color-input-container-border-radius); 8 | border: var(--stylo-color-input-container-border); 9 | } 10 | 11 | span { 12 | width: var(--stylo-color-hash-width, 28px); 13 | height: var(--stylo-color-hash-height, 28px); 14 | 15 | display: inline-flex; 16 | justify-content: center; 17 | align-items: center; 18 | 19 | background: var(--stylo-color-hash-background, rgb(240, 240, 240) none repeat scroll 0 0); 20 | color: var(--stylo-color-hash-color, rgb(152, 161, 164)); 21 | 22 | border-radius: var(--stylo-color-hash-border-radius, 4px 0 0 4px); 23 | } 24 | 25 | input { 26 | color: var(--stylo-color-input-color, rgb(102, 102, 102)); 27 | background: var(--stylo-color-input-background, inherit); 28 | 29 | border: none; 30 | outline: none; 31 | 32 | height: var(--stylo-color-input-height, 28px); 33 | 34 | box-shadow: var(--stylo-color-input-box-shadow, rgb(240, 240, 240) 0 0 0 1px inset); 35 | border-radius: var(--stylo-color-input-border-radius, 0 4px 4px 0); 36 | 37 | padding: var(--stylo-color-input-padding, 0 4px); 38 | 39 | max-width: var(--stylo-color-input-max-width, 136px); 40 | 41 | font-family: var(--stylo-color-input-font-family, inherit); 42 | } 43 | -------------------------------------------------------------------------------- /src/components/color/input/input.tsx: -------------------------------------------------------------------------------- 1 | import {debounce, hexToRgb, rgbToHex} from '@deckdeckgo/utils'; 2 | import {Component, Event, EventEmitter, h, Host, Prop, State, Watch} from '@stencil/core'; 3 | import {StyloPaletteColor} from '../../../types/palette'; 4 | 5 | @Component({ 6 | tag: 'stylo-color-input', 7 | styleUrl: 'input.scss', 8 | shadow: true 9 | }) 10 | export class Input { 11 | @Prop() 12 | colorHex: string; 13 | 14 | @Prop() 15 | colorRgb: string; 16 | 17 | @Prop() 18 | customColorRgb: string; 19 | 20 | @Prop() 21 | inputAlt: string; 22 | 23 | @State() 24 | private color: string; 25 | 26 | @Event() 27 | selectHexColor: EventEmitter; 28 | 29 | private readonly debounceSelectColor: (inputColor: string) => void = debounce( 30 | (inputColor: string) => this.emitColor(inputColor), 31 | 500 32 | ); 33 | 34 | async componentWillLoad() { 35 | this.color = this.initColorHex(); 36 | } 37 | 38 | @Watch('colorHex') 39 | @Watch('colorRgb') 40 | @Watch('customColorRgb') 41 | async watchColors() { 42 | this.color = this.initColorHex(); 43 | } 44 | 45 | private initColorHex(): string { 46 | if (this.colorHex) { 47 | return this.colorHex; 48 | } 49 | 50 | if (this.customColorRgb) { 51 | return rgbToHex(this.customColorRgb); 52 | } 53 | 54 | return rgbToHex(this.colorRgb); 55 | } 56 | 57 | private emitColor(inputColor: string) { 58 | const hex: string = `#${inputColor.replace('#', '')}`; 59 | 60 | const rgb: string | undefined = hexToRgb(hex); 61 | 62 | if (!rgb) { 63 | return; 64 | } 65 | 66 | this.selectHexColor.emit({ 67 | hex, 68 | rgb 69 | }); 70 | } 71 | 72 | render() { 73 | return ( 74 | 75 | # 76 | 81 | this.debounceSelectColor(($event.target as InputTargetEvent).value) 82 | } 83 | value={this.color?.replace('#', '')} 84 | /> 85 | 86 | ); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/color/input/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-color-input 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | ---------------- | ------------------ | ----------- | -------- | ----------- | 9 | | `colorHex` | `color-hex` | | `string` | `undefined` | 10 | | `colorRgb` | `color-rgb` | | `string` | `undefined` | 11 | | `customColorRgb` | `custom-color-rgb` | | `string` | `undefined` | 12 | | `inputAlt` | `input-alt` | | `string` | `undefined` | 13 | 14 | ## Events 15 | 16 | | Event | Description | Type | 17 | | ---------------- | ----------- | -------------------------------- | 18 | | `selectHexColor` | | `CustomEvent` | 19 | 20 | ## Dependencies 21 | 22 | ### Used by 23 | 24 | - [stylo-color](../color) 25 | 26 | ### Graph 27 | 28 | ```mermaid 29 | graph TD; 30 | stylo-color --> stylo-color-input 31 | style stylo-color-input fill:#f9f,stroke:#333,stroke-width:4px 32 | ``` 33 | 34 | --- 35 | 36 | _Built with [StencilJS](https://stenciljs.com/)_ 37 | -------------------------------------------------------------------------------- /src/components/editor/editor.e2e.ts: -------------------------------------------------------------------------------- 1 | import {newE2EPage} from '@stencil/core/testing'; 2 | 3 | describe('editor', () => { 4 | let page; 5 | 6 | beforeEach(async () => { 7 | page = await newE2EPage(); 8 | 9 | await page.setContent( 10 | `
11 |
${'\u200B'}
12 |
13 | 14 | ` 15 | ); 16 | 17 | await page.$eval('stylo-editor', (el) => { 18 | const container = document.querySelector('article'); 19 | (el as HTMLStyloEditorElement).containerRef = container; 20 | }); 21 | 22 | await page.waitForChanges(); 23 | }); 24 | 25 | it('renders', async () => { 26 | const element = await page.find('stylo-editor'); 27 | expect(element).toHaveClass('hydrated'); 28 | }); 29 | 30 | describe('add', () => { 31 | it('should display add', async () => { 32 | const div = await page.find('div'); 33 | await div.click(); 34 | 35 | await page.waitForChanges(); 36 | 37 | const styleTop: string | undefined = await page.$eval('stylo-add', ({style}: HTMLElement) => 38 | style.getPropertyValue('--actions-top') 39 | ); 40 | 41 | const styleLeft: string | undefined = await page.$eval('stylo-add', ({style}: HTMLElement) => 42 | style.getPropertyValue('--actions-left') 43 | ); 44 | 45 | expect(styleTop).toEqual('72px'); 46 | expect(styleLeft).toEqual(''); 47 | }); 48 | 49 | it('should not display add', async () => { 50 | const aside = await page.find('aside'); 51 | await aside.click(); 52 | 53 | await page.waitForChanges(); 54 | 55 | const styleTop: string | undefined = await page.$eval('stylo-add', ({style}: HTMLElement) => 56 | style.getPropertyValue('--actions-top') 57 | ); 58 | 59 | const styleLeft: string | undefined = await page.$eval('stylo-add', ({style}: HTMLElement) => 60 | style.getPropertyValue('--actions-left') 61 | ); 62 | 63 | expect(styleTop).toEqual(''); 64 | expect(styleLeft).toEqual(''); 65 | }); 66 | }); 67 | 68 | describe('transform', () => { 69 | it('should display transform', async () => { 70 | const div = await page.find('div'); 71 | await div.click(); 72 | 73 | await page.waitForChanges(); 74 | 75 | const add = await page.find('stylo-add >>> button'); 76 | await add.click(); 77 | 78 | await page.waitForChanges(); 79 | 80 | const styleTop: string | undefined = await page.$eval( 81 | 'stylo-plugins', 82 | ({style}: HTMLElement) => style.getPropertyValue('--actions-top') 83 | ); 84 | 85 | const styleLeft: string | undefined = await page.$eval( 86 | 'stylo-plugins', 87 | ({style}: HTMLElement) => style.getPropertyValue('--actions-left') 88 | ); 89 | 90 | expect(styleTop).toEqual('109.5px'); 91 | expect(styleLeft).toEqual('80px'); 92 | 93 | const transform = await page.find('stylo-plugins'); 94 | expect(transform).toHaveClasses(['display', 'hydrated']); 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/components/editor/editor.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/editor/editor.spec.ts: -------------------------------------------------------------------------------- 1 | import {newSpecPage} from '@stencil/core/testing'; 2 | import {h1} from '../../plugins/h1.plugin'; 3 | import configStore from '../../stores/config.store'; 4 | import {StyloConfig} from '../../types/config'; 5 | import {Editor} from './editor'; 6 | 7 | describe('editor', () => { 8 | const container = document.createElement('div'); 9 | container.setAttribute('contenteditable', 'true'); 10 | 11 | it('renders', async () => { 12 | const {root} = await newSpecPage({ 13 | components: [Editor], 14 | html: '' 15 | }); 16 | 17 | expect(root).toEqualHtml(` 18 | 19 | 20 | 21 | 22 | 23 | `); 24 | }); 25 | 26 | it('should render without shadow dom', async () => { 27 | const {root} = await newSpecPage({ 28 | components: [Editor], 29 | html: '', 30 | supportsShadowDom: false 31 | }); 32 | expect(root.shadowRoot).toBeFalsy(); 33 | expect(root.querySelector('stylo-toolbar')).toBeTruthy(); 34 | }); 35 | 36 | it('should init after container ref set', async () => { 37 | const page = await newSpecPage({ 38 | components: [Editor], 39 | html: '' 40 | }); 41 | 42 | const spySize = spyOn(page.rootInstance, 'applySize'); 43 | const spyEvents = spyOn(page.rootInstance, 'initEvents'); 44 | 45 | page.root.containerRef = container; 46 | await page.waitForChanges(); 47 | 48 | expect(spySize).toHaveBeenCalled(); 49 | expect(spyEvents).toHaveBeenCalled(); 50 | }); 51 | 52 | it('should replace plugins', async () => { 53 | const page = await newSpecPage({ 54 | components: [Editor], 55 | html: '' 56 | }); 57 | 58 | page.root.containerRef = container; 59 | await page.waitForChanges(); 60 | 61 | page.root.config = {plugins: [h1]}; 62 | 63 | await page.waitForChanges(); 64 | 65 | expect(configStore.state.plugins).toEqual([h1]); 66 | }); 67 | 68 | it('should merge toolbar config', async () => { 69 | const page = await newSpecPage({ 70 | components: [Editor], 71 | html: '' 72 | }); 73 | 74 | page.root.containerRef = container; 75 | await page.waitForChanges(); 76 | 77 | let config: StyloConfig = { 78 | toolbar: { 79 | style: { 80 | list: false, 81 | align: true, 82 | backgroundColor: true, 83 | fontSize: true 84 | } 85 | } 86 | }; 87 | 88 | const original = {...configStore.state.toolbar}; 89 | 90 | page.root.config = config; 91 | 92 | await page.waitForChanges(); 93 | 94 | expect(configStore.state.toolbar).toEqual({...original, ...config.toolbar}); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/components/editor/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-editor 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | ----------- | 9 | | `config` | -- | Optional editor configurations - i18n provides language and optional list of custom translations - plugins, if provided, replaces the default plugin config - Toolbar, if provided, is merged with the default toolbar config - Menus, if provided, is merged with the default menus config | `StyloConfig` | `undefined` | 10 | | `containerRef` | -- | The container (e.g. an article, a div, etc.) that contains the content, the paragraphs. Must have the attribute `contenteditable` set to `true`. | `HTMLElement` | `undefined` | 11 | 12 | ## Dependencies 13 | 14 | ### Depends on 15 | 16 | - [stylo-add](../plugins/add) 17 | - [stylo-plugins](../plugins/plugins) 18 | - [stylo-menus](../toolbars/menu) 19 | - [stylo-toolbar](../toolbars/toolbar/toolbar) 20 | 21 | ### Graph 22 | 23 | ```mermaid 24 | graph TD; 25 | stylo-editor --> stylo-add 26 | stylo-editor --> stylo-plugins 27 | stylo-editor --> stylo-menus 28 | stylo-editor --> stylo-toolbar 29 | stylo-plugins --> stylo-list 30 | stylo-menus --> stylo-toolbar-triangle 31 | stylo-menus --> stylo-toolbar-button 32 | stylo-toolbar --> stylo-toolbar-triangle 33 | stylo-toolbar --> stylo-toolbar-link 34 | stylo-toolbar --> stylo-toolbar-color 35 | stylo-toolbar --> stylo-toolbar-align 36 | stylo-toolbar --> stylo-toolbar-list 37 | stylo-toolbar --> stylo-toolbar-font-size 38 | stylo-toolbar --> stylo-toolbar-separator 39 | stylo-toolbar --> stylo-toolbar-button 40 | stylo-toolbar --> stylo-toolbar-text 41 | stylo-toolbar-color --> stylo-color 42 | stylo-color --> stylo-color-input 43 | stylo-toolbar-align --> stylo-toolbar-button 44 | stylo-toolbar-list --> stylo-toolbar-button 45 | stylo-toolbar-font-size --> stylo-toolbar-button 46 | stylo-toolbar-text --> stylo-toolbar-button 47 | style stylo-editor fill:#f9f,stroke:#333,stroke-width:4px 48 | ``` 49 | 50 | --- 51 | 52 | _Built with [StencilJS](https://stenciljs.com/)_ 53 | -------------------------------------------------------------------------------- /src/components/icons/add.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Aadd%3A 4 | export const IconAdd: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/align-center.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Aformat_align_center%3A 4 | export const IconAlignCenter: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/align-left.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Aformat_align_left%3A 4 | export const IconAlignLeft: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/align-right.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Aformat_align_right%3A 4 | export const IconAlignRight: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/blockquote.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%20Outlined%3Aformat_quote%3A 4 | export const IconBlockquote: FunctionalComponent = () => ( 5 | 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/components/icons/code.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Acode%3A 4 | export const IconCode: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/color.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%20Outlined%3Aformat_color_fill%3A 4 | export const IconColor: FunctionalComponent = () => ( 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | -------------------------------------------------------------------------------- /src/components/icons/image.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%20Outlined%3Aimage%3A 4 | export const IconImage: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/link.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Alink%3A 4 | export const IconLink: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/more.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Amore_horiz%3A 4 | export const IconEllipsisHorizontal: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/ol.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%3Aformat_list_numbered%3A 4 | export const IconOl: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/icons/palette.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%20Outlined%3Apalette%3A 4 | export const IconPalette: FunctionalComponent = () => ( 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | -------------------------------------------------------------------------------- /src/components/icons/ul.tsx: -------------------------------------------------------------------------------- 1 | import {FunctionalComponent, h} from '@stencil/core'; 2 | 3 | // Source: https://fonts.google.com/icons?selected=Material%20Icons%20Outlined%3Aformat_list_bulleted%3A 4 | export const IconUl: FunctionalComponent = () => ( 5 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/components/plugins/add/add.scss: -------------------------------------------------------------------------------- 1 | @use "../../../themes/variables"; 2 | @use "../../../themes/button"; 3 | 4 | :host { 5 | display: block; 6 | 7 | position: absolute; 8 | top: var(--actions-top); 9 | left: var(--stylo-add-left, 8px); 10 | 11 | transition: top 0.1s ease; 12 | 13 | @include variables.colors; 14 | } 15 | 16 | button { 17 | @include button.button; 18 | 19 | flex-direction: column; 20 | justify-content: center; 21 | align-items: center; 22 | 23 | /** 24 | * @prop --stylo-add-background-color: The background-color of the button 25 | */ 26 | background: var(--stylo-add-background); 27 | 28 | /** 29 | * @prop --stylo-add-border-radius: The border-radius of the button 30 | * @default 50% 31 | */ 32 | border-radius: var(--stylo-add-border-radius, 50%); 33 | 34 | /** 35 | * @prop --stylo-add-border: The border of the button 36 | */ 37 | border: var(--stylo-add-border); 38 | 39 | /** 40 | * @prop --stylo-add-size: The size of the button 41 | * @default 1.4rem 42 | */ 43 | width: var(--stylo-add-size, 1.4rem); 44 | height: var(--stylo-add-size, 1.4rem); 45 | 46 | /** 47 | * @prop --stylo-add-size: The padding of the button 48 | * @default 0.2rem 49 | */ 50 | padding: var(--stylo-add-padding, 0.2rem); 51 | 52 | /** 53 | * @prop --stylo-add-color: The color of the button 54 | * @default var(--medium) 55 | */ 56 | color: var(--stylo-add-color, var(--medium)); 57 | 58 | &:hover, 59 | &:focus { 60 | /** 61 | * @prop --stylo-add-color-hover: The hover color of the button 62 | * @default var(--light-contrast) 63 | */ 64 | color: var(--stylo-add-color-hover, var(--light-contrast)); 65 | 66 | /** 67 | * @prop --stylo-add-background-color-hover: The hover background-color of the button 68 | */ 69 | background: var(--stylo-add-background-hover); 70 | } 71 | } 72 | 73 | svg { 74 | width: 100%; 75 | height: 100%; 76 | } 77 | -------------------------------------------------------------------------------- /src/components/plugins/add/add.spec.ts: -------------------------------------------------------------------------------- 1 | import {newSpecPage} from '@stencil/core/testing'; 2 | import {Add} from './add'; 3 | 4 | describe('add', () => { 5 | it('renders', async () => { 6 | const {root} = await newSpecPage({ 7 | components: [Add], 8 | html: '' 9 | }); 10 | 11 | expect(root).toEqualHtml(` 12 | 13 | 14 | 20 | 21 | 22 | `); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/plugins/add/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-add 2 | 3 | 4 | 5 | ## Events 6 | 7 | | Event | Description | Type | 8 | | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | 9 | | `hidePlugins` | If user types anything else than a "/" in an empty paragraph, hide the plugins. | `CustomEvent` | 10 | | `listPlugins` | An event emitted when user click on the shadowed button. - If selected paragraph is empty, emitted straight away - If not empty, first a new paragraph is created and then event is emitted Event is catched in `` and used to trigger the display of list of plugins. | `CustomEvent` | 11 | | `selectParagraph` | Emits the paragraph that is selected either with mouse, touch or keyboard actions | `CustomEvent` | 12 | 13 | ## CSS Custom Properties 14 | 15 | | Name | Description | 16 | | ------------------------------------ | ------------------------------------------------------------ | 17 | | `--stylo-add-background-color` | The background-color of the button | 18 | | `--stylo-add-background-color-hover` | The hover background-color of the button | 19 | | `--stylo-add-border` | The border of the button | 20 | | `--stylo-add-border-radius` | The border-radius of the button @default 50% | 21 | | `--stylo-add-color` | The color of the button @default var(--medium) | 22 | | `--stylo-add-color-hover` | The hover color of the button @default var(--light-contrast) | 23 | | `--stylo-add-size` | The size of the button @default 1.4rem | 24 | 25 | ## Dependencies 26 | 27 | ### Used by 28 | 29 | - [stylo-editor](../../editor) 30 | 31 | ### Graph 32 | 33 | ```mermaid 34 | graph TD; 35 | stylo-editor --> stylo-add 36 | style stylo-add fill:#f9f,stroke:#333,stroke-width:4px 37 | ``` 38 | 39 | --- 40 | 41 | _Built with [StencilJS](https://stenciljs.com/)_ 42 | -------------------------------------------------------------------------------- /src/components/plugins/list/list.scss: -------------------------------------------------------------------------------- 1 | @use "../../../themes/variables"; 2 | @use "../../../themes/button"; 3 | 4 | :host { 5 | display: flex; 6 | flex-direction: column; 7 | 8 | @include variables.colors; 9 | 10 | /** 11 | * @prop --stylo-list-background-active: Background on focus and hover in the list 12 | * @default --light (see _variables.scss) 13 | */ 14 | --background-active: var(--stylo-list-background-active, var(--light)); 15 | 16 | /** 17 | * @prop --stylo-list-border-active: Border on focus and hover in the list 18 | * @default 1px solid var(--background-active) 19 | */ 20 | --border-active: var(--stylo-list-border-active, 1px solid var(--background-active)); 21 | 22 | /** 23 | * @prop --stylo-list-color-active: Color on focus and hover in the list 24 | * @default --light-contrast (see _variables.scss) 25 | */ 26 | --color-active: var(--stylo-list-color-active, var(--light-contrast)); 27 | } 28 | 29 | button { 30 | @include button.button; 31 | 32 | justify-content: flex-start; 33 | align-items: center; 34 | 35 | font-size: 1rem; 36 | 37 | color: var(--stylo-toolbar-button-color, black); 38 | 39 | white-space: pre; 40 | 41 | &:hover, 42 | &:focus { 43 | background: var(--background-active); 44 | color: var(--color-active); 45 | } 46 | } 47 | 48 | svg, 49 | span.placeholder { 50 | width: 1.4rem; 51 | height: 1.4rem; 52 | 53 | border: var(--border-active); 54 | border-radius: var(--stylo-border, 4px); 55 | 56 | margin: 4px 12px 4px 8px; 57 | 58 | background: var(--white); 59 | } 60 | 61 | svg { 62 | padding: 8px; 63 | } 64 | 65 | span.placeholder { 66 | padding: 10px 8px 6px; 67 | font-size: 0.8rem; 68 | font-weight: 700; 69 | } 70 | 71 | span.placeholder::first-letter { 72 | font-size: 125%; 73 | } 74 | 75 | div.icon { 76 | display: flex; 77 | } 78 | 79 | .empty { 80 | padding: 10px 16px 6px; 81 | 82 | strong { 83 | overflow: hidden; 84 | text-overflow: ellipsis; 85 | white-space: nowrap; 86 | display: block; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/components/plugins/list/list.spec.ts: -------------------------------------------------------------------------------- 1 | import {newSpecPage} from '@stencil/core/testing'; 2 | import {List} from './list'; 3 | 4 | describe('list', () => { 5 | it('renders', async () => { 6 | const {root} = await newSpecPage({ 7 | components: [List], 8 | html: '' 9 | }); 10 | 11 | expect(root).toEqualHtml(` 12 | 13 | 14 | 22 | 30 | 38 | 45 | 52 | 59 | 66 | 73 | 205 | ); 206 | } 207 | 208 | private renderText(text: string) { 209 | const textValue: string = i18n.state.plugins[text] ?? i18n.state.custom[text] ?? text; 210 | 211 | if (this.filter.length > 0) { 212 | const rgxSplit = new RegExp(this.filter + '(.*)', 'gi'); 213 | const split = textValue.split(rgxSplit); 214 | 215 | const rgxFilter = new RegExp(this.filter, 'gi'); 216 | const filter = textValue.match(rgxFilter); 217 | 218 | return ( 219 | 220 | {split[0] ?? ''} 221 | {filter[0] ?? ''} 222 | {split[1] ?? ''} 223 | 224 | ); 225 | } 226 | 227 | return textValue; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/components/plugins/list/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-list 2 | 3 | 4 | 5 | ## Events 6 | 7 | | Event | Description | Type | 8 | | --------------- | ------------------------------------------------------ | -------------------------- | 9 | | `applyPlugin` | Emit which plugin the user want to apply. | `CustomEvent` | 10 | | `cancelPlugins` | Emit when user actually do not want to apply a plugin. | `CustomEvent` | 11 | 12 | ## Methods 13 | 14 | ### `focusFirstButton() => Promise` 15 | 16 | #### Returns 17 | 18 | Type: `Promise` 19 | 20 | ## CSS Custom Properties 21 | 22 | | Name | Description | 23 | | -------------------------------- | ------------------------------------------------------------------------------------- | 24 | | `--stylo-list-background-active` | Background on focus and hover in the list @default --light (see \_variables.scss) | 25 | | `--stylo-list-border-active` | Border on focus and hover in the list @default 1px solid var(--background-active) | 26 | | `--stylo-list-color-active` | Color on focus and hover in the list @default --light-contrast (see \_variables.scss) | 27 | 28 | ## Dependencies 29 | 30 | ### Used by 31 | 32 | - [stylo-plugins](../plugins) 33 | 34 | ### Graph 35 | 36 | ```mermaid 37 | graph TD; 38 | stylo-plugins --> stylo-list 39 | style stylo-list fill:#f9f,stroke:#333,stroke-width:4px 40 | ``` 41 | 42 | --- 43 | 44 | _Built with [StencilJS](https://stenciljs.com/)_ 45 | -------------------------------------------------------------------------------- /src/components/plugins/plugins/plugins.scss: -------------------------------------------------------------------------------- 1 | @use "../../../themes/variables"; 2 | @use "../../../themes/overlay"; 3 | 4 | :host { 5 | display: flex; 6 | flex-direction: column; 7 | 8 | position: absolute; 9 | top: var(--actions-top); 10 | left: var(--actions-left); 11 | transform: translate(0, var(--actions-translate-y)) scale(0.95); 12 | 13 | opacity: 0; 14 | 15 | margin: 2px 0; 16 | 17 | width: 220px; 18 | max-height: 220px; 19 | 20 | overflow: auto; 21 | 22 | @include variables.colors; 23 | @include overlay.dialog; 24 | @include overlay.overlay; 25 | 26 | pointer-events: none; 27 | 28 | transition-property: opacity, transform; 29 | transition-duration: 0.15s, 0.15s; 30 | transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275), 31 | cubic-bezier(0.175, 0.885, 0.32, 1.275); 32 | } 33 | 34 | :host(.display) { 35 | opacity: 1; 36 | transform: translate(0, var(--actions-translate-y)) scale(1); 37 | pointer-events: all; 38 | } 39 | 40 | input { 41 | visibility: hidden; 42 | opacity: 0; 43 | } 44 | -------------------------------------------------------------------------------- /src/components/plugins/plugins/plugins.spec.ts: -------------------------------------------------------------------------------- 1 | import {newSpecPage} from '@stencil/core/testing'; 2 | import {Plugins} from './plugins'; 3 | 4 | describe('plugins', () => { 5 | it('renders', async () => { 6 | const {root} = await newSpecPage({ 7 | components: [Plugins], 8 | html: '' 9 | }); 10 | 11 | expect(root).toEqualHtml(` 12 | 18 | `); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /src/components/plugins/plugins/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-plugins 2 | 3 | 4 | 5 | ## Dependencies 6 | 7 | ### Used by 8 | 9 | - [stylo-editor](../../editor) 10 | 11 | ### Depends on 12 | 13 | - [stylo-list](../list) 14 | 15 | ### Graph 16 | 17 | ```mermaid 18 | graph TD; 19 | stylo-plugins --> stylo-list 20 | stylo-editor --> stylo-plugins 21 | style stylo-plugins fill:#f9f,stroke:#333,stroke-width:4px 22 | ``` 23 | 24 | --- 25 | 26 | _Built with [StencilJS](https://stenciljs.com/)_ 27 | -------------------------------------------------------------------------------- /src/components/toolbars/button/button.scss: -------------------------------------------------------------------------------- 1 | @use "../../../themes/variables"; 2 | 3 | .host { 4 | @include variables.colors; 5 | } 6 | 7 | button { 8 | pointer-events: initial; 9 | cursor: pointer; 10 | 11 | margin: 0; 12 | padding: 4px 6px 6px; 13 | height: 40px; 14 | 15 | z-index: var(--stylo-toolbar-button-zindex, 2); 16 | 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | 21 | border: 0; 22 | color: var(--stylo-toolbar-button-color, black); 23 | 24 | transition: 0.1s background-color, 0.1s border-color, 0.1s fill; 25 | 26 | background: transparent; 27 | 28 | font-size: var(--stylo-toolbar-button-font-size, 1.4rem); 29 | font-family: var(--stylo-toolbar-button-font-family, inherit); 30 | 31 | -webkit-touch-callout: none; 32 | user-select: none; 33 | 34 | outline: 0; 35 | 36 | &.active { 37 | color: var(--stylo-toolbar-button-color-active, var(--highlight)); 38 | } 39 | 40 | &[disabled] { 41 | color: var(--stylo-toolbar-button-color-disabled, var(--light)); 42 | display: var(--stylo-toolbar-button-display-disabled, none); 43 | } 44 | 45 | &.active { 46 | > div { 47 | background-color: var(--stylo-toolbar-button-color-active, var(--highlight)); 48 | } 49 | } 50 | 51 | &[disabled] { 52 | > div { 53 | background-color: var(--stylo-toolbar-button-color-disabled, var(--light)); 54 | } 55 | } 56 | } 57 | 58 | ::slotted(*) { 59 | pointer-events: none; 60 | } 61 | 62 | ::slotted(svg) { 63 | width: 18px; 64 | height: 18px; 65 | 66 | padding-bottom: 2px; 67 | } 68 | -------------------------------------------------------------------------------- /src/components/toolbars/button/button.tsx: -------------------------------------------------------------------------------- 1 | import {Component, Event, EventEmitter, h, Prop} from '@stencil/core'; 2 | 3 | @Component({ 4 | tag: 'stylo-toolbar-button', 5 | styleUrl: 'button.scss', 6 | shadow: true 7 | }) 8 | export class Button { 9 | @Prop() 10 | disableAction: boolean = false; 11 | 12 | @Prop() 13 | cssClass: string; 14 | 15 | @Prop() 16 | label: string; 17 | 18 | @Event() 19 | action: EventEmitter; 20 | 21 | render() { 22 | return ( 23 | 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/components/toolbars/button/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-button 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | --------------- | ---------------- | ----------- | --------- | ----------- | 9 | | `cssClass` | `css-class` | | `string` | `undefined` | 10 | | `disableAction` | `disable-action` | | `boolean` | `false` | 11 | | `label` | `label` | | `string` | `undefined` | 12 | 13 | ## Events 14 | 15 | | Event | Description | Type | 16 | | -------- | ----------- | ---------------------- | 17 | | `action` | | `CustomEvent` | 18 | 19 | ## Dependencies 20 | 21 | ### Used by 22 | 23 | - [stylo-menus](../menu) 24 | - [stylo-toolbar](../toolbar/toolbar) 25 | - [stylo-toolbar-align](../toolbar/actions/align) 26 | - [stylo-toolbar-font-size](../toolbar/actions/font-size) 27 | - [stylo-toolbar-list](../toolbar/actions/list) 28 | - [stylo-toolbar-text](../toolbar/actions/text) 29 | 30 | ### Graph 31 | 32 | ```mermaid 33 | graph TD; 34 | stylo-menus --> stylo-toolbar-button 35 | stylo-toolbar --> stylo-toolbar-button 36 | stylo-toolbar-align --> stylo-toolbar-button 37 | stylo-toolbar-font-size --> stylo-toolbar-button 38 | stylo-toolbar-list --> stylo-toolbar-button 39 | stylo-toolbar-text --> stylo-toolbar-button 40 | style stylo-toolbar-button fill:#f9f,stroke:#333,stroke-width:4px 41 | ``` 42 | 43 | --- 44 | 45 | _Built with [StencilJS](https://stenciljs.com/)_ 46 | -------------------------------------------------------------------------------- /src/components/toolbars/menu/menus.scss: -------------------------------------------------------------------------------- 1 | @use "../../../themes/variables"; 2 | @use "../../../themes/overlay"; 3 | 4 | :host { 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | 9 | position: absolute; 10 | top: var(--menu-top); 11 | left: 50%; 12 | transform: translate(-50%, calc(-100% - 16px)); 13 | 14 | @include variables.colors; 15 | @include overlay.overlay; 16 | @include overlay.dialog; 17 | @include overlay.toolbar; 18 | 19 | z-index: var(--stylo-toolbar-zindex, 1); 20 | } 21 | 22 | div.icon { 23 | display: flex; 24 | flex-direction: column; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/toolbars/menu/menus.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | ComponentInterface, 4 | Event, 5 | EventEmitter, 6 | h, 7 | Host, 8 | JSX, 9 | Listen, 10 | State 11 | } from '@stencil/core'; 12 | import configStore from '../../../stores/config.store'; 13 | import containerStore from '../../../stores/container.store'; 14 | import i18n from '../../../stores/i18n.store'; 15 | import {StyloMenu, StyloMenuAction} from '../../../types/menu'; 16 | import {renderIcon} from '../../../utils/icon.utils'; 17 | import {toHTMLElement} from '../../../utils/node.utils'; 18 | import {findParagraph} from '../../../utils/paragraph.utils'; 19 | 20 | @Component({ 21 | tag: 'stylo-menus', 22 | styleUrl: 'menus.scss', 23 | shadow: true 24 | }) 25 | export class Menus implements ComponentInterface { 26 | @State() 27 | private top: number | undefined; 28 | 29 | @State() 30 | private menu: StyloMenu | undefined = undefined; 31 | 32 | @Event() 33 | menuActivated: EventEmitter<{paragraph: HTMLElement}>; 34 | 35 | private paragraph: HTMLElement | undefined; 36 | 37 | private destroyListener: () => void | undefined; 38 | 39 | componentDidLoad() { 40 | this.destroyListener = containerStore.onChange('ref', () => { 41 | this.removeContainerListener(); 42 | this.addContainerListener(); 43 | }); 44 | 45 | this.addContainerListener(); 46 | } 47 | 48 | disconnectedCallback() { 49 | this.removeContainerListener(); 50 | 51 | this.destroyListener?.(); 52 | } 53 | 54 | private addContainerListener() { 55 | containerStore.state.ref?.addEventListener('keydown', this.onKeyDown, {passive: true}); 56 | } 57 | 58 | private removeContainerListener() { 59 | containerStore.state.ref?.removeEventListener('keydown', this.onKeyDown); 60 | } 61 | 62 | private onKeyDown = () => { 63 | this.hide(); 64 | }; 65 | 66 | @Listen('resize', {target: 'window'}) 67 | onResize() { 68 | this.hide(); 69 | } 70 | 71 | @Listen('click', {target: 'document', passive: true}) 72 | onClick({target}: MouseEvent | TouchEvent) { 73 | const paragraph: Node | undefined = findParagraph({ 74 | element: target as Node, 75 | container: containerStore.state.ref 76 | }); 77 | 78 | this.paragraph = toHTMLElement(paragraph); 79 | 80 | if (!this.paragraph) { 81 | this.hide(); 82 | return; 83 | } 84 | 85 | this.menuActivated.emit({paragraph: this.paragraph}); 86 | 87 | this.menu = configStore.state.menus?.find(({match}: StyloMenu) => 88 | match({paragraph: this.paragraph}) 89 | ); 90 | 91 | this.top = this.menu && this.paragraph?.offsetTop; 92 | } 93 | 94 | private async selectMenuAction({action}: StyloMenuAction) { 95 | if (!this.paragraph) { 96 | this.hide(); 97 | return; 98 | } 99 | 100 | await action({paragraph: this.paragraph}); 101 | 102 | this.hide(); 103 | } 104 | 105 | private hide() { 106 | this.paragraph = undefined; 107 | this.menu = undefined; 108 | this.top = undefined; 109 | } 110 | 111 | render() { 112 | const style: Record = 113 | this.top === undefined ? {display: 'none'} : {'--menu-top': `${this.top}px`}; 114 | 115 | return ( 116 | 117 | {this.renderMenu()} 118 | 119 | 123 | 124 | ); 125 | } 126 | 127 | private renderMenu() { 128 | return this.menu?.actions.map((action: StyloMenuAction) => this.renderAction(action)); 129 | } 130 | 131 | private renderAction(action: StyloMenuAction) { 132 | const {icon: iconSrc, text} = action; 133 | 134 | const icon: JSX.IntrinsicElements | undefined = renderIcon(iconSrc); 135 | 136 | return ( 137 | await this.selectMenuAction(action)} 139 | label={i18n.state.menus[text] ?? i18n.state.custom[text] ?? text}> 140 | {icon ? icon :
} 141 |
142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/components/toolbars/menu/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-menus 2 | 3 | 4 | 5 | ## Events 6 | 7 | | Event | Description | Type | 8 | | --------------- | ----------- | ------------------------------------------ | 9 | | `menuActivated` | | `CustomEvent<{ paragraph: HTMLElement; }>` | 10 | 11 | ## Dependencies 12 | 13 | ### Used by 14 | 15 | - [stylo-editor](../../editor) 16 | 17 | ### Depends on 18 | 19 | - [stylo-toolbar-triangle](../triangle) 20 | - [stylo-toolbar-button](../button) 21 | 22 | ### Graph 23 | 24 | ```mermaid 25 | graph TD; 26 | stylo-menus --> stylo-toolbar-triangle 27 | stylo-menus --> stylo-toolbar-button 28 | stylo-editor --> stylo-menus 29 | style stylo-menus fill:#f9f,stroke:#333,stroke-width:4px 30 | ``` 31 | 32 | --- 33 | 34 | _Built with [StencilJS](https://stenciljs.com/)_ 35 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/align/align.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../../themes/variables"; 2 | 3 | :host { 4 | display: flex; 5 | 6 | @include variables.colors; 7 | } 8 | 9 | stylo-toolbar-button.active { 10 | --stylo-toolbar-button-color: var(--stylo-toolbar-button-color-active, var(--highlight)); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/align/align.tsx: -------------------------------------------------------------------------------- 1 | import {Component, Event, EventEmitter, h, Host, Prop} from '@stencil/core'; 2 | import configStore from '../../../../../stores/config.store'; 3 | import i18n from '../../../../../stores/i18n.store'; 4 | import {ToolbarAlign} from '../../../../../types/toolbar'; 5 | import {execCommandAlign} from '../../../../../utils/execcommand-align.utils'; 6 | import {execCommandNativeAlign} from '../../../../../utils/execcommnad-native.utils'; 7 | import {IconAlignCenter} from '../../../../icons/align-center'; 8 | import {IconAlignLeft} from '../../../../icons/align-left'; 9 | import {IconAlignRight} from '../../../../icons/align-right'; 10 | 11 | @Component({ 12 | tag: 'stylo-toolbar-align', 13 | styleUrl: 'align.scss', 14 | shadow: true 15 | }) 16 | export class Align { 17 | @Prop() 18 | anchorEvent: MouseEvent | TouchEvent; 19 | 20 | @Prop() 21 | align: ToolbarAlign; 22 | 23 | @Prop() 24 | containerRef: HTMLElement | undefined; 25 | 26 | @Event() 27 | private alignModified: EventEmitter; 28 | 29 | private justifyContent($event: UIEvent, align: ToolbarAlign) { 30 | $event.stopPropagation(); 31 | 32 | if (configStore.state.toolbar.command === 'native') { 33 | execCommandNativeAlign(align); 34 | } else { 35 | execCommandAlign(this.anchorEvent, this.containerRef, align); 36 | } 37 | 38 | this.alignModified.emit(); 39 | } 40 | 41 | render() { 42 | return ( 43 | 44 | ) => 47 | this.justifyContent($event.detail, ToolbarAlign.LEFT) 48 | } 49 | class={this.align === ToolbarAlign.LEFT ? 'active' : undefined}> 50 | 51 | 52 | ) => 55 | this.justifyContent($event.detail, ToolbarAlign.CENTER) 56 | } 57 | class={this.align === ToolbarAlign.CENTER ? 'active' : undefined}> 58 | 59 | 60 | ) => 63 | this.justifyContent($event.detail, ToolbarAlign.RIGHT) 64 | } 65 | class={this.align === ToolbarAlign.RIGHT ? 'active' : undefined}> 66 | 67 | 68 | 69 | ); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/align/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-align 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | -------------- | --------- | ----------- | ---------------------------------------------------------------- | ----------- | 9 | | `align` | `align` | | `ToolbarAlign.CENTER \| ToolbarAlign.LEFT \| ToolbarAlign.RIGHT` | `undefined` | 10 | | `anchorEvent` | -- | | `MouseEvent \| TouchEvent` | `undefined` | 11 | | `containerRef` | -- | | `HTMLElement` | `undefined` | 12 | 13 | ## Events 14 | 15 | | Event | Description | Type | 16 | | --------------- | ----------- | ------------------ | 17 | | `alignModified` | | `CustomEvent` | 18 | 19 | ## Dependencies 20 | 21 | ### Used by 22 | 23 | - [stylo-toolbar](../../toolbar) 24 | 25 | ### Depends on 26 | 27 | - [stylo-toolbar-button](../../../button) 28 | 29 | ### Graph 30 | 31 | ```mermaid 32 | graph TD; 33 | stylo-toolbar-align --> stylo-toolbar-button 34 | stylo-toolbar --> stylo-toolbar-align 35 | style stylo-toolbar-align fill:#f9f,stroke:#333,stroke-width:4px 36 | ``` 37 | 38 | --- 39 | 40 | _Built with [StencilJS](https://stenciljs.com/)_ 41 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/color/color.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | padding: 4px 0; 3 | 4 | stylo-color { 5 | pointer-events: all; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/color/color.tsx: -------------------------------------------------------------------------------- 1 | import {getAnchorElement, hexToRgb} from '@deckdeckgo/utils'; 2 | import {Component, Event, EventEmitter, h, Prop, State} from '@stencil/core'; 3 | import configStore from '../../../../../stores/config.store'; 4 | import {ExecCommandAction} from '../../../../../types/execcommand'; 5 | import {toHTMLElement} from '../../../../../utils/node.utils'; 6 | import {getRange, getSelection} from '../../../../../utils/selection.utils'; 7 | import {findStyleNode} from '../../../../../utils/toolbar.utils'; 8 | 9 | @Component({ 10 | tag: 'stylo-toolbar-color', 11 | styleUrl: 'color.scss', 12 | shadow: true 13 | }) 14 | export class Color { 15 | @Prop() 16 | action: 'color' | 'background-color'; 17 | 18 | @Prop() 19 | containerRef: HTMLElement | undefined; 20 | 21 | @State() 22 | private colorRgb: string | undefined; 23 | 24 | @Event() 25 | execCommand: EventEmitter; 26 | 27 | @Event() 28 | close: EventEmitter; 29 | 30 | private range: Range | undefined; 31 | 32 | componentWillLoad() { 33 | this.initColor(); 34 | } 35 | 36 | connectedCallback() { 37 | this.addListener(); 38 | } 39 | 40 | disconnectedCallback() { 41 | this.removeListener(); 42 | } 43 | 44 | private addListener() { 45 | const listenerElement: HTMLElement | Document = this.containerRef || document; 46 | listenerElement?.addEventListener('click', this.closeToolbar, {passive: true}); 47 | } 48 | 49 | private removeListener() { 50 | const listenerElement: HTMLElement | Document = this.containerRef || document; 51 | listenerElement?.removeEventListener('click', this.closeToolbar); 52 | } 53 | 54 | private closeToolbar = () => { 55 | this.close.emit(); 56 | }; 57 | 58 | private initColor() { 59 | const {range, selection} = getRange(this.containerRef); 60 | this.range = range; 61 | 62 | const anchor: HTMLElement | null = getAnchorElement(selection); 63 | 64 | if (!anchor) { 65 | return; 66 | } 67 | 68 | const style: Node | null = findStyleNode( 69 | anchor, 70 | this.action === 'color' ? 'color' : 'background-color', 71 | this.containerRef 72 | ); 73 | 74 | if (!style) { 75 | return; 76 | } 77 | 78 | const css: CSSStyleDeclaration = window?.getComputedStyle(toHTMLElement(style)); 79 | 80 | this.colorRgb = (this.action === 'color' ? css.color : css.backgroundColor) 81 | .replace('rgb(', '') 82 | .replace(')', ''); 83 | } 84 | 85 | private selectColor($event: CustomEvent) { 86 | const selection: Selection | undefined = getSelection(this.containerRef); 87 | 88 | if (!selection || !$event || !$event.detail) { 89 | return; 90 | } 91 | 92 | if (!this.action) { 93 | return; 94 | } 95 | 96 | selection?.removeAllRanges(); 97 | selection?.addRange(this.range); 98 | 99 | const observer: MutationObserver = new MutationObserver((_mutations: MutationRecord[]) => { 100 | observer.disconnect(); 101 | 102 | // No node were added so the style was modified 103 | this.range = selection?.getRangeAt(0); 104 | }); 105 | 106 | const anchorNode: HTMLElement | null = getAnchorElement(selection); 107 | 108 | if (!anchorNode) { 109 | return; 110 | } 111 | 112 | observer.observe(anchorNode, {childList: true}); 113 | 114 | this.execCommand.emit({ 115 | cmd: 'style', 116 | detail: { 117 | style: this.action, 118 | value: $event.detail.hex, 119 | initial: (element: HTMLElement | null) => { 120 | const rgb: string = hexToRgb($event.detail.hex); 121 | return ( 122 | element && 123 | (element.style[this.action] === $event.detail.hex || 124 | element.style[this.action] === `rgb(${rgb})`) 125 | ); 126 | } 127 | } 128 | }); 129 | } 130 | 131 | render() { 132 | return ( 133 | this.selectColor($event)} 136 | palette={configStore.state.toolbar.palette}> 137 | ); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/color/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-color 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | -------------- | --------- | ----------- | ------------------------------- | ----------- | 9 | | `action` | `action` | | `"background-color" \| "color"` | `undefined` | 10 | | `containerRef` | -- | | `HTMLElement` | `undefined` | 11 | 12 | ## Events 13 | 14 | | Event | Description | Type | 15 | | ------------- | ----------- | -------------------------------- | 16 | | `close` | | `CustomEvent` | 17 | | `execCommand` | | `CustomEvent` | 18 | 19 | ## Dependencies 20 | 21 | ### Used by 22 | 23 | - [stylo-toolbar](../../toolbar) 24 | 25 | ### Depends on 26 | 27 | - [stylo-color](../../../../color/color) 28 | 29 | ### Graph 30 | 31 | ```mermaid 32 | graph TD; 33 | stylo-toolbar-color --> stylo-color 34 | stylo-color --> stylo-color-input 35 | stylo-toolbar --> stylo-toolbar-color 36 | style stylo-toolbar-color fill:#f9f,stroke:#333,stroke-width:4px 37 | ``` 38 | 39 | --- 40 | 41 | _Built with [StencilJS](https://stenciljs.com/)_ 42 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/font-size/font-size.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../../themes/variables"; 2 | 3 | :host { 4 | @include variables.colors; 5 | 6 | display: flex; 7 | } 8 | 9 | stylo-toolbar-button.active { 10 | --stylo-toolbar-button-color: var(--stylo-toolbar-button-color-active, var(--highlight)); 11 | } 12 | 13 | stylo-toolbar-button:first-of-type { 14 | --stylo-toolbar-button-font-size: 0.8rem; 15 | } 16 | 17 | stylo-toolbar-button:nth-of-type(2) { 18 | --stylo-toolbar-button-font-size: 1rem; 19 | } 20 | 21 | stylo-toolbar-button:nth-of-type(3) { 22 | --stylo-toolbar-button-font-size: 1.2rem; 23 | } 24 | 25 | stylo-toolbar-button:nth-of-type(5) { 26 | --stylo-toolbar-button-font-size: 1.6rem; 27 | } 28 | 29 | stylo-toolbar-button:nth-of-type(6) { 30 | --stylo-toolbar-button-font-size: 1.8rem; 31 | } 32 | 33 | stylo-toolbar-button:nth-of-type(7) { 34 | --stylo-toolbar-button-font-size: 2rem; 35 | } 36 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/font-size/font-size.tsx: -------------------------------------------------------------------------------- 1 | import {Component, Event, EventEmitter, h, Host, Prop} from '@stencil/core'; 2 | import i18n from '../../../../../stores/i18n.store'; 3 | import {ExecCommandAction} from '../../../../../types/execcommand'; 4 | import {ToolbarFontSize} from '../../../../../types/toolbar'; 5 | 6 | @Component({ 7 | tag: 'stylo-toolbar-font-size', 8 | styleUrl: 'font-size.scss', 9 | shadow: true 10 | }) 11 | export class FontSize { 12 | @Prop({mutable: true}) 13 | fontSize: ToolbarFontSize; 14 | 15 | @Event() 16 | private execCommand: EventEmitter; 17 | 18 | private modifyFontSize($event: UIEvent, size: ToolbarFontSize) { 19 | $event.stopPropagation(); 20 | 21 | const value: string = Object.keys(ToolbarFontSize).find((key) => ToolbarFontSize[key] === size); 22 | 23 | this.execCommand.emit({ 24 | cmd: 'style', 25 | detail: { 26 | style: 'font-size', 27 | value: value.toLowerCase().replace('_', '-'), 28 | initial: (element: HTMLElement | null) => 29 | element && element.style['font-size'] === value.toLowerCase().replace('_', '-') 30 | } 31 | }); 32 | 33 | this.fontSize = size; 34 | } 35 | 36 | render() { 37 | return ( 38 | 39 | {this.renderAction(ToolbarFontSize.X_SMALL)} 40 | {this.renderAction(ToolbarFontSize.SMALL)} 41 | {this.renderAction(ToolbarFontSize.MEDIUM)} 42 | {this.renderAction(ToolbarFontSize.LARGE)} 43 | {this.renderAction(ToolbarFontSize.X_LARGE)} 44 | {this.renderAction(ToolbarFontSize.XX_LARGE)} 45 | {this.renderAction(ToolbarFontSize.XXX_LARGE)} 46 | 47 | ); 48 | } 49 | 50 | private renderAction(size: ToolbarFontSize) { 51 | return ( 52 | ) => this.modifyFontSize($event.detail, size)} 55 | class={this.fontSize === size ? 'active' : undefined}> 56 | {size.toString()} 57 | 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/font-size/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-font-size 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | ---------- | ----------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | 9 | | `fontSize` | `font-size` | | `ToolbarFontSize.LARGE \| ToolbarFontSize.MEDIUM \| ToolbarFontSize.SMALL \| ToolbarFontSize.XXX_LARGE \| ToolbarFontSize.XX_LARGE \| ToolbarFontSize.X_LARGE \| ToolbarFontSize.X_SMALL` | `undefined` | 10 | 11 | ## Events 12 | 13 | | Event | Description | Type | 14 | | ------------- | ----------- | -------------------------------- | 15 | | `execCommand` | | `CustomEvent` | 16 | 17 | ## Dependencies 18 | 19 | ### Used by 20 | 21 | - [stylo-toolbar](../../toolbar) 22 | 23 | ### Depends on 24 | 25 | - [stylo-toolbar-button](../../../button) 26 | 27 | ### Graph 28 | 29 | ```mermaid 30 | graph TD; 31 | stylo-toolbar-font-size --> stylo-toolbar-button 32 | stylo-toolbar --> stylo-toolbar-font-size 33 | style stylo-toolbar-font-size fill:#f9f,stroke:#333,stroke-width:4px 34 | ``` 35 | 36 | --- 37 | 38 | _Built with [StencilJS](https://stenciljs.com/)_ 39 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/link/link.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | width: 100%; 3 | height: 100%; 4 | 5 | z-index: var(--stylo-toolbar-link-zindex, 2); 6 | 7 | input { 8 | pointer-events: visible; 9 | 10 | background: transparent; 11 | width: 97%; 12 | height: 100%; 13 | color: var(--stylo-toolbar-link-color, black); 14 | border: none; 15 | outline: 0; 16 | font-size: 16px; 17 | 18 | &::placeholder { 19 | color: var(--stylo-toolbar-link-placeholder-color, black); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/link/link.tsx: -------------------------------------------------------------------------------- 1 | import {Component, ComponentInterface, Event, EventEmitter, h, Host, Prop} from '@stencil/core'; 2 | import {ToolbarActions, ToolbarAnchorLink} from '../../../../../types/toolbar'; 3 | import {createLink} from '../../../../../utils/link.utils'; 4 | import {toHTMLElement} from '../../../../../utils/node.utils'; 5 | import {findParagraph} from '../../../../../utils/paragraph.utils'; 6 | 7 | @Component({ 8 | tag: 'stylo-toolbar-link', 9 | styleUrl: 'link.scss', 10 | shadow: true 11 | }) 12 | export class Link implements ComponentInterface { 13 | private linkUrl: string; 14 | 15 | @Prop() 16 | containerRef: HTMLElement | undefined; 17 | 18 | @Prop() 19 | toolbarActions: ToolbarActions; 20 | 21 | @Prop() 22 | anchorLink: ToolbarAnchorLink; 23 | 24 | @Prop() 25 | linkCreated: EventEmitter; 26 | 27 | @Event() 28 | linkModified: EventEmitter; 29 | 30 | @Event() 31 | close: EventEmitter; 32 | 33 | private input: HTMLInputElement | undefined; 34 | 35 | componentDidLoad() { 36 | setTimeout(() => this.input?.focus(), 250); 37 | } 38 | 39 | connectedCallback() { 40 | this.addListener(); 41 | } 42 | 43 | disconnectedCallback() { 44 | this.removeListener(); 45 | } 46 | 47 | private addListener() { 48 | const listenerElement: HTMLElement | Document = this.containerRef || document; 49 | listenerElement?.addEventListener('click', this.closeToolbar, {passive: true}); 50 | } 51 | 52 | private removeListener() { 53 | const listenerElement: HTMLElement | Document = this.containerRef || document; 54 | listenerElement?.removeEventListener('click', this.closeToolbar); 55 | } 56 | 57 | private closeToolbar = () => { 58 | this.close.emit(); 59 | }; 60 | 61 | private handleLinkInput($event: UIEvent) { 62 | this.linkUrl = ($event.target as InputTargetEvent).value; 63 | } 64 | 65 | private createLink() { 66 | if (!this.anchorLink) { 67 | return; 68 | } 69 | 70 | const {range} = this.anchorLink; 71 | 72 | if (!range) { 73 | return; 74 | } 75 | 76 | if (!this.linkUrl || this.linkUrl.length <= 0) { 77 | return; 78 | } 79 | 80 | createLink({range, linkUrl: this.linkUrl}); 81 | 82 | const container: Node | undefined = findParagraph({ 83 | element: range.commonAncestorContainer, 84 | container: this.containerRef 85 | }); 86 | 87 | if (!container) { 88 | return; 89 | } 90 | 91 | this.linkCreated.emit(toHTMLElement(container)); 92 | } 93 | 94 | private handleLinkEnter($event: KeyboardEvent) { 95 | if (!$event) { 96 | return; 97 | } 98 | 99 | if ( 100 | this.toolbarActions === ToolbarActions.STYLE && 101 | ($event.key.toLowerCase() === 'backspace' || $event.key.toLowerCase() === 'delete') 102 | ) { 103 | this.linkModified.emit(false); 104 | } else if ( 105 | this.toolbarActions === ToolbarActions.LINK && 106 | $event.key.toLowerCase() === 'enter' 107 | ) { 108 | this.createLink(); 109 | this.linkModified.emit(true); 110 | } 111 | } 112 | 113 | render() { 114 | return ( 115 | 116 | (this.input = el as HTMLInputElement)} 118 | placeholder="Add a link..." 119 | onInput={($event: UIEvent) => this.handleLinkInput($event)} 120 | onKeyUp={($event: KeyboardEvent) => this.handleLinkEnter($event)}> 121 | 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/link/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-link 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | ---------------- | ----------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | 9 | | `anchorLink` | -- | | `ToolbarAnchorLink` | `undefined` | 10 | | `containerRef` | -- | | `HTMLElement` | `undefined` | 11 | | `linkCreated` | -- | | `EventEmitter` | `undefined` | 12 | | `toolbarActions` | `toolbar-actions` | | `ToolbarActions.ALIGNMENT \| ToolbarActions.BACKGROUND_COLOR \| ToolbarActions.COLOR \| ToolbarActions.FONT_SIZE \| ToolbarActions.LINK \| ToolbarActions.LIST \| ToolbarActions.STYLE` | `undefined` | 13 | 14 | ## Events 15 | 16 | | Event | Description | Type | 17 | | -------------- | ----------- | ---------------------- | 18 | | `close` | | `CustomEvent` | 19 | | `linkModified` | | `CustomEvent` | 20 | 21 | ## Dependencies 22 | 23 | ### Used by 24 | 25 | - [stylo-toolbar](../../toolbar) 26 | 27 | ### Graph 28 | 29 | ```mermaid 30 | graph TD; 31 | stylo-toolbar --> stylo-toolbar-link 32 | style stylo-toolbar-link fill:#f9f,stroke:#333,stroke-width:4px 33 | ``` 34 | 35 | --- 36 | 37 | _Built with [StencilJS](https://stenciljs.com/)_ 38 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/list/list.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../../themes/variables"; 2 | 3 | :host { 4 | display: flex; 5 | 6 | @include variables.colors; 7 | } 8 | 9 | stylo-toolbar-button.active { 10 | --stylo-toolbar-button-color: var(--stylo-toolbar-button-color-active, var(--highlight)); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/list/list.tsx: -------------------------------------------------------------------------------- 1 | import {Component, Event, EventEmitter, h, Host, Prop} from '@stencil/core'; 2 | import i18n from '../../../../../stores/i18n.store'; 3 | import {ExecCommandAction} from '../../../../../types/execcommand'; 4 | import {ToolbarList} from '../../../../../types/toolbar'; 5 | import {IconOl} from '../../../../icons/ol'; 6 | import {IconUl} from '../../../../icons/ul'; 7 | 8 | @Component({ 9 | tag: 'stylo-toolbar-list', 10 | styleUrl: 'list.scss', 11 | shadow: true 12 | }) 13 | export class AlignActions { 14 | @Prop() 15 | list: ToolbarList; 16 | 17 | @Event() 18 | private execCommand: EventEmitter; 19 | 20 | private toggleList(e: UIEvent, type: 'ol' | 'ul') { 21 | e.stopPropagation(); 22 | 23 | this.execCommand.emit({ 24 | cmd: 'list', 25 | detail: { 26 | type 27 | } 28 | }); 29 | } 30 | 31 | render() { 32 | return ( 33 | 34 | ) => this.toggleList($event.detail, 'ol')} 37 | class={this.list === ToolbarList.ORDERED ? 'active' : undefined}> 38 | 39 | 40 | 41 | ) => this.toggleList($event.detail, 'ul')} 44 | class={this.list === ToolbarList.UNORDERED ? 'active' : undefined}> 45 | 46 | 47 | 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/list/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-list 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | -------- | --------- | ----------- | ---------------------------------------------- | ----------- | 9 | | `list` | `list` | | `ToolbarList.ORDERED \| ToolbarList.UNORDERED` | `undefined` | 10 | 11 | ## Events 12 | 13 | | Event | Description | Type | 14 | | ------------- | ----------- | -------------------------------- | 15 | | `execCommand` | | `CustomEvent` | 16 | 17 | ## Dependencies 18 | 19 | ### Used by 20 | 21 | - [stylo-toolbar](../../toolbar) 22 | 23 | ### Depends on 24 | 25 | - [stylo-toolbar-button](../../../button) 26 | 27 | ### Graph 28 | 29 | ```mermaid 30 | graph TD; 31 | stylo-toolbar-list --> stylo-toolbar-button 32 | stylo-toolbar --> stylo-toolbar-list 33 | style stylo-toolbar-list fill:#f9f,stroke:#333,stroke-width:4px 34 | ``` 35 | 36 | --- 37 | 38 | _Built with [StencilJS](https://stenciljs.com/)_ 39 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/style/style.tsx: -------------------------------------------------------------------------------- 1 | import {Fragment, FunctionalComponent, h} from '@stencil/core'; 2 | import configStore from '../../../../../stores/config.store'; 3 | import i18n from '../../../../../stores/i18n.store'; 4 | import {ExecCommandAction} from '../../../../../types/execcommand'; 5 | import {ToolbarActions, ToolbarAlign, ToolbarList} from '../../../../../types/toolbar'; 6 | import {IconAlignCenter} from '../../../../icons/align-center'; 7 | import {IconAlignLeft} from '../../../../icons/align-left'; 8 | import {IconAlignRight} from '../../../../icons/align-right'; 9 | import {IconColor} from '../../../../icons/color'; 10 | import {IconLink} from '../../../../icons/link'; 11 | import {IconOl} from '../../../../icons/ol'; 12 | import {IconPalette} from '../../../../icons/palette'; 13 | import {IconUl} from '../../../../icons/ul'; 14 | 15 | interface StyleProps { 16 | align: ToolbarAlign; 17 | list: ToolbarList | undefined; 18 | disabledTitle: boolean; 19 | bold: 'bold' | 'initial' | undefined; 20 | italic: 'italic' | 'initial' | undefined; 21 | underline: 'underline' | 'initial' | undefined; 22 | strikethrough: 'strikethrough' | 'initial' | undefined; 23 | link: boolean; 24 | 25 | switchToolbarActions: (actions: ToolbarActions) => void; 26 | onExecCommand: ($event: CustomEvent) => void; 27 | toggleLink: () => void; 28 | } 29 | 30 | export const Style: FunctionalComponent = ({ 31 | align, 32 | list, 33 | switchToolbarActions, 34 | disabledTitle, 35 | bold, 36 | italic, 37 | strikethrough, 38 | underline, 39 | link, 40 | onExecCommand, 41 | toggleLink 42 | }: StyleProps) => { 43 | const renderSeparator = () => ; 44 | 45 | const renderLinkSeparator = () => { 46 | if (!list && !align) { 47 | return undefined; 48 | } 49 | 50 | return renderSeparator(); 51 | }; 52 | 53 | const renderListAction = () => { 54 | return ( 55 | switchToolbarActions(ToolbarActions.LIST)} 57 | label={i18n.state.toolbar.style_list}> 58 | {list === ToolbarList.UNORDERED ? : } 59 | 60 | ); 61 | }; 62 | 63 | const renderAlignAction = () => { 64 | if (!configStore.state.toolbar.style.align) { 65 | return undefined; 66 | } 67 | 68 | return ( 69 | switchToolbarActions(ToolbarActions.ALIGNMENT)} 71 | label={i18n.state.toolbar.style_align}> 72 | {align === ToolbarAlign.LEFT ? ( 73 | 74 | ) : align === ToolbarAlign.CENTER ? ( 75 | 76 | ) : ( 77 | 78 | )} 79 | 80 | ); 81 | }; 82 | 83 | const renderFontSizeAction = () => { 84 | if (!configStore.state.toolbar.style.fontSize) { 85 | return undefined; 86 | } 87 | 88 | return ( 89 | 90 | switchToolbarActions(ToolbarActions.FONT_SIZE)} 92 | label={i18n.state.toolbar.style_font_size}> 93 | 94 | AA 95 | 96 | 97 | 98 | {renderSeparator()} 99 | 100 | ); 101 | }; 102 | 103 | const renderColorActions = () => { 104 | const result = [ 105 | switchToolbarActions(ToolbarActions.COLOR)} 107 | label={i18n.state.toolbar.style_color}> 108 | 109 | 110 | ]; 111 | 112 | if (configStore.state.toolbar.style.backgroundColor) { 113 | result.push( 114 | switchToolbarActions(ToolbarActions.BACKGROUND_COLOR)} 116 | label={i18n.state.toolbar.style_background}> 117 | 118 | 119 | ); 120 | } 121 | 122 | return result; 123 | }; 124 | 125 | return ( 126 | 127 | ) => 134 | onExecCommand($event) 135 | }> 136 | 137 | {renderSeparator()} 138 | 139 | {renderFontSizeAction()} 140 | 141 | {renderColorActions()} 142 | 143 | {renderSeparator()} 144 | 145 | {renderAlignAction()} 146 | 147 | {renderListAction()} 148 | 149 | {renderLinkSeparator()} 150 | 151 | 155 | 156 | 157 | 158 | ); 159 | }; 160 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/text/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-style 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | --------------- | ---------------- | ----------- | --------- | ----------- | 9 | | `bold` | `bold` | | `boolean` | `undefined` | 10 | | `disabledTitle` | `disabled-title` | | `boolean` | `false` | 11 | | `italic` | `italic` | | `boolean` | `undefined` | 12 | | `strikethrough` | `strikethrough` | | `boolean` | `undefined` | 13 | | `underline` | `underline` | | `boolean` | `undefined` | 14 | 15 | ## Events 16 | 17 | | Event | Description | Type | 18 | | ------------- | ----------- | -------------------------------- | 19 | | `execCommand` | | `CustomEvent` | 20 | 21 | ## Dependencies 22 | 23 | ### Used by 24 | 25 | - [stylo-toolbar](../../toolbar) 26 | 27 | ### Depends on 28 | 29 | - [stylo-toolbar-button](../../../button) 30 | 31 | ### Graph 32 | 33 | ```mermaid 34 | graph TD; 35 | stylo-toolbar-text --> stylo-toolbar-button 36 | stylo-toolbar --> stylo-toolbar-text 37 | style stylo-toolbar-text fill:#f9f,stroke:#333,stroke-width:4px 38 | ``` 39 | 40 | --- 41 | 42 | _Built with [StencilJS](https://stenciljs.com/)_ 43 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/text/text.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../../themes/variables"; 2 | 3 | :host { 4 | display: inline-flex; 5 | 6 | @include variables.colors; 7 | } 8 | 9 | stylo-toolbar-button { 10 | margin: 0 2px; 11 | 12 | &.italic { 13 | span { 14 | font-style: italic; 15 | } 16 | } 17 | 18 | &.underline { 19 | &.active { 20 | span { 21 | border-bottom: 1px solid var(--stylo-toolbar-button-color-active, var(--highlight)); 22 | } 23 | } 24 | 25 | span { 26 | border-bottom: 1px solid var(--stylo-toolbar-button-color, black); 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/actions/text/text.tsx: -------------------------------------------------------------------------------- 1 | import {Component, Event, EventEmitter, h, Host, Prop} from '@stencil/core'; 2 | import i18n from '../../../../../stores/i18n.store'; 3 | import {ExecCommandAction} from '../../../../../types/execcommand'; 4 | import { 5 | actionBold, 6 | actionItalic, 7 | actionStrikeThrough, 8 | actionUnderline 9 | } from '../../../../../utils/execcomand-text.utils'; 10 | 11 | @Component({ 12 | tag: 'stylo-toolbar-text', 13 | styleUrl: 'text.scss', 14 | shadow: true 15 | }) 16 | export class Text { 17 | @Prop() 18 | disabledTitle: boolean = false; 19 | 20 | @Prop() 21 | bold: boolean; 22 | 23 | @Prop() 24 | italic: boolean; 25 | 26 | @Prop() 27 | underline: boolean; 28 | 29 | @Prop() 30 | strikethrough: boolean; 31 | 32 | @Event() 33 | private execCommand: EventEmitter; 34 | 35 | private styleBold($event: UIEvent) { 36 | $event.stopPropagation(); 37 | 38 | this.execCommand.emit(actionBold); 39 | } 40 | 41 | private styleItalic($event: UIEvent) { 42 | $event.stopPropagation(); 43 | 44 | this.execCommand.emit(actionItalic); 45 | } 46 | 47 | private styleUnderline($event: UIEvent) { 48 | $event.stopPropagation(); 49 | 50 | this.execCommand.emit(actionUnderline); 51 | } 52 | 53 | private styleStrikeThrough($event: UIEvent) { 54 | $event.stopPropagation(); 55 | 56 | this.execCommand.emit(actionStrikeThrough); 57 | } 58 | 59 | render() { 60 | return ( 61 | 62 | ) => this.styleBold($event.detail)} 65 | disableAction={this.disabledTitle} 66 | cssClass={this.bold ? 'active' : undefined} 67 | class="bold"> 68 | B 69 | 70 | ) => this.styleItalic($event.detail)} 73 | cssClass={this.italic ? 'active' : undefined} 74 | class="italic"> 75 | I 76 | 77 | ) => this.styleUnderline($event.detail)} 80 | cssClass={this.underline ? 'active' : undefined} 81 | class={this.underline ? 'active underline' : 'underline'}> 82 | U 83 | 84 | ) => this.styleStrikeThrough($event.detail)} 87 | cssClass={this.strikethrough ? 'active' : undefined} 88 | class="strikethrough"> 89 | S 90 | 91 | 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/separator/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-separator 2 | 3 | 4 | 5 | ## Dependencies 6 | 7 | ### Used by 8 | 9 | - [stylo-toolbar](../toolbar) 10 | 11 | ### Graph 12 | 13 | ```mermaid 14 | graph TD; 15 | stylo-toolbar --> stylo-toolbar-separator 16 | style stylo-toolbar-separator fill:#f9f,stroke:#333,stroke-width:4px 17 | ``` 18 | 19 | --- 20 | 21 | _Built with [StencilJS](https://stenciljs.com/)_ 22 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/separator/separator.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../themes/variables"; 2 | 3 | :host { 4 | @include variables.colors; 5 | } 6 | 7 | div.separator { 8 | display: inline-block; 9 | vertical-align: middle; 10 | width: 1px; 11 | min-width: 1px; 12 | margin: 0 6px; 13 | height: 24px; 14 | background: var(--stylo-toolbar-separator-background, var(--light)); 15 | } 16 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/separator/separator.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h} from '@stencil/core'; 2 | 3 | @Component({ 4 | tag: 'stylo-toolbar-separator', 5 | styleUrl: 'separator.scss', 6 | shadow: true 7 | }) 8 | export class Separator { 9 | render() { 10 | return
; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/toolbar/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | -------------- | --------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | 9 | | `config` | -- | If used in a standalone mode, the configuration can also be set. It will be applied over the default configuration. | `{ palette?: StyloPalette[]; command?: "custom" \| "native"; style?: { list: boolean; align: boolean; fontSize: boolean; backgroundColor: boolean; }; }` | `undefined` | 10 | | `containerRef` | -- | To attach the inline editor event listeners to a specific container instead of the document | `HTMLElement` | `undefined` | 11 | 12 | ## Events 13 | 14 | | Event | Description | Type | 15 | | ------------------ | -------------------------------------------------------------------------------------------------------------- | -------------------------- | 16 | | `linkCreated` | Triggered when a link is created by the user. The event detail is the container | `CustomEvent` | 17 | | `styleDidChange` | Triggered when the style is modified (bold, italic, color, alignment, etc.). The event detail is the container | `CustomEvent` | 18 | | `toolbarActivated` | | `CustomEvent` | 19 | 20 | ## CSS Custom Properties 21 | 22 | | Name | Description | 23 | | ----------------------------------------- | ---------------------------------------------------------------------------------- | 24 | | `--stylo-toolbar-button-color` | The buttons color @default black | 25 | | `--stylo-toolbar-button-color-active` | The color of the buttons when active @default --highlight (see \_variables.scss) | 26 | | `--stylo-toolbar-button-color-disabled` | The color of the buttons when disabled @default --light (see \_variables.scss) | 27 | | `--stylo-toolbar-button-display-disabled` | Per default the disable elements on title elements are not displayed @default none | 28 | | `--stylo-toolbar-button-font-family` | The buttons font family @default inherit | 29 | | `--stylo-toolbar-button-font-size` | The buttons font size @default 1.4rem | 30 | | `--stylo-toolbar-button-zindex` | The z-index of the action buttons @default 2 | 31 | | `--stylo-toolbar-link-color` | The color property of the link input @default black | 32 | | `--stylo-toolbar-link-placeholder-color` | The color of the place holder of the link input @default black | 33 | | `--stylo-toolbar-link-zindex` | The z-index property of the link input @default 2 | 34 | | `--stylo-toolbar-min-height` | The height of the toolbar @default 44px | 35 | | `--stylo-toolbar-min-width` | The width of the toolbar @default 280px | 36 | | `--stylo-toolbar-separator-background` | The background of the separator @default #f4f5f8 | 37 | | `--stylo-toolbar-triangle-box-shadow` | The box-shadow of the triangle @default 0 0 8px 0 rgba(0, 0, 0, 0.1) | 38 | | `--stylo-toolbar-triangle-center` | The center position of the triangle (overwrite --stylo-toolbar-triangle-start) | 39 | | `--stylo-toolbar-triangle-start` | The left position of the triangle @default 8px | 40 | | `--stylo-toolbar-zindex` | The z-Index of the toolbar @default 2 | 41 | 42 | ## Dependencies 43 | 44 | ### Used by 45 | 46 | - [stylo-editor](../../../editor) 47 | 48 | ### Depends on 49 | 50 | - [stylo-toolbar-triangle](../../triangle) 51 | - [stylo-toolbar-link](../actions/link) 52 | - [stylo-toolbar-color](../actions/color) 53 | - [stylo-toolbar-align](../actions/align) 54 | - [stylo-toolbar-list](../actions/list) 55 | - [stylo-toolbar-font-size](../actions/font-size) 56 | - [stylo-toolbar-separator](../separator) 57 | - [stylo-toolbar-button](../../button) 58 | - [stylo-toolbar-text](../actions/text) 59 | 60 | ### Graph 61 | 62 | ```mermaid 63 | graph TD; 64 | stylo-toolbar --> stylo-toolbar-triangle 65 | stylo-toolbar --> stylo-toolbar-link 66 | stylo-toolbar --> stylo-toolbar-color 67 | stylo-toolbar --> stylo-toolbar-align 68 | stylo-toolbar --> stylo-toolbar-list 69 | stylo-toolbar --> stylo-toolbar-font-size 70 | stylo-toolbar --> stylo-toolbar-separator 71 | stylo-toolbar --> stylo-toolbar-button 72 | stylo-toolbar --> stylo-toolbar-text 73 | stylo-toolbar-color --> stylo-color 74 | stylo-color --> stylo-color-input 75 | stylo-toolbar-align --> stylo-toolbar-button 76 | stylo-toolbar-list --> stylo-toolbar-button 77 | stylo-toolbar-font-size --> stylo-toolbar-button 78 | stylo-toolbar-text --> stylo-toolbar-button 79 | stylo-editor --> stylo-toolbar 80 | style stylo-toolbar fill:#f9f,stroke:#333,stroke-width:4px 81 | ``` 82 | 83 | --- 84 | 85 | _Built with [StencilJS](https://stenciljs.com/)_ 86 | -------------------------------------------------------------------------------- /src/components/toolbars/toolbar/toolbar/toolbar.scss: -------------------------------------------------------------------------------- 1 | @use "../../../../themes/overlay"; 2 | @use "../../../../themes/variables"; 3 | 4 | :host { 5 | direction: ltr; 6 | position: absolute; 7 | 8 | @include variables.colors; 9 | } 10 | 11 | div.tools { 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | 16 | visibility: hidden; 17 | opacity: 0; 18 | height: 0; 19 | 20 | animation: 0s ease 0s 1 normal none running none; 21 | transition: opacity 0.1s ease-out; 22 | 23 | position: absolute; 24 | top: var(--actions-top); 25 | left: var(--actions-left); 26 | right: var(--actions-right); 27 | transform: translate(var(--actions-translate-x), var(--actions-translate-y)); 28 | 29 | /** 30 | * @prop --stylo-toolbar-zindex: The z-Index of the toolbar 31 | * @default 2 32 | */ 33 | z-index: var(--stylo-toolbar-zindex, 2); 34 | 35 | @include overlay.overlay; 36 | @include overlay.dialog; 37 | @include overlay.toolbar; 38 | 39 | /** 40 | * @prop --stylo-toolbar-min-width: The width of the toolbar 41 | * @default 280px 42 | */ 43 | min-width: var(--stylo-toolbar-min-width, 280px); 44 | 45 | &.tools-activated { 46 | visibility: visible; 47 | opacity: 1; 48 | 49 | height: unset; 50 | 51 | /** 52 | * @prop --stylo-toolbar-min-height: The height of the toolbar 53 | * @default 44px 54 | */ 55 | min-height: var(--stylo-toolbar-min-height, 44px); 56 | } 57 | } 58 | 59 | /* Other SCSS files of the component */ 60 | 61 | /** 62 | * @prop --stylo-toolbar-button-color-active: The color of the buttons when active 63 | * @default --highlight (see _variables.scss) 64 | */ 65 | /** 66 | * @prop --stylo-toolbar-link-zindex: The z-index property of the link input 67 | * @default 2 68 | */ 69 | /** 70 | * @prop --stylo-toolbar-link-color: The color property of the link input 71 | * @default black 72 | */ 73 | /** 74 | * @prop --stylo-toolbar-link-placeholder-color: The color of the place holder of the link input 75 | * @default black 76 | */ 77 | /** 78 | * @prop --stylo-toolbar-button-color: The buttons color 79 | * @default black 80 | */ 81 | /** 82 | * @prop --stylo-toolbar-button-zindex: The z-index of the action buttons 83 | * @default 2 84 | */ 85 | /** 86 | * @prop --stylo-toolbar-button-font-size: The buttons font size 87 | * @default 1.4rem 88 | */ 89 | /** 90 | * @prop --stylo-toolbar-button-font-family: The buttons font family 91 | * @default inherit 92 | */ 93 | /** 94 | * @prop --stylo-toolbar-button-color-disabled: The color of the buttons when disabled 95 | * @default --light (see _variables.scss) 96 | */ 97 | /** 98 | * @prop --stylo-toolbar-button-display-disabled: Per default the disable elements on title elements are not displayed 99 | * @default none 100 | */ 101 | /** 102 | * @prop --stylo-toolbar-separator-background: The background of the separator 103 | * @default #f4f5f8 104 | */ 105 | /** 106 | * @prop --stylo-toolbar-triangle-start: The left position of the triangle 107 | * @default 8px 108 | */ 109 | /** 110 | * @prop --stylo-toolbar-triangle-center: The center position of the triangle (overwrite --stylo-toolbar-triangle-start) 111 | */ 112 | /** 113 | * @prop --stylo-toolbar-triangle-box-shadow: The box-shadow of the triangle 114 | * @default 0 0 8px 0 rgba(0, 0, 0, 0.1) 115 | */ 116 | -------------------------------------------------------------------------------- /src/components/toolbars/triangle/readme.md: -------------------------------------------------------------------------------- 1 | # stylo-toolbar-triangle 2 | 3 | 4 | 5 | ## Properties 6 | 7 | | Property | Attribute | Description | Type | Default | 8 | | -------- | --------- | ----------- | --------- | ----------- | 9 | | `mobile` | `mobile` | | `boolean` | `undefined` | 10 | 11 | ## CSS Custom Properties 12 | 13 | | Name | Description | 14 | | ------------------------------ | --------------------------------------- | 15 | | `--stylo-triangle-drop-shadow` | drop-shadow of the triangle | 16 | | `--stylo-triangle-width` | The width of the triangle @default 22px | 17 | 18 | ## Dependencies 19 | 20 | ### Used by 21 | 22 | - [stylo-menus](../menu) 23 | - [stylo-toolbar](../toolbar/toolbar) 24 | 25 | ### Graph 26 | 27 | ```mermaid 28 | graph TD; 29 | stylo-menus --> stylo-toolbar-triangle 30 | stylo-toolbar --> stylo-toolbar-triangle 31 | style stylo-toolbar-triangle fill:#f9f,stroke:#333,stroke-width:4px 32 | ``` 33 | 34 | --- 35 | 36 | _Built with [StencilJS](https://stenciljs.com/)_ 37 | -------------------------------------------------------------------------------- /src/components/toolbars/triangle/triangle.scss: -------------------------------------------------------------------------------- 1 | :host(.top) { 2 | div.triangle { 3 | bottom: unset; 4 | top: 0; 5 | transform: rotate(180deg) translate(50%, 100%); 6 | } 7 | } 8 | 9 | div.triangle { 10 | z-index: 1; 11 | 12 | /** 13 | * @prop --stylo-triangle-width: The width of the triangle 14 | * @default 22px 15 | */ 16 | --triangle-width: var(--stylo-triangle-width, 22px); 17 | --triangle-half-width: calc(var(--triangle-width) / 2); 18 | --triangle-start: calc(var(--stylo-toolbar-triangle-center) - var(--triangle-half-width)); 19 | 20 | position: absolute; 21 | width: 0; 22 | height: 0; 23 | bottom: 0; 24 | left: var(--triangle-start, var(--stylo-toolbar-triangle-start)); 25 | transform: translate(0, 100%); 26 | 27 | background: transparent; 28 | 29 | border-left: var(--triangle-half-width) solid transparent; 30 | border-right: var(--triangle-half-width) solid transparent; 31 | border-top: var(--triangle-half-width) solid var(--stylo-background, var(--white)); 32 | 33 | /** 34 | * @prop --stylo-triangle-drop-shadow: drop-shadow of the triangle 35 | */ 36 | filter: drop-shadow(var(--stylo-triangle-drop-shadow)); 37 | } 38 | -------------------------------------------------------------------------------- /src/components/toolbars/triangle/triangle.tsx: -------------------------------------------------------------------------------- 1 | import {Component, h, Prop} from '@stencil/core'; 2 | 3 | @Component({ 4 | tag: 'stylo-toolbar-triangle', 5 | styleUrl: 'triangle.scss', 6 | shadow: true 7 | }) 8 | export class Separator { 9 | @Prop() 10 | mobile: boolean; 11 | 12 | render() { 13 | if (this.mobile) { 14 | return undefined; 15 | } 16 | 17 | return
; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/events/placeholder.events.ts: -------------------------------------------------------------------------------- 1 | import configStore from '../stores/config.store'; 2 | import containerStore from '../stores/container.store'; 3 | import {elementIndex, isTextNode} from '../utils/node.utils'; 4 | import {findParagraph, isParagraphEmpty} from '../utils/paragraph.utils'; 5 | 6 | export class PlaceholderEvents { 7 | private editorRef: HTMLElement | undefined; 8 | 9 | init({editorRef}: {editorRef: HTMLElement | undefined}) { 10 | this.editorRef = editorRef; 11 | 12 | this.editorRef?.addEventListener('selectParagraph', this.onSelectParagraph); 13 | containerStore.state.ref?.addEventListener('focusin', this.onSelectParagraph, {passive: true}); 14 | 15 | this.classesEmpty(); 16 | } 17 | 18 | destroy() { 19 | this.editorRef?.removeEventListener('selectParagraph', this.onSelectParagraph); 20 | containerStore.state.ref?.removeEventListener('focusin', this.onSelectParagraph); 21 | } 22 | 23 | private onSelectParagraph = ({detail}: CustomEvent) => { 24 | const firstParagraph: Element | undefined = containerStore.state.ref?.firstElementChild; 25 | const secondParagraph: Element | undefined = containerStore.state.ref?.children[1]; 26 | 27 | const first: boolean = firstParagraph && detail && detail.isEqualNode(firstParagraph); 28 | const second: boolean = secondParagraph && detail && detail.isEqualNode(secondParagraph); 29 | 30 | containerStore.state.ref?.removeEventListener('keydown', this.onKeyChange); 31 | containerStore.state.ref?.removeEventListener('keyup', this.onKeyChange); 32 | 33 | if (first || second) { 34 | containerStore.state.ref?.addEventListener('keydown', this.onKeyChange); 35 | containerStore.state.ref?.addEventListener('keyup', this.onKeyChange); 36 | } 37 | 38 | this.classesEmpty(); 39 | 40 | this.cleanEmpty(); 41 | }; 42 | 43 | private onKeyChange = () => { 44 | const paragraph: Node | undefined = findParagraph({ 45 | element: getSelection()?.anchorNode, 46 | container: containerStore.state.ref 47 | }); 48 | 49 | if (!paragraph || isTextNode(paragraph)) { 50 | return; 51 | } 52 | 53 | // Workaround: add a bit of delay if user enters and deletes text quickly in the first paragraphs to detect the classes empty 54 | setTimeout(() => this.toggleClassEmpty(paragraph as HTMLElement), 250); 55 | }; 56 | 57 | private classesEmpty() { 58 | const firstParagraph: Element | undefined = containerStore.state.ref?.firstElementChild; 59 | const secondParagraph: Element | undefined = containerStore.state.ref?.children[1]; 60 | 61 | this.classEmpty(firstParagraph); 62 | this.classEmpty(secondParagraph); 63 | } 64 | 65 | private classEmpty(element: Element | undefined) { 66 | if (!element) { 67 | return; 68 | } 69 | 70 | const paragraph: Node | undefined = findParagraph({ 71 | element, 72 | container: containerStore.state.ref 73 | }); 74 | 75 | if (!paragraph || isTextNode(paragraph)) { 76 | return; 77 | } 78 | 79 | this.toggleClassEmpty(paragraph as HTMLElement); 80 | } 81 | 82 | private toggleClassEmpty(paragraph: HTMLElement) { 83 | const {classList, nodeName} = paragraph; 84 | 85 | if (!configStore.state.textParagraphs.includes(nodeName.toLowerCase())) { 86 | classList.remove('stylo-placeholder-empty'); 87 | return; 88 | } 89 | 90 | const empty: boolean = isParagraphEmpty({paragraph}); 91 | 92 | const index: number = elementIndex(paragraph); 93 | 94 | // We add a placeholder for the title if empty. 95 | // We can display a placeholder for the second element if there are no other paragraphs, a bit weird to display a placeholder if user has began typing in another paragraph 96 | if (empty && (index === 0 || containerStore.state.ref?.children.length <= 2)) { 97 | classList.add('stylo-placeholder-empty'); 98 | return; 99 | } 100 | 101 | classList.remove('stylo-placeholder-empty'); 102 | } 103 | 104 | /** 105 | * If a paragraph is added between the two first placeholder the new div might be created with a copy of this class so we clean it 106 | */ 107 | private cleanEmpty() { 108 | const elements: NodeListOf | undefined = 109 | containerStore.state.ref?.querySelectorAll('.stylo-empty'); 110 | 111 | const others: HTMLElement[] = Array.from(elements || []).filter( 112 | (element: HTMLElement) => elementIndex(element) > 1 113 | ); 114 | 115 | for (const other of others) { 116 | other.classList.remove('stylo-empty'); 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type {Components, JSX} from './components'; 2 | export * from './menus/img.menu'; 3 | export * from './plugins/blockquote.plugin'; 4 | export * from './plugins/code.plugin'; 5 | export * from './plugins/h1.plugin'; 6 | export * from './plugins/h2.plugin'; 7 | export * from './plugins/h3.plugin'; 8 | export * from './plugins/hr.plugin'; 9 | export * from './plugins/img.plugin'; 10 | export * from './plugins/list.plugin'; 11 | export * from './types/config'; 12 | export * from './types/i18n'; 13 | export * from './types/icon'; 14 | export {StyloIcon} from './types/icon'; 15 | export * from './types/menu'; 16 | export * from './types/palette'; 17 | export * from './types/plugin'; 18 | export * from './types/toolbar'; 19 | export {createEmptyElement} from './utils/create-element.utils'; 20 | export {transformParagraph} from './utils/paragraph.utils'; 21 | -------------------------------------------------------------------------------- /src/interface.d.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export * from './menus/img.menu'; 3 | export * from './plugins/blockquote.plugin'; 4 | export * from './plugins/code.plugin'; 5 | export * from './plugins/h1.plugin'; 6 | export * from './plugins/h2.plugin'; 7 | export * from './plugins/h3.plugin'; 8 | export * from './plugins/hr.plugin'; 9 | export * from './plugins/img.plugin'; 10 | export * from './plugins/list.plugin'; 11 | export * from './types/config'; 12 | export * from './types/i18n'; 13 | export * from './types/icon'; 14 | export {StyloIcon} from './types/icon'; 15 | export * from './types/menu'; 16 | export * from './types/palette'; 17 | export * from './types/plugin'; 18 | export * from './types/toolbar'; 19 | export {createEmptyElement} from './utils/create-element.utils'; 20 | export {transformParagraph} from './utils/paragraph.utils'; 21 | -------------------------------------------------------------------------------- /src/jest-setup.ts: -------------------------------------------------------------------------------- 1 | import MutationObserver from 'mutation-observer'; 2 | 3 | global.MutationObserver = MutationObserver; 4 | -------------------------------------------------------------------------------- /src/menus/img.menu.ts: -------------------------------------------------------------------------------- 1 | import {StyloMenu} from '../types/menu'; 2 | 3 | const imageSvg = ({width, height}: {width: number; height: number}): string => ` 14 | 18 | 22 | `; 23 | 24 | const setImageWith = ({ 25 | size, 26 | paragraph 27 | }: { 28 | size: '25%' | '50%' | '75%' | '100%'; 29 | paragraph: HTMLElement; 30 | }) => paragraph.style.setProperty('width', size); 31 | 32 | export const imgMenu: StyloMenu = { 33 | match: ({paragraph}: {paragraph: HTMLElement}) => paragraph.nodeName.toLowerCase() === 'img', 34 | actions: [ 35 | { 36 | text: 'img_width_original', 37 | icon: imageSvg({width: 20, height: 20}), 38 | action: async ({paragraph}: {paragraph: HTMLElement}) => 39 | setImageWith({paragraph, size: '100%'}) 40 | }, 41 | { 42 | text: 'img_width_large', 43 | icon: imageSvg({width: 18, height: 18}), 44 | action: async ({paragraph}: {paragraph: HTMLElement}) => 45 | setImageWith({paragraph, size: '75%'}) 46 | }, 47 | { 48 | text: 'img_width_medium', 49 | icon: imageSvg({width: 14, height: 14}), 50 | action: async ({paragraph}: {paragraph: HTMLElement}) => 51 | setImageWith({paragraph, size: '50%'}) 52 | }, 53 | { 54 | text: 'img_width_small', 55 | icon: imageSvg({width: 10, height: 10}), 56 | action: async ({paragraph}: {paragraph: HTMLElement}) => 57 | setImageWith({paragraph, size: '25%'}) 58 | }, 59 | { 60 | text: 'img_delete', 61 | icon: ` 62 | 70 | 77 | 85 | `, 86 | action: async ({paragraph}: {paragraph: HTMLElement}) => { 87 | paragraph.parentElement.removeChild(paragraph); 88 | } 89 | } 90 | ] 91 | }; 92 | -------------------------------------------------------------------------------- /src/plugins/blockquote.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | export const blockquote: StyloPlugin = { 6 | text: 'blockquote', 7 | icon: 'blockquote', 8 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => 9 | transformParagraph({ 10 | elements: [createEmptyElement({nodeName: 'blockquote'})], 11 | paragraph, 12 | container 13 | }) 14 | }; 15 | -------------------------------------------------------------------------------- /src/plugins/code.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | export const code: StyloPlugin = { 6 | text: 'code', 7 | icon: 'code', 8 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => { 9 | transformParagraph({ 10 | elements: [createEmptyElement({nodeName: 'code'}), createEmptyElement({nodeName: 'div'})], 11 | paragraph, 12 | container 13 | }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/plugins/h1.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | export const h1: StyloPlugin = { 6 | text: 'huge_title', 7 | icon: `H1`, 8 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => 9 | transformParagraph({ 10 | elements: [createEmptyElement({nodeName: 'h1'})], 11 | paragraph, 12 | container 13 | }) 14 | }; 15 | -------------------------------------------------------------------------------- /src/plugins/h2.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | export const h2: StyloPlugin = { 6 | text: 'large_title', 7 | icon: `H2`, 8 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => 9 | transformParagraph({ 10 | elements: [createEmptyElement({nodeName: 'h2'})], 11 | paragraph, 12 | container 13 | }) 14 | }; 15 | -------------------------------------------------------------------------------- /src/plugins/h3.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | export const h3: StyloPlugin = { 6 | text: 'small_title', 7 | icon: `H3`, 8 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => 9 | transformParagraph({ 10 | elements: [createEmptyElement({nodeName: 'h3'})], 11 | paragraph, 12 | container 13 | }) 14 | }; 15 | -------------------------------------------------------------------------------- /src/plugins/hr.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | export const hr: StyloPlugin = { 6 | text: 'separator', 7 | icon: 'hr', 8 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => { 9 | const hr: HTMLHRElement = document.createElement('hr'); 10 | 11 | transformParagraph({ 12 | elements: [hr, createEmptyElement({nodeName: 'div'})], 13 | paragraph, 14 | container, 15 | focus: 'last' 16 | }); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/plugins/img.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | export const img: StyloPlugin = { 6 | text: 'image', 7 | icon: 'img', 8 | files: { 9 | accept: 'image/x-png,image/jpeg,image/gif,image/svg+xml,image/webp', 10 | multiple: false 11 | }, 12 | createParagraphs: async ({container, paragraph, files}: StyloPluginCreateParagraphsParams) => { 13 | const URL = window.URL || window.webkitURL; 14 | const imgUrl: string = URL.createObjectURL(files[0]); 15 | 16 | const img: HTMLImageElement = document.createElement('img'); 17 | img.src = imgUrl; 18 | img.setAttribute('loading', 'lazy'); 19 | 20 | const emptyDiv: HTMLElement = createEmptyElement({nodeName: 'div'}); 21 | 22 | transformParagraph({ 23 | elements: [img, emptyDiv], 24 | paragraph, 25 | container, 26 | focus: 'last' 27 | }); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/plugins/list.plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloPlugin, StyloPluginCreateParagraphsParams} from '../types/plugin'; 2 | import {createEmptyElement} from '../utils/create-element.utils'; 3 | import {transformParagraph} from '../utils/paragraph.utils'; 4 | 5 | const createListItem = (): HTMLLIElement => { 6 | const item: HTMLLIElement = document.createElement('li'); 7 | item.innerHTML = '\u200B'; 8 | return item; 9 | }; 10 | 11 | export const ul: StyloPlugin = { 12 | text: 'unordered_list', 13 | icon: 'ul', 14 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => { 15 | const ul: HTMLUListElement = document.createElement('ul'); 16 | 17 | ul.append(createListItem()); 18 | 19 | await transformParagraph({ 20 | elements: [ul, createEmptyElement({nodeName: 'div'})], 21 | paragraph, 22 | container 23 | }); 24 | } 25 | }; 26 | 27 | export const ol: StyloPlugin = { 28 | text: 'ordered_list', 29 | icon: 'ol', 30 | createParagraphs: async ({container, paragraph}: StyloPluginCreateParagraphsParams) => { 31 | const ol: HTMLOListElement = document.createElement('ol'); 32 | 33 | ol.append(createListItem()); 34 | 35 | await transformParagraph({ 36 | elements: [ol, createEmptyElement({nodeName: 'div'})], 37 | paragraph, 38 | container 39 | }); 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/plugins/plugin.spec.ts: -------------------------------------------------------------------------------- 1 | import {Blob} from 'blob-polyfill'; 2 | import {StyloPlugin} from '../types/plugin'; 3 | import {blockquote} from './blockquote.plugin'; 4 | import {code} from './code.plugin'; 5 | import {h1} from './h1.plugin'; 6 | import {h2} from './h2.plugin'; 7 | import {h3} from './h3.plugin'; 8 | import {hr} from './hr.plugin'; 9 | import {img} from './img.plugin'; 10 | import {ol, ul} from './list.plugin'; 11 | 12 | describe('plugins', () => { 13 | let container, paragraph; 14 | 15 | beforeEach(() => { 16 | container = document.createElement('article'); 17 | 18 | paragraph = document.createElement('div'); 19 | paragraph.setAttribute('test', 'test'); 20 | 21 | Object.defineProperty(paragraph, 'replaceWith', { 22 | value: jest.fn((node1, node2) => { 23 | container.append(node1); 24 | 25 | if (node2) { 26 | container.append(node2); 27 | } 28 | 29 | paragraph.parentElement.removeChild(paragraph); 30 | }) 31 | }); 32 | 33 | container.append(paragraph); 34 | }); 35 | 36 | const expectTransform = ({ 37 | plugin, 38 | firstNodeName, 39 | files 40 | }: { 41 | plugin: StyloPlugin; 42 | firstNodeName: string; 43 | files?: FileList; 44 | }) => { 45 | const {createParagraphs} = plugin; 46 | 47 | createParagraphs({ 48 | container, 49 | paragraph, 50 | files 51 | }); 52 | 53 | expect(container.firstChild.nodeName.toLowerCase()).toEqual(firstNodeName); 54 | }; 55 | 56 | const expectEmpty = (node) => { 57 | expect(node.nodeName.toLowerCase()).toEqual('div'); 58 | expect(node.innerHTML).toEqual('\u200B'); 59 | }; 60 | 61 | it('should transform to h1', () => expectTransform({plugin: h1, firstNodeName: 'h1'})); 62 | it('should transform to h2', () => expectTransform({plugin: h2, firstNodeName: 'h2'})); 63 | it('should transform to h3', () => expectTransform({plugin: h3, firstNodeName: 'h3'})); 64 | it('should transform to blockquote', () => 65 | expectTransform({plugin: blockquote, firstNodeName: 'blockquote'})); 66 | 67 | it('should transform to hr', () => { 68 | expectTransform({plugin: hr, firstNodeName: 'hr'}); 69 | 70 | const {lastChild} = container; 71 | 72 | expectEmpty(lastChild); 73 | }); 74 | 75 | it('should transform to ul', () => { 76 | expectTransform({plugin: ul, firstNodeName: 'ul'}); 77 | 78 | const {firstChild, lastChild} = container; 79 | 80 | expect(firstChild.firstChild.nodeName.toLowerCase()).toEqual('li'); 81 | 82 | expectEmpty(lastChild); 83 | }); 84 | 85 | it('should transform to img', () => { 86 | const blob = new Blob([''], {type: 'text/plain'}); 87 | blob['lastModifiedDate'] = ''; 88 | blob['name'] = 'filename'; 89 | const fileList: FileList = { 90 | 0: blob as File, 91 | length: 1, 92 | item: (_index: number) => blob as File 93 | }; 94 | 95 | expectTransform({ 96 | plugin: img, 97 | firstNodeName: 'img', 98 | files: fileList 99 | }); 100 | 101 | const {firstChild, lastChild} = container; 102 | 103 | expect(firstChild.hasAttribute('src')).toBeTruthy(); 104 | expect(firstChild.getAttribute('loading')).toEqual('lazy'); 105 | 106 | expectEmpty(lastChild); 107 | }); 108 | 109 | it('should transform to code', () => { 110 | expectTransform({plugin: code, firstNodeName: 'code'}); 111 | 112 | const {lastChild} = container; 113 | 114 | expectEmpty(lastChild); 115 | }); 116 | 117 | it('should render properties h1', () => { 118 | expect(h1.text).toEqual('huge_title'); 119 | expect(h1.icon).toEqual("H1"); 120 | }); 121 | 122 | it('should render properties h2', () => { 123 | expect(h2.text).toEqual('large_title'); 124 | expect(h2.icon).toEqual("H2"); 125 | }); 126 | 127 | it('should render properties h3', () => { 128 | expect(h3.text).toEqual('small_title'); 129 | expect(h3.icon).toEqual("H3"); 130 | }); 131 | 132 | it('should render properties hr', () => { 133 | expect(hr.text).toEqual('separator'); 134 | expect(hr.icon).toEqual('hr'); 135 | }); 136 | 137 | it('should render properties code', () => { 138 | expect(code.text).toEqual('code'); 139 | expect(code.icon).toEqual('code'); 140 | }); 141 | 142 | it('should render properties img', () => { 143 | expect(img.text).toEqual('image'); 144 | expect(img.icon).toEqual('img'); 145 | expect(img.files.accept).toEqual('image/x-png,image/jpeg,image/gif,image/svg+xml,image/webp'); 146 | expect(img.files.multiple).toBeFalsy(); 147 | }); 148 | 149 | it('should render properties ul', () => { 150 | expect(ul.text).toEqual('unordered_list'); 151 | expect(ul.icon).toEqual('ul'); 152 | }); 153 | 154 | it('should render properties ol', () => { 155 | expect(ol.text).toEqual('ordered_list'); 156 | expect(ol.icon).toEqual('ol'); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /src/stores/config.store.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from '@stencil/store'; 2 | import {blockquote} from '../plugins/blockquote.plugin'; 3 | import {code} from '../plugins/code.plugin'; 4 | import {h1} from '../plugins/h1.plugin'; 5 | import {h2} from '../plugins/h2.plugin'; 6 | import {h3} from '../plugins/h3.plugin'; 7 | import {hr} from '../plugins/hr.plugin'; 8 | import {img} from '../plugins/img.plugin'; 9 | import {ol, ul} from '../plugins/list.plugin'; 10 | import {StyloConfigAttributes} from '../types/attributes'; 11 | import {StyloMenu} from '../types/menu'; 12 | import {DEFAULT_PALETTE} from '../types/palette'; 13 | import {StyloPlugin} from '../types/plugin'; 14 | import {StyloConfigToolbar} from '../types/toolbar'; 15 | 16 | interface ConfigStore { 17 | plugins: StyloPlugin[]; 18 | toolbar: StyloConfigToolbar; 19 | menus: StyloMenu[] | undefined; 20 | placeholders: string[] | undefined; 21 | textParagraphs: string[] | undefined; 22 | attributes: StyloConfigAttributes; 23 | } 24 | 25 | export const DEFAULT_PLUGINS: StyloPlugin[] = [h1, h2, h3, ul, ol, blockquote, img, code, hr]; 26 | 27 | export const DEFAULT_TOOLBAR: StyloConfigToolbar = { 28 | palette: DEFAULT_PALETTE, 29 | command: 'native', 30 | style: { 31 | list: false, 32 | align: true, 33 | fontSize: true, 34 | backgroundColor: true 35 | } 36 | }; 37 | 38 | export const DEFAULT_PLACEHOLDERS = ['div', 'p', 'span']; 39 | 40 | export const DEFAULT_TEXT_PARAGRAPHS = ['h1', 'h2', 'h3', 'div', 'p', 'blockquote']; 41 | 42 | export const DEFAULT_EXCLUDE_ATTRIBUTES = [ 43 | 'placeholder', 44 | 'class', 45 | 'spellcheck', 46 | 'contenteditable', 47 | 'data-gramm', 48 | 'data-gramm_id', 49 | 'data-gramm_editor', 50 | 'data-gr-id', 51 | 'autocomplete' 52 | ]; 53 | 54 | export const DEFAULT_PARAGRAPH_IDENTIFIER: string = 'paragraph_id'; 55 | 56 | const DEFAULT_ATTRIBUTES: StyloConfigAttributes = { 57 | paragraphIdentifier: DEFAULT_PARAGRAPH_IDENTIFIER, 58 | exclude: [...DEFAULT_EXCLUDE_ATTRIBUTES, DEFAULT_PARAGRAPH_IDENTIFIER] 59 | }; 60 | 61 | const {state, onChange} = createStore({ 62 | plugins: DEFAULT_PLUGINS, 63 | toolbar: DEFAULT_TOOLBAR, 64 | placeholders: DEFAULT_PLACEHOLDERS, 65 | textParagraphs: DEFAULT_TEXT_PARAGRAPHS, 66 | menus: undefined, 67 | attributes: DEFAULT_ATTRIBUTES 68 | }); 69 | 70 | export default {state, onChange}; 71 | -------------------------------------------------------------------------------- /src/stores/container.store.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from '@stencil/store'; 2 | 3 | interface ContainerStore { 4 | ref: HTMLElement | undefined; 5 | size: DOMRect | undefined; 6 | } 7 | 8 | const {state, onChange} = createStore({ 9 | ref: undefined, 10 | size: undefined 11 | }); 12 | 13 | export default {state, onChange}; 14 | -------------------------------------------------------------------------------- /src/stores/i18n.store.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from '@stencil/store'; 2 | import {en} from '../assets/i18n/en'; 3 | import {I18n, Languages} from '../types/i18n'; 4 | 5 | interface I18nStore extends I18n { 6 | custom?: Record; 7 | } 8 | 9 | const {state, onChange} = createStore(en); 10 | 11 | const esI18n = async (): Promise => { 12 | const {es} = await import(`../assets/i18n/es`); 13 | return es; 14 | }; 15 | 16 | const deI18n = async (): Promise => { 17 | const {de} = await import(`../assets/i18n/de`); 18 | return de; 19 | }; 20 | 21 | const nlI18n = async (): Promise => { 22 | const {nl} = await import(`../assets/i18n/nl`); 23 | return nl; 24 | }; 25 | 26 | const jaI18n = async (): Promise => { 27 | const {ja} = await import(`../assets/i18n/ja`); 28 | return ja; 29 | }; 30 | 31 | const zhCnI18n = async (): Promise => { 32 | const {zhCn} = await import(`../assets/i18n/zh-cn`); 33 | return zhCn; 34 | }; 35 | 36 | const frI18n = async (): Promise => { 37 | const {fr} = await import(`../assets/i18n/fr`); 38 | return fr; 39 | }; 40 | 41 | const enI18n = (): I18n => en; 42 | 43 | onChange('lang', async (lang: Languages) => { 44 | let bundle: I18n; 45 | 46 | switch (lang) { 47 | case 'es': 48 | bundle = await esI18n(); 49 | break; 50 | case 'de': 51 | bundle = await deI18n(); 52 | break; 53 | case 'nl': 54 | bundle = await nlI18n(); 55 | break; 56 | case 'ja': 57 | bundle = await jaI18n(); 58 | break; 59 | case 'zh-cn': 60 | bundle = await zhCnI18n(); 61 | break; 62 | case 'fr': 63 | bundle = await frI18n(); 64 | break; 65 | default: 66 | bundle = enI18n(); 67 | } 68 | 69 | Object.assign(state, { 70 | custom: state.custom, 71 | ...bundle 72 | }); 73 | }); 74 | 75 | export default {state}; 76 | -------------------------------------------------------------------------------- /src/stores/undo-redo.store.ts: -------------------------------------------------------------------------------- 1 | import {createStore} from '@stencil/store'; 2 | import {UndoRedoChanges} from '../types/undo-redo'; 3 | 4 | interface UndoRedoStore { 5 | undo: UndoRedoChanges[] | undefined; 6 | redo: UndoRedoChanges[] | undefined; 7 | 8 | observe: boolean; 9 | } 10 | 11 | const {state, onChange, reset} = createStore({ 12 | undo: undefined, 13 | redo: undefined, 14 | observe: true 15 | }); 16 | 17 | export default {state, onChange, reset}; 18 | -------------------------------------------------------------------------------- /src/themes/_button.scss: -------------------------------------------------------------------------------- 1 | @mixin button { 2 | display: flex; 3 | 4 | position: relative; 5 | 6 | isolation: isolate; 7 | 8 | overflow: hidden; 9 | 10 | background: transparent; 11 | border: none; 12 | outline: none; 13 | 14 | cursor: pointer; 15 | 16 | transition: color 0.25s ease-out, background 0.25s ease-out, transform 0.15s ease-out; 17 | 18 | &:active { 19 | box-shadow: none; 20 | transform: translateX(1px) translateY(1px); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/themes/_overlay.scss: -------------------------------------------------------------------------------- 1 | @use "./variables"; 2 | 3 | @mixin overlay { 4 | @include variables.effects; 5 | 6 | box-shadow: var(--stylo-box-shadow, var(--box-shadow)); 7 | border: var(--stylo-border); 8 | } 9 | 10 | @mixin dialog { 11 | background: var(--stylo-background, var(--white)); 12 | color: var(--stylo-color, var(--white-contrast)); 13 | 14 | border-radius: var(--stylo-border, 4px); 15 | } 16 | 17 | @mixin toolbar { 18 | padding: var(--stylo-toolbar-padding, 0 8px); 19 | } 20 | -------------------------------------------------------------------------------- /src/themes/_variables.scss: -------------------------------------------------------------------------------- 1 | @mixin colors { 2 | --light: #f4f5f8; 3 | --light-contrast: #000000; 4 | 5 | --medium: #6e6d6f; 6 | --medium-contrast: #000000; 7 | 8 | --black: #000000; 9 | --black-contrast: #ffffff; 10 | 11 | --white: #ffffff; 12 | --white-contrast: #000000; 13 | 14 | --highlight: #3880ff; 15 | --highlight-rgb: 56, 128, 255; 16 | --highlight-contrast: #ffffff; 17 | } 18 | 19 | @mixin effects { 20 | --box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.12); 21 | } 22 | -------------------------------------------------------------------------------- /src/types/attributes.ts: -------------------------------------------------------------------------------- 1 | export interface StyloConfigAttributes { 2 | /** 3 | * An identifier that is mostly use to detect deletion of paragraphs. It identifies which elements are paragraphs, which are not. 4 | */ 5 | paragraphIdentifier: string; 6 | /** 7 | * Exclude attributes that should not be observed for changes. List will be merged with the DEFAULT_EXCLUDE_ATTRIBUTES list and ParagraphIdentifier will be added to the list. 8 | */ 9 | exclude: string[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/config.ts: -------------------------------------------------------------------------------- 1 | import {StyloConfigAttributes} from './attributes'; 2 | import {Languages} from './i18n'; 3 | import {StyloMenu} from './menu'; 4 | import {StyloPlugin} from './plugin'; 5 | import {StyloConfigToolbar} from './toolbar'; 6 | 7 | export interface StyloConfigI18n { 8 | lang: Languages; 9 | custom?: Record; 10 | } 11 | 12 | export interface StyloConfig { 13 | i18n?: StyloConfigI18n; 14 | plugins?: StyloPlugin[]; 15 | toolbar?: Partial; 16 | menus?: StyloMenu[]; 17 | /** 18 | * In which type of nodes the placeholder "Press "/" for plugins" should be displayed 19 | */ 20 | placeholders?: string[]; 21 | /** 22 | * The paragraphs that accept text. In case user / browser tries to enter text withing another type of paragraphs, Stylo will first preprend the text in a new div to avoid text nodes at the root of the contenteditable container. 23 | */ 24 | textParagraphs?: string[]; 25 | attributes?: Partial; 26 | } 27 | -------------------------------------------------------------------------------- /src/types/execcommand.ts: -------------------------------------------------------------------------------- 1 | export interface ExecCommandStyle { 2 | style: 3 | | 'color' 4 | | 'background-color' 5 | | 'font-size' 6 | | 'font-weight' 7 | | 'font-style' 8 | | 'text-decoration'; 9 | value: string; 10 | initial: (element: HTMLElement | null) => boolean; 11 | } 12 | 13 | export interface ExecCommandList { 14 | type: 'ol' | 'ul'; 15 | } 16 | 17 | export interface ExecCommandAction { 18 | cmd: 'style' | 'list'; 19 | detail: ExecCommandStyle | ExecCommandList; 20 | } 21 | -------------------------------------------------------------------------------- /src/types/i18n.ts: -------------------------------------------------------------------------------- 1 | export interface I18nPlugins { 2 | huge_title: string; 3 | large_title: string; 4 | small_title: string; 5 | paragraph: string; 6 | separator: string; 7 | unordered_list: string; 8 | ordered_list: string; 9 | image: string; 10 | code: string; 11 | no_matches: string; 12 | blockquote: string; 13 | } 14 | 15 | export interface I18nAdd { 16 | placeholder: string; 17 | add_element: string; 18 | } 19 | 20 | export interface I18Toolbar { 21 | align_left: string; 22 | align_center: string; 23 | align_right: string; 24 | font_size: string; 25 | list_ol: string; 26 | list_ul: string; 27 | style_list: string; 28 | style_align: string; 29 | style_font_size: string; 30 | style_color: string; 31 | style_background: string; 32 | link: string; 33 | bold: string; 34 | italic: string; 35 | underline: string; 36 | strikethrough: string; 37 | } 38 | 39 | export interface I18Menus { 40 | img_width_original: string; 41 | img_width_large: string; 42 | img_width_medium: string; 43 | img_width_small: string; 44 | img_delete: string; 45 | } 46 | 47 | export type Languages = 'en' | 'es' | 'de' | 'nl' | 'ja' | 'zh-cn' | 'fr'; 48 | 49 | export interface I18n { 50 | lang: Languages; 51 | plugins: I18nPlugins; 52 | add: I18nAdd; 53 | toolbar: I18Toolbar; 54 | menus: I18Menus; 55 | } 56 | -------------------------------------------------------------------------------- /src/types/icon.ts: -------------------------------------------------------------------------------- 1 | export type StyloIcon = 'code' | 'ul' | 'hr' | 'img' | string; 2 | -------------------------------------------------------------------------------- /src/types/input.d.ts: -------------------------------------------------------------------------------- 1 | interface InputTargetEvent extends EventTarget { 2 | value: string; 3 | } 4 | -------------------------------------------------------------------------------- /src/types/menu.ts: -------------------------------------------------------------------------------- 1 | import {StyloIcon} from './icon'; 2 | 3 | export interface StyloMenuAction { 4 | text: string; 5 | icon: StyloIcon; 6 | action: (params: {paragraph: HTMLElement}) => Promise; 7 | } 8 | 9 | /** 10 | * A list of custom actions to display for a specific type of paragraph 11 | */ 12 | export interface StyloMenu { 13 | match: (params: {paragraph: HTMLElement}) => boolean; 14 | actions: [StyloMenuAction, ...StyloMenuAction[]]; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/palette.ts: -------------------------------------------------------------------------------- 1 | export interface StyloPaletteColor { 2 | hex: string; 3 | rgb?: string; 4 | } 5 | 6 | export interface StyloPaletteDisplayColor { 7 | borderColor: string; 8 | boxShadowColor: string; 9 | } 10 | 11 | export interface StyloPalette { 12 | color: StyloPaletteColor; 13 | alt?: string; 14 | display?: StyloPaletteDisplayColor; 15 | } 16 | 17 | export const DEFAULT_PALETTE: StyloPalette[] = [ 18 | { 19 | color: { 20 | hex: '#8ED1FC', 21 | rgb: '142,209,252' 22 | }, 23 | alt: 'Light blue' 24 | }, 25 | { 26 | color: { 27 | hex: '#0693E3', 28 | rgb: '6,147,227' 29 | }, 30 | alt: 'Blue' 31 | }, 32 | { 33 | color: { 34 | hex: '#7BDCB5', 35 | rgb: '123,220,181' 36 | }, 37 | alt: 'Light green' 38 | }, 39 | { 40 | color: { 41 | hex: '#00D084', 42 | rgb: '0,208,132' 43 | }, 44 | alt: 'Green' 45 | }, 46 | { 47 | color: { 48 | hex: '#FCB900', 49 | rgb: '252,185,0' 50 | }, 51 | alt: 'Yellow' 52 | }, 53 | { 54 | color: { 55 | hex: '#FF6900', 56 | rgb: '255,105,0' 57 | }, 58 | alt: 'Orange' 59 | }, 60 | { 61 | color: { 62 | hex: '#F78DA7', 63 | rgb: '247,141,167' 64 | }, 65 | alt: 'Pink' 66 | }, 67 | { 68 | color: { 69 | hex: '#EB144C', 70 | rgb: '235,20,76' 71 | }, 72 | alt: 'Red' 73 | }, 74 | { 75 | color: { 76 | hex: '#ffffff', 77 | rgb: '255,255,255' 78 | }, 79 | alt: 'White', 80 | display: { 81 | borderColor: '#ddd', 82 | boxShadowColor: '221,221,221' 83 | } 84 | }, 85 | { 86 | color: { 87 | hex: '#ABB8C3', 88 | rgb: '171,184,195' 89 | }, 90 | alt: 'Grey' 91 | }, 92 | { 93 | color: { 94 | hex: '#000000', 95 | rgb: '0,0,0' 96 | }, 97 | alt: 'Black' 98 | } 99 | ]; 100 | -------------------------------------------------------------------------------- /src/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import {StyloIcon} from './icon'; 2 | 3 | export interface StyloPluginCreateParagraphsParams { 4 | container: HTMLElement; 5 | paragraph: HTMLElement; 6 | files?: FileList; 7 | } 8 | 9 | export interface StyloPluginFiles { 10 | accept: string; 11 | multiple: boolean; 12 | } 13 | 14 | export interface StyloPlugin { 15 | text: string; 16 | icon: StyloIcon; 17 | files?: StyloPluginFiles; 18 | createParagraphs: (params: StyloPluginCreateParagraphsParams) => Promise; 19 | } 20 | -------------------------------------------------------------------------------- /src/types/toolbar.ts: -------------------------------------------------------------------------------- 1 | import {StyloPalette} from './palette'; 2 | 3 | export interface StyloConfigToolbar { 4 | /** 5 | * The list of selectable colors 6 | */ 7 | palette?: StyloPalette[]; 8 | /** 9 | * Use `document.execCommand` (= "native") to modify the document or, alternatively use the `custom` implementation 10 | * Ultimately 'native' should / will be replaced by custom which still need improvements 11 | */ 12 | command: 'native' | 'custom'; 13 | /** 14 | * Configure some actions of the toolbar 15 | */ 16 | style: { 17 | /** 18 | * Enable actions to manipulate list. Disabled per default. 19 | */ 20 | list: boolean; 21 | /** 22 | * Actions to manipulate the alignment enabled? 23 | */ 24 | align: boolean; 25 | /** 26 | * Actions to modify the selection font-size enabled? 27 | */ 28 | fontSize: boolean; 29 | /** 30 | * To hide the option to select a background-color 31 | */ 32 | backgroundColor: boolean; 33 | }; 34 | } 35 | 36 | export enum ToolbarActions { 37 | STYLE, 38 | LINK, 39 | COLOR, 40 | ALIGNMENT, 41 | LIST, 42 | FONT_SIZE, 43 | BACKGROUND_COLOR 44 | } 45 | 46 | export enum ToolbarList { 47 | ORDERED = 'insertOrderedList', 48 | UNORDERED = 'insertUnorderedList' 49 | } 50 | 51 | export enum ToolbarAlign { 52 | LEFT = 'left', 53 | CENTER = 'center', 54 | RIGHT = 'right' 55 | } 56 | 57 | export enum ToolbarFontSize { 58 | X_SMALL = '1', 59 | SMALL = '2', 60 | MEDIUM = '3', 61 | LARGE = '4', 62 | X_LARGE = '5', 63 | XX_LARGE = '6', 64 | XXX_LARGE = '7' 65 | } 66 | 67 | export interface ToolbarAnchorLink { 68 | range: Range; 69 | } 70 | -------------------------------------------------------------------------------- /src/types/undo-redo.ts: -------------------------------------------------------------------------------- 1 | export interface UndoRedoInput { 2 | offset: number; 3 | oldValue: string; 4 | index: number; 5 | indexDepths: number[]; 6 | } 7 | 8 | export interface UndoRedoAddRemoveParagraph { 9 | index: number; 10 | mutation: 'add' | 'remove'; 11 | outerHTML: string; 12 | } 13 | 14 | export interface UndoRedoUpdateParagraph { 15 | outerHTML: string; 16 | index: number; 17 | } 18 | 19 | export interface UndoRedoChange { 20 | type: 'input' | 'paragraph' | 'update'; 21 | target: Node; 22 | data: UndoRedoInput | UndoRedoAddRemoveParagraph[] | UndoRedoUpdateParagraph[]; 23 | } 24 | 25 | export interface UndoRedoSelection { 26 | startIndex: number; 27 | startIndexDepths: number[]; 28 | startOffset: number; 29 | endIndex: number; 30 | endIndexDepths: number[]; 31 | endOffset: number; 32 | reverse: boolean; 33 | } 34 | 35 | export interface UndoRedoChanges { 36 | changes: UndoRedoChange[]; 37 | selection?: UndoRedoSelection; 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/create-element.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import {createEmptyElement} from './create-element.utils'; 2 | 3 | describe('create element', () => { 4 | it('should create empty element', () => { 5 | const empty = createEmptyElement({nodeName: 'div'}); 6 | 7 | expect(empty.nodeName.toLowerCase()).toEqual('div'); 8 | expect(empty.innerHTML).toEqual('\u200B'); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/utils/create-element.utils.ts: -------------------------------------------------------------------------------- 1 | export const createEmptyElement = ({ 2 | nodeName 3 | }: { 4 | nodeName: 'h1' | 'h2' | 'h3' | 'div' | 'code' | 'blockquote'; 5 | }): HTMLElement => { 6 | const element: HTMLElement = document.createElement(nodeName); 7 | element.innerHTML = '\u200B'; 8 | 9 | return element; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/css.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import {injectCSS} from './css.utils'; 2 | 3 | describe('css', () => { 4 | it('should inject global css in head', () => { 5 | injectCSS({rootNode: document}); 6 | 7 | let style: HTMLStyleElement | null = document.head.querySelector('style[stylo-editor]'); 8 | expect(style).not.toBeNull(); 9 | }); 10 | 11 | it('should not inject twice global css in head', () => { 12 | injectCSS({rootNode: document}); 13 | injectCSS({rootNode: document}); 14 | 15 | let styles: NodeListOf = 16 | document.head.querySelectorAll('style[stylo-editor]'); 17 | expect(styles.length).toEqual(1); 18 | }); 19 | }); 20 | 21 | describe('css shadow', () => { 22 | it('should inject global css in shadowRoot', () => { 23 | const div = document.createElement('div'); 24 | const shadowRoot = div.attachShadow({mode: 'open'}); 25 | const divInner = document.createElement('div'); 26 | shadowRoot.appendChild(divInner); 27 | 28 | injectCSS({rootNode: divInner.getRootNode()}); 29 | 30 | let style: HTMLStyleElement | null = shadowRoot.querySelector('style[stylo-editor]'); 31 | expect(style).not.toBeNull(); 32 | }); 33 | 34 | it('should not inject twice global css in shadowRoot', () => { 35 | const div = document.createElement('div'); 36 | const shadowRoot = div.attachShadow({mode: 'open'}); 37 | const divInner = document.createElement('div'); 38 | shadowRoot.appendChild(divInner); 39 | 40 | injectCSS({rootNode: divInner.getRootNode()}); 41 | injectCSS({rootNode: divInner.getRootNode()}); 42 | 43 | let styles: NodeListOf = shadowRoot.querySelectorAll('style[stylo-editor]'); 44 | expect(styles.length).toEqual(1); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/utils/css.utils.ts: -------------------------------------------------------------------------------- 1 | export const injectCSS = ({rootNode}: {rootNode: Node}) => { 2 | let style: HTMLStyleElement | null = ( 3 | rootNode === document ? document.head : rootNode 4 | ).querySelector('style[stylo-editor]'); 5 | 6 | if (style !== null) { 7 | return; 8 | } 9 | 10 | style = document.createElement('style'); 11 | style.setAttribute('stylo-editor', ''); 12 | style.innerHTML = ` 13 | .stylo-container > * { 14 | white-space: pre-wrap; 15 | position: relative; 16 | } 17 | 18 | .stylo-container > *:after { 19 | content: attr(placeholder); 20 | color: var(--stylo-placeholder-color, rgba(55, 53, 47, 0.5)); 21 | position: absolute; 22 | top: 0; 23 | } 24 | `; 25 | 26 | if (rootNode === document) { 27 | document.head.append(style); 28 | return; 29 | } 30 | 31 | (rootNode).prepend(style); 32 | }; 33 | -------------------------------------------------------------------------------- /src/utils/events.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import {MockHTMLElement} from '@stencil/core/mock-doc'; 2 | import {emitAddParagraphs, emitDeleteParagraphs, emitUpdateParagraphs} from './events.utils'; 3 | 4 | describe('event', () => { 5 | let editorRef, element, dispatchEventSpy; 6 | 7 | beforeEach(() => { 8 | editorRef = document.createElement('div'); 9 | 10 | element = document.createElement('div'); 11 | element.setAttribute('test', 'test'); 12 | 13 | dispatchEventSpy = jest.spyOn(editorRef, 'dispatchEvent'); 14 | }); 15 | 16 | const expectDispatched = ({eventName}: {eventName: string}) => { 17 | expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event)); 18 | 19 | const event = dispatchEventSpy.mock.calls[0][0]; 20 | 21 | expect(event.bubbles).toBeTruthy(); 22 | expect(event.type).toBe(eventName); 23 | 24 | const detail = (event as unknown as {detail: MockHTMLElement[]}).detail[0]; 25 | expect(detail.getAttribute('test')).toEqual('test'); 26 | }; 27 | 28 | it('should emit addParagraphs', () => { 29 | const addedParagraphs = [element]; 30 | 31 | emitAddParagraphs({ 32 | editorRef, 33 | addedParagraphs 34 | }); 35 | 36 | expectDispatched({eventName: 'addParagraphs'}); 37 | }); 38 | 39 | it('should emit deleteParagraphs', () => { 40 | const removedParagraphs = [element]; 41 | 42 | emitDeleteParagraphs({ 43 | editorRef, 44 | removedParagraphs 45 | }); 46 | 47 | expectDispatched({eventName: 'deleteParagraphs'}); 48 | }); 49 | 50 | it('should emit updateParagraphs', () => { 51 | const updatedParagraphs = [element]; 52 | 53 | emitUpdateParagraphs({ 54 | editorRef, 55 | updatedParagraphs 56 | }); 57 | 58 | expectDispatched({eventName: 'updateParagraphs'}); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/utils/events.utils.ts: -------------------------------------------------------------------------------- 1 | export const emitAddParagraphs = ({ 2 | editorRef, 3 | addedParagraphs 4 | }: { 5 | editorRef: HTMLElement | undefined; 6 | addedParagraphs: HTMLElement[]; 7 | }) => emit({editorRef, detail: addedParagraphs, message: 'addParagraphs'}); 8 | 9 | export const emitDeleteParagraphs = ({ 10 | editorRef, 11 | removedParagraphs 12 | }: { 13 | editorRef: HTMLElement | undefined; 14 | removedParagraphs: HTMLElement[]; 15 | }) => emit({editorRef, detail: removedParagraphs, message: 'deleteParagraphs'}); 16 | 17 | export const emitUpdateParagraphs = ({ 18 | editorRef, 19 | updatedParagraphs 20 | }: { 21 | editorRef: HTMLElement | undefined; 22 | updatedParagraphs: HTMLElement[]; 23 | }) => emit({editorRef, detail: updatedParagraphs, message: 'updateParagraphs'}); 24 | 25 | const emit = ({ 26 | editorRef, 27 | message, 28 | detail 29 | }: { 30 | editorRef: HTMLElement | undefined; 31 | message: string; 32 | detail?: T; 33 | }) => { 34 | const $event: CustomEvent = new CustomEvent(message, {detail, bubbles: true}); 35 | editorRef?.dispatchEvent($event); 36 | }; 37 | -------------------------------------------------------------------------------- /src/utils/execcomand-text.utils.ts: -------------------------------------------------------------------------------- 1 | import {ExecCommandAction} from '../types/execcommand'; 2 | 3 | export const actionBold: ExecCommandAction = { 4 | cmd: 'style', 5 | detail: { 6 | style: 'font-weight', 7 | value: 'bold', 8 | initial: (element: HTMLElement | null) => element && element.style['font-weight'] === 'bold' 9 | } 10 | }; 11 | 12 | export const actionItalic: ExecCommandAction = { 13 | cmd: 'style', 14 | detail: { 15 | style: 'font-style', 16 | value: 'italic', 17 | initial: (element: HTMLElement | null) => element && element.style['font-style'] === 'italic' 18 | } 19 | }; 20 | 21 | export const actionUnderline: ExecCommandAction = { 22 | cmd: 'style', 23 | detail: { 24 | style: 'text-decoration', 25 | value: 'underline', 26 | initial: (element: HTMLElement | null) => 27 | element && element.style['text-decoration'] === 'underline' 28 | } 29 | }; 30 | 31 | export const actionStrikeThrough: ExecCommandAction = { 32 | cmd: 'style', 33 | detail: { 34 | style: 'text-decoration', 35 | value: 'line-through', 36 | initial: (element: HTMLElement | null) => 37 | element && element.style['text-decoration'] === 'line-through' 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/utils/execcommand-align.utils.ts: -------------------------------------------------------------------------------- 1 | import {ToolbarAlign} from '../types/toolbar'; 2 | import {toHTMLElement} from './node.utils'; 3 | import {findParagraph} from './paragraph.utils'; 4 | 5 | export const execCommandAlign = ( 6 | anchorEvent: MouseEvent | TouchEvent, 7 | container: HTMLElement, 8 | align: ToolbarAlign 9 | ) => { 10 | const anchorElement: HTMLElement = toHTMLElement(anchorEvent.target as Node); 11 | const paragraph: HTMLElement | undefined = toHTMLElement( 12 | findParagraph({element: anchorElement, container}) 13 | ); 14 | 15 | if (!paragraph) { 16 | return; 17 | } 18 | 19 | paragraph.style.textAlign = paragraph?.style.textAlign === align ? '' : align; 20 | }; 21 | -------------------------------------------------------------------------------- /src/utils/execcommand-list.utils.ts: -------------------------------------------------------------------------------- 1 | import {ExecCommandList} from '../types/execcommand'; 2 | import {toHTMLElement} from './node.utils'; 3 | import {findParagraph} from './paragraph.utils'; 4 | 5 | export function execCommandList( 6 | selection: Selection, 7 | action: ExecCommandList, 8 | container: HTMLElement 9 | ) { 10 | const anchorNode: Node = selection.anchorNode; 11 | 12 | if (!anchorNode) { 13 | return; 14 | } 15 | 16 | const anchor: HTMLElement | undefined = toHTMLElement( 17 | findParagraph({element: anchorNode, container}) 18 | ); 19 | 20 | if (!anchor) { 21 | return; 22 | } 23 | 24 | // Did the user select the all list 25 | if (anchor.nodeName.toLowerCase() === action.type) { 26 | removeList(anchor); 27 | return; 28 | } 29 | 30 | if (!['ol', 'ul', 'dl'].includes(anchor.nodeName.toLowerCase())) { 31 | createList(anchor, selection, action.type); 32 | return; 33 | } 34 | 35 | // Create a brand new list 36 | cloneList(anchor, selection, action.type); 37 | removeList(anchor, false); 38 | } 39 | 40 | function createList(container: HTMLElement, selection: Selection, type: 'ol' | 'ul') { 41 | const range: Range = selection.getRangeAt(0); 42 | 43 | const fragment: DocumentFragment = range.extractContents(); 44 | 45 | const list: HTMLOListElement | HTMLUListElement = document.createElement(type); 46 | 47 | const li: HTMLLIElement = document.createElement('li'); 48 | li.style.cssText = container.style.cssText; 49 | li.appendChild(fragment); 50 | 51 | list.appendChild(li); 52 | 53 | range.insertNode(list); 54 | selection.selectAllChildren(list); 55 | } 56 | 57 | function cloneList(container: HTMLElement, selection: Selection, type: 'ol' | 'ul') { 58 | const list: HTMLOListElement | HTMLUListElement = document.createElement(type); 59 | 60 | list.append(...Array.from(container.childNodes)); 61 | 62 | Array.from(container.attributes).forEach((attr: Attr) => 63 | list.setAttribute(attr.nodeName, attr.nodeValue) 64 | ); 65 | 66 | container.parentElement.insertBefore(list, container); 67 | 68 | selection.selectAllChildren(list); 69 | } 70 | 71 | function removeList(list: HTMLElement, preserveChildren: boolean = true) { 72 | if (list.hasChildNodes() && preserveChildren) { 73 | Array.from(list.childNodes).forEach((child: Node) => { 74 | if ( 75 | child.hasChildNodes() && 76 | child.childNodes.length > 1 && 77 | child.firstChild.nodeType !== Node.TEXT_NODE && 78 | child.firstChild.nodeType !== Node.COMMENT_NODE 79 | ) { 80 | const span: HTMLSpanElement = document.createElement('span'); 81 | span.append(...Array.from(child.childNodes)); 82 | list.parentElement.insertBefore(span, list); 83 | } else { 84 | const text: Text = document.createTextNode(child.textContent); 85 | list.parentElement.insertBefore(text, list); 86 | } 87 | }); 88 | } 89 | 90 | list.parentElement.removeChild(list); 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/execcommand-style.utils.ts: -------------------------------------------------------------------------------- 1 | import {getAnchorElement} from '@deckdeckgo/utils'; 2 | import {ExecCommandStyle} from '../types/execcommand'; 3 | import {isParagraph} from './paragraph.utils'; 4 | import {findStyleNode} from './toolbar.utils'; 5 | 6 | export function execCommandStyle( 7 | selection: Selection, 8 | action: ExecCommandStyle, 9 | container: HTMLElement 10 | ) { 11 | const anchor: HTMLElement | null = getAnchorElement(selection); 12 | 13 | if (!anchor) { 14 | return; 15 | } 16 | 17 | const sameSelection: boolean = anchor && anchor.innerText === selection.toString(); 18 | 19 | if ( 20 | sameSelection && 21 | !isParagraph({element: anchor, container}) && 22 | anchor.style[action.style] !== undefined 23 | ) { 24 | updateSelection(anchor, action, container); 25 | 26 | return; 27 | } 28 | 29 | replaceSelection(anchor, action, selection, container); 30 | } 31 | 32 | function updateSelection(anchor: HTMLElement, action: ExecCommandStyle, container: HTMLElement) { 33 | anchor.style[action.style] = getStyleValue(container, action, container); 34 | 35 | cleanChildren(action, anchor); 36 | } 37 | 38 | function replaceSelection( 39 | anchor: HTMLElement, 40 | action: ExecCommandStyle, 41 | selection: Selection, 42 | container: HTMLElement 43 | ) { 44 | const range: Range = selection.getRangeAt(0); 45 | 46 | // User selected a all list? 47 | if ( 48 | range.commonAncestorContainer && 49 | ['ol', 'ul', 'dl'].some( 50 | (listType) => listType === range.commonAncestorContainer.nodeName.toLowerCase() 51 | ) 52 | ) { 53 | updateSelection(range.commonAncestorContainer as HTMLElement, action, container); 54 | return; 55 | } 56 | 57 | const fragment: DocumentFragment = range.extractContents(); 58 | 59 | const span: HTMLSpanElement = createSpan(anchor, action, container); 60 | span.appendChild(fragment); 61 | 62 | cleanChildren(action, span); 63 | flattenChildren(action, span); 64 | 65 | range.insertNode(span); 66 | selection.selectAllChildren(span); 67 | } 68 | 69 | function cleanChildren(action: ExecCommandStyle, span: HTMLSpanElement) { 70 | if (!span.hasChildNodes()) { 71 | return; 72 | } 73 | 74 | // Clean direct (> *) children with same style 75 | const children: HTMLElement[] = Array.from(span.children).filter((element: HTMLElement) => { 76 | return element.style[action.style] !== undefined && element.style[action.style] !== ''; 77 | }) as HTMLElement[]; 78 | 79 | if (children && children.length > 0) { 80 | children.forEach((element: HTMLElement) => { 81 | element.style[action.style] = ''; 82 | 83 | if (element.getAttribute('style') === '' || element.style === null) { 84 | element.removeAttribute('style'); 85 | } 86 | }); 87 | } 88 | 89 | // Direct children (> *) may have children (*) which need to be cleaned too 90 | Array.from(span.children).forEach((element: HTMLElement) => cleanChildren(action, element)); 91 | } 92 | 93 | function createSpan( 94 | anchor: HTMLElement, 95 | action: ExecCommandStyle, 96 | container: HTMLElement 97 | ): HTMLSpanElement { 98 | const span: HTMLSpanElement = document.createElement('span'); 99 | span.style[action.style] = getStyleValue(anchor, action, container); 100 | 101 | return span; 102 | } 103 | 104 | // We assume that if the same style is applied, user want actually to remove it (same behavior as in MS Word) 105 | // Note: initial may have no effect on the background-color 106 | function getStyleValue( 107 | anchor: HTMLElement, 108 | action: ExecCommandStyle, 109 | container: HTMLElement 110 | ): string { 111 | if (!anchor) { 112 | return action.value; 113 | } 114 | 115 | if (action.initial(anchor)) { 116 | return 'initial'; 117 | } 118 | 119 | const style: Node | null = findStyleNode(anchor, action.style, container); 120 | 121 | if (action.initial(style as HTMLElement)) { 122 | return 'initial'; 123 | } 124 | 125 | return action.value; 126 | } 127 | 128 | // We try to not keep in the tree if we can use text 129 | function flattenChildren(action: ExecCommandStyle, span: HTMLSpanElement) { 130 | if (!span.hasChildNodes()) { 131 | return; 132 | } 133 | 134 | // Flatten direct (> *) children with no style 135 | const children: HTMLElement[] = Array.from(span.children).filter((element: HTMLElement) => { 136 | const style: string | null = element.getAttribute('style'); 137 | return !style || style === ''; 138 | }) as HTMLElement[]; 139 | 140 | if (children && children.length > 0) { 141 | children.forEach((element: HTMLElement) => { 142 | // Can only be flattened if there is no other style applied to a children, like a color to part of a text with a background 143 | const styledChildren: NodeListOf = element.querySelectorAll('[style]'); 144 | if (!styledChildren || styledChildren.length === 0) { 145 | const text: Text = document.createTextNode(element.textContent); 146 | element.parentElement.replaceChild(text, element); 147 | } 148 | }); 149 | 150 | return; 151 | } 152 | 153 | // Direct children (> *) may have children (*) which need to be flattened too 154 | Array.from(span.children).forEach((element: HTMLElement) => flattenChildren(action, element)); 155 | } 156 | -------------------------------------------------------------------------------- /src/utils/execcommand.utils.ts: -------------------------------------------------------------------------------- 1 | import {ExecCommandAction, ExecCommandList, ExecCommandStyle} from '../types/execcommand'; 2 | import {execCommandList} from './execcommand-list.utils'; 3 | import {execCommandStyle} from './execcommand-style.utils'; 4 | 5 | export function execCommand( 6 | selection: Selection, 7 | action: ExecCommandAction, 8 | container: HTMLElement 9 | ) { 10 | if (!document || !selection) { 11 | return; 12 | } 13 | 14 | if (action.cmd === 'style') { 15 | execCommandStyle(selection, action.detail as ExecCommandStyle, container); 16 | } else if (action.cmd === 'list') { 17 | execCommandList(selection, action.detail as ExecCommandList, container); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/execcommnad-native.utils.ts: -------------------------------------------------------------------------------- 1 | import {ExecCommandAction, ExecCommandList, ExecCommandStyle} from '../types/execcommand'; 2 | import {ToolbarAlign, ToolbarFontSize} from '../types/toolbar'; 3 | 4 | export const execCommandNative = (action: ExecCommandAction) => { 5 | if (action.cmd === 'style') { 6 | execCommandNativeStyle(action); 7 | } else if (action.cmd === 'list') { 8 | execCommandNativeList(action); 9 | } 10 | }; 11 | 12 | const execCommandNativeStyle = (action: ExecCommandAction) => { 13 | const detail: ExecCommandStyle = action.detail as ExecCommandStyle; 14 | 15 | // @ts-ignore 16 | document.execCommand('styleWithCSS', false, true); 17 | 18 | switch (detail.style) { 19 | case 'color': 20 | document.execCommand('foreColor', false, detail.value); 21 | break; 22 | case 'background-color': 23 | document.execCommand('backColor', false, detail.value); 24 | break; 25 | case 'font-size': 26 | document.execCommand( 27 | 'fontSize', 28 | false, 29 | ToolbarFontSize[detail.value.replace('-', '_').toUpperCase()] 30 | ); 31 | break; 32 | case 'font-weight': 33 | document.execCommand('bold', false, null); 34 | break; 35 | case 'font-style': 36 | document.execCommand('italic', false, null); 37 | break; 38 | case 'text-decoration': 39 | document.execCommand( 40 | detail.value === 'line-through' ? 'strikeThrough' : 'underline', 41 | false, 42 | null 43 | ); 44 | break; 45 | } 46 | }; 47 | 48 | const execCommandNativeList = (action: ExecCommandAction) => { 49 | const detail: ExecCommandList = action.detail as ExecCommandList; 50 | 51 | switch (detail.type) { 52 | case 'ol': 53 | document.execCommand('insertOrderedList', false, null); 54 | break; 55 | case 'ul': 56 | document.execCommand('insertUnorderedList', false, null); 57 | break; 58 | } 59 | }; 60 | 61 | export const execCommandNativeAlign = (align: ToolbarAlign) => { 62 | switch (align) { 63 | case ToolbarAlign.CENTER: 64 | document.execCommand('justifyCenter', false, null); 65 | break; 66 | case ToolbarAlign.RIGHT: 67 | document.execCommand('justifyRight', false, null); 68 | break; 69 | default: 70 | document.execCommand('justifyLeft', false, null); 71 | } 72 | }; 73 | -------------------------------------------------------------------------------- /src/utils/icon.utils.tsx: -------------------------------------------------------------------------------- 1 | import {h, JSX} from '@stencil/core'; 2 | import {IconBlockquote} from '../components/icons/blockquote'; 3 | import {IconCode} from '../components/icons/code'; 4 | import {IconImage} from '../components/icons/image'; 5 | import {IconEllipsisHorizontal} from '../components/icons/more'; 6 | import {IconOl} from '../components/icons/ol'; 7 | import {IconUl} from '../components/icons/ul'; 8 | import {StyloIcon} from '../types/icon'; 9 | 10 | export const renderIcon = (icon: StyloIcon): JSX.IntrinsicElements | undefined => { 11 | switch (icon) { 12 | case 'code': 13 | return ; 14 | case 'ul': 15 | return ; 16 | case 'ol': 17 | return ; 18 | case 'hr': 19 | return ; 20 | case 'img': 21 | return ; 22 | case 'blockquote': 23 | return ; 24 | default: 25 | return undefined; 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/keyboard.utils.ts: -------------------------------------------------------------------------------- 1 | // keyCode 229 = input is processing - Japanese entry 2 | // https://lists.w3.org/Archives/Public/www-dom/2010JulSep/att-0182/keyCode-spec.html 3 | export const isKeyboardEnter = ({code, keyCode}: KeyboardEvent): boolean => 4 | ['Enter'].includes(code) && keyCode !== 229; 5 | -------------------------------------------------------------------------------- /src/utils/link.utils.ts: -------------------------------------------------------------------------------- 1 | import {isFirefox} from '@deckdeckgo/utils'; 2 | import {toHTMLElement} from './node.utils'; 3 | import {getRange} from './selection.utils'; 4 | 5 | export const createLink = ({range, linkUrl}: {range: Range; linkUrl: string}) => { 6 | const fragment: DocumentFragment = range.extractContents(); 7 | const a: HTMLAnchorElement = createLinkElementForFragment({fragment, linkUrl}); 8 | 9 | range.insertNode(a); 10 | }; 11 | 12 | export const removeLink = (container?: HTMLElement) => { 13 | const {range, selection} = getRange(container); 14 | 15 | if (!range) { 16 | return; 17 | } 18 | 19 | if (isFirefox()) { 20 | removeFirefoxLink(selection); 21 | return; 22 | } 23 | 24 | const anchor: HTMLElement = toHTMLElement(selection.anchorNode); 25 | 26 | const fragment: DocumentFragment = range.extractContents(); 27 | 28 | anchor.parentElement.replaceChild(fragment, anchor); 29 | }; 30 | 31 | const removeFirefoxLink = (selection: Selection) => { 32 | const container: HTMLElement | undefined = toHTMLElement(selection.anchorNode); 33 | 34 | if (!container || container.nodeName.toLowerCase() !== 'a') { 35 | return; 36 | } 37 | 38 | container.parentElement.insertBefore(document.createTextNode(container.textContent), container); 39 | container.parentElement.removeChild(container); 40 | }; 41 | 42 | const createLinkElementForFragment = ({ 43 | fragment, 44 | linkUrl 45 | }: { 46 | fragment: DocumentFragment; 47 | linkUrl: string; 48 | }): HTMLAnchorElement => { 49 | const a: HTMLAnchorElement = createLinkElement({linkUrl}); 50 | a.appendChild(fragment); 51 | return a; 52 | }; 53 | 54 | export const createLinkElement = ({linkUrl}: {linkUrl: string}): HTMLAnchorElement => { 55 | const a: HTMLAnchorElement = document.createElement('a'); 56 | a.href = linkUrl; 57 | a.rel = 'noopener noreferrer'; 58 | a.target = '_blank'; 59 | 60 | return a; 61 | }; 62 | -------------------------------------------------------------------------------- /src/utils/mobile.utils.ts: -------------------------------------------------------------------------------- 1 | import {isAndroid, isIOS, isMobile as isMobileDevice} from '@deckdeckgo/utils'; 2 | 3 | /** 4 | * isMobileDevice detects device that has touch and no mouse pointer so for example a Samsung Note 20 would probably not match. 5 | * that's why we enhance the check with android and ios, considering these as mobile devices too. 6 | */ 7 | export const isMobile = (): boolean => isMobileDevice() || isAndroid() || isIOS(); 8 | -------------------------------------------------------------------------------- /src/utils/node.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | elementIndex, 3 | findNodeAtDepths, 4 | isNodeList, 5 | isTextNode, 6 | nodeDepths, 7 | nodeIndex, 8 | toHTMLElement 9 | } from './node.utils'; 10 | 11 | describe('node utils', () => { 12 | it('should be a text node', () => { 13 | const text = document.createTextNode('test'); 14 | expect(isTextNode(text)).toBeTruthy(); 15 | }); 16 | 17 | it('should not be a text node', () => { 18 | const div = document.createElement('div'); 19 | expect(isTextNode(div)).toBeFalsy(); 20 | }); 21 | 22 | it('should return parent html element', () => { 23 | const text = document.createTextNode('test'); 24 | const div = document.createElement('div'); 25 | 26 | div.append(text); 27 | 28 | expect(toHTMLElement(text).nodeName).toEqual(div.nodeName); 29 | }); 30 | 31 | it('should return same html element', () => { 32 | const div = document.createElement('div'); 33 | 34 | expect(toHTMLElement(div).nodeName).toEqual(div.nodeName); 35 | }); 36 | 37 | it('should return an html element index', () => { 38 | const container = document.createElement('div'); 39 | const child1 = document.createElement('div'); 40 | const child2 = document.createTextNode('test'); 41 | const child3 = document.createElement('div'); 42 | 43 | container.append(child1, child2, child3); 44 | 45 | expect(elementIndex(child1)).toEqual(0); 46 | expect(elementIndex(child3)).toEqual(1); 47 | }); 48 | 49 | it('should return a node index', () => { 50 | const container = document.createElement('div'); 51 | const child1 = document.createElement('div'); 52 | const child2 = document.createTextNode('test'); 53 | const child3 = document.createElement('div'); 54 | 55 | container.append(child1, child2, child3); 56 | 57 | expect(nodeIndex(child1)).toEqual(0); 58 | expect(nodeIndex(child2)).toEqual(1); 59 | expect(nodeIndex(child3)).toEqual(2); 60 | }); 61 | 62 | const buildDepths = () => { 63 | const container = document.createElement('div'); 64 | container.setAttribute('name', 'container'); 65 | 66 | const child1 = document.createElement('div'); 67 | child1.setAttribute('name', 'child1'); 68 | 69 | const child2a = document.createElement('div'); 70 | child2a.setAttribute('name', 'child2a'); 71 | 72 | const child2b = document.createElement('div'); 73 | child2b.setAttribute('name', 'child2b'); 74 | 75 | const child3 = document.createElement('div'); 76 | child3.setAttribute('name', 'child3'); 77 | 78 | child2b.appendChild(child3); 79 | child1.appendChild(child2a); 80 | child1.appendChild(child2b); 81 | container.appendChild(child1); 82 | 83 | return [container, child1, child2a, child2b, child3]; 84 | }; 85 | 86 | it('should find node depths', () => { 87 | const [container, child1, child2a, child2b, child3] = buildDepths(); 88 | 89 | expect(nodeDepths({target: container, paragraph: container})).toEqual([-1]); 90 | expect(nodeDepths({target: child1, paragraph: container})).toEqual([0]); 91 | expect(nodeDepths({target: child2a, paragraph: container})).toEqual([0, 0]); 92 | expect(nodeDepths({target: child2b, paragraph: container})).toEqual([0, 1]); 93 | expect(nodeDepths({target: child3, paragraph: container})).toEqual([0, 1, 0]); 94 | }); 95 | 96 | it('should find node at depths', () => { 97 | const [container, _rest] = buildDepths(); 98 | 99 | expect( 100 | (findNodeAtDepths({parent: container, indexDepths: [0]}) as HTMLElement).getAttribute('name') 101 | ).toEqual('child1'); 102 | 103 | expect( 104 | (findNodeAtDepths({parent: container, indexDepths: [0, 0]}) as HTMLElement).getAttribute( 105 | 'name' 106 | ) 107 | ).toEqual('child2a'); 108 | expect( 109 | (findNodeAtDepths({parent: container, indexDepths: [0, 1]}) as HTMLElement).getAttribute( 110 | 'name' 111 | ) 112 | ).toEqual('child2b'); 113 | 114 | expect( 115 | (findNodeAtDepths({parent: container, indexDepths: [0, 1, 0]}) as HTMLElement).getAttribute( 116 | 'name' 117 | ) 118 | ).toEqual('child3'); 119 | }); 120 | 121 | it('should be a list node', () => { 122 | const paragraph = document.createElement('div'); 123 | expect(isNodeList({node: paragraph})).toBeFalsy(); 124 | 125 | const ul = document.createElement('ul'); 126 | expect(isNodeList({node: ul})).toBeTruthy(); 127 | 128 | const ol = document.createElement('ol'); 129 | expect(isNodeList({node: ol})).toBeTruthy(); 130 | 131 | const dl = document.createElement('dl'); 132 | expect(isNodeList({node: dl})).toBeTruthy(); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/utils/node.utils.ts: -------------------------------------------------------------------------------- 1 | export const isTextNode = (element: Node | undefined): boolean => { 2 | return element?.nodeType === Node.TEXT_NODE || element?.nodeType === Node.COMMENT_NODE; 3 | }; 4 | 5 | export const toHTMLElement = (element: Node | undefined): HTMLElement | undefined | null => { 6 | return isTextNode(element) ? element.parentElement : (element as HTMLElement); 7 | }; 8 | 9 | export const elementIndex = (element: HTMLElement): number => { 10 | return Array.from(element.parentNode?.children || []).indexOf(element); 11 | }; 12 | 13 | export const nodeIndex = (node: Node): number => { 14 | return Array.from(node.parentNode?.childNodes || []).indexOf(node as ChildNode); 15 | }; 16 | 17 | export const nodeDepths = ({target, paragraph}: {target: Node; paragraph: Node | undefined}) => { 18 | const depths: number[] = [nodeIndex(target)]; 19 | 20 | if (!paragraph) { 21 | return depths; 22 | } 23 | 24 | let parentElement: HTMLElement = target.parentElement; 25 | 26 | while (parentElement && !parentElement.isSameNode(paragraph)) { 27 | depths.push(nodeIndex(parentElement)); 28 | parentElement = parentElement.parentElement; 29 | } 30 | 31 | return depths.reverse(); 32 | }; 33 | 34 | export const findNodeAtDepths = ({ 35 | parent, 36 | indexDepths 37 | }: { 38 | parent: Node | undefined; 39 | indexDepths: number[]; 40 | }): Node | undefined => { 41 | const childNode: ChildNode | undefined = ( 42 | parent?.childNodes ? Array.from(parent?.childNodes) : [] 43 | )[indexDepths[0]]; 44 | 45 | if (!childNode) { 46 | return undefined; 47 | } 48 | 49 | const [, ...rest] = indexDepths; 50 | 51 | if (rest?.length <= 0) { 52 | return childNode; 53 | } 54 | 55 | return findNodeAtDepths({parent: childNode, indexDepths: rest}); 56 | }; 57 | 58 | // https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_categories 59 | export const isPhrasingContent = (node: Node): boolean => 60 | isTextNode(node) || 61 | [ 62 | 'abbr', 63 | 'audio', 64 | 'b', 65 | 'bdo', 66 | 'br', 67 | 'button', 68 | 'canvas', 69 | 'cite', 70 | 'code', 71 | 'data', 72 | 'datalist', 73 | 'dfn', 74 | 'em', 75 | 'embed', 76 | 'i', 77 | 'iframe', 78 | 'img', 79 | 'input', 80 | 'kbd', 81 | 'label', 82 | 'mark', 83 | 'math', 84 | 'meter', 85 | 'noscript', 86 | 'object', 87 | 'output', 88 | 'picture', 89 | 'progress', 90 | 'q', 91 | 'ruby', 92 | 'samp', 93 | 'script', 94 | 'select', 95 | 'small', 96 | 'span', 97 | 'strong', 98 | 'sub', 99 | 'sup', 100 | 'svg', 101 | 'textarea', 102 | 'time', 103 | 'u', 104 | 'var', 105 | 'video', 106 | 'wbr' 107 | ].includes(node.nodeName.toLowerCase()) || 108 | ['a', 'area', 'del', 'ins', 'map'].includes(node.nodeName.toLowerCase()); 109 | 110 | export const isMetaContent = ({nodeName}: Node): boolean => 111 | ['base', 'link', 'meta', 'noscript', 'script', 'style', 'title'].includes(nodeName.toLowerCase()); 112 | 113 | export const isNodeList = ({node: {nodeName}}: {node: Node}): boolean => 114 | ['ul', 'ol', 'dl'].includes(nodeName.toLowerCase()); 115 | -------------------------------------------------------------------------------- /src/utils/paragraphs.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | findAddedNodesParagraphs, 3 | findAddedParagraphs, 4 | findRemovedNodesParagraphs, 5 | findRemovedParagraphs, 6 | findUpdatedParagraphs 7 | } from './paragraphs.utils'; 8 | 9 | describe('paragraphs utils', () => { 10 | const createDiv = ({depth}: {depth: number}) => { 11 | const div = document.createElement('div'); 12 | div.setAttribute('depth', `${depth}`); 13 | 14 | Object.defineProperty(div, 'isEqualNode', { 15 | value: jest.fn((node) => div?.getAttribute('depth') === node?.getAttribute('depth')) 16 | }); 17 | 18 | return div; 19 | }; 20 | 21 | const app = createDiv({depth: 0}); 22 | const container = createDiv({depth: 1}); 23 | 24 | const child1 = createDiv({depth: 2}); 25 | child1.setAttribute('paragraph_id', ''); 26 | 27 | const child2 = createDiv({depth: 2}); 28 | child1.setAttribute('paragraph_id', ''); 29 | 30 | const text = document.createTextNode('test'); 31 | const leaf = createDiv({depth: 1}); 32 | 33 | container.append(child1); 34 | container.append(child2); 35 | child2.append(text); 36 | app.append(container); 37 | app.append(leaf); 38 | 39 | it('should find added paragraphs', () => { 40 | const mutation = { 41 | addedNodes: [child1, text, child2, leaf] 42 | } as unknown as MutationRecord; 43 | 44 | const paragraphs = findAddedParagraphs({container, mutations: [mutation, mutation]}); 45 | expect(paragraphs.length).toEqual(4); 46 | }); 47 | 48 | it('should find removed paragraphs', () => { 49 | const mutation = { 50 | addedNodes: [child1, text, child2], 51 | target: container 52 | } as unknown as MutationRecord; 53 | 54 | const mutationRemoved = { 55 | removedNodes: [child1], 56 | target: container 57 | } as unknown as MutationRecord; 58 | 59 | const removedParagraphs = findRemovedParagraphs({ 60 | mutations: [mutation, mutationRemoved, mutation], 61 | container, 62 | paragraphIdentifier: 'paragraph_id' 63 | }); 64 | expect(removedParagraphs.length).toEqual(1); 65 | }); 66 | 67 | it('should find updated paragraphs', () => { 68 | const mutation1 = { 69 | target: child1 70 | } as unknown as MutationRecord; 71 | 72 | const mutation2 = { 73 | target: child2 74 | } as unknown as MutationRecord; 75 | 76 | const elements = findUpdatedParagraphs({container, mutations: [mutation1, mutation2]}); 77 | expect(elements.length).toEqual(2); 78 | }); 79 | 80 | it('should find added nodes', () => { 81 | const mutation = { 82 | addedNodes: [child1, text, child2, leaf] 83 | } as unknown as MutationRecord; 84 | 85 | const mutationNode = { 86 | addedNodes: [text] 87 | } as unknown as MutationRecord; 88 | 89 | const mutations = findAddedNodesParagraphs({ 90 | container, 91 | mutations: [mutation, mutationNode, mutation] 92 | }); 93 | expect(mutations.length).toEqual(1); 94 | }); 95 | 96 | it('should find removed nodes', () => { 97 | const mutationParagraphs = { 98 | removedNodes: [child1, text, child2], 99 | target: container 100 | } as unknown as MutationRecord; 101 | 102 | const mutationNode = { 103 | removedNodes: [text], 104 | target: child2 105 | } as unknown as MutationRecord; 106 | 107 | const mutationLeaf = { 108 | removedNodes: [leaf], 109 | target: createDiv({depth: 1}) 110 | } as unknown as MutationRecord; 111 | 112 | const mutations = findRemovedNodesParagraphs({ 113 | paragraphIdentifier: 'paragraph_id', 114 | mutations: [mutationParagraphs, mutationNode, mutationLeaf] 115 | }); 116 | 117 | expect(mutations.length).toEqual(2); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /src/utils/selection.utils.ts: -------------------------------------------------------------------------------- 1 | import {getSelection as getDocumentSelection} from '@deckdeckgo/utils'; 2 | 3 | /** 4 | * The document selection or, if a container is provided 5 | */ 6 | export const getSelection = (container?: HTMLElement): Selection | null => { 7 | // https://stackoverflow.com/questions/62054839/shadowroot-getselection 8 | // https://twitter.com/bocoup/status/1459120675390689284?s=20 9 | // https://github.com/WICG/webcomponents/issues/79 10 | if (isShadowRoot(container) && hasShadowRootSelectionApi(container)) { 11 | return getShadowRootSelection(container); 12 | } 13 | 14 | return getDocumentSelection(); 15 | }; 16 | 17 | const isShadowRoot = (container?: HTMLElement): boolean => 18 | container?.getRootNode() instanceof ShadowRoot; 19 | 20 | const hasShadowRootSelectionApi = (container?: HTMLElement): boolean => 21 | (container?.getRootNode() as any).getSelection; 22 | 23 | const getShadowRootSelection = (container: HTMLElement): Selection | null => 24 | (container.getRootNode() as any).getSelection(); 25 | 26 | export const getRange = ( 27 | container?: HTMLElement 28 | ): {range: Range | null; selection: Selection | null} => { 29 | const selection: Selection | null = getSelection(container); 30 | 31 | if (!selection || selection.rangeCount <= 0) { 32 | return { 33 | range: null, 34 | selection: null 35 | }; 36 | } 37 | 38 | return { 39 | selection, 40 | range: selection.getRangeAt(0) 41 | }; 42 | }; 43 | 44 | export const deleteRange = (range: Range) => { 45 | const {startOffset, endOffset} = range; 46 | 47 | const textSelected: boolean = startOffset !== endOffset; 48 | 49 | if (!textSelected) { 50 | return; 51 | } 52 | 53 | range.extractContents(); 54 | }; 55 | -------------------------------------------------------------------------------- /src/utils/toolbar.utils.spec.ts: -------------------------------------------------------------------------------- 1 | import {ToolbarFontSize, ToolbarList} from '../types/toolbar'; 2 | import { 3 | findStyleNode, 4 | getBold, 5 | getFontSize, 6 | getItalic, 7 | getList, 8 | getStrikeThrough, 9 | getUnderline 10 | } from './toolbar.utils'; 11 | 12 | describe('toolbar utils', () => { 13 | const createElement = ({nodeName, depth}: {nodeName: string; depth: number}) => { 14 | const element = document.createElement(nodeName); 15 | element.setAttribute('depth', `${depth}`); 16 | 17 | Object.defineProperty(element, 'isEqualNode', { 18 | value: jest.fn((node) => element?.getAttribute('depth') === node?.getAttribute('depth')) 19 | }); 20 | 21 | return element; 22 | }; 23 | 24 | it('should find style in node', () => { 25 | const app = createElement({nodeName: 'div', depth: 0}); 26 | 27 | const div = createElement({nodeName: 'div', depth: 1}); 28 | div.style.backgroundColor = '#ff0000'; 29 | 30 | const span = createElement({nodeName: 'span', depth: 2}); 31 | span.style.backgroundColor = '#ff0033'; 32 | 33 | div.append(span); 34 | app.append(div); 35 | 36 | const node: Node | null = findStyleNode(span, 'background-color', app); 37 | 38 | expect(node).not.toBeNull(); 39 | expect((node as HTMLElement).style.backgroundColor).toEqual('#ff0033'); 40 | }); 41 | 42 | it('should get bold info', () => { 43 | expect(getBold(document.createElement('b'))).toEqual('bold'); 44 | expect(getBold(document.createElement('strong'))).toEqual('bold'); 45 | 46 | const bold = document.createElement('span'); 47 | bold.style.fontWeight = 'bold'; 48 | expect(getBold(bold)).toEqual('bold'); 49 | 50 | const notBold = document.createElement('span'); 51 | expect(getBold(notBold)).toBeUndefined(); 52 | 53 | const initial = document.createElement('span'); 54 | initial.style.fontWeight = 'initial'; 55 | expect(getBold(initial)).toEqual('initial'); 56 | }); 57 | 58 | it('should get font size', () => { 59 | const span = document.createElement('span'); 60 | span.setAttribute('size', '2'); 61 | expect(getFontSize(span)).toEqual(ToolbarFontSize.SMALL); 62 | }); 63 | 64 | it('should get ul', () => { 65 | const li = document.createElement('li'); 66 | const ul = document.createElement('ul'); 67 | ul.append(li); 68 | expect(getList(li)).toEqual(ToolbarList.UNORDERED); 69 | }); 70 | 71 | it('should get ol', () => { 72 | const li = document.createElement('li'); 73 | const ul = document.createElement('ol'); 74 | ul.append(li); 75 | expect(getList(li)).toEqual(ToolbarList.ORDERED); 76 | }); 77 | 78 | it('should get strike', () => { 79 | expect(getStrikeThrough(document.createElement('strike'))).toEqual('strikethrough'); 80 | 81 | const span = document.createElement('span'); 82 | span.style.textDecoration = 'line-through'; 83 | expect(getStrikeThrough(span)).toEqual('strikethrough'); 84 | 85 | const initial = document.createElement('span'); 86 | initial.style.textDecoration = 'initial'; 87 | expect(getStrikeThrough(initial)).toEqual('initial'); 88 | }); 89 | 90 | it('should get underline', () => { 91 | expect(getUnderline(document.createElement('u'))).toEqual('underline'); 92 | 93 | const span = document.createElement('span'); 94 | span.style.textDecoration = 'underline'; 95 | expect(getUnderline(span)).toEqual('underline'); 96 | 97 | const initial = document.createElement('span'); 98 | initial.style.textDecoration = 'initial'; 99 | expect(getUnderline(initial)).toEqual('initial'); 100 | }); 101 | 102 | it('should get italic', () => { 103 | expect(getItalic(document.createElement('i'))).toEqual('italic'); 104 | expect(getItalic(document.createElement('em'))).toEqual('italic'); 105 | 106 | const span = document.createElement('span'); 107 | span.style.fontStyle = 'italic'; 108 | expect(getItalic(span)).toEqual('italic'); 109 | 110 | const initial = document.createElement('span'); 111 | initial.style.fontStyle = 'initial'; 112 | expect(getItalic(initial)).toEqual('initial'); 113 | }); 114 | }); 115 | -------------------------------------------------------------------------------- /src/utils/undo-redo-selection.utils.ts: -------------------------------------------------------------------------------- 1 | import {UndoRedoSelection} from '../types/undo-redo'; 2 | import {elementIndex, findNodeAtDepths, nodeDepths, toHTMLElement} from './node.utils'; 3 | import {findParagraph} from './paragraph.utils'; 4 | import {getRange, getSelection} from './selection.utils'; 5 | 6 | export const toUndoRedoSelection = (container: Node): UndoRedoSelection | undefined => { 7 | const {range, selection} = getRange(); 8 | 9 | if (!range) { 10 | return undefined; 11 | } 12 | 13 | const {anchorNode, focusNode} = selection; 14 | 15 | const startParagraph: HTMLElement | undefined = toHTMLElement( 16 | findParagraph({element: anchorNode, container}) 17 | ); 18 | const endParagraph: HTMLElement | undefined = toHTMLElement( 19 | findParagraph({element: focusNode, container}) 20 | ); 21 | 22 | if (!startParagraph || !endParagraph) { 23 | return; 24 | } 25 | 26 | return { 27 | startIndex: elementIndex(startParagraph), 28 | startIndexDepths: nodeDepths({ 29 | target: anchorNode, 30 | paragraph: findParagraph({element: anchorNode, container}) 31 | }), 32 | startOffset: selection.anchorOffset, 33 | endIndex: elementIndex(endParagraph), 34 | endIndexDepths: nodeDepths({ 35 | target: focusNode, 36 | paragraph: findParagraph({element: focusNode, container}) 37 | }), 38 | endOffset: selection.focusOffset, 39 | reverse: !anchorNode.isEqualNode(range.startContainer) 40 | }; 41 | }; 42 | 43 | export const redoSelection = ({ 44 | selection, 45 | container 46 | }: { 47 | selection: UndoRedoSelection | undefined; 48 | container: HTMLElement; 49 | }) => { 50 | if (!selection) { 51 | return; 52 | } 53 | 54 | const {startIndex, startIndexDepths, startOffset, endIndex, endIndexDepths, endOffset, reverse} = 55 | selection; 56 | 57 | const startParagraph: Element | undefined = 58 | container.children[Math.min(startIndex, container.children.length - 1)]; 59 | 60 | const endParagraph: Element | undefined = 61 | container.children[Math.min(endIndex, container.children.length - 1)]; 62 | 63 | const startNode: Node | undefined = findNodeAtDepths({ 64 | parent: startParagraph, 65 | indexDepths: startIndexDepths 66 | }); 67 | const endNode: Node | undefined = findNodeAtDepths({ 68 | parent: endParagraph, 69 | indexDepths: endIndexDepths 70 | }); 71 | 72 | if (!startNode || !endNode) { 73 | return; 74 | } 75 | 76 | // Prevent error "DOMException: Failed to execute 'setStart' on 'Range': The offset 7 is larger than the node's length (1)." 77 | if (startNode.textContent.length < startOffset || endNode.textContent.length < endOffset) { 78 | return; 79 | } 80 | 81 | const range: Range = document.createRange(); 82 | 83 | if (!reverse) { 84 | range.setStart(startNode, startOffset); 85 | range.setEnd(endNode, endOffset); 86 | } else { 87 | range.setEnd(startNode, startOffset); 88 | range.setStart(endNode, endOffset); 89 | } 90 | 91 | const windowSelection: Selection | null = getSelection(container); 92 | windowSelection?.removeAllRanges(); 93 | windowSelection?.addRange(range); 94 | 95 | range.detach(); 96 | }; 97 | -------------------------------------------------------------------------------- /stencil.config.ts: -------------------------------------------------------------------------------- 1 | import {Config} from '@stencil/core'; 2 | import {postcss} from '@stencil/postcss'; 3 | import {sass} from '@stencil/sass'; 4 | // @ts-ignore 5 | import autoprefixer from 'autoprefixer'; 6 | 7 | export const config: Config = { 8 | namespace: 'stylo', 9 | outputTargets: [ 10 | { 11 | type: 'dist' 12 | }, 13 | { 14 | type: 'www', 15 | serviceWorker: null 16 | }, 17 | { 18 | type: 'docs-readme' 19 | }, 20 | { 21 | type: 'dist-custom-elements', 22 | customElementsExportBehavior: 'auto-define-custom-elements' 23 | } 24 | ], 25 | plugins: [ 26 | sass(), 27 | postcss({ 28 | plugins: [autoprefixer()] 29 | }) 30 | ], 31 | devServer: { 32 | openBrowser: false 33 | }, 34 | testing: {setupFilesAfterEnv: ['/src/jest-setup.ts']}, 35 | extras: { 36 | enableImportInjection: true 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowSyntheticDefaultImports": true, 4 | "allowUnreachableCode": false, 5 | "declaration": false, 6 | "experimentalDecorators": true, 7 | "lib": ["dom", "es2017"], 8 | "moduleResolution": "node", 9 | "module": "esnext", 10 | "target": "es2017", 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "jsx": "react", 14 | "jsxFactory": "h" 15 | }, 16 | "include": ["src"], 17 | "exclude": ["node_modules"] 18 | } 19 | --------------------------------------------------------------------------------