import { isFunctionKey, isCtrlMetaKey } from "./helpers/unicode.mjs"; import { stopImmediatePropagation } from "./helpers/dom/event.mjs"; import { isOutsideInput } from "./helpers/dom/element.mjs"; import { getEditorInstance } from "./editors/registry.mjs"; import EventManager from "./eventManager.mjs"; import { isDefined } from "./helpers/mixed.mjs"; export const SHORTCUTS_GROUP_NAVIGATION = 'editorManager.navigation'; export const SHORTCUTS_GROUP_EDITOR = 'editorManager.handlingEditor'; class EditorManager { /** * @param {Core} instance The Handsontable instance. * @param {TableMeta} tableMeta The table meta instance. * @param {Selection} selection The selection instance. */ constructor(instance, tableMeta, selection) { /** * Instance of {@link Handsontable}. * * @private * @type {Handsontable} */ this.instance = instance; /** * Reference to an instance's private GridSettings object. * * @private * @type {GridSettings} */ this.tableMeta = tableMeta; /** * Instance of {@link Selection}. * * @private * @type {Selection} */ this.selection = selection; /** * Instance of {@link EventManager}. * * @private * @type {EventManager} */ this.eventManager = new EventManager(instance); /** * Determines if EditorManager is destroyed. * * @private * @type {boolean} */ this.destroyed = false; /** * Determines if EditorManager is locked. * * @private * @type {boolean} */ this.lock = false; /** * A reference to an instance of the activeEditor. * * @private * @type {BaseEditor} */ this.activeEditor = void 0; /** * Keeps a reference to the cell's properties object. * * @type {object} */ this.cellProperties = void 0; const shortcutManager = this.instance.getShortcutManager(); shortcutManager.addContext('editor'); this.registerShortcuts(); this.instance.addHook('afterDocumentKeyDown', event => this.onAfterDocumentKeyDown(event)); // Open editor when text composition is started (IME editor) this.eventManager.addEventListener(this.instance.rootDocument.documentElement, 'compositionstart', event => { if (!this.destroyed && this.instance.isListening()) { this.openEditor('', event); } }); this.instance.view._wt.update('onCellDblClick', (event, coords, elem) => this.onCellDblClick(event, coords, elem)); } /** * Register shortcuts responsible for handling some actions related to an editor. * * @private */ registerShortcuts() { const shortcutManager = this.instance.getShortcutManager(); const gridContext = shortcutManager.getContext('grid'); const editorContext = shortcutManager.getContext('editor'); const config = { group: SHORTCUTS_GROUP_EDITOR }; editorContext.addShortcuts([{ keys: [['Enter'], ['Enter', 'Shift'], ['Enter', 'Control/Meta'], ['Enter', 'Control/Meta', 'Shift']], callback: (event, keys) => { this.closeEditorAndSaveChanges(shortcutManager.isCtrlPressed()); this.moveSelectionAfterEnter(keys.includes('shift')); } }, { keys: [['Escape'], ['Escape', 'Control/Meta']], callback: () => { this.closeEditorAndRestoreOriginalValue(shortcutManager.isCtrlPressed()); this.activeEditor.focus(); } }], config); gridContext.addShortcuts([{ keys: [['F2']], callback: event => { this.openEditor(null, event, true); } }, { keys: [['Backspace'], ['Delete']], callback: () => { this.instance.emptySelectedCells(); this.prepareEditor(); } }, { keys: [['Enter'], ['Enter', 'Shift']], callback: (event, keys) => { if (this.instance.getSettings().enterBeginsEditing) { if (this.cellProperties.readOnly) { this.moveSelectionAfterEnter(); } else { this.openEditor(null, event, true); } } else { this.moveSelectionAfterEnter(keys.includes('shift')); } stopImmediatePropagation(event); // required by HandsontableEditor } }], { ...config, runOnlyIf: () => isDefined(this.instance.getSelected()) }); } /** * Lock the editor from being prepared and closed. Locking the editor prevents its closing and * reinitialized after selecting the new cell. This feature is necessary for a mobile editor. */ lockEditor() { this.lock = true; } /** * Unlock the editor from being prepared and closed. This method restores the original behavior of * the editors where for every new selection its instances are closed. */ unlockEditor() { this.lock = false; } /** * Destroy current editor, if exists. * * @param {boolean} revertOriginal If `false` and the cell using allowInvalid option, * then an editor won't be closed until validation is passed. */ destroyEditor(revertOriginal) { if (!this.lock) { this.closeEditor(revertOriginal); } } /** * Get active editor. * * @returns {BaseEditor} */ getActiveEditor() { return this.activeEditor; } /** * Prepare text input to be displayed at given grid cell. */ prepareEditor() { if (this.lock) { return; } if (this.activeEditor && this.activeEditor.isWaiting()) { this.closeEditor(false, false, dataSaved => { if (dataSaved) { this.prepareEditor(); } }); return; } const { row, col } = this.instance.getSelectedRangeLast().highlight; const modifiedCellCoords = this.instance.runHooks('modifyGetCellCoords', row, col); let visualRowToCheck = row; let visualColumnToCheck = col; if (Array.isArray(modifiedCellCoords)) { [visualRowToCheck, visualColumnToCheck] = modifiedCellCoords; } // Getting values using the modified coordinates. this.cellProperties = this.instance.getCellMeta(visualRowToCheck, visualColumnToCheck); const { activeElement } = this.instance.rootDocument; // Blurring the `activeElement` removes the unwanted border around the focusable element (#6877) // and resets the `document.activeElement` property. The blurring should happen only when the // previously selected input element has not belonged to the Handsontable editor. If blurring is // triggered for all elements, there is a problem with the disappearing IME editor (#9672). if (activeElement && isOutsideInput(activeElement)) { activeElement.blur(); } if (!this.isCellEditable()) { this.clearActiveEditor(); return; } const td = this.instance.getCell(row, col, true); // Skip the preparation when the cell is not rendered in the DOM. The cell is scrolled out of // the table's viewport. if (td) { const editorClass = this.instance.getCellEditor(this.cellProperties); const prop = this.instance.colToProp(visualColumnToCheck); const originalValue = this.instance.getSourceDataAtCell(this.instance.toPhysicalRow(visualRowToCheck), visualColumnToCheck); this.activeEditor = getEditorInstance(editorClass, this.instance); // Using not modified coordinates, as we need to get the table element using selection coordinates. // There is an extra translation in the editor for saving value. this.activeEditor.prepare(row, col, prop, td, originalValue, this.cellProperties); } } /** * Check is editor is opened/showed. * * @returns {boolean} */ isEditorOpened() { return this.activeEditor && this.activeEditor.isOpened(); } /** * Open editor with initial value. * * @param {null|string} newInitialValue New value from which editor will start if handled property it's not the `null`. * @param {Event} event The event object. * @param {boolean} [enableFullEditMode=false] When true, an editor works in full editing mode. Mode disallows closing an editor * when arrow keys are pressed. */ openEditor(newInitialValue, event) { let enableFullEditMode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false; if (!this.isCellEditable()) { this.clearActiveEditor(); return; } if (!this.activeEditor) { const { row, col } = this.instance.getSelectedRangeLast().highlight; const renderableRowIndex = this.instance.rowIndexMapper.getRenderableFromVisualIndex(row); const renderableColumnIndex = this.instance.columnIndexMapper.getRenderableFromVisualIndex(col); this.instance.view.scrollViewport(this.instance._createCellCoords(renderableRowIndex, renderableColumnIndex)); this.instance.view.render(); this.prepareEditor(); } if (this.activeEditor) { if (enableFullEditMode) { this.activeEditor.enableFullEditMode(); } this.activeEditor.beginEditing(newInitialValue, event); } } /** * Close editor, finish editing cell. * * @param {boolean} restoreOriginalValue If `true`, then closes editor without saving value from the editor into a cell. * @param {boolean} isCtrlPressed If `true`, then editor will save value to each cell in the last selected range. * @param {Function} callback The callback function, fired after editor closing. */ closeEditor(restoreOriginalValue, isCtrlPressed, callback) { if (this.activeEditor) { this.activeEditor.finishEditing(restoreOriginalValue, isCtrlPressed, callback); } else if (callback) { callback(false); } } /** * Close editor and save changes. * * @param {boolean} isCtrlPressed If `true`, then editor will save value to each cell in the last selected range. */ closeEditorAndSaveChanges(isCtrlPressed) { this.closeEditor(false, isCtrlPressed); } /** * Close editor and restore original value. * * @param {boolean} isCtrlPressed Indication of whether the CTRL button is pressed. */ closeEditorAndRestoreOriginalValue(isCtrlPressed) { this.closeEditor(true, isCtrlPressed); } /** * Clears reference to an instance of the active editor. * * @private */ clearActiveEditor() { this.activeEditor = void 0; } /** * Checks if the currently selected cell (pointed by selection highlight coords) is editable. * Editable cell is when: * - the cell has defined an editor type; * - the cell is not marked as read-only; * - the cell is not hidden. * * @private * @returns {boolean} */ isCellEditable() { const editorClass = this.instance.getCellEditor(this.cellProperties); const { row, col } = this.instance.getSelectedRangeLast().highlight; const { rowIndexMapper, columnIndexMapper } = this.instance; const isCellHidden = rowIndexMapper.isHidden(this.instance.toPhysicalRow(row)) || columnIndexMapper.isHidden(this.instance.toPhysicalColumn(col)); if (this.cellProperties.readOnly || !editorClass || isCellHidden) { return false; } return true; } /** * Controls selection's behaviour after clicking `Enter`. * * @private * @param {boolean} isShiftPressed If `true`, then the selection will move up after hit enter. */ moveSelectionAfterEnter(isShiftPressed) { const enterMoves = typeof this.tableMeta.enterMoves === 'function' ? this.tableMeta.enterMoves(event) : this.tableMeta.enterMoves; if (isShiftPressed) { // move selection up this.selection.transformStart(-enterMoves.row, -enterMoves.col); } else { // move selection down (add a new row if needed) this.selection.transformStart(enterMoves.row, enterMoves.col, true); } } /** * OnAfterDocumentKeyDown callback. * * @private * @param {KeyboardEvent} event The keyboard event object. */ onAfterDocumentKeyDown(event) { if (!this.instance.isListening()) { return; } const { keyCode } = event; if (!this.selection.isSelected()) { return; } // catch CTRL but not right ALT (which in some systems triggers ALT+CTRL) const isCtrlPressed = (event.ctrlKey || event.metaKey) && !event.altKey; if (!this.activeEditor || this.activeEditor && !this.activeEditor.isWaiting()) { if (!isFunctionKey(keyCode) && !isCtrlMetaKey(keyCode) && !isCtrlPressed && !this.isEditorOpened()) { const shortcutManager = this.instance.getShortcutManager(); const editorContext = shortcutManager.getContext('editor'); const runOnlySelectedConfig = { runOnlyIf: () => isDefined(this.instance.getSelected()), group: SHORTCUTS_GROUP_NAVIGATION }; editorContext.addShortcuts([{ keys: [['ArrowUp']], callback: () => { this.instance.selection.transformStart(-1, 0); } }, { keys: [['ArrowDown']], callback: () => { this.instance.selection.transformStart(1, 0); } }, { keys: [['ArrowLeft']], callback: () => { this.instance.selection.transformStart(0, -1 * this.instance.getDirectionFactor()); } }, { keys: [['ArrowRight']], callback: () => { this.instance.selection.transformStart(0, this.instance.getDirectionFactor()); } }], runOnlySelectedConfig); this.openEditor('', event); } } } /** * OnCellDblClick callback. * * @private * @param {MouseEvent} event The mouse event object. * @param {object} coords The cell coordinates. * @param {HTMLTableCellElement|HTMLTableHeaderCellElement} elem The element which triggers the action. */ onCellDblClick(event, coords, elem) { // may be TD or TH if (elem.nodeName === 'TD') { this.openEditor(null, event, true); } } /** * Destroy the instance. */ destroy() { this.destroyed = true; this.eventManager.destroy(); } } const instances = new WeakMap(); /** * @param {Core} hotInstance The Handsontable instance. * @param {TableMeta} tableMeta The table meta class instance. * @param {Selection} selection The selection instance. * @returns {EditorManager} */ EditorManager.getInstance = function (hotInstance, tableMeta, selection) { let editorManager = instances.get(hotInstance); if (!editorManager) { editorManager = new EditorManager(hotInstance, tableMeta, selection); instances.set(hotInstance, editorManager); } return editorManager; }; export default EditorManager;