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 3ae491474..9ff2497f8 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" @@ -29,7 +30,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" }, @@ -64,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 648c2d978..e25a3c31c 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) { @@ -138,12 +74,13 @@ 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}`; } - setText.call(this, this.inputText, position); + this.render(); + setCaretPosition(this.textEl.el, position); } function resetSuggestItems() { @@ -159,40 +96,50 @@ 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); + 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); 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 +159,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,15 +193,19 @@ 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(); } } setCell(cell, validator) { + if (cell && cell.editable === false) return; + // console.log('::', validator); const { el, datepicker, suggest } = this; el.show(); @@ -275,7 +232,39 @@ 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); + } + + formulaSelectCellRange(ri, ci) { + this.formula.selectCellRange(ri, ci); } } diff --git a/src/component/formula.js b/src/component/formula.js new file mode 100644 index 000000000..89b3e0661 --- /dev/null +++ b/src/component/formula.js @@ -0,0 +1,328 @@ +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'; + +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 `
`; +} + +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; + this.cellEl = this.editor.cellEl.el; + + this.cells = []; + this.cell = null; + this.cellSelectStartRowCol = null; + this.cellSelectEndRowCol = null; + + let cellLastSelectionColor = 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; + } + } + + // 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(); + }); + + 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(); + + let rowShift = 0; + let colShift = 0; + + // Left + if (keyCode == 37) { + colShift = -1; + } + // Up + else if (keyCode == 38) { + rowShift = -1; + } + // Right + else if (keyCode == 39) { + colShift = 1; + } + // Down + else if (keyCode == 40) { + 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) + + 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) { + // Selected end before union with merge cells + this.cellSelectEndRowCol = [eri, eci]; + + 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 = []; + + 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(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]}`; + + 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; + } + } + + const afterOpenParen = (pre == 5) && (text[i - 1] == '('); + if (pre == 4 || afterOpenParen) { + // between operator and the end of text + this.cells.push({ + from: text.length, + to: text.length, + }); + } + + this.el.innerHTML = html; + } + + renderCells() { + const cells = this.cells; + const data = this.editor.data; + let cellHtml = ""; + + for (let cell of cells) { + const { color } = cell; + if (color) { + 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); + } + } + + this.cellEl.innerHTML = cellHtml; + } +} \ No newline at end of file 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..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 @@ -86,7 +88,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 +586,35 @@ 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; + } + editor.clear(); contextMenu.hide(); // the left mouse button: mousedown → mouseup → click @@ -611,11 +642,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) => { @@ -864,10 +890,19 @@ 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.rows.height, + data, ); // data validation this.modalValidation = new ModalValidation(); @@ -879,6 +914,7 @@ export default class Sheet { .children( this.editor.el, this.selector.el, + this.editor.cellEl, ); this.overlayerEl = h('div', `${cssPrefix}-overlayer`) .child(this.overlayerCEl); @@ -923,6 +959,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/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/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() { 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 a856ae07c..1f29e142d 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,16 +28,26 @@ 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; } -// 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]*/; +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,length of expr /** translate A1-tag to XY-tag * @date 2019-10-10 * @export @@ -47,16 +55,60 @@ 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; + } + + 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, 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]; } - return [indexAt(x), parseInt(y, 10) - 1]; + + 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 @@ -67,8 +119,29 @@ 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 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) @@ -77,19 +150,32 @@ 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 { 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/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/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/cell_range.js b/src/core/cell_range.js index 75176cade..d50d4a1c0 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,32 @@ 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) { + // 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 + 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) { - // 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); } } 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/core/formula.js b/src/core/formula.js deleted file mode 100644 index 299facb50..000000000 --- a/src/core/formula.js +++ /dev/null @@ -1,98 +0,0 @@ -/** - formula: - key - title - render -*/ -/** - * @typedef {object} Formula - * @property {string} key - * @property {function} title - * @property {function} render - */ -import { tf } from '../locale/locale'; -import { numberCalc } from './helper'; - -/** @type {Formula[]} */ -const baseFormulas = [ - { - key: 'SUM', - title: tf('formula.sum'), - render: ary => ary.reduce((a, b) => numberCalc('+', a, b), 0), - }, - { - key: 'AVERAGE', - title: tf('formula.average'), - render: ary => ary.reduce((a, b) => Number(a) + Number(b), 0) / ary.length, - }, - { - key: 'MAX', - title: tf('formula.max'), - render: ary => Math.max(...ary.map(v => Number(v))), - }, - { - key: 'MIN', - title: tf('formula.min'), - render: ary => Math.min(...ary.map(v => Number(v))), - }, - { - key: 'IF', - title: tf('formula._if'), - render: ([b, t, f]) => (b ? t : f), - }, - { - key: 'AND', - title: tf('formula.and'), - render: ary => ary.every(it => it), - }, - { - key: 'OR', - title: tf('formula.or'), - render: ary => ary.some(it => it), - }, - { - key: 'CONCAT', - title: tf('formula.concat'), - render: ary => ary.join(''), - }, - /* support: 1 + A1 + B2 * 3 - { - key: 'DIVIDE', - title: tf('formula.divide'), - render: ary => ary.reduce((a, b) => Number(a) / Number(b)), - }, - { - key: 'PRODUCT', - title: tf('formula.product'), - render: ary => ary.reduce((a, b) => Number(a) * Number(b),1), - }, - { - key: 'SUBTRACT', - title: tf('formula.subtract'), - render: ary => ary.reduce((a, b) => Number(a) - Number(b)), - }, - */ -]; - -const formulas = baseFormulas; - -// const formulas = (formulaAry = []) => { -// const formulaMap = {}; -// baseFormulas.concat(formulaAry).forEach((f) => { -// formulaMap[f.key] = f; -// }); -// return formulaMap; -// }; -const formulam = {}; -baseFormulas.forEach((f) => { - formulam[f.key] = f; -}); - -export default { -}; - -export { - formulam, - formulas, - baseFormulas, -}; diff --git a/src/core/row.js b/src/core/row.js index cc01eb1ce..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 }) { @@ -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; @@ -145,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; @@ -154,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)) @@ -215,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)); } }); } @@ -236,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)); } }); } @@ -253,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; @@ -273,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)); } } }); 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 { diff --git a/src/locale/de.js b/src/locale/de.js index 228cf0b13..18bfd2ae8 100644 --- a/src/locale/de.js +++ b/src/locale/de.js @@ -48,10 +48,11 @@ export default { duration: 'Dauer', }, formula: { - sum: 'Summe', - average: 'Durchschnittliche', - max: 'Max', - min: 'Min', - concat: 'Concat', + SUM: 'Summe', + AVERAGE: 'Durchschnittliche', + MAX: 'Max', + MIN: 'Min', + CONCATENATE: 'Concat', + ROUND: 'Round', }, }; diff --git a/src/locale/en.js b/src/locale/en.js index cd8cdf335..0cd1a4cc8 100644 --- a/src/locale/en.js +++ b/src/locale/en.js @@ -63,14 +63,8 @@ export default { duration: 'Duration', }, formula: { - sum: 'Sum', - average: 'Average', - max: 'Max', - min: 'Min', - _if: 'IF', - and: 'AND', - or: 'OR', - concat: 'Concat', + // Not required + // Will use FormulaJS function names, which are already in English }, validation: { required: 'it must be required', diff --git a/src/locale/locale.js b/src/locale/locale.js index 9d53ae37a..e299c6cf1 100644 --- a/src/locale/locale.js +++ b/src/locale/locale.js @@ -1,23 +1,39 @@ /* 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]; + + // 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]; + + // 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 +49,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; } diff --git a/src/locale/nl.js b/src/locale/nl.js index 6fec014b8..40a180c3a 100644 --- a/src/locale/nl.js +++ b/src/locale/nl.js @@ -48,10 +48,11 @@ export default { duration: 'Duratie', }, formula: { - sum: 'Som', - average: 'Gemiddelde', - max: 'Max', - min: 'Min', - concat: 'Concat', + SUM: 'Som', + AVERAGE: 'Gemiddelde', + MAX: 'Max', + MIN: 'Min', + CONCATENATE: 'Concat', + ROUND: 'Round', }, }; diff --git a/src/locale/zh-cn.js b/src/locale/zh-cn.js index 6ba276e51..58c612051 100644 --- a/src/locale/zh-cn.js +++ b/src/locale/zh-cn.js @@ -63,14 +63,15 @@ export default { duration: '持续时间', }, formula: { - sum: '求和', - average: '求平均值', - max: '求最大值', - min: '求最小值', - concat: '字符拼接', - _if: '条件判断', - and: '和', - or: '或', + SUM: '求和', + AVERAGE: '求平均值', + MAX: '求最大值', + MIN: '求最小值', + CONCATENATE: '字符拼接', + IF: '条件判断', + AND: '和', + OR: '或', + ROUND: '保留小数', }, validation: { required: '此值必填', diff --git a/test/core/alphabet_test.js b/test/core/alphabet_test.js index bea78b51a..c58ba3ec9 100644 --- a/test/core/alphabet_test.js +++ b/test/core/alphabet_test.js @@ -4,67 +4,120 @@ import { describe, it } from 'mocha'; import { indexAt, stringAt, + xy2expr, expr2xy, expr2expr, + expr2cellRangeArgs, + cellRangeArgs2expr, } from '../../src/core/alphabet'; 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'); + assert.equal(stringAt(1 * 26 ** 0 - 1), 'A'); }); - it('should return Z when the value is 25', () => { - assert.equal(stringAt(25), 'Z'); + it('should return AB when the value is 27', () => { + assert.equal(stringAt(1 * 26 ** 1 + 2 * 26 ** 0 - 1), 'AB'); }); - it('should return AA when the value is 26', () => { - assert.equal(stringAt(26), 'AA'); + 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 BC when the value is 54', () => { - assert.equal(stringAt(54), 'BC'); + 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'); }); - it('should return CB when the value is 78', () => { - assert.equal(stringAt(78), 'CA'); + }); + 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 ZA when the value is 26 * 26', () => { - assert.equal(stringAt(26 * 26), 'ZA'); + 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 Z when the value is 26 * 26 + 1', () => { - assert.equal(stringAt((26 * 26) + 1), 'ZB'); + 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 AAA when the value is 26 * 26 + 26', () => { - assert.equal(stringAt((26 * 26) + 26), 'AAA'); + it('should return B$4$ when the value is 1,3 and X/Y are absolute', () => { + assert.equal(xy2expr(1, 3, true, true), '$B$4'); }); }); describe('.expr2xy()', () => { it('should return 0 when the value is A1', () => { - assert.equal(expr2xy('A1')[0], 0); - assert.equal(expr2xy('A1')[1], 0); + const expr = 'A1'; + const ret = expr2xy(expr); + assert.equal(ret[0], 0); + assert.equal(ret[1], 0); + assert.equal(ret[2], false); + assert.equal(ret[3], false); + assert.equal(ret[4], expr.length); + }); + it('should return 1,3 when the value is B4', () => { + const expr = 'B4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], false); + assert.equal(ret[3], false); + assert.equal(ret[4], expr.length); + }); + it('should return that X is absolute when the value is $B4', () => { + const expr = '$B4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], true); + assert.equal(ret[3], false); + assert.equal(ret[4], expr.length); + }); + it('should return that Y is absolute when the value is $B4', () => { + const expr = 'B$4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], false); + assert.equal(ret[3], true); + assert.equal(ret[4], expr.length); + }); + it('should return that X and Y are absolute when the value is $B$4', () => { + const expr = '$B$4'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 3); + assert.equal(ret[2], true); + assert.equal(ret[3], true); + assert.equal(ret[4], expr.length); + }); + // Note: defined REGEX currently supports up to ZZZ (3 letters max) + it('should return 27,999 when the value is $ABC$1000', () => { + const expr = '$ABC$1000'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1 * 26 ** 2 + 2 * 26 ** 1 + 3 * 26 ** 0 - 1); + assert.equal(ret[1], 999); + assert.equal(ret[2], true); + assert.equal(ret[3], true); + assert.equal(ret[4], expr.length); + }); + it('should return 1,29 when the value is B$30:B$335', () => { + const expr = 'B$30:B$335'; + const ret = expr2xy(expr); + assert.equal(ret[0], 1); + assert.equal(ret[1], 29); + assert.equal(ret[2], false); + assert.equal(ret[3], true); + assert.equal(ret[4], 'B$30'.length); }); }); describe('.expr2expr()', () => { @@ -74,5 +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'); + }); }); }); 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'); - }); - }); -}); 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'); + }); + }); +});