├── .babelrc.json ├── .github └── workflows │ └── main.yml ├── .gitignore ├── CNAME ├── LICENSE.md ├── README.md ├── build.sh ├── dist ├── index-pretty.js ├── index.js └── index.js.gz ├── docs ├── index.html ├── logo.png └── logo.svg ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── _LooseArray.js ├── _cleanVal.js ├── _defaultCss.js ├── _isEmpty.js ├── _shift.js ├── _shift.test.js ├── demo │ ├── demo.css │ ├── demo.js │ ├── index.ejs │ ├── public │ │ ├── logo.png │ │ └── logo.svg │ ├── samples │ │ ├── simple.js │ │ └── with-checks.js │ └── screenshot.jpg ├── index.js └── sheetclip.js └── webpack.config.js /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | "@babel/plugin-proposal-class-properties" 14 | ] 15 | } -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | on: [push] 6 | 7 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 8 | jobs: 9 | # This workflow contains a single job called "build" 10 | test: 11 | # The type of runner that the job will run on 12 | runs-on: ubuntu-latest 13 | 14 | # Steps represent a sequence of tasks that will be executed as part of the job 15 | steps: 16 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 17 | - uses: actions/checkout@v2 18 | 19 | # Runs a single command using the runners shell 20 | - run: npm install 21 | - run: npm run test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | node_modules -------------------------------------------------------------------------------- /CNAME: -------------------------------------------------------------------------------- 1 | importabular.lecaro.me 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Renan LE CARO 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 | # importabular 2 | 3 | Lightweight spreadsheet editor for the web, to easily let your users import their data from excel. 4 | 5 | - Lightweight (under 5kb gzipped) 6 | - Mobile friendly 7 | - Copy / paste 8 | - MIT License 9 | 10 | 11 | # Quickstart 12 | 13 | The quick and dirty way : 14 | 15 | ``` 16 |
17 | 18 | 34 | ``` 35 | # Demo and doc 36 | 37 | The website will give you more details : https://importabular.lecaro.me/ 38 | 39 | NPM : https://www.npmjs.com/package/importabular 40 | 41 | ![Screenshot of the demo website](./src/demo/screenshot.jpg) 42 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | NODE_ENV=production webpack 4 | 5 | # Gzipp size check 6 | gzip -kf ./dist/index.js 7 | # Pretty copy 8 | cp ./dist/index.js ./dist/index-pretty.js 9 | prettier --write ./dist/index-pretty.js 10 | 11 | 12 | ls -l ./dist/ 13 | -------------------------------------------------------------------------------- /dist/index-pretty.js: -------------------------------------------------------------------------------- 1 | !(function (t, e) { 2 | "object" == typeof exports && "object" == typeof module 3 | ? (module.exports = e()) 4 | : "function" == typeof define && define.amd 5 | ? define([], e) 6 | : "object" == typeof exports 7 | ? (exports.Importabular = e()) 8 | : (t.Importabular = e()); 9 | })(self, function () { 10 | return (() => { 11 | "use strict"; 12 | var t = { 13 | 103: (t, e, i) => { 14 | i.d(e, { default: () => l }); 15 | class s { 16 | constructor() { 17 | var t, e; 18 | (e = {}), 19 | (t = "_data") in this 20 | ? Object.defineProperty(this, t, { 21 | value: e, 22 | enumerable: !0, 23 | configurable: !0, 24 | writable: !0, 25 | }) 26 | : (this[t] = e); 27 | } 28 | _setVal(t, e, i) { 29 | const s = this._data, 30 | n = (function (t) { 31 | return 0 === t ? "0" : t ? t.toString() : ""; 32 | })(i); 33 | var o; 34 | n 35 | ? (s[t] || (s[t] = {}), (s[t][e] = n)) 36 | : s[t] && 37 | s[t][e] && 38 | (delete s[t][e], 39 | (o = s[t]), 40 | 0 === Object.keys(o).length && delete s[t]); 41 | } 42 | _clear() { 43 | this._data = {}; 44 | } 45 | _getVal(t, e) { 46 | const i = this._data; 47 | return (i && i[t] && i[t][e]) || ""; 48 | } 49 | _toArr(t, e) { 50 | const i = []; 51 | for (let s = 0; s < e; s++) { 52 | i.push([]); 53 | for (let e = 0; e < t; e++) i[s].push(this._getVal(e, s)); 54 | } 55 | return i; 56 | } 57 | } 58 | function n(t, e, i, s, n, o, r) { 59 | if ((t += i) < s) { 60 | if (n === 1 / 0) return { x: s, y: e }; 61 | if (((t = n), --e < o)) { 62 | if (r === 1 / 0) return { x: s, y: o }; 63 | e = r; 64 | } 65 | } 66 | return ( 67 | t > n && ((t = s), ++e > r && ((e = o), (t = s))), { x: t, y: e } 68 | ); 69 | } 70 | function o(t) { 71 | return t.split('"').length - 1; 72 | } 73 | function r(t, e, i) { 74 | return ( 75 | e in t 76 | ? Object.defineProperty(t, e, { 77 | value: i, 78 | enumerable: !0, 79 | configurable: !0, 80 | writable: !0, 81 | }) 82 | : (t[e] = i), 83 | t 84 | ); 85 | } 86 | const h = [ 87 | "mousedown", 88 | "mouseenter", 89 | "mouseup", 90 | "mouseleave", 91 | "touchstart", 92 | "touchend", 93 | "touchmove", 94 | "keydown", 95 | "paste", 96 | "cut", 97 | "copy", 98 | ]; 99 | class l { 100 | constructor(t) { 101 | r(this, "_width", 1), 102 | r(this, "_height", 1), 103 | r(this, "_data", new s()), 104 | r(this, "paste", (t) => { 105 | if (this._editing) return; 106 | t.preventDefault(); 107 | const e = (function (t) { 108 | var e, 109 | i, 110 | s, 111 | n, 112 | r, 113 | h, 114 | l, 115 | a = [], 116 | c = 0; 117 | for ( 118 | (s = t.split("\n")).length > 1 && 119 | "" === s[s.length - 1] && 120 | s.pop(), 121 | e = 0, 122 | i = s.length; 123 | e < i; 124 | e += 1 125 | ) { 126 | for ( 127 | s[e] = s[e].split("\t"), n = 0, r = s[e].length; 128 | n < r; 129 | n += 1 130 | ) 131 | a[c] || (a[c] = []), 132 | h && 0 === n 133 | ? ((l = a[c].length - 1), 134 | (a[c][l] = a[c][l] + "\n" + s[e][0]), 135 | h && 136 | 1 & o(s[e][0]) && 137 | ((h = !1), 138 | (a[c][l] = a[c][l] 139 | .substring(0, a[c][l].length - 1) 140 | .replace(/""/g, '"')))) 141 | : n === r - 1 && 142 | 0 === s[e][n].indexOf('"') && 143 | 1 & o(s[e][n]) 144 | ? (a[c].push( 145 | s[e][n].substring(1).replace(/""/g, '"') 146 | ), 147 | (h = !0)) 148 | : (a[c].push(s[e][n].replace(/""/g, '"')), 149 | (h = !1)); 150 | h || (c += 1); 151 | } 152 | return a; 153 | })( 154 | (t.clipboardData || window.clipboardData).getData( 155 | "text/plain" 156 | ) 157 | ), 158 | { rx: i, ry: s } = this._selection, 159 | n = { x: i[0], y: s[0] }; 160 | for (let t = 0; t < e.length; t++) 161 | for (let i = 0; i < e[0].length; i++) 162 | this._setVal(n.x + i, n.y + t, e[t][i]); 163 | this._changeSelectedCellsStyle(() => { 164 | (this._selectionStart = n), 165 | (this._selectionEnd = { 166 | x: n.x + e[0].length - 1, 167 | y: n.y + e.length - 1, 168 | }), 169 | this._onDataChanged(); 170 | }); 171 | }), 172 | r(this, "copy", (t) => { 173 | if (this._editing) return; 174 | const e = this._getSelectionAsArray(); 175 | e && 176 | (t.preventDefault(), 177 | t.clipboardData.setData( 178 | "text/plain", 179 | (function (t) { 180 | var e, 181 | i, 182 | s, 183 | n, 184 | o, 185 | r = ""; 186 | for (e = 0, i = t.length; e < i; e += 1) { 187 | for (s = 0, n = t[e].length; s < n; s += 1) 188 | s > 0 && (r += "\t"), 189 | "string" == typeof (o = t[e][s]) 190 | ? o.indexOf("\n") > -1 191 | ? (r += '"' + o.replace(/"/g, '""') + '"') 192 | : (r += o) 193 | : (r += null == o ? "" : o); 194 | r += "\n"; 195 | } 196 | return r; 197 | })(e) 198 | )); 199 | }), 200 | r(this, "cut", (t) => { 201 | this._editing || 202 | (this.copy(t), this._setAllSelectedCellsTo("")); 203 | }), 204 | r(this, "keydown", (t) => { 205 | t.ctrlKey || 206 | t.metaKey || 207 | (this._selectionStart && 208 | ("Escape" === t.key && 209 | this._editing && 210 | (t.preventDefault(), 211 | this._revertEdit(), 212 | this._stopEditing()), 213 | "Enter" === t.key && 214 | (t.preventDefault(), 215 | this._tabCursorInSelection(!1, t.shiftKey ? -1 : 1)), 216 | "Tab" === t.key && 217 | (t.preventDefault(), 218 | this._tabCursorInSelection(!0, t.shiftKey ? -1 : 1)), 219 | this._editing || 220 | ("F2" === t.key && 221 | (t.preventDefault(), this._startEditing(this._focus)), 222 | ("Delete" !== t.key && "Backspace" !== t.key) || 223 | (t.preventDefault(), this._setAllSelectedCellsTo("")), 224 | "ArrowDown" === t.key && 225 | (t.preventDefault(), 226 | this._moveCursor({ y: 1 }, t.shiftKey)), 227 | "ArrowUp" === t.key && 228 | (t.preventDefault(), 229 | this._moveCursor({ y: -1 }, t.shiftKey)), 230 | "ArrowLeft" === t.key && 231 | (t.preventDefault(), 232 | this._moveCursor({ x: -1 }, t.shiftKey)), 233 | "ArrowRight" === t.key && 234 | (t.preventDefault(), 235 | this._moveCursor({ x: 1 }, t.shiftKey))), 236 | 1 !== t.key.length || 237 | this._editing || 238 | this._changeSelectedCellsStyle(() => { 239 | const { x: t, y: e } = this._focus; 240 | this._startEditing({ x: t, y: e }), 241 | (this._getCell(t, e).firstChild.value = ""); 242 | }))); 243 | }), 244 | r(this, "_selecting", !1), 245 | r(this, "_selectionStart", null), 246 | r(this, "_selectionEnd", null), 247 | r(this, "_selection", { rx: [0, 0], ry: [0, 0] }), 248 | r(this, "_editing", null), 249 | r(this, "_focus", null), 250 | r(this, "mousedown", (t) => { 251 | if (!this.mobile) { 252 | if ( 253 | 3 === t.which && 254 | !this._editing && 255 | this._selectionSize() 256 | ) { 257 | let t = new Range(); 258 | const { rx: e, ry: i } = this._selection; 259 | return ( 260 | t.setStart(this._getCell(e[0], i[0]), 0), 261 | t.setEnd(this._getCell(e[0], i[0]), 1), 262 | this.cwd.getSelection().removeAllRanges(), 263 | void this.cwd.getSelection().addRange(t) 264 | ); 265 | } 266 | this._changeSelectedCellsStyle(() => { 267 | (this.tbody.style.userSelect = "none"), 268 | (this._selectionEnd = this._selectionStart = this._focus = this._getCoords( 269 | t 270 | )), 271 | (this._selecting = !0); 272 | }); 273 | } 274 | }), 275 | r(this, "mouseenter", (t) => { 276 | this.mobile || 277 | (this._selecting && 278 | this._changeSelectedCellsStyle(() => { 279 | this._selectionEnd = this._getCoords(t); 280 | })); 281 | }), 282 | r(this, "_lastMouseUp", null), 283 | r(this, "_lastMouseUpTarget", null), 284 | r(this, "mouseup", (t) => { 285 | this.mobile || 286 | (3 !== t.which && 287 | this._selecting && 288 | this._changeSelectedCellsStyle(() => { 289 | (this._selectionEnd = this._getCoords(t)), 290 | this._endSelection(), 291 | this._lastMouseUp && 292 | this._lastMouseUp > Date.now() - 300 && 293 | this._lastMouseUpTarget.x === 294 | this._selectionEnd.x && 295 | this._lastMouseUpTarget.y === 296 | this._selectionEnd.y && 297 | this._startEditing(this._selectionEnd), 298 | (this._lastMouseUp = Date.now()), 299 | (this._lastMouseUpTarget = this._selectionEnd); 300 | })); 301 | }), 302 | r(this, "mouseleave", (t) => { 303 | t.target === this.tbody && 304 | this._selecting && 305 | this._endSelection(); 306 | }), 307 | r(this, "touchstart", (t) => { 308 | this._editing || ((this.mobile = !0), (this.moved = !1)); 309 | }), 310 | r(this, "touchend", (t) => { 311 | this.mobile && 312 | (this._editing || 313 | this.moved || 314 | (this._changeSelectedCellsStyle(() => { 315 | this._selectionEnd = this._selectionStart = this._focus = this._getCoords( 316 | t 317 | ); 318 | }), 319 | this._startEditing(this._focus))); 320 | }), 321 | r(this, "touchmove", (t) => { 322 | this.mobile && (this.moved = !0); 323 | }), 324 | r(this, "_stopEditing", () => { 325 | if (!this._editing) return; 326 | const { x: t, y: e } = this._editing, 327 | i = this._getCell(t, e); 328 | (i.style.width = ""), (i.style.height = ""); 329 | const s = i.firstChild; 330 | s.removeEventListener("blur", this._stopEditing), 331 | s.removeEventListener("keydown", this._blurIfEnter), 332 | this._setVal(t, e, s.value), 333 | this._onDataChanged(), 334 | i.removeChild(s), 335 | (this._editing = null), 336 | this._renderTDContent(i, t, e); 337 | }), 338 | r(this, "_blurIfEnter", (t) => { 339 | 13 === t.keyCode && (this._stopEditing(), t.preventDefault()); 340 | }), 341 | r(this, "_restyle", ({ x: t, y: e }) => { 342 | const i = this._getCell(t, e); 343 | i.className = this._classNames(t, e); 344 | const s = a(this.checkResults.titles, t, e); 345 | s ? i.setAttribute("title", s) : i.removeAttribute("title"); 346 | }), 347 | r(this, "_refreshDisplayedValue", ({ x: t, y: e }) => { 348 | const i = this._getCell(t, e).firstChild; 349 | "DIV" === i.tagName && 350 | (i.textContent = this._divContent(t, e)), 351 | this._restyle({ x: t, y: e }); 352 | }), 353 | this._saveConstructorOptions(t), 354 | this._setupDom(), 355 | this._replaceDataWithArray(t.data), 356 | this._incrementToFit({ 357 | x: this.columns.length - 1, 358 | y: this._options.minRows - 1, 359 | }), 360 | this._fillScrollSpace(); 361 | } 362 | _runChecks(t) { 363 | const { titles: e, classNames: i } = this.checks(t); 364 | this.checkResults = { titles: e, classNames: i }; 365 | } 366 | _saveConstructorOptions({ 367 | data: t = [], 368 | node: e = null, 369 | onChange: i = null, 370 | minRows: s = 1, 371 | maxRows: n = 1 / 0, 372 | css: o = "", 373 | width: r = "100%", 374 | height: h = "80vh", 375 | columns: l, 376 | checks: a, 377 | }) { 378 | if ( 379 | ((this.columns = l), 380 | (this.checks = a || (() => ({}))), 381 | this._runChecks(t), 382 | !e) 383 | ) 384 | throw new Error( 385 | "You need to pass a node argument to Importabular, like this : new Importabular({node: document.body})" 386 | ); 387 | (this._parent = e), 388 | (this._options = { 389 | onChange: i, 390 | minRows: s, 391 | maxRows: n, 392 | css: 393 | "\nhtml{\n -ms-overflow-style: none;\n scrollbar-width: none;\n}\n::-webkit-scrollbar {\n width: 0;\n height:0;\n}\n*{\n box-sizing: border-box;\n}\nbody{\n padding: 0; \n margin: 0;\n}\ntable{\n border-spacing: 0;\n background: white;\n border: 1px solid #ddd;\n border-width: 0 1px 1px 0;\n font-size: 16px;\n font-family: sans-serif;\n border-collapse: separate;\n min-width:100%;\n}\ntd, th{\n padding:0;\n border: 1px solid;\n border-color: #ddd transparent transparent #ddd; \n}\ntd.selected.multi:not(.editing){\n background:#d7f2f9;\n} \ntd.focus:not(.editing){\n border-color: black;\n} \ntd>*, th>*{\n border:none;\n padding:10px;\n min-width:100px;\n min-height: 40px;\n font:inherit;\n line-height: 20px;\n color:inherit;\n white-space: normal;\n}\ntd>div::selection {\n color: none;\n background: none;\n}\n\n.placeholder div{\n user-select:none;\n color:rgba(0,0,0,0.2);\n}\n*[title] div{cursor:help;}\nth{text-align:left;}\n" + 394 | o, 395 | }), 396 | (this._iframeStyle = { 397 | width: r, 398 | height: h, 399 | border: "none", 400 | background: "transparent", 401 | }); 402 | } 403 | _fitBounds({ x: t, y: e }) { 404 | return ( 405 | t >= 0 && 406 | t < this.columns.length && 407 | e >= 0 && 408 | e < this._options.maxRows 409 | ); 410 | } 411 | _fillScrollSpace() { 412 | const t = Math.ceil(this.iframe.contentWindow.innerHeight / 40), 413 | e = Math.ceil(this.iframe.contentWindow.innerWidth / 100); 414 | this._incrementToFit({ x: e - 1, y: t - 1 }); 415 | } 416 | getData() { 417 | return this._data._toArr(this._width, this._height); 418 | } 419 | _onDataChanged() { 420 | const t = this.getData(); 421 | this._options.onChange && this._options.onChange(t), 422 | this._runChecks(t), 423 | this._restyleAll(); 424 | } 425 | _renderTDContent(t, e, i) { 426 | const s = document.createElement("div"); 427 | t.setAttribute("x", e.toString()), 428 | t.setAttribute("y", i.toString()); 429 | const n = this._divContent(e, i); 430 | n ? (s.textContent = n) : (s.innerHTML = " "), 431 | t.appendChild(s), 432 | this._restyle({ x: e, y: i }); 433 | } 434 | _divContent(t, e) { 435 | return this._getVal(t, e) || this.columns[t].placeholder; 436 | } 437 | _setupDom() { 438 | const t = document.createElement("iframe"); 439 | (this.iframe = t), this._parent.appendChild(t); 440 | const e = t.contentWindow.document; 441 | (this.cwd = e), 442 | e.open(), 443 | e.write( 444 | `` 445 | ), 446 | e.close(), 447 | Object.assign(t.style, this._iframeStyle); 448 | const i = document.createElement("table"), 449 | s = document.createElement("tbody"), 450 | n = document.createElement("THEAD"), 451 | o = document.createElement("TR"); 452 | n.appendChild(o), 453 | this.columns.forEach((t) => { 454 | const e = document.createElement("TH"), 455 | i = document.createElement("div"); 456 | (i.innerHTML = t.label), 457 | t.title && e.setAttribute("title", t.title), 458 | e.appendChild(i), 459 | o.appendChild(e); 460 | }), 461 | i.appendChild(n), 462 | i.appendChild(s), 463 | e.body.appendChild(i), 464 | (this.tbody = s), 465 | (this.table = i); 466 | for (let t = 0; t < this._height; t++) { 467 | const e = document.createElement("tr"); 468 | s.appendChild(e); 469 | for (let i = 0; i < this._width; i++) this._addCell(e, i, t); 470 | } 471 | h.forEach((t) => e.addEventListener(t, this[t], !0)); 472 | } 473 | destroy() { 474 | this._destroyEditing(), 475 | h.forEach((t) => this.cwd.removeEventListener(t, this[t], !0)), 476 | this.iframe.parentElement.removeChild(this.iframe); 477 | } 478 | _addCell(t, e, i) { 479 | const s = document.createElement("td"); 480 | t.appendChild(s), this._renderTDContent(s, e, i); 481 | } 482 | _incrementHeight() { 483 | if (!this._fitBounds({ x: 0, y: this._height })) return !1; 484 | this._height++; 485 | const t = this._height - 1, 486 | e = document.createElement("tr"); 487 | this.tbody.appendChild(e); 488 | for (let i = 0; i < this._width; i++) this._addCell(e, i, t); 489 | return !0; 490 | } 491 | _incrementWidth() { 492 | if (!this._fitBounds({ x: this._width, y: 0 })) return !1; 493 | this._width++; 494 | const t = this._width - 1; 495 | return ( 496 | Array.prototype.forEach.call(this.tbody.children, (e, i) => { 497 | this._addCell(e, t, i); 498 | }), 499 | !0 500 | ); 501 | } 502 | _incrementToFit({ x: t, y: e }) { 503 | for (; t > this._width - 1 && this._incrementWidth(); ); 504 | for (; e > this._height - 1 && this._incrementHeight(); ); 505 | } 506 | _getSelectionAsArray() { 507 | const { rx: t, ry: e } = this._selection; 508 | if (t[0] === t[1]) return null; 509 | const i = t[1] - t[0], 510 | s = e[1] - e[0], 511 | n = []; 512 | for (let o = 0; o < s; o++) { 513 | n.push([]); 514 | for (let s = 0; s < i; s++) 515 | n[o].push(this._getVal(t[0] + s, e[0] + o)); 516 | } 517 | return n; 518 | } 519 | _setAllSelectedCellsTo(t) { 520 | this._forSelectionCoord(this._selection, ({ x: e, y: i }) => 521 | this._setVal(e, i, t) 522 | ), 523 | this._onDataChanged(), 524 | this._forSelectionCoord( 525 | this._selection, 526 | this._refreshDisplayedValue 527 | ); 528 | } 529 | _moveCursor({ x: t = 0, y: e = 0 }, i) { 530 | const s = i ? this._selectionEnd : this._selectionStart, 531 | n = { x: s.x + t, y: s.y + e }; 532 | this._fitBounds(n) && 533 | (this._stopEditing(), 534 | this._incrementToFit(n), 535 | this._changeSelectedCellsStyle(() => { 536 | i 537 | ? (this._selectionEnd = n) 538 | : (this._selectionStart = this._selectionEnd = this._focus = n); 539 | }), 540 | this._scrollIntoView(n)); 541 | } 542 | _tabCursorInSelection(t, e = 1) { 543 | let { x: i, y: s } = this._focus || { x: 0, y: 0 }; 544 | const o = this._selectionSize(), 545 | { rx: r, ry: h } = 546 | o > 1 547 | ? this._selection 548 | : { 549 | rx: [0, this.columns.length], 550 | ry: [0, this._options.maxRows], 551 | }; 552 | let l; 553 | if (t) l = n(i, s, e, r[0], r[1] - 1, h[0], h[1] - 1); 554 | else { 555 | const t = n(s, i, e, h[0], h[1] - 1, r[0], r[1] - 1); 556 | l = { x: t.y, y: t.x }; 557 | } 558 | this._fitBounds(l) && 559 | (this._stopEditing(), 560 | this._incrementToFit(l), 561 | this._changeSelectedCellsStyle(() => { 562 | (this._focus = l), 563 | o <= 1 && (this._selectionStart = this._selectionEnd = l); 564 | }), 565 | this._scrollIntoView(l)); 566 | } 567 | _scrollIntoView({ x: t, y: e }) { 568 | this._getCell(t, e).scrollIntoView({ 569 | behavior: "smooth", 570 | block: "nearest", 571 | }); 572 | } 573 | _endSelection() { 574 | (this._selecting = !1), (this.tbody.style.userSelect = ""); 575 | } 576 | _startEditing({ x: t, y: e }) { 577 | this._editing = { x: t, y: e }; 578 | const i = this._getCell(t, e), 579 | s = i.getBoundingClientRect(), 580 | n = i.firstChild.getBoundingClientRect(); 581 | i.removeChild(i.firstChild); 582 | const o = document.createElement("input"); 583 | (o.type = "text"), 584 | (o.value = this._getVal(t, e)), 585 | i.appendChild(o), 586 | Object.assign(i.style, { 587 | width: s.width - 2, 588 | height: s.height, 589 | }), 590 | Object.assign(o.style, { 591 | width: `${n.width}px`, 592 | height: `${n.height}px`, 593 | }), 594 | o.focus(), 595 | o.addEventListener("blur", this._stopEditing), 596 | o.addEventListener("keydown", this._blurIfEnter); 597 | } 598 | _destroyEditing() { 599 | if (this._editing) { 600 | const { x: t, y: e } = this._editing, 601 | i = this._getCell(t, e).firstChild; 602 | i.removeEventListener("blur", this._stopEditing), 603 | i.removeEventListener("keydown", this._blurIfEnter); 604 | } 605 | } 606 | _revertEdit() { 607 | if (!this._editing) return; 608 | const { x: t, y: e } = this._editing; 609 | this._getCell(t, e).firstChild.value = this._getVal(t, e); 610 | } 611 | _changeSelectedCellsStyle(t) { 612 | const e = this._selection; 613 | t(), 614 | (this._selection = this._getSelectionCoords()), 615 | this._forSelectionCoord(e, this._restyle), 616 | this._forSelectionCoord(this._selection, this._restyle); 617 | } 618 | _getSelectionCoords() { 619 | if (!this._selectionStart) return { rx: [0, 0], ry: [0, 0] }; 620 | let t = [this._selectionStart.x, this._selectionEnd.x]; 621 | t[0] > t[1] && t.reverse(); 622 | let e = [this._selectionStart.y, this._selectionEnd.y]; 623 | return ( 624 | e[0] > e[1] && e.reverse(), 625 | { rx: [t[0], t[1] + 1], ry: [e[0], e[1] + 1] } 626 | ); 627 | } 628 | _forSelectionCoord({ rx: t, ry: e }, i) { 629 | for (let s = t[0]; s < t[1]; s++) 630 | for (let t = e[0]; t < e[1]; t++) 631 | this._fitBounds({ x: s, y: t }) && i({ x: s, y: t }); 632 | } 633 | _restyleAll() { 634 | for (var t = 0; t < this._width; t++) 635 | for (var e = 0; e < this._height; e++) 636 | this._restyle({ x: t, y: e }); 637 | } 638 | _selectionSize() { 639 | const { rx: t, ry: e } = this._selection; 640 | return (t[1] - t[0]) * (e[1] - e[0]); 641 | } 642 | _classNames(t, e) { 643 | const { rx: i, ry: s } = this._selection; 644 | let n = ""; 645 | return ( 646 | t >= i[0] && 647 | t < i[1] && 648 | e >= s[0] && 649 | e < s[1] && 650 | ((n += " selected"), 651 | this._selectionSize() > 1 && (n += " multi")), 652 | this._focus && 653 | this._focus.x === t && 654 | this._focus.y === e && 655 | (n += " focus"), 656 | this._editing && 657 | t === this._editing.x && 658 | e === this._editing.y && 659 | (n += " editing"), 660 | this._getVal(t, e) || (n += " placeholder"), 661 | (n += " " + a(this.checkResults.classNames, t, e)), 662 | n 663 | ); 664 | } 665 | _getCoords(t) { 666 | let e = t.target; 667 | for (; !e.getAttribute("x") && e.parentElement; ) 668 | e = e.parentElement; 669 | return { 670 | x: parseInt(e.getAttribute("x")) || 0, 671 | y: parseInt(e.getAttribute("y")) || 0, 672 | }; 673 | } 674 | setData(t) { 675 | this._data._clear(), this._replaceDataWithArray(t); 676 | for (let t = 0; t < this._width; t++) 677 | for (let e = 0; e < this._height; e++) 678 | this._refreshDisplayedValue({ x: t, y: e }); 679 | } 680 | _replaceDataWithArray(t = [[]]) { 681 | t.forEach((t, e) => { 682 | t.forEach((t, i) => { 683 | this._setVal(i, e, t); 684 | }); 685 | }); 686 | } 687 | _setVal(t, e, i) { 688 | this._fitBounds({ x: t, y: e }) && 689 | (this._data._setVal(t, e, i), 690 | this._incrementToFit({ x: t + 1, y: e + 1 }), 691 | this._refreshDisplayedValue({ x: t, y: e })); 692 | } 693 | _getVal(t, e) { 694 | return this._data._getVal(t, e); 695 | } 696 | _getCell(t, e) { 697 | return this.tbody.children[e].children[t]; 698 | } 699 | } 700 | function a(t, e, i) { 701 | return (t && t[i] && t[i][e]) || ""; 702 | } 703 | }, 704 | }, 705 | e = {}; 706 | function i(s) { 707 | if (e[s]) return e[s].exports; 708 | var n = (e[s] = { exports: {} }); 709 | return t[s](n, n.exports, i), n.exports; 710 | } 711 | return ( 712 | (i.d = (t, e) => { 713 | for (var s in e) 714 | i.o(e, s) && 715 | !i.o(t, s) && 716 | Object.defineProperty(t, s, { enumerable: !0, get: e[s] }); 717 | }), 718 | (i.o = (t, e) => Object.prototype.hasOwnProperty.call(t, e)), 719 | i(103) 720 | ); 721 | })().default; 722 | }); 723 | -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.Importabular=e():t.Importabular=e()}(self,(function(){return(()=>{"use strict";var t={103:(t,e,i)=>{i.d(e,{default:()=>l});class s{constructor(){var t,e;e={},(t="_data")in this?Object.defineProperty(this,t,{value:e,enumerable:!0,configurable:!0,writable:!0}):this[t]=e}_setVal(t,e,i){const s=this._data,n=function(t){return 0===t?"0":t?t.toString():""}(i);var o;n?(s[t]||(s[t]={}),s[t][e]=n):s[t]&&s[t][e]&&(delete s[t][e],o=s[t],0===Object.keys(o).length&&delete s[t])}_clear(){this._data={}}_getVal(t,e){const i=this._data;return i&&i[t]&&i[t][e]||""}_toArr(t,e){const i=[];for(let s=0;sn&&(t=s,++e>r&&(e=o,t=s)),{x:t,y:e}}function o(t){return t.split('"').length-1}function r(t,e,i){return e in t?Object.defineProperty(t,e,{value:i,enumerable:!0,configurable:!0,writable:!0}):t[e]=i,t}const h=["mousedown","mouseenter","mouseup","mouseleave","touchstart","touchend","touchmove","keydown","paste","cut","copy"];class l{constructor(t){r(this,"_width",1),r(this,"_height",1),r(this,"_data",new s),r(this,"paste",(t=>{if(this._editing)return;t.preventDefault();const e=function(t){var e,i,s,n,r,h,l,a=[],c=0;for((s=t.split("\n")).length>1&&""===s[s.length-1]&&s.pop(),e=0,i=s.length;e{this._selectionStart=n,this._selectionEnd={x:n.x+e[0].length-1,y:n.y+e.length-1},this._onDataChanged()}))})),r(this,"copy",(t=>{if(this._editing)return;const e=this._getSelectionAsArray();e&&(t.preventDefault(),t.clipboardData.setData("text/plain",function(t){var e,i,s,n,o,r="";for(e=0,i=t.length;e0&&(r+="\t"),"string"==typeof(o=t[e][s])?o.indexOf("\n")>-1?r+='"'+o.replace(/"/g,'""')+'"':r+=o:r+=null==o?"":o;r+="\n"}return r}(e)))})),r(this,"cut",(t=>{this._editing||(this.copy(t),this._setAllSelectedCellsTo(""))})),r(this,"keydown",(t=>{t.ctrlKey||t.metaKey||this._selectionStart&&("Escape"===t.key&&this._editing&&(t.preventDefault(),this._revertEdit(),this._stopEditing()),"Enter"===t.key&&(t.preventDefault(),this._tabCursorInSelection(!1,t.shiftKey?-1:1)),"Tab"===t.key&&(t.preventDefault(),this._tabCursorInSelection(!0,t.shiftKey?-1:1)),this._editing||("F2"===t.key&&(t.preventDefault(),this._startEditing(this._focus)),"Delete"!==t.key&&"Backspace"!==t.key||(t.preventDefault(),this._setAllSelectedCellsTo("")),"ArrowDown"===t.key&&(t.preventDefault(),this._moveCursor({y:1},t.shiftKey)),"ArrowUp"===t.key&&(t.preventDefault(),this._moveCursor({y:-1},t.shiftKey)),"ArrowLeft"===t.key&&(t.preventDefault(),this._moveCursor({x:-1},t.shiftKey)),"ArrowRight"===t.key&&(t.preventDefault(),this._moveCursor({x:1},t.shiftKey))),1!==t.key.length||this._editing||this._changeSelectedCellsStyle((()=>{const{x:t,y:e}=this._focus;this._startEditing({x:t,y:e}),this._getCell(t,e).firstChild.value=""})))})),r(this,"_selecting",!1),r(this,"_selectionStart",null),r(this,"_selectionEnd",null),r(this,"_selection",{rx:[0,0],ry:[0,0]}),r(this,"_editing",null),r(this,"_focus",null),r(this,"mousedown",(t=>{if(!this.mobile){if(3===t.which&&!this._editing&&this._selectionSize()){let t=new Range;const{rx:e,ry:i}=this._selection;return t.setStart(this._getCell(e[0],i[0]),0),t.setEnd(this._getCell(e[0],i[0]),1),this.cwd.getSelection().removeAllRanges(),void this.cwd.getSelection().addRange(t)}this._changeSelectedCellsStyle((()=>{this.tbody.style.userSelect="none",this._selectionEnd=this._selectionStart=this._focus=this._getCoords(t),this._selecting=!0}))}})),r(this,"mouseenter",(t=>{this.mobile||this._selecting&&this._changeSelectedCellsStyle((()=>{this._selectionEnd=this._getCoords(t)}))})),r(this,"_lastMouseUp",null),r(this,"_lastMouseUpTarget",null),r(this,"mouseup",(t=>{this.mobile||3!==t.which&&this._selecting&&this._changeSelectedCellsStyle((()=>{this._selectionEnd=this._getCoords(t),this._endSelection(),this._lastMouseUp&&this._lastMouseUp>Date.now()-300&&this._lastMouseUpTarget.x===this._selectionEnd.x&&this._lastMouseUpTarget.y===this._selectionEnd.y&&this._startEditing(this._selectionEnd),this._lastMouseUp=Date.now(),this._lastMouseUpTarget=this._selectionEnd}))})),r(this,"mouseleave",(t=>{t.target===this.tbody&&this._selecting&&this._endSelection()})),r(this,"touchstart",(t=>{this._editing||(this.mobile=!0,this.moved=!1)})),r(this,"touchend",(t=>{this.mobile&&(this._editing||this.moved||(this._changeSelectedCellsStyle((()=>{this._selectionEnd=this._selectionStart=this._focus=this._getCoords(t)})),this._startEditing(this._focus)))})),r(this,"touchmove",(t=>{this.mobile&&(this.moved=!0)})),r(this,"_stopEditing",(()=>{if(!this._editing)return;const{x:t,y:e}=this._editing,i=this._getCell(t,e);i.style.width="",i.style.height="";const s=i.firstChild;s.removeEventListener("blur",this._stopEditing),s.removeEventListener("keydown",this._blurIfEnter),this._setVal(t,e,s.value),this._onDataChanged(),i.removeChild(s),this._editing=null,this._renderTDContent(i,t,e)})),r(this,"_blurIfEnter",(t=>{13===t.keyCode&&(this._stopEditing(),t.preventDefault())})),r(this,"_restyle",(({x:t,y:e})=>{const i=this._getCell(t,e);i.className=this._classNames(t,e);const s=a(this.checkResults.titles,t,e);s?i.setAttribute("title",s):i.removeAttribute("title")})),r(this,"_refreshDisplayedValue",(({x:t,y:e})=>{const i=this._getCell(t,e).firstChild;"DIV"===i.tagName&&(i.textContent=this._divContent(t,e)),this._restyle({x:t,y:e})})),this._saveConstructorOptions(t),this._setupDom(),this._replaceDataWithArray(t.data),this._incrementToFit({x:this.columns.length-1,y:this._options.minRows-1}),this._fillScrollSpace()}_runChecks(t){const{titles:e,classNames:i}=this.checks(t);this.checkResults={titles:e,classNames:i}}_saveConstructorOptions({data:t=[],node:e=null,onChange:i=null,minRows:s=1,maxRows:n=1/0,css:o="",width:r="100%",height:h="80vh",columns:l,checks:a}){if(this.columns=l,this.checks=a||(()=>({})),this._runChecks(t),!e)throw new Error("You need to pass a node argument to Importabular, like this : new Importabular({node: document.body})");this._parent=e,this._options={onChange:i,minRows:s,maxRows:n,css:"\nhtml{\n -ms-overflow-style: none;\n scrollbar-width: none;\n}\n::-webkit-scrollbar {\n width: 0;\n height:0;\n}\n*{\n box-sizing: border-box;\n}\nbody{\n padding: 0; \n margin: 0;\n}\ntable{\n border-spacing: 0;\n background: white;\n border: 1px solid #ddd;\n border-width: 0 1px 1px 0;\n font-size: 16px;\n font-family: sans-serif;\n border-collapse: separate;\n min-width:100%;\n}\ntd, th{\n padding:0;\n border: 1px solid;\n border-color: #ddd transparent transparent #ddd; \n}\ntd.selected.multi:not(.editing){\n background:#d7f2f9;\n} \ntd.focus:not(.editing){\n border-color: black;\n} \ntd>*, th>*{\n border:none;\n padding:10px;\n min-width:100px;\n min-height: 40px;\n font:inherit;\n line-height: 20px;\n color:inherit;\n white-space: normal;\n}\ntd>div::selection {\n color: none;\n background: none;\n}\n\n.placeholder div{\n user-select:none;\n color:rgba(0,0,0,0.2);\n}\n*[title] div{cursor:help;}\nth{text-align:left;}\n"+o},this._iframeStyle={width:r,height:h,border:"none",background:"transparent"}}_fitBounds({x:t,y:e}){return t>=0&&t=0&&e`),e.close(),Object.assign(t.style,this._iframeStyle);const i=document.createElement("table"),s=document.createElement("tbody"),n=document.createElement("THEAD"),o=document.createElement("TR");n.appendChild(o),this.columns.forEach((t=>{const e=document.createElement("TH"),i=document.createElement("div");i.innerHTML=t.label,t.title&&e.setAttribute("title",t.title),e.appendChild(i),o.appendChild(e)})),i.appendChild(n),i.appendChild(s),e.body.appendChild(i),this.tbody=s,this.table=i;for(let t=0;te.addEventListener(t,this[t],!0)))}destroy(){this._destroyEditing(),h.forEach((t=>this.cwd.removeEventListener(t,this[t],!0))),this.iframe.parentElement.removeChild(this.iframe)}_addCell(t,e,i){const s=document.createElement("td");t.appendChild(s),this._renderTDContent(s,e,i)}_incrementHeight(){if(!this._fitBounds({x:0,y:this._height}))return!1;this._height++;const t=this._height-1,e=document.createElement("tr");this.tbody.appendChild(e);for(let i=0;i{this._addCell(e,t,i)})),!0}_incrementToFit({x:t,y:e}){for(;t>this._width-1&&this._incrementWidth(););for(;e>this._height-1&&this._incrementHeight(););}_getSelectionAsArray(){const{rx:t,ry:e}=this._selection;if(t[0]===t[1])return null;const i=t[1]-t[0],s=e[1]-e[0],n=[];for(let o=0;othis._setVal(e,i,t))),this._onDataChanged(),this._forSelectionCoord(this._selection,this._refreshDisplayedValue)}_moveCursor({x:t=0,y:e=0},i){const s=i?this._selectionEnd:this._selectionStart,n={x:s.x+t,y:s.y+e};this._fitBounds(n)&&(this._stopEditing(),this._incrementToFit(n),this._changeSelectedCellsStyle((()=>{i?this._selectionEnd=n:this._selectionStart=this._selectionEnd=this._focus=n})),this._scrollIntoView(n))}_tabCursorInSelection(t,e=1){let{x:i,y:s}=this._focus||{x:0,y:0};const o=this._selectionSize(),{rx:r,ry:h}=o>1?this._selection:{rx:[0,this.columns.length],ry:[0,this._options.maxRows]};let l;if(t)l=n(i,s,e,r[0],r[1]-1,h[0],h[1]-1);else{const t=n(s,i,e,h[0],h[1]-1,r[0],r[1]-1);l={x:t.y,y:t.x}}this._fitBounds(l)&&(this._stopEditing(),this._incrementToFit(l),this._changeSelectedCellsStyle((()=>{this._focus=l,o<=1&&(this._selectionStart=this._selectionEnd=l)})),this._scrollIntoView(l))}_scrollIntoView({x:t,y:e}){this._getCell(t,e).scrollIntoView({behavior:"smooth",block:"nearest"})}_endSelection(){this._selecting=!1,this.tbody.style.userSelect=""}_startEditing({x:t,y:e}){this._editing={x:t,y:e};const i=this._getCell(t,e),s=i.getBoundingClientRect(),n=i.firstChild.getBoundingClientRect();i.removeChild(i.firstChild);const o=document.createElement("input");o.type="text",o.value=this._getVal(t,e),i.appendChild(o),Object.assign(i.style,{width:s.width-2,height:s.height}),Object.assign(o.style,{width:`${n.width}px`,height:`${n.height}px`}),o.focus(),o.addEventListener("blur",this._stopEditing),o.addEventListener("keydown",this._blurIfEnter)}_destroyEditing(){if(this._editing){const{x:t,y:e}=this._editing,i=this._getCell(t,e).firstChild;i.removeEventListener("blur",this._stopEditing),i.removeEventListener("keydown",this._blurIfEnter)}}_revertEdit(){if(!this._editing)return;const{x:t,y:e}=this._editing;this._getCell(t,e).firstChild.value=this._getVal(t,e)}_changeSelectedCellsStyle(t){const e=this._selection;t(),this._selection=this._getSelectionCoords(),this._forSelectionCoord(e,this._restyle),this._forSelectionCoord(this._selection,this._restyle)}_getSelectionCoords(){if(!this._selectionStart)return{rx:[0,0],ry:[0,0]};let t=[this._selectionStart.x,this._selectionEnd.x];t[0]>t[1]&&t.reverse();let e=[this._selectionStart.y,this._selectionEnd.y];return e[0]>e[1]&&e.reverse(),{rx:[t[0],t[1]+1],ry:[e[0],e[1]+1]}}_forSelectionCoord({rx:t,ry:e},i){for(let s=t[0];s=i[0]&&t=s[0]&&e1&&(n+=" multi")),this._focus&&this._focus.x===t&&this._focus.y===e&&(n+=" focus"),this._editing&&t===this._editing.x&&e===this._editing.y&&(n+=" editing"),this._getVal(t,e)||(n+=" placeholder"),n+=" "+a(this.checkResults.classNames,t,e),n}_getCoords(t){let e=t.target;for(;!e.getAttribute("x")&&e.parentElement;)e=e.parentElement;return{x:parseInt(e.getAttribute("x"))||0,y:parseInt(e.getAttribute("y"))||0}}setData(t){this._data._clear(),this._replaceDataWithArray(t);for(let t=0;t{t.forEach(((t,i)=>{this._setVal(i,e,t)}))}))}_setVal(t,e,i){this._fitBounds({x:t,y:e})&&(this._data._setVal(t,e,i),this._incrementToFit({x:t+1,y:e+1}),this._refreshDisplayedValue({x:t,y:e}))}_getVal(t,e){return this._data._getVal(t,e)}_getCell(t,e){return this.tbody.children[e].children[t]}}function a(t,e,i){return t&&t[i]&&t[i][e]||""}}},e={};function i(s){if(e[s])return e[s].exports;var n=e[s]={exports:{}};return t[s](n,n.exports,i),n.exports}return i.d=(t,e)=>{for(var s in e)i.o(e,s)&&!i.o(t,s)&&Object.defineProperty(t,s,{enumerable:!0,get:e[s]})},i.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e),i(103)})().default})); -------------------------------------------------------------------------------- /dist/index.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renanlecaro/importabular/5b8f4ed9383e3821f78bc2e053ee3280bec2ccfa/dist/index.js.gz -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Importabular : 5kb javascript spreadsheet component

Minimal spreadsheet javascript component

Install it from npm

npm install importabular

Instantiate it on a dom node

Manipulate the sheet programmatically

sheet.getData();
sheet.setData([['First','line','text']);
sheet.destroy()

More complete example

Goals and limitations

I've created this lib because I was tired of having to remove 90% of the features offered by the very few open source libs for web spreadsheets.

So for this reinventing the wheel to make sense, I should not add any extra features to this core.

  • No "drag cell corner to expand its value"
  • No virtual rendering
  • No sorting, pivot, formula, etc ..
  • Only basic keyboard shortcuts
  • Only strings as data type
  • Only for recent browsers

The lib is fresh and not battle tested, probably has some bugs. The API is not stable yet. Feel free to create an issue if you find a bug.

Fork me on GitHub -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renanlecaro/importabular/5b8f4ed9383e3821f78bc2e053ee3280bec2ccfa/docs/logo.png -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 46 | 47 | 70 | 72 | 73 | 75 | image/svg+xml 76 | 78 | 79 | 80 | 81 | 82 | 87 | 94 | 5k 106 | 107 | 108 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | // All imported modules in your tests should be mocked automatically 6 | // automock: false, 7 | 8 | // Stop running tests after `n` failures 9 | // bail: 0, 10 | 11 | // The directory where Jest should store its cached dependency information 12 | // cacheDirectory: "/tmp/jest_rs", 13 | 14 | // Automatically clear mock calls and instances between every test 15 | clearMocks: true, 16 | 17 | // Indicates whether the coverage information should be collected while executing the test 18 | // collectCoverage: false, 19 | 20 | // An array of glob patterns indicating a set of files for which coverage information should be collected 21 | // collectCoverageFrom: undefined, 22 | 23 | // The directory where Jest should output its coverage files 24 | // coverageDirectory: "coverage", 25 | 26 | // An array of regexp pattern strings used to skip coverage collection 27 | // coveragePathIgnorePatterns: [ 28 | // "/node_modules/" 29 | // ], 30 | 31 | // Indicates which provider should be used to instrument code for coverage 32 | // coverageProvider: "v8", 33 | 34 | // A list of reporter names that Jest uses when writing coverage reports 35 | // coverageReporters: [ 36 | // "json", 37 | // "text", 38 | // "lcov", 39 | // "clover" 40 | // ], 41 | 42 | // An object that configures minimum threshold enforcement for coverage results 43 | // coverageThreshold: undefined, 44 | 45 | // A path to a custom dependency extractor 46 | // dependencyExtractor: undefined, 47 | 48 | // Make calling deprecated APIs throw helpful error messages 49 | // errorOnDeprecated: false, 50 | 51 | // Force coverage collection from ignored files using an array of glob patterns 52 | // forceCoverageMatch: [], 53 | 54 | // A path to a module which exports an async function that is triggered once before all test suites 55 | // globalSetup: undefined, 56 | 57 | // A path to a module which exports an async function that is triggered once after all test suites 58 | // globalTeardown: undefined, 59 | 60 | // A set of global variables that need to be available in all test environments 61 | // globals: {}, 62 | 63 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 64 | // maxWorkers: "50%", 65 | 66 | // An array of directory names to be searched recursively up from the requiring module's location 67 | // moduleDirectories: [ 68 | // "node_modules" 69 | // ], 70 | 71 | // An array of file extensions your modules use 72 | // moduleFileExtensions: [ 73 | // "js", 74 | // "json", 75 | // "jsx", 76 | // "ts", 77 | // "tsx", 78 | // "node" 79 | // ], 80 | 81 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 82 | // moduleNameMapper: {}, 83 | 84 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 85 | // modulePathIgnorePatterns: [], 86 | 87 | // Activates notifications for test results 88 | // notify: false, 89 | 90 | // An enum that specifies notification mode. Requires { notify: true } 91 | // notifyMode: "failure-change", 92 | 93 | // A preset that is used as a base for Jest's configuration 94 | // preset: undefined, 95 | 96 | // Run tests from one or more projects 97 | // projects: undefined, 98 | 99 | // Use this configuration option to add custom reporters to Jest 100 | // reporters: undefined, 101 | 102 | // Automatically reset mock state between every test 103 | // resetMocks: false, 104 | 105 | // Reset the module registry before running each individual test 106 | // resetModules: false, 107 | 108 | // A path to a custom resolver 109 | // resolver: undefined, 110 | 111 | // Automatically restore mock state between every test 112 | // restoreMocks: false, 113 | 114 | // The root directory that Jest should scan for tests and modules within 115 | rootDir: './src', 116 | 117 | // A list of paths to directories that Jest should use to search for files in 118 | // roots: [ 119 | // "" 120 | // ], 121 | 122 | // Allows you to use a custom runner instead of Jest's default test runner 123 | // runner: "jest-runner", 124 | 125 | // The paths to modules that run some code to configure or set up the testing environment before each test 126 | // setupFiles: [], 127 | 128 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 129 | // setupFilesAfterEnv: [], 130 | 131 | // The number of seconds after which a test is considered as slow and reported as such in the results. 132 | // slowTestThreshold: 5, 133 | 134 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 135 | // snapshotSerializers: [], 136 | 137 | // The test environment that will be used for testing 138 | // testEnvironment: "jest-environment-jsdom", 139 | 140 | // Options that will be passed to the testEnvironment 141 | // testEnvironmentOptions: {}, 142 | 143 | // Adds a location field to test results 144 | // testLocationInResults: false, 145 | 146 | // The glob patterns Jest uses to detect test files 147 | // testMatch: [ 148 | // "**/__tests__/**/*.[jt]s?(x)", 149 | // "**/?(*.)+(spec|test).[tj]s?(x)" 150 | // ], 151 | 152 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 153 | // testPathIgnorePatterns: [ 154 | // "/node_modules/" 155 | // ], 156 | 157 | // The regexp pattern or array of patterns that Jest uses to detect test files 158 | // testRegex: [], 159 | 160 | // This option allows the use of a custom results processor 161 | // testResultsProcessor: undefined, 162 | 163 | // This option allows use of a custom test runner 164 | // testRunner: "jasmine2", 165 | 166 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 167 | // testURL: "http://localhost", 168 | 169 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 170 | // timers: "real", 171 | 172 | // A map from regular expressions to paths to transformers 173 | // transform: undefined, 174 | 175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 176 | // transformIgnorePatterns: [ 177 | // "/node_modules/", 178 | // "\\.pnp\\.[^\\/]+$" 179 | // ], 180 | 181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 182 | // unmockedModulePathPatterns: undefined, 183 | 184 | // Indicates whether each individual test should be reported during the run 185 | // verbose: undefined, 186 | 187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 188 | // watchPathIgnorePatterns: [], 189 | 190 | // Whether to use watchman for file crawling 191 | // watchman: true, 192 | }; 193 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "importabular", 3 | "version": "0.2.13", 4 | "description": "5kb javascript spreadsheet, let your users import their data from excel.", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist/index.js" 8 | ], 9 | "scripts": { 10 | "start": "webpack serve", 11 | "build": "bash ./build.sh", 12 | "pretty": "prettier --write src/**", 13 | "test": "jest" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/renanlecaro/importabular.git" 18 | }, 19 | "keywords": [ 20 | "spreadsheet", 21 | "vanillajs", 22 | "ui", 23 | "lightweight", 24 | "minimalist", 25 | "data", 26 | "import" 27 | ], 28 | "author": "Renan LE CARO", 29 | "license": "MIT", 30 | "bugs": { 31 | "url": "https://github.com/renanlecaro/importabular/issues" 32 | }, 33 | "homepage": "https://lecaro.me/importabular/", 34 | "devDependencies": { 35 | "@babel/cli": "^7.11.6", 36 | "@babel/core": "^7.12.9", 37 | "@babel/plugin-proposal-class-properties": "^7.10.4", 38 | "@babel/preset-env": "^7.12.7", 39 | "babel-jest": "^26.3.0", 40 | "babel-loader": "^8.2.2", 41 | "clean-webpack-plugin": "^3.0.0", 42 | "copy-webpack-plugin": "^6.3.2", 43 | "css-loader": "^5.0.1", 44 | "documentation": "*", 45 | "html-inline-css-webpack-plugin": "^1.10.0", 46 | "html-inline-script-webpack-plugin": "^1.0.1", 47 | "html-webpack-plugin": "^4.5.0", 48 | "jest": "^26.4.2", 49 | "mini-css-extract-plugin": "^1.3.1", 50 | "prettier": "2.1.2", 51 | "raw-loader": "^4.0.2", 52 | "style-loader": "^2.0.0", 53 | "terser": "^5.3.2", 54 | "webpack": "^5.9.0", 55 | "webpack-cli": "^4.2.0", 56 | "webpack-dev-server": "^3.11.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/_LooseArray.js: -------------------------------------------------------------------------------- 1 | import { _cleanVal } from "./_cleanVal"; 2 | import { _isEmpty } from "./_isEmpty"; 3 | 4 | export class _LooseArray { 5 | // An 2D array of strings that only stores non "" values 6 | _data = {}; 7 | 8 | _setVal(x, y, val) { 9 | const hash = this._data; 10 | const cleanedVal = _cleanVal(val); 11 | if (cleanedVal) { 12 | if (!hash[x]) hash[x] = {}; 13 | hash[x][y] = cleanedVal; 14 | } else { 15 | // delete item 16 | if (hash[x] && hash[x][y]) { 17 | delete hash[x][y]; 18 | if (_isEmpty(hash[x])) delete hash[x]; 19 | } 20 | } 21 | } 22 | 23 | _clear() { 24 | this._data = {}; 25 | } 26 | 27 | _getVal(x, y) { 28 | const hash = this._data; 29 | return (hash && hash[x] && hash[x][y]) || ""; 30 | } 31 | 32 | _toArr(width, height) { 33 | const result = []; 34 | for (let y = 0; y < height; y++) { 35 | result.push([]); 36 | for (let x = 0; x < width; x++) { 37 | result[y].push(this._getVal(x, y)); 38 | } 39 | } 40 | return result; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/_cleanVal.js: -------------------------------------------------------------------------------- 1 | export function _cleanVal(val) { 2 | if (val === 0) return "0"; 3 | if (!val) return ""; 4 | return val.toString(); 5 | } 6 | -------------------------------------------------------------------------------- /src/_defaultCss.js: -------------------------------------------------------------------------------- 1 | export const _defaultCss = ` 2 | html{ 3 | -ms-overflow-style: none; 4 | scrollbar-width: none; 5 | } 6 | ::-webkit-scrollbar { 7 | width: 0; 8 | height:0; 9 | } 10 | *{ 11 | box-sizing: border-box; 12 | } 13 | body{ 14 | padding: 0; 15 | margin: 0; 16 | } 17 | table{ 18 | border-spacing: 0; 19 | background: white; 20 | border: 1px solid #ddd; 21 | border-width: 0 1px 1px 0; 22 | font-size: 16px; 23 | font-family: sans-serif; 24 | border-collapse: separate; 25 | min-width:100%; 26 | } 27 | td, th{ 28 | padding:0; 29 | border: 1px solid; 30 | border-color: #ddd transparent transparent #ddd; 31 | } 32 | td.selected.multi:not(.editing){ 33 | background:#d7f2f9; 34 | } 35 | td.focus:not(.editing){ 36 | border-color: black; 37 | } 38 | td>*, th>*{ 39 | border:none; 40 | padding:10px; 41 | min-width:100px; 42 | min-height: 40px; 43 | font:inherit; 44 | line-height: 20px; 45 | color:inherit; 46 | white-space: normal; 47 | } 48 | td>div::selection { 49 | color: none; 50 | background: none; 51 | } 52 | 53 | .placeholder div{ 54 | user-select:none; 55 | color:rgba(0,0,0,0.2); 56 | } 57 | *[title] div{cursor:help;} 58 | th{text-align:left;} 59 | `; 60 | -------------------------------------------------------------------------------- /src/_isEmpty.js: -------------------------------------------------------------------------------- 1 | export function _isEmpty(obj) { 2 | return Object.keys(obj).length === 0; 3 | } 4 | -------------------------------------------------------------------------------- /src/_shift.js: -------------------------------------------------------------------------------- 1 | export function _shift(x, y, deltaX, xMin, xMax, yMin, yMax) { 2 | x += deltaX; 3 | if (x < xMin) { 4 | if (xMax === Infinity) { 5 | return { x: xMin, y }; 6 | } 7 | x = xMax; 8 | y--; 9 | if (y < yMin) { 10 | if (yMax === Infinity) { 11 | return { x: xMin, y: yMin }; 12 | } 13 | y = yMax; 14 | } 15 | } 16 | if (x > xMax) { 17 | x = xMin; 18 | y++; 19 | if (y > yMax) { 20 | y = yMin; 21 | x = xMin; 22 | } 23 | } 24 | return { x, y }; 25 | } 26 | -------------------------------------------------------------------------------- /src/_shift.test.js: -------------------------------------------------------------------------------- 1 | import { _shift } from "./_shift"; 2 | 3 | describe("moving forward ", () => { 4 | test("moves one cell to the right", () => { 5 | // X0 to 0X 6 | // 00 00 7 | 8 | expect( 9 | _shift( 10 | 0, // x 11 | 0, // y 12 | 1, // deltaX 13 | 0, // xMin 14 | 1, // xMax 15 | 0, // yMin 16 | 1 // yMax 17 | ) 18 | ).toEqual({ x: 1, y: 0 }); 19 | }); 20 | 21 | test("goes to the next row if it is exiting the bounds horizontally", () => { 22 | // 0X to 00 23 | // 00 X0 24 | expect( 25 | _shift( 26 | 1, // x 27 | 0, // y 28 | 1, // deltaX 29 | 0, // xMin 30 | 1, // xMax 31 | 0, // yMin 32 | 1 // yMax 33 | ) 34 | ).toEqual({ x: 0, y: 1 }); 35 | }); 36 | test("goes back to the first row if it is at the last one", () => { 37 | // 00 to X0 38 | // 0X 00 39 | expect( 40 | _shift( 41 | 1, // x 42 | 1, // y 43 | 1, // deltaX 44 | 0, // xMin 45 | 1, // xMax 46 | 0, // yMin 47 | 1 // yMax 48 | ) 49 | ).toEqual({ x: 0, y: 0 }); 50 | }); 51 | }); 52 | describe("moving backward ", () => { 53 | test("moves one cell to the left", () => { 54 | // 00 to X0 55 | // 0X 00 56 | expect( 57 | _shift( 58 | 1, // x 59 | 0, // y 60 | -1, // deltaX 61 | 0, // xMin 62 | 1, // xMax 63 | 0, // yMin 64 | 1 // yMax 65 | ) 66 | ).toEqual({ x: 0, y: 0 }); 67 | }); 68 | 69 | test("goes to the previous row if it is exiting the bounds horizontally", () => { 70 | // 00 to 0X 71 | // X0 00 72 | expect( 73 | _shift( 74 | 0, // x 75 | 1, // y 76 | -1, // deltaX 77 | 0, // xMin 78 | 1, // xMax 79 | 0, // yMin 80 | 1 // yMax 81 | ) 82 | ).toEqual({ x: 1, y: 0 }); 83 | }); 84 | 85 | test("does not do anything if the new X coordinates would be infinite", () => { 86 | // 0000.. to 0000.. 87 | // X000.. X000.. 88 | expect( 89 | _shift( 90 | 0, // x 91 | 1, // y 92 | -1, // deltaX 93 | 0, // xMin 94 | Infinity, // xMax 95 | 0, // yMin 96 | 1 // yMax 97 | ) 98 | ).toEqual({ x: 0, y: 1 }); 99 | }); 100 | test("does not do anything if the new Y coordinates would be infinite", () => { 101 | // X0 to X0 102 | // 00 00 103 | // .. .. 104 | expect( 105 | _shift( 106 | 0, // x 107 | 0, // y 108 | -1, // deltaX 109 | 0, // xMin 110 | 1, // xMax 111 | 0, // yMin 112 | Infinity // yMax 113 | ) 114 | ).toEqual({ x: 0, y: 0 }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/demo/demo.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Roboto, sans-serif; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | section { 8 | color: white; 9 | background-attachment: fixed; 10 | } 11 | section a { 12 | color: white; 13 | } 14 | @media (max-width: 570px) { 15 | #editorNode { 16 | margin-left: -10px; 17 | margin-right: -10px; 18 | width: auto; 19 | align-self: stretch; 20 | } 21 | } 22 | @media (max-width: 1400px) { 23 | section { 24 | padding: 20px; 25 | display: flex; 26 | flex-direction: column; 27 | align-items: center; 28 | } 29 | section > * { 30 | width: 100%; 31 | max-width: 580px; 32 | } 33 | section > *:last-child { 34 | margin-top: 20px; 35 | } 36 | #editorNode > table { 37 | min-width: 100%; 38 | } 39 | } 40 | @media (min-width: 1400px) { 41 | section { 42 | padding: 80px; 43 | display: flex; 44 | min-height: 80vh; 45 | align-items: center; 46 | justify-content: center; 47 | font-size: 20px; 48 | } 49 | section > * { 50 | box-sizing: border-box; 51 | max-width: 600px; 52 | margin: 0 40px; 53 | flex-grow: 1; 54 | flex-shrink: 1; 55 | } 56 | } 57 | 58 | ul.checks { 59 | list-style: none; 60 | padding: 0; 61 | } 62 | ul.checks li { 63 | margin-bottom: 10px; 64 | } 65 | ul.checks li:before { 66 | content: "\2611"; 67 | margin-right: 10px; 68 | } 69 | h1, 70 | h2 { 71 | font-size: 44px; 72 | font-weight: 600; 73 | } 74 | 75 | #editorNode { 76 | overflow: auto; 77 | } 78 | pre > code { 79 | background: #2e3548; 80 | color: #e6f7fd; 81 | border-radius: 4px; 82 | display: block; 83 | font-size: 14px; 84 | padding: 20px; 85 | overflow: auto; 86 | max-width: 100%; 87 | max-height: 70vh; 88 | } 89 | pre.auto { 90 | display: block; 91 | white-space: pre; 92 | background: white; 93 | color: black; 94 | padding: 20px; 95 | font-family: monospace; 96 | line-height: 20px; 97 | margin-bottom: 40px; 98 | font-size: 12px; 99 | max-height: 80vh; 100 | overflow: auto; 101 | } 102 | section.sample { 103 | color: rgba(0, 0, 0, 0.8); 104 | } 105 | 106 | section.sample > :first-child { 107 | /*We flip the order of the script and the container so that the script can see the container when it runs*/ 108 | order: 2; 109 | } 110 | .github-corner { 111 | display: block; 112 | text-decoration: none; 113 | background: white; 114 | color: black; 115 | text-align: center; 116 | padding: 20px; 117 | } 118 | @media screen and (min-width: 1200px) { 119 | .github-corner { 120 | position: fixed; 121 | top: 0; 122 | right: 0; 123 | padding: 10px 60px; 124 | transform: translate(50px, 50px) rotate(45deg); 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/demo/demo.js: -------------------------------------------------------------------------------- 1 | import "./demo.css"; 2 | 3 | function setCode(code, codeBlockId) { 4 | const pre = document.createElement("pre"); 5 | const formattedCode = code.replace(/ from "[^;]*;/, ' from "importabular";'); 6 | 7 | pre.innerText = formattedCode; 8 | 9 | document 10 | .querySelector('code[data-script="' + codeBlockId + '"]') 11 | .appendChild(pre); 12 | } 13 | 14 | import S from "!!raw-loader!./samples/simple.js"; 15 | setCode(S, "simple"); 16 | 17 | import "./samples/simple"; 18 | 19 | import WC from "!!raw-loader!./samples/with-checks.js"; 20 | setCode(WC, "with-checks"); 21 | 22 | import "./samples/with-checks"; 23 | -------------------------------------------------------------------------------- /src/demo/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | Importabular : 5kb javascript spreadsheet component 12 | 13 | 14 | 15 | 16 |
20 |
21 |
22 | 23 |

Minimal spreadsheet javascript component

24 | 25 |

Install it from npm

26 |
npm install importabular
27 | 28 |

Instantiate it on a dom node

29 | 30 |
31 | 32 |

Manipulate the sheet programmatically

33 |
sheet.getData();
34 |
sheet.setData([['First','line','text']);
35 |
sheet.destroy()
36 | 37 | 38 |
39 | 40 |
41 | 42 | 43 | 44 |
48 |
49 |
50 |

More complete example

51 | 52 | 53 |
54 |
55 |
56 |
59 |
60 |

Goals and limitations

61 |

62 | I've created this lib because I was tired of having to remove 90% of 63 | the features offered by the very few open source libs for web 64 | spreadsheets. 65 |

66 |

67 | So for this reinventing the wheel to make sense, I should not add any 68 | extra features to this core. 69 |

70 |
    71 |
  • No "drag cell corner to expand its value"
  • 72 |
  • No virtual rendering
  • 73 |
  • No sorting, pivot, formula, etc ..
  • 74 |
  • Only basic keyboard shortcuts
  • 75 |
  • Only strings as data type
  • 76 |
  • Only for recent browsers
  • 77 |
78 | 79 |

80 | The lib is fresh and not battle tested, probably has some bugs. The 81 | API is not stable yet. Feel free to 82 | 83 | create an issue 85 | if you find a bug. 86 |

87 |
88 |
89 | 90 | 91 | 92 | 93 | Fork me on GitHub 94 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/demo/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renanlecaro/importabular/5b8f4ed9383e3821f78bc2e053ee3280bec2ccfa/src/demo/public/logo.png -------------------------------------------------------------------------------- /src/demo/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 23 | 25 | 28 | 32 | 36 | 37 | 46 | 47 | 70 | 72 | 73 | 75 | image/svg+xml 76 | 78 | 79 | 80 | 81 | 82 | 87 | 94 | 5k 106 | 107 | 108 | -------------------------------------------------------------------------------- /src/demo/samples/simple.js: -------------------------------------------------------------------------------- 1 | import Importabular from "../../index"; 2 | 3 | const sheet = new Importabular({ 4 | node: document.getElementById("editor"), 5 | columns: [ 6 | { 7 | label: "Contact name", 8 | }, 9 | { 10 | label: "Phone number", 11 | }, 12 | { 13 | label: "Email address", 14 | }, 15 | ], 16 | }); 17 | -------------------------------------------------------------------------------- /src/demo/samples/with-checks.js: -------------------------------------------------------------------------------- 1 | import Importabular from "../../index"; 2 | 3 | const sheet = new Importabular({ 4 | node: document.getElementById("with-checks"), 5 | columns: [ 6 | { 7 | label: "Contact name", 8 | title: "Full name", 9 | placeholder: "John Doe", 10 | }, 11 | { 12 | label: "Phone number", 13 | title: "In the international format", 14 | placeholder: "+33XXXXXXX", 15 | }, 16 | { 17 | label: "Email address", 18 | placeholder: "xxxx@yyyy.zzz", 19 | }, 20 | ], 21 | data: [ 22 | ["Name", "+333555555", "email@adress"], 23 | ["Bad data", "33366666", "email@"], 24 | ["", "", "missing@name"], 25 | ], 26 | checks: checkData, 27 | css: ` 28 | td>div{ 29 | border-right:2px solid transparent; 30 | } 31 | td.invalid >div{ 32 | border-right:2px solid red; 33 | background:rgba(255,0,0,0.1); 34 | } 35 | td.valid > div{ 36 | border-right:2px solid green; 37 | 38 | } 39 | 40 | `, 41 | onChange(data) { 42 | console.table(data); 43 | }, 44 | }); 45 | 46 | function checkData(data) { 47 | // Generate tooltip content for each problem 48 | const titles = data.map((line) => [ 49 | checkName(line), 50 | checkPhone(line), 51 | checkEmail(line), 52 | ]); 53 | 54 | // Display the cell as invalid if there's a problem 55 | const classNames = data.map((line, index) => [ 56 | titles[index][0] ? "invalid" : line[0] && "valid", 57 | titles[index][1] ? "invalid" : line[1] && "valid", 58 | titles[index][2] ? "invalid" : line[2] && "valid", 59 | ]); 60 | 61 | return { titles, classNames }; 62 | } 63 | 64 | function checkName([name, phone, email]) { 65 | if (!name && (phone || email)) { 66 | return "Name is required"; 67 | } 68 | } 69 | function checkPhone([name, phone, email]) { 70 | if (phone && !phone.match(/\+[0-9]+/)) return "Invalid phone number"; 71 | } 72 | 73 | function checkEmail([name, phone, email]) { 74 | if (name && !email) return "Email is required"; 75 | 76 | if (!email.match(/[a-z0-9.-]+@[a-z0-9.-]+/gi)) { 77 | return "Invalid email address"; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/demo/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/renanlecaro/importabular/5b8f4ed9383e3821f78bc2e053ee3280bec2ccfa/src/demo/screenshot.jpg -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { _defaultCss } from "./_defaultCss"; 2 | import { _LooseArray } from "./_LooseArray"; 3 | import { _shift } from "./_shift"; 4 | import { parseArrayString, stringifyArray } from "./sheetclip"; 5 | /** @private All the events we listen to inside the iframe at the root level. 6 | * Each one is mapped to the corresponding method on the instance. */ 7 | const _events = [ 8 | "mousedown", 9 | "mouseenter", 10 | "mouseup", 11 | "mouseleave", 12 | "touchstart", 13 | "touchend", 14 | "touchmove", 15 | "keydown", 16 | "paste", 17 | "cut", 18 | "copy", 19 | ]; 20 | 21 | /** 22 | * Spreadsheet component 23 | * @param {Object} options 24 | * @param {Array} options.data Initial data of the table, ie [['A1','A2'],['B1','B2']] 25 | * @param {Node} options.node Dom node to create the table into 26 | * @param {Node} options.onChange Callback to run whenever the data 27 | * has changed, receives the new data as an argument. 28 | *@param {Number} options.minRows Minimum number of rows. 29 | *@param {Number} options.maxRows Maximum number of rows, the table will not grow vertically beyond this. 30 | *@param {String} options.css Css code to add inside the iframe. 31 | *@param {String} options.columns Array of columns definition 32 | * 33 | *@param {Object} options.width Width of the iframe that will contain the table. 34 | *@param {Object} options.height Height of the iframe that will contain the table. 35 | * 36 | */ 37 | 38 | export default class Importabular { 39 | constructor(options) { 40 | this._saveConstructorOptions(options); 41 | this._setupDom(); 42 | this._replaceDataWithArray(options.data); 43 | this._incrementToFit({ 44 | x: this.columns.length - 1, 45 | y: this._options.minRows - 1, 46 | }); 47 | this._fillScrollSpace(); 48 | } 49 | _runChecks(data) { 50 | const { titles, classNames } = this.checks(data); 51 | 52 | this.checkResults = { 53 | titles, 54 | classNames, 55 | }; 56 | } 57 | _saveConstructorOptions({ 58 | data = [], 59 | node = null, 60 | onChange = null, 61 | minRows = 1, 62 | maxRows = Infinity, 63 | css = "", 64 | width = "100%", 65 | height = "80vh", 66 | columns, 67 | checks, 68 | }) { 69 | this.columns = columns; 70 | this.checks = checks || (() => ({})); 71 | this._runChecks(data); 72 | 73 | if (!node) { 74 | throw new Error( 75 | "You need to pass a node argument to Importabular, like this : new Importabular({node: document.body})" 76 | ); 77 | } 78 | // Reference to the parent DOM element, contains the iframe 79 | this._parent = node; 80 | this._options = { 81 | onChange, 82 | minRows, 83 | maxRows, 84 | css: _defaultCss + css, 85 | }; 86 | this._iframeStyle = { 87 | width, 88 | height, 89 | border: "none", 90 | background: "transparent", 91 | }; 92 | } 93 | 94 | /** @private {Number} Current number of columns of the table. */ 95 | _width = 1; 96 | 97 | /** @private {Number} Current number of rows of the table. */ 98 | _height = 1; 99 | 100 | /** @private {_LooseArray} Current content of the table, stored as 2D map.*/ 101 | _data = new _LooseArray(); 102 | 103 | /** @private Checks whether this cell should be editable, or if it's out of bounds*/ 104 | _fitBounds({ x, y }) { 105 | return ( 106 | x >= 0 && x < this.columns.length && y >= 0 && y < this._options.maxRows 107 | ); 108 | } 109 | 110 | /** @private Fill the iframe visible window with empty cells*/ 111 | _fillScrollSpace() { 112 | const rows = Math.ceil(this.iframe.contentWindow.innerHeight / 40); 113 | const cols = Math.ceil(this.iframe.contentWindow.innerWidth / 100); 114 | this._incrementToFit({ x: cols - 1, y: rows - 1 }); 115 | } 116 | 117 | /**Returns the current data as a 2D array 118 | * @return {[[String]]} data the latest data as a 2D array. 119 | * 120 | * */ 121 | getData() { 122 | return this._data._toArr(this._width, this._height); 123 | } 124 | /** @private Runs the onchange callback*/ 125 | _onDataChanged() { 126 | const asArr = this.getData(); 127 | if (this._options.onChange) this._options.onChange(asArr); 128 | this._runChecks(asArr); 129 | this._restyleAll(); 130 | } 131 | 132 | /** @private Create a div with the cell content and correct style */ 133 | _renderTDContent(td, x, y) { 134 | const div = document.createElement("div"); 135 | td.setAttribute("x", x.toString()); 136 | td.setAttribute("y", y.toString()); 137 | const val = this._divContent(x, y); 138 | if (val) { 139 | div.textContent = val; 140 | } else { 141 | // Force no collapse of cell 142 | div.innerHTML = " "; 143 | } 144 | td.appendChild(div); 145 | this._restyle({ x, y }); 146 | } 147 | 148 | _divContent(x, y) { 149 | return this._getVal(x, y) || this.columns[x].placeholder; 150 | } 151 | 152 | _setupDom() { 153 | // We wrap the table in an iframe mostly to let the browser 154 | // handle the focus for us, without the need for a hidden 155 | // input. It also makes sure that the style of the table is "clean" 156 | // but makes it harder to style the content. 157 | const iframe = document.createElement("iframe"); 158 | this.iframe = iframe; 159 | this._parent.appendChild(iframe); 160 | const cwd = iframe.contentWindow.document; 161 | this.cwd = cwd; 162 | cwd.open(); 163 | cwd.write( 164 | `` 165 | ); 166 | cwd.close(); 167 | Object.assign(iframe.style, this._iframeStyle); 168 | 169 | const table = document.createElement("table"); 170 | const tbody = document.createElement("tbody"); 171 | 172 | const thead = document.createElement("THEAD"); 173 | const tr = document.createElement("TR"); 174 | thead.appendChild(tr); 175 | this.columns.forEach((col) => { 176 | const th = document.createElement("TH"); 177 | const div = document.createElement("div"); 178 | div.innerHTML = col.label; 179 | col.title && th.setAttribute("title", col.title); 180 | th.appendChild(div); 181 | tr.appendChild(th); 182 | }); 183 | table.appendChild(thead); 184 | table.appendChild(tbody); 185 | cwd.body.appendChild(table); 186 | this.tbody = tbody; 187 | this.table = table; 188 | 189 | for (let y = 0; y < this._height; y++) { 190 | const tr = document.createElement("tr"); 191 | tbody.appendChild(tr); 192 | for (let x = 0; x < this._width; x++) { 193 | this._addCell(tr, x, y); 194 | } 195 | } 196 | 197 | _events.forEach((name) => cwd.addEventListener(name, this[name], true)); 198 | } 199 | 200 | /** Destroys the table, and clears even listeners 201 | * @public 202 | * */ 203 | destroy() { 204 | this._destroyEditing(); 205 | 206 | _events.forEach((name) => 207 | this.cwd.removeEventListener(name, this[name], true) 208 | ); 209 | 210 | this.iframe.parentElement.removeChild(this.iframe); 211 | } 212 | 213 | /** @private Creates a TD, sets its content and adds it to the TR */ 214 | _addCell(tr, x, y) { 215 | const td = document.createElement("td"); 216 | tr.appendChild(td); 217 | this._renderTDContent(td, x, y); 218 | } 219 | 220 | _incrementHeight() { 221 | if (!this._fitBounds({ x: 0, y: this._height })) return false; 222 | this._height++; 223 | const y = this._height - 1; 224 | const tr = document.createElement("tr"); 225 | 226 | this.tbody.appendChild(tr); 227 | for (let x = 0; x < this._width; x++) { 228 | this._addCell(tr, x, y); 229 | } 230 | return true; 231 | } 232 | 233 | _incrementWidth() { 234 | if (!this._fitBounds({ x: this._width, y: 0 })) return false; 235 | this._width++; 236 | const x = this._width - 1; 237 | Array.prototype.forEach.call(this.tbody.children, (tr, y) => { 238 | this._addCell(tr, x, y); 239 | }); 240 | return true; 241 | } 242 | /** @private Makes the table bigger to contain a cell for those coordinates*/ 243 | _incrementToFit({ x, y }) { 244 | while (x > this._width - 1 && this._incrementWidth()); 245 | while (y > this._height - 1 && this._incrementHeight()); 246 | } 247 | 248 | /** @private Handles the paste event on the node.*/ 249 | paste = (e) => { 250 | if (this._editing) return; 251 | e.preventDefault(); 252 | const rows = parseArrayString( 253 | (e.clipboardData || window.clipboardData).getData("text/plain") 254 | ); 255 | const { rx, ry } = this._selection; 256 | const offset = { x: rx[0], y: ry[0] }; 257 | 258 | for (let y = 0; y < rows.length; y++) 259 | // Using the first column here makes sure that 260 | // if the paste data had various row length, we only 261 | // paste a clean rectangle 262 | for (let x = 0; x < rows[0].length; x++) 263 | this._setVal(offset.x + x, offset.y + y, rows[y][x]); 264 | 265 | this._changeSelectedCellsStyle(() => { 266 | this._selectionStart = offset; 267 | this._selectionEnd = { 268 | x: offset.x + rows[0].length - 1, 269 | y: offset.y + rows.length - 1, 270 | }; 271 | // THis needs to run before rerender 272 | this._onDataChanged(); 273 | }); 274 | }; 275 | 276 | /** @private Returns the currently selected cells as a 2D array of strings.*/ 277 | _getSelectionAsArray() { 278 | const { rx, ry } = this._selection; 279 | if (rx[0] === rx[1]) return null; 280 | const width = rx[1] - rx[0]; 281 | const height = ry[1] - ry[0]; 282 | const result = []; 283 | for (let y = 0; y < height; y++) { 284 | result.push([]); 285 | for (let x = 0; x < width; x++) { 286 | result[y].push(this._getVal(rx[0] + x, ry[0] + y)); 287 | } 288 | } 289 | return result; 290 | } 291 | 292 | /** @private Called when the copy even happens in the iframe.*/ 293 | copy = (e) => { 294 | if (this._editing) return; 295 | const asArr = this._getSelectionAsArray(); 296 | if (asArr) { 297 | e.preventDefault(); 298 | e.clipboardData.setData("text/plain", stringifyArray(asArr)); 299 | } 300 | }; 301 | 302 | /** @private Called when the cut even happens in the iframe. 303 | * Runs the copy method and then clears the cells. 304 | * */ 305 | cut = (e) => { 306 | if (this._editing) return; 307 | this.copy(e); 308 | this._setAllSelectedCellsTo(""); 309 | }; 310 | 311 | keydown = (e) => { 312 | if (e.ctrlKey || e.metaKey) return; 313 | 314 | if (this._selectionStart) { 315 | if (e.key === "Escape" && this._editing) { 316 | e.preventDefault(); 317 | this._revertEdit(); 318 | this._stopEditing(); 319 | } 320 | if (e.key === "Enter") { 321 | e.preventDefault(); 322 | this._tabCursorInSelection(false, e.shiftKey ? -1 : 1); 323 | } 324 | 325 | if (e.key === "Tab") { 326 | e.preventDefault(); 327 | this._tabCursorInSelection(true, e.shiftKey ? -1 : 1); 328 | } 329 | if (!this._editing) { 330 | if (e.key === "F2") { 331 | e.preventDefault(); 332 | this._startEditing(this._focus); 333 | } 334 | if (e.key === "Delete" || e.key === "Backspace") { 335 | e.preventDefault(); 336 | this._setAllSelectedCellsTo(""); 337 | } 338 | if (e.key === "ArrowDown") { 339 | e.preventDefault(); 340 | this._moveCursor({ y: 1 }, e.shiftKey); 341 | } 342 | 343 | if (e.key === "ArrowUp") { 344 | e.preventDefault(); 345 | this._moveCursor({ y: -1 }, e.shiftKey); 346 | } 347 | if (e.key === "ArrowLeft") { 348 | e.preventDefault(); 349 | this._moveCursor({ x: -1 }, e.shiftKey); 350 | } 351 | if (e.key === "ArrowRight") { 352 | e.preventDefault(); 353 | this._moveCursor({ x: +1 }, e.shiftKey); 354 | } 355 | } 356 | 357 | if (e.key.length === 1 && !this._editing) { 358 | this._changeSelectedCellsStyle(() => { 359 | const { x, y } = this._focus; 360 | // We clear the value of the cell, and the keyup event will 361 | // happen with the cursor inside the cell and type the character there 362 | this._startEditing({ x, y }); 363 | this._getCell(x, y).firstChild.value = ""; 364 | }); 365 | } 366 | } 367 | }; 368 | 369 | _setAllSelectedCellsTo(value) { 370 | this._forSelectionCoord(this._selection, ({ x, y }) => 371 | this._setVal(x, y, value) 372 | ); 373 | this._onDataChanged(); 374 | this._forSelectionCoord(this._selection, this._refreshDisplayedValue); 375 | } 376 | 377 | _moveCursor({ x = 0, y = 0 }, shiftSelectionEnd) { 378 | const curr = shiftSelectionEnd ? this._selectionEnd : this._selectionStart; 379 | const nc = { x: curr.x + x, y: curr.y + y }; 380 | if (!this._fitBounds(nc)) return; 381 | this._stopEditing(); 382 | this._incrementToFit(nc); 383 | this._changeSelectedCellsStyle(() => { 384 | if (shiftSelectionEnd) { 385 | this._selectionEnd = nc; 386 | } else { 387 | this._selectionStart = this._selectionEnd = this._focus = nc; 388 | } 389 | }); 390 | this._scrollIntoView(nc); 391 | } 392 | 393 | _tabCursorInSelection(horizontal, delta = 1) { 394 | // if (this._selectionSize() <= 1) { 395 | // return this._moveCursor(horizontal? { x: delta, y: 0 }:{ x:0, y: delta }); 396 | // } 397 | let { x, y } = this._focus || { x: 0, y: 0 }; 398 | const selectionSize = this._selectionSize(); 399 | const { rx, ry } = 400 | selectionSize > 1 401 | ? this._selection 402 | : { 403 | rx: [0, this.columns.length], 404 | ry: [0, this._options.maxRows], 405 | }; 406 | 407 | let nc; 408 | if (horizontal) { 409 | nc = _shift(x, y, delta, rx[0], rx[1] - 1, ry[0], ry[1] - 1); 410 | } else { 411 | // use the same logic, but with x and y inverted for the horizontal tabbing wiht enter/ shift enter 412 | const temporaryCursor = _shift( 413 | y, 414 | x, 415 | delta, 416 | ry[0], 417 | ry[1] - 1, 418 | rx[0], 419 | rx[1] - 1 420 | ); 421 | nc = { 422 | x: temporaryCursor.y, 423 | y: temporaryCursor.x, 424 | }; 425 | } 426 | 427 | if (!this._fitBounds(nc)) return; 428 | this._stopEditing(); 429 | this._incrementToFit(nc); 430 | this._changeSelectedCellsStyle(() => { 431 | this._focus = nc; 432 | if (selectionSize <= 1) { 433 | this._selectionStart = this._selectionEnd = nc; 434 | } 435 | }); 436 | this._scrollIntoView(nc); 437 | } 438 | 439 | _scrollIntoView({ x, y }) { 440 | this._getCell(x, y).scrollIntoView({ 441 | behavior: "smooth", 442 | block: "nearest", 443 | }); 444 | } 445 | 446 | _selecting = false; 447 | _selectionStart = null; 448 | _selectionEnd = null; 449 | _selection = { rx: [0, 0], ry: [0, 0] }; 450 | _editing = null; 451 | _focus = null; 452 | 453 | mousedown = (e) => { 454 | if (this.mobile) return; 455 | if (e.which === 3 && !this._editing && this._selectionSize()) { 456 | // select some text to make the browser offer the copy option 457 | let range = new Range(); 458 | const { rx, ry } = this._selection; 459 | range.setStart(this._getCell(rx[0], ry[0]), 0); 460 | range.setEnd(this._getCell(rx[0], ry[0]), 1); 461 | this.cwd.getSelection().removeAllRanges(); 462 | this.cwd.getSelection().addRange(range); 463 | 464 | return; 465 | } 466 | this._changeSelectedCellsStyle(() => { 467 | this.tbody.style.userSelect = "none"; 468 | this._selectionEnd = this._selectionStart = this._focus = this._getCoords( 469 | e 470 | ); 471 | this._selecting = true; 472 | }); 473 | }; 474 | mouseenter = (e) => { 475 | if (this.mobile) return; 476 | if (this._selecting) { 477 | this._changeSelectedCellsStyle(() => { 478 | this._selectionEnd = this._getCoords(e); 479 | }); 480 | } 481 | }; 482 | 483 | _lastMouseUp = null; 484 | _lastMouseUpTarget = null; 485 | 486 | _endSelection() { 487 | this._selecting = false; 488 | this.tbody.style.userSelect = ""; 489 | } 490 | 491 | mouseup = (e) => { 492 | if (this.mobile) return; 493 | if (e.which === 3) return; 494 | 495 | if (this._selecting) { 496 | this._changeSelectedCellsStyle(() => { 497 | this._selectionEnd = this._getCoords(e); 498 | this._endSelection(); 499 | 500 | if ( 501 | this._lastMouseUp && 502 | this._lastMouseUp > Date.now() - 300 && 503 | this._lastMouseUpTarget.x === this._selectionEnd.x && 504 | this._lastMouseUpTarget.y === this._selectionEnd.y 505 | ) { 506 | this._startEditing(this._selectionEnd); 507 | } 508 | this._lastMouseUp = Date.now(); 509 | this._lastMouseUpTarget = this._selectionEnd; 510 | }); 511 | } 512 | }; 513 | mouseleave = (e) => { 514 | if (e.target === this.tbody && this._selecting) { 515 | this._endSelection(); 516 | } 517 | }; 518 | 519 | touchstart = (e) => { 520 | if (this._editing) return; 521 | this.mobile = true; 522 | this.moved = false; 523 | }; 524 | touchend = (e) => { 525 | if (!this.mobile) return; 526 | if (this._editing) return; 527 | if (!this.moved) { 528 | this._changeSelectedCellsStyle(() => { 529 | this._selectionEnd = this._selectionStart = this._focus = this._getCoords( 530 | e 531 | ); 532 | }); 533 | this._startEditing(this._focus); 534 | } 535 | }; 536 | touchmove = (e) => { 537 | if (!this.mobile) return; 538 | this.moved = true; 539 | }; 540 | 541 | _startEditing({ x, y }) { 542 | this._editing = { x, y }; 543 | const td = this._getCell(x, y); 544 | 545 | // Measure the current content 546 | const tdSize = td.getBoundingClientRect(); 547 | const cellSize = td.firstChild.getBoundingClientRect(); 548 | 549 | // remove the current content 550 | td.removeChild(td.firstChild); 551 | 552 | // add the input 553 | const input = document.createElement("input"); 554 | input.type = "text"; 555 | input.value = this._getVal(x, y); 556 | td.appendChild(input); 557 | 558 | // Make the new content fit the past size 559 | Object.assign(td.style, { 560 | width: tdSize.width - 2, 561 | height: tdSize.height, 562 | }); 563 | 564 | Object.assign(input.style, { 565 | width: `${cellSize.width}px`, 566 | height: `${cellSize.height}px`, 567 | }); 568 | 569 | input.focus(); 570 | input.addEventListener("blur", this._stopEditing); 571 | input.addEventListener("keydown", this._blurIfEnter); 572 | } 573 | 574 | _destroyEditing() { 575 | if (this._editing) { 576 | const { x, y } = this._editing; 577 | const input = this._getCell(x, y).firstChild; 578 | input.removeEventListener("blur", this._stopEditing); 579 | input.removeEventListener("keydown", this._blurIfEnter); 580 | } 581 | } 582 | 583 | _revertEdit() { 584 | if (!this._editing) return; 585 | const { x, y } = this._editing; 586 | const td = this._getCell(x, y); 587 | const input = td.firstChild; 588 | input.value = this._getVal(x, y); 589 | } 590 | 591 | _stopEditing = () => { 592 | if (!this._editing) return; 593 | const { x, y } = this._editing; 594 | const td = this._getCell(x, y); 595 | td.style.width = ""; 596 | td.style.height = ""; 597 | const input = td.firstChild; 598 | input.removeEventListener("blur", this._stopEditing); 599 | input.removeEventListener("keydown", this._blurIfEnter); 600 | this._setVal(x, y, input.value); 601 | this._onDataChanged(); 602 | td.removeChild(input); 603 | this._editing = null; 604 | this._renderTDContent(td, x, y); 605 | }; 606 | _blurIfEnter = (e) => { 607 | const code = e.keyCode; 608 | if (code === 13) { 609 | // enter 610 | this._stopEditing(); 611 | e.preventDefault(); 612 | } 613 | }; 614 | 615 | _changeSelectedCellsStyle(callback) { 616 | const oldS = this._selection; 617 | callback(); 618 | this._selection = this._getSelectionCoords(); 619 | this._forSelectionCoord(oldS, this._restyle); 620 | this._forSelectionCoord(this._selection, this._restyle); 621 | } 622 | 623 | _getSelectionCoords() { 624 | if (!this._selectionStart) return { rx: [0, 0], ry: [0, 0] }; 625 | let rx = [this._selectionStart.x, this._selectionEnd.x]; 626 | if (rx[0] > rx[1]) rx.reverse(); 627 | let ry = [this._selectionStart.y, this._selectionEnd.y]; 628 | if (ry[0] > ry[1]) ry.reverse(); 629 | return { rx: [rx[0], rx[1] + 1], ry: [ry[0], ry[1] + 1] }; 630 | } 631 | 632 | _forSelectionCoord({ rx, ry }, cb) { 633 | for (let x = rx[0]; x < rx[1]; x++) 634 | for (let y = ry[0]; y < ry[1]; y++) 635 | if (this._fitBounds({ x, y })) cb({ x, y }); 636 | } 637 | 638 | _restyle = ({ x, y }) => { 639 | const td = this._getCell(x, y); 640 | td.className = this._classNames(x, y); 641 | 642 | const title = _fromArr(this.checkResults.titles, x, y); 643 | if (title) td.setAttribute("title", title); 644 | else td.removeAttribute("title"); 645 | }; 646 | 647 | _restyleAll() { 648 | for (var x = 0; x < this._width; x++) 649 | for (var y = 0; y < this._height; y++) this._restyle({ x, y }); 650 | } 651 | 652 | _selectionSize() { 653 | const { rx, ry } = this._selection; 654 | return (rx[1] - rx[0]) * (ry[1] - ry[0]); 655 | } 656 | 657 | _classNames(x, y) { 658 | const { rx, ry } = this._selection; 659 | let classes = ""; 660 | if (x >= rx[0] && x < rx[1] && y >= ry[0] && y < ry[1]) { 661 | classes += " selected"; 662 | if (this._selectionSize() > 1) { 663 | classes += " multi"; 664 | } 665 | } 666 | 667 | if (this._focus && this._focus.x === x && this._focus.y === y) { 668 | classes += " focus"; 669 | } 670 | if (this._editing && x === this._editing.x && y === this._editing.y) { 671 | classes += " editing"; 672 | } 673 | 674 | if (!this._getVal(x, y)) classes += " placeholder"; 675 | classes += " " + _fromArr(this.checkResults.classNames, x, y); 676 | 677 | return classes; 678 | } 679 | 680 | _refreshDisplayedValue = ({ x, y }) => { 681 | const div = this._getCell(x, y).firstChild; 682 | if (div.tagName === "DIV") { 683 | div.textContent = this._divContent(x, y); 684 | } 685 | this._restyle({ x, y }); 686 | }; 687 | 688 | _getCoords(e) { 689 | // Returns the clicked cell coords or null 690 | let node = e.target; 691 | while (!node.getAttribute("x") && node.parentElement) { 692 | node = node.parentElement; 693 | } 694 | return { 695 | x: parseInt(node.getAttribute("x")) || 0, 696 | y: parseInt(node.getAttribute("y")) || 0, 697 | }; 698 | } 699 | 700 | /** Replace the current data with the provided 2D array. 701 | * @param {[[String]]} data the new data, as a 2D array. 702 | * */ 703 | setData(data) { 704 | // Empty table 705 | this._data._clear(); 706 | // paste data 707 | this._replaceDataWithArray(data); 708 | 709 | // Refresh all cell, including outide of the 710 | // provided rect, as they've juste been emptied 711 | for (let x = 0; x < this._width; x++) 712 | for (let y = 0; y < this._height; y++) 713 | this._refreshDisplayedValue({ x, y }); 714 | } 715 | 716 | _replaceDataWithArray(data = [[]]) { 717 | data.forEach((line, y) => { 718 | line.forEach((val, x) => { 719 | this._setVal(x, y, val); 720 | }); 721 | }); 722 | } 723 | _setVal(x, y, val) { 724 | if (!this._fitBounds({ x, y })) return; 725 | 726 | this._data._setVal(x, y, val); 727 | this._incrementToFit({ x: x + 1, y: y + 1 }); 728 | this._refreshDisplayedValue({ x, y }); 729 | } 730 | 731 | _getVal(x, y) { 732 | return this._data._getVal(x, y); 733 | } 734 | 735 | _getCell(x, y) { 736 | return this.tbody.children[y].children[x]; 737 | } 738 | } 739 | 740 | function _fromArr(arr, x, y) { 741 | return (arr && arr[y] && arr[y][x]) || ""; 742 | } 743 | -------------------------------------------------------------------------------- /src/sheetclip.js: -------------------------------------------------------------------------------- 1 | // from https://github.com/warpech/sheetclip/blob/master/sheetclip.js 2 | 3 | function countQuotes(str) { 4 | return str.split('"').length - 1; 5 | } 6 | 7 | export function parseArrayString(str) { 8 | var r, 9 | rlen, 10 | rows, 11 | arr = [], 12 | a = 0, 13 | c, 14 | clen, 15 | multiline, 16 | last; 17 | rows = str.split("\n"); 18 | if (rows.length > 1 && rows[rows.length - 1] === "") { 19 | rows.pop(); 20 | } 21 | for (r = 0, rlen = rows.length; r < rlen; r += 1) { 22 | rows[r] = rows[r].split("\t"); 23 | for (c = 0, clen = rows[r].length; c < clen; c += 1) { 24 | if (!arr[a]) { 25 | arr[a] = []; 26 | } 27 | if (multiline && c === 0) { 28 | last = arr[a].length - 1; 29 | arr[a][last] = arr[a][last] + "\n" + rows[r][0]; 30 | if (multiline && countQuotes(rows[r][0]) & 1) { 31 | //& 1 is a bitwise way of performing mod 2 32 | multiline = false; 33 | arr[a][last] = arr[a][last] 34 | .substring(0, arr[a][last].length - 1) 35 | .replace(/""/g, '"'); 36 | } 37 | } else { 38 | if ( 39 | c === clen - 1 && 40 | rows[r][c].indexOf('"') === 0 && 41 | countQuotes(rows[r][c]) & 1 42 | ) { 43 | arr[a].push(rows[r][c].substring(1).replace(/""/g, '"')); 44 | multiline = true; 45 | } else { 46 | arr[a].push(rows[r][c].replace(/""/g, '"')); 47 | multiline = false; 48 | } 49 | } 50 | } 51 | if (!multiline) { 52 | a += 1; 53 | } 54 | } 55 | return arr; 56 | } 57 | 58 | export function stringifyArray(arr) { 59 | var r, 60 | rlen, 61 | c, 62 | clen, 63 | str = "", 64 | val; 65 | for (r = 0, rlen = arr.length; r < rlen; r += 1) { 66 | for (c = 0, clen = arr[r].length; c < clen; c += 1) { 67 | if (c > 0) { 68 | str += "\t"; 69 | } 70 | val = arr[r][c]; 71 | if (typeof val === "string") { 72 | if (val.indexOf("\n") > -1) { 73 | str += '"' + val.replace(/"/g, '""') + '"'; 74 | } else { 75 | str += val; 76 | } 77 | } else if (val === null || val === void 0) { 78 | //void 0 resolves to undefined 79 | str += ""; 80 | } else { 81 | str += val; 82 | } 83 | } 84 | str += "\n"; 85 | } 86 | return str; 87 | } 88 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | var MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 5 | const isProd = process.env.NODE_ENV === 'production' 6 | const HTMLInlineCSSWebpackPlugin = require("html-inline-css-webpack-plugin").default; 7 | const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin'); 8 | 9 | 10 | 11 | const CopyPlugin = require("copy-webpack-plugin"); 12 | 13 | const common={ 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.css$/i, 18 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 19 | }, 20 | { 21 | test: /\.js$/, 22 | exclude: /node_modules/, 23 | use: { 24 | loader: 'babel-loader', 25 | options: { 26 | babelrc: true 27 | } 28 | } 29 | } 30 | ], 31 | }, 32 | optimization: { 33 | minimize: isProd, 34 | }, 35 | stats: 'errors-only', 36 | } 37 | 38 | const docExport= { 39 | entry: './src/demo/demo.js', 40 | output: { 41 | filename: 'main.js', 42 | path: path.resolve(__dirname, 'docs'), 43 | }, 44 | devServer: { 45 | contentBase: './docs', 46 | port: 1234, 47 | open: true, 48 | disableHostCheck: true, 49 | }, 50 | plugins: [ 51 | new CleanWebpackPlugin(), 52 | new MiniCssExtractPlugin(), 53 | new HtmlWebpackPlugin({ 54 | template:'src/demo/index.ejs', 55 | 56 | }), 57 | new HTMLInlineCSSWebpackPlugin(), 58 | new HtmlInlineScriptPlugin(), 59 | new CopyPlugin({ 60 | patterns: [ 61 | { from: "src/demo/public", to: "." } 62 | ], 63 | }), 64 | ], 65 | ...common 66 | } 67 | 68 | function libExport({filename, mangle}) { 69 | return { 70 | ...common, 71 | entry:'./src/index.js', 72 | output: { 73 | filename: 'index.js', 74 | path: path.resolve(__dirname, 'dist'), 75 | libraryExport: "default" , 76 | libraryTarget: 'umd', 77 | library: 'Importabular', 78 | }, 79 | plugins: [ 80 | new CleanWebpackPlugin(), 81 | ], 82 | } 83 | } 84 | module.exports = [ 85 | docExport, 86 | isProd && libExport({ 87 | filename:"", 88 | mangle:{} 89 | }) 90 | ].filter(i=>i) --------------------------------------------------------------------------------