/**
* 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();
}
});
}