UNPKG

handsontable

Version:

Handsontable is a JavaScript Data Grid available for React, Angular and Vue.

566 lines (534 loc) • 20.4 kB
import "core-js/modules/es.error.cause.js"; function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; } function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } import { stringify } from "../../helpers/mixed.mjs"; import { mixin } from "../../helpers/object.mjs"; import hooksRefRegisterer from "../../mixins/hooksRefRegisterer.mjs"; import { getScrollbarWidth, offset, hasVerticalScrollbar, hasHorizontalScrollbar, outerWidth, outerHeight } from "../../helpers/dom/element.mjs"; export const EDITOR_TYPE = 'base'; export const EDITOR_STATE = Object.freeze({ VIRGIN: 'STATE_VIRGIN', // before editing EDITING: 'STATE_EDITING', WAITING: 'STATE_WAITING', // waiting for async validation FINISHED: 'STATE_FINISHED' }); /** * @class BaseEditor */ export class BaseEditor { static get EDITOR_TYPE() { return EDITOR_TYPE; } /** * A reference to the source instance of the Handsontable. * * @type {Handsontable} */ /** * @param {Handsontable} hotInstance A reference to the source instance of the Handsontable. */ constructor(hotInstance) { _defineProperty(this, "hot", void 0); /** * Editor's state. * * @type {string} */ _defineProperty(this, "state", EDITOR_STATE.VIRGIN); /** * Flag to store information about editor's opening status. * * @private * * @type {boolean} */ _defineProperty(this, "_opened", false); /** * Defines the editor's editing mode. When false, then an editor works in fast editing mode. * * @private * * @type {boolean} */ _defineProperty(this, "_fullEditMode", false); /** * Callback to call after closing editor. * * @type {Function} */ _defineProperty(this, "_closeCallback", null); /** * Currently rendered cell's TD element. * * @type {HTMLTableCellElement} */ _defineProperty(this, "TD", null); /** * Visual row index. * * @type {number} */ _defineProperty(this, "row", null); /** * Visual column index. * * @type {number} */ _defineProperty(this, "col", null); /** * Column property name or a column index, if datasource is an array of arrays. * * @type {number|string} */ _defineProperty(this, "prop", null); /** * Original cell's value. * * @type {*} */ _defineProperty(this, "originalValue", null); /** * Object containing the cell's properties. * * @type {object} */ _defineProperty(this, "cellProperties", null); this.hot = hotInstance; this.init(); } /** * Fires callback after closing editor. * * @private * @param {boolean} result The editor value. */ _fireCallbacks(result) { if (this._closeCallback) { this._closeCallback(result); this._closeCallback = null; } } /** * Initializes an editor's intance. */ init() {} /** * Required method to get current value from editable element. */ getValue() { throw Error('Editor getValue() method unimplemented'); } /** * Required method to set new value into editable element. */ setValue() { throw Error('Editor setValue() method unimplemented'); } /** * Required method to open editor. */ open() { throw Error('Editor open() method unimplemented'); } /** * Required method to close editor. */ close() { throw Error('Editor close() method unimplemented'); } /** * Prepares editor's meta data. * * @param {number} row The visual row index. * @param {number} col The visual column index. * @param {number|string} prop The column property (passed when datasource is an array of objects). * @param {HTMLTableCellElement} td The rendered cell element. * @param {*} value The rendered value. * @param {object} cellProperties The cell meta object (see {@link Core#getCellMeta}). */ prepare(row, col, prop, td, value, cellProperties) { this.TD = td; this.row = row; this.col = col; this.prop = prop; this.originalValue = value; this.cellProperties = cellProperties; this.state = this.isOpened() ? this.state : EDITOR_STATE.VIRGIN; } /** * Fallback method to provide extendable editors in ES5. * * @returns {Function} */ extend() { return class Editor extends this.constructor {}; } /** * Saves value from editor into data storage. * * @param {*} value The editor value. * @param {boolean} ctrlDown If `true`, applies value to each cell in the last selected range. */ saveValue(value, ctrlDown) { let visualRowFrom; let visualColumnFrom; let visualRowTo; let visualColumnTo; // if ctrl+enter and multiple cells selected, behave like Excel (finish editing and apply to all cells) if (ctrlDown) { const activeRange = this.hot.getSelectedRangeActive(); const topStartCorner = activeRange.getTopStartCorner(); const bottomEndCorner = activeRange.getBottomEndCorner(); visualRowFrom = topStartCorner.row; visualColumnFrom = topStartCorner.col; visualRowTo = bottomEndCorner.row; visualColumnTo = bottomEndCorner.col; } else { [visualRowFrom, visualColumnFrom, visualRowTo, visualColumnTo] = [this.row, this.col, null, null]; } const modifiedCellCoords = this.hot.runHooks('modifyGetCellCoords', visualRowFrom, visualColumnFrom, false, 'meta'); if (Array.isArray(modifiedCellCoords)) { [visualRowFrom, visualColumnFrom] = modifiedCellCoords; } // Saving values using the modified coordinates. this.hot.populateFromArray(visualRowFrom, visualColumnFrom, value, visualRowTo, visualColumnTo, 'edit'); } /** * Begins editing on a highlighted cell and hides fillHandle corner if was present. * * @param {*} newInitialValue The initial editor value. * @param {Event} event The keyboard event object. */ beginEditing(newInitialValue, event) { if (this.state !== EDITOR_STATE.VIRGIN) { return; } const hotInstance = this.hot; // We have to convert visual indexes into renderable indexes // due to hidden columns don't participate in the rendering process const renderableRowIndex = hotInstance.rowIndexMapper.getRenderableFromVisualIndex(this.row); const renderableColumnIndex = hotInstance.columnIndexMapper.getRenderableFromVisualIndex(this.col); const openEditor = () => { this.state = EDITOR_STATE.EDITING; // Set the editor value only in the full edit mode. In other mode the focusable element has to be empty, // otherwise IME (editor for Asia users) doesn't work. if (this.isInFullEditMode()) { const originalValue = this.cellProperties.valueGetter ? this.cellProperties.valueGetter(this.originalValue) : this.originalValue; const stringifiedInitialValue = typeof newInitialValue === 'string' ? newInitialValue : stringify(originalValue); this.setValue(stringifiedInitialValue); } this.open(event); this._opened = true; this.focus(); // only rerender the selections (FillHandle should disappear when beginEditing is triggered) hotInstance.view.render(); hotInstance.runHooks('afterBeginEditing', this.row, this.col); }; this.hot.addHookOnce('afterScroll', openEditor); const wasScroll = hotInstance.view.scrollViewport(hotInstance._createCellCoords(renderableRowIndex, renderableColumnIndex)); if (!wasScroll) { this.hot.removeHook('afterScroll', openEditor); openEditor(); } this.addHook('beforeDialogShow', () => this.cancelChanges()); } /** * Finishes editing and start saving or restoring process for editing cell or last selected range. * * @param {boolean} restoreOriginalValue If true, then closes editor without saving value from the editor into a cell. * @param {boolean} ctrlDown If true, then saveValue will save editor's value to each cell in the last selected range. * @param {Function} callback The callback function, fired after editor closing. */ finishEditing(restoreOriginalValue, ctrlDown, callback) { let val; if (callback) { const previousCloseCallback = this._closeCallback; this._closeCallback = result => { if (previousCloseCallback) { previousCloseCallback(result); } callback(result); this.hot.view.render(); }; } if (this.isWaiting()) { return; } if (this.state === EDITOR_STATE.VIRGIN) { this.hot._registerTimeout(() => { this._fireCallbacks(true); }); return; } if (this.state === EDITOR_STATE.EDITING) { if (restoreOriginalValue) { this.cancelChanges(); this.hot.view.render(); return; } const value = this.getValue(); if (this.cellProperties.trimWhitespace) { // We trim only string values val = [[typeof value === 'string' ? String.prototype.trim.call(value || '') : value]]; } else { val = [[value]]; } this.state = EDITOR_STATE.WAITING; this.saveValue(val, ctrlDown); if (this.hot.getCellValidator(this.cellProperties)) { this.hot.addHookOnce('postAfterValidate', result => { this.state = EDITOR_STATE.FINISHED; this.discardEditor(result); }); } else { this.state = EDITOR_STATE.FINISHED; this.discardEditor(true); } } } /** * Finishes editing without singout saving value. */ cancelChanges() { this.state = EDITOR_STATE.FINISHED; this.discardEditor(); } /** * Verifies result of validation or closes editor if user's cancelled changes. * * @param {boolean|undefined} result If `false` and the cell using allowInvalid option, * then an editor won't be closed until validation is passed. */ discardEditor(result) { if (this.state !== EDITOR_STATE.FINISHED) { return; } // validator was defined and failed if (result === false && this.cellProperties.allowInvalid !== true) { this.hot.selectCell(this.row, this.col); this.focus(); this.state = EDITOR_STATE.EDITING; this._fireCallbacks(false); } else { this.close(); this._opened = false; this._fullEditMode = false; this.state = EDITOR_STATE.VIRGIN; this._fireCallbacks(true); const shortcutManager = this.hot.getShortcutManager(); shortcutManager.setActiveContextName('grid'); } } /** * Switch editor into full edit mode. In this state navigation keys don't close editor. This mode is activated * automatically after hit ENTER or F2 key on the cell or while editing cell press F2 key. */ enableFullEditMode() { this._fullEditMode = true; } /** * Checks if editor is in full edit mode. * * @returns {boolean} */ isInFullEditMode() { return this._fullEditMode; } /** * Returns information whether the editor is open. * * @returns {boolean} */ isOpened() { return this._opened; } /** * Returns information whether the editor is waiting, eg.: for async validation. * * @returns {boolean} */ isWaiting() { return this.state === EDITOR_STATE.WAITING; } /* eslint-disable jsdoc/require-description-complete-sentence */ /** * Gets the object that provides information about the edited cell size and its position * relative to the table viewport. * * The rectangle has six integer properties: * - `top` The top position relative to the table viewport * - `start` The left (or right in RTL) position relative to the table viewport * - `width` The cell's current width; * - `maxWidth` The maximum cell's width after which the editor goes out of the table viewport * - `height` The cell's current height; * - `maxHeight` The maximum cell's height after which the editor goes out of the table viewport * * @returns {{top: number, start: number, width: number, maxWidth: number, height: number, maxHeight: number} | undefined} */ getEditedCellRect() { var _wtOverlays$getParent; const TD = this.getEditedCell(); // TD is outside of the viewport. if (!TD) { return; } const { wtOverlays, wtViewport } = this.hot.view._wt; const rootWindow = this.hot.rootWindow; const currentOffset = offset(TD); const cellWidth = outerWidth(TD); const containerOffset = offset(this.hot.rootElement); const containerWidth = outerWidth(this.hot.rootElement); const scrollableContainerTop = wtOverlays.topOverlay.holder; const scrollableContainerLeft = wtOverlays.inlineStartOverlay.holder; const containerScrollTop = scrollableContainerTop !== rootWindow ? scrollableContainerTop.scrollTop : 0; const containerScrollLeft = scrollableContainerLeft !== rootWindow ? scrollableContainerLeft.scrollLeft : 0; const gridMostRightPos = rootWindow.innerWidth - containerOffset.left - containerWidth; const { wtTable: overlayTable } = (_wtOverlays$getParent = wtOverlays.getParentOverlay(TD)) !== null && _wtOverlays$getParent !== void 0 ? _wtOverlays$getParent : this.hot.view._wt; const overlayName = overlayTable.name; const scrollTop = ['master', 'inline_start'].includes(overlayName) ? containerScrollTop : 0; const scrollLeft = ['master', 'top', 'bottom'].includes(overlayName) ? containerScrollLeft : 0; // If colHeaders is disabled, cells in the first row have border-top const editTopModifier = currentOffset.top === containerOffset.top ? 0 : 1; let topPos = currentOffset.top - containerOffset.top - editTopModifier - scrollTop; let inlineStartPos = 0; if (this.hot.isRtl()) { inlineStartPos = rootWindow.innerWidth - currentOffset.left - cellWidth - gridMostRightPos - 1 + scrollLeft; } else { inlineStartPos = currentOffset.left - containerOffset.left - 1 - scrollLeft; } // When the scrollable element is Window object then the editor position needs to be compensated // by the overlays' position (position relative to the table viewport). In other cases, the overlay's // position always returns 0. if (['top', 'top_inline_start_corner'].includes(overlayName)) { topPos += wtOverlays.topOverlay.getOverlayOffset(); } if (['inline_start', 'top_inline_start_corner'].includes(overlayName)) { inlineStartPos += Math.abs(wtOverlays.inlineStartOverlay.getOverlayOffset()); } const hasColumnHeaders = this.hot.hasColHeaders(); const renderableRow = this.hot.rowIndexMapper.getRenderableFromVisualIndex(this.row); const renderableColumn = this.hot.columnIndexMapper.getRenderableFromVisualIndex(this.col); const nrOfRenderableRowIndexes = this.hot.rowIndexMapper.getRenderableIndexesLength(); const firstRowIndexOfTheBottomOverlay = nrOfRenderableRowIndexes - this.hot.view._wt.getSetting('fixedRowsBottom'); if (hasColumnHeaders && renderableRow <= 0 || renderableRow === firstRowIndexOfTheBottomOverlay) { topPos += 1; } if (renderableColumn <= 0) { inlineStartPos += 1; } const firstRowOffset = wtViewport.rowsRenderCalculator.startPosition; const firstColumnOffset = wtViewport.columnsRenderCalculator.startPosition; const horizontalScrollPosition = Math.abs(wtOverlays.inlineStartOverlay.getScrollPosition()); const verticalScrollPosition = wtOverlays.topOverlay.getScrollPosition(); const scrollbarWidth = getScrollbarWidth(this.hot.rootDocument); let cellTopOffset = TD.offsetTop; if (['inline_start', 'master'].includes(overlayName)) { cellTopOffset += firstRowOffset - verticalScrollPosition; } if (['bottom', 'bottom_inline_start_corner'].includes(overlayName)) { const { wtViewport: bottomWtViewport, wtTable: bottomWtTable } = wtOverlays.bottomOverlay.clone; cellTopOffset += bottomWtViewport.getWorkspaceHeight() - bottomWtTable.getHeight() - scrollbarWidth; } let cellStartOffset = TD.offsetLeft; if (this.hot.isRtl()) { if (cellStartOffset >= 0) { cellStartOffset = overlayTable.getWidth() - TD.offsetLeft; } else { // The `offsetLeft` returns negative values when the parent offset element has position relative // (it happens when on the cell the selection is applied - the `area` CSS class). // When it happens the `offsetLeft` value is calculated from the right edge of the parent element. cellStartOffset = Math.abs(cellStartOffset); } cellStartOffset += firstColumnOffset - horizontalScrollPosition - cellWidth; } else if (['top', 'master', 'bottom'].includes(overlayName)) { cellStartOffset += firstColumnOffset - horizontalScrollPosition; } const cellComputedStyle = rootWindow.getComputedStyle(this.TD); const borderPhysicalWidthProp = this.hot.isRtl() ? 'borderRightWidth' : 'borderLeftWidth'; const inlineStartBorderCompensation = parseInt(cellComputedStyle[borderPhysicalWidthProp], 10) > 0 ? 0 : 1; const topBorderCompensation = parseInt(cellComputedStyle.borderTopWidth, 10) > 0 ? 0 : 1; const width = outerWidth(TD) + inlineStartBorderCompensation; const height = outerHeight(TD) + topBorderCompensation; const actualVerticalScrollbarWidth = hasVerticalScrollbar(scrollableContainerTop) ? scrollbarWidth : 0; const actualHorizontalScrollbarWidth = hasHorizontalScrollbar(scrollableContainerLeft) ? scrollbarWidth : 0; const maxWidth = this.hot.view.maximumVisibleElementWidth(cellStartOffset) - actualVerticalScrollbarWidth + inlineStartBorderCompensation; const maxHeight = Math.max(this.hot.view.maximumVisibleElementHeight(cellTopOffset) - actualHorizontalScrollbarWidth + topBorderCompensation, this.hot.stylesHandler.getDefaultRowHeight()); return { top: topPos, start: inlineStartPos, height, maxHeight, width, maxWidth }; } /* eslint-enable jsdoc/require-description-complete-sentence */ /** * Gets className of the edited cell if exist. * * @returns {string} */ getEditedCellsLayerClass() { const editorSection = this.checkEditorSection(); switch (editorSection) { case 'inline-start': return 'ht_clone_left ht_clone_inline_start'; case 'bottom': return 'ht_clone_bottom'; case 'bottom-inline-start-corner': return 'ht_clone_bottom_left_corner ht_clone_bottom_inline_start_corner'; case 'top': return 'ht_clone_top'; case 'top-inline-start-corner': return 'ht_clone_top_left_corner ht_clone_top_inline_start_corner'; default: return 'ht_clone_master'; } } /** * Gets HTMLTableCellElement of the edited cell if exist. * * @returns {HTMLTableCellElement|null} */ getEditedCell() { return this.hot.getCell(this.row, this.col, true); } /** * Returns name of the overlay, where editor is placed. * * @private * @returns {string} */ checkEditorSection() { const totalRows = this.hot.countRows(); let section = ''; if (this.row < this.hot.getSettings().fixedRowsTop) { if (this.col < this.hot.getSettings().fixedColumnsStart) { section = 'top-inline-start-corner'; } else { section = 'top'; } } else if (this.hot.getSettings().fixedRowsBottom && this.row >= totalRows - this.hot.getSettings().fixedRowsBottom) { if (this.col < this.hot.getSettings().fixedColumnsStart) { section = 'bottom-inline-start-corner'; } else { section = 'bottom'; } } else if (this.col < this.hot.getSettings().fixedColumnsStart) { section = 'inline-start'; } return section; } } mixin(BaseEditor, hooksRefRegisterer);