From 26b1a3128445043e57708cf33681c6b23fabd2d7 Mon Sep 17 00:00:00 2001 From: chieveit Date: Tue, 2 Jun 2020 07:34:59 +0000 Subject: [PATCH 01/18] fix alphabet indexAt and stringAt --- src/core/alphabet.js | 14 +++++----- test/core/alphabet_test.js | 52 ++++++++++---------------------------- 2 files changed, 20 insertions(+), 46 deletions(-) diff --git a/src/core/alphabet.js b/src/core/alphabet.js index a856ae07c..eb3980bde 100644 --- a/src/core/alphabet.js +++ b/src/core/alphabet.js @@ -12,13 +12,11 @@ const alphabets = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', ' export function stringAt(index) { let str = ''; let cindex = index; - while (cindex >= alphabets.length) { + do { + str = alphabets[parseInt(cindex, 10) % alphabets.length] + str; cindex /= alphabets.length; cindex -= 1; - str += alphabets[parseInt(cindex, 10) % alphabets.length]; - } - const last = index % alphabets.length; - str += alphabets[last]; + } while (cindex >= 0); return str; } @@ -30,12 +28,12 @@ export function stringAt(index) { */ export function indexAt(str) { let ret = 0; - for (let i = 0; i < str.length - 1; i += 1) { + for (let i = 0; i <= str.length - 1; i += 1) { const cindex = str.charCodeAt(i) - 65; const exponet = str.length - 1 - i; - ret += (alphabets.length ** exponet) + (alphabets.length * cindex); + ret += (cindex + 1) * (alphabets.length ** exponet); } - ret += str.charCodeAt(str.length - 1) - 65; + ret -= 1; return ret; } diff --git a/test/core/alphabet_test.js b/test/core/alphabet_test.js index bea78b51a..597e37903 100644 --- a/test/core/alphabet_test.js +++ b/test/core/alphabet_test.js @@ -11,54 +11,30 @@ import { describe('alphabet', () => { describe('.indexAt()', () => { it('should return 0 when the value is A', () => { - assert.equal(indexAt('A'), 0); + assert.equal(indexAt('A'), 1 * 26 ** 0 - 1); }); - it('should return 25 when the value is Z', () => { - assert.equal(indexAt('Z'), 25); + it('should return 27 when the value is AB', () => { + assert.equal(indexAt('AB'), 1 * 26 ** 1 + 2 * 26 ** 0 - 1); }); - it('should return 26 when the value is AA', () => { - assert.equal(indexAt('AA'), 26); + it('should return 730 when the value is ABC', () => { + assert.equal(indexAt('ABC'), 1 * 26 ** 2 + 2 * 26 ** 1 + 3 * 26 ** 0 - 1); }); - it('should return 52 when the value is BA', () => { - assert.equal(indexAt('BA'), 52); - }); - it('should return 54 when the value is BC', () => { - assert.equal(indexAt('BC'), 54); - }); - it('should return 78 when the value is CA', () => { - assert.equal(indexAt('CA'), 78); - }); - it('should return 26 * 26 when the value is ZA', () => { - assert.equal(indexAt('ZA'), 26 * 26); - }); - it('should return 26 * 26 + 26 when the value is AAA', () => { - assert.equal(indexAt('AAA'), (26 * 26) + 26); + it('should return 19009 when the value is ABCD', () => { + assert.equal(indexAt('ABCD'), 1 * 26 ** 3 + 2 * 26 ** 2 + 3 * 26 ** 1 + 4 * 26 ** 0 - 1); }); }); describe('.stringAt()', () => { it('should return A when the value is 0', () => { - assert.equal(stringAt(0), 'A'); - }); - it('should return Z when the value is 25', () => { - assert.equal(stringAt(25), 'Z'); - }); - it('should return AA when the value is 26', () => { - assert.equal(stringAt(26), 'AA'); - }); - it('should return BC when the value is 54', () => { - assert.equal(stringAt(54), 'BC'); - }); - it('should return CB when the value is 78', () => { - assert.equal(stringAt(78), 'CA'); + assert.equal(stringAt(1 * 26 ** 0 - 1), 'A'); }); - it('should return ZA when the value is 26 * 26', () => { - assert.equal(stringAt(26 * 26), 'ZA'); + it('should return AB when the value is 27', () => { + assert.equal(stringAt(1 * 26 ** 1 + 2 * 26 ** 0 - 1), 'AB'); }); - it('should return Z when the value is 26 * 26 + 1', () => { - assert.equal(stringAt((26 * 26) + 1), 'ZB'); + it('should return ABC when the value is 730', () => { + assert.equal(stringAt(1 * 26 ** 2 + 2 * 26 ** 1 + 3 * 26 ** 0 - 1), 'ABC'); }); - it('should return AAA when the value is 26 * 26 + 26', () => { - assert.equal(stringAt((26 * 26) + 26), 'AAA'); + it('should return ABCD when the value is 19009', () => { + assert.equal(stringAt(1 * 26 ** 3 + 2 * 26 ** 2 + 3 * 26 ** 1 + 4 * 26 ** 0 - 1), 'ABCD'); }); }); describe('.expr2xy()', () => { From 00da1e6f39b281ea664686a370f79fca00dac8f3 Mon Sep 17 00:00:00 2001 From: chieveit Date: Tue, 2 Jun 2020 07:52:12 +0000 Subject: [PATCH 02/18] delete weird input element in selector --- src/component/selector.js | 20 +------------------- src/component/sheet.js | 5 ----- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/src/component/selector.js b/src/component/selector.js index 5b70d6268..5d5964748 100644 --- a/src/component/selector.js +++ b/src/component/selector.js @@ -6,9 +6,7 @@ const selectorHeightBorderWidth = 2 * 2 - 1; let startZIndex = 10; class SelectorElement { - constructor(useHideInput = false) { - this.useHideInput = useHideInput; - this.inputChange = () => {}; + constructor() { this.cornerEl = h('div', `${cssPrefix}-selector-corner`); this.areaEl = h('div', `${cssPrefix}-selector-area`) .child(this.cornerEl).hide(); @@ -18,14 +16,6 @@ class SelectorElement { .css('z-index', `${startZIndex}`) .children(this.areaEl, this.clipboardEl, this.autofillEl) .hide(); - if (useHideInput) { - this.hideInput = h('input', '') - .on('compositionend', (evt) => { - this.inputChange(evt.target.value); - }); - this.el.child(this.hideInputDiv = h('div', 'hide-input').child(this.hideInput)); - this.el.child(this.hideInputDiv = h('div', 'hide-input').child(this.hideInput)); - } startZIndex += 1; } @@ -50,10 +40,6 @@ class SelectorElement { top: top - 0.8, }; this.areaEl.offset(of).show(); - if (this.useHideInput) { - this.hideInputDiv.offset(of); - this.hideInput.val('').focus(); - } } setClipboardOffset(v) { @@ -195,15 +181,11 @@ function setAllClipboardOffset(offset) { export default class Selector { constructor(data) { - this.inputChange = () => {}; this.data = data; this.br = new SelectorElement(true); this.t = new SelectorElement(); this.l = new SelectorElement(); this.tl = new SelectorElement(); - this.br.inputChange = (v) => { - this.inputChange(v); - }; this.br.el.show(); this.offset = null; this.areaOffset = null; diff --git a/src/component/sheet.js b/src/component/sheet.js index 5b8f08940..eca26e543 100644 --- a/src/component/sheet.js +++ b/src/component/sheet.js @@ -611,11 +611,6 @@ function sheetInitEvents() { if (offsetX <= 0) rowResizer.hide(); }); - selector.inputChange = (v) => { - dataSetCellText.call(this, v, 'input'); - editorSet.call(this); - }; - // slide on mobile bindTouch(overlayerEl.el, { move: (direction, d) => { From 0b918b8fcde5c5e955273125e5d0f7b3df659e67 Mon Sep 17 00:00:00 2001 From: chieveit Date: Tue, 2 Jun 2020 07:55:28 +0000 Subject: [PATCH 03/18] better disable editing --- src/component/editor.js | 2 ++ src/core/row.js | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/component/editor.js b/src/component/editor.js index 648c2d978..d3a66685c 100644 --- a/src/component/editor.js +++ b/src/component/editor.js @@ -249,6 +249,8 @@ export default class Editor { } setCell(cell, validator) { + if (cell && cell.editable === false) return; + // console.log('::', validator); const { el, datepicker, suggest } = this; el.show(); diff --git a/src/core/row.js b/src/core/row.js index cc01eb1ce..9e1881459 100644 --- a/src/core/row.js +++ b/src/core/row.js @@ -106,11 +106,12 @@ class Rows { setCellText(ri, ci, text) { const cell = this.getCellOrNew(ri, ci); + if (cell.editable === false) return; cell.text = text; } // what: all | format | text - copyPaste(srcCellRange, dstCellRange, what, autofill = false, cb = () => {}) { + copyPaste(srcCellRange, dstCellRange, what, autofill = false, cb = () => { }) { const { sri, sci, eri, eci, } = srcCellRange; From 274b7e6a08817658a66b21199050ed5a45f55a3f Mon Sep 17 00:00:00 2001 From: chieveit Date: Tue, 2 Jun 2020 07:56:14 +0000 Subject: [PATCH 04/18] 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 d3a66685c..8f8220b19 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 = ''; + 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(); } } @@ -277,7 +230,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 000000000..ddeee14e2 --- /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 eca26e543..62b079f16 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; @@ -584,6 +584,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 @@ -862,7 +869,7 @@ export default class Sheet { this.editor = new Editor( formulas, () => this.getTableOffset(), - data.rows.height, + data, ); // data validation this.modalValidation = new ModalValidation(); @@ -874,6 +881,7 @@ export default class Sheet { .children( this.editor.el, this.selector.el, + this.editor.cellEl, ); this.overlayerEl = h('div', `${cssPrefix}-overlayer`) .child(this.overlayerCEl); @@ -918,6 +926,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 000000000..c2399ad09 --- /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 ecc5c495d..3c6d9b2cb 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(); @@ -409,7 +409,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; @@ -434,7 +434,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(() => { @@ -460,9 +460,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 9bc68c752..ed1cfe309 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 29dd9b7c7ea8f79ee98073c529091d51a15ab8ac Mon Sep 17 00:00:00 2001 From: chieveit Date: Fri, 5 Jun 2020 05:17:41 +0000 Subject: [PATCH 05/18] add round --- src/core/formula.js | 5 +++++ src/locale/de.js | 1 + src/locale/en.js | 1 + src/locale/nl.js | 1 + src/locale/zh-cn.js | 1 + 5 files changed, 9 insertions(+) diff --git a/src/core/formula.js b/src/core/formula.js index 299facb50..b093b998d 100644 --- a/src/core/formula.js +++ b/src/core/formula.js @@ -72,6 +72,11 @@ const baseFormulas = [ render: ary => ary.reduce((a, b) => Number(a) - Number(b)), }, */ + { + key: 'ROUND', + title: tf('formula.round'), + render: ([num, precise]) => Number(num).toFixed(precise), + }, ]; const formulas = baseFormulas; diff --git a/src/locale/de.js b/src/locale/de.js index 228cf0b13..f25c5c5ad 100644 --- a/src/locale/de.js +++ b/src/locale/de.js @@ -53,5 +53,6 @@ export default { max: 'Max', min: 'Min', concat: 'Concat', + round: 'Round', }, }; diff --git a/src/locale/en.js b/src/locale/en.js index cd8cdf335..d52943ed0 100644 --- a/src/locale/en.js +++ b/src/locale/en.js @@ -71,6 +71,7 @@ export default { and: 'AND', or: 'OR', concat: 'Concat', + round: 'Round', }, validation: { required: 'it must be required', diff --git a/src/locale/nl.js b/src/locale/nl.js index 6fec014b8..62a75d529 100644 --- a/src/locale/nl.js +++ b/src/locale/nl.js @@ -53,5 +53,6 @@ export default { max: 'Max', min: 'Min', concat: 'Concat', + round: 'Round', }, }; diff --git a/src/locale/zh-cn.js b/src/locale/zh-cn.js index 6ba276e51..73b986846 100644 --- a/src/locale/zh-cn.js +++ b/src/locale/zh-cn.js @@ -71,6 +71,7 @@ export default { _if: '条件判断', and: '和', or: '或', + round: '保留小数', }, validation: { required: '此值必填', From fc3d1bc7c5ab2389f3a364ba7868a65b2c762c0d Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Thu, 10 Sep 2020 15:11:38 -0400 Subject: [PATCH 06/18] Make 'npm run test' command cross-platform Fixes a path error on Windows when looking for tests within the test folder. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3ae491474..ce97b2a3a 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "build": "webpack --config build/webpack.prod.js", "build-locale": "webpack --config build/webpack.locale.js", "lint": "./node_modules/eslint/bin/eslint.js src", - "test": "nyc ./node_modules/mocha/bin/mocha --require @babel/register test/*", + "test": "nyc ./node_modules/mocha/bin/mocha --require @babel/register --recursive test", "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov -t 31ecdb12-8ecb-46f7-a486-65c2516307dd", "postinstall": "opencollective-postinstall" }, From cc90efd823f87f5b7cf5aa225f5899c29aa87a35 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 01:14:13 -0400 Subject: [PATCH 07/18] Enable locale fallback if translation is missing Using the locale function now adds the specified language to the front of a list of languages which are searched for a translation string. This allows a primary language as well as one or more fallback languages. On initialization, the languages array contains only English---making English the default fallback. To clear the existing languages array upon specifying a language, set the clearLangList argument of the locale function to true. --- src/locale/locale.js | 50 +++++++++++++++++++++++++++++++++----------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/src/locale/locale.js b/src/locale/locale.js index 9d53ae37a..f99059a1c 100644 --- a/src/locale/locale.js +++ b/src/locale/locale.js @@ -1,23 +1,36 @@ /* global window */ import en from './en'; -let $lang = 'en'; +// Defines the fallback language as English +let $languages = ['en']; const $messages = { en, }; function translate(key, messages) { - if (messages && messages[$lang]) { - let message = messages[$lang]; - const keys = key.split('.'); - for (let i = 0; i < keys.length; i += 1) { - const property = keys[i]; - const value = message[property]; - if (i === keys.length - 1) return value; - if (!value) return undefined; - message = value; + if (messages) { + // Return the translation from the first language in the languages array + // that has a value for the provided key. + for (const lang of $languages) { + if (!messages[lang]) break; + + let message = messages[lang]; + const keys = key.split('.'); + for (let i = 0; i < keys.length; i += 1) { + const property = keys[i]; + const value = message[property]; + + // If value doesn't exist, try next language + if (!value) break; + + if (i === keys.length - 1) return value; + + // Move down to the next level of the messages object + message = value; + } } } + return undefined; } @@ -33,8 +46,21 @@ function tf(key) { return () => t(key); } -function locale(lang, message) { - $lang = lang; +// If clearLangList is set to false, lang will be added to the front of the +// languages array. The languages in the language array are searched in order +// to find a translation. This allows the use of other languages as a fallback +// if lang is missing some keys. The language array is preloaded with English. +// To set the languages array to only include lang, set clearLangList to true. +function locale(lang, message, clearLangList=false) { + if (clearLangList) { + $languages = [lang]; + } else { + // Append to front of array. + // Translation method will use the first language in the list that has a + // matching key. + $languages.unshift(lang); + } + if (message) { $messages[lang] = message; } From 02138ad40cda91177e3aa90be5997845aaac3a3a Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 14:09:40 -0400 Subject: [PATCH 08/18] Allow translation message keys containing '.' If a locale messages key contains a '.' character, it must be escaped with a backslash. Because the backslash character must itself be escaped with a backslash, the proper way to write a key the includes a '.' character is: { "My\\.Key": "Value" } Some Excel formula names include a '.' character, so creating a translation value using that formula name as a key requires support for '.' in keys. --- src/locale/locale.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/locale/locale.js b/src/locale/locale.js index f99059a1c..e299c6cf1 100644 --- a/src/locale/locale.js +++ b/src/locale/locale.js @@ -15,7 +15,10 @@ function translate(key, messages) { if (!messages[lang]) break; let message = messages[lang]; - const keys = key.split('.'); + + // Splits the key at '.' except where escaped as '\.' + const keys = key.match(/(?:\\.|[^.])+/g); + for (let i = 0; i < keys.length; i += 1) { const property = keys[i]; const value = message[property]; From a3c07129ffe6e68ff19a8c573b75893a8da9c7ef Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sun, 27 Sep 2020 20:06:25 -0400 Subject: [PATCH 09/18] Include locale.js in lcov reporting --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index ce97b2a3a..509482f1c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "nyc": { "all": true, "include": [ - "src/core/*.js" + "src/core/*.js", + "src/locale/locale.js" ], "exclude": [ "**/*.spec.js" From a67d36d338684efddb9a7ba5d7734a299288c835 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sun, 27 Sep 2020 20:06:59 -0400 Subject: [PATCH 10/18] Create initial test suite for locale.js --- test/locale/locale_test.js | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 test/locale/locale_test.js diff --git a/test/locale/locale_test.js b/test/locale/locale_test.js new file mode 100644 index 000000000..d9bf0d05d --- /dev/null +++ b/test/locale/locale_test.js @@ -0,0 +1,93 @@ +import assert from 'assert'; +import { describe, it } from 'mocha'; +import { + locale, + t, + tf +} from '../../src/locale/locale'; + +// Override messages that exist in the fallback locale +const localeTest1 = 'TEST_1'; +const localeTest1Messages = { + toolbar: { + undo: 'Test 1 Undo', + redo: 'Test 1 Redo', + }, + formula: { + "VAR\\.P": "Test 1 VARP" + } +}; + +const localeTest2 = 'TEST_2'; +const localeTest2Messages = { + toolbar: { + undo: 'Test 2 Undo', + // Do not define "redo" message in locale test 2 + }, +}; + +describe('locale', () => { + describe('.t()', () => { + it('should return an empty string when the value has no available translation', () => { + assert.equal(t('something.not.defined'), ''); + }); + it('should return Undo when the value is toolbar.undo', () => { + assert.equal(t('toolbar.undo'), 'Undo'); + }); + }); + describe('.tf()', () => { + it('should return Undo when the value is toolbar.undo', () => { + const functionWhichReturnsTranslatedValue = tf('toolbar.undo'); + assert.equal(functionWhichReturnsTranslatedValue(), 'Undo'); + }); + }); + describe('.locale()', () => { + // Must be the first test which calls the locale function, + // as it depends on the first locale in the language list to be unchanged + // from the default (English). Subsequent tests must clear the language + // list to work as intended (otherwise thet language list will grow with + // each test). + it('should return Print when the value is toolbar.print and the fallback locale is English', () => { + // Provides no value for toolbar.print, so the English fallback will be used + locale(localeTest1, localeTest1Messages, false); + assert.equal(t('toolbar.print'), 'Print'); + }); + it('should return Test 2 Undo when the value is toolbar.undo', () => { + // Set language list to prioritize use of locale test 2, then locale test 1 + locale(localeTest1, localeTest1Messages, true); + locale(localeTest2, localeTest2Messages, false); + + assert.equal(t('toolbar.undo'), 'Test 2 Undo'); + }); + it('should return Test 1 Redo when the value is toolbar.redo', () => { + // Set language list to prioritize use of locale test 2, then locale test 1 + locale(localeTest1, localeTest1Messages, true); + locale(localeTest2, localeTest2Messages, false); + + // locale test 2 doesn't have the toolbar.redo message defined + assert.equal(t('toolbar.redo'), 'Test 1 Redo'); + }); + }); + describe('.t() [tests which depend on modified language list]', () => { + it('should return Test 1 VARP when the value is toolbar.formula.VAR\\.P', () => { + locale(localeTest1, localeTest1Messages, true); + assert.equal(t('formula.VAR\\.P'), 'Test 1 VARP'); + }); + it('should return Test 1 Redo when the value is toolbar.redo and a fallback is specified on the window global', () => { + // Only define locale test 2 messages here + locale(localeTest2, localeTest2Messages, true); + + // Add window global if it doesn't exist + if (typeof window === 'undefined') { + global.window = {}; + } + + // Supply a fallback locale test 2 message dictionary (from locale test 1) + window.x_spreadsheet = { $messages: { + 'TEST_2': localeTest1Messages + }}; + + assert.equal(t('toolbar.redo'), 'Test 1 Redo'); + }); + }); +}); From ecef1516c12ec8c8f783edc1c1884a41bee805ee Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Fri, 4 Sep 2020 07:26:01 -0400 Subject: [PATCH 11/18] 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/core/cell.js | 228 ++---------------------------- src/core/formula.js | 103 -------------- test/core/cell_test.js | 154 +++++++++++--------- test/core/formula_test.js | 42 ------ 11 files changed, 242 insertions(+), 479 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 c794befcf..0802d8e2b 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 509482f1c..9ff2497f8 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 dd65f718d..000000000 --- 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 7363fa241..31f11fec5 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 8f8220b19..ff7fedd29 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 62b079f16..b2c1ac17b 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 } 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 @@ -584,10 +586,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; } @@ -866,8 +890,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 a365106b2..7d2efd826 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' }; @@ -74,10 +76,10 @@ export function renderCell(draw, data, rindex, cindex, yoffset = 0) { } draw.rect(dbox, () => { // render text - let cellText = _cell.render(cell.text || '', formulam, (y, x) => (data.getCellTextOrDefault(x, y))); + let cellText = _cell.render(cell.text || '', this.formulaParser); if (style.format) { // console.log(data.formatm, '>>', cell.format); - cellText = formatm[style.format].render(cellText); + cellText = formatm[style.format].render(cellText, this.formulaParser); } const font = Object.assign({}, style.font); font.size = getFontSizePxByPt(font.size); @@ -139,7 +141,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(); @@ -150,7 +152,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); @@ -299,6 +301,63 @@ class Table { this.el = el; this.draw = new Draw(el, data.viewWidth(), data.viewHeight()); this.data = data; + this.formulaParser = new Parser(); + + 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 = that.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; + } + + this.formulaParser.on('callCellValue', function(cellCoord, done) { + const cellValue = getFormulaParserCellValue(cellCoord); + done(cellValue); + }); + + this.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/core/cell.js b/src/core/cell.js index 0c2b577a1..807c80097 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 b093b998d..000000000 --- a/src/core/formula.js +++ /dev/null @@ -1,103 +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)), - }, - */ - { - key: 'ROUND', - title: tf('formula.round'), - render: ([num, precise]) => Number(num).toFixed(precise), - }, -]; - -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 5b0dcae6e..1f61846db 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 9eff625aa..000000000 --- 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 366c4019c92a0ca85bf963fa21604fa4544e3f1e Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 14:28:34 -0400 Subject: [PATCH 12/18] 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 | 12 ++++++------ src/locale/en.js | 11 ++--------- src/locale/nl.js | 12 ++++++------ src/locale/zh-cn.js | 18 +++++++++--------- 4 files changed, 23 insertions(+), 30 deletions(-) diff --git a/src/locale/de.js b/src/locale/de.js index f25c5c5ad..18bfd2ae8 100644 --- a/src/locale/de.js +++ b/src/locale/de.js @@ -48,11 +48,11 @@ export default { duration: 'Dauer', }, formula: { - sum: 'Summe', - average: 'Durchschnittliche', - max: 'Max', - min: 'Min', - concat: 'Concat', - round: 'Round', + 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 d52943ed0..0cd1a4cc8 100644 --- a/src/locale/en.js +++ b/src/locale/en.js @@ -63,15 +63,8 @@ export default { duration: 'Duration', }, formula: { - sum: 'Sum', - average: 'Average', - max: 'Max', - min: 'Min', - _if: 'IF', - and: 'AND', - or: 'OR', - concat: 'Concat', - round: 'Round', + // 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 62a75d529..40a180c3a 100644 --- a/src/locale/nl.js +++ b/src/locale/nl.js @@ -48,11 +48,11 @@ export default { duration: 'Duratie', }, formula: { - sum: 'Som', - average: 'Gemiddelde', - max: 'Max', - min: 'Min', - concat: 'Concat', - round: 'Round', + 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 73b986846..58c612051 100644 --- a/src/locale/zh-cn.js +++ b/src/locale/zh-cn.js @@ -63,15 +63,15 @@ export default { duration: '持续时间', }, formula: { - sum: '求和', - average: '求平均值', - max: '求最大值', - min: '求最小值', - concat: '字符拼接', - _if: '条件判断', - and: '和', - or: '或', - round: '保留小数', + SUM: '求和', + AVERAGE: '求平均值', + MAX: '求最大值', + MIN: '求最小值', + CONCATENATE: '字符拼接', + IF: '条件判断', + AND: '和', + OR: '或', + ROUND: '保留小数', }, validation: { required: '此值必填', From c7b348e2031a607e065ab18ef799d8b85b2a31b2 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sat, 5 Sep 2020 01:48:42 -0400 Subject: [PATCH 13/18] 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 ddeee14e2..8f1cf7327 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 eb3980bde..634979460 100644 --- a/src/core/alphabet.js +++ b/src/core/alphabet.js @@ -37,7 +37,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 @@ -45,16 +54,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 @@ -65,8 +81,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) @@ -75,13 +92,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 { @@ -90,4 +115,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 9e1881459..1b6a475e7 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 }) { @@ -146,7 +146,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; @@ -155,7 +155,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)) @@ -216,7 +219,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)); } }); } @@ -237,7 +240,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)); } }); } @@ -254,7 +257,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; @@ -274,7 +277,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 15e6803e325b3383666dee53acc3487564a68d68 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sun, 6 Sep 2020 00:41:44 -0400 Subject: [PATCH 14/18] 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 ff7fedd29..e25a3c31c 100644 --- a/src/component/editor.js +++ b/src/component/editor.js @@ -263,4 +263,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 8f1cf7327..3586f9d54 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 c3a11091c..12060017c 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 634979460..1f29e142d 100644 --- a/src/core/alphabet.js +++ b/src/core/alphabet.js @@ -42,11 +42,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 @@ -70,7 +71,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 @@ -86,6 +124,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 @@ -113,8 +171,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 75176cade..b8e3730bc 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 672a7a65620b195f6000248a1cce29b512a75733 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Sun, 27 Sep 2020 22:31:09 -0400 Subject: [PATCH 15/18] 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 597e37903..c58ba3ec9 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', () => { @@ -37,10 +40,84 @@ describe('alphabet', () => { assert.equal(stringAt(1 * 26 ** 3 + 2 * 26 ** 2 + 3 * 26 ** 1 + 4 * 26 ** 0 - 1), 'ABCD'); }); }); + 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()', () => { @@ -50,5 +127,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 11f6e1fd8f6dbec67a5dd8251b441cbad8eab070 Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 19:18:40 -0400 Subject: [PATCH 16/18] 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 b8e3730bc..d50d4a1c0 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 b70c5f9feb7d23b0eddf6316a5c2b07c1e1e142a Mon Sep 17 00:00:00 2001 From: Jared Kirschner Date: Wed, 9 Sep 2020 19:19:15 -0400 Subject: [PATCH 17/18] 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 3586f9d54..89b3e0661 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) From a462f8230e30f9af38841a6f15a5e650d8563561 Mon Sep 17 00:00:00 2001 From: ThibautSF Date: Mon, 1 Feb 2021 17:36:04 +0100 Subject: [PATCH 18/18] Fix undefined on formula toolbar selection --- src/component/toolbar.js | 2 +- src/component/toolbar/formula.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/component/toolbar.js b/src/component/toolbar.js index 9792aa743..1938a0579 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 8d8a297d7..e853b9eab 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() {