/** * Keyboard Handler — global keydown listener for spreadsheet shortcuts. * * Extracted from main.ts for decomposition. */ import { cellId } from './formulas.js'; import { showShortcutModal } from './shortcuts-modal.js'; // ── Types ─────────────────────────────────────────────────── export interface KeyboardHandlerDeps { grid: HTMLElement; formulaInput: HTMLInputElement; sheetContainer: HTMLElement | null; provider: any; getSelectedCell: () => { col: number; row: number }; setSelectedCell: (cell: { col: number; row: number }) => void; getSelectionRange: () => any; setSelectionRange: (range: any) => void; getActiveSheet: () => any; getCellData: (id: string) => any; setCellData: (id: string, data: any) => void; getEditingCell: () => any; startEditing: (col: number, row: number) => void; moveSelection: (dCol: number, dRow: number) => void; extendSelection: (dCol: number, dRow: number) => void; moveSelectionTo: (col: number, row: number) => void; getDataExtent: () => { col: number; row: number }; updateSelectionVisuals: () => void; updateStatusBar: () => void; copySelection: () => void; deleteSelectedCells: () => void; showPasteSpecialDialog: () => void; applyStyleToSelection: (prop: string, value: any) => void; clearFormattingSelection: () => void; updateUnderlineButtonState: () => void; updateStrikethroughButtonState: () => void; undoManager: any; evalCache: { clear: () => void }; clearSpillMaps: () => void; invalidateRecalcEngine: () => void; refreshVisibleCells: () => void; renderGrid: () => void; hideSelectedRows: () => void; hideSelectedCols: () => void; unhideAdjacentRows: (row: number) => void; unhideAdjacentCols: (col: number) => void; toggleFilterMode: () => void; printSheet: () => void; showFindReplaceBar: (showReplace: boolean) => void; DEFAULT_COLS: number; DEFAULT_ROWS: number; } // ── Grid focus wiring ─────────────────────────────────────── /** * Make the grid itself a keyboard tab stop so users can Tab into it from * the toolbar / formula bar and get a visible focus indicator on the * currently-selected cell. * * Regression: #698 — without `tabindex=0`, `` is * not focusable, so keyboard users Tab through the toolbar and formula bar * but never reach the grid body and get stuck. * * The existing global keydown handler (see `wireKeyboardHandler`) already * routes arrow keys to `moveSelection()` when `document.activeElement` is * the grid (it falls through the formula-input / doc-title / find-bar / * ai-chat-sidebar guards), so no additional event wiring is needed — only * the `tabindex` attribute plus the matching `:focus` ring in app.css. * * Call once during sheets initialization, before `wireKeyboardHandler`. */ export function wireGridFocus(grid: HTMLElement): void { grid.setAttribute('tabindex', '0'); } // ── Keyboard listener ─────────────────────────────────────── /** * Wire the global keydown listener for spreadsheet keyboard shortcuts. * Call once during initialization. */ export function wireKeyboardHandler(deps: KeyboardHandlerDeps): void { document.addEventListener('keydown', (e) => { const key = e.key; if ((e.metaKey || e.ctrlKey) && key === '/') { e.preventDefault(); showShortcutModal(); return; } if ((e.metaKey || e.ctrlKey) && key === 's') { e.preventDefault(); deps.provider._saveSnapshot(); return; } if ((e.metaKey || e.ctrlKey) && key === 'p') { e.preventDefault(); deps.printSheet(); return; } // Find (Cmd+F) — works even during editing if ((e.metaKey || e.ctrlKey) && key === 'f' && !e.shiftKey) { e.preventDefault(); deps.showFindReplaceBar(false); return; } // Find & Replace (Cmd+H) if ((e.metaKey || e.ctrlKey) && key === 'h') { e.preventDefault(); deps.showFindReplaceBar(true); return; } if (deps.getEditingCell()) return; if (document.activeElement === deps.formulaInput) return; if (document.activeElement === document.getElementById('doc-title')) return; // Skip if find-replace bar inputs are focused if (document.activeElement && document.activeElement.closest('.sheets-find-bar')) return; // Skip if AI chat sidebar inputs are focused if (document.activeElement && document.activeElement.closest('.ai-chat-sidebar')) return; if (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight') { e.preventDefault(); const dCol = key === 'ArrowLeft' ? -1 : key === 'ArrowRight' ? 1 : 0; const dRow = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0; if (e.shiftKey) { deps.extendSelection(dCol, dRow); } else { deps.moveSelection(dCol, dRow); } } else if (key === 'PageDown' || key === 'PageUp') { e.preventDefault(); const pageRows = Math.max(1, Math.floor((deps.sheetContainer?.clientHeight || 600) / 26) - 2); const dir = key === 'PageDown' ? pageRows : -pageRows; if (e.shiftKey) { deps.extendSelection(0, dir); } else { deps.moveSelection(0, dir); } } else if (key === 'Home') { e.preventDefault(); if (e.metaKey || e.ctrlKey) { // Ctrl/Cmd+Home → go to A1 deps.setSelectedCell({ col: 1, row: 1 }); deps.setSelectionRange({ startCol: 1, startRow: 1, endCol: 1, endRow: 1 }); deps.updateSelectionVisuals(); deps.updateStatusBar(); } else { // Home → go to column 1 in current row deps.moveSelectionTo(1, deps.getSelectedCell().row); } } else if (key === 'End') { e.preventDefault(); if (e.metaKey || e.ctrlKey) { // Ctrl/Cmd+End → go to last used cell const extent = deps.getDataExtent(); deps.moveSelectionTo(extent.col, extent.row); } } // Select entire row: Shift+Space else if (key === ' ' && e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault(); const sheet = deps.getActiveSheet(); const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; deps.setSelectionRange({ startCol: 1, startRow: deps.getSelectedCell().row, endCol: maxCol, endRow: deps.getSelectedCell().row }); deps.updateSelectionVisuals(); } // Select entire column: Ctrl+Space else if (key === ' ' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { e.preventDefault(); const sheet = deps.getActiveSheet(); const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; deps.setSelectionRange({ startCol: deps.getSelectedCell().col, startRow: 1, endCol: deps.getSelectedCell().col, endRow: maxRow }); deps.updateSelectionVisuals(); } else if (key === 'Tab') { // #698 — when the grid itself is focused (user Tabbed in from the // toolbar / formula bar), Shift+Tab should let the browser move focus // BACK out to the previous focusable (toolbar, formula bar) instead // of being hijacked as selection-navigation. Without this escape // hatch, keyboard users who Tab into the grid can never Tab back out. if (e.shiftKey && document.activeElement?.classList?.contains('sheet-grid')) { return; } e.preventDefault(); deps.moveSelection(e.shiftKey ? -1 : 1, 0); } else if (key === 'Enter') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); } else if (key === 'Delete' || key === 'Backspace') { e.preventDefault(); deps.deleteSelectedCells(); } else if (key === 'F2') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); } else if (key.length === 1 && !e.ctrlKey && !e.metaKey) { // preventDefault is critical: without it, the browser's native keypress/input // handling on the now-focused cell-editor input inserts the same character // again after we set `editor.value = key`, producing a doubled character. e.preventDefault(); const selectedCell = deps.getSelectedCell(); deps.startEditing(selectedCell.col, selectedCell.row); const editor = deps.grid.querySelector('.cell-editor') as HTMLInputElement | null; if (editor) { editor.value = key; editor.setSelectionRange(1, 1); } } if ((e.metaKey || e.ctrlKey) && key === 'c') { e.preventDefault(); deps.copySelection(); } // Paste Special: Cmd+Shift+V / Ctrl+Shift+V if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'v' || key === 'V')) { e.preventDefault(); deps.showPasteSpecialDialog(); } if ((e.metaKey || e.ctrlKey) && key === 'b') { e.preventDefault(); const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); deps.applyStyleToSelection('bold', !deps.getCellData(id)?.s?.bold); } if ((e.metaKey || e.ctrlKey) && key === 'i') { e.preventDefault(); const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); deps.applyStyleToSelection('italic', !deps.getCellData(id)?.s?.italic); } if ((e.metaKey || e.ctrlKey) && key === 'u') { e.preventDefault(); const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); deps.applyStyleToSelection('underline', !deps.getCellData(id)?.s?.underline); deps.updateUnderlineButtonState(); } if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'x' || key === 'X')) { e.preventDefault(); const id = cellId(deps.getSelectedCell().col, deps.getSelectedCell().row); deps.applyStyleToSelection('strikethrough', !deps.getCellData(id)?.s?.strikethrough); deps.updateStrikethroughButtonState(); } // Undo: Cmd+Z (Mac) / Ctrl+Z if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) { e.preventDefault(); if (deps.undoManager) { deps.undoManager.undo(); deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); deps.refreshVisibleCells(); } } // Redo: Cmd+Shift+Z (Mac) / Ctrl+Y (Windows/Linux) if (((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'z') || (e.ctrlKey && !e.metaKey && key === 'y')) { e.preventDefault(); if (deps.undoManager) { deps.undoManager.redo(); deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); deps.refreshVisibleCells(); } } // Hide rows: Cmd+9 if ((e.metaKey || e.ctrlKey) && key === '9' && !e.shiftKey) { e.preventDefault(); deps.hideSelectedRows(); } // Unhide rows: Cmd+Shift+9 if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === '9' || key === '(')) { e.preventDefault(); deps.unhideAdjacentRows(deps.getSelectedCell().row); } // Hide cols: Cmd+0 if ((e.metaKey || e.ctrlKey) && key === '0' && !e.shiftKey) { e.preventDefault(); deps.hideSelectedCols(); } // Unhide cols: Cmd+Shift+0 if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === '0' || key === ')')) { e.preventDefault(); deps.unhideAdjacentCols(deps.getSelectedCell().col); } // Toggle filter: Cmd+Shift+L if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'l' || key === 'L')) { e.preventDefault(); deps.toggleFilterMode(); deps.renderGrid(); } // Insert current date: Cmd+; if ((e.metaKey || e.ctrlKey) && key === ';' && !e.shiftKey) { e.preventDefault(); const today = new Date(); const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; deps.setCellData(cellId(deps.getSelectedCell().col, deps.getSelectedCell().row), { v: dateStr }); deps.renderGrid(); } // Insert current time: Cmd+Shift+; if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === ';' || key === ':')) { e.preventDefault(); const now = new Date(); const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; deps.setCellData(cellId(deps.getSelectedCell().col, deps.getSelectedCell().row), { v: timeStr }); deps.renderGrid(); } // Clear formatting: Cmd+Backslash if ((e.metaKey || e.ctrlKey) && key === '\\') { e.preventDefault(); deps.clearFormattingSelection(); } // Select all: Cmd+A if ((e.metaKey || e.ctrlKey) && key === 'a') { e.preventDefault(); const sheet = deps.getActiveSheet(); const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; deps.setSelectionRange({ startCol: 1, startRow: 1, endCol: maxCol, endRow: maxRow }); deps.updateSelectionVisuals(); deps.updateStatusBar(); } }); }