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