UNPKG

@reactual/handsontable

Version:

Spreadsheet-like data grid editor

878 lines (728 loc) 24.7 kB
import SheetClip from './../lib/SheetClip/SheetClip.js'; import {cellMethodLookupFactory} from './helpers/data'; import {columnFactory} from './helpers/setting'; import {createObjectPropListener, duckSchema, deepExtend, deepClone, isObject, deepObjectSize, hasOwnProperty} from './helpers/object'; import {extendArray, to2dArray} from './helpers/array'; import Interval from './utils/interval'; import {rangeEach} from './helpers/number'; import MultiMap from './multiMap'; import Hooks from './pluginHooks'; /** * Utility class that gets and saves data from/to the data source using mapping of columns numbers to object property names * @todo refactor arguments of methods getRange, getText to be numbers (not objects) * @todo remove priv, GridSettings from object constructor * * @param {Object} instance Instance of Handsontable * @param {*} priv * @param {*} GridSettings Grid settings * @util * @class DataMap */ function DataMap(instance, priv, GridSettings) { this.instance = instance; this.priv = priv; this.GridSettings = GridSettings; this.dataSource = this.instance.getSettings().data; this.cachedLength = null; this.skipCache = false; this.latestSourceRowsCount = 0; if (this.dataSource && this.dataSource[0]) { this.duckSchema = this.recursiveDuckSchema(this.dataSource[0]); } else { this.duckSchema = {}; } this.createMap(); this.interval = Interval.create(() => this.clearLengthCache(), '15fps'); this.instance.addHook('skipLengthCache', (delay) => this.onSkipLengthCache(delay)); this.onSkipLengthCache(500); } DataMap.prototype.DESTINATION_RENDERER = 1; DataMap.prototype.DESTINATION_CLIPBOARD_GENERATOR = 2; /** * @param {Object|Array} object * @returns {Object|Array} */ DataMap.prototype.recursiveDuckSchema = function(object) { return duckSchema(object); }; /** * @param {Object} schema * @param {Number} lastCol * @param {Number} parent * @returns {Number} */ DataMap.prototype.recursiveDuckColumns = function(schema, lastCol, parent) { var prop, i; if (typeof lastCol === 'undefined') { lastCol = 0; parent = ''; } if (typeof schema === 'object' && !Array.isArray(schema)) { for (i in schema) { if (hasOwnProperty(schema, i)) { if (schema[i] === null) { prop = parent + i; this.colToPropCache.push(prop); this.propToColCache.set(prop, lastCol); lastCol++; } else { lastCol = this.recursiveDuckColumns(schema[i], lastCol, `${i}.`); } } } } return lastCol; }; DataMap.prototype.createMap = function() { let i; let schema = this.getSchema(); if (typeof schema === 'undefined') { throw new Error('trying to create `columns` definition but you didn\'t provide `schema` nor `data`'); } this.colToPropCache = []; this.propToColCache = new MultiMap(); let columns = this.instance.getSettings().columns; if (columns) { const maxCols = this.instance.getSettings().maxCols; let columnsLen = Math.min(maxCols, columns.length); let filteredIndex = 0; let columnsAsFunc = false; let schemaLen = deepObjectSize(schema); if (typeof columns === 'function') { columnsLen = schemaLen > 0 ? schemaLen : this.instance.countSourceCols(); columnsAsFunc = true; } for (i = 0; i < columnsLen; i++) { let column = columnsAsFunc ? columns(i) : columns[i]; if (isObject(column)) { if (typeof column.data !== 'undefined') { let index = columnsAsFunc ? filteredIndex : i; this.colToPropCache[index] = column.data; this.propToColCache.set(column.data, index); } filteredIndex++; } } } else { this.recursiveDuckColumns(schema); } }; /** * Returns property name that corresponds with the given column index. * * @param {Number} col Visual column index. * @returns {Number} Physical column index. */ DataMap.prototype.colToProp = function(col) { col = this.instance.runHooks('modifyCol', col); if (!isNaN(col) && this.colToPropCache && typeof this.colToPropCache[col] !== 'undefined') { return this.colToPropCache[col]; } return col; }; /** * @param {Object} prop * @fires Hooks#modifyCol * @returns {*} */ DataMap.prototype.propToCol = function(prop) { var col; if (typeof this.propToColCache.get(prop) === 'undefined') { col = prop; } else { col = this.propToColCache.get(prop); } col = this.instance.runHooks('unmodifyCol', col); return col; }; /** * @returns {Object} */ DataMap.prototype.getSchema = function() { var schema = this.instance.getSettings().dataSchema; if (schema) { if (typeof schema === 'function') { return schema(); } return schema; } return this.duckSchema; }; /** * Creates row at the bottom of the data array. * * @param {Number} [index] Physical index of the row before which the new row will be inserted. * @param {Number} [amount] An amount of rows to add. * @param {String} [source] Source of method call. * @fires Hooks#afterCreateRow * @returns {Number} Returns number of created rows. */ DataMap.prototype.createRow = function(index, amount, source) { var row, colCount = this.instance.countCols(), numberOfCreatedRows = 0, currentIndex; if (!amount) { amount = 1; } if (typeof index !== 'number' || index >= this.instance.countSourceRows()) { index = this.instance.countSourceRows(); } this.instance.runHooks('beforeCreateRow', index, amount, source); currentIndex = index; var maxRows = this.instance.getSettings().maxRows; while (numberOfCreatedRows < amount && this.instance.countSourceRows() < maxRows) { if (this.instance.dataType === 'array') { if (this.instance.getSettings().dataSchema) { // Clone template array row = deepClone(this.getSchema()); } else { row = []; /* eslint-disable no-loop-func */ rangeEach(colCount - 1, () => row.push(null)); } } else if (this.instance.dataType === 'function') { row = this.instance.getSettings().dataSchema(index); } else { row = {}; deepExtend(row, this.getSchema()); } if (index === this.instance.countSourceRows()) { this.dataSource.push(row); } else { this.spliceData(index, 0, row); } numberOfCreatedRows++; currentIndex++; } this.instance.runHooks('afterCreateRow', index, numberOfCreatedRows, source); this.instance.forceFullRender = true; // used when data was changed return numberOfCreatedRows; }; /** * Creates col at the right of the data array. * * @param {Number} [index] Visual index of the column before which the new column will be inserted * @param {Number} [amount] An amount of columns to add. * @param {String} [source] Source of method call. * @fires Hooks#afterCreateCol * @returns {Number} Returns number of created columns */ DataMap.prototype.createCol = function(index, amount, source) { if (!this.instance.isColumnModificationAllowed()) { throw new Error('Cannot create new column. When data source in an object, ' + 'you can only have as much columns as defined in first data row, data schema or in the \'columns\' setting.' + 'If you want to be able to add new columns, you have to use array datasource.'); } var rlen = this.instance.countSourceRows(), data = this.dataSource, constructor, numberOfCreatedCols = 0, currentIndex; if (!amount) { amount = 1; } if (typeof index !== 'number' || index >= this.instance.countCols()) { index = this.instance.countCols(); } this.instance.runHooks('beforeCreateCol', index, amount, source); currentIndex = index; var maxCols = this.instance.getSettings().maxCols; while (numberOfCreatedCols < amount && this.instance.countCols() < maxCols) { constructor = columnFactory(this.GridSettings, this.priv.columnsSettingConflicts); if (typeof index !== 'number' || index >= this.instance.countCols()) { if (rlen > 0) { for (var r = 0; r < rlen; r++) { if (typeof data[r] === 'undefined') { data[r] = []; } data[r].push(null); } } else { data.push([null]); } // Add new column constructor this.priv.columnSettings.push(constructor); } else { for (let r = 0; r < rlen; r++) { data[r].splice(currentIndex, 0, null); } // Add new column constructor at given index this.priv.columnSettings.splice(currentIndex, 0, constructor); } numberOfCreatedCols++; currentIndex++; } this.instance.runHooks('afterCreateCol', index, numberOfCreatedCols, source); this.instance.forceFullRender = true; // used when data was changed return numberOfCreatedCols; }; /** * Removes row from the data array. * * @param {Number} [index] Visual index of the row to be removed. If not provided, the last row will be removed * @param {Number} [amount] Amount of the rows to be removed. If not provided, one row will be removed * @param {String} [source] Source of method call. * @fires Hooks#beforeRemoveRow * @fires Hooks#afterRemoveRow */ DataMap.prototype.removeRow = function(index, amount, source) { if (!amount) { amount = 1; } if (typeof index !== 'number') { index = -amount; } amount = this.instance.runHooks('modifyRemovedAmount', amount, index); index = (this.instance.countSourceRows() + index) % this.instance.countSourceRows(); let logicRows = this.visualRowsToPhysical(index, amount); let actionWasNotCancelled = this.instance.runHooks('beforeRemoveRow', index, amount, logicRows, source); if (actionWasNotCancelled === false) { return; } let data = this.dataSource; let newData; newData = this.filterData(index, amount); if (newData) { data.length = 0; Array.prototype.push.apply(data, newData); } this.instance.runHooks('afterRemoveRow', index, amount, logicRows, source); this.instance.forceFullRender = true; // used when data was changed }; /** * Removes column from the data array. * * @param {Number} [index] Visual index of the column to be removed. If not provided, the last column will be removed * @param {Number} [amount] Amount of the columns to be removed. If not provided, one column will be removed * @param {String} [source] Source of method call. * @fires Hooks#beforeRemoveCol * @fires Hooks#afterRemoveCol */ DataMap.prototype.removeCol = function(index, amount, source) { if (this.instance.dataType === 'object' || this.instance.getSettings().columns) { throw new Error('cannot remove column with object data source or columns option specified'); } if (!amount) { amount = 1; } if (typeof index !== 'number') { index = -amount; } index = (this.instance.countCols() + index) % this.instance.countCols(); let logicColumns = this.visualColumnsToPhysical(index, amount); let descendingLogicColumns = logicColumns.slice(0).sort((a, b) => b - a); let actionWasNotCancelled = this.instance.runHooks('beforeRemoveCol', index, amount, logicColumns, source); if (actionWasNotCancelled === false) { return; } let isTableUniform = true; let removedColumnsCount = descendingLogicColumns.length; let data = this.dataSource; for (let c = 0; c < removedColumnsCount; c++) { if (isTableUniform && logicColumns[0] !== logicColumns[c] - c) { isTableUniform = false; } } if (isTableUniform) { for (let r = 0, rlen = this.instance.countSourceRows(); r < rlen; r++) { data[r].splice(logicColumns[0], amount); } } else { for (let r = 0, rlen = this.instance.countSourceRows(); r < rlen; r++) { for (let c = 0; c < removedColumnsCount; c++) { data[r].splice(descendingLogicColumns[c], 1); } } for (let c = 0; c < removedColumnsCount; c++) { this.priv.columnSettings.splice(logicColumns[c], 1); } } this.instance.runHooks('afterRemoveCol', index, amount, logicColumns, source); this.instance.forceFullRender = true; // used when data was changed }; /** * Add/Removes data from the column. * * @param {Number} col Physical index of column in which do you want to do splice * @param {Number} index Index at which to start changing the array. If negative, will begin that many elements from the end * @param {Number} amount An integer indicating the number of old array elements to remove. If amount is 0, no elements are removed * @returns {Array} Returns removed portion of columns */ DataMap.prototype.spliceCol = function(col, index, amount/* , elements... */) { var elements = arguments.length >= 4 ? [].slice.call(arguments, 3) : []; var colData = this.instance.getDataAtCol(col); var removed = colData.slice(index, index + amount); var after = colData.slice(index + amount); extendArray(elements, after); var i = 0; while (i < amount) { elements.push(null); // add null in place of removed elements i++; } to2dArray(elements); this.instance.populateFromArray(index, col, elements, null, null, 'spliceCol'); return removed; }; /** * Add/Removes data from the row. * * @param {Number} row Physical index of row in which do you want to do splice * @param {Number} index Index at which to start changing the array. If negative, will begin that many elements from the end. * @param {Number} amount An integer indicating the number of old array elements to remove. If amount is 0, no elements are removed. * @returns {Array} Returns removed portion of rows */ DataMap.prototype.spliceRow = function(row, index, amount/* , elements... */) { var elements = arguments.length >= 4 ? [].slice.call(arguments, 3) : []; var rowData = this.instance.getSourceDataAtRow(row); var removed = rowData.slice(index, index + amount); var after = rowData.slice(index + amount); extendArray(elements, after); var i = 0; while (i < amount) { elements.push(null); // add null in place of removed elements i++; } this.instance.populateFromArray(row, index, [elements], null, null, 'spliceRow'); return removed; }; /** * Add/remove row(s) to/from the data source. * * @param {Number} index Physical index of the element to remove. * @param {Number} amount Number of rows to add/remove. * @param {Object} element Row to add. */ DataMap.prototype.spliceData = function(index, amount, element) { let continueSplicing = this.instance.runHooks('beforeDataSplice', index, amount, element); if (continueSplicing !== false) { this.dataSource.splice(index, amount, element); } }; /** * Filter unwanted data elements from the data source. * * @param {Number} index Visual index of the element to remove. * @param {Number} amount Number of rows to add/remove. * @returns {Array} */ DataMap.prototype.filterData = function(index, amount) { let physicalRows = this.visualRowsToPhysical(index, amount); let continueSplicing = this.instance.runHooks('beforeDataFilter', index, amount, physicalRows); if (continueSplicing !== false) { let newData = this.dataSource.filter((row, index) => physicalRows.indexOf(index) == -1); return newData; } }; /** * Returns single value from the data array. * * @param {Number} row Visual row index. * @param {Number} prop */ DataMap.prototype.get = function(row, prop) { row = this.instance.runHooks('modifyRow', row); let dataRow = this.dataSource[row]; // TODO: To remove, use 'modifyData' hook instead (see below) let modifiedRowData = this.instance.runHooks('modifyRowData', row); dataRow = isNaN(modifiedRowData) ? modifiedRowData : dataRow; // let value = null; // try to get value under property `prop` (includes dot) if (dataRow && dataRow.hasOwnProperty && hasOwnProperty(dataRow, prop)) { value = dataRow[prop]; } else if (typeof prop === 'string' && prop.indexOf('.') > -1) { let sliced = prop.split('.'); let out = dataRow; if (!out) { return null; } for (let i = 0, ilen = sliced.length; i < ilen; i++) { out = out[sliced[i]]; if (typeof out === 'undefined') { return null; } } value = out; } else if (typeof prop === 'function') { /** * allows for interacting with complex structures, for example * d3/jQuery getter/setter properties: * * {columns: [{ * data: function(row, value){ * if(arguments.length === 1){ * return row.property(); * } * row.property(value); * } * }]} */ value = prop(this.dataSource.slice(row, row + 1)[0]); } if (this.instance.hasHook('modifyData')) { const valueHolder = createObjectPropListener(value); this.instance.runHooks('modifyData', row, this.propToCol(prop), valueHolder, 'get'); if (valueHolder.isTouched()) { value = valueHolder.value; } } return value; }; var copyableLookup = cellMethodLookupFactory('copyable', false); /** * Returns single value from the data array (intended for clipboard copy to an external application). * * @param {Number} row Physical row index. * @param {Number} prop * @returns {String} */ DataMap.prototype.getCopyable = function(row, prop) { if (copyableLookup.call(this.instance, row, this.propToCol(prop))) { return this.get(row, prop); } return ''; }; /** * Saves single value to the data array. * * @param {Number} row Visual row index. * @param {Number} prop * @param {String} value * @param {String} [source] Source of hook runner. */ DataMap.prototype.set = function(row, prop, value, source) { row = this.instance.runHooks('modifyRow', row, source || 'datamapGet'); let dataRow = this.dataSource[row]; // TODO: To remove, use 'modifyData' hook instead (see below) let modifiedRowData = this.instance.runHooks('modifyRowData', row); dataRow = isNaN(modifiedRowData) ? modifiedRowData : dataRow; // if (this.instance.hasHook('modifyData')) { const valueHolder = createObjectPropListener(value); this.instance.runHooks('modifyData', row, this.propToCol(prop), valueHolder, 'set'); if (valueHolder.isTouched()) { value = valueHolder.value; } } // try to set value under property `prop` (includes dot) if (dataRow && dataRow.hasOwnProperty && hasOwnProperty(dataRow, prop)) { dataRow[prop] = value; } else if (typeof prop === 'string' && prop.indexOf('.') > -1) { let sliced = prop.split('.'); let out = dataRow; let i = 0; let ilen; for (i = 0, ilen = sliced.length - 1; i < ilen; i++) { if (typeof out[sliced[i]] === 'undefined') { out[sliced[i]] = {}; } out = out[sliced[i]]; } out[sliced[i]] = value; } else if (typeof prop === 'function') { /* see the `function` handler in `get` */ prop(this.dataSource.slice(row, row + 1)[0], value); } else { dataRow[prop] = value; } }; /** * This ridiculous piece of code maps rows Id that are present in table data to those displayed for user. * The trick is, the physical row id (stored in settings.data) is not necessary the same * as the visual (displayed) row id (e.g. when sorting is applied). * * @param {Number} index Visual row index. * @param {Number} amount * @fires Hooks#modifyRow * @returns {Number} */ DataMap.prototype.visualRowsToPhysical = function(index, amount) { var totalRows = this.instance.countSourceRows(); var physicRow = (totalRows + index) % totalRows; var logicRows = []; var rowsToRemove = amount; var row; while (physicRow < totalRows && rowsToRemove) { row = this.instance.runHooks('modifyRow', physicRow); logicRows.push(row); rowsToRemove--; physicRow++; } return logicRows; }; /** * * @param index Visual column index. * @param amount * @returns {Array} */ DataMap.prototype.visualColumnsToPhysical = function(index, amount) { let totalCols = this.instance.countCols(); let physicalCol = (totalCols + index) % totalCols; let visualCols = []; let colsToRemove = amount; while (physicalCol < totalCols && colsToRemove) { let col = this.instance.runHooks('modifyCol', physicalCol); visualCols.push(col); colsToRemove--; physicalCol++; } return visualCols; }; /** * Clears the data array. */ DataMap.prototype.clear = function() { for (var r = 0; r < this.instance.countSourceRows(); r++) { for (var c = 0; c < this.instance.countCols(); c++) { this.set(r, this.colToProp(c), ''); } } }; /** * Clear cached data length. */ DataMap.prototype.clearLengthCache = function() { this.cachedLength = null; }; /** * Get data length. * * @returns {Number} */ DataMap.prototype.getLength = function() { let maxRows, maxRowsFromSettings = this.instance.getSettings().maxRows; if (maxRowsFromSettings < 0 || maxRowsFromSettings === 0) { maxRows = 0; } else { maxRows = maxRowsFromSettings || Infinity; } let length = this.instance.countSourceRows(); if (this.instance.hasHook('modifyRow')) { let reValidate = this.skipCache; this.interval.start(); if (length !== this.latestSourceRowsCount) { reValidate = true; } this.latestSourceRowsCount = length; if (this.cachedLength === null || reValidate) { rangeEach(length - 1, (row) => { row = this.instance.runHooks('modifyRow', row); if (row === null) { --length; } }); this.cachedLength = length; } else { length = this.cachedLength; } } else { this.interval.stop(); } return Math.min(length, maxRows); }; /** * Returns the data array. * * @returns {Array} */ DataMap.prototype.getAll = function() { const start = { row: 0, col: 0, }; let end = { row: Math.max(this.instance.countSourceRows() - 1, 0), col: Math.max(this.instance.countCols() - 1, 0), }; if (start.row - end.row === 0 && !this.instance.countSourceRows()) { return []; } return this.getRange(start, end, DataMap.prototype.DESTINATION_RENDERER); }; /** * Returns data range as array. * * @param {Object} [start] Start selection position. Visual indexes. * @param {Object} [end] End selection position. Visual indexes. * @param {Number} destination Destination of datamap.get * @returns {Array} */ DataMap.prototype.getRange = function(start, end, destination) { var r, rlen, c, clen, output = [], row; const maxRows = this.instance.getSettings().maxRows; const maxCols = this.instance.getSettings().maxCols; if (maxRows === 0 || maxCols === 0) { return []; } var getFn = destination === this.DESTINATION_CLIPBOARD_GENERATOR ? this.getCopyable : this.get; rlen = Math.min(Math.max(maxRows - 1, 0), Math.max(start.row, end.row)); clen = Math.min(Math.max(maxCols - 1, 0), Math.max(start.col, end.col)); for (r = Math.min(start.row, end.row); r <= rlen; r++) { row = []; let physicalRow = this.instance.runHooks('modifyRow', r); for (c = Math.min(start.col, end.col); c <= clen; c++) { if (physicalRow === null) { break; } row.push(getFn.call(this, r, this.colToProp(c))); } if (physicalRow !== null) { output.push(row); } } return output; }; /** * Return data as text (tab separated columns). * * @param {Object} [start] Start selection position. Visual indexes. * @param {Object} [end] End selection position. Visual indexes. * @returns {String} */ DataMap.prototype.getText = function(start, end) { return SheetClip.stringify(this.getRange(start, end, this.DESTINATION_RENDERER)); }; /** * Return data as copyable text (tab separated columns intended for clipboard copy to an external application). * * @param {Object} [start] Start selection position. Visual indexes. * @param {Object} [end] End selection position. Visual indexes. * @returns {String} */ DataMap.prototype.getCopyableText = function(start, end) { return SheetClip.stringify(this.getRange(start, end, this.DESTINATION_CLIPBOARD_GENERATOR)); }; /** * `skipLengthCache` callback. * @private * @param {Number} delay Time of the delay in milliseconds. */ DataMap.prototype.onSkipLengthCache = function(delay) { this.skipCache = true; setTimeout(() => { this.skipCache = false; }, delay); }; /** * Destroy instance. */ DataMap.prototype.destroy = function() { this.interval.stop(); this.interval = null; this.instance = null; this.priv = null; this.GridSettings = null; this.dataSource = null; this.cachedLength = null; this.duckSchema = null; }; export default DataMap;