From ee61ed62a100ed41632084550bc276b2ba499df7 Mon Sep 17 00:00:00 2001 From: chieveit Date: Tue, 2 Jun 2020 07:56:14 +0000 Subject: [PATCH 1/9] Improve formula editing process within cells --- src/component/editor.js | 191 ++++++++++++++----------------- src/component/formula.js | 240 +++++++++++++++++++++++++++++++++++++++ src/component/sheet.js | 13 ++- src/core/caret.js | 44 +++++++ src/core/data_proxy.js | 12 +- src/index.less | 9 +- 6 files changed, 394 insertions(+), 115 deletions(-) create mode 100644 src/component/formula.js create mode 100644 src/core/caret.js diff --git a/src/component/editor.js b/src/component/editor.js index 616824fc..c80dc98d 100644 --- a/src/component/editor.js +++ b/src/component/editor.js @@ -4,45 +4,17 @@ import Suggest from './suggest'; import Datepicker from './datepicker'; import { cssPrefix } from '../config'; // import { mouseMoveUp } from '../event'; - -function resetTextareaSize() { - const { inputText } = this; - if (!/^\s*$/.test(inputText)) { - const { - textlineEl, textEl, areaOffset, - } = this; - const txts = inputText.split('\n'); - const maxTxtSize = Math.max(...txts.map(it => it.length)); - const tlOffset = textlineEl.offset(); - const fontWidth = tlOffset.width / inputText.length; - const tlineWidth = (maxTxtSize + 1) * fontWidth + 5; - const maxWidth = this.viewFn().width - areaOffset.left - fontWidth; - let h1 = txts.length; - if (tlineWidth > areaOffset.width) { - let twidth = tlineWidth; - if (tlineWidth > maxWidth) { - twidth = maxWidth; - h1 += parseInt(tlineWidth / maxWidth, 10); - h1 += (tlineWidth % maxWidth) > 0 ? 1 : 0; - } - textEl.css('width', `${twidth}px`); - } - h1 *= this.rowHeight; - if (h1 > areaOffset.height) { - textEl.css('height', `${h1}px`); - } - } -} +import Formula from './formula'; +import { setCaretPosition, saveCaretPosition } from '../core/caret'; function insertText({ target }, itxt) { const { value, selectionEnd } = target; const ntxt = `${value.slice(0, selectionEnd)}${itxt}${value.slice(selectionEnd)}`; target.value = ntxt; - target.setSelectionRange(selectionEnd + 1, selectionEnd + 1); - this.inputText = ntxt; - this.textlineEl.html(ntxt); - resetTextareaSize.call(this); + this.render(); + + setCaretPosition(target, selectionEnd + 1); } function keydownEventHandler(evt) { @@ -55,72 +27,36 @@ function keydownEventHandler(evt) { if (keyCode === 13 && !altKey) evt.preventDefault(); } -function inputEventHandler(evt) { - const v = evt.target.value; +function inputEventHandler() { + // save caret position + const restore = saveCaretPosition(this.textEl.el); + + const text = this.textEl.el.textContent; + this.inputText = text; // console.log(evt, 'v:', v); - const { suggest, textlineEl, validator } = this; - const { cell } = this; - if (cell !== null) { - if (('editable' in cell && cell.editable === true) || (cell.editable === undefined)) { - this.inputText = v; - if (validator) { - if (validator.type === 'list') { - suggest.search(v); - } else { - suggest.hide(); - } - } else { - const start = v.lastIndexOf('='); - if (start !== -1) { - suggest.search(v.substring(start + 1)); - } else { - suggest.hide(); - } - } - textlineEl.html(v); - resetTextareaSize.call(this); - this.change('input', v); + + const { suggest, validator } = this; + + if (validator) { + if (validator.type === 'list') { + suggest.search(text); } else { - evt.target.value = cell.text; + suggest.hide(); } } else { - this.inputText = v; - if (validator) { - if (validator.type === 'list') { - suggest.search(v); - } else { - suggest.hide(); - } + const start = text.lastIndexOf('='); + if (start !== -1) { + suggest.search(text.substring(start + 1)); } else { - const start = v.lastIndexOf('='); - if (start !== -1) { - suggest.search(v.substring(start + 1)); - } else { - suggest.hide(); - } + suggest.hide(); } - textlineEl.html(v); - resetTextareaSize.call(this); - this.change('input', v); } -} + this.render(); + this.change('input', text); -function setTextareaRange(position) { - const { el } = this.textEl; - setTimeout(() => { - el.focus(); - el.setSelectionRange(position, position); - }, 0); -} - -function setText(text, position) { - const { textEl, textlineEl } = this; - // firefox bug - textEl.el.blur(); - - textEl.val(text); - textlineEl.html(text); - setTextareaRange.call(this, position); + // restore caret postion + // to avoid caret postion missing when this.el.innerHTML changed + restore(); } function suggestItemClick(it) { @@ -143,7 +79,8 @@ function suggestItemClick(it) { position = this.inputText.length; this.inputText += `)${eit}`; } - setText.call(this, this.inputText, position); + this.render(); + setCaretPosition(this.textEl.el, position); } function resetSuggestItems() { @@ -159,9 +96,10 @@ function dateFormat(d) { } export default class Editor { - constructor(formulas, viewFn, rowHeight) { + constructor(formulas, viewFn, data) { + this.data = data; this.viewFn = viewFn; - this.rowHeight = rowHeight; + this.rowHeight = data.rows.height; this.formulas = formulas; this.suggest = new Suggest(formulas, (it) => { suggestItemClick.call(this, it); @@ -172,27 +110,34 @@ export default class Editor { this.setText(dateFormat(d)); this.clear(); }); + this.composing = false; this.areaEl = h('div', `${cssPrefix}-editor-area`) .children( - this.textEl = h('textarea', '') + this.textEl = h('div', 'textarea') + .attr('contenteditable', 'true') .on('input', evt => inputEventHandler.call(this, evt)) - .on('paste.stop', () => {}) - .on('keydown', evt => keydownEventHandler.call(this, evt)), + .on('paste.stop', () => { }) + .on('keydown', evt => keydownEventHandler.call(this, evt)) + .on('compositionstart.stop', () => this.composing = true) + .on('compositionend.stop', () => this.composing = false), this.textlineEl = h('div', 'textline'), this.suggest.el, this.datepicker.el, ) - .on('mousemove.stop', () => {}) - .on('mousedown.stop', () => {}); + .on('mousemove.stop', () => { }) + .on('mousedown.stop', () => { }); this.el = h('div', `${cssPrefix}-editor`) - .child(this.areaEl).hide(); + .children(this.areaEl).hide(); + this.cellEl = h('div', `${cssPrefix}-formula-cell`) this.suggest.bindInputEvents(this.textEl); this.areaOffset = null; this.freeze = { w: 0, h: 0 }; this.cell = null; this.inputText = ''; - this.change = () => {}; + this.change = () => { }; + + this.formula = new Formula(this); } setFreezeLengths(width, height) { @@ -212,13 +157,19 @@ export default class Editor { this.el.hide(); this.textEl.val(''); this.textlineEl.html(''); + this.formula.clear(); resetSuggestItems.call(this); this.datepicker.hide(); } + resetData(data) { + this.data = data; + this.rowHeight = data.rows.height; + } + setOffset(offset, suggestPosition = 'top') { const { - textEl, areaEl, suggest, freeze, el, + textEl, areaEl, suggest, freeze, el, formula } = this; if (offset) { this.areaOffset = offset; @@ -240,11 +191,13 @@ export default class Editor { } el.offset(elOffset); areaEl.offset({ left: left - elOffset.left - 0.8, top: top - elOffset.top - 0.8 }); - textEl.offset({ width: width - 9 + 0.8, height: height - 3 + 0.8 }); + textEl.css('min-width', `${width - 9 + 0.8}px`); + textEl.css('min-height', `${height - 3 + 0.8}px`); const sOffset = { left: 0 }; sOffset[suggestPosition] = height; suggest.setOffset(sOffset); suggest.hide(); + formula.renderCells(); } } @@ -275,7 +228,35 @@ export default class Editor { setText(text) { this.inputText = text; // console.log('text>>:', text); - setText.call(this, text, text.length); - resetTextareaSize.call(this); + + // firefox bug + this.textEl.el.blur(); + + this.render(); + setTimeout(() => { + setCaretPosition(this.textEl.el, text.length); + }) + } + + render() { + if (this.composing) return; + + const text = this.inputText; + + if (text[0] != '=') { + this.textEl.html(text); + } else { + this.formula.render(); + } + + this.textlineEl.html(text); + } + + formulaCellSelecting() { + return Boolean(this.formula.cell); + } + + formulaSelectCell(ri, ci) { + this.formula.selectCell(ri, ci); } } diff --git a/src/component/formula.js b/src/component/formula.js new file mode 100644 index 00000000..ddeee14e --- /dev/null +++ b/src/component/formula.js @@ -0,0 +1,240 @@ +import { stringAt, expr2xy } from '../core/alphabet'; +import { setCaretPosition, getCaretPosition } from '../core/caret'; +import CellRange from '../core/cell_range'; + +function renderCell(left, top, width, height, color, selected = false) { + let style = `position:absolute;box-sizing: border-box;`; + style += `left:${left}px;`; + style += `top:${top}px;`; + style += `width:${width}px;`; + style += `height:${height}px;`; + style += `border:${color} 2px dashed;`; + if (selected) { + style += `background:rgba(101, 101, 101, 0.1);`; + } + return `
`; +} + +export default class Formula { + constructor(editor) { + this.editor = editor; + this.el = this.editor.textEl.el; + this.cellEl = this.editor.cellEl.el; + + this.cells = []; + this.cell = null; + document.addEventListener("selectionchange", () => { + if (document.activeElement !== this.el) return; + + this.cell = null; + if (this.editor.inputText[0] != '=') return; + + const index = getCaretPosition(this.el); + for (let cell of this.cells) { + const { from, to } = cell; + if (from <= index && index <= to) { + this.cell = cell; + break; + } + } + + this.renderCells(); + }); + + this.el.addEventListener("keydown", (e) => { + const keyCode = e.keyCode || e.which; + if ([37, 38, 39, 40].indexOf(keyCode) == -1) return; + + if (!this.cell || this.cell.from == this.cell.to) return; + + e.preventDefault(); + e.stopPropagation(); + + const text = this.editor.inputText; + let expr = text.slice(this.cell.from, this.cell.to); + let [ci, ri] = expr2xy(expr); + + const { merges } = this.editor.data; + let mergeCell = merges.getFirstIncludes(ri, ci); + if (mergeCell) { + ri = mergeCell.sri; + ci = mergeCell.sci; + } + + if (keyCode == 37 && ci >= 1) { + ci -= 1; + } else if (keyCode == 38 && ri >= 1) { + ri -= 1; + } + else if (keyCode == 39) { + if (mergeCell) { + ci = mergeCell.eci; + } + ci += 1; + } + else if (keyCode == 40) { + if (mergeCell) { + ri = mergeCell.eri; + } + ri += 1; + } + + mergeCell = merges.getFirstIncludes(ri, ci); + if (mergeCell) { + ri = mergeCell.sri; + ci = mergeCell.sci; + } + + this.selectCell(ri, ci); + }); + } + + clear() { + this.cell = null; + this.cells = []; + this.cellEl.innerHTML = ''; + } + + selectCell(ri, ci) { + if (this.cell) { + const row = String(ri + 1); + const col = stringAt(ci); + const text = this.editor.inputText; + const { from, to } = this.cell; + + this.editor.inputText = text.slice(0, from) + col + row + text.slice(to); + this.editor.render(); + setTimeout(() => { + setCaretPosition(this.el, from + col.length + row.length); + }); + + this.cell = null; + } + } + + render() { + const text = this.editor.inputText; + this.cells = []; + + let i = 0; + let m = null; + let html = ""; + + const goldenRatio = 0.618033988749895; + let h = 34 / 360; + function pickColor() { + const color = `hsl(${Math.floor(h * 360)}, 90%, 50%)`; + h += goldenRatio; + h %= 1; + return color; + } + + let pre = 0; + while (i < text.length) { + const sub = text.slice(i); + if ((m = sub.match(/^[A-Za-z]+[1-9][0-9]*/))) { + // cell + const color = pickColor(); + html += `${m[0]}`; + + this.cells.push({ + from: i, + to: i + m[0].length, + color, + }); + pre = 1; + i = i + m[0].length; + } else if ((m = sub.match(/^[A-Za-z]+/))) { + // function + html += `${m[0]}`; + pre = 2; + i = i + m[0].length; + } else if ((m = sub.match(/^[0-9.]+/))) { + // number + html += `${m[0]}`; + pre = 3; + i = i + m[0].length; + } else if ((m = sub.match(/^[\+\-\*\/\,\=]/))) { + // operator + html += `${m[0]}`; + if (pre == 4) { + // between two operators + this.cells.push({ + from: i, + to: i, + }); + } + if (text[i - 1] == '(') { + // between '(' and operator + this.cells.push({ + from: i, + to: i, + }); + } + pre = 4; + i = i + 1; + } else if ((m = sub.match(/^[\(\)]/))) { + // parenthesis + html += `${m[0]}`; + if (text[i - 1] == '(' && text[i] == ')') { + // between parenthesis pair + this.cells.push({ + from: i, + to: i, + }); + } + if (pre == 4 && text[i] == ')') { + // between operator and ')' + this.cells.push({ + from: i, + to: i, + }); + } + pre = 5; + i = i + 1; + } else { + // unknown + html += `${text.charAt(i)}`; + pre = 6; + i = i + 1; + } + } + + if (pre == 4) { + // between operator and the end of text + this.cells.push({ + from: text.length, + to: text.length, + }); + } + + // console.log('formula cells', this.cells); + + this.el.innerHTML = html; + } + + renderCells() { + const text = this.editor.inputText; + const cells = this.cells; + const data = this.editor.data; + let cellHtml = ""; + + for (let cell of cells) { + const { from, to, color } = cell; + if (color) { + const [ci, ri] = expr2xy(text.slice(from, to)); + const mergeCell = data.merges.getFirstIncludes(ri, ci); + let box = null; + if (mergeCell) { + box = data.getRect(mergeCell); + } else { + box = data.getRect(new CellRange(ri, ci, ri, ci)); + } + const { left, top, width, height } = box; + cellHtml += renderCell(left, top, width, height, color, this.cell === cell); + } + } + + this.cellEl.innerHTML = cellHtml; + } +} \ No newline at end of file diff --git a/src/component/sheet.js b/src/component/sheet.js index 0c34d2dd..9a982e6d 100644 --- a/src/component/sheet.js +++ b/src/component/sheet.js @@ -86,7 +86,7 @@ function selectorSet(multiple, ri, ci, indexesUpdated = true, moving = false) { // direction: left | right | up | down | row-first | row-last | col-first | col-last function selectorMove(multiple, direction) { const { - selector, data, + selector, data } = this; const { rows, cols } = data; let [ri, ci] = selector.indexes; @@ -585,6 +585,13 @@ function sheetInitEvents() { overlayerMousemove.call(this, evt); }) .on('mousedown', (evt) => { + if (evt.buttons === 1 && evt.detail <= 1 && editor.formulaCellSelecting()) { + const { offsetX, offsetY } = evt; + const { ri, ci } = this.data.getCellRectByXY(offsetX, offsetY); + editor.formulaSelectCell(ri, ci); + return; + } + editor.clear(); contextMenu.hide(); // the left mouse button: mousedown → mouseup → click @@ -869,7 +876,7 @@ export default class Sheet { this.editor = new Editor( formulas, () => this.getTableOffset(), - data.rows.height, + data, ); // data validation this.modalValidation = new ModalValidation(); @@ -881,6 +888,7 @@ export default class Sheet { .children( this.editor.el, this.selector.el, + this.editor.cellEl, ); this.overlayerEl = h('div', `${cssPrefix}-overlayer`) .child(this.overlayerCEl); @@ -923,6 +931,7 @@ export default class Sheet { this.data = data; verticalScrollbarSet.call(this); horizontalScrollbarSet.call(this); + this.editor.resetData(data); this.toolbar.resetData(data); this.print.resetData(data); this.selector.resetData(data); diff --git a/src/core/caret.js b/src/core/caret.js new file mode 100644 index 00000000..c2399ad0 --- /dev/null +++ b/src/core/caret.js @@ -0,0 +1,44 @@ +// Thanks to https://stackoverflow.com/questions/4576694/saving-and-restoring-caret-position-for-contenteditable-div + +export function getCaretPosition(context) { + const selection = window.getSelection(); + const range = selection.getRangeAt(0).cloneRange(); + range.setStart(context, 0); + const index = range.toString().length; + return index; +} + +function getTextNodeAtPosition(root, index) { + const treeWalker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + function next(elem) { + if (index > elem.textContent.length) { + index -= elem.textContent.length; + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + ); + const c = treeWalker.nextNode(); + return { + node: c ? c : root, + position: index, + }; +} + +export function setCaretPosition(context, index) { + const selection = window.getSelection(); + const pos = getTextNodeAtPosition(context, index); + selection.removeAllRanges(); + const range = new Range(); + range.setStart(pos.node, pos.position); + selection.addRange(range); +} + +export function saveCaretPosition(context) { + const index = getCaretPosition(context); + return function restore() { + setCaretPosition(context, index); + }; +} \ No newline at end of file diff --git a/src/core/data_proxy.js b/src/core/data_proxy.js index c931b1d3..ff780b86 100644 --- a/src/core/data_proxy.js +++ b/src/core/data_proxy.js @@ -111,7 +111,7 @@ const bottombarHeight = 41; // src: cellRange // dst: cellRange -function canPaste(src, dst, error = () => {}) { +function canPaste(src, dst, error = () => { }) { const { merges } = this; const cellRange = dst.clone(); const [srn, scn] = src.size(); @@ -343,7 +343,7 @@ export default class DataProxy { this.history = new History(); this.clipboard = new Clipboard(); this.autoFilter = new AutoFilter(); - this.change = () => {}; + this.change = () => { }; this.exceptRowSet = new Set(); this.sortedRowMap = new Map(); this.unsortedRowMap = new Map(); @@ -442,7 +442,7 @@ export default class DataProxy { } // what: all | text | format - paste(what = 'all', error = () => {}) { + paste(what = 'all', error = () => { }) { // console.log('sIndexes:', sIndexes); const { clipboard, selector } = this; if (clipboard.isClear()) return false; @@ -467,7 +467,7 @@ export default class DataProxy { }); } - autofill(cellRange, what, error = () => {}) { + autofill(cellRange, what, error = () => { }) { const srcRange = this.selector.range; if (!canPaste.call(this, srcRange, cellRange, error)) return false; this.changeData(() => { @@ -493,9 +493,9 @@ export default class DataProxy { if (ri < 0) nri = rows.len - 1; if (ci < 0) nci = cols.len - 1; if (nri > cri) [sri, eri] = [cri, nri]; - else [sri, eri] = [nri, cri]; + else[sri, eri] = [nri, cri]; if (nci > cci) [sci, eci] = [cci, nci]; - else [sci, eci] = [nci, cci]; + else[sci, eci] = [nci, cci]; selector.range = merges.union(new CellRange( sri, sci, eri, eci, )); diff --git a/src/index.less b/src/index.less index 9bc68c75..ed1cfe30 100644 --- a/src/index.less +++ b/src/index.less @@ -67,7 +67,7 @@ body { background: #fff; -webkit-font-smoothing: antialiased; - textarea { + .textarea { font: 400 13px Arial, 'Lato', 'Source Sans Pro', Roboto, Helvetica, sans-serif; } } @@ -383,7 +383,7 @@ body { z-index: 100; pointer-events: auto; - textarea { + .textarea { box-sizing: content-box; border: none; padding: 0 3px; @@ -397,6 +397,11 @@ body { word-wrap: break-word; line-height: 22px; margin: 0; + background-color: white; + + .formula-token { + margin-right: 2px; + } } .textline { From 7fa005f0df6b5e2e12c933be8df94affe1b1f43e Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Fri, 4 Sep 2020 07:26:01 -0400 Subject: [PATCH 2/9] Compute all formulas using formula.js Replace existing formula parsing and execution with an external parser library built on top of formula.js: https://github.com/handsontable/formula-parser This dramatically increases the number of supported formulas; see list here: https://formulajs.info/functions/ --- package-lock.json | 33 +++++ package.json | 1 + src/algorithm/expression.js | 39 ----- src/component/dropdown_formula.js | 7 +- src/component/editor.js | 8 +- src/component/sheet.js | 37 ++++- src/component/table.js | 69 ++++++++- src/component/toolbar.js | 2 +- src/component/toolbar/formula.js | 2 +- src/core/cell.js | 228 ++---------------------------- src/core/formula.js | 98 ------------- test/core/cell_test.js | 154 +++++++++++--------- test/core/formula_test.js | 42 ------ 13 files changed, 244 insertions(+), 476 deletions(-) delete mode 100644 src/algorithm/expression.js delete mode 100644 src/core/formula.js delete mode 100644 test/core/formula_test.js diff --git a/package-lock.json b/package-lock.json index 8c06d8e5..5645a1aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -818,6 +818,15 @@ "to-fast-properties": "^2.0.0" } }, + "@handsontable/formulajs": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@handsontable/formulajs/-/formulajs-2.0.2.tgz", + "integrity": "sha512-maIyMJtYjA5e/R9nyA22Qd7Yw73MBSxClJvle0a8XWAS/5l6shc/OFpQqrmwMy4IXUCmywJ9ER0gOGz/YA720w==", + "requires": { + "bessel": "^1.0.2", + "jstat": "^1.9.2" + } + }, "@types/events": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", @@ -1530,6 +1539,11 @@ "tweetnacl": "^0.14.3" } }, + "bessel": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bessel/-/bessel-1.0.2.tgz", + "integrity": "sha512-Al3nHGQGqDYqqinXhQzmwmcRToe/3WyBv4N8aZc5Pef8xw2neZlR9VPi84Sa23JtgWcucu18HxVZrnI0fn2etw==" + }, "big.js": { "version": "5.2.2", "resolved": "http://registry.npm.taobao.org/big.js/download/big.js-5.2.2.tgz", @@ -4808,6 +4822,15 @@ "integrity": "sha1-l/I2l3vW4SVAiTD/bePuxigewEc=", "dev": true }, + "hot-formula-parser": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/hot-formula-parser/-/hot-formula-parser-3.0.2.tgz", + "integrity": "sha512-W/Dj/UbIyuViMIQOQD6tUEVySl7jd6ei+gfWslTiRqa4yRhkyHnIz8N4oLnqgDRhhVAQIcFF5NfNz49k4X8IxQ==", + "requires": { + "@handsontable/formulajs": "^2.0.2", + "tiny-emitter": "^2.1.0" + } + }, "hpack.js": { "version": "2.1.6", "resolved": "http://registry.npm.taobao.org/hpack.js/download/hpack.js-2.1.6.tgz", @@ -5685,6 +5708,11 @@ "verror": "1.10.0" } }, + "jstat": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/jstat/-/jstat-1.9.3.tgz", + "integrity": "sha512-/2JL4Xv6xfhN2+AEKQGTYr1LZTmBCR/5fHxJVvb9zWNsmKZfKrl3wYYK8SD/Z8kXkf+ZSusfumLZ4wDTHrWujA==" + }, "killable": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.1.tgz", @@ -9921,6 +9949,11 @@ "setimmediate": "^1.0.4" } }, + "tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==" + }, "tmp": { "version": "0.0.33", "resolved": "https://registry.npm.taobao.org/tmp/download/tmp-0.0.33.tgz", diff --git a/package.json b/package.json index 99a1802b..31f6b036 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "webpack-merge": "^4.1.4" }, "dependencies": { + "hot-formula-parser": "^3.0.2", "opencollective": "^1.0.3", "opencollective-postinstall": "^2.0.2" }, diff --git a/src/algorithm/expression.js b/src/algorithm/expression.js deleted file mode 100644 index dd65f718..00000000 --- a/src/algorithm/expression.js +++ /dev/null @@ -1,39 +0,0 @@ -// src: include chars: [0-9], +, -, *, / -// // 9+(3-1)*3+10/2 => 9 3 1-3*+ 10 2/+ -const infix2suffix = (src) => { - const operatorStack = []; - const stack = []; - for (let i = 0; i < src.length; i += 1) { - const c = src.charAt(i); - if (c !== ' ') { - if (c >= '0' && c <= '9') { - stack.push(c); - } else if (c === ')') { - let c1 = operatorStack.pop(); - while (c1 !== '(') { - stack.push(c1); - c1 = operatorStack.pop(); - } - } else { - // priority: */ > +- - if (operatorStack.length > 0 && (c === '+' || c === '-')) { - const last = operatorStack[operatorStack.length - 1]; - if (last === '*' || last === '/') { - while (operatorStack.length > 0) { - stack.push(operatorStack.pop()); - } - } - } - operatorStack.push(c); - } - } - } - while (operatorStack.length > 0) { - stack.push(operatorStack.pop()); - } - return stack; -}; - -export default { - infix2suffix, -}; diff --git a/src/component/dropdown_formula.js b/src/component/dropdown_formula.js index 7363fa24..31f11fec 100644 --- a/src/component/dropdown_formula.js +++ b/src/component/dropdown_formula.js @@ -1,17 +1,18 @@ import Dropdown from './dropdown'; import Icon from './icon'; import { h } from './element'; -import { baseFormulas } from '../core/formula'; import { cssPrefix } from '../config'; +import { SUPPORTED_FORMULAS } from 'hot-formula-parser'; + export default class DropdownFormula extends Dropdown { constructor() { - const nformulas = baseFormulas.map(it => h('div', `${cssPrefix}-item`) + const nformulas = SUPPORTED_FORMULAS.map(it => h('div', `${cssPrefix}-item`) .on('click', () => { this.hide(); this.change(it); }) - .child(it.key)); + .child(it)); super(new Icon('formula'), '180px', true, 'bottom-left', ...nformulas); } } diff --git a/src/component/editor.js b/src/component/editor.js index c80dc98d..096ef4cc 100644 --- a/src/component/editor.js +++ b/src/component/editor.js @@ -74,7 +74,7 @@ function suggestItemClick(it) { } else { eit = ''; } - this.inputText = `${sit + it.key}(`; + this.inputText = `${sit + it}(`; // console.log('inputText:', this.inputText); position = this.inputText.length; this.inputText += `)${eit}`; @@ -101,9 +101,11 @@ export default class Editor { this.viewFn = viewFn; this.rowHeight = data.rows.height; this.formulas = formulas; - this.suggest = new Suggest(formulas, (it) => { - suggestItemClick.call(this, it); + this.suggest = new Suggest(this.formulas, (it) => { + const unescapedKey = it.key.replace('\\.', '.'); + suggestItemClick.call(this, unescapedKey); }); + this.datepicker = new Datepicker(); this.datepicker.change((d) => { // console.log('d:', d); diff --git a/src/component/sheet.js b/src/component/sheet.js index 9a982e6d..97e1a5a0 100644 --- a/src/component/sheet.js +++ b/src/component/sheet.js @@ -1,6 +1,7 @@ /* global window */ import { h } from './element'; import { bind, mouseMoveUp, bindTouch, createEventEmitter } from './event'; +import { t } from '../locale/locale'; import Resizer from './resizer'; import Scrollbar from './scrollbar'; import Selector from './selector'; @@ -13,7 +14,8 @@ import ModalValidation from './modal_validation'; import SortFilter from './sort_filter'; import { xtoast } from './message'; import { cssPrefix } from '../config'; -import { formulas } from '../core/formula'; + +import { SUPPORTED_FORMULAS } from 'hot-formula-parser'; /** * @desc throttle fn @@ -585,10 +587,32 @@ function sheetInitEvents() { overlayerMousemove.call(this, evt); }) .on('mousedown', (evt) => { + // If a formula cell is being edited and a left click is made, + // set that formula cell to start at the selected sheet cell and set a + // temporary mousemove event handler that updates said formula cell to + // end at the sheet cell currently being hovered over. if (evt.buttons === 1 && evt.detail <= 1 && editor.formulaCellSelecting()) { const { offsetX, offsetY } = evt; const { ri, ci } = this.data.getCellRectByXY(offsetX, offsetY); editor.formulaSelectCell(ri, ci); + + const that = this; + + let lastCellRect = { ri: null, ci: null }; + mouseMoveUp(window, (e) => { + const cellRect = that.data.getCellRectByXY(e.offsetX, e.offsetY); + + const hasRangeChanged = (cellRect.ri != lastCellRect.ri) || (cellRect.ci != lastCellRect.ci); + const isRangeValid = (cellRect.ri >= 0) && (cellRect.ci >= 0); + + if (hasRangeChanged && isRangeValid) { + editor.formulaSelectCellRange(cellRect.ri, cellRect.ci); + + lastCellRect.ri = cellRect.ri; + lastCellRect.ci = cellRect.ci; + } + }, () => {}); + return; } @@ -873,8 +897,17 @@ export default class Sheet { this.verticalScrollbar = new Scrollbar(true); this.horizontalScrollbar = new Scrollbar(false); // editor + const formulaSuggestions = SUPPORTED_FORMULAS.map((formulaName) => { + const escapedFormulaName = formulaName.replace('.', '\\.'); + return { + key: escapedFormulaName, + // Function that returns translation of the formula name if one exists, + // otherwise the formula name + title: () => t(`formula.${escapedFormulaName}`) || formulaName + }; + }); this.editor = new Editor( - formulas, + formulaSuggestions, () => this.getTableOffset(), data, ); diff --git a/src/component/table.js b/src/component/table.js index a8cf6291..aca9f395 100644 --- a/src/component/table.js +++ b/src/component/table.js @@ -1,12 +1,14 @@ import { stringAt } from '../core/alphabet'; import { getFontSizePxByPt } from '../core/font'; import _cell from '../core/cell'; -import { formulam } from '../core/formula'; import { formatm } from '../core/format'; import { Draw, DrawBox, thinLineWidth, npx, } from '../canvas/draw'; + +import { Parser } from 'hot-formula-parser'; + // gobal var const cellPaddingWidth = 5; const tableFixedHeaderCleanStyle = { fillStyle: '#f4f5f8' }; @@ -15,6 +17,7 @@ const tableGridStyle = { lineWidth: thinLineWidth, strokeStyle: '#e6e6e6', }; +const formulaParser = new Parser(); function tableFixedHeaderStyle() { return { textAlign: 'center', @@ -76,13 +79,13 @@ export function renderCell(draw, data, rindex, cindex, yoffset = 0) { // render text let cellText = ""; if(!data.settings.evalPaused) { - cellText = _cell.render(cell.text || '', formulam, (y, x) => (data.getCellTextOrDefault(x, y))); + cellText = _cell.render(cell.text || '', formulaParser); } else { cellText = cell.text || ''; } if (style.format) { // console.log(data.formatm, '>>', cell.format); - cellText = formatm[style.format].render(cellText); + cellText = formatm[style.format].render(cellText, formulaParser); } const font = Object.assign({}, style.font); font.size = getFontSizePxByPt(font.size); @@ -144,7 +147,7 @@ function renderContent(viewRange, fw, fh, tx, ty) { draw.save(); draw.translate(0, -exceptRowTotalHeight); viewRange.each((ri, ci) => { - renderCell(draw, data, ri, ci); + renderCell.call(this, draw, data, ri, ci); }, ri => filteredTranslateFunc(ri)); draw.restore(); @@ -155,7 +158,7 @@ function renderContent(viewRange, fw, fh, tx, ty) { draw.translate(0, -exceptRowTotalHeight); data.eachMergesInView(viewRange, ({ sri, sci, eri }) => { if (!exceptRowSet.has(sri)) { - renderCell(draw, data, sri, sci); + renderCell.call(this, draw, data, sri, sci); } else if (!rset.has(sri)) { rset.add(sri); const height = data.rows.sumHeight(sri, eri + 1); @@ -304,6 +307,62 @@ class Table { this.el = el; this.draw = new Draw(el, data.viewWidth(), data.viewHeight()); this.data = data; + + const that = this; + + // Whenever formulaParser.parser encounters a cell reference, it will + // execute this callback to query the true value of that cell reference. + // If the referenced cell contains a formula, we need to use formulaParser + // to determine its value---which will then trigger more callCellValue + // events to computer the values of its cell references. This recursion + // will continue until the original formula is fully resolved. + const getFormulaParserCellValue = function(cellCoord) { + let cellText = that.data.getCellTextOrDefault(cellCoord.row.index, cellCoord.column.index); + + // If cell contains a formula, return the result of the formula rather + // than the formula text itself + if (cellText && cellText.length > 0 && cellText[0] === '=') { + const parsedResult = formulaParser.parse(cellText.slice(1)); + + // If there's an error, return the error instead of the result + return (parsedResult.error) ? + parsedResult.error : + parsedResult.result; + } + + // The cell doesn't contain a formula, so return its contents as a value. + // If the string is a number, return as a number; + // otherwise, return as a string. + return Number(cellText) || cellText; + } + + formulaParser.on('callCellValue', function(cellCoord, done) { + const cellValue = getFormulaParserCellValue(cellCoord); + done(cellValue); + }); + + formulaParser.on('callRangeValue', function (startCellCoord, endCellCoord, done) { + let fragment = []; + + for (let row = startCellCoord.row.index; row <= endCellCoord.row.index; row++) { + let colFragment = []; + + for (let col = startCellCoord.column.index; col <= endCellCoord.column.index; col++) { + // Copy the parts of the structure of a Parser cell coordinate used + // by getFormulaParserCellValue + const constructedCellCoord = { + row: { index: row }, + column: { index: col } + }; + const cellValue = getFormulaParserCellValue(constructedCellCoord); + + colFragment.push(cellValue); + } + fragment.push(colFragment); + } + + done(fragment); + }) } resetData(data) { diff --git a/src/component/toolbar.js b/src/component/toolbar.js index 9792aa74..1938a057 100644 --- a/src/component/toolbar.js +++ b/src/component/toolbar.js @@ -39,7 +39,7 @@ function buildButtonWithIcon(tooltipdata, iconName, change = () => {}) { function bindDropdownChange() { this.ddFormat.change = it => this.change('format', it.key); this.ddFont.change = it => this.change('font-name', it.key); - this.ddFormula.change = it => this.change('formula', it.key); + this.ddFormula.change = it => this.change('formula', it); this.ddFontSize.change = it => this.change('font-size', it.pt); this.ddTextColor.change = it => this.change('color', it); this.ddFillColor.change = it => this.change('bgcolor', it); diff --git a/src/component/toolbar/formula.js b/src/component/toolbar/formula.js index 8d8a297d..e853b9ea 100644 --- a/src/component/toolbar/formula.js +++ b/src/component/toolbar/formula.js @@ -7,7 +7,7 @@ export default class Format extends DropdownItem { } getValue(it) { - return it.key; + return it; } dropdown() { diff --git a/src/core/cell.js b/src/core/cell.js index 0c2b577a..807c8009 100644 --- a/src/core/cell.js +++ b/src/core/cell.js @@ -1,226 +1,20 @@ -import { expr2xy, xy2expr } from './alphabet'; -import { numberCalc } from './helper'; +// formulaParser is a Parser object from the hot-formula-parser package +const cellRender = (src, formulaParser) => { + // If cell contains a formula, recursively parse that formula to get the value + if (src.length > 0 && src[0] === '=') { + const parsedResult = formulaParser.parse(src.slice(1)); + const recursedSrc = (parsedResult.error) ? + parsedResult.error : + parsedResult.result; -// Converting infix expression to a suffix expression -// src: AVERAGE(SUM(A1,A2), B1) + 50 + B20 -// return: [A1, A2], SUM[, B1],AVERAGE,50,+,B20,+ -const infixExprToSuffixExpr = (src) => { - const operatorStack = []; - const stack = []; - let subStrs = []; // SUM, A1, B2, 50 ... - let fnArgType = 0; // 1 => , 2 => : - let fnArgOperator = ''; - let fnArgsLen = 1; // A1,A2,A3... - let oldc = ''; - for (let i = 0; i < src.length; i += 1) { - const c = src.charAt(i); - if (c !== ' ') { - if (c >= 'a' && c <= 'z') { - subStrs.push(c.toUpperCase()); - } else if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || c === '.') { - subStrs.push(c); - } else if (c === '"') { - i += 1; - while (src.charAt(i) !== '"') { - subStrs.push(src.charAt(i)); - i += 1; - } - stack.push(`"${subStrs.join('')}`); - subStrs = []; - } else if (c === '-' && /[+\-*/,(]/.test(oldc)) { - subStrs.push(c); - } else { - // console.log('subStrs:', subStrs.join(''), stack); - if (c !== '(' && subStrs.length > 0) { - stack.push(subStrs.join('')); - } - if (c === ')') { - let c1 = operatorStack.pop(); - if (fnArgType === 2) { - // fn argument range => A1:B5 - try { - const [ex, ey] = expr2xy(stack.pop()); - const [sx, sy] = expr2xy(stack.pop()); - // console.log('::', sx, sy, ex, ey); - let rangelen = 0; - for (let x = sx; x <= ex; x += 1) { - for (let y = sy; y <= ey; y += 1) { - stack.push(xy2expr(x, y)); - rangelen += 1; - } - } - stack.push([c1, rangelen]); - } catch (e) { - // console.log(e); - } - } else if (fnArgType === 1 || fnArgType === 3) { - if (fnArgType === 3) stack.push(fnArgOperator); - // fn argument => A1,A2,B5 - stack.push([c1, fnArgsLen]); - fnArgsLen = 1; - } else { - // console.log('c1:', c1, fnArgType, stack, operatorStack); - while (c1 !== '(') { - stack.push(c1); - if (operatorStack.length <= 0) break; - c1 = operatorStack.pop(); - } - } - fnArgType = 0; - } else if (c === '=' || c === '>' || c === '<') { - const nc = src.charAt(i + 1); - fnArgOperator = c; - if (nc === '=' || nc === '-') { - fnArgOperator += nc; - i += 1; - } - fnArgType = 3; - } else if (c === ':') { - fnArgType = 2; - } else if (c === ',') { - if (fnArgType === 3) { - stack.push(fnArgOperator); - } - fnArgType = 1; - fnArgsLen += 1; - } else if (c === '(' && subStrs.length > 0) { - // function - operatorStack.push(subStrs.join('')); - } else { - // priority: */ > +- - // console.log('xxxx:', operatorStack, c, stack); - if (operatorStack.length > 0 && (c === '+' || c === '-')) { - let top = operatorStack[operatorStack.length - 1]; - if (top !== '(') stack.push(operatorStack.pop()); - if (top === '*' || top === '/') { - while (operatorStack.length > 0) { - top = operatorStack[operatorStack.length - 1]; - if (top !== '(') stack.push(operatorStack.pop()); - else break; - } - } - } else if (operatorStack.length > 0) { - const top = operatorStack[operatorStack.length - 1]; - if (top === '*' || top === '/') stack.push(operatorStack.pop()); - } - operatorStack.push(c); - } - subStrs = []; - } - oldc = c; - } + const parsedResultRecurse = cellRender(recursedSrc, formulaParser); + return parsedResultRecurse; } - if (subStrs.length > 0) { - stack.push(subStrs.join('')); - } - while (operatorStack.length > 0) { - stack.push(operatorStack.pop()); - } - return stack; -}; -const evalSubExpr = (subExpr, cellRender) => { - const [fl] = subExpr; - let expr = subExpr; - if (fl === '"') { - return subExpr.substring(1); - } - let ret = 1; - if (fl === '-') { - expr = subExpr.substring(1); - ret = -1; - } - if (expr[0] >= '0' && expr[0] <= '9') { - return ret * Number(expr); - } - const [x, y] = expr2xy(expr); - return ret * cellRender(x, y); -}; - -// evaluate the suffix expression -// srcStack: <= infixExprToSufixExpr -// formulaMap: {'SUM': {}, ...} -// cellRender: (x, y) => {} -const evalSuffixExpr = (srcStack, formulaMap, cellRender, cellList) => { - const stack = []; - // console.log(':::::formulaMap:', formulaMap); - for (let i = 0; i < srcStack.length; i += 1) { - // console.log(':::>>>', srcStack[i]); - const expr = srcStack[i]; - const fc = expr[0]; - if (expr === '+') { - const top = stack.pop(); - stack.push(numberCalc('+', stack.pop(), top)); - } else if (expr === '-') { - if (stack.length === 1) { - const top = stack.pop(); - stack.push(numberCalc('*', top, -1)); - } else { - const top = stack.pop(); - stack.push(numberCalc('-', stack.pop(), top)); - } - } else if (expr === '*') { - stack.push(numberCalc('*', stack.pop(), stack.pop())); - } else if (expr === '/') { - const top = stack.pop(); - stack.push(numberCalc('/', stack.pop(), top)); - } else if (fc === '=' || fc === '>' || fc === '<') { - let top = stack.pop(); - if (!Number.isNaN(top)) top = Number(top); - let left = stack.pop(); - if (!Number.isNaN(left)) left = Number(left); - let ret = false; - if (fc === '=') { - ret = (left === top); - } else if (expr === '>') { - ret = (left > top); - } else if (expr === '>=') { - ret = (left >= top); - } else if (expr === '<') { - ret = (left < top); - } else if (expr === '<=') { - ret = (left <= top); - } - stack.push(ret); - } else if (Array.isArray(expr)) { - const [formula, len] = expr; - const params = []; - for (let j = 0; j < len; j += 1) { - params.push(stack.pop()); - } - stack.push(formulaMap[formula].render(params.reverse())); - } else { - if (cellList.includes(expr)) { - return 0; - } - if ((fc >= 'a' && fc <= 'z') || (fc >= 'A' && fc <= 'Z')) { - cellList.push(expr); - } - stack.push(evalSubExpr(expr, cellRender)); - cellList.pop(); - } - // console.log('stack:', stack); - } - return stack[0]; -}; - -const cellRender = (src, formulaMap, getCellText, cellList = []) => { - if (src[0] === '=') { - const stack = infixExprToSuffixExpr(src.substring(1)); - if (stack.length <= 0) return src; - return evalSuffixExpr( - stack, - formulaMap, - (x, y) => cellRender(getCellText(x, y), formulaMap, getCellText, cellList), - cellList, - ); - } + // If cell doesn't contain a formula, render its content as is return src; }; export default { render: cellRender, }; -export { - infixExprToSuffixExpr, -}; diff --git a/src/core/formula.js b/src/core/formula.js deleted file mode 100644 index 299facb5..00000000 --- a/src/core/formula.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - formula: - key - title - render -*/ -/** - * @typedef {object} Formula - * @property {string} key - * @property {function} title - * @property {function} render - */ -import { tf } from '../locale/locale'; -import { numberCalc } from './helper'; - -/** @type {Formula[]} */ -const baseFormulas = [ - { - key: 'SUM', - title: tf('formula.sum'), - render: ary => ary.reduce((a, b) => numberCalc('+', a, b), 0), - }, - { - key: 'AVERAGE', - title: tf('formula.average'), - render: ary => ary.reduce((a, b) => Number(a) + Number(b), 0) / ary.length, - }, - { - key: 'MAX', - title: tf('formula.max'), - render: ary => Math.max(...ary.map(v => Number(v))), - }, - { - key: 'MIN', - title: tf('formula.min'), - render: ary => Math.min(...ary.map(v => Number(v))), - }, - { - key: 'IF', - title: tf('formula._if'), - render: ([b, t, f]) => (b ? t : f), - }, - { - key: 'AND', - title: tf('formula.and'), - render: ary => ary.every(it => it), - }, - { - key: 'OR', - title: tf('formula.or'), - render: ary => ary.some(it => it), - }, - { - key: 'CONCAT', - title: tf('formula.concat'), - render: ary => ary.join(''), - }, - /* support: 1 + A1 + B2 * 3 - { - key: 'DIVIDE', - title: tf('formula.divide'), - render: ary => ary.reduce((a, b) => Number(a) / Number(b)), - }, - { - key: 'PRODUCT', - title: tf('formula.product'), - render: ary => ary.reduce((a, b) => Number(a) * Number(b),1), - }, - { - key: 'SUBTRACT', - title: tf('formula.subtract'), - render: ary => ary.reduce((a, b) => Number(a) - Number(b)), - }, - */ -]; - -const formulas = baseFormulas; - -// const formulas = (formulaAry = []) => { -// const formulaMap = {}; -// baseFormulas.concat(formulaAry).forEach((f) => { -// formulaMap[f.key] = f; -// }); -// return formulaMap; -// }; -const formulam = {}; -baseFormulas.forEach((f) => { - formulam[f.key] = f; -}); - -export default { -}; - -export { - formulam, - formulas, - baseFormulas, -}; diff --git a/test/core/cell_test.js b/test/core/cell_test.js index 5b0dcae6..1f61846d 100644 --- a/test/core/cell_test.js +++ b/test/core/cell_test.js @@ -1,81 +1,105 @@ import assert from 'assert'; import { describe, it } from 'mocha'; -import cell, { infixExprToSuffixExpr } from '../../src/core/cell'; -import { formulam } from '../../src/core/formula'; +import { expr2xy } from '../../src/core/alphabet'; +import cell from '../../src/core/cell'; +import Table from '../../src/component/table'; -describe('infixExprToSuffixExpr', () => { - it('should return myname:A1 score:50 when the value is CONCAT("my name:", A1, " score:", 50)', () => { - assert.equal(infixExprToSuffixExpr('CONCAT("my name:", A1, " score:", 50)').join(''), '"my name:A1" score:50CONCAT,4'); - }); - it('should return A1B2SUM,2C1C5AVERAGE,350B20++ when the value is AVERAGE(SUM(A1,B2), C1, C5) + 50 + B20', () => { - assert.equal(infixExprToSuffixExpr('AVERAGE(SUM(A1,B2), C1, C5) + 50 + B20').join(''), 'A1B2SUM,2C1C5AVERAGE,350+B20+'); - }); - it('should return A1B2B3SUM,3C1C5AVERAGE,350+B20+ when the value is ((AVERAGE(SUM(A1,B2, B3), C1, C5) + 50) + B20)', () => { - assert.equal(infixExprToSuffixExpr('((AVERAGE(SUM(A1,B2, B3), C1, C5) + 50) + B20)').join(''), 'A1B2B3SUM,3C1C5AVERAGE,350+B20+'); - }); - it('should return 11==tfIF,3 when the value is IF(1==1, "t", "f")', () => { - assert.equal(infixExprToSuffixExpr('IF(1==1, "t", "f")').join(''), '11=="t"fIF,3'); - }); - it('should return 11=tfIF,3 when the value is IF(1=1, "t", "f")', () => { - assert.equal(infixExprToSuffixExpr('IF(1=1, "t", "f")').join(''), '11="t"fIF,3'); - }); - it('should return 21>21IF,3 when the value is IF(2>1, 2, 1)', () => { - assert.equal(infixExprToSuffixExpr('IF(2>1, 2, 1)').join(''), '21>21IF,3'); - }); - it('should return 11=AND,121IF,3 when the value is IF(AND(1=1), 2, 1)', () => { - assert.equal(infixExprToSuffixExpr('IF(AND(1=1), 2, 1)').join(''), '11=AND,121IF,3'); - }); - it('should return 11=21>AND,221IF,3 when the value is IF(AND(1=1, 2>1), 2, 1)', () => { - assert.equal(infixExprToSuffixExpr('IF(AND(1=1, 2>1), 2, 1)').join(''), '11=21>AND,221IF,3'); - }); - it('should return 105-20- when the value is 10-5-20', () => { - assert.equal(infixExprToSuffixExpr('10-5-20').join(''), '105-20-'); - }); - it('should return 105-2010*- when the value is 10-5-20*10', () => { - assert.equal(infixExprToSuffixExpr('10-5-20*10').join(''), '105-2010*-'); - }); - it('should return 10520*- when the value is 10-5*20', () => { - assert.equal(infixExprToSuffixExpr('10-5*20').join(''), '10520*-'); - }); - it('should return 105-20+ when the value is 10-5+20', () => { - assert.equal(infixExprToSuffixExpr('10-5+20').join(''), '105-20+'); - }); - it('should return 123*+45*6+7*+ when the value is 1 + 2*3 + (4 * 5 + 6) * 7', () => { - assert.equal(infixExprToSuffixExpr('1+2*3+(4*5+6)*7').join(''), '123*+45*6+7*+'); - }); - it('should return 9312*-3*+42/+ when the value is 9+(3-1*2)*3+4/2', () => { - assert.equal(infixExprToSuffixExpr('9+(3-1*2)*3+4/2').join(''), '9312*-3*+42/+'); - }); - it('should return 931-+23+*42/+ when the value is (9+(3-1))*(2+3)+4/2', () => { - assert.equal(infixExprToSuffixExpr('(9+(3-1))*(2+3)+4/2').join(''), '931-+23+*42/+'); - }); - it('should return SUM(1) when the value is 1SUM,1', () => { - assert.equal(infixExprToSuffixExpr('SUM(1)').join(''), '1SUM'); - }); - it('should return SUM() when the value is ""', () => { - assert.equal(infixExprToSuffixExpr('SUM()').join(''), 'SUM'); - }); - it('should return SUM( when the value is SUM', () => { - assert.equal(infixExprToSuffixExpr('SUM(').join(''), 'SUM'); - }); -}); +// ---------------------------------------------------------------------------- +// MOCKS +// ---------------------------------------------------------------------------- + +// The cell module's render function uses the hot-formula-parser library's +// Parser.parse method. Parser.parse relies on its callCellValue and +// callRangeValue event handlers being defined by the calling application to +// provide the values contains by cells referenced in the formula being parsed. +// The Table object instantiates the Parser.parse object in the application and +// defines the callCellValue and callRangeValue event handlers. Therefore, +// calling the cell module's render function also requires instantiating a +// Table object so that said event handlers are defined. And instantiating a +// Table object requires mocking some of its dependencies. + +// Mock storage for the values in each cell in the table +let __cellData = {}; + +// Example: setCellData('A3', 3) stores the value 3 in __cellData[2][0] +function setCellData(expr, value) { + const [x, y] = expr2xy(expr); + __cellData[y] = __cellData[y] || {}; + __cellData[y][x] = value.toString(); +} + +// Add window global if it doesn't exist +if (typeof window === 'undefined') { + global.window = {}; +} +window.devicePixelRatio = 0; + +const mockEl = { + getContext: (_) => { + return { + scale: (x, y) => {} + }; + }, + width: 0, + height: 0, + style: { + width: 0, + height: 0 + } +}; + +const mockData = { + viewWidth: () => 0, + viewHeight: () => 0, + getCellTextOrDefault: (rowIndex, colIndex) => { + if (__cellData[rowIndex] && __cellData[rowIndex][colIndex]) + return __cellData[rowIndex][colIndex]; + + return null; + } +} + +// ---------------------------------------------------------------------------- +// TEST CASES +// ---------------------------------------------------------------------------- + +// The table objects sets up the following dependencies of cell.render: +// - the hot-formula-parser module's Parser object needed as an argument +// - the above Parser object's callCellValue and callRangeValue event handlers +const table = new Table(mockEl, mockData); describe('cell', () => { describe('.render()', () => { + it('should return 2 when the value is IF(AND(1=1, 2>1), 2, 1)', () => { + assert.equal(cell.render('=IF(AND(1=1, 2>1), 2, 1)', table.formulaParser), 2); + }); + it('should return 57 when the value is =(9+(3-1))*(2+3)+4/2', () => { + assert.equal(cell.render('=(9+(3-1))*(2+3)+4/2', table.formulaParser), 57); + }); it('should return 0 + 2 + 2 + 6 + 49 + 20 when the value is =SUM(A1,B2, C1, C5) + 50 + B20', () => { - assert.equal(cell.render('=SUM(A1,B2, C1, C5) + 50 + B20', formulam, (x, y) => x + y), 0 + 2 + 2 + 6 + 50 + 20); + setCellData('A1', 0); + setCellData('B2', 2); + setCellData('C1', 2); + setCellData('C5', 6); + setCellData('B20', 20); + + assert.equal(cell.render('=SUM(A1,B2, C1, C5) + 50 + B20', table.formulaParser), 0 + 2 + 2 + 6 + 50 + 20); }); it('should return 50 + 20 when the value is =50 + B20', () => { - assert.equal(cell.render('=50 + B20', formulam, (x, y) => x + y), 50 + 20); + setCellData('B20', 20); + + assert.equal(cell.render('=50 + B20', table.formulaParser), 50 + 20); }); it('should return 2 when the value is =IF(2>1, 2, 1)', () => { - assert.equal(cell.render('=IF(2>1, 2, 1)', formulam, (x, y) => x + y), 2); + assert.equal(cell.render('=IF(2>1, 2, 1)', table.formulaParser), 2); }); it('should return 1 + 500 - 20 when the value is =AVERAGE(A1:A3) + 50 * 10 - B20', () => { - assert.equal(cell.render('=AVERAGE(A1:A3) + 50 * 10 - B20', formulam, (x, y) => { - // console.log('x:', x, ', y:', y); - return x + y; - }), 1 + 500 - 20); + setCellData('A1', -1); + setCellData('A2', 1); + setCellData('A3', 3); + setCellData('B20', 20); + + assert.equal(cell.render('=AVERAGE(A1:A3) + 50 * 10 - B20', table.formulaParser), 1 + 500 - 20); }); }); }); diff --git a/test/core/formula_test.js b/test/core/formula_test.js deleted file mode 100644 index 9eff625a..00000000 --- a/test/core/formula_test.js +++ /dev/null @@ -1,42 +0,0 @@ -import assert from 'assert'; -import { describe, it } from 'mocha'; -import { formulam } from '../../src/core/formula'; - -const gformulas = formulam; -describe('formula', () => { - describe('#render()', () => { - it('SUM: should return 36 when the value is [\'12\', \'12\', 12]', () => { - assert.equal(gformulas.SUM.render(['12', '12', 12]), 36); - }); - it('AVERAGE: should return 13 when the value is [\'12\', \'13\', 14]', () => { - assert.equal(gformulas.AVERAGE.render(['12', '13', 14]), 13); - }); - it('MAX: should return 14 when the value is [\'12\', \'13\', 14]', () => { - assert.equal(gformulas.MAX.render(['12', '13', 14]), 14); - }); - it('MIN: should return 12 when the value is [\'12\', \'13\', 14]', () => { - assert.equal(gformulas.MIN.render(['12', '13', 14]), 12); - }); - it('IF: should return 12 when the value is [12 > 11, 12, 11]', () => { - assert.equal(gformulas.IF.render([12 > 11, 12, 11]), 12); - }); - it('AND: should return true when the value is ["a", true, "ok"]', () => { - assert.equal(gformulas.AND.render(['a', true, 'ok']), true); - }); - it('AND: should return false when the value is ["a", false, "ok"]', () => { - assert.equal(gformulas.AND.render(['a', false, 'ok']), false); - }); - it('OR: should return true when the value is ["a", true]', () => { - assert.equal(gformulas.OR.render(['a', true]), true); - }); - it('OR: should return true when the value is ["a", false]', () => { - assert.equal(gformulas.OR.render(['a', false]), true); - }); - it('OR: should return false when the value is [0, false]', () => { - assert.equal(gformulas.OR.render([0, false]), false); - }); - it('CONCAT: should return 1200USD when the value is [\'1200\', \'USD\']', () => { - assert.equal(gformulas.CONCAT.render(['1200', 'USD']), '1200USD'); - }); - }); -}); From e19a2b11126fb4fc1960ae84f4f9bb399ab56636 Mon Sep 17 00:00:00 2001 From: Aliaksei Ladyha Date: Wed, 28 Oct 2020 12:40:51 +0300 Subject: [PATCH 3/9] Make function dropdown content scrollable With the inclusion of many formulas from formula.js, the number of formula options is now too great to display without a scrollable container. --- src/component/dropdown_formula.js | 2 +- src/index.less | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/component/dropdown_formula.js b/src/component/dropdown_formula.js index 31f11fec..144a7d10 100644 --- a/src/component/dropdown_formula.js +++ b/src/component/dropdown_formula.js @@ -13,6 +13,6 @@ export default class DropdownFormula extends Dropdown { this.change(it); }) .child(it)); - super(new Icon('formula'), '180px', true, 'bottom-left', ...nformulas); + super(new Icon('formula'), '180px', true, 'bottom-left limit-height', ...nformulas); } } diff --git a/src/index.less b/src/index.less index ed1cfe30..c4567675 100644 --- a/src/index.less +++ b/src/index.less @@ -199,6 +199,13 @@ body { box-shadow: 1px 2px 5px 2px rgba(51,51,51,.15); } + &.limit-height { + & > .@{css-prefix}-dropdown-content { + max-height: 250px; + overflow: auto; + } + } + &.bottom-left { .@{css-prefix}-dropdown-content { top: calc(~'100% + 5px'); From 3981e881178132320685af661b52f57241b6579c Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 14:28:34 -0400 Subject: [PATCH 4/9] Update locale keys with formulajs function names Formula keys within locale files should match the name of the formulajs function that a translation string is being provided for. If a formula name has a '.' in it, it should be escaped as follows: { "FORMULA\\.NAME": "TRANSLATION" } --- src/locale/de.js | 11 ++++++----- src/locale/en.js | 10 ++-------- src/locale/nl.js | 11 ++++++----- src/locale/zh-cn.js | 17 +++++++++-------- 4 files changed, 23 insertions(+), 26 deletions(-) diff --git a/src/locale/de.js b/src/locale/de.js index 228cf0b1..18bfd2ae 100644 --- a/src/locale/de.js +++ b/src/locale/de.js @@ -48,10 +48,11 @@ export default { duration: 'Dauer', }, formula: { - sum: 'Summe', - average: 'Durchschnittliche', - max: 'Max', - min: 'Min', - concat: 'Concat', + SUM: 'Summe', + AVERAGE: 'Durchschnittliche', + MAX: 'Max', + MIN: 'Min', + CONCATENATE: 'Concat', + ROUND: 'Round', }, }; diff --git a/src/locale/en.js b/src/locale/en.js index cd8cdf33..0cd1a4cc 100644 --- a/src/locale/en.js +++ b/src/locale/en.js @@ -63,14 +63,8 @@ export default { duration: 'Duration', }, formula: { - sum: 'Sum', - average: 'Average', - max: 'Max', - min: 'Min', - _if: 'IF', - and: 'AND', - or: 'OR', - concat: 'Concat', + // Not required + // Will use FormulaJS function names, which are already in English }, validation: { required: 'it must be required', diff --git a/src/locale/nl.js b/src/locale/nl.js index 6fec014b..40a180c3 100644 --- a/src/locale/nl.js +++ b/src/locale/nl.js @@ -48,10 +48,11 @@ export default { duration: 'Duratie', }, formula: { - sum: 'Som', - average: 'Gemiddelde', - max: 'Max', - min: 'Min', - concat: 'Concat', + SUM: 'Som', + AVERAGE: 'Gemiddelde', + MAX: 'Max', + MIN: 'Min', + CONCATENATE: 'Concat', + ROUND: 'Round', }, }; diff --git a/src/locale/zh-cn.js b/src/locale/zh-cn.js index 6ba276e5..58c61205 100644 --- a/src/locale/zh-cn.js +++ b/src/locale/zh-cn.js @@ -63,14 +63,15 @@ export default { duration: '持续时间', }, formula: { - sum: '求和', - average: '求平均值', - max: '求最大值', - min: '求最小值', - concat: '字符拼接', - _if: '条件判断', - and: '和', - or: '或', + SUM: '求和', + AVERAGE: '求平均值', + MAX: '求最大值', + MIN: '求最小值', + CONCATENATE: '字符拼接', + IF: '条件判断', + AND: '和', + OR: '或', + ROUND: '保留小数', }, validation: { required: '此值必填', From cf0b9dab80a225b5f64c4e47b21defd3ac070bd7 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sat, 5 Sep 2020 01:48:42 -0400 Subject: [PATCH 5/9] Support absolute cell references Absolute cell references are evaluated correctly. When dragging the bottom-right corner of a formula cell to copy its contents to other cells, absolute cell references will be incremented or decremented appropriately (only relative axes will be modified, not absolute). When adding or removing rows or columns, absolute cell references will be adjusted appropriately. --- src/component/formula.js | 4 +-- src/core/alphabet.js | 57 +++++++++++++++++++++++++++++----------- src/core/row.js | 17 +++++++----- 3 files changed, 54 insertions(+), 24 deletions(-) diff --git a/src/component/formula.js b/src/component/formula.js index ddeee14e..8f1cf732 100644 --- a/src/component/formula.js +++ b/src/component/formula.js @@ -1,4 +1,4 @@ -import { stringAt, expr2xy } from '../core/alphabet'; +import { stringAt, expr2xy, REGEX_EXPR_NONGLOBAL_AT_START } from '../core/alphabet'; import { setCaretPosition, getCaretPosition } from '../core/caret'; import CellRange from '../core/cell_range'; @@ -132,7 +132,7 @@ export default class Formula { let pre = 0; while (i < text.length) { const sub = text.slice(i); - if ((m = sub.match(/^[A-Za-z]+[1-9][0-9]*/))) { + if ((m = sub.match(REGEX_EXPR_NONGLOBAL_AT_START))) { // cell const color = pickColor(); html += `${m[0]}`; diff --git a/src/core/alphabet.js b/src/core/alphabet.js index a856ae07..adfeb003 100644 --- a/src/core/alphabet.js +++ b/src/core/alphabet.js @@ -39,7 +39,16 @@ export function indexAt(str) { return ret; } -// B10 => x,y +// Regex looks for: +// [1] Optional $ (absolute X symbol) +// [2] 1-3 letters representing the column (X) +// [3] Optional $ (absolute Y symbol) +// [4] Sequence of digits representing the row (Y), first digit cannot be 0 +export const REGEX_EXPR_GLOBAL = /[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*/g; +export const REGEX_EXPR_NONGLOBAL_AT_START = /^[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*/; + const REGEX_EXPR_NONGLOBAL_CAPTURE = /([$])?([a-zA-Z]{1,3})([$])?([1-9][0-9]*)/; + +// B10 => x,y,xIsAbsolute,yIsAbsolute /** translate A1-tag to XY-tag * @date 2019-10-10 * @export @@ -47,16 +56,23 @@ export function indexAt(str) { * @returns {tagXY} */ export function expr2xy(src) { - let x = ''; - let y = ''; - for (let i = 0; i < src.length; i += 1) { - if (src.charAt(i) >= '0' && src.charAt(i) <= '9') { - y += src.charAt(i); - } else { - x += src.charAt(i); - } + // Regex looks for: + // [1] Optional $ (absolute X symbol) + // [2] 1-3 letters representing the column (X) + // [3] Optional $ (absolute Y symbol) + // [4] Sequence of digits representing the row (Y) + const found = src.match(REGEX_EXPR_NONGLOBAL_CAPTURE); + + if (!found) { + return null; } - return [indexAt(x), parseInt(y, 10) - 1]; + + const xIsAbsolute = found[1] !== undefined; + const x = found[2]; + const yIsAbsolute = found[3] !== undefined; + const y = found[4]; + + return [indexAt(x), parseInt(y, 10) - 1, xIsAbsolute, yIsAbsolute]; } /** translate XY-tag to A1-tag @@ -67,8 +83,9 @@ export function expr2xy(src) { * @param {number} y * @returns {tagA1} */ -export function xy2expr(x, y) { - return `${stringAt(x)}${y + 1}`; +export function xy2expr(x, y, xIsAbsolute = false, yIsAbsolute = false) { + const insertAbs = function(isAbsolute) { return (isAbsolute) ? '$' : '' }; + return `${insertAbs(xIsAbsolute)}${stringAt(x)}${insertAbs(yIsAbsolute)}${y + 1}`; } /** translate A1-tag src by (xn, yn) @@ -77,13 +94,21 @@ export function xy2expr(x, y) { * @param {tagA1} src * @param {number} xn * @param {number} yn + * @param {Boolean} dontTranslateAbsolute * @returns {tagA1} */ -export function expr2expr(src, xn, yn, condition = () => true) { +export function expr2expr(src, xn, yn, translateAbsolute = false, condition = () => true) { if (xn === 0 && yn === 0) return src; - const [x, y] = expr2xy(src); + const [x, y, xIsAbsolute, yIsAbsolute] = expr2xy(src); + + if (!translateAbsolute) { + // Ignore translation request if axis is absolute + if (xIsAbsolute) xn = 0; + if (yIsAbsolute) yn = 0; + } + if (!condition(x, y)) return src; - return xy2expr(x + xn, y + yn); + return xy2expr(x + xn, y + yn, xIsAbsolute, yIsAbsolute); } export default { @@ -92,4 +117,6 @@ export default { expr2xy, xy2expr, expr2expr, + REGEX_EXPR_GLOBAL, + REGEX_EXPR_NONGLOBAL_AT_START, }; diff --git a/src/core/row.js b/src/core/row.js index dc3243a7..d60db28b 100644 --- a/src/core/row.js +++ b/src/core/row.js @@ -1,5 +1,5 @@ import helper from './helper'; -import { expr2expr } from './alphabet'; +import { expr2expr, REGEX_EXPR_GLOBAL } from './alphabet'; class Rows { constructor({ len, height }) { @@ -145,7 +145,7 @@ class Rows { n -= dn + 1; } if (text[0] === '=') { - ncell.text = text.replace(/[a-zA-Z]{1,3}\d+/g, (word) => { + ncell.text = text.replace(REGEX_EXPR_GLOBAL, (word) => { let [xn, yn] = [0, 0]; if (sri === dsri) { xn = n - 1; @@ -154,7 +154,10 @@ class Rows { yn = n - 1; } if (/^\d+$/.test(word)) return word; - return expr2expr(word, xn, yn); + + // Set expr2expr to not perform translation on axes with an + // absolute reference + return expr2expr(word, xn, yn, false); }); } else if ((rn <= 1 && cn > 1 && (dsri > eri || deri < sri)) || (cn <= 1 && rn > 1 && (dsci > eci || deci < sci)) @@ -215,7 +218,7 @@ class Rows { nri += n; this.eachCells(ri, (ci, cell) => { if (cell.text && cell.text[0] === '=') { - cell.text = cell.text.replace(/[a-zA-Z]{1,3}\d+/g, word => expr2expr(word, 0, n, (x, y) => y >= sri)); + cell.text = cell.text.replace(REGEX_EXPR_GLOBAL, word => expr2expr(word, 0, n, true, (x, y) => y >= sri)); } }); } @@ -236,7 +239,7 @@ class Rows { ndata[nri - n] = row; this.eachCells(ri, (ci, cell) => { if (cell.text && cell.text[0] === '=') { - cell.text = cell.text.replace(/[a-zA-Z]{1,3}\d+/g, word => expr2expr(word, 0, -n, (x, y) => y > eri)); + cell.text = cell.text.replace(REGEX_EXPR_GLOBAL, word => expr2expr(word, 0, -n, true, (x, y) => y > eri)); } }); } @@ -253,7 +256,7 @@ class Rows { if (nci >= sci) { nci += n; if (cell.text && cell.text[0] === '=') { - cell.text = cell.text.replace(/[a-zA-Z]{1,3}\d+/g, word => expr2expr(word, n, 0, x => x >= sci)); + cell.text = cell.text.replace(REGEX_EXPR_GLOBAL, word => expr2expr(word, n, 0, true, x => x >= sci)); } } rndata[nci] = cell; @@ -273,7 +276,7 @@ class Rows { } else if (nci > eci) { rndata[nci - n] = cell; if (cell.text && cell.text[0] === '=') { - cell.text = cell.text.replace(/[a-zA-Z]{1,3}\d+/g, word => expr2expr(word, -n, 0, x => x > eci)); + cell.text = cell.text.replace(REGEX_EXPR_GLOBAL, word => expr2expr(word, -n, 0, true, x => x > eci)); } } }); From f557e8344ba33cc19c98114adc895cd25e60b3b6 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sun, 6 Sep 2020 00:41:44 -0400 Subject: [PATCH 6/9] Enable click-drag cell range select in formulas The initial click sets the cell reference range start position. If the click is held, the cell reference range end position is updated on mouse move. The cell reference range can then be modified in the same ways as a single cell reference (e.g., arrow keys). --- src/component/editor.js | 4 + src/component/formula.js | 190 ++++++++++++++++++++++++++++----------- src/core/_.prototypes.js | 4 + src/core/alphabet.js | 71 +++++++++++++-- src/core/cell_range.js | 25 ++++-- 5 files changed, 227 insertions(+), 67 deletions(-) diff --git a/src/component/editor.js b/src/component/editor.js index 096ef4cc..a720fba4 100644 --- a/src/component/editor.js +++ b/src/component/editor.js @@ -261,4 +261,8 @@ export default class Editor { formulaSelectCell(ri, ci) { this.formula.selectCell(ri, ci); } + + formulaSelectCellRange(ri, ci) { + this.formula.selectCellRange(ri, ci); + } } diff --git a/src/component/formula.js b/src/component/formula.js index 8f1cf732..3586f9d5 100644 --- a/src/component/formula.js +++ b/src/component/formula.js @@ -1,4 +1,11 @@ -import { stringAt, expr2xy, REGEX_EXPR_NONGLOBAL_AT_START } from '../core/alphabet'; +import { + stringAt, + expr2xy, + expr2cellRangeArgs, + cellRangeArgs2expr, + REGEX_EXPR_NONGLOBAL_AT_START, + REGEX_EXPR_RANGE_NONGLOBAL_AT_START +} from '../core/alphabet'; import { setCaretPosition, getCaretPosition } from '../core/caret'; import CellRange from '../core/cell_range'; @@ -15,7 +22,30 @@ function renderCell(left, top, width, height, color, selected = false) { return `
`; } +function generalSelectCell(sri, sci, eri, eci) { + if (this.cell) { + const expr = cellRangeArgs2expr(sri, sci, eri, eci); + const text = this.editor.inputText; + const { from, to } = this.cell; + + this.editor.inputText = text.slice(0, from) + expr + text.slice(to); + this.editor.render(); + setTimeout(() => { + setCaretPosition(this.el, from + expr.length); + }); + + this.cell = null; + } +} + export default class Formula { + getCellPositionRange(cell) { + const cellExpr = this.editor.inputText.slice(cell.from, cell.to); + const cellRangeArgs = expr2cellRangeArgs(cellExpr); + + return new CellRange(...cellRangeArgs); + } + constructor(editor) { this.editor = editor; this.el = this.editor.textEl.el; @@ -23,6 +53,10 @@ export default class Formula { this.cells = []; this.cell = null; + this.cellSelectStartRowCol = null; + this.cellSelectEndRowCol = null; + + let cellLastSelectionColor = null; document.addEventListener("selectionchange", () => { if (document.activeElement !== this.el) return; @@ -38,6 +72,27 @@ export default class Formula { } } + // If there's an active range/single formula cell (as determined by + // whether it has the color property), see if either: + // - there is no start value saved, suggesting that the formula cell was + // clicked (bypassing the selectCell call) rather than a sheet cell was + // selected via click + // - there is a start value saved, but it is for a different formula + // cell than the current one (as determined by a color change), + // suggesting the user clicked on a different formula cell since + // last call + // In either case, update the start/end select accordingly. + // TODO: find a more reliable way to check a change of cell than by using + // the color property + if (this.cell && this.cell.color && + (this.cell.color !== cellLastSelectionColor || !this.cellSelectStartRowCol)) { + const cellRange = this.getCellPositionRange(this.cell); + this.cellSelectStartRowCol = [cellRange.sri, cellRange.sci]; + this.cellSelectEndRowCol = [cellRange.eri, cellRange.eci]; + + cellLastSelectionColor = this.cell.color; + } + this.renderCells(); }); @@ -50,68 +105,91 @@ export default class Formula { e.preventDefault(); e.stopPropagation(); - const text = this.editor.inputText; - let expr = text.slice(this.cell.from, this.cell.to); - let [ci, ri] = expr2xy(expr); + // Get values before merge cells applied + const cellRangeArgs = this.getCellRangeArgsFromSelectStartEnd(); - const { merges } = this.editor.data; - let mergeCell = merges.getFirstIncludes(ri, ci); - if (mergeCell) { - ri = mergeCell.sri; - ci = mergeCell.sci; - } + // Account for merge cells + let cellRange = new CellRange(...cellRangeArgs); - if (keyCode == 37 && ci >= 1) { - ci -= 1; - } else if (keyCode == 38 && ri >= 1) { - ri -= 1; + // Left + if (keyCode == 37) { + cellRange.translate(0, -1); + this.cellSelectStartRowCol[1] = Math.max(0, this.cellSelectStartRowCol[1] - 1); + this.cellSelectEndRowCol[1] = Math.max(0, this.cellSelectEndRowCol[1] - 1); + } + // Up + else if (keyCode == 38) { + cellRange.translate(-1, 0); + this.cellSelectStartRowCol[0] = Math.max(0, this.cellSelectStartRowCol[0] - 1); + this.cellSelectEndRowCol[0] = Math.max(0, this.cellSelectEndRowCol[0] - 1); } + // Right else if (keyCode == 39) { - if (mergeCell) { - ci = mergeCell.eci; - } - ci += 1; + cellRange.translate(0, 1); + this.cellSelectStartRowCol[1] = this.cellSelectStartRowCol[1] + 1; + this.cellSelectEndRowCol[1] = this.cellSelectEndRowCol[1] + 1; } + // Down else if (keyCode == 40) { - if (mergeCell) { - ri = mergeCell.eri; - } - ri += 1; + cellRange.translate(1, 0); + this.cellSelectStartRowCol[0] = this.cellSelectStartRowCol[0] + 1; + this.cellSelectEndRowCol[0] = this.cellSelectEndRowCol[0] + 1; } - mergeCell = merges.getFirstIncludes(ri, ci); - if (mergeCell) { - ri = mergeCell.sri; - ci = mergeCell.sci; - } + // Reapply merge cells after translation + cellRange = this.editor.data.merges.union(cellRange) - this.selectCell(ri, ci); + generalSelectCell.call(this, cellRange.sri, cellRange.sci, cellRange.eri, cellRange.eci); }); } clear() { this.cell = null; + this.cellSelectStartRowCol = null; + this.cellSelectEndRowCol = null; this.cells = []; this.cellEl.innerHTML = ''; } selectCell(ri, ci) { + // To represent a single cell (no range), pass start and end row/col as + // equal + generalSelectCell.call(this, ri, ci, ri, ci); + this.cellSelectStartRowCol = [ri, ci]; + this.cellSelectEndRowCol = [ri, ci]; + } + + selectCellRange(eri, eci) { if (this.cell) { - const row = String(ri + 1); - const col = stringAt(ci); - const text = this.editor.inputText; - const { from, to } = this.cell; - - this.editor.inputText = text.slice(0, from) + col + row + text.slice(to); - this.editor.render(); - setTimeout(() => { - setCaretPosition(this.el, from + col.length + row.length); - }); + // Selected end before union with merge cells + this.cellSelectEndRowCol = [eri, eci]; - this.cell = null; + const cellRangeArgs = this.getCellRangeArgsFromSelectStartEnd(); + + // Account for merge cells + let cr = new CellRange(...cellRangeArgs); + cr = this.editor.data.merges.union(cr); + + // Keep current cell range start, but use new range end values + generalSelectCell.call(this, cr.sri, cr.sci, cr.eri, cr.eci); } } + getCellRangeArgsFromSelectStartEnd() { + // Normalize so that start index is not larger than the end index + let [sri, sci] = this.cellSelectStartRowCol; + let [eri, eci] = this.cellSelectEndRowCol; + + if (sri > eri) { + [sri, eri] = [eri, sri]; + } + if (sci > eci) { + [sci, eci] = [eci, sci]; + } + + return [sri, sci, eri, eci]; + } + render() { const text = this.editor.inputText; this.cells = []; @@ -132,7 +210,19 @@ export default class Formula { let pre = 0; while (i < text.length) { const sub = text.slice(i); - if ((m = sub.match(REGEX_EXPR_NONGLOBAL_AT_START))) { + if ((m = sub.match(REGEX_EXPR_RANGE_NONGLOBAL_AT_START))) { + // cell range + const color = pickColor(); + html += `${m[0]}`; + + this.cells.push({ + from: i, + to: i + m[0].length, + color, + }); + pre = 1; + i = i + m[0].length; + } else if ((m = sub.match(REGEX_EXPR_NONGLOBAL_AT_START))) { // cell const color = pickColor(); html += `${m[0]}`; @@ -200,7 +290,8 @@ export default class Formula { } } - if (pre == 4) { + const afterOpenParen = (pre == 5) && (text[i - 1] == '('); + if (pre == 4 || afterOpenParen) { // between operator and the end of text this.cells.push({ from: text.length, @@ -208,28 +299,21 @@ export default class Formula { }); } - // console.log('formula cells', this.cells); - this.el.innerHTML = html; } renderCells() { - const text = this.editor.inputText; const cells = this.cells; const data = this.editor.data; let cellHtml = ""; for (let cell of cells) { - const { from, to, color } = cell; + const { color } = cell; if (color) { - const [ci, ri] = expr2xy(text.slice(from, to)); - const mergeCell = data.merges.getFirstIncludes(ri, ci); - let box = null; - if (mergeCell) { - box = data.getRect(mergeCell); - } else { - box = data.getRect(new CellRange(ri, ci, ri, ci)); - } + const cellRange = this.getCellPositionRange(cell); + + const cellRangeIncludingMerges = data.merges.union(cellRange); + const box = data.getRect(cellRangeIncludingMerges); const { left, top, width, height } = box; cellHtml += renderCell(left, top, width, height, color, this.cell === cell); } diff --git a/src/core/_.prototypes.js b/src/core/_.prototypes.js index c3a11091..12060017 100644 --- a/src/core/_.prototypes.js +++ b/src/core/_.prototypes.js @@ -22,6 +22,10 @@ * @typedef {string} tagA1 A1 tag for XY-tag (0, 0) * @example "A1" */ +/** + * @typedef {string} tagA1B2 Cell reference range tag for XY-tags (0, 0) and (1, 1) + * @example "A1:B2" + */ /** * @typedef {[number, number]} tagXY * @example [0, 0] diff --git a/src/core/alphabet.js b/src/core/alphabet.js index adfeb003..31df3ae4 100644 --- a/src/core/alphabet.js +++ b/src/core/alphabet.js @@ -44,11 +44,12 @@ export function indexAt(str) { // [2] 1-3 letters representing the column (X) // [3] Optional $ (absolute Y symbol) // [4] Sequence of digits representing the row (Y), first digit cannot be 0 -export const REGEX_EXPR_GLOBAL = /[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*/g; -export const REGEX_EXPR_NONGLOBAL_AT_START = /^[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*/; - const REGEX_EXPR_NONGLOBAL_CAPTURE = /([$])?([a-zA-Z]{1,3})([$])?([1-9][0-9]*)/; +export const REGEX_EXPR_GLOBAL = /[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*/g; +export const REGEX_EXPR_NONGLOBAL_AT_START = /^[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*/; +export const REGEX_EXPR_RANGE_NONGLOBAL_AT_START = /^[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*:[$]?[a-zA-Z]{1,3}[$]?[1-9][0-9]*/; + const REGEX_EXPR_NONGLOBAL_CAPTURE = /([$])?([a-zA-Z]{1,3})([$])?([1-9][0-9]*)/; -// B10 => x,y,xIsAbsolute,yIsAbsolute +// B10 => x,y,xIsAbsolute,yIsAbsolute,length of expr /** translate A1-tag to XY-tag * @date 2019-10-10 * @export @@ -72,7 +73,44 @@ export function expr2xy(src) { const yIsAbsolute = found[3] !== undefined; const y = found[4]; - return [indexAt(x), parseInt(y, 10) - 1, xIsAbsolute, yIsAbsolute]; + return [indexAt(x), parseInt(y, 10) - 1, xIsAbsolute, yIsAbsolute, found[0].length]; +} + +/** translate tagA1B2 to cell range arguments (sri, sci, eri, eci) + * @date 2020-09-09 + * @export + * @param {tagA1B2} src + * @returns {number[4]} + */ +export function expr2cellRangeArgs(src) { + const startRef = expr2xy(src); + + if (!startRef) { + return null; + } + + const sci = startRef[0]; + const sri = startRef[1]; + + const srcIndexEndOfStartRef = startRef[4]; + + // If we've reached the end of the string OR + // if the next character after start reference is not a colon, + // then we just have a start reference (no end) + if (srcIndexEndOfStartRef >= src.length || src[srcIndexEndOfStartRef] != ':') { + return [sri, sci, sri, sci]; + } + + let endRef = expr2xy(src.slice(srcIndexEndOfStartRef + 1)); + + if (!endRef) { + return null; + } + + const eci = endRef[0]; + const eri = endRef[1]; + + return [sri, sci, eri, eci]; } /** translate XY-tag to A1-tag @@ -88,6 +126,26 @@ export function xy2expr(x, y, xIsAbsolute = false, yIsAbsolute = false) { return `${insertAbs(xIsAbsolute)}${stringAt(x)}${insertAbs(yIsAbsolute)}${y + 1}`; } +/** translate cell range arguments to cell range string expression + * @example 1, 1, 2, 4 => A2:D3 + * @date 2020-09-09 + * @export + * @param {number} sri + * @param {number} sri + * @param {number} eri + * @param {number} eci + * @returns {tagA1B2} + */ +export function cellRangeArgs2expr(sri, sci, eri, eci) { + let expr = xy2expr(sci, sri); + + if (sci != eci || sri != eri) { + expr += `:${xy2expr(eci, eri)}`; + } + + return expr; +} + /** translate A1-tag src by (xn, yn) * @date 2019-10-10 * @export @@ -115,8 +173,11 @@ export default { stringAt, indexAt, expr2xy, + expr2cellRangeArgs, xy2expr, + cellRangeArgs2expr, expr2expr, REGEX_EXPR_GLOBAL, REGEX_EXPR_NONGLOBAL_AT_START, + REGEX_EXPR_RANGE_NONGLOBAL_AT_START, }; diff --git a/src/core/cell_range.js b/src/core/cell_range.js index 75176cad..b8e3730b 100644 --- a/src/core/cell_range.js +++ b/src/core/cell_range.js @@ -1,4 +1,4 @@ -import { xy2expr, expr2xy } from './alphabet'; +import { xy2expr, expr2xy, expr2cellRangeArgs } from './alphabet'; class CellRange { constructor(sri, sci, eri, eci, w = 0, h = 0) { @@ -205,15 +205,22 @@ class CellRange { && this.sci === other.sci; } + // Translates the cell range by the given values, unless such a translation + // would be invalid (e.g., index less than 1) + translate(rowShift, colShift) { + // Ensure row/col values remain valid (>= 0) + // NOTE: this assumes a cellRange isn't used with a row or column index of + // -1, which is sometimes used in the application to denote an entire row + // or column is being referenced (not just a single index) + this.sri = Math.max(0, this.sri + rowShift); + this.eri = Math.max(0, this.eri + rowShift); + this.sci = Math.max(0, this.sci + colShift); + this.eci = Math.max(0, this.eci + colShift); + } + static valueOf(ref) { - // B1:B8, B1 => 1 x 1 cell range - const refs = ref.split(':'); - const [sci, sri] = expr2xy(refs[0]); - let [eri, eci] = [sri, sci]; - if (refs.length > 1) { - [eci, eri] = expr2xy(refs[1]); - } - return new CellRange(sri, sci, eri, eci); + const cellRangeArgs = expr2cellRangeArgs(ref); + return new CellRange(...cellRangeArgs); } } From 0dc6d340c9668f7fbb784b1e42293fc73498688c Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sun, 27 Sep 2020 22:31:09 -0400 Subject: [PATCH 7/9] Improve test coverage of alphabet.js --- test/core/alphabet_test.js | 149 ++++++++++++++++++++++++++++++++++++- 1 file changed, 147 insertions(+), 2 deletions(-) diff --git a/test/core/alphabet_test.js b/test/core/alphabet_test.js index bea78b51..ef86dd57 100644 --- a/test/core/alphabet_test.js +++ b/test/core/alphabet_test.js @@ -4,8 +4,11 @@ import { describe, it } from 'mocha'; import { indexAt, stringAt, + xy2expr, expr2xy, expr2expr, + expr2cellRangeArgs, + cellRangeArgs2expr, } from '../../src/core/alphabet'; describe('alphabet', () => { @@ -61,10 +64,84 @@ describe('alphabet', () => { assert.equal(stringAt((26 * 26) + 26), 'AAA'); }); }); + describe('.xy2expr()', () => { + it('should return B4 when the value is 1,3 and X/Y are relative', () => { + assert.equal(xy2expr(1, 3), 'B4'); + }); + it('should return $B4 when the value is 1,3 and X is absolute', () => { + assert.equal(xy2expr(1, 3, true, false), '$B4'); + }); + it('should return B$4 when the value is 1,3 and Y is absolute', () => { + assert.equal(xy2expr(1, 3, false, true), 'B$4'); + }); + it('should return B$4$ when the value is 1,3 and X/Y are absolute', () => { + assert.equal(xy2expr(1, 3, true, true), '$B$4'); + }); + }); describe('.expr2xy()', () => { it('should return 0 when the value is A1', () => { - assert.equal(expr2xy('A1')[0], 0); - assert.equal(expr2xy('A1')[1], 0); + const expr = 'A1'; + const ret = expr2xy(expr); + assert.equal(ret[0], 0); + assert.equal(ret[1], 0); + assert.equal(ret[2], false); + assert.equal(ret[3], false); + assert.equal(ret[4], expr.length); + }); + it('should return 1,3 when the value is B4', () => { + const expr = 'B4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], false); + assert.equal(ret[3], false); + assert.equal(ret[4], expr.length); + }); + it('should return that X is absolute when the value is $B4', () => { + const expr = '$B4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], true); + assert.equal(ret[3], false); + assert.equal(ret[4], expr.length); + }); + it('should return that Y is absolute when the value is $B4', () => { + const expr = 'B$4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], false); + assert.equal(ret[3], true); + assert.equal(ret[4], expr.length); + }); + it('should return that X and Y are absolute when the value is $B$4', () => { + const expr = '$B$4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], true); + assert.equal(ret[3], true); + assert.equal(ret[4], expr.length); + }); + // Note: defined REGEX currently supports up to ZZZ (3 letters max) + it('should return 27,999 when the value is $ABC$1000', () => { + const expr = '$ABC$1000'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1 * 26 ** 2 + 2 * 26 ** 1 + 3 * 26 ** 0 - 1); + assert.equal(ret[1], 999); + assert.equal(ret[2], true); + assert.equal(ret[3], true); + assert.equal(ret[4], expr.length); + }); + it('should return 1,29 when the value is B$30:B$335', () => { + const expr = 'B$30:B$335'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 29); + assert.equal(ret[2], false); + assert.equal(ret[3], true); + assert.equal(ret[4], 'B$30'.length); }); }); describe('.expr2expr()', () => { @@ -74,5 +151,73 @@ describe('alphabet', () => { it('should return C4 when the value is A1, 2, 3', () => { assert.equal(expr2expr('A1', 2, 3), 'C4'); }); + // Use of the optional condition function argument + it('should return A1 when the value is A1, 1, 1, false, () => false', () => { + assert.equal(expr2expr('A1', 1, 1, false, () => false), 'A1'); + }); + // Start of absolute cell reference cases + it('should return $A2 when the value is $A1, 1, 1', () => { + assert.equal(expr2expr('$A1', 1, 1), '$A2'); + }); + it('should return B$1 when the value is A$1, 1, 1', () => { + assert.equal(expr2expr('A$1', 1, 1), 'B$1'); + }); + it('should return $A$A when the value is $A$1, 1, 1', () => { + assert.equal(expr2expr('$A$1', 1, 1), '$A$1'); + }); + it('should return $B2 when the value is $A1, 1, 1, true', () => { + assert.equal(expr2expr('$A1', 1, 1, true), '$B2'); + }); + it('should return B$2 when the value is A$1, 1, 1, true', () => { + assert.equal(expr2expr('A$1', 1, 1, true), 'B$2'); + }); + it('should return $B$2 when the value is $A$1, 1, 1, true', () => { + assert.equal(expr2expr('$A$1', 1, 1, true), '$B$2'); + }); + }); + describe('.expr2cellRangeArgs()', () => { + it('should return null when the value is empty', () => { + assert.equal(expr2cellRangeArgs(''), null); + }); + it('should return null when the value is A', () => { + assert.equal(expr2cellRangeArgs('A'), null); + }); + it('should return null when the value is 1', () => { + assert.equal(expr2cellRangeArgs('1'), null); + }); + it('should return 0,0,0,0 when the value is A1', () => { + assert.deepEqual(expr2cellRangeArgs('A1'), [0, 0, 0, 0]); + }); + // Single cell + it('should return 3,1,3,1 when the value is B4', () => { + assert.deepEqual(expr2cellRangeArgs('B4'), [3, 1, 3, 1]); + }); + // Single cell with absolute references + it('should return 3,1,3,1 when the value is $B4', () => { + assert.deepEqual(expr2cellRangeArgs('$B4'), [3, 1, 3, 1]); + }); + it('should return 3,1,3,1 when the value is B$4', () => { + assert.deepEqual(expr2cellRangeArgs('B$4'), [3, 1, 3, 1]); + }); + it('should return 3,1,3,1 when the value is $B$4', () => { + assert.deepEqual(expr2cellRangeArgs('$B$4'), [3, 1, 3, 1]); + }); + // Cell range + it('should return 3,1,3,1 when the value is B4:C6', () => { + assert.deepEqual(expr2cellRangeArgs('B4:C6'), [3, 1, 5, 2]); + }); + it('should return 3,1,3,1 when the value is $B4:C$6', () => { + assert.deepEqual(expr2cellRangeArgs('$B4:C$6'), [3, 1, 5, 2]); + }); + }); + describe('.cellRangeArgs2expr()', () => { + // Single cell + it('should return B4 when the value is 3,1,3,1', () => { + assert.equal(cellRangeArgs2expr(3, 1, 3, 1), 'B4'); + }); + // Cell range + it('should return B4 when the value is 3,1,5,2', () => { + assert.equal(cellRangeArgs2expr(3, 1, 5, 2), 'B4:C6'); + }); }); }); From ba9947023edfbe8bec057737c522a4250c8c45f4 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 19:18:40 -0400 Subject: [PATCH 8/9] Add morph and translate methods to CellRange --- src/core/cell_range.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/core/cell_range.js b/src/core/cell_range.js index b8e3730b..d50d4a1c 100644 --- a/src/core/cell_range.js +++ b/src/core/cell_range.js @@ -208,14 +208,24 @@ class CellRange { // Translates the cell range by the given values, unless such a translation // would be invalid (e.g., index less than 1) translate(rowShift, colShift) { + // Morph the same amount in each direction, resulting in a translation + this.morph(colShift, rowShift, colShift, rowShift); + } + + // Move the left, top, right, and bottom boundaries of the cell range by the + // specified amounts + morph(leftShift, topShift, rightShift, bottomShift) { + // Start is left/top. + // End is bottom/right. + // Ensure row/col values remain valid (>= 0) // NOTE: this assumes a cellRange isn't used with a row or column index of // -1, which is sometimes used in the application to denote an entire row // or column is being referenced (not just a single index) - this.sri = Math.max(0, this.sri + rowShift); - this.eri = Math.max(0, this.eri + rowShift); - this.sci = Math.max(0, this.sci + colShift); - this.eci = Math.max(0, this.eci + colShift); + this.sri = Math.max(0, this.sri + topShift); + this.eri = Math.max(0, this.eri + bottomShift); + this.sci = Math.max(0, this.sci + leftShift); + this.eci = Math.max(0, this.eci + rightShift); } static valueOf(ref) { From 597b0958bd570c68935ad16695280b61671f9c47 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 19:19:15 -0400 Subject: [PATCH 9/9] Handle SHIFT key when moving cell reference range If the user is editing a cell reference range within a formula and is holding the shift key while using the direction arrows to move the range, the start of the range will be fixed. This mirrors the behavior of Excel when editing a cell reference range. --- src/component/formula.js | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/component/formula.js b/src/component/formula.js index 3586f9d5..89b3e066 100644 --- a/src/component/formula.js +++ b/src/component/formula.js @@ -98,6 +98,7 @@ export default class Formula { this.el.addEventListener("keydown", (e) => { const keyCode = e.keyCode || e.which; + if ([37, 38, 39, 40].indexOf(keyCode) == -1) return; if (!this.cell || this.cell.from == this.cell.to) return; @@ -105,36 +106,39 @@ export default class Formula { e.preventDefault(); e.stopPropagation(); - // Get values before merge cells applied - const cellRangeArgs = this.getCellRangeArgsFromSelectStartEnd(); - - // Account for merge cells - let cellRange = new CellRange(...cellRangeArgs); + let rowShift = 0; + let colShift = 0; // Left if (keyCode == 37) { - cellRange.translate(0, -1); - this.cellSelectStartRowCol[1] = Math.max(0, this.cellSelectStartRowCol[1] - 1); - this.cellSelectEndRowCol[1] = Math.max(0, this.cellSelectEndRowCol[1] - 1); + colShift = -1; } // Up else if (keyCode == 38) { - cellRange.translate(-1, 0); - this.cellSelectStartRowCol[0] = Math.max(0, this.cellSelectStartRowCol[0] - 1); - this.cellSelectEndRowCol[0] = Math.max(0, this.cellSelectEndRowCol[0] - 1); + rowShift = -1; } // Right else if (keyCode == 39) { - cellRange.translate(0, 1); - this.cellSelectStartRowCol[1] = this.cellSelectStartRowCol[1] + 1; - this.cellSelectEndRowCol[1] = this.cellSelectEndRowCol[1] + 1; + colShift = 1; } // Down else if (keyCode == 40) { - cellRange.translate(1, 0); - this.cellSelectStartRowCol[0] = this.cellSelectStartRowCol[0] + 1; - this.cellSelectEndRowCol[0] = this.cellSelectEndRowCol[0] + 1; + rowShift = 1; + } + + // If the shift key is applied, hold the start position fixed + if (!e.shiftKey) { + this.cellSelectStartRowCol[0] = Math.max(0, this.cellSelectStartRowCol[0] + rowShift); + this.cellSelectStartRowCol[1] = Math.max(0, this.cellSelectStartRowCol[1] + colShift); } + this.cellSelectEndRowCol[0] = Math.max(0, this.cellSelectEndRowCol[0] + rowShift); + this.cellSelectEndRowCol[1] = Math.max(0, this.cellSelectEndRowCol[1] + colShift); + + // Get values before merge cells applied + const cellRangeArgs = this.getCellRangeArgsFromSelectStartEnd(); + + // Account for merge cells + let cellRange = new CellRange(...cellRangeArgs); // Reapply merge cells after translation cellRange = this.editor.data.merges.union(cellRange)