diff --git a/src/component/editor.js b/src/component/editor.js index 616824fc..c80dc98d 100644 --- a/src/component/editor.js +++ b/src/component/editor.js @@ -4,45 +4,17 @@ import Suggest from './suggest'; import Datepicker from './datepicker'; import { cssPrefix } from '../config'; // import { mouseMoveUp } from '../event'; - -function resetTextareaSize() { - const { inputText } = this; - if (!/^\s*$/.test(inputText)) { - const { - textlineEl, textEl, areaOffset, - } = this; - const txts = inputText.split('\n'); - const maxTxtSize = Math.max(...txts.map(it => it.length)); - const tlOffset = textlineEl.offset(); - const fontWidth = tlOffset.width / inputText.length; - const tlineWidth = (maxTxtSize + 1) * fontWidth + 5; - const maxWidth = this.viewFn().width - areaOffset.left - fontWidth; - let h1 = txts.length; - if (tlineWidth > areaOffset.width) { - let twidth = tlineWidth; - if (tlineWidth > maxWidth) { - twidth = maxWidth; - h1 += parseInt(tlineWidth / maxWidth, 10); - h1 += (tlineWidth % maxWidth) > 0 ? 1 : 0; - } - textEl.css('width', `${twidth}px`); - } - h1 *= this.rowHeight; - if (h1 > areaOffset.height) { - textEl.css('height', `${h1}px`); - } - } -} +import Formula from './formula'; +import { setCaretPosition, saveCaretPosition } from '../core/caret'; function insertText({ target }, itxt) { const { value, selectionEnd } = target; const ntxt = `${value.slice(0, selectionEnd)}${itxt}${value.slice(selectionEnd)}`; target.value = ntxt; - target.setSelectionRange(selectionEnd + 1, selectionEnd + 1); - this.inputText = ntxt; - this.textlineEl.html(ntxt); - resetTextareaSize.call(this); + this.render(); + + setCaretPosition(target, selectionEnd + 1); } function keydownEventHandler(evt) { @@ -55,72 +27,36 @@ function keydownEventHandler(evt) { if (keyCode === 13 && !altKey) evt.preventDefault(); } -function inputEventHandler(evt) { - const v = evt.target.value; +function inputEventHandler() { + // save caret position + const restore = saveCaretPosition(this.textEl.el); + + const text = this.textEl.el.textContent; + this.inputText = text; // console.log(evt, 'v:', v); - const { suggest, textlineEl, validator } = this; - const { cell } = this; - if (cell !== null) { - if (('editable' in cell && cell.editable === true) || (cell.editable === undefined)) { - this.inputText = v; - if (validator) { - if (validator.type === 'list') { - suggest.search(v); - } else { - suggest.hide(); - } - } else { - const start = v.lastIndexOf('='); - if (start !== -1) { - suggest.search(v.substring(start + 1)); - } else { - suggest.hide(); - } - } - textlineEl.html(v); - resetTextareaSize.call(this); - this.change('input', v); + + const { suggest, validator } = this; + + if (validator) { + if (validator.type === 'list') { + suggest.search(text); } else { - evt.target.value = cell.text; + suggest.hide(); } } else { - this.inputText = v; - if (validator) { - if (validator.type === 'list') { - suggest.search(v); - } else { - suggest.hide(); - } + const start = text.lastIndexOf('='); + if (start !== -1) { + suggest.search(text.substring(start + 1)); } else { - const start = v.lastIndexOf('='); - if (start !== -1) { - suggest.search(v.substring(start + 1)); - } else { - suggest.hide(); - } + suggest.hide(); } - textlineEl.html(v); - resetTextareaSize.call(this); - this.change('input', v); } -} + this.render(); + this.change('input', text); -function setTextareaRange(position) { - const { el } = this.textEl; - setTimeout(() => { - el.focus(); - el.setSelectionRange(position, position); - }, 0); -} - -function setText(text, position) { - const { textEl, textlineEl } = this; - // firefox bug - textEl.el.blur(); - - textEl.val(text); - textlineEl.html(text); - setTextareaRange.call(this, position); + // restore caret postion + // to avoid caret postion missing when this.el.innerHTML changed + restore(); } function suggestItemClick(it) { @@ -143,7 +79,8 @@ function suggestItemClick(it) { position = this.inputText.length; this.inputText += `)${eit}`; } - setText.call(this, this.inputText, position); + this.render(); + setCaretPosition(this.textEl.el, position); } function resetSuggestItems() { @@ -159,9 +96,10 @@ function dateFormat(d) { } export default class Editor { - constructor(formulas, viewFn, rowHeight) { + constructor(formulas, viewFn, data) { + this.data = data; this.viewFn = viewFn; - this.rowHeight = rowHeight; + this.rowHeight = data.rows.height; this.formulas = formulas; this.suggest = new Suggest(formulas, (it) => { suggestItemClick.call(this, it); @@ -172,27 +110,34 @@ export default class Editor { this.setText(dateFormat(d)); this.clear(); }); + this.composing = false; this.areaEl = h('div', `${cssPrefix}-editor-area`) .children( - this.textEl = h('textarea', '') + this.textEl = h('div', 'textarea') + .attr('contenteditable', 'true') .on('input', evt => inputEventHandler.call(this, evt)) - .on('paste.stop', () => {}) - .on('keydown', evt => keydownEventHandler.call(this, evt)), + .on('paste.stop', () => { }) + .on('keydown', evt => keydownEventHandler.call(this, evt)) + .on('compositionstart.stop', () => this.composing = true) + .on('compositionend.stop', () => this.composing = false), this.textlineEl = h('div', 'textline'), this.suggest.el, this.datepicker.el, ) - .on('mousemove.stop', () => {}) - .on('mousedown.stop', () => {}); + .on('mousemove.stop', () => { }) + .on('mousedown.stop', () => { }); this.el = h('div', `${cssPrefix}-editor`) - .child(this.areaEl).hide(); + .children(this.areaEl).hide(); + this.cellEl = h('div', `${cssPrefix}-formula-cell`) this.suggest.bindInputEvents(this.textEl); this.areaOffset = null; this.freeze = { w: 0, h: 0 }; this.cell = null; this.inputText = ''; - this.change = () => {}; + this.change = () => { }; + + this.formula = new Formula(this); } setFreezeLengths(width, height) { @@ -212,13 +157,19 @@ export default class Editor { this.el.hide(); this.textEl.val(''); this.textlineEl.html(''); + this.formula.clear(); resetSuggestItems.call(this); this.datepicker.hide(); } + resetData(data) { + this.data = data; + this.rowHeight = data.rows.height; + } + setOffset(offset, suggestPosition = 'top') { const { - textEl, areaEl, suggest, freeze, el, + textEl, areaEl, suggest, freeze, el, formula } = this; if (offset) { this.areaOffset = offset; @@ -240,11 +191,13 @@ export default class Editor { } el.offset(elOffset); areaEl.offset({ left: left - elOffset.left - 0.8, top: top - elOffset.top - 0.8 }); - textEl.offset({ width: width - 9 + 0.8, height: height - 3 + 0.8 }); + textEl.css('min-width', `${width - 9 + 0.8}px`); + textEl.css('min-height', `${height - 3 + 0.8}px`); const sOffset = { left: 0 }; sOffset[suggestPosition] = height; suggest.setOffset(sOffset); suggest.hide(); + formula.renderCells(); } } @@ -275,7 +228,35 @@ export default class Editor { setText(text) { this.inputText = text; // console.log('text>>:', text); - setText.call(this, text, text.length); - resetTextareaSize.call(this); + + // firefox bug + this.textEl.el.blur(); + + this.render(); + setTimeout(() => { + setCaretPosition(this.textEl.el, text.length); + }) + } + + render() { + if (this.composing) return; + + const text = this.inputText; + + if (text[0] != '=') { + this.textEl.html(text); + } else { + this.formula.render(); + } + + this.textlineEl.html(text); + } + + formulaCellSelecting() { + return Boolean(this.formula.cell); + } + + formulaSelectCell(ri, ci) { + this.formula.selectCell(ri, ci); } } diff --git a/src/component/formula.js b/src/component/formula.js new file mode 100644 index 00000000..ddeee14e --- /dev/null +++ b/src/component/formula.js @@ -0,0 +1,240 @@ +import { stringAt, expr2xy } from '../core/alphabet'; +import { setCaretPosition, getCaretPosition } from '../core/caret'; +import CellRange from '../core/cell_range'; + +function renderCell(left, top, width, height, color, selected = false) { + let style = `position:absolute;box-sizing: border-box;`; + style += `left:${left}px;`; + style += `top:${top}px;`; + style += `width:${width}px;`; + style += `height:${height}px;`; + style += `border:${color} 2px dashed;`; + if (selected) { + style += `background:rgba(101, 101, 101, 0.1);`; + } + return `
`; +} + +export default class Formula { + constructor(editor) { + this.editor = editor; + this.el = this.editor.textEl.el; + this.cellEl = this.editor.cellEl.el; + + this.cells = []; + this.cell = null; + document.addEventListener("selectionchange", () => { + if (document.activeElement !== this.el) return; + + this.cell = null; + if (this.editor.inputText[0] != '=') return; + + const index = getCaretPosition(this.el); + for (let cell of this.cells) { + const { from, to } = cell; + if (from <= index && index <= to) { + this.cell = cell; + break; + } + } + + this.renderCells(); + }); + + this.el.addEventListener("keydown", (e) => { + const keyCode = e.keyCode || e.which; + if ([37, 38, 39, 40].indexOf(keyCode) == -1) return; + + if (!this.cell || this.cell.from == this.cell.to) return; + + e.preventDefault(); + e.stopPropagation(); + + const text = this.editor.inputText; + let expr = text.slice(this.cell.from, this.cell.to); + let [ci, ri] = expr2xy(expr); + + const { merges } = this.editor.data; + let mergeCell = merges.getFirstIncludes(ri, ci); + if (mergeCell) { + ri = mergeCell.sri; + ci = mergeCell.sci; + } + + if (keyCode == 37 && ci >= 1) { + ci -= 1; + } else if (keyCode == 38 && ri >= 1) { + ri -= 1; + } + else if (keyCode == 39) { + if (mergeCell) { + ci = mergeCell.eci; + } + ci += 1; + } + else if (keyCode == 40) { + if (mergeCell) { + ri = mergeCell.eri; + } + ri += 1; + } + + mergeCell = merges.getFirstIncludes(ri, ci); + if (mergeCell) { + ri = mergeCell.sri; + ci = mergeCell.sci; + } + + this.selectCell(ri, ci); + }); + } + + clear() { + this.cell = null; + this.cells = []; + this.cellEl.innerHTML = ''; + } + + selectCell(ri, ci) { + if (this.cell) { + const row = String(ri + 1); + const col = stringAt(ci); + const text = this.editor.inputText; + const { from, to } = this.cell; + + this.editor.inputText = text.slice(0, from) + col + row + text.slice(to); + this.editor.render(); + setTimeout(() => { + setCaretPosition(this.el, from + col.length + row.length); + }); + + this.cell = null; + } + } + + render() { + const text = this.editor.inputText; + this.cells = []; + + let i = 0; + let m = null; + let html = ""; + + const goldenRatio = 0.618033988749895; + let h = 34 / 360; + function pickColor() { + const color = `hsl(${Math.floor(h * 360)}, 90%, 50%)`; + h += goldenRatio; + h %= 1; + return color; + } + + let pre = 0; + while (i < text.length) { + const sub = text.slice(i); + if ((m = sub.match(/^[A-Za-z]+[1-9][0-9]*/))) { + // cell + const color = pickColor(); + html += `${m[0]}`; + + this.cells.push({ + from: i, + to: i + m[0].length, + color, + }); + pre = 1; + i = i + m[0].length; + } else if ((m = sub.match(/^[A-Za-z]+/))) { + // function + html += `${m[0]}`; + pre = 2; + i = i + m[0].length; + } else if ((m = sub.match(/^[0-9.]+/))) { + // number + html += `${m[0]}`; + pre = 3; + i = i + m[0].length; + } else if ((m = sub.match(/^[\+\-\*\/\,\=]/))) { + // operator + html += `${m[0]}`; + if (pre == 4) { + // between two operators + this.cells.push({ + from: i, + to: i, + }); + } + if (text[i - 1] == '(') { + // between '(' and operator + this.cells.push({ + from: i, + to: i, + }); + } + pre = 4; + i = i + 1; + } else if ((m = sub.match(/^[\(\)]/))) { + // parenthesis + html += `${m[0]}`; + if (text[i - 1] == '(' && text[i] == ')') { + // between parenthesis pair + this.cells.push({ + from: i, + to: i, + }); + } + if (pre == 4 && text[i] == ')') { + // between operator and ')' + this.cells.push({ + from: i, + to: i, + }); + } + pre = 5; + i = i + 1; + } else { + // unknown + html += `${text.charAt(i)}`; + pre = 6; + i = i + 1; + } + } + + if (pre == 4) { + // between operator and the end of text + this.cells.push({ + from: text.length, + to: text.length, + }); + } + + // console.log('formula cells', this.cells); + + this.el.innerHTML = html; + } + + renderCells() { + const text = this.editor.inputText; + const cells = this.cells; + const data = this.editor.data; + let cellHtml = ""; + + for (let cell of cells) { + const { from, to, color } = cell; + if (color) { + const [ci, ri] = expr2xy(text.slice(from, to)); + const mergeCell = data.merges.getFirstIncludes(ri, ci); + let box = null; + if (mergeCell) { + box = data.getRect(mergeCell); + } else { + box = data.getRect(new CellRange(ri, ci, ri, ci)); + } + const { left, top, width, height } = box; + cellHtml += renderCell(left, top, width, height, color, this.cell === cell); + } + } + + this.cellEl.innerHTML = cellHtml; + } +} \ No newline at end of file diff --git a/src/component/sheet.js b/src/component/sheet.js index 0c34d2dd..9a982e6d 100644 --- a/src/component/sheet.js +++ b/src/component/sheet.js @@ -86,7 +86,7 @@ function selectorSet(multiple, ri, ci, indexesUpdated = true, moving = false) { // direction: left | right | up | down | row-first | row-last | col-first | col-last function selectorMove(multiple, direction) { const { - selector, data, + selector, data } = this; const { rows, cols } = data; let [ri, ci] = selector.indexes; @@ -585,6 +585,13 @@ function sheetInitEvents() { overlayerMousemove.call(this, evt); }) .on('mousedown', (evt) => { + if (evt.buttons === 1 && evt.detail <= 1 && editor.formulaCellSelecting()) { + const { offsetX, offsetY } = evt; + const { ri, ci } = this.data.getCellRectByXY(offsetX, offsetY); + editor.formulaSelectCell(ri, ci); + return; + } + editor.clear(); contextMenu.hide(); // the left mouse button: mousedown → mouseup → click @@ -869,7 +876,7 @@ export default class Sheet { this.editor = new Editor( formulas, () => this.getTableOffset(), - data.rows.height, + data, ); // data validation this.modalValidation = new ModalValidation(); @@ -881,6 +888,7 @@ export default class Sheet { .children( this.editor.el, this.selector.el, + this.editor.cellEl, ); this.overlayerEl = h('div', `${cssPrefix}-overlayer`) .child(this.overlayerCEl); @@ -923,6 +931,7 @@ export default class Sheet { this.data = data; verticalScrollbarSet.call(this); horizontalScrollbarSet.call(this); + this.editor.resetData(data); this.toolbar.resetData(data); this.print.resetData(data); this.selector.resetData(data); diff --git a/src/core/caret.js b/src/core/caret.js new file mode 100644 index 00000000..c2399ad0 --- /dev/null +++ b/src/core/caret.js @@ -0,0 +1,44 @@ +// Thanks to https://stackoverflow.com/questions/4576694/saving-and-restoring-caret-position-for-contenteditable-div + +export function getCaretPosition(context) { + const selection = window.getSelection(); + const range = selection.getRangeAt(0).cloneRange(); + range.setStart(context, 0); + const index = range.toString().length; + return index; +} + +function getTextNodeAtPosition(root, index) { + const treeWalker = document.createTreeWalker( + root, + NodeFilter.SHOW_TEXT, + function next(elem) { + if (index > elem.textContent.length) { + index -= elem.textContent.length; + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + ); + const c = treeWalker.nextNode(); + return { + node: c ? c : root, + position: index, + }; +} + +export function setCaretPosition(context, index) { + const selection = window.getSelection(); + const pos = getTextNodeAtPosition(context, index); + selection.removeAllRanges(); + const range = new Range(); + range.setStart(pos.node, pos.position); + selection.addRange(range); +} + +export function saveCaretPosition(context) { + const index = getCaretPosition(context); + return function restore() { + setCaretPosition(context, index); + }; +} \ No newline at end of file diff --git a/src/core/data_proxy.js b/src/core/data_proxy.js index c931b1d3..ff780b86 100644 --- a/src/core/data_proxy.js +++ b/src/core/data_proxy.js @@ -111,7 +111,7 @@ const bottombarHeight = 41; // src: cellRange // dst: cellRange -function canPaste(src, dst, error = () => {}) { +function canPaste(src, dst, error = () => { }) { const { merges } = this; const cellRange = dst.clone(); const [srn, scn] = src.size(); @@ -343,7 +343,7 @@ export default class DataProxy { this.history = new History(); this.clipboard = new Clipboard(); this.autoFilter = new AutoFilter(); - this.change = () => {}; + this.change = () => { }; this.exceptRowSet = new Set(); this.sortedRowMap = new Map(); this.unsortedRowMap = new Map(); @@ -442,7 +442,7 @@ export default class DataProxy { } // what: all | text | format - paste(what = 'all', error = () => {}) { + paste(what = 'all', error = () => { }) { // console.log('sIndexes:', sIndexes); const { clipboard, selector } = this; if (clipboard.isClear()) return false; @@ -467,7 +467,7 @@ export default class DataProxy { }); } - autofill(cellRange, what, error = () => {}) { + autofill(cellRange, what, error = () => { }) { const srcRange = this.selector.range; if (!canPaste.call(this, srcRange, cellRange, error)) return false; this.changeData(() => { @@ -493,9 +493,9 @@ export default class DataProxy { if (ri < 0) nri = rows.len - 1; if (ci < 0) nci = cols.len - 1; if (nri > cri) [sri, eri] = [cri, nri]; - else [sri, eri] = [nri, cri]; + else[sri, eri] = [nri, cri]; if (nci > cci) [sci, eci] = [cci, nci]; - else [sci, eci] = [nci, cci]; + else[sci, eci] = [nci, cci]; selector.range = merges.union(new CellRange( sri, sci, eri, eci, )); diff --git a/src/index.less b/src/index.less index 9bc68c75..ed1cfe30 100644 --- a/src/index.less +++ b/src/index.less @@ -67,7 +67,7 @@ body { background: #fff; -webkit-font-smoothing: antialiased; - textarea { + .textarea { font: 400 13px Arial, 'Lato', 'Source Sans Pro', Roboto, Helvetica, sans-serif; } } @@ -383,7 +383,7 @@ body { z-index: 100; pointer-events: auto; - textarea { + .textarea { box-sizing: content-box; border: none; padding: 0 3px; @@ -397,6 +397,11 @@ body { word-wrap: break-word; line-height: 22px; margin: 0; + background-color: white; + + .formula-token { + margin-right: 2px; + } } .textline {