├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
80 |
81 | `);
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/src/components/plugins/list/list.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Component,
3 | ComponentInterface,
4 | Element,
5 | Event,
6 | EventEmitter,
7 | Fragment,
8 | h,
9 | JSX,
10 | Method,
11 | Prop,
12 | State,
13 | Watch
14 | } from '@stencil/core';
15 | import configStore from '../../../stores/config.store';
16 | import i18n from '../../../stores/i18n.store';
17 | import {StyloPlugin} from '../../../types/plugin';
18 | import {renderIcon} from '../../../utils/icon.utils';
19 | import {toHTMLElement} from '../../../utils/node.utils';
20 |
21 | @Component({
22 | tag: 'stylo-list',
23 | styleUrl: 'list.scss',
24 | shadow: true
25 | })
26 | export class List implements ComponentInterface {
27 | @Element()
28 | private el: HTMLElement;
29 |
30 | /**
31 | * Emit which plugin the user want to apply.
32 | */
33 | @Event()
34 | applyPlugin: EventEmitter;
35 |
36 | /**
37 | * Emit when user actually do not want to apply a plugin.
38 | */
39 | @Event()
40 | cancelPlugins: EventEmitter;
41 |
42 | /**
43 | * @internal
44 | */
45 | @Prop()
46 | display: boolean = false;
47 |
48 | @State()
49 | private plugins: StyloPlugin[];
50 |
51 | private focusButton: HTMLElement | undefined;
52 |
53 | private filter: string = '';
54 |
55 | componentWillLoad() {
56 | this.plugins = [...configStore.state.plugins];
57 | }
58 |
59 | componentDidUpdate() {
60 | this.focusOnUpdate();
61 | }
62 |
63 | private focusOnUpdate() {
64 | // If only one plugin button is displayed, focus it
65 | const buttons: NodeListOf = this.el.shadowRoot.querySelectorAll('button');
66 |
67 | if (buttons.length === 1) {
68 | buttons[0].focus();
69 | }
70 | }
71 |
72 | private emitPlugin($event: UIEvent, plugin: StyloPlugin) {
73 | $event.stopPropagation();
74 |
75 | this.applyPlugin.emit(plugin);
76 | }
77 |
78 | @Watch('display')
79 | onDisplay() {
80 | if (this.display) {
81 | document.addEventListener('keydown', this.onKeyDown);
82 | return;
83 | }
84 |
85 | document.removeEventListener('keydown', this.onKeyDown, false);
86 |
87 | this.reset();
88 | }
89 |
90 | private reset() {
91 | this.filter = '';
92 | this.plugins = [...configStore.state.plugins];
93 | }
94 |
95 | private onKeyDown = ($event: KeyboardEvent) => {
96 | const {code} = $event;
97 |
98 | if (['Enter'].includes(code)) {
99 | return;
100 | }
101 |
102 | $event.preventDefault();
103 |
104 | if (['Escape'].includes(code)) {
105 | this.cancelPlugins.emit();
106 | return;
107 | }
108 |
109 | if (['ArrowDown'].includes(code)) {
110 | this.focusNext();
111 | return;
112 | }
113 |
114 | if (['ArrowUp'].includes(code)) {
115 | this.focusPrevious();
116 | return;
117 | }
118 |
119 | this.filterPlugins($event);
120 | };
121 |
122 | @Method()
123 | async focusFirstButton() {
124 | this.focusButton = this.el.shadowRoot.querySelector('button');
125 | this.focusButton?.focus();
126 | }
127 |
128 | private focusNext() {
129 | this.focusButton = toHTMLElement(
130 | (this.focusButton || this.el.shadowRoot.firstElementChild)?.nextElementSibling
131 | );
132 | this.focusButton?.focus();
133 | }
134 |
135 | private focusPrevious() {
136 | this.focusButton = toHTMLElement(
137 | (this.focusButton || this.el.shadowRoot.lastElementChild)?.previousElementSibling
138 | );
139 | this.focusButton?.focus();
140 | }
141 |
142 | private filterPlugins($event: KeyboardEvent) {
143 | const {code, metaKey, ctrlKey, key} = $event;
144 |
145 | if (metaKey || ctrlKey) {
146 | return;
147 | }
148 |
149 | // For example Space or ArrowUp
150 | if (key.length > 1 && !['Backspace'].includes(code)) {
151 | return;
152 | }
153 |
154 | this.filter =
155 | code === 'Backspace'
156 | ? this.filter.length > 0
157 | ? this.filter.slice(0, -1)
158 | : this.filter
159 | : `${this.filter}${key}`;
160 |
161 | this.plugins = [...configStore.state.plugins].filter(({text}: StyloPlugin) => {
162 | const label: string = i18n.state.plugins[text] ?? i18n.state.custom[text] ?? text;
163 |
164 | return label.toLowerCase().indexOf(this.filter.toLowerCase()) > -1;
165 | });
166 | }
167 |
168 | render() {
169 | return (
170 |
171 | {this.plugins.map((plugin: StyloPlugin, i: number) =>
172 | this.renderPlugin(plugin, `plugin-${i}`)
173 | )}
174 |
175 | {this.renderEmpty()}
176 |
177 | );
178 | }
179 |
180 | private renderEmpty() {
181 | if (this.plugins.length > 0) {
182 | return undefined;
183 | }
184 |
185 | return (
186 |
187 | {i18n.state.plugins.no_matches}: {this.filter}
188 |
189 | );
190 | }
191 |
192 | private renderPlugin(plugin: StyloPlugin, key: string) {
193 | const {text, icon: iconSrc} = plugin;
194 |
195 | const icon: JSX.IntrinsicElements | undefined = renderIcon(iconSrc);
196 |
197 | return (
198 |
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 |
13 |
14 |
15 |
16 |
17 |
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 => ``;
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: ``,
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 |
--------------------------------------------------------------------------------