├── .gitignore
├── travis.yml
├── tsconfig.json
├── webpack.config.ts
├── README.md
├── src
├── index.scss
└── index.ts
├── tslint.json
├── LICENSE
└── package.json
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | node_modules
--------------------------------------------------------------------------------
/travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | cache:
3 | directories:
4 | - "node_modules"
5 | node_js:
6 | - "10"
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "moduleResolution": "node",
4 | "target": "es5",
5 | "module":"commonjs",
6 | "lib": ["es2015", "es2016", "es2017", "dom"],
7 | "sourceMap": true,
8 | "declaration": true,
9 | "allowSyntheticDefaultImports": true,
10 | "experimentalDecorators": true,
11 | "emitDecoratorMetadata": true,
12 | "outDir": "dist",
13 | "typeRoots": [
14 | "node_modules/@types"
15 | ]
16 | },
17 | "include": [
18 | "src/**/*"
19 | ],
20 | "exclude": ["node_modules", "dist"]
21 | }
--------------------------------------------------------------------------------
/webpack.config.ts:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | entry: './src/index.ts',
3 | output: {
4 | path: __dirname + '/dist/umd',
5 | filename: 'index.js',
6 | libraryTarget: 'umd',
7 | library: 'quillTableUI'
8 | },
9 | module: {
10 | rules: [
11 | {
12 | test: /\.ts$/,
13 | loader: 'tslint-loader',
14 | exclude: /node_modules/,
15 | enforce: 'pre',
16 | options: {
17 | emitErrors: true,
18 | failOnHint: true
19 | }
20 | },
21 | {
22 | test: /\.ts$/,
23 | loader: 'ts-loader',
24 | exclude: /node_modules/,
25 | options: {
26 | compilerOptions: {
27 | module: 'es2015',
28 | declaration: false
29 | }
30 | }
31 | }
32 | ]
33 | },
34 | resolve: {
35 | extensions: ['.ts', '.js', '.scss']
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # quill-table-ui
2 | A module for table UI in Quill
3 |
4 | # Online Demo
5 | [quill-table-ui Codepen Demo](https://codepen.io/volser/pen/QWWpOpr)
6 |
7 | # Requirements
8 | [quilljs](https://github.com/quilljs/quill) v2.0.0-dev.3
9 |
10 | # Installation
11 | ```
12 | npm install quill-table-ui
13 | ```
14 |
15 | # Usage
16 | Load quill and style dependencies
17 | ```
18 |
19 |
20 | ```
21 | ```
22 |
23 |
24 | ```
25 |
26 | ES6
27 | ```
28 | import * as QuillTableUI from 'quill-table-ui'
29 |
30 | Quill.register({
31 | 'modules/tableUI': QuillTableUI.default
32 | }, true)
33 |
34 | window.onload = () => {
35 | const quill = new Quill('#editor-wrapper', {
36 | theme: 'snow',
37 | modules: {
38 | table: true,
39 | tableUI: true,
40 | }
41 | })
42 | }
43 | ```
44 |
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | .ql-table-toggle {
2 | display: flex;
3 | align-items: center;
4 | justify-content: center;
5 | position: absolute;
6 | background: #fff;
7 | border: 2px solid #e9ebf0;
8 | border-radius: 50%;
9 | margin: 3px 0 0 -22px;
10 | width: 18px;
11 | height: 18px;
12 | top: 0;
13 | left: 0;
14 | cursor: pointer;
15 | fill: #b9bec7;
16 |
17 | &_hidden {
18 | display: none;
19 | }
20 |
21 |
22 | &:hover {
23 | border-color: #b9bec7;
24 | }
25 | }
26 |
27 | .ql-table-menu {
28 | top: 0;
29 | left: 0;
30 | position: absolute;
31 | background: #fff;
32 | z-index: 2100;
33 | box-shadow: rgba(15, 15, 15, 0.05) 0 0 0 1px, rgba(15, 15, 15, 0.1) 0 3px 6px,
34 | rgba(15, 15, 15, 0.2) 0 9px 24px;
35 | border-radius: 4px;
36 | animation: fadeIn 0.05s ease-in forwards;
37 |
38 | &__item {
39 | display: flex;
40 | align-items: center;
41 | cursor: pointer;
42 | min-height: 32px;
43 | padding: 5px;
44 |
45 | &:hover {
46 | background-color: #fafbfc;
47 | }
48 |
49 | &-icon {
50 | margin-right: 5px;
51 | }
52 |
53 | &-text {
54 | font: 300 12px;
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "class-name": true,
4 | "curly": false,
5 | "eofline": false,
6 | "indent": "spaces",
7 | "max-line-length": [
8 | true,
9 | 140
10 | ],
11 | "member-ordering": [
12 | false,
13 | "public-before-private",
14 | "static-before-instance",
15 | "variables-before-functions"
16 | ],
17 | "no-arg": true,
18 | "no-construct": true,
19 | "no-duplicate-variable": true,
20 | "no-empty": true,
21 | "no-eval": true,
22 | "no-trailing-whitespace": true,
23 | "no-unused-expression": true,
24 | "one-line": [
25 | true,
26 | "check-open-brace",
27 | "check-catch",
28 | "check-else",
29 | "check-whitespace"
30 | ],
31 | "quotemark": [
32 | true,
33 | "single"
34 | ],
35 | "semicolon": [false],
36 | "triple-equals": [
37 | true,
38 | "allow-null-check"
39 | ],
40 | "variable-name": false,
41 | "whitespace": [
42 | true,
43 | "check-branch",
44 | "check-decl",
45 | "check-operator",
46 | "check-separator",
47 | "check-type"
48 | ],
49 | "typedef": [
50 | false,
51 | "call-signature",
52 | "parameter",
53 | "property-declaration",
54 | "variable-declaration",
55 | "member-variable-declaration"
56 | ]
57 | }
58 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2019, Sergiy Voloshyn
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "quill-table-ui",
3 | "version": "1.0.7",
4 | "description": "UI for Quill tables",
5 | "main": "dist/umd/index.js",
6 | "module": "dist/index.js",
7 | "types": "dist/index.d.ts",
8 | "engines": {
9 | "node": ">=6.0.0"
10 | },
11 | "scripts": {
12 | "build:umd": "webpack --config webpack.config.ts --mode=production",
13 | "build:esm": "tsc --module es2015",
14 | "build:dist": "npm run build:esm && npm run build:umd",
15 | "build:clean": "rm -rf dist",
16 | "build:styles": "node-sass src/index.scss --output dist/ --output-style compressed",
17 | "postversion": "npm run build:dist && npm run build:styles && git push && npm publish && npm run build:clean",
18 | "test": "echo \"Error: no test specified\" && exit 1"
19 | },
20 | "repository": {
21 | "type": "git",
22 | "url": "git+https://github.com/volser/quill-table-ui.git"
23 | },
24 | "keywords": [
25 | "quill",
26 | "table",
27 | "ui"
28 | ],
29 | "author": "Sergiy Voloshyn ",
30 | "license": "BSD-3-Clause",
31 | "bugs": {
32 | "url": "https://github.com/volser/quill-table-ui/issues"
33 | },
34 | "homepage": "https://github.com/volser/quill-table-ui#readme",
35 | "devDependencies": {
36 | "@types/node": "^12.7.2",
37 | "@types/webpack": "^4.32.2",
38 | "node-sass": "^4.12.0",
39 | "quill": "^2.0.0-dev.3",
40 | "ts-loader": "^6.0.4",
41 | "ts-node": "^8.3.0",
42 | "tslint": "^5.18.0",
43 | "tslint-loader": "^3.5.4",
44 | "typescript": "^3.6.3",
45 | "webpack": "^4.39.2",
46 | "webpack-cli": "^3.3.7"
47 | },
48 | "dependencies": {
49 | "positioning": "^2.0.0"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as Quill from 'quill';
2 | import { positionElements, Placement } from 'positioning';
3 |
4 | interface Range {
5 | index: number;
6 | length: number;
7 | }
8 |
9 | enum QuillEvents {
10 | EDITOR_CHANGE = 'editor-change',
11 | SCROLL_BEFORE_UPDATE = 'scroll-before-update',
12 | SCROLL_BLOT_MOUNT = 'scroll-blot-mount',
13 | SCROLL_BLOT_UNMOUNT = 'scroll-blot-unmount',
14 | SCROLL_OPTIMIZE = 'scroll-optimize',
15 | SCROLL_UPDATE = 'scroll-update',
16 | SELECTION_CHANGE = 'selection-change',
17 | TEXT_CHANGE = 'text-change',
18 | }
19 |
20 | enum QuillSources {
21 | API = 'api',
22 | SILENT = 'silent',
23 | USER = 'user',
24 | }
25 |
26 | const DEFAULT_PLACEMENT: Placement[] = [
27 | 'bottom-left',
28 | 'bottom-right',
29 | 'top-left',
30 | 'top-right',
31 | 'auto',
32 | ];
33 |
34 | const iconAddColRight =
35 | '';
36 | const iconAddColLeft =
37 | '';
38 | const iconAddRowAbove =
39 | '';
40 | const iconAddRowBelow =
41 | '';
42 | const iconRemoveCol =
43 | '';
44 | const iconRemoveRow =
45 | '';
46 | const iconRemoveTable =
47 | '';
48 |
49 | export interface MenuItem {
50 | title: string;
51 | icon: string;
52 | handler: () => void;
53 | }
54 |
55 | export interface TableUIOptions {
56 | maxRowCount?: number;
57 | }
58 |
59 | export default class TableUI {
60 | TOGGLE_TEMPLATE = ``;
61 | DEFAULTS: TableUIOptions = {
62 | maxRowCount: -1,
63 | };
64 |
65 | quill: Quill;
66 | options: any;
67 | toggle: HTMLElement;
68 | menu: HTMLElement;
69 | position: any;
70 | table: any;
71 |
72 | menuItems: MenuItem[] = [
73 | {
74 | title: 'Insert column right',
75 | icon: iconAddColRight,
76 | handler: () => {
77 | if (
78 | !(this.options.maxRowCount > 0) ||
79 | this.getColCount() < this.options.maxRowCount
80 | ) {
81 | this.table.insertColumnRight();
82 | }
83 | },
84 | },
85 | {
86 | title: 'Insert column left',
87 | icon: iconAddColLeft,
88 | handler: () => {
89 | if (
90 | !(this.options.maxRowCount > 0) ||
91 | this.getColCount() < this.options.maxRowCount
92 | ) {
93 | this.table.insertColumnLeft();
94 | }
95 | },
96 | },
97 | {
98 | title: 'Insert row above',
99 | icon: iconAddRowAbove,
100 | handler: () => {
101 | this.table.insertRowAbove();
102 | },
103 | },
104 | {
105 | title: 'Insert row below',
106 | icon: iconAddRowBelow,
107 | handler: () => {
108 | this.table.insertRowBelow();
109 | },
110 | },
111 | {
112 | title: 'Delete column',
113 | icon: iconRemoveCol,
114 | handler: () => {
115 | this.table.deleteColumn();
116 | },
117 | },
118 | {
119 | title: 'Delete row',
120 | icon: iconRemoveRow,
121 | handler: () => {
122 | this.table.deleteRow();
123 | },
124 | },
125 | {
126 | title: 'Delete table',
127 | icon: iconRemoveTable,
128 | handler: () => {
129 | this.table.deleteTable();
130 | },
131 | },
132 | ];
133 |
134 | constructor(quill: Quill, options: any) {
135 | this.quill = quill;
136 | this.options = { ...this.DEFAULTS, ...options };
137 | this.table = quill.getModule('table');
138 | if (!this.table) {
139 | console.error('"table" module not found');
140 | return;
141 | }
142 |
143 | this.toggle = quill.addContainer('ql-table-toggle');
144 | this.toggle.classList.add('ql-table-toggle_hidden');
145 | this.toggle.innerHTML = this.TOGGLE_TEMPLATE;
146 | this.toggle.addEventListener('click', this.toggleClickHandler);
147 | this.quill.on(QuillEvents.EDITOR_CHANGE, this.editorChangeHandler);
148 | this.quill.root.addEventListener('contextmenu', this.contextMenuHandler);
149 | }
150 |
151 | editorChangeHandler = (
152 | type: QuillEvents,
153 | range: Range,
154 | oldRange: Range,
155 | source: QuillSources
156 | ) => {
157 | if (type === QuillEvents.SELECTION_CHANGE) {
158 | this.detectButton(range);
159 | }
160 | };
161 |
162 | contextMenuHandler = (evt: MouseEvent) => {
163 | if (!this.isTable()) {
164 | return true;
165 | }
166 | evt.preventDefault();
167 | this.showMenu();
168 | };
169 |
170 | toggleClickHandler = (e) => {
171 | this.toggleMenu();
172 |
173 | e.preventDefault();
174 | e.stopPropagation();
175 | };
176 |
177 | docClickHandler = () => this.hideMenu;
178 |
179 | isTable(range?: Range) {
180 | if (!range) {
181 | range = this.quill.getSelection();
182 | }
183 | if (!range) {
184 | return false;
185 | }
186 | const formats = this.quill.getFormat(range.index);
187 |
188 | return !!(formats['table'] && !range.length);
189 | }
190 |
191 | getColCount(range: Range = null) {
192 | if (!range) {
193 | range = this.quill.getSelection();
194 | }
195 | if (!range) {
196 | return 0;
197 | }
198 | const [table] = this.table.getTable(range);
199 | if (!table) {
200 | return 0;
201 | }
202 | const maxColumns = table.rows().reduce((max, row) => {
203 | return Math.max(row.children.length, max);
204 | }, 0);
205 | return maxColumns;
206 | }
207 |
208 | showMenu() {
209 | this.hideMenu();
210 | this.menu = this.quill.addContainer('ql-table-menu');
211 |
212 | this.menuItems.forEach((it) => {
213 | this.menu.appendChild(this.createMenuItem(it));
214 | });
215 | positionElements(this.toggle, this.menu, DEFAULT_PLACEMENT, false);
216 | document.addEventListener('click', this.docClickHandler);
217 | }
218 |
219 | hideMenu() {
220 | if (this.menu) {
221 | this.menu.remove();
222 | this.menu = null;
223 | document.removeEventListener('click', this.docClickHandler);
224 | }
225 | }
226 |
227 | createMenuItem(item: MenuItem) {
228 | const node = document.createElement('div');
229 | node.classList.add('ql-table-menu__item');
230 |
231 | const iconSpan = document.createElement('span');
232 | iconSpan.classList.add('ql-table-menu__item-icon');
233 | iconSpan.innerHTML = item.icon;
234 |
235 | const textSpan = document.createElement('span');
236 | textSpan.classList.add('ql-table-menu__item-text');
237 | textSpan.innerText = item.title;
238 |
239 | node.appendChild(iconSpan);
240 | node.appendChild(textSpan);
241 | node.addEventListener(
242 | 'click',
243 | (e) => {
244 | e.preventDefault();
245 | e.stopPropagation();
246 | this.quill.focus();
247 | item.handler();
248 | this.hideMenu();
249 | this.detectButton(this.quill.getSelection());
250 | },
251 | false
252 | );
253 | return node;
254 | }
255 |
256 | detectButton(range: Range) {
257 | if (range == null) {
258 | return;
259 | }
260 |
261 | const show = this.isTable(range);
262 | if (show) {
263 | const [cell, offset] = this.quill.getLine(range.index);
264 | const containerBounds = this.quill.container.getBoundingClientRect();
265 | let bounds = cell.domNode.getBoundingClientRect();
266 | bounds = {
267 | bottom: bounds.bottom - containerBounds.top,
268 | height: bounds.height,
269 | left: bounds.left - containerBounds.left,
270 | right: bounds.right - containerBounds.left,
271 | top: bounds.top - containerBounds.top,
272 | width: bounds.width,
273 | };
274 |
275 | this.showToggle(bounds);
276 | } else {
277 | this.hideToggle();
278 | this.hideMenu();
279 | }
280 | }
281 |
282 | showToggle(position: any) {
283 | this.position = position;
284 | this.toggle.classList.remove('ql-table-toggle_hidden');
285 | this.toggle.style.top = `${position.top}px`;
286 | this.toggle.style.left = `${position.left}px`;
287 | }
288 |
289 | hideToggle() {
290 | this.toggle.classList.add('ql-table-toggle_hidden');
291 | }
292 |
293 | toggleMenu() {
294 | if (this.menu) {
295 | this.hideToggle();
296 | } else {
297 | this.showMenu();
298 | }
299 | }
300 |
301 | destroy() {
302 | this.hideMenu();
303 | this.quill.off(QuillEvents.EDITOR_CHANGE, this.editorChangeHandler);
304 | this.quill.root.removeEventListener('contextmenu', this.contextMenuHandler);
305 | this.toggle.removeEventListener('click', this.toggleClickHandler);
306 | this.toggle.remove();
307 | this.toggle = null;
308 | this.options = this.DEFAULTS;
309 | this.menu = null;
310 | this.table = null;
311 | this.quill = null;
312 | }
313 | }
314 |
--------------------------------------------------------------------------------