├── .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 | 
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 |
--------------------------------------------------------------------------------