├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── assets └── 68747470733a2f2f636170656c6c612e706963732f34313239346365632d613262332d343135372d383339392d6666656665643364386666642e6a7067.jpeg ├── index.html ├── package.json ├── postcss.config.js ├── src ├── index.js ├── plugin.js ├── styles │ ├── index.pcss │ ├── popover.pcss │ ├── settings.pcss │ ├── table.pcss │ └── toolboxes.pcss ├── table.js ├── toolbox.js └── utils │ ├── dom.js │ ├── popover.js │ └── throttled.js ├── tsconfig.json ├── vite.config.js └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .github 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "codex" 4 | ], 5 | "rules": { 6 | "jsdoc/no-undefined-types": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to NPM 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish-and-notify: 10 | uses: codex-team/github-workflows/.github/workflows/npm-publish-and-notify-reusable.yml@main 11 | secrets: 12 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 13 | CODEX_BOT_NOTIFY_EDITORJS_PUBLIC_CHAT: ${{ secrets.CODEX_BOT_NOTIFY_EDITORJS_PUBLIC_CHAT }} 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | assets/ 3 | src/ 4 | .eslintrc 5 | postcss.config.js 6 | vite.config.js 7 | test.html 8 | yarn.lock 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CodeX 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Table tool 2 | 3 | The Table Block for the [Editor.js](https://editorjs.io). Finally improved. 4 | 5 | ![](assets/68747470733a2f2f636170656c6c612e706963732f34313239346365632d613262332d343135372d383339392d6666656665643364386666642e6a7067.jpeg) 6 | 7 | ## Installation 8 | 9 | Get the package 10 | 11 | ```shell 12 | yarn add @editorjs/table 13 | ``` 14 | 15 | Include module at your application 16 | 17 | ```javascript 18 | import Table from '@editorjs/table' 19 | ``` 20 | 21 | Optionally, you can load this tool from CDN [JsDelivr CDN](https://cdn.jsdelivr.net/npm/@editorjs/table@latest) 22 | 23 | 24 | 25 | ## Usage 26 | 27 | Add a new Tool to the `tools` property of the Editor.js initial config. 28 | 29 | ```javascript 30 | import Table from '@editorjs/table'; 31 | 32 | var editor = EditorJS({ 33 | tools: { 34 | table: Table, 35 | } 36 | }); 37 | ``` 38 | 39 | Or init the Table tool with additional settings 40 | 41 | ```javascript 42 | var editor = EditorJS({ 43 | tools: { 44 | table: { 45 | class: Table, 46 | inlineToolbar: true, 47 | config: { 48 | rows: 2, 49 | cols: 3, 50 | maxRows: 5, 51 | maxCols: 5, 52 | }, 53 | }, 54 | }, 55 | }); 56 | ``` 57 | 58 | ## Config Params 59 | 60 | | Field | Type | Description | 61 | | ------------------ | -------- | ---------------------------------------- | 62 | | `rows` | `number` | initial number of rows. `2` by default | 63 | | `cols` | `number` | initial number of columns. `2` by default | 64 | | `maxRows` | `number` | maximum number of rows. `5` by params | 65 | | `maxCols` | `number` | maximum number of columns. `5` by params | 66 | | `withHeadings` | `boolean` | toggle table headings. `false` by default | 67 | | `stretched` | `boolean` | whether the table is stretched to fill the full width of the container | 68 | 69 | ## Output data 70 | 71 | This Tool returns `data` in the following format 72 | 73 | | Field | Type | Description | 74 | | -------------- | ------------ | ----------------------------------------- | 75 | | `withHeadings` | `boolean` | Uses the first line as headings | 76 | | `stretched` | `boolean` | whether the table is stretched to fill the full width of the container | 77 | | `content` | `string[][]` | two-dimensional array with table contents | 78 | 79 | ```json 80 | { 81 | "type" : "table", 82 | "data" : { 83 | "withHeadings": true, 84 | "stretched": false, 85 | "content" : [ [ "Kine", "Pigs", "Chicken" ], [ "1 pcs", "3 pcs", "12 pcs" ], [ "100$", "200$", "150$" ] ] 86 | } 87 | } 88 | ``` 89 | 90 | ## CSP support 91 | 92 | If you're using Content Security Policy (CSP) pass a `nonce` via [``](https://github.com/marco-prontera/vite-plugin-css-injected-by-js#usestrictcsp-boolean) in your document head. 93 | 94 | # Support maintenance 🎖 95 | 96 | If you're using this tool and editor.js in your business, please consider supporting their maintenance and evolution. 97 | 98 | [http://opencollective.com/editorjs](http://opencollective.com/editorjs) 99 | 100 | # About CodeX 101 | 102 | 103 | 104 | CodeX is a team of digital specialists around the world interested in building high-quality open source products on a global market. We are [open](https://codex.so/join) for young people who want to constantly improve their skills and grow professionally with experiments in leading technologies. 105 | 106 | | 🌐 | Join 👋 | Twitter | Instagram | 107 | | -- | -- | -- | -- | 108 | | [codex.so](https://codex.so) | [codex.so/join](https://codex.so/join) |[@codex_team](http://twitter.com/codex_team) | [@codex_team](http://instagram.com/codex_team) | 109 | -------------------------------------------------------------------------------- /assets/68747470733a2f2f636170656c6c612e706963732f34313239346365632d613262332d343135372d383339392d6666656665643364386666642e6a7067.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/editor-js/table/0eff0828e2345f8e79976c5bdebe27ef837b59e5/assets/68747470733a2f2f636170656c6c612e706963732f34313239346365632d613262332d343135372d383339392d6666656665643364386666642e6a7067.jpeg -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Test of a New Beautiful Table 7 | 8 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | 39 | 40 |

41 | 
42 |   
43 | 
44 |   
87 | 
88 | 
89 | 
90 | 


--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "name": "@editorjs/table",
 3 |   "description": "Table for Editor.js",
 4 |   "version": "2.4.5",
 5 |   "license": "MIT",
 6 |   "repository": "https://github.com/editor-js/table",
 7 |   "files": [
 8 |     "dist"
 9 |   ],
10 |   "main": "./dist/table.umd.js",
11 |   "module": "./dist/table.mjs",
12 |   "types": "./dist/index.d.ts",
13 |   "exports": {
14 |     ".": {
15 |       "import": "./dist/table.mjs",
16 |       "require": "./dist/table.umd.js",
17 |       "types": "./dist/index.d.ts"
18 |     }
19 |   },
20 |   "scripts": {
21 |     "dev": "vite",
22 |     "build": "vite build",
23 |     "lint": "eslint -c ./.eslintrc --ext .js --fix ."
24 |   },
25 |   "author": {
26 |     "name": "CodeX Team",
27 |     "email": "team@ifmo.su"
28 |   },
29 |   "keywords": [
30 |     "codex",
31 |     "codex-editor",
32 |     "table",
33 |     "editor.js",
34 |     "editorjs"
35 |   ],
36 |   "devDependencies": {
37 |     "autoprefixer": "^9.3.1",
38 |     "css-loader": "^1.0.0",
39 |     "cssnano": "^4.1.7",
40 |     "eslint": "^5.8.0",
41 |     "eslint-config-codex": "^2.0.1",
42 |     "postcss-import": "^12.0.1",
43 |     "postcss-nested": "^4.1.0",
44 |     "postcss-nesting": "^7.0.0",
45 |     "vite": "^4.5.0",
46 |     "vite-plugin-css-injected-by-js": "^3.3.0",
47 |     "vite-plugin-dts": "^3.9.1",
48 |     "typescript": "^5.5.4"
49 |   },
50 |   "dependencies": {
51 |     "@codexteam/icons": "^0.0.6"
52 |   }
53 | }


--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
 1 | module.exports = {
 2 |   plugins: [
 3 |     require('postcss-import'),
 4 |     require('autoprefixer'),
 5 |     require('cssnano'),
 6 |     require('postcss-nested'),
 7 |     require('postcss-nesting')
 8 |   ]
 9 | };
10 | 


--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Plugin from './plugin';
2 | import './styles/index.pcss';
3 | 
4 | export default Plugin;
5 | 


--------------------------------------------------------------------------------
/src/plugin.js:
--------------------------------------------------------------------------------
  1 | import Table from './table';
  2 | import * as $ from './utils/dom';
  3 | 
  4 | import { IconTable, IconTableWithHeadings, IconTableWithoutHeadings, IconStretch, IconCollapse } from '@codexteam/icons';
  5 | /**
  6 |  * @typedef {object} TableData - configuration that the user can set for the table
  7 |  * @property {number} rows - number of rows in the table
  8 |  * @property {number} cols - number of columns in the table
  9 |  */
 10 | /**
 11 |  * @typedef {object} Tune - setting for the table
 12 |  * @property {string} name - tune name
 13 |  * @property {HTMLElement} icon - icon for the tune
 14 |  * @property {boolean} isActive - default state of the tune
 15 |  * @property {void} setTune - set tune state to the table data
 16 |  */
 17 | /**
 18 |  * @typedef {object} TableConfig - object with the data transferred to form a table
 19 |  * @property {boolean} withHeading - setting to use cells of the first row as headings
 20 |  * @property {string[][]} content - two-dimensional array which contains table content
 21 |  */
 22 | /**
 23 |  * @typedef {object} TableConstructor
 24 |  * @property {TableConfig} data — previously saved data
 25 |  * @property {TableConfig} config - user config for Tool
 26 |  * @property {object} api - Editor.js API
 27 |  * @property {boolean} readOnly - read-only mode flag
 28 |  */
 29 | /**
 30 |  * @typedef {import('@editorjs/editorjs').PasteEvent} PasteEvent
 31 |  */
 32 | 
 33 | 
 34 | /**
 35 |  * Table block for Editor.js
 36 |  */
 37 | export default class TableBlock {
 38 |   /**
 39 |    * Notify core that read-only mode is supported
 40 |    *
 41 |    * @returns {boolean}
 42 |    */
 43 |   static get isReadOnlySupported() {
 44 |     return true;
 45 |   }
 46 | 
 47 |   /**
 48 |    * Allow to press Enter inside the CodeTool textarea
 49 |    *
 50 |    * @returns {boolean}
 51 |    * @public
 52 |    */
 53 |   static get enableLineBreaks() {
 54 |     return true;
 55 |   }
 56 | 
 57 |   /**
 58 |    * Render plugin`s main Element and fill it with saved data
 59 |    *
 60 |    * @param {TableConstructor} init
 61 |    */
 62 |   constructor({data, config, api, readOnly, block}) {
 63 |     this.api = api;
 64 |     this.readOnly = readOnly;
 65 |     this.config = config;
 66 |     this.data = {
 67 |       withHeadings: this.getConfig('withHeadings', false, data),
 68 |       stretched: this.getConfig('stretched', false, data),
 69 |       content: data && data.content ? data.content : []
 70 |     };
 71 |     this.table = null;
 72 |     this.block = block;
 73 |   }
 74 | 
 75 |   /**
 76 |    * Get Tool toolbox settings
 77 |    * icon - Tool icon's SVG
 78 |    * title - title to show in toolbox
 79 |    *
 80 |    * @returns {{icon: string, title: string}}
 81 |    */
 82 |   static get toolbox() {
 83 |     return {
 84 |       icon: IconTable,
 85 |       title: 'Table'
 86 |     };
 87 |   }
 88 | 
 89 |   /**
 90 |    * Return Tool's view
 91 |    *
 92 |    * @returns {HTMLDivElement}
 93 |    */
 94 |   render() {
 95 |     /** creating table */
 96 |     this.table = new Table(this.readOnly, this.api, this.data, this.config);
 97 | 
 98 |     /** creating container around table */
 99 |     this.container = $.make('div', this.api.styles.block);
100 |     this.container.appendChild(this.table.getWrapper());
101 | 
102 |     this.table.setHeadingsSetting(this.data.withHeadings);
103 | 
104 |     return this.container;
105 |   }
106 | 
107 |   /**
108 |    * Returns plugin settings
109 |    *
110 |    * @returns {Array}
111 |    */
112 |   renderSettings() {
113 |     return [
114 |       {
115 |         label: this.api.i18n.t('With headings'),
116 |         icon: IconTableWithHeadings,
117 |         isActive: this.data.withHeadings,
118 |         closeOnActivate: true,
119 |         toggle: true,
120 |         onActivate: () => {
121 |           this.data.withHeadings = true;
122 |           this.table.setHeadingsSetting(this.data.withHeadings);
123 |         }
124 |       }, {
125 |         label: this.api.i18n.t('Without headings'),
126 |         icon: IconTableWithoutHeadings,
127 |         isActive: !this.data.withHeadings,
128 |         closeOnActivate: true,
129 |         toggle: true,
130 |         onActivate: () => {
131 |           this.data.withHeadings = false;
132 |           this.table.setHeadingsSetting(this.data.withHeadings);
133 |         }
134 |       }, {
135 |         label: this.data.stretched ? this.api.i18n.t('Collapse') : this.api.i18n.t('Stretch'),
136 |         icon: this.data.stretched ? IconCollapse : IconStretch,
137 |         closeOnActivate: true,
138 |         toggle: true,
139 |         onActivate: () => {
140 |           this.data.stretched = !this.data.stretched;
141 |           this.block.stretched = this.data.stretched;
142 |         }
143 |       }
144 |     ];
145 |   }
146 |   /**
147 |    * Extract table data from the view
148 |    *
149 |    * @returns {TableData} - saved data
150 |    */
151 |   save() {
152 |     const tableContent = this.table.getData();
153 | 
154 |     const result = {
155 |       withHeadings: this.data.withHeadings,
156 |       stretched: this.data.stretched,
157 |       content: tableContent
158 |     };
159 | 
160 |     return result;
161 |   }
162 | 
163 |   /**
164 |    * Plugin destroyer
165 |    *
166 |    * @returns {void}
167 |    */
168 |   destroy() {
169 |     this.table.destroy();
170 |   }
171 | 
172 |   /**
173 |    * A helper to get config value.
174 |    *
175 |    * @param {string} configName - the key to get from the config.
176 |    * @param {any} defaultValue - default value if config doesn't have passed key
177 |    * @param {object} savedData - previously saved data. If passed, the key will be got from there, otherwise from the config
178 |    * @returns {any} - config value.
179 |    */
180 |   getConfig(configName, defaultValue = undefined, savedData = undefined) {
181 |     const data = this.data || savedData;
182 | 
183 |     if (data) {
184 |       return data[configName] ? data[configName] : defaultValue;
185 |     }
186 | 
187 |     return this.config && this.config[configName] ? this.config[configName] : defaultValue;
188 |   }
189 | 
190 |   /**
191 |    * Table onPaste configuration
192 |    *
193 |    * @public
194 |    */
195 |   static get pasteConfig() {
196 |     return { tags: ['TABLE', 'TR', 'TH', 'TD'] };
197 |   }
198 | 
199 |   /**
200 |    * On paste callback that is fired from Editor
201 |    *
202 |    * @param {PasteEvent} event - event with pasted data
203 |    */
204 |   onPaste(event) {
205 |     const table = event.detail.data;
206 | 
207 |     /** Check if the first row is a header */
208 |     const firstRowHeading = table.querySelector(':scope > thead, tr:first-of-type th');
209 | 
210 |     /** Get all rows from the table */
211 |     const rows = Array.from(table.querySelectorAll('tr'));
212 | 
213 |     /** Generate a content matrix */
214 |     const content = rows.map((row) => {
215 |       /** Get cells from row */
216 |       const cells = Array.from(row.querySelectorAll('th, td'))
217 | 
218 |       /** Return cells content */
219 |       return cells.map((cell) => cell.innerHTML);
220 |     });
221 | 
222 |     /** Update Tool's data */
223 |     this.data = {
224 |       withHeadings: firstRowHeading !== null,
225 |       content
226 |     };
227 | 
228 |     /** Update table block */
229 |     if (this.table.wrapper) {
230 |       this.table.wrapper.replaceWith(this.render());
231 |     }
232 |   }
233 | }
234 | 


--------------------------------------------------------------------------------
/src/styles/index.pcss:
--------------------------------------------------------------------------------
1 | @import './table.pcss';
2 | @import './toolboxes.pcss';
3 | @import './settings.pcss';
4 | @import './popover.pcss';
5 | 


--------------------------------------------------------------------------------
/src/styles/popover.pcss:
--------------------------------------------------------------------------------
 1 | .tc-popover {
 2 |   --color-border: #eaeaea;
 3 |   --color-background: #fff;
 4 |   --color-background-hover: rgba(232,232,235,0.49);
 5 |   --color-background-confirm: #e24a4a;
 6 |   --color-background-confirm-hover: #d54040;
 7 |   --color-text-confirm: #fff;
 8 | 
 9 |   background: var(--color-background);
10 |   border: 1px solid var(--color-border);
11 |   box-shadow: 0 3px 15px -3px rgba(13,20,33,0.13);
12 |   border-radius: 6px;
13 |   padding: 6px;
14 |   display: none;
15 |   will-change: opacity, transform;
16 | 
17 |   &--opened {
18 |     display: block;
19 |     animation: menuShowing 100ms cubic-bezier(0.215, 0.61, 0.355, 1) forwards;
20 |   }
21 | 
22 | 
23 |   &__item {
24 |     display: flex;
25 |     align-items: center;
26 |     padding: 2px 14px 2px 2px;
27 |     border-radius: 5px;
28 |     cursor: pointer;
29 |     white-space: nowrap;
30 |     user-select: none;
31 | 
32 |     &:hover {
33 |       background: var(--color-background-hover);
34 |     }
35 | 
36 |     &:not(:last-of-type){
37 |       margin-bottom: 2px;
38 |     }
39 | 
40 |     &-icon {
41 |       display: inline-flex;
42 |       width: 26px;
43 |       height: 26px;
44 |       align-items: center;
45 |       justify-content: center;
46 |       background: var(--color-background);
47 |       border-radius: 5px;
48 |       border: 1px solid var(--color-border);
49 |       margin-right: 8px;
50 |     }
51 | 
52 |     &-label {
53 |       line-height: 22px;
54 |       font-size: 14px;
55 |       font-weight: 500;
56 |     }
57 | 
58 |     &--confirm {
59 |       background: var(--color-background-confirm);
60 |       color: var(--color-text-confirm);
61 | 
62 |       &:hover {
63 |         background-color: var(--color-background-confirm-hover);
64 |       }
65 |     }
66 | 
67 |     &--confirm &-icon {
68 |       background: var(--color-background-confirm);
69 |       border-color: rgba(0,0,0,0.1);
70 | 
71 |       svg {
72 |         transition: transform 200ms ease-in;
73 |         transform: rotate(90deg) scale(1.2);
74 |       }
75 |     }
76 | 
77 |     &--hidden {
78 |       display: none;
79 |     }
80 |   }
81 | }
82 | 
83 | @keyframes menuShowing {
84 |   0% {
85 |     opacity:0;
86 |     transform:translateY(-8px) scale(.9)
87 |   }
88 |   70% {
89 |     opacity:1;
90 |     transform:translateY(2px)
91 |   }
92 |   to {
93 |     transform:translateY(0)
94 |   }
95 | }
96 | 


--------------------------------------------------------------------------------
/src/styles/settings.pcss:
--------------------------------------------------------------------------------
1 | .tc-settings {
2 |   .cdx-settings-button {
3 |     width: 50%;
4 |     margin: 0;
5 |   }
6 | }
7 | 


--------------------------------------------------------------------------------
/src/styles/table.pcss:
--------------------------------------------------------------------------------
  1 | /* tc- project's prefix*/
  2 | .tc-wrap {
  3 |   --color-background: #f9f9fb;
  4 |   --color-text-secondary: #7b7e89;
  5 |   --color-border: #e8e8eb;
  6 |   --cell-size: 34px;
  7 |   --toolbox-icon-size: 18px;
  8 |   --toolbox-padding: 6px;
  9 |   --toolbox-aiming-field-size: calc(
 10 |     var(--toolbox-icon-size) + 2 * var(--toolbox-padding)
 11 |   );
 12 | 
 13 |   border-left: 0px;
 14 |   position: relative;
 15 |   height: 100%;
 16 |   width: 100%;
 17 |   margin-top: var(--toolbox-icon-size);
 18 |   box-sizing: border-box;
 19 |   display: grid;
 20 |   grid-template-columns: calc(100% - var(--cell-size)) var(--cell-size);
 21 |   /* Bug-fix: https://github.com/editor-js/table/issues/175 */
 22 |   z-index: 0;
 23 | 
 24 |   &--readonly {
 25 |     grid-template-columns: 100% var(--cell-size);
 26 |   }
 27 | 
 28 |   svg {
 29 |     vertical-align: top;
 30 |   }
 31 | 
 32 |   @media print {
 33 |     border-left: 1px solid var(--color-border);
 34 |     grid-template-columns: 100% var(--cell-size);
 35 |   }
 36 | 
 37 |   .tc-row::after {
 38 |     @media print {
 39 |       display: none;
 40 |     }
 41 |   }
 42 | }
 43 | 
 44 | .tc-table {
 45 |   position: relative;
 46 |   width: 100%;
 47 |   height: 100%;
 48 |   display: grid;
 49 |   font-size: 14px;
 50 |   border-top: 1px solid var(--color-border);
 51 |   line-height: 1.4;
 52 | 
 53 |   &::after {
 54 |     position: absolute;
 55 |     content: "";
 56 |     width: calc(var(--cell-size));
 57 |     height: 100%;
 58 |     left: calc(-1 * var(--cell-size));
 59 |     top: 0;
 60 |   }
 61 | 
 62 |   &::before {
 63 |     position: absolute;
 64 |     content: "";
 65 |     width: 100%;
 66 |     height: var(--toolbox-aiming-field-size);
 67 |     top: calc(-1 * var(--toolbox-aiming-field-size));
 68 |     left: 0;
 69 |   }
 70 | 
 71 |   &--heading {
 72 |     & .tc-row:first-child {
 73 |       font-weight: 600;
 74 |       border-bottom: 2px solid var(--color-border);
 75 |       position: sticky;
 76 |       top: 0;
 77 |       z-index: 2;
 78 |       background: var(--color-background);
 79 |       & [contenteditable]:empty::before {
 80 |         content: attr(heading);
 81 |         color: var(--color-text-secondary);
 82 |       }
 83 | 
 84 |       &::after {
 85 |         bottom: -2px;
 86 |         border-bottom: 2px solid var(--color-border);
 87 |       }
 88 |     }
 89 |   }
 90 | }
 91 | 
 92 | .tc-add {
 93 |   &-column,
 94 |   &-row {
 95 |     display: flex;
 96 |     color: var(--color-text-secondary);
 97 |   }
 98 | 
 99 |   @media print {
100 |     display: none;
101 |   }
102 | }
103 | 
104 | .tc-add-column {
105 |   display: grid;
106 |   border-top: 1px solid var(--color-border);
107 |   grid-template-columns: var(--cell-size);
108 |   grid-auto-rows: var(--cell-size);
109 |   place-items: center;
110 |   svg {
111 |     padding: 5px;
112 |     position: sticky;
113 |     top: 0;
114 |     background-color: var(--color-background);
115 |   }
116 | 
117 |   &--disabled {
118 |     visibility: hidden;
119 |   }
120 | 
121 |   @media print {
122 |     display: none;
123 |   }
124 | }
125 | 
126 | .tc-add-row {
127 |   height: var(--cell-size);
128 |   align-items: center;
129 |   padding-left: 4px;
130 |   position: relative;
131 |   &--disabled {
132 |     display: none;
133 |   }
134 | 
135 |   &::before {
136 |     content: "";
137 |     position: absolute;
138 |     right: calc(-1 * var(--cell-size));
139 |     width: var(--cell-size);
140 |     height: 100%;
141 |   }
142 | 
143 |   @media print {
144 |     display: none;
145 |   }
146 | }
147 | 
148 | .tc-add {
149 |   &-column,
150 |   &-row {
151 |     transition: 0s;
152 |     cursor: pointer;
153 |     will-change: background-color;
154 | 
155 |     &:hover {
156 |       transition: background-color 0.1s ease;
157 |       background-color: var(--color-background);
158 |     }
159 |   }
160 | 
161 |   &-row {
162 |     margin-top: 1px;
163 | 
164 |     &:hover::before {
165 |       transition: 0.1s;
166 |       background-color: var(--color-background);
167 |     }
168 |   }
169 | }
170 | 
171 | .tc-row {
172 |   display: grid;
173 |   grid-template-columns: repeat(auto-fit, minmax(10px, 1fr));
174 |   position: relative;
175 |   border-bottom: 1px solid var(--color-border);
176 | 
177 |   &::after {
178 |     content: "";
179 |     pointer-events: none;
180 |     position: absolute;
181 |     width: var(--cell-size);
182 |     height: 100%;
183 |     bottom: -1px;
184 |     right: calc(-1 * var(--cell-size));
185 |     border-bottom: 1px solid var(--color-border);
186 |   }
187 | 
188 |   &--selected {
189 |     background: var(--color-background);
190 |   }
191 | }
192 | 
193 | .tc-row--selected {
194 |   &::after {
195 |     background: var(--color-background);
196 |   }
197 | }
198 | 
199 | .tc-cell {
200 |   border-right: 1px solid var(--color-border);
201 |   padding: 6px 12px 6px 12px;
202 |   overflow: hidden;
203 |   outline: none;
204 |   line-break: normal;
205 | 
206 |   &--selected {
207 |     background: var(--color-background);
208 |   }
209 | }
210 | 
211 | .tc-wrap--readonly .tc-row::after {
212 |   display: none;
213 | }
214 | 


--------------------------------------------------------------------------------
/src/styles/toolboxes.pcss:
--------------------------------------------------------------------------------
 1 | .tc-toolbox {
 2 |   --toolbox-padding: 6px;
 3 |   --popover-margin: 30px;
 4 |   --toggler-click-zone-size: 30px;
 5 |   --toggler-dots-color: #7B7E89;
 6 |   --toggler-dots-color-hovered: #1D202B;
 7 | 
 8 |   position: absolute;
 9 |   cursor: pointer;
10 |   z-index: 1;
11 |   opacity: 0;
12 |   transition: opacity 0.1s;
13 | 
14 |   will-change: left, opacity;
15 | 
16 |   &--column {
17 |     top: calc(-1 * (var(--toggler-click-zone-size)));
18 |     transform: translateX(calc(-1 * var(--toggler-click-zone-size) / 2));
19 |     will-change: left, opacity;
20 |   }
21 | 
22 |   &--row {
23 |     left: calc(-1 * var(--popover-margin));
24 |     transform: translateY(calc(-1 * var(--toggler-click-zone-size) / 2));
25 |     margin-top: -1px; /* because of top border */
26 |     will-change: top, opacity;
27 |   }
28 | 
29 |   &--showed {
30 |     opacity: 1;
31 |   }
32 | 
33 |   .tc-popover {
34 |     position: absolute;
35 |     top: 0;
36 |     left: var(--popover-margin)
37 |   }
38 | 
39 |   &__toggler {
40 |     display: flex;
41 |     align-items: center;
42 |     justify-content: center;
43 |     width: var(--toggler-click-zone-size);
44 |     height: var(--toggler-click-zone-size);
45 |     color: var(--toggler-dots-color);
46 |     opacity: 0;
47 |     transition: opacity 150ms ease;
48 |     will-change: opacity;
49 | 
50 |     &:hover {
51 |       color: var(--toggler-dots-color-hovered);
52 |     }
53 | 
54 |     svg {
55 |       fill: currentColor;
56 |     }
57 |   }
58 | }
59 | 
60 | .tc-wrap:hover .tc-toolbox__toggler {
61 |   opacity: 1;
62 | }
63 | 


--------------------------------------------------------------------------------
/src/table.js:
--------------------------------------------------------------------------------
   1 | import Toolbox from './toolbox';
   2 | import * as $ from './utils/dom';
   3 | import throttled from './utils/throttled';
   4 | 
   5 | import {
   6 |   IconDirectionLeftDown,
   7 |   IconDirectionRightDown,
   8 |   IconDirectionUpRight,
   9 |   IconDirectionDownRight,
  10 |   IconCross,
  11 |   IconPlus
  12 | } from '@codexteam/icons';
  13 | 
  14 | const CSS = {
  15 |   wrapper: 'tc-wrap',
  16 |   wrapperReadOnly: 'tc-wrap--readonly',
  17 |   table: 'tc-table',
  18 |   row: 'tc-row',
  19 |   withHeadings: 'tc-table--heading',
  20 |   rowSelected: 'tc-row--selected',
  21 |   cell: 'tc-cell',
  22 |   cellSelected: 'tc-cell--selected',
  23 |   addRow: 'tc-add-row',
  24 |   addRowDisabled: 'tc-add-row--disabled',
  25 |   addColumn: 'tc-add-column',
  26 |   addColumnDisabled: 'tc-add-column--disabled',
  27 | };
  28 | 
  29 | /**
  30 |  * @typedef {object} TableConfig
  31 |  * @description Tool's config from Editor
  32 |  * @property {boolean} withHeadings — Uses the first line as headings
  33 |  * @property {string[][]} withHeadings — two-dimensional array with table contents
  34 |  */
  35 | 
  36 | /**
  37 |  * @typedef {object} TableData - object with the data transferred to form a table
  38 |  * @property {number} rows - number of rows in the table
  39 |  * @property {number} cols - number of columns in the table
  40 |  */
  41 | 
  42 | 
  43 | /**
  44 |  * Generates and manages table contents.
  45 |  */
  46 | export default class Table {
  47 |   /**
  48 |    * Creates
  49 |    *
  50 |    * @constructor
  51 |    * @param {boolean} readOnly - read-only mode flag
  52 |    * @param {object} api - Editor.js API
  53 |    * @param {TableData} data - Editor.js API
  54 |    * @param {TableConfig} config - Editor.js API
  55 |    */
  56 |   constructor(readOnly, api, data, config) {
  57 |     this.readOnly = readOnly;
  58 |     this.api = api;
  59 |     this.data = data;
  60 |     this.config = config;
  61 | 
  62 |     /**
  63 |      * DOM nodes
  64 |      */
  65 |     this.wrapper = null;
  66 |     this.table = null;
  67 | 
  68 |     /**
  69 |      * Toolbox for managing of columns
  70 |      */
  71 |     this.toolboxColumn = this.createColumnToolbox();
  72 |     this.toolboxRow = this.createRowToolbox();
  73 | 
  74 |     /**
  75 |      * Create table and wrapper elements
  76 |      */
  77 |     this.createTableWrapper();
  78 | 
  79 |     // Current hovered row index
  80 |     this.hoveredRow = 0;
  81 | 
  82 |     // Current hovered column index
  83 |     this.hoveredColumn = 0;
  84 | 
  85 |     // Index of last selected row via toolbox
  86 |     this.selectedRow = 0;
  87 | 
  88 |     // Index of last selected column via toolbox
  89 |     this.selectedColumn = 0;
  90 | 
  91 |     // Additional settings for the table
  92 |     this.tunes = {
  93 |       withHeadings: false
  94 |     };
  95 | 
  96 |     /**
  97 |      * Resize table to match config/data size
  98 |      */
  99 |     this.resize();
 100 | 
 101 |     /**
 102 |      * Fill the table with data
 103 |      */
 104 |     this.fill();
 105 | 
 106 |     /**
 107 |      * The cell in which the focus is currently located, if 0 and 0 then there is no focus
 108 |      * Uses to switch between cells with buttons
 109 |      */
 110 |     this.focusedCell = {
 111 |       row: 0,
 112 |       column: 0
 113 |     };
 114 | 
 115 |     /**
 116 |      * Global click listener allows to delegate clicks on some elements
 117 |      */
 118 |     this.documentClicked = (event) => {
 119 |       const clickedInsideTable = event.target.closest(`.${CSS.table}`) !== null;
 120 |       const outsideTableClicked = event.target.closest(`.${CSS.wrapper}`) === null;
 121 |       const clickedOutsideToolboxes = clickedInsideTable || outsideTableClicked;
 122 | 
 123 |       if (clickedOutsideToolboxes) {
 124 |         this.hideToolboxes();
 125 |       }
 126 | 
 127 |       const clickedOnAddRowButton = event.target.closest(`.${CSS.addRow}`);
 128 |       const clickedOnAddColumnButton = event.target.closest(`.${CSS.addColumn}`);
 129 | 
 130 |       /**
 131 |        * Also, check if clicked in current table, not other (because documentClicked bound to the whole document)
 132 |        */
 133 |       if (clickedOnAddRowButton && clickedOnAddRowButton.parentNode === this.wrapper) {
 134 |         this.addRow(undefined, true);
 135 |         this.hideToolboxes();
 136 |       } else if (clickedOnAddColumnButton && clickedOnAddColumnButton.parentNode === this.wrapper) {
 137 |         this.addColumn(undefined, true);
 138 |         this.hideToolboxes();
 139 |       }
 140 |     };
 141 | 
 142 |     if (!this.readOnly) {
 143 |       this.bindEvents();
 144 |     }
 145 |   }
 146 | 
 147 |   /**
 148 |    * Returns the rendered table wrapper
 149 |    *
 150 |    * @returns {Element}
 151 |    */
 152 |   getWrapper() {
 153 |     return this.wrapper;
 154 |   }
 155 | 
 156 |   /**
 157 |    * Hangs the necessary handlers to events
 158 |    */
 159 |   bindEvents() {
 160 |     // set the listener to close toolboxes when click outside
 161 |     document.addEventListener('click', this.documentClicked);
 162 | 
 163 |     // Update toolboxes position depending on the mouse movements
 164 |     this.table.addEventListener('mousemove', throttled(150, (event) => this.onMouseMoveInTable(event)), { passive: true });
 165 | 
 166 |     // Controls some of the keyboard buttons inside the table
 167 |     this.table.onkeypress = (event) => this.onKeyPressListener(event);
 168 | 
 169 |     // Tab is executed by default before keypress, so it must be intercepted on keydown
 170 |     this.table.addEventListener('keydown', (event) => this.onKeyDownListener(event));
 171 | 
 172 |     // Determine the position of the cell in focus
 173 |     this.table.addEventListener('focusin', event => this.focusInTableListener(event));
 174 |   }
 175 | 
 176 |   /**
 177 |    * Configures and creates the toolbox for manipulating with columns
 178 |    *
 179 |    * @returns {Toolbox}
 180 |    */
 181 |   createColumnToolbox() {
 182 |     return new Toolbox({
 183 |       api: this.api,
 184 |       cssModifier: 'column',
 185 |       items: [
 186 |         {
 187 |           label: this.api.i18n.t('Add column to left'),
 188 |           icon: IconDirectionLeftDown,
 189 |           hideIf: () => {
 190 |             return this.numberOfColumns === this.config.maxcols
 191 |           },
 192 |           onClick: () => {
 193 |             this.addColumn(this.selectedColumn, true);
 194 |             this.hideToolboxes();
 195 |           }
 196 |         },
 197 |         {
 198 |           label: this.api.i18n.t('Add column to right'),
 199 |           icon: IconDirectionRightDown,
 200 |           hideIf: () => {
 201 |             return this.numberOfColumns === this.config.maxcols
 202 |           },
 203 |           onClick: () => {
 204 |             this.addColumn(this.selectedColumn + 1, true);
 205 |             this.hideToolboxes();
 206 |           }
 207 |         },
 208 |         {
 209 |           label: this.api.i18n.t('Delete column'),
 210 |           icon: IconCross,
 211 |           hideIf: () => {
 212 |             return this.numberOfColumns === 1;
 213 |           },
 214 |           confirmationRequired: true,
 215 |           onClick: () => {
 216 |             this.deleteColumn(this.selectedColumn);
 217 |             this.hideToolboxes();
 218 |           }
 219 |         }
 220 |       ],
 221 |       onOpen: () => {
 222 |         this.selectColumn(this.hoveredColumn);
 223 |         this.hideRowToolbox();
 224 |       },
 225 |       onClose: () => {
 226 |         this.unselectColumn();
 227 |       }
 228 |     });
 229 |   }
 230 | 
 231 |   /**
 232 |    * Configures and creates the toolbox for manipulating with rows
 233 |    *
 234 |    * @returns {Toolbox}
 235 |    */
 236 |   createRowToolbox() {
 237 |     return new Toolbox({
 238 |       api: this.api,
 239 |       cssModifier: 'row',
 240 |       items: [
 241 |         {
 242 |           label: this.api.i18n.t('Add row above'),
 243 |           icon: IconDirectionUpRight,
 244 |           hideIf: () => {
 245 |             return this.numberOfRows === this.config.maxrows
 246 |           },
 247 |           onClick: () => {
 248 |             this.addRow(this.selectedRow, true);
 249 |             this.hideToolboxes();
 250 |           }
 251 |         },
 252 |         {
 253 |           label: this.api.i18n.t('Add row below'),
 254 |           icon: IconDirectionDownRight,
 255 |           hideIf: () => {
 256 |             return this.numberOfRows === this.config.maxrows
 257 |           },
 258 |           onClick: () => {
 259 |             this.addRow(this.selectedRow + 1, true);
 260 |             this.hideToolboxes();
 261 |           }
 262 |         },
 263 |         {
 264 |           label: this.api.i18n.t('Delete row'),
 265 |           icon: IconCross,
 266 |           hideIf: () => {
 267 |             return this.numberOfRows === 1;
 268 |           },
 269 |           confirmationRequired: true,
 270 |           onClick: () => {
 271 |             this.deleteRow(this.selectedRow);
 272 |             this.hideToolboxes();
 273 |           }
 274 |         }
 275 |       ],
 276 |       onOpen: () => {
 277 |         this.selectRow(this.hoveredRow);
 278 |         this.hideColumnToolbox();
 279 |       },
 280 |       onClose: () => {
 281 |         this.unselectRow();
 282 |       }
 283 |     });
 284 |   }
 285 | 
 286 |   /**
 287 |    * When you press enter it moves the cursor down to the next row
 288 |    * or creates it if the click occurred on the last one
 289 |    */
 290 |   moveCursorToNextRow() {
 291 |     if (this.focusedCell.row !== this.numberOfRows) {
 292 |       this.focusedCell.row += 1;
 293 |       this.focusCell(this.focusedCell);
 294 |     } else {
 295 |       this.addRow();
 296 |       this.focusedCell.row += 1;
 297 |       this.focusCell(this.focusedCell);
 298 |       this.updateToolboxesPosition(0, 0);
 299 |     }
 300 |   }
 301 | 
 302 |   /**
 303 |    * Get table cell by row and col index
 304 |    *
 305 |    * @param {number} row - cell row coordinate
 306 |    * @param {number} column - cell column coordinate
 307 |    * @returns {HTMLElement}
 308 |    */
 309 |   getCell(row, column) {
 310 |     return this.table.querySelectorAll(`.${CSS.row}:nth-child(${row}) .${CSS.cell}`)[column - 1];
 311 |   }
 312 | 
 313 |   /**
 314 |    * Get table row by index
 315 |    *
 316 |    * @param {number} row - row coordinate
 317 |    * @returns {HTMLElement}
 318 |    */
 319 |   getRow(row) {
 320 |     return this.table.querySelector(`.${CSS.row}:nth-child(${row})`);
 321 |   }
 322 | 
 323 |   /**
 324 |    * The parent of the cell which is the row
 325 |    *
 326 |    * @param {HTMLElement} cell - cell element
 327 |    * @returns {HTMLElement}
 328 |    */
 329 |   getRowByCell(cell) {
 330 |     return cell.parentElement;
 331 |   }
 332 | 
 333 |   /**
 334 |    * Ger row's first cell
 335 |    *
 336 |    * @param {Element} row - row to find its first cell
 337 |    * @returns {Element}
 338 |    */
 339 |   getRowFirstCell(row) {
 340 |     return row.querySelector(`.${CSS.cell}:first-child`);
 341 |   }
 342 | 
 343 |   /**
 344 |    * Set the sell's content by row and column numbers
 345 |    *
 346 |    * @param {number} row - cell row coordinate
 347 |    * @param {number} column - cell column coordinate
 348 |    * @param {string} content - cell HTML content
 349 |    */
 350 |   setCellContent(row, column, content) {
 351 |     const cell = this.getCell(row, column);
 352 | 
 353 |     cell.innerHTML = content;
 354 |   }
 355 | 
 356 |   /**
 357 |    * Add column in table on index place
 358 |    * Add cells in each row
 359 |    *
 360 |    * @param {number} columnIndex - number in the array of columns, where new column to insert, -1 if insert at the end
 361 |    * @param {boolean} [setFocus] - pass true to focus the first cell
 362 |    */
 363 |   addColumn(columnIndex = -1, setFocus = false) {
 364 |     let numberOfColumns = this.numberOfColumns;
 365 |      /**
 366 |       * Check if the number of columns has reached the maximum allowed columns specified in the configuration,
 367 |       * and if so, exit the function to prevent adding more columns beyond the limit.
 368 |       */
 369 |     if (this.config && this.config.maxcols && this.numberOfColumns >= this.config.maxcols) {
 370 |       return;
 371 |   }
 372 | 
 373 |     /**
 374 |      * Iterate all rows and add a new cell to them for creating a column
 375 |      */
 376 |     for (let rowIndex = 1; rowIndex <= this.numberOfRows; rowIndex++) {
 377 |       let cell;
 378 |       const cellElem = this.createCell();
 379 | 
 380 |       if (columnIndex > 0 && columnIndex <= numberOfColumns) {
 381 |         cell = this.getCell(rowIndex, columnIndex);
 382 | 
 383 |         $.insertBefore(cellElem, cell);
 384 |       } else {
 385 |         cell = this.getRow(rowIndex).appendChild(cellElem);
 386 |       }
 387 | 
 388 |       /**
 389 |        * Autofocus first cell
 390 |        */
 391 |       if (rowIndex === 1) {
 392 |         const firstCell = this.getCell(rowIndex, columnIndex > 0 ? columnIndex : numberOfColumns + 1);
 393 | 
 394 |         if (firstCell && setFocus) {
 395 |           $.focus(firstCell);
 396 |         }
 397 |       }
 398 |     }
 399 | 
 400 |     const addColButton = this.wrapper.querySelector(`.${CSS.addColumn}`);
 401 |     if (this.config?.maxcols && this.numberOfColumns > this.config.maxcols - 1 && addColButton ){
 402 |       addColButton.classList.add(CSS.addColumnDisabled);
 403 |     }
 404 |     this.addHeadingAttrToFirstRow();
 405 |   };
 406 | 
 407 |   /**
 408 |    * Add row in table on index place
 409 |    *
 410 |    * @param {number} index - number in the array of rows, where new column to insert, -1 if insert at the end
 411 |    * @param {boolean} [setFocus] - pass true to focus the inserted row
 412 |    * @returns {HTMLElement} row
 413 |    */
 414 |   addRow(index = -1, setFocus = false) {
 415 |     let insertedRow;
 416 |     let rowElem = $.make('div', CSS.row);
 417 | 
 418 |     if (this.tunes.withHeadings) {
 419 |       this.removeHeadingAttrFromFirstRow();
 420 |     }
 421 | 
 422 |     /**
 423 |      * We remember the number of columns, because it is calculated
 424 |      * by the number of cells in the first row
 425 |      * It is necessary that the first line is filled in correctly
 426 |      */
 427 |     let numberOfColumns = this.numberOfColumns;
 428 |      /**
 429 |       * Check if the number of rows has reached the maximum allowed rows specified in the configuration,
 430 |       * and if so, exit the function to prevent adding more columns beyond the limit.
 431 |       */  
 432 |     if (this.config && this.config.maxrows && this.numberOfRows >= this.config.maxrows && addRowButton) {
 433 |       return;
 434 |     }
 435 | 
 436 |     if (index > 0 && index <= this.numberOfRows) {
 437 |       let row = this.getRow(index);
 438 | 
 439 |       insertedRow = $.insertBefore(rowElem, row);
 440 |     } else {
 441 |       insertedRow = this.table.appendChild(rowElem);
 442 |     }
 443 | 
 444 |     this.fillRow(insertedRow, numberOfColumns);
 445 | 
 446 |     if (this.tunes.withHeadings) {
 447 |       this.addHeadingAttrToFirstRow();
 448 |     }
 449 | 
 450 |     const insertedRowFirstCell = this.getRowFirstCell(insertedRow);
 451 | 
 452 |     if (insertedRowFirstCell && setFocus) {
 453 |       $.focus(insertedRowFirstCell);
 454 |     }
 455 | 
 456 |     const addRowButton = this.wrapper.querySelector(`.${CSS.addRow}`);
 457 |     if (this.config && this.config.maxrows && this.numberOfRows >= this.config.maxrows && addRowButton) {
 458 |       addRowButton.classList.add(CSS.addRowDisabled);
 459 |     }
 460 |     return insertedRow;
 461 |   };
 462 | 
 463 |   /**
 464 |    * Delete a column by index
 465 |    *
 466 |    * @param {number} index
 467 |    */
 468 |   deleteColumn(index) {
 469 |     for (let i = 1; i <= this.numberOfRows; i++) {
 470 |       const cell = this.getCell(i, index);
 471 | 
 472 |       if (!cell) {
 473 |         return;
 474 |       }
 475 | 
 476 |       cell.remove();
 477 |     }
 478 |     const addColButton = this.wrapper.querySelector(`.${CSS.addColumn}`);
 479 |     if (addColButton) {
 480 |       addColButton.classList.remove(CSS.addColumnDisabled);
 481 |     }
 482 |   }
 483 | 
 484 |   /**
 485 |    * Delete a row by index
 486 |    *
 487 |    * @param {number} index
 488 |    */
 489 |   deleteRow(index) {
 490 |     this.getRow(index).remove();
 491 |     const addRowButton = this.wrapper.querySelector(`.${CSS.addRow}`);
 492 |     if (addRowButton) {
 493 |       addRowButton.classList.remove(CSS.addRowDisabled);
 494 |     }
 495 | 
 496 |     this.addHeadingAttrToFirstRow();
 497 |   }
 498 | 
 499 |   /**
 500 |    * Create a wrapper containing a table, toolboxes
 501 |    * and buttons for adding rows and columns
 502 |    *
 503 |    * @returns {HTMLElement} wrapper - where all buttons for a table and the table itself will be
 504 |    */
 505 |   createTableWrapper() {
 506 |     this.wrapper = $.make('div', CSS.wrapper);
 507 |     this.table = $.make('div', CSS.table);
 508 | 
 509 |     if (this.readOnly) {
 510 |       this.wrapper.classList.add(CSS.wrapperReadOnly);
 511 |     }
 512 | 
 513 |     this.wrapper.appendChild(this.toolboxRow.element);
 514 |     this.wrapper.appendChild(this.toolboxColumn.element);
 515 |     this.wrapper.appendChild(this.table);
 516 | 
 517 |     if (!this.readOnly) {
 518 |       const addColumnButton = $.make('div', CSS.addColumn, {
 519 |         innerHTML: IconPlus
 520 |       });
 521 |       const addRowButton = $.make('div', CSS.addRow, {
 522 |         innerHTML: IconPlus
 523 |       });
 524 | 
 525 |       this.wrapper.appendChild(addColumnButton);
 526 |       this.wrapper.appendChild(addRowButton);
 527 |     }
 528 |   }
 529 | 
 530 |   /**
 531 |    * Returns the size of the table based on initial data or config "size" property
 532 |    *
 533 |    * @return {{rows: number, cols: number}} - number of cols and rows
 534 |    */
 535 |   computeInitialSize() {
 536 |     const content = this.data && this.data.content;
 537 |     const isValidArray = Array.isArray(content);
 538 |     const isNotEmptyArray = isValidArray ? content.length : false;
 539 |     const contentRows = isValidArray ? content.length : undefined;
 540 |     const contentCols = isNotEmptyArray ? content[0].length : undefined;
 541 |     const parsedRows = Number.parseInt(this.config && this.config.rows);
 542 |     const parsedCols = Number.parseInt(this.config && this.config.cols);
 543 | 
 544 |     /**
 545 |      * Value of config have to be positive number
 546 |      */
 547 |     const configRows = !isNaN(parsedRows) && parsedRows > 0 ? parsedRows : undefined;
 548 |     const configCols = !isNaN(parsedCols) && parsedCols > 0 ? parsedCols : undefined;
 549 |     const defaultRows = 2;
 550 |     const defaultCols = 2;
 551 |     const rows = contentRows || configRows || defaultRows;
 552 |     const cols = contentCols || configCols || defaultCols;
 553 | 
 554 |     return {
 555 |       rows: rows,
 556 |       cols: cols
 557 |     };
 558 |   }
 559 | 
 560 |   /**
 561 |    * Resize table to match config size or transmitted data size
 562 |    *
 563 |    * @return {{rows: number, cols: number}} - number of cols and rows
 564 |    */
 565 |   resize() {
 566 |     const { rows, cols } = this.computeInitialSize();
 567 | 
 568 |     for (let i = 0; i < rows; i++) {
 569 |       this.addRow();
 570 |     }
 571 | 
 572 |     for (let i = 0; i < cols; i++) {
 573 |       this.addColumn();
 574 |     }
 575 |   }
 576 | 
 577 |   /**
 578 |    * Fills the table with data passed to the constructor
 579 |    *
 580 |    * @returns {void}
 581 |    */
 582 |   fill() {
 583 |     const data = this.data;
 584 | 
 585 |     if (data && data.content) {
 586 |       for (let i = 0; i < data.content.length; i++) {
 587 |         for (let j = 0; j < data.content[i].length; j++) {
 588 |           this.setCellContent(i + 1, j + 1, data.content[i][j]);
 589 |         }
 590 |       }
 591 |     }
 592 |   }
 593 | 
 594 |   /**
 595 |    * Fills a row with cells
 596 |    *
 597 |    * @param {HTMLElement} row - row to fill
 598 |    * @param {number} numberOfColumns - how many cells should be in a row
 599 |    */
 600 |   fillRow(row, numberOfColumns) {
 601 |     for (let i = 1; i <= numberOfColumns; i++) {
 602 |       const newCell = this.createCell();
 603 | 
 604 |       row.appendChild(newCell);
 605 |     }
 606 |   }
 607 | 
 608 |   /**
 609 |    * Creating a cell element
 610 |    *
 611 |    * @return {Element}
 612 |    */
 613 |   createCell() {
 614 |     return $.make('div', CSS.cell, {
 615 |       contentEditable: !this.readOnly
 616 |     });
 617 |   }
 618 | 
 619 |   /**
 620 |    * Get number of rows in the table
 621 |    */
 622 |   get numberOfRows() {
 623 |     return this.table.childElementCount;
 624 |   }
 625 | 
 626 |   /**
 627 |    * Get number of columns in the table
 628 |    */
 629 |   get numberOfColumns() {
 630 |     if (this.numberOfRows) {
 631 |       return this.table.querySelectorAll(`.${CSS.row}:first-child .${CSS.cell}`).length;
 632 |     }
 633 | 
 634 |     return 0;
 635 |   }
 636 | 
 637 |   /**
 638 |    * Is the column toolbox menu displayed or not
 639 |    *
 640 |    * @returns {boolean}
 641 |    */
 642 |   get isColumnMenuShowing() {
 643 |     return this.selectedColumn !== 0;
 644 |   }
 645 | 
 646 |   /**
 647 |    * Is the row toolbox menu displayed or not
 648 |    *
 649 |    * @returns {boolean}
 650 |    */
 651 |   get isRowMenuShowing() {
 652 |     return this.selectedRow !== 0;
 653 |   }
 654 | 
 655 |   /**
 656 |    * Recalculate position of toolbox icons
 657 |    *
 658 |    * @param {Event} event - mouse move event
 659 |    */
 660 |   onMouseMoveInTable(event) {
 661 |     const { row, column } = this.getHoveredCell(event);
 662 | 
 663 |     this.hoveredColumn = column;
 664 |     this.hoveredRow = row;
 665 | 
 666 |     this.updateToolboxesPosition();
 667 |   }
 668 | 
 669 |   /**
 670 |    * Prevents default Enter behaviors
 671 |    * Adds Shift+Enter processing
 672 |    *
 673 |    * @param {KeyboardEvent} event - keypress event
 674 |    */
 675 |   onKeyPressListener(event) {
 676 |     if (event.key === 'Enter') {
 677 |       if (event.shiftKey) {
 678 |         return true;
 679 |       }
 680 | 
 681 |       this.moveCursorToNextRow();
 682 |     }
 683 | 
 684 |     return event.key !== 'Enter';
 685 |   };
 686 | 
 687 |   /**
 688 |    * Prevents tab keydown event from bubbling
 689 |    * so that it only works inside the table
 690 |    *
 691 |    * @param {KeyboardEvent} event - keydown event
 692 |    */
 693 |   onKeyDownListener(event) {
 694 |     if (event.key === 'Tab') {
 695 |       event.stopPropagation();
 696 |     }
 697 |   }
 698 | 
 699 |   /**
 700 |    * Set the coordinates of the cell that the focus has moved to
 701 |    *
 702 |    * @param {FocusEvent} event - focusin event
 703 |    */
 704 |   focusInTableListener(event) {
 705 |     const cell = event.target;
 706 |     const row = this.getRowByCell(cell);
 707 | 
 708 |     this.focusedCell = {
 709 |       row: Array.from(this.table.querySelectorAll(`.${CSS.row}`)).indexOf(row) + 1,
 710 |       column: Array.from(row.querySelectorAll(`.${CSS.cell}`)).indexOf(cell) + 1
 711 |     };
 712 |   }
 713 | 
 714 |   /**
 715 |    * Unselect row/column
 716 |    * Close toolbox menu
 717 |    * Hide toolboxes
 718 |    *
 719 |    * @returns {void}
 720 |    */
 721 |   hideToolboxes() {
 722 |     this.hideRowToolbox();
 723 |     this.hideColumnToolbox();
 724 |     this.updateToolboxesPosition();
 725 |   }
 726 | 
 727 |   /**
 728 |    * Unselect row, close toolbox
 729 |    *
 730 |    * @returns {void}
 731 |    */
 732 |   hideRowToolbox() {
 733 |     this.unselectRow();
 734 |     this.toolboxRow.hide();
 735 |   }
 736 |   /**
 737 |    * Unselect column, close toolbox
 738 |    *
 739 |    * @returns {void}
 740 |    */
 741 |   hideColumnToolbox() {
 742 |     this.unselectColumn();
 743 | 
 744 |     this.toolboxColumn.hide();
 745 |   }
 746 | 
 747 |   /**
 748 |    * Set the cursor focus to the focused cell
 749 |    *
 750 |    * @returns {void}
 751 |    */
 752 |   focusCell() {
 753 |     this.focusedCellElem.focus();
 754 |   }
 755 | 
 756 |   /**
 757 |    * Get current focused element
 758 |    *
 759 |    * @returns {HTMLElement} - focused cell
 760 |    */
 761 |   get focusedCellElem() {
 762 |     const { row, column } = this.focusedCell;
 763 | 
 764 |     return this.getCell(row, column);
 765 |   }
 766 | 
 767 |   /**
 768 |    * Update toolboxes position
 769 |    *
 770 |    * @param {number} row - hovered row
 771 |    * @param {number} column - hovered column
 772 |    */
 773 |   updateToolboxesPosition(row = this.hoveredRow, column = this.hoveredColumn) {
 774 |     if (!this.isColumnMenuShowing) {
 775 |       if (column > 0 && column <= this.numberOfColumns) { // not sure this statement is needed. Maybe it should be fixed in getHoveredCell()
 776 |         this.toolboxColumn.show(() => {
 777 |           return {
 778 |             left: `calc((100% - var(--cell-size)) / (${this.numberOfColumns} * 2) * (1 + (${column} - 1) * 2))`
 779 |           };
 780 |         });
 781 |       }
 782 |     }
 783 | 
 784 |     if (!this.isRowMenuShowing) {
 785 |       if (row > 0 && row <= this.numberOfRows) { // not sure this statement is needed. Maybe it should be fixed in getHoveredCell()
 786 |         this.toolboxRow.show(() => {
 787 |           const hoveredRowElement = this.getRow(row);
 788 |           const { fromTopBorder } = $.getRelativeCoordsOfTwoElems(this.table, hoveredRowElement);
 789 |           const { height } = hoveredRowElement.getBoundingClientRect();
 790 | 
 791 |           return {
 792 |             top: `${Math.ceil(fromTopBorder + height / 2)}px`
 793 |           };
 794 |         });
 795 |       }
 796 |     }
 797 |   }
 798 | 
 799 |   /**
 800 |    * Makes the first row headings
 801 |    *
 802 |    * @param {boolean} withHeadings - use headings row or not
 803 |    */
 804 |   setHeadingsSetting(withHeadings) {
 805 |     this.tunes.withHeadings = withHeadings;
 806 | 
 807 |     if (withHeadings) {
 808 |       this.table.classList.add(CSS.withHeadings);
 809 |       this.addHeadingAttrToFirstRow();
 810 |     } else {
 811 |       this.table.classList.remove(CSS.withHeadings);
 812 |       this.removeHeadingAttrFromFirstRow();
 813 |     }
 814 |   }
 815 | 
 816 |   /**
 817 |    * Adds an attribute for displaying the placeholder in the cell
 818 |    */
 819 |   addHeadingAttrToFirstRow() {
 820 |     for (let cellIndex = 1; cellIndex <= this.numberOfColumns; cellIndex++) {
 821 |       let cell = this.getCell(1, cellIndex);
 822 | 
 823 |       if (cell) {
 824 |         cell.setAttribute('heading', this.api.i18n.t('Heading'));
 825 |       }
 826 |     }
 827 |   }
 828 | 
 829 |   /**
 830 |    * Removes an attribute for displaying the placeholder in the cell
 831 |    */
 832 |   removeHeadingAttrFromFirstRow() {
 833 |     for (let cellIndex = 1; cellIndex <= this.numberOfColumns; cellIndex++) {
 834 |       let cell = this.getCell(1, cellIndex);
 835 | 
 836 |       if (cell) {
 837 |         cell.removeAttribute('heading');
 838 |       }
 839 |     }
 840 |   }
 841 | 
 842 |   /**
 843 |    * Add effect of a selected row
 844 |    *
 845 |    * @param {number} index
 846 |    */
 847 |   selectRow(index) {
 848 |     const row = this.getRow(index);
 849 | 
 850 |     if (row) {
 851 |       this.selectedRow = index;
 852 |       row.classList.add(CSS.rowSelected);
 853 |     }
 854 |   }
 855 | 
 856 |   /**
 857 |    * Remove effect of a selected row
 858 |    */
 859 |   unselectRow() {
 860 |     if (this.selectedRow <= 0) {
 861 |       return;
 862 |     }
 863 | 
 864 |     const row = this.table.querySelector(`.${CSS.rowSelected}`);
 865 | 
 866 |     if (row) {
 867 |       row.classList.remove(CSS.rowSelected);
 868 |     }
 869 | 
 870 |     this.selectedRow = 0;
 871 |   }
 872 | 
 873 |   /**
 874 |    * Add effect of a selected column
 875 |    *
 876 |    * @param {number} index
 877 |    */
 878 |   selectColumn(index) {
 879 |     for (let i = 1; i <= this.numberOfRows; i++) {
 880 |       const cell = this.getCell(i, index);
 881 | 
 882 |       if (cell) {
 883 |         cell.classList.add(CSS.cellSelected);
 884 |       }
 885 |     }
 886 | 
 887 |     this.selectedColumn = index;
 888 |   }
 889 | 
 890 |   /**
 891 |    * Remove effect of a selected column
 892 |    */
 893 |   unselectColumn() {
 894 |     if (this.selectedColumn <= 0) {
 895 |       return;
 896 |     }
 897 | 
 898 |     let cells = this.table.querySelectorAll(`.${CSS.cellSelected}`);
 899 | 
 900 |     Array.from(cells).forEach(column => {
 901 |       column.classList.remove(CSS.cellSelected);
 902 |     });
 903 | 
 904 |     this.selectedColumn = 0;
 905 |   }
 906 | 
 907 |   /**
 908 |    * Calculates the row and column that the cursor is currently hovering over
 909 |    * The search was optimized from O(n) to O (log n) via bin search to reduce the number of calculations
 910 |    *
 911 |    * @param {Event} event - mousemove event
 912 |    * @returns hovered cell coordinates as an integer row and column
 913 |    */
 914 |   getHoveredCell(event) {
 915 |     let hoveredRow = this.hoveredRow;
 916 |     let hoveredColumn = this.hoveredColumn;
 917 |     const { width, height, x, y } = $.getCursorPositionRelativeToElement(this.table, event);
 918 | 
 919 |     // Looking for hovered column
 920 |     if (x >= 0) {
 921 |       hoveredColumn = this.binSearch(
 922 |         this.numberOfColumns,
 923 |         (mid) => this.getCell(1, mid),
 924 |         ({ fromLeftBorder }) => x < fromLeftBorder,
 925 |         ({ fromRightBorder }) => x > (width - fromRightBorder)
 926 |       );
 927 |     }
 928 | 
 929 |     // Looking for hovered row
 930 |     if (y >= 0) {
 931 |       hoveredRow = this.binSearch(
 932 |         this.numberOfRows,
 933 |         (mid) => this.getCell(mid, 1),
 934 |         ({ fromTopBorder }) => y < fromTopBorder,
 935 |         ({ fromBottomBorder }) => y > (height - fromBottomBorder)
 936 |       );
 937 |     }
 938 | 
 939 |     return {
 940 |       row: hoveredRow || this.hoveredRow,
 941 |       column: hoveredColumn || this.hoveredColumn
 942 |     };
 943 |   }
 944 | 
 945 |   /**
 946 |    * Looks for the index of the cell the mouse is hovering over.
 947 |    * Cells can be represented as ordered intervals with left and
 948 |    * right (upper and lower for rows) borders inside the table, if the mouse enters it, then this is our index
 949 |    *
 950 |    * @param {number} numberOfCells - upper bound of binary search
 951 |    * @param {function} getCell - function to take the currently viewed cell
 952 |    * @param {function} beforeTheLeftBorder - determines the cursor position, to the left of the cell or not
 953 |    * @param {function} afterTheRightBorder - determines the cursor position, to the right of the cell or not
 954 |    * @returns {number}
 955 |    */
 956 |   binSearch(numberOfCells, getCell, beforeTheLeftBorder, afterTheRightBorder) {
 957 |     let leftBorder = 0;
 958 |     let rightBorder = numberOfCells + 1;
 959 |     let totalIterations = 0;
 960 |     let mid;
 961 | 
 962 |     while (leftBorder < rightBorder - 1 && totalIterations < 10) {
 963 |       mid = Math.ceil((leftBorder + rightBorder) / 2);
 964 | 
 965 |       const cell = getCell(mid);
 966 |       const relativeCoords = $.getRelativeCoordsOfTwoElems(this.table, cell);
 967 | 
 968 |       if (beforeTheLeftBorder(relativeCoords)) {
 969 |         rightBorder = mid;
 970 |       } else if (afterTheRightBorder(relativeCoords)) {
 971 |         leftBorder = mid;
 972 |       } else {
 973 |         break;
 974 |       }
 975 | 
 976 |       totalIterations++;
 977 |     }
 978 | 
 979 |     return mid;
 980 |   }
 981 | 
 982 |   /**
 983 |    * Collects data from cells into a two-dimensional array
 984 |    *
 985 |    * @returns {string[][]}
 986 |    */
 987 |   getData() {
 988 |     const data = [];
 989 | 
 990 |     for (let i = 1; i <= this.numberOfRows; i++) {
 991 |       const row = this.table.querySelector(`.${CSS.row}:nth-child(${i})`);
 992 |       const cells = Array.from(row.querySelectorAll(`.${CSS.cell}`));
 993 |       const isEmptyRow = cells.every(cell => !cell.textContent.trim());
 994 | 
 995 |       if (isEmptyRow) {
 996 |         continue;
 997 |       }
 998 | 
 999 |       data.push(cells.map(cell => cell.innerHTML));
1000 |     }
1001 | 
1002 |     return data;
1003 |   }
1004 | 
1005 |   /**
1006 |    * Remove listeners on the document
1007 |    */
1008 |   destroy() {
1009 |     document.removeEventListener('click', this.documentClicked);
1010 |   }
1011 | }


--------------------------------------------------------------------------------
/src/toolbox.js:
--------------------------------------------------------------------------------
  1 | import Popover from "./utils/popover";
  2 | import * as $ from "./utils/dom";
  3 | import { IconMenuSmall } from "@codexteam/icons";
  4 | 
  5 | /**
  6 |  * @typedef {object} PopoverItem
  7 |  * @property {string} label - button text
  8 |  * @property {string} icon - button icon
  9 |  * @property {boolean} confirmationRequired - if true, a confirmation state will be applied on the first click
 10 |  * @property {function} hideIf - if provided, item will be hid, if this method returns true
 11 |  * @property {function} onClick - click callback
 12 |  */
 13 | 
 14 | /**
 15 |  * Toolbox is a menu for manipulation of rows/cols
 16 |  *
 17 |  * It contains toggler and Popover:
 18 |  *   
 19 |  *     
 20 |  *     
 21 |  *   
 22 |  */
 23 | export default class Toolbox {
 24 |   /**
 25 |    * Creates toolbox buttons and toolbox menus
 26 |    *
 27 |    * @param {Object} config
 28 |    * @param {any} config.api - Editor.js api
 29 |    * @param {PopoverItem[]} config.items - Editor.js api
 30 |    * @param {function} config.onOpen - callback fired when the Popover is opening
 31 |    * @param {function} config.onClose - callback fired when the Popover is closing
 32 |    * @param {string} config.cssModifier - the modifier for the Toolbox. Allows to add some specific styles.
 33 |    */
 34 |   constructor({ api, items, onOpen, onClose, cssModifier = "" }) {
 35 |     this.api = api;
 36 | 
 37 |     this.items = items;
 38 |     this.onOpen = onOpen;
 39 |     this.onClose = onClose;
 40 |     this.cssModifier = cssModifier;
 41 | 
 42 |     this.popover = null;
 43 |     this.wrapper = this.createToolbox();
 44 |   }
 45 | 
 46 |   /**
 47 |    * Style classes
 48 |    */
 49 |   static get CSS() {
 50 |     return {
 51 |       toolbox: "tc-toolbox",
 52 |       toolboxShowed: "tc-toolbox--showed",
 53 |       toggler: "tc-toolbox__toggler",
 54 |     };
 55 |   }
 56 | 
 57 |   /**
 58 |    * Returns rendered Toolbox element
 59 |    */
 60 |   get element() {
 61 |     return this.wrapper;
 62 |   }
 63 | 
 64 |   /**
 65 |    * Creating a toolbox to open menu for a manipulating columns
 66 |    *
 67 |    * @returns {Element}
 68 |    */
 69 |   createToolbox() {
 70 |     const wrapper = $.make("div", [
 71 |       Toolbox.CSS.toolbox,
 72 |       this.cssModifier ? `${Toolbox.CSS.toolbox}--${this.cssModifier}` : "",
 73 |     ]);
 74 | 
 75 |     wrapper.dataset.mutationFree = "true";
 76 |     const popover = this.createPopover();
 77 |     const toggler = this.createToggler();
 78 | 
 79 |     wrapper.appendChild(toggler);
 80 |     wrapper.appendChild(popover);
 81 | 
 82 |     return wrapper;
 83 |   }
 84 | 
 85 |   /**
 86 |    * Creates the Toggler
 87 |    *
 88 |    * @returns {Element}
 89 |    */
 90 |   createToggler() {
 91 |     const toggler = $.make("div", Toolbox.CSS.toggler, {
 92 |       innerHTML: IconMenuSmall,
 93 |     });
 94 | 
 95 |     toggler.addEventListener("click", () => {
 96 |       this.togglerClicked();
 97 |     });
 98 | 
 99 |     return toggler;
100 |   }
101 | 
102 |   /**
103 |    * Creates the Popover instance and render it
104 |    *
105 |    * @returns {Element}
106 |    */
107 |   createPopover() {
108 |     this.popover = new Popover({
109 |       items: this.items,
110 |     });
111 | 
112 |     return this.popover.render();
113 |   }
114 | 
115 |   /**
116 |    * Toggler click handler. Opens/Closes the popover
117 |    *
118 |    * @returns {void}
119 |    */
120 |   togglerClicked() {
121 |     if (this.popover.opened) {
122 |       this.popover.close();
123 |       this.onClose();
124 |     } else {
125 |       this.popover.open();
126 |       this.onOpen();
127 |     }
128 |   }
129 | 
130 |   /**
131 |    * Shows the Toolbox
132 |    *
133 |    * @param {function} computePositionMethod - method that returns the position coordinate
134 |    * @returns {void}
135 |    */
136 |   show(computePositionMethod) {
137 |     const position = computePositionMethod();
138 | 
139 |     /**
140 |      * Set 'top' or 'left' style
141 |      */
142 |     Object.entries(position).forEach(([prop, value]) => {
143 |       this.wrapper.style[prop] = value;
144 |     });
145 | 
146 |     this.wrapper.classList.add(Toolbox.CSS.toolboxShowed);
147 |   }
148 | 
149 |   /**
150 |    * Hides the Toolbox
151 |    *
152 |    * @returns {void}
153 |    */
154 |   hide() {
155 |     this.popover.close();
156 |     this.wrapper.classList.remove(Toolbox.CSS.toolboxShowed);
157 |   }
158 | }
159 | 


--------------------------------------------------------------------------------
/src/utils/dom.js:
--------------------------------------------------------------------------------
  1 | /**
  2 |  * Helper for making Elements with attributes
  3 |  *
  4 |  * @param  {string} tagName           - new Element tag name
  5 |  * @param  {string|string[]} classNames  - list or name of CSS classname(s)
  6 |  * @param  {object} attributes        - any attributes
  7 |  * @returns {Element}
  8 |  */
  9 | export function make(
 10 |   tagName,
 11 |   classNames,
 12 |   attributes = {}
 13 | ) {
 14 |   const el = document.createElement(tagName);
 15 | 
 16 |   if (Array.isArray(classNames)) {
 17 |     el.classList.add(...classNames);
 18 |   } else if (classNames) {
 19 |     el.classList.add(classNames);
 20 |   }
 21 | 
 22 |   for (const attrName in attributes) {
 23 |     if (!Object.prototype.hasOwnProperty.call(attributes, attrName)) {
 24 |       continue;
 25 |     }
 26 | 
 27 |     el[attrName] = attributes[attrName];
 28 |   }
 29 | 
 30 |   return el;
 31 | }
 32 | 
 33 | /**
 34 |  * Get item position relative to document
 35 |  *
 36 |  * @param {HTMLElement} elem - item
 37 |  * @returns {{x1: number, y1: number, x2: number, y2: number}} coordinates of the upper left (x1,y1) and lower right(x2,y2) corners
 38 |  */
 39 | export function getCoords(elem) {
 40 |   const rect = elem.getBoundingClientRect();
 41 | 
 42 |   return {
 43 |     y1: Math.floor(rect.top + window.pageYOffset),
 44 |     x1: Math.floor(rect.left + window.pageXOffset),
 45 |     x2: Math.floor(rect.right + window.pageXOffset),
 46 |     y2: Math.floor(rect.bottom + window.pageYOffset)
 47 |   };
 48 | }
 49 | 
 50 | /**
 51 |  * Calculate paddings of the first element relative to the second
 52 |  *
 53 |  * @param {HTMLElement} firstElem - outer element, if the second element is inside it, then all padding will be positive
 54 |  * @param {HTMLElement} secondElem - inner element, if its borders go beyond the first, then the paddings will be considered negative
 55 |  * @returns {{fromTopBorder: number, fromLeftBorder: number, fromRightBorder: number, fromBottomBorder: number}}
 56 |  */
 57 | export function getRelativeCoordsOfTwoElems(firstElem, secondElem) {
 58 |   const firstCoords = getCoords(firstElem);
 59 |   const secondCoords = getCoords(secondElem);
 60 | 
 61 |   return {
 62 |     fromTopBorder: secondCoords.y1 - firstCoords.y1,
 63 |     fromLeftBorder: secondCoords.x1 - firstCoords.x1,
 64 |     fromRightBorder: firstCoords.x2 - secondCoords.x2,
 65 |     fromBottomBorder: firstCoords.y2 - secondCoords.y2
 66 |   };
 67 | }
 68 | 
 69 | /**
 70 |  * Get the width and height of an element and the position of the cursor relative to it
 71 |  *
 72 |  * @param {HTMLElement} elem - element relative to which the coordinates will be calculated
 73 |  * @param {Event} event - mouse event
 74 |  */
 75 | export function getCursorPositionRelativeToElement(elem, event) {
 76 |   const rect = elem.getBoundingClientRect();
 77 |   const { width, height, x, y } = rect;
 78 |   const { clientX, clientY } = event;
 79 | 
 80 |   return {
 81 |     width,
 82 |     height,
 83 |     x: clientX - x,
 84 |     y: clientY - y
 85 |   };
 86 | }
 87 | 
 88 | /**
 89 |  * Insert element after the referenced
 90 |  *
 91 |  * @param {HTMLElement} newNode
 92 |  * @param {HTMLElement} referenceNode
 93 |  * @returns {HTMLElement}
 94 |  */
 95 | export function insertAfter(newNode, referenceNode) {
 96 |   return referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
 97 | }
 98 | 
 99 | /**
100 |  * Insert element after the referenced
101 |  *
102 |  * @param {HTMLElement} newNode
103 |  * @param {HTMLElement} referenceNode
104 |  * @returns {HTMLElement}
105 |  */
106 | export function insertBefore(newNode, referenceNode) {
107 |   return referenceNode.parentNode.insertBefore(newNode, referenceNode);
108 | }
109 | 
110 | 
111 | /**
112 |  * Set focus to contenteditable or native input element
113 |  *
114 |  * @param {Element} element - element where to set focus
115 |  * @param {boolean} atStart - where to set focus: at the start or at the end
116 |  *
117 |  * @returns {void}
118 |  */
119 | export function focus(element, atStart = true) {
120 |   const range = document.createRange();
121 |   const selection = window.getSelection();
122 | 
123 |   range.selectNodeContents(element);
124 |   range.collapse(atStart);
125 | 
126 |   selection.removeAllRanges();
127 |   selection.addRange(range);
128 | }
129 | 


--------------------------------------------------------------------------------
/src/utils/popover.js:
--------------------------------------------------------------------------------
  1 | import * as $ from './dom';
  2 | 
  3 | /**
  4 |  * @typedef {object} PopoverItem
  5 |  * @property {string} label - button text
  6 |  * @property {string} icon - button icon
  7 |  * @property {boolean} confirmationRequired - if true, a confirmation state will be applied on the first click
  8 |  * @property {function} hideIf - if provided, item will be hid, if this method returns true
  9 |  * @property {function} onClick - click callback
 10 |  */
 11 | 
 12 | /**
 13 |  * This cass provides a popover rendering
 14 |  */
 15 | export default class Popover {
 16 |   /**
 17 |    * @param {object} options - constructor options
 18 |    * @param {PopoverItem[]} options.items - constructor options
 19 |    */
 20 |   constructor({items}) {
 21 |     this.items = items;
 22 |     this.wrapper = undefined;
 23 |     this.itemEls = [];
 24 |   }
 25 | 
 26 |   /**
 27 |    * Set of CSS classnames used in popover
 28 |    *
 29 |    * @returns {object}
 30 |    */
 31 |   static get CSS() {
 32 |     return {
 33 |       popover: 'tc-popover',
 34 |       popoverOpened: 'tc-popover--opened',
 35 |       item: 'tc-popover__item',
 36 |       itemHidden: 'tc-popover__item--hidden',
 37 |       itemConfirmState: 'tc-popover__item--confirm',
 38 |       itemIcon: 'tc-popover__item-icon',
 39 |       itemLabel: 'tc-popover__item-label'
 40 |     };
 41 |   }
 42 | 
 43 |   /**
 44 |    * Returns the popover element
 45 |    *
 46 |    * @returns {Element}
 47 |    */
 48 |   render() {
 49 |     this.wrapper = $.make('div', Popover.CSS.popover);
 50 | 
 51 |     this.items.forEach((item, index) => {
 52 |       const itemEl = $.make('div', Popover.CSS.item);
 53 |       const icon = $.make('div', Popover.CSS.itemIcon, {
 54 |         innerHTML: item.icon
 55 |       });
 56 |       const label = $.make('div', Popover.CSS.itemLabel, {
 57 |         textContent: item.label
 58 |       });
 59 | 
 60 |       itemEl.dataset.index = index;
 61 | 
 62 |       itemEl.appendChild(icon);
 63 |       itemEl.appendChild(label);
 64 | 
 65 |       this.wrapper.appendChild(itemEl);
 66 |       this.itemEls.push(itemEl);
 67 |     });
 68 | 
 69 |     /**
 70 |      * Delegate click
 71 |      */
 72 |     this.wrapper.addEventListener('click', (event) => {
 73 |       this.popoverClicked(event);
 74 |     });
 75 | 
 76 |     return this.wrapper;
 77 |   }
 78 | 
 79 |   /**
 80 |    * Popover wrapper click listener
 81 |    * Used to delegate clicks in items
 82 |    *
 83 |    * @returns {void}
 84 |    */
 85 |   popoverClicked(event) {
 86 |     const clickedItem = event.target.closest(`.${Popover.CSS.item}`);
 87 | 
 88 |     /**
 89 |      * Clicks outside or between item
 90 |      */
 91 |     if (!clickedItem) {
 92 |       return;
 93 |     }
 94 | 
 95 |     const clickedItemIndex = clickedItem.dataset.index;
 96 |     const item = this.items[clickedItemIndex];
 97 | 
 98 |     if (item.confirmationRequired && !this.hasConfirmationState(clickedItem)) {
 99 |       this.setConfirmationState(clickedItem);
100 | 
101 |       return;
102 |     }
103 | 
104 |     item.onClick();
105 |   }
106 | 
107 |   /**
108 |    * Enable the confirmation state on passed item
109 |    *
110 |    * @returns {void}
111 |    */
112 |   setConfirmationState(itemEl) {
113 |     itemEl.classList.add(Popover.CSS.itemConfirmState);
114 |   }
115 | 
116 |   /**
117 |    * Disable the confirmation state on passed item
118 |    *
119 |    * @returns {void}
120 |    */
121 |   clearConfirmationState(itemEl) {
122 |     itemEl.classList.remove(Popover.CSS.itemConfirmState);
123 |   }
124 | 
125 |   /**
126 |    * Check if passed item has the confirmation state
127 |    *
128 |    * @returns {boolean}
129 |    */
130 |   hasConfirmationState(itemEl) {
131 |     return itemEl.classList.contains(Popover.CSS.itemConfirmState);
132 |   }
133 | 
134 |   /**
135 |    * Return an opening state
136 |    *
137 |    * @returns {boolean}
138 |    */
139 |   get opened() {
140 |     return this.wrapper.classList.contains(Popover.CSS.popoverOpened);
141 |   }
142 | 
143 |   /**
144 |    * Opens the popover
145 |    *
146 |    * @returns {void}
147 |    */
148 |   open() {
149 |     /**
150 |      * If item provides 'hideIf()' method that returns true, hide item
151 |      */
152 |     this.items.forEach((item, index) => {
153 |       if (typeof item.hideIf === 'function') {
154 |         this.itemEls[index].classList.toggle(Popover.CSS.itemHidden, item.hideIf());
155 |       }
156 |     });
157 | 
158 |     this.wrapper.classList.add(Popover.CSS.popoverOpened);
159 |   }
160 | 
161 |   /**
162 |    * Closes the popover
163 |    *
164 |    * @returns {void}
165 |    */
166 |   close() {
167 |     this.wrapper.classList.remove(Popover.CSS.popoverOpened);
168 |     this.itemEls.forEach(el => {
169 |       this.clearConfirmationState(el);
170 |     });
171 |   }
172 | }
173 | 


--------------------------------------------------------------------------------
/src/utils/throttled.js:
--------------------------------------------------------------------------------
 1 | 
 2 | /**
 3 |  * Limits the frequency of calling a function
 4 |  *
 5 |  * @param {number} delay - delay between calls in milliseconds
 6 |  * @param {function} fn - function to be throttled
 7 |  */
 8 | export default function throttled(delay, fn) {
 9 |   let lastCall = 0;
10 | 
11 |   return function (...args) {
12 |     const now = new Date().getTime();
13 | 
14 |     if (now - lastCall < delay) {
15 |       return;
16 |     }
17 | 
18 |     lastCall = now;
19 | 
20 |     return fn(...args);
21 |   };
22 | }
23 | 


--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
 1 | {
 2 |   "include": ["src/**/*"],
 3 |   "compilerOptions": {
 4 |     "allowJs": true,
 5 |     "declaration": true,
 6 |     "emitDeclarationOnly": true,
 7 |     "outDir": "dist",
 8 |   }
 9 | }
10 | 


--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
 1 | import path from "path";
 2 | import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
 3 | import * as pkg from "./package.json";
 4 | import dts from 'vite-plugin-dts';
 5 | 
 6 | const NODE_ENV = process.argv.mode || "development";
 7 | const VERSION = pkg.version;
 8 | 
 9 | export default {
10 |   build: {
11 |     copyPublicDir: false,
12 |     lib: {
13 |       entry: path.resolve(__dirname, "src", "index.js"),
14 |       name: "Table",
15 |       fileName: "table",
16 |     },
17 |   },
18 |   define: {
19 |     NODE_ENV: JSON.stringify(NODE_ENV),
20 |     VERSION: JSON.stringify(VERSION),
21 |   },
22 |   server: {
23 |     open: true, 
24 |     watch: {
25 |       usePolling: true, 
26 |     },
27 |   },
28 |   plugins: [
29 |     cssInjectedByJsPlugin({ useStrictCSP: true }),
30 |     dts({ tsconfigPath: './tsconfig.json' })
31 |   ],
32 | };
33 | 


--------------------------------------------------------------------------------