Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'refactor(sheets): phase 6 — extract keyboard, selection, clipboard' (#285) from refactor/sheets-decompose-phase6 into main

scott 7a3eda9f 6628d517

+668 -439
+186
src/sheets/clipboard-operations.ts
··· 1 + /** 2 + * Clipboard Operations — copy, paste, delete selection, paste-special wiring. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId } from './formulas.js'; 8 + import { normalizeRange } from './selection-utils.js'; 9 + import { buildCopyHtml, buildCopyTsv } from './clipboard-copy.js'; 10 + import { parseClipboardHtml, parseClipboardTsv } from './clipboard-paste.js'; 11 + import { showToast } from './import-export.js'; 12 + import { showPasteSpecialDialog as _showPasteSpecialDialogUI } from './paste-special-ui.js'; 13 + 14 + // ── Types ─────────────────────────────────────────────────── 15 + 16 + export interface ClipboardDeps { 17 + ydoc: any; 18 + grid: HTMLElement; 19 + getSelectedCell: () => { col: number; row: number }; 20 + getSelectionRange: () => any; 21 + getCellData: (id: string) => any; 22 + setCellData: (id: string, data: any) => void; 23 + getCells: () => any; 24 + evalCache: { clear: () => void }; 25 + clearSpillMaps: () => void; 26 + invalidateRecalcEngine: () => void; 27 + refreshVisibleCells: () => void; 28 + getClipboardBuffer: () => Array<Array<{ value: any; formula: string; style: Record<string, any> }>> | null; 29 + setClipboardBuffer: (buf: Array<Array<{ value: any; formula: string; style: Record<string, any> }>> | null) => void; 30 + } 31 + 32 + // ── Functions ─────────────────────────────────────────────── 33 + 34 + export function deleteSelectedCells(deps: ClipboardDeps): void { 35 + const selectionRange = deps.getSelectionRange(); 36 + if (!selectionRange) return; 37 + const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 38 + const cells = deps.getCells(); 39 + deps.ydoc.transact(() => { 40 + for (let r = startRow; r <= endRow; r++) { 41 + for (let c = startCol; c <= endCol; c++) { 42 + const id = cellId(c, r); 43 + if (cells.has(id)) cells.delete(id); 44 + } 45 + } 46 + }); 47 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 48 + deps.refreshVisibleCells(); 49 + } 50 + 51 + export function copySelection(deps: ClipboardDeps): void { 52 + const selectionRange = deps.getSelectionRange(); 53 + if (!selectionRange) return; 54 + const norm = normalizeRange(selectionRange); 55 + const html = buildCopyHtml(deps.getCellData, norm, cellId); 56 + const tsv = buildCopyTsv(deps.getCellData, norm, cellId); 57 + 58 + // Populate the internal clipboard buffer for paste-special 59 + const { startCol, startRow, endCol, endRow } = norm; 60 + const bufferRows: Array<Array<{ value: any; formula: string; style: Record<string, any> }>> = []; 61 + for (let r = startRow; r <= endRow; r++) { 62 + const row: Array<{ value: any; formula: string; style: Record<string, any> }> = []; 63 + for (let c = startCol; c <= endCol; c++) { 64 + const data = deps.getCellData(cellId(c, r)); 65 + row.push({ 66 + value: data?.f ? '=' + data.f : (data?.v ?? ''), 67 + formula: data?.f || '', 68 + style: data?.s ? { ...data.s } : {}, 69 + }); 70 + } 71 + bufferRows.push(row); 72 + } 73 + deps.setClipboardBuffer(bufferRows); 74 + 75 + // Write both HTML and plain text to the system clipboard 76 + try { 77 + const blob = new Blob([html], { type: 'text/html' }); 78 + const textBlob = new Blob([tsv], { type: 'text/plain' }); 79 + navigator.clipboard.write([ 80 + new ClipboardItem({ 81 + 'text/html': blob, 82 + 'text/plain': textBlob, 83 + }), 84 + ]).catch(() => { 85 + // Fallback: write plain text only 86 + navigator.clipboard.writeText(tsv).catch(() => {}); 87 + }); 88 + } catch { 89 + // ClipboardItem not supported -- fallback to plain text 90 + navigator.clipboard.writeText(tsv).catch(() => {}); 91 + } 92 + 93 + // Show feedback toast 94 + const norm2 = normalizeRange(selectionRange); 95 + const rows = norm2.endRow - norm2.startRow + 1; 96 + const cols = norm2.endCol - norm2.startCol + 1; 97 + if (rows === 1 && cols === 1) { 98 + showToast('Copied cell'); 99 + } else { 100 + showToast('Copied ' + rows + ' \u00d7 ' + cols + ' cells'); 101 + } 102 + } 103 + 104 + /** 105 + * Paste parsed clipboard rows at the current selection. 106 + * Accepts the row format from parseClipboardHtml/parseClipboardTsv: 107 + * Array<Array<{ value, formula, style }>> 108 + */ 109 + export function pasteRowsAtSelection(deps: ClipboardDeps, rows: any[]): void { 110 + if (!rows || rows.length === 0) return; 111 + const selectedCell = deps.getSelectedCell(); 112 + const sc = selectedCell.col; 113 + const sr = selectedCell.row; 114 + deps.ydoc.transact(() => { 115 + for (let r = 0; r < rows.length; r++) { 116 + const row = rows[r]; 117 + for (let c = 0; c < row.length; c++) { 118 + const cell = row[c]; 119 + const id = cellId(sc + c, sr + r); 120 + const val = cell.value; 121 + const formula = cell.formula || ''; 122 + const style = cell.style && Object.keys(cell.style).length > 0 ? cell.style : undefined; 123 + if (formula) { 124 + deps.setCellData(id, { v: val ?? '', f: formula, ...(style ? { s: style } : {}) }); 125 + } else { 126 + const n = typeof val === 'number' ? val : Number(val); 127 + const v = val === '' ? '' : (typeof val === 'number' || !isNaN(n) && String(val).trim() !== '' ? n : val); 128 + deps.setCellData(id, { v, f: '', ...(style ? { s: style } : {}) }); 129 + } 130 + } 131 + } 132 + }); 133 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 134 + deps.refreshVisibleCells(); 135 + } 136 + 137 + /** 138 + * Legacy plain-text paste (kept for context menu fallback). 139 + */ 140 + export function pasteAtSelection(deps: ClipboardDeps, text: string): void { 141 + const parsed = parseClipboardTsv(text); 142 + if (parsed) { 143 + pasteRowsAtSelection(deps, parsed.rows); 144 + } 145 + } 146 + 147 + /** 148 + * Show the paste-special dialog using the internal clipboard buffer. 149 + */ 150 + export function showPasteSpecialDialog(deps: ClipboardDeps): void { 151 + _showPasteSpecialDialogUI({ 152 + getClipboardBuffer: deps.getClipboardBuffer, 153 + pasteRowsAtSelection: (rows: any[]) => pasteRowsAtSelection(deps, rows), 154 + }); 155 + } 156 + 157 + /** 158 + * Wire the paste event listener on the document. 159 + * Call once during initialization. 160 + */ 161 + export function wirePasteListener(deps: ClipboardDeps, opts: { getEditingCell: () => any; formulaInput: HTMLInputElement }): void { 162 + document.addEventListener('paste', (e) => { 163 + if (opts.getEditingCell() || document.activeElement === opts.formulaInput) return; 164 + e.preventDefault(); 165 + // Try HTML first (from Excel/Google Sheets), fall back to TSV/plain text 166 + const htmlData = (e as ClipboardEvent).clipboardData?.getData('text/html') ?? ''; 167 + const textData = (e as ClipboardEvent).clipboardData?.getData('text/plain') ?? ''; 168 + let parsed = null; 169 + if (htmlData) { 170 + parsed = parseClipboardHtml(htmlData); 171 + } 172 + if (!parsed && textData) { 173 + parsed = parseClipboardTsv(textData); 174 + } 175 + if (parsed) { 176 + pasteRowsAtSelection(deps, parsed.rows); 177 + const rows = parsed.rows.length; 178 + const cols = parsed.rows[0]?.length || 0; 179 + if (rows === 1 && cols === 1) { 180 + showToast('Pasted cell'); 181 + } else { 182 + showToast('Pasted ' + rows + ' \u00d7 ' + cols + ' cells'); 183 + } 184 + } 185 + }); 186 + }
+203
src/sheets/keyboard-handler.ts
··· 1 + /** 2 + * Keyboard Handler — global keydown listener for spreadsheet shortcuts. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId } from './formulas.js'; 8 + import { showShortcutModal } from './shortcuts-modal.js'; 9 + 10 + // ── Types ─────────────────────────────────────────────────── 11 + 12 + export interface KeyboardHandlerDeps { 13 + grid: HTMLElement; 14 + formulaInput: HTMLInputElement; 15 + sheetContainer: HTMLElement | null; 16 + provider: any; 17 + getSelectedCell: () => { col: number; row: number }; 18 + setSelectedCell: (cell: { col: number; row: number }) => void; 19 + getSelectionRange: () => any; 20 + setSelectionRange: (range: any) => void; 21 + getActiveSheet: () => any; 22 + getCellData: (id: string) => any; 23 + setCellData: (id: string, data: any) => void; 24 + getEditingCell: () => any; 25 + startEditing: (col: number, row: number) => void; 26 + moveSelection: (dCol: number, dRow: number) => void; 27 + extendSelection: (dCol: number, dRow: number) => void; 28 + moveSelectionTo: (col: number, row: number) => void; 29 + getDataExtent: () => { col: number; row: number }; 30 + updateSelectionVisuals: () => void; 31 + updateStatusBar: () => void; 32 + copySelection: () => void; 33 + deleteSelectedCells: () => void; 34 + showPasteSpecialDialog: () => void; 35 + applyStyleToSelection: (prop: string, value: any) => void; 36 + clearFormattingSelection: () => void; 37 + updateUnderlineButtonState: () => void; 38 + updateStrikethroughButtonState: () => void; 39 + undoManager: any; 40 + evalCache: { clear: () => void }; 41 + clearSpillMaps: () => void; 42 + invalidateRecalcEngine: () => void; 43 + refreshVisibleCells: () => void; 44 + renderGrid: () => void; 45 + hideSelectedRows: () => void; 46 + hideSelectedCols: () => void; 47 + unhideAdjacentRows: (row: number) => void; 48 + unhideAdjacentCols: (col: number) => void; 49 + toggleFilterMode: () => void; 50 + printSheet: () => void; 51 + showFindReplaceBar: (showReplace: boolean) => void; 52 + DEFAULT_COLS: number; 53 + DEFAULT_ROWS: number; 54 + } 55 + 56 + // ── Keyboard listener ─────────────────────────────────────── 57 + 58 + /** 59 + * Wire the global keydown listener for spreadsheet keyboard shortcuts. 60 + * Call once during initialization. 61 + */ 62 + export function wireKeyboardHandler(deps: KeyboardHandlerDeps): void { 63 + document.addEventListener('keydown', (e) => { 64 + const key = e.key; 65 + if ((e.metaKey || e.ctrlKey) && key === '/') { e.preventDefault(); showShortcutModal(); return; } 66 + if ((e.metaKey || e.ctrlKey) && key === 's') { e.preventDefault(); deps.provider._saveSnapshot(); return; } 67 + if ((e.metaKey || e.ctrlKey) && key === 'p') { e.preventDefault(); deps.printSheet(); return; } 68 + // Find (Cmd+F) — works even during editing 69 + if ((e.metaKey || e.ctrlKey) && key === 'f' && !e.shiftKey) { e.preventDefault(); deps.showFindReplaceBar(false); return; } 70 + // Find & Replace (Cmd+H) 71 + if ((e.metaKey || e.ctrlKey) && key === 'h') { e.preventDefault(); deps.showFindReplaceBar(true); return; } 72 + if (deps.getEditingCell()) return; 73 + if (document.activeElement === deps.formulaInput) return; 74 + if (document.activeElement === document.getElementById('doc-title')) return; 75 + // Skip if find-replace bar inputs are focused 76 + if (document.activeElement && document.activeElement.closest('.sheets-find-bar')) return; 77 + // Skip if AI chat sidebar inputs are focused 78 + if (document.activeElement && document.activeElement.closest('.ai-chat-sidebar')) return; 79 + 80 + if (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight') { 81 + e.preventDefault(); 82 + const dCol = key === 'ArrowLeft' ? -1 : key === 'ArrowRight' ? 1 : 0; 83 + const dRow = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0; 84 + if (e.shiftKey) { 85 + deps.extendSelection(dCol, dRow); 86 + } else { 87 + deps.moveSelection(dCol, dRow); 88 + } 89 + } 90 + else if (key === 'PageDown' || key === 'PageUp') { 91 + e.preventDefault(); 92 + const pageRows = Math.max(1, Math.floor((deps.sheetContainer?.clientHeight || 600) / 26) - 2); 93 + const dir = key === 'PageDown' ? pageRows : -pageRows; 94 + if (e.shiftKey) { 95 + deps.extendSelection(0, dir); 96 + } else { 97 + deps.moveSelection(0, dir); 98 + } 99 + } 100 + else if (key === 'Home') { 101 + e.preventDefault(); 102 + if (e.metaKey || e.ctrlKey) { 103 + // Ctrl/Cmd+Home → go to A1 104 + deps.setSelectedCell({ col: 1, row: 1 }); 105 + deps.setSelectionRange({ startCol: 1, startRow: 1, endCol: 1, endRow: 1 }); 106 + deps.updateSelectionVisuals(); deps.updateStatusBar(); 107 + } else { 108 + // Home → go to column 1 in current row 109 + deps.moveSelectionTo(1, deps.getSelectedCell().row); 110 + } 111 + } 112 + else if (key === 'End') { 113 + e.preventDefault(); 114 + if (e.metaKey || e.ctrlKey) { 115 + // Ctrl/Cmd+End → go to last used cell 116 + const extent = deps.getDataExtent(); 117 + deps.moveSelectionTo(extent.col, extent.row); 118 + } 119 + } 120 + // Select entire row: Shift+Space 121 + else if (key === ' ' && e.shiftKey && !e.ctrlKey && !e.metaKey) { 122 + e.preventDefault(); 123 + const sheet = deps.getActiveSheet(); 124 + const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; 125 + deps.setSelectionRange({ startCol: 1, startRow: deps.getSelectedCell().row, endCol: maxCol, endRow: deps.getSelectedCell().row }); 126 + deps.updateSelectionVisuals(); 127 + } 128 + // Select entire column: Ctrl+Space 129 + else if (key === ' ' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { 130 + e.preventDefault(); 131 + const sheet = deps.getActiveSheet(); 132 + const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; 133 + deps.setSelectionRange({ startCol: deps.getSelectedCell().col, startRow: 1, endCol: deps.getSelectedCell().col, endRow: maxRow }); 134 + deps.updateSelectionVisuals(); 135 + } 136 + else if (key === 'Tab') { e.preventDefault(); deps.moveSelection(e.shiftKey ? -1 : 1, 0); } 137 + else if (key === 'Enter') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); } 138 + else if (key === 'Delete' || key === 'Backspace') { e.preventDefault(); deps.deleteSelectedCells(); } 139 + else if (key === 'F2') { e.preventDefault(); deps.startEditing(deps.getSelectedCell().col, deps.getSelectedCell().row); } 140 + else if (key.length === 1 && !e.ctrlKey && !e.metaKey) { 141 + const selectedCell = deps.getSelectedCell(); 142 + deps.startEditing(selectedCell.col, selectedCell.row); 143 + const editor = deps.grid.querySelector('.cell-editor') as HTMLInputElement | null; 144 + if (editor) { editor.value = key; editor.setSelectionRange(1, 1); } 145 + } 146 + 147 + if ((e.metaKey || e.ctrlKey) && key === 'c') { e.preventDefault(); deps.copySelection(); } 148 + // Paste Special: Cmd+Shift+V / Ctrl+Shift+V 149 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'v' || key === 'V')) { e.preventDefault(); deps.showPasteSpecialDialog(); } 150 + 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); } 151 + 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); } 152 + 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(); } 153 + 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(); } 154 + // Undo: Cmd+Z (Mac) / Ctrl+Z 155 + if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) { 156 + e.preventDefault(); 157 + if (deps.undoManager) { deps.undoManager.undo(); deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); deps.refreshVisibleCells(); } 158 + } 159 + // Redo: Cmd+Shift+Z (Mac) / Ctrl+Y (Windows/Linux) 160 + if (((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'z') || (e.ctrlKey && !e.metaKey && key === 'y')) { 161 + e.preventDefault(); 162 + if (deps.undoManager) { deps.undoManager.redo(); deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); deps.refreshVisibleCells(); } 163 + } 164 + // Hide rows: Cmd+9 165 + if ((e.metaKey || e.ctrlKey) && key === '9' && !e.shiftKey) { e.preventDefault(); deps.hideSelectedRows(); } 166 + // Unhide rows: Cmd+Shift+9 167 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === '9' || key === '(')) { e.preventDefault(); deps.unhideAdjacentRows(deps.getSelectedCell().row); } 168 + // Hide cols: Cmd+0 169 + if ((e.metaKey || e.ctrlKey) && key === '0' && !e.shiftKey) { e.preventDefault(); deps.hideSelectedCols(); } 170 + // Unhide cols: Cmd+Shift+0 171 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === '0' || key === ')')) { e.preventDefault(); deps.unhideAdjacentCols(deps.getSelectedCell().col); } 172 + // Toggle filter: Cmd+Shift+L 173 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'l' || key === 'L')) { e.preventDefault(); deps.toggleFilterMode(); deps.renderGrid(); } 174 + // Insert current date: Cmd+; 175 + if ((e.metaKey || e.ctrlKey) && key === ';' && !e.shiftKey) { 176 + e.preventDefault(); 177 + const today = new Date(); 178 + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; 179 + deps.setCellData(cellId(deps.getSelectedCell().col, deps.getSelectedCell().row), { v: dateStr }); 180 + deps.renderGrid(); 181 + } 182 + // Insert current time: Cmd+Shift+; 183 + if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === ';' || key === ':')) { 184 + e.preventDefault(); 185 + const now = new Date(); 186 + const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; 187 + deps.setCellData(cellId(deps.getSelectedCell().col, deps.getSelectedCell().row), { v: timeStr }); 188 + deps.renderGrid(); 189 + } 190 + // Clear formatting: Cmd+Backslash 191 + if ((e.metaKey || e.ctrlKey) && key === '\\') { e.preventDefault(); deps.clearFormattingSelection(); } 192 + // Select all: Cmd+A 193 + if ((e.metaKey || e.ctrlKey) && key === 'a') { 194 + e.preventDefault(); 195 + const sheet = deps.getActiveSheet(); 196 + const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; 197 + const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; 198 + deps.setSelectionRange({ startCol: 1, startRow: 1, endCol: maxCol, endRow: maxRow }); 199 + deps.updateSelectionVisuals(); 200 + deps.updateStatusBar(); 201 + } 202 + }); 203 + }
+69 -439
src/sheets/main.ts
··· 42 42 // format-painter, row-col-ops — now used via toolbar-wiring.ts 43 43 // context-menu — now used via context-menu-handler.ts 44 44 // Sheet tab management functions used via sheet-tabs-ui.ts (no longer directly imported here) 45 - import { buildCopyHtml, buildCopyTsv } from './clipboard-copy.js'; 46 - import { parseClipboardHtml, parseClipboardTsv } from './clipboard-paste.js'; 47 - // paste-special — now used via paste-special-ui.ts 45 + // clipboard-copy, clipboard-paste — now used via clipboard-operations.ts 46 + // paste-special — now used via paste-special-ui.ts → clipboard-operations.ts 48 47 import { computeVisibleRows, computeVisibleCols, hiddenRowsSpacerAdjustment, getAdjacentHiddenRows, getAdjacentHiddenCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 49 48 import { isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 50 49 import { isSparklineResult, drawSparkline } from './sparkline.js'; ··· 71 70 import { wireConnectionStatus as _wireConnectionStatus, setupCollabAvatars as _setupCollabAvatars } from './collaboration-ui.js'; 72 71 import { updateStatusBar as _updateStatusBarUI, wireStatusBarFreezeClick as _wireStatusBarFreezeClick } from './status-bar-ui.js'; 73 72 import { hideActiveContextMenu, wireContextMenu as _wireContextMenu, setActiveContextMenu } from './context-menu-handler.js'; 74 - import { showPasteSpecialDialog as _showPasteSpecialDialogUI } from './paste-special-ui.js'; 73 + // paste-special-ui — now used via clipboard-operations.ts 75 74 import { updateFormulaHighlight as _updateFormulaHighlight, updateFormulaRangeHighlights as _updateFormulaRangeHighlights, updateFormulaTooltip, onFormulaInputUpdate as _onFormulaInputUpdate, commitFormulaBar as _commitFormulaBar, wireFormulaBarKeys as _wireFormulaBarKeys, refreshVisibleCells as _refreshVisibleCells } from './formula-bar-ui.js'; 76 75 import { hideAutocomplete as _hideAutocomplete, attachCellEditorAutocomplete as _attachCellEditorAutocomplete, wireAutocomplete as _wireAutocomplete } from './formula-autocomplete-ui.js'; 77 - import { showShortcutModal, wireShortcutButton } from './shortcuts-modal.js'; 76 + import { wireShortcutButton } from './shortcuts-modal.js'; 77 + import { wireKeyboardHandler as _wireKeyboardHandler } from './keyboard-handler.js'; 78 + import { moveSelection as _moveSelection, extendSelection as _extendSelection, moveSelectionTo as _moveSelectionTo, getDataExtent as _getDataExtent, scrollCellIntoView as _scrollCellIntoView, updateSelectionVisuals as _updateSelectionVisuals, clearPrevSelection as _clearPrevSelection, getCellEl as _getCellEl } from './selection-navigation.js'; 79 + import { deleteSelectedCells as _deleteSelectedCells, copySelection as _copySelection, pasteRowsAtSelection as _pasteRowsAtSelection, pasteAtSelection as _pasteAtSelection, showPasteSpecialDialog as _showPasteSpecialDialogCO, wirePasteListener as _wirePasteListener } from './clipboard-operations.js'; 78 80 import { wireSaveStatus } from './save-status-ui.js'; 79 81 import { 80 82 applyStyleToSelection as _applyStyleToSelection, clearFormattingSelection as _clearFormattingSelection, ··· 1545 1547 } 1546 1548 } 1547 1549 1548 - // --- Keyboard navigation --- 1549 - document.addEventListener('keydown', (e) => { 1550 - const key = e.key; 1551 - if ((e.metaKey || e.ctrlKey) && key === '/') { e.preventDefault(); showShortcutModal(); return; } 1552 - if ((e.metaKey || e.ctrlKey) && key === 's') { e.preventDefault(); provider._saveSnapshot(); return; } 1553 - if ((e.metaKey || e.ctrlKey) && key === 'p') { e.preventDefault(); printSheet(); return; } 1554 - // Find (Cmd+F) — works even during editing 1555 - if ((e.metaKey || e.ctrlKey) && key === 'f' && !e.shiftKey) { e.preventDefault(); showFindReplaceBar(false); return; } 1556 - // Find & Replace (Cmd+H) 1557 - if ((e.metaKey || e.ctrlKey) && key === 'h') { e.preventDefault(); showFindReplaceBar(true); return; } 1558 - if (editingCell) return; 1559 - if (document.activeElement === formulaInput) return; 1560 - if (document.activeElement === document.getElementById('doc-title')) return; 1561 - // Skip if find-replace bar inputs are focused 1562 - if (document.activeElement && document.activeElement.closest('.sheets-find-bar')) return; 1563 - // Skip if AI chat sidebar inputs are focused 1564 - if (document.activeElement && document.activeElement.closest('.ai-chat-sidebar')) return; 1565 - 1566 - if (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight') { 1567 - e.preventDefault(); 1568 - const dCol = key === 'ArrowLeft' ? -1 : key === 'ArrowRight' ? 1 : 0; 1569 - const dRow = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0; 1570 - if (e.shiftKey) { 1571 - extendSelection(dCol, dRow); 1572 - } else { 1573 - moveSelection(dCol, dRow); 1574 - } 1575 - } 1576 - else if (key === 'PageDown' || key === 'PageUp') { 1577 - e.preventDefault(); 1578 - const pageRows = Math.max(1, Math.floor((sheetContainer?.clientHeight || 600) / 26) - 2); 1579 - const dir = key === 'PageDown' ? pageRows : -pageRows; 1580 - if (e.shiftKey) { 1581 - extendSelection(0, dir); 1582 - } else { 1583 - moveSelection(0, dir); 1584 - } 1585 - } 1586 - else if (key === 'Home') { 1587 - e.preventDefault(); 1588 - if (e.metaKey || e.ctrlKey) { 1589 - // Ctrl/Cmd+Home → go to A1 1590 - selectedCell = { col: 1, row: 1 }; 1591 - selectionRange = { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }; 1592 - updateSelectionVisuals(); updateFormulaBar(); scrollCellIntoView(1, 1); 1593 - } else { 1594 - // Home → go to column 1 in current row 1595 - moveSelectionTo(1, selectedCell.row); 1596 - } 1597 - } 1598 - else if (key === 'End') { 1599 - e.preventDefault(); 1600 - if (e.metaKey || e.ctrlKey) { 1601 - // Ctrl/Cmd+End → go to last used cell 1602 - const extent = getDataExtent(); 1603 - moveSelectionTo(extent.col, extent.row); 1604 - } 1605 - } 1606 - // Select entire row: Shift+Space 1607 - else if (key === ' ' && e.shiftKey && !e.ctrlKey && !e.metaKey) { 1608 - e.preventDefault(); 1609 - const sheet = getActiveSheet(); 1610 - const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1611 - selectionRange = { startCol: 1, startRow: selectedCell.row, endCol: maxCol, endRow: selectedCell.row }; 1612 - updateSelectionVisuals(); 1613 - } 1614 - // Select entire column: Ctrl+Space 1615 - else if (key === ' ' && (e.ctrlKey || e.metaKey) && !e.shiftKey) { 1616 - e.preventDefault(); 1617 - const sheet = getActiveSheet(); 1618 - const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1619 - selectionRange = { startCol: selectedCell.col, startRow: 1, endCol: selectedCell.col, endRow: maxRow }; 1620 - updateSelectionVisuals(); 1621 - } 1622 - else if (key === 'Tab') { e.preventDefault(); moveSelection(e.shiftKey ? -1 : 1, 0); } 1623 - else if (key === 'Enter') { e.preventDefault(); startEditing(selectedCell.col, selectedCell.row); } 1624 - else if (key === 'Delete' || key === 'Backspace') { e.preventDefault(); deleteSelectedCells(); } 1625 - else if (key === 'F2') { e.preventDefault(); startEditing(selectedCell.col, selectedCell.row); } 1626 - else if (key.length === 1 && !e.ctrlKey && !e.metaKey) { 1627 - startEditing(selectedCell.col, selectedCell.row); 1628 - const editor = grid.querySelector('.cell-editor'); 1629 - if (editor) { editor.value = key; editor.setSelectionRange(1, 1); } 1630 - } 1631 - 1632 - if ((e.metaKey || e.ctrlKey) && key === 'c') { e.preventDefault(); copySelection(); } 1633 - // Paste Special: Cmd+Shift+V / Ctrl+Shift+V 1634 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'v' || key === 'V')) { e.preventDefault(); showPasteSpecialDialog(); } 1635 - if ((e.metaKey || e.ctrlKey) && key === 'b') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('bold', !getCellData(id)?.s?.bold); } 1636 - if ((e.metaKey || e.ctrlKey) && key === 'i') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('italic', !getCellData(id)?.s?.italic); } 1637 - if ((e.metaKey || e.ctrlKey) && key === 'u') { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('underline', !getCellData(id)?.s?.underline); updateUnderlineButtonState(); } 1638 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'x' || key === 'X')) { e.preventDefault(); const id = cellId(selectedCell.col, selectedCell.row); applyStyleToSelection('strikethrough', !getCellData(id)?.s?.strikethrough); updateStrikethroughButtonState(); } 1639 - // Undo: Cmd+Z (Mac) / Ctrl+Z 1640 - if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) { 1641 - e.preventDefault(); 1642 - if (undoManager) { undoManager.undo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1643 - } 1644 - // Redo: Cmd+Shift+Z (Mac) / Ctrl+Y (Windows/Linux) 1645 - if (((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'z') || (e.ctrlKey && !e.metaKey && key === 'y')) { 1646 - e.preventDefault(); 1647 - if (undoManager) { undoManager.redo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1648 - } 1649 - // Hide rows: Cmd+9 1650 - if ((e.metaKey || e.ctrlKey) && key === '9' && !e.shiftKey) { e.preventDefault(); hideSelectedRows(); } 1651 - // Unhide rows: Cmd+Shift+9 1652 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === '9' || key === '(')) { e.preventDefault(); unhideAdjacentRows(selectedCell.row); } 1653 - // Hide cols: Cmd+0 1654 - if ((e.metaKey || e.ctrlKey) && key === '0' && !e.shiftKey) { e.preventDefault(); hideSelectedCols(); } 1655 - // Unhide cols: Cmd+Shift+0 1656 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === '0' || key === ')')) { e.preventDefault(); unhideAdjacentCols(selectedCell.col); } 1657 - // Toggle filter: Cmd+Shift+L 1658 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === 'l' || key === 'L')) { e.preventDefault(); toggleFilterMode(); renderGrid(); } 1659 - // Insert current date: Cmd+; 1660 - if ((e.metaKey || e.ctrlKey) && key === ';' && !e.shiftKey) { 1661 - e.preventDefault(); 1662 - const today = new Date(); 1663 - const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; 1664 - setCellData(cellId(selectedCell.col, selectedCell.row), { v: dateStr }); 1665 - renderGrid(); 1666 - } 1667 - // Insert current time: Cmd+Shift+; 1668 - if ((e.metaKey || e.ctrlKey) && e.shiftKey && (key === ';' || key === ':')) { 1669 - e.preventDefault(); 1670 - const now = new Date(); 1671 - const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; 1672 - setCellData(cellId(selectedCell.col, selectedCell.row), { v: timeStr }); 1673 - renderGrid(); 1674 - } 1675 - // Clear formatting: Cmd+Backslash 1676 - if ((e.metaKey || e.ctrlKey) && key === '\\') { e.preventDefault(); clearFormattingSelection(); } 1677 - // Select all: Cmd+A 1678 - if ((e.metaKey || e.ctrlKey) && key === 'a') { 1679 - e.preventDefault(); 1680 - const sheet = getActiveSheet(); 1681 - const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1682 - const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1683 - selectionRange = { startCol: 1, startRow: 1, endCol: maxCol, endRow: maxRow }; 1684 - updateSelectionVisuals(); 1685 - updateStatusBar(); 1686 - } 1687 - }); 1688 - 1689 - document.addEventListener('paste', (e) => { 1690 - if (editingCell || document.activeElement === formulaInput) return; 1691 - e.preventDefault(); 1692 - // Try HTML first (from Excel/Google Sheets), fall back to TSV/plain text 1693 - const htmlData = e.clipboardData.getData('text/html'); 1694 - const textData = e.clipboardData.getData('text/plain'); 1695 - let parsed = null; 1696 - if (htmlData) { 1697 - parsed = parseClipboardHtml(htmlData); 1698 - } 1699 - if (!parsed && textData) { 1700 - parsed = parseClipboardTsv(textData); 1701 - } 1702 - if (parsed) { 1703 - pasteRowsAtSelection(parsed.rows); 1704 - const rows = parsed.rows.length; 1705 - const cols = parsed.rows[0]?.length || 0; 1706 - if (rows === 1 && cols === 1) { 1707 - showToast('Pasted cell'); 1708 - } else { 1709 - showToast('Pasted ' + rows + ' \u00d7 ' + cols + ' cells'); 1710 - } 1711 - } 1712 - }); 1713 - 1714 - function moveSelection(dCol, dRow) { 1715 - const sheet = getActiveSheet(); 1716 - const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1717 - const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1718 - const newCol = Math.max(1, Math.min(maxCol, selectedCell.col + dCol)); 1719 - const newRow = Math.max(1, Math.min(maxRow, selectedCell.row + dRow)); 1720 - selectedCell = { col: newCol, row: newRow }; 1721 - selectionRange = { startCol: newCol, startRow: newRow, endCol: newCol, endRow: newRow }; 1722 - updateSelectionVisuals(); 1723 - updateFormulaBar(); 1724 - updateMergeButtonState(); 1725 - updateWrapButtonState(); 1726 - updateBoldButtonState(); 1727 - updateItalicButtonState(); 1728 - updateUnderlineButtonState(); 1729 - updateStrikethroughButtonState(); 1730 - updateFontSizeSelect(); 1731 - updateFontFamilySelect(); 1732 - updateVerticalAlignButton(); 1733 - scrollCellIntoView(newCol, newRow); 1734 - } 1735 - 1736 - function extendSelection(dCol, dRow) { 1737 - const sheet = getActiveSheet(); 1738 - const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1739 - const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1740 - if (!selectionRange) { 1741 - selectionRange = { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 1742 - } 1743 - selectionRange.endCol = Math.max(1, Math.min(maxCol, selectionRange.endCol + dCol)); 1744 - selectionRange.endRow = Math.max(1, Math.min(maxRow, selectionRange.endRow + dRow)); 1745 - updateSelectionVisuals(); 1746 - scrollCellIntoView(selectionRange.endCol, selectionRange.endRow); 1747 - } 1748 - 1749 - function moveSelectionTo(col, row) { 1750 - selectedCell = { col, row }; 1751 - selectionRange = { startCol: col, startRow: row, endCol: col, endRow: row }; 1752 - updateSelectionVisuals(); 1753 - updateFormulaBar(); 1754 - updateBoldButtonState(); 1755 - updateItalicButtonState(); 1756 - updateUnderlineButtonState(); 1757 - updateStrikethroughButtonState(); 1758 - updateFontSizeSelect(); 1759 - updateFontFamilySelect(); 1760 - scrollCellIntoView(col, row); 1761 - } 1762 - 1763 - function getDataExtent() { 1764 - let maxRow = 1, maxCol = 1; 1765 - const cells = getCells(); 1766 - cells.forEach((_, id) => { 1767 - const ref = parseRef(id); 1768 - if (ref) { 1769 - if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 1770 - if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 1771 - } 1772 - }); 1773 - return { col: maxCol, row: maxRow }; 1774 - } 1775 - 1776 - function scrollCellIntoView(col, row) { 1777 - const td = grid.querySelector('td[data-col="' + col + '"][data-row="' + row + '"]'); 1778 - if (td) { 1779 - td.scrollIntoView({ block: 'nearest', inline: 'nearest' }); 1780 - return; 1781 - } 1782 - // Cell not in DOM (outside virtual range) — scroll to estimated position 1783 - if (!sheetContainer) return; 1784 - let targetTop = 0; 1785 - for (let r = 1; r < row; r++) targetTop += getRowHeight(r); 1786 - let targetLeft = ROW_HEADER_WIDTH; 1787 - for (let c = 1; c < col; c++) targetLeft += getColWidth(c); 1788 - sheetContainer.scrollTop = Math.max(0, targetTop - sheetContainer.clientHeight / 3); 1789 - sheetContainer.scrollLeft = Math.max(0, targetLeft - sheetContainer.clientWidth / 3); 1790 - } 1791 - 1792 - function deleteSelectedCells() { 1793 - if (!selectionRange) return; 1794 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 1795 - const cells = getCells(); 1796 - ydoc.transact(() => { 1797 - for (let r = startRow; r <= endRow; r++) { 1798 - for (let c = startCol; c <= endCol; c++) { 1799 - const id = cellId(c, r); 1800 - if (cells.has(id)) cells.delete(id); 1801 - } 1802 - } 1803 - }); 1804 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1805 - refreshVisibleCells(); 1806 - } 1807 - 1808 - function copySelection() { 1809 - if (!selectionRange) return; 1810 - const norm = normalizeRange(selectionRange); 1811 - const html = buildCopyHtml(getCellData, norm, cellId); 1812 - const tsv = buildCopyTsv(getCellData, norm, cellId); 1813 - 1814 - // Populate the internal clipboard buffer for paste-special 1815 - const { startCol, startRow, endCol, endRow } = norm; 1816 - const bufferRows = []; 1817 - for (let r = startRow; r <= endRow; r++) { 1818 - const row = []; 1819 - for (let c = startCol; c <= endCol; c++) { 1820 - const data = getCellData(cellId(c, r)); 1821 - row.push({ 1822 - value: data?.f ? '=' + data.f : (data?.v ?? ''), 1823 - formula: data?.f || '', 1824 - style: data?.s ? { ...data.s } : {}, 1825 - }); 1826 - } 1827 - bufferRows.push(row); 1828 - } 1829 - _clipboardBuffer = bufferRows; 1830 - 1831 - // Write both HTML and plain text to the system clipboard 1832 - try { 1833 - const blob = new Blob([html], { type: 'text/html' }); 1834 - const textBlob = new Blob([tsv], { type: 'text/plain' }); 1835 - navigator.clipboard.write([ 1836 - new ClipboardItem({ 1837 - 'text/html': blob, 1838 - 'text/plain': textBlob, 1839 - }), 1840 - ]).catch(() => { 1841 - // Fallback: write plain text only 1842 - navigator.clipboard.writeText(tsv).catch(() => {}); 1843 - }); 1844 - } catch { 1845 - // ClipboardItem not supported -- fallback to plain text 1846 - navigator.clipboard.writeText(tsv).catch(() => {}); 1847 - } 1848 - 1849 - // Show feedback toast 1850 - const norm2 = normalizeRange(selectionRange); 1851 - const rows = norm2.endRow - norm2.startRow + 1; 1852 - const cols = norm2.endCol - norm2.startCol + 1; 1853 - if (rows === 1 && cols === 1) { 1854 - showToast('Copied cell'); 1855 - } else { 1856 - showToast('Copied ' + rows + ' \u00d7 ' + cols + ' cells'); 1857 - } 1858 - } 1859 - 1860 - /** 1861 - * Paste parsed clipboard rows at the current selection. 1862 - * Accepts the row format from parseClipboardHtml/parseClipboardTsv: 1863 - * Array<Array<{ value, formula, style }>> 1864 - */ 1865 - function pasteRowsAtSelection(rows) { 1866 - if (!rows || rows.length === 0) return; 1867 - const sc = selectedCell.col; 1868 - const sr = selectedCell.row; 1869 - ydoc.transact(() => { 1870 - for (let r = 0; r < rows.length; r++) { 1871 - const row = rows[r]; 1872 - for (let c = 0; c < row.length; c++) { 1873 - const cell = row[c]; 1874 - const id = cellId(sc + c, sr + r); 1875 - const val = cell.value; 1876 - const formula = cell.formula || ''; 1877 - const style = cell.style && Object.keys(cell.style).length > 0 ? cell.style : undefined; 1878 - if (formula) { 1879 - setCellData(id, { v: val ?? '', f: formula, ...(style ? { s: style } : {}) }); 1880 - } else { 1881 - const n = typeof val === 'number' ? val : Number(val); 1882 - const v = val === '' ? '' : (typeof val === 'number' || !isNaN(n) && String(val).trim() !== '' ? n : val); 1883 - setCellData(id, { v, f: '', ...(style ? { s: style } : {}) }); 1884 - } 1885 - } 1886 - } 1887 - }); 1888 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1889 - refreshVisibleCells(); 1890 - } 1891 - 1892 - /** 1893 - * Legacy plain-text paste (kept for context menu fallback). 1894 - */ 1895 - function pasteAtSelection(text) { 1896 - const parsed = parseClipboardTsv(text); 1897 - if (parsed) { 1898 - pasteRowsAtSelection(parsed.rows); 1899 - } 1900 - } 1901 - 1902 - // --- Paste Special (extracted to paste-special-ui.ts) --- 1903 - function showPasteSpecialDialog() { 1904 - _showPasteSpecialDialogUI({ getClipboardBuffer: () => _clipboardBuffer, pasteRowsAtSelection }); 1905 - } 1906 - 1907 - // --- Visual updates (#18: improved range selection) --- 1908 - // Track previously styled elements to avoid full-grid querySelectorAll on every selection change 1909 - const selectionClasses = ['selected', 'in-range', 'range-top', 'range-bottom', 'range-left', 'range-right', 'col-selected', 'row-selected', 'has-fill-handle', 'fill-preview'] as const; 1910 - let prevSelectionEls: Element[] = []; 1911 - 1912 - function clearPrevSelection() { 1913 - for (const el of prevSelectionEls) { 1914 - el.classList.remove(...selectionClasses); 1915 - el.removeAttribute('aria-selected'); 1916 - } 1917 - prevSelectionEls = []; 1550 + // --- Selection & navigation (extracted to selection-navigation.ts) --- 1551 + function _selNavDeps() { 1552 + return { 1553 + grid, cellAddressInput, sheetContainer, 1554 + getActiveSheet, getSelectedCell: () => selectedCell, 1555 + setSelectedCell: (c) => { selectedCell = c; }, 1556 + getSelectionRange: () => selectionRange, 1557 + setSelectionRange: (r) => { selectionRange = r; }, 1558 + getCells, getColWidth, getRowHeight, 1559 + updateFormulaBar, updateStatusBar, updateMergeButtonState, 1560 + updateWrapButtonState, updateBoldButtonState, updateItalicButtonState, 1561 + updateUnderlineButtonState, updateStrikethroughButtonState, 1562 + updateFontSizeSelect, updateFontFamilySelect, updateVerticalAlignButton, 1563 + ROW_HEADER_WIDTH, DEFAULT_COLS, DEFAULT_ROWS, 1564 + }; 1918 1565 } 1919 - 1566 + function moveSelection(dCol, dRow) { _moveSelection(_selNavDeps(), dCol, dRow); } 1567 + function extendSelection(dCol, dRow) { _extendSelection(_selNavDeps(), dCol, dRow); } 1568 + function moveSelectionTo(col, row) { _moveSelectionTo(_selNavDeps(), col, row); } 1569 + function getDataExtent() { return _getDataExtent(_selNavDeps()); } 1570 + function scrollCellIntoView(col, row) { _scrollCellIntoView(_selNavDeps(), col, row); } 1920 1571 function getCellEl(col: number, row: number): Element | null { 1921 - return grid.querySelector(`td[data-col="${col}"][data-row="${row}"]`); 1572 + return _getCellEl(grid, col, row); 1922 1573 } 1574 + function updateSelectionVisuals() { _updateSelectionVisuals(_selNavDeps()); } 1575 + function clearPrevSelection() { _clearPrevSelection(); } 1923 1576 1924 - function updateSelectionVisuals() { 1925 - clearPrevSelection(); 1926 - 1927 - const currentTd = getCellEl(selectedCell.col, selectedCell.row); 1928 - if (currentTd) { 1929 - currentTd.classList.add('selected'); 1930 - currentTd.setAttribute('aria-selected', 'true'); 1931 - prevSelectionEls.push(currentTd); 1932 - } 1577 + // --- Clipboard operations (extracted to clipboard-operations.ts) --- 1578 + function _clipboardDeps() { 1579 + return { 1580 + ydoc, grid, 1581 + getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 1582 + getCellData, setCellData, getCells, 1583 + evalCache: { clear: () => evalCache.clear() }, 1584 + clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 1585 + getClipboardBuffer: () => _clipboardBuffer, 1586 + setClipboardBuffer: (buf) => { _clipboardBuffer = buf; }, 1587 + }; 1588 + } 1589 + function deleteSelectedCells() { _deleteSelectedCells(_clipboardDeps()); } 1590 + function copySelection() { _copySelection(_clipboardDeps()); } 1591 + function pasteRowsAtSelection(rows) { _pasteRowsAtSelection(_clipboardDeps(), rows); } 1592 + function pasteAtSelection(text) { _pasteAtSelection(_clipboardDeps(), text); } 1593 + function showPasteSpecialDialog() { _showPasteSpecialDialogCO(_clipboardDeps()); } 1933 1594 1934 - if (selectionRange) { 1935 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 1936 - const isMultiCell = startCol !== endCol || startRow !== endRow; 1937 - for (let r = startRow; r <= endRow; r++) { 1938 - for (let c = startCol; c <= endCol; c++) { 1939 - const td = getCellEl(c, r); 1940 - if (!td) continue; 1941 - td.setAttribute('aria-selected', 'true'); 1942 - if (!(c === selectedCell.col && r === selectedCell.row)) td.classList.add('in-range'); 1943 - if (isMultiCell) { 1944 - if (r === startRow) td.classList.add('range-top'); 1945 - if (r === endRow) td.classList.add('range-bottom'); 1946 - if (c === startCol) td.classList.add('range-left'); 1947 - if (c === endCol) td.classList.add('range-right'); 1948 - } 1949 - prevSelectionEls.push(td); 1950 - } 1951 - } 1952 - for (let c = startCol; c <= endCol; c++) { 1953 - const th = grid.querySelector('thead th[data-col="' + c + '"]'); 1954 - if (th) { th.classList.add('col-selected'); prevSelectionEls.push(th); } 1955 - } 1956 - for (let r = startRow; r <= endRow; r++) { 1957 - const th = grid.querySelector('th.row-header[data-row="' + r + '"]'); 1958 - if (th) { th.classList.add('row-selected'); prevSelectionEls.push(th); } 1959 - } 1960 - if (isMultiCell) { 1961 - cellAddressInput.value = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 1962 - } 1963 - } 1964 - // Render fill handle on bottom-right cell of selection 1965 - const existingHandle = grid.querySelector('.fill-handle'); 1966 - if (existingHandle) existingHandle.remove(); 1967 - if (selectionRange) { 1968 - const { endCol: brCol, endRow: brRow } = normalizeRange(selectionRange); 1969 - const brTd = getCellEl(brCol, brRow); 1970 - if (brTd) { 1971 - brTd.classList.add('has-fill-handle'); 1972 - const handle = document.createElement('div'); 1973 - handle.className = 'fill-handle'; 1974 - (brTd as HTMLElement).appendChild(handle); 1975 - } 1976 - } else { 1977 - const curTd = getCellEl(selectedCell.col, selectedCell.row); 1978 - if (curTd) { 1979 - curTd.classList.add('has-fill-handle'); 1980 - const handle = document.createElement('div'); 1981 - handle.className = 'fill-handle'; 1982 - (curTd as HTMLElement).appendChild(handle); 1983 - } 1984 - } 1595 + // --- Keyboard navigation (extracted to keyboard-handler.ts) --- 1596 + _wireKeyboardHandler({ 1597 + grid, formulaInput, sheetContainer, provider, 1598 + getSelectedCell: () => selectedCell, 1599 + setSelectedCell: (c) => { selectedCell = c; }, 1600 + getSelectionRange: () => selectionRange, 1601 + setSelectionRange: (r) => { selectionRange = r; }, 1602 + getActiveSheet, getCellData, setCellData, 1603 + getEditingCell: () => editingCell, startEditing, 1604 + moveSelection, extendSelection, moveSelectionTo, getDataExtent, 1605 + updateSelectionVisuals, updateStatusBar, 1606 + copySelection, deleteSelectedCells, showPasteSpecialDialog, 1607 + applyStyleToSelection, clearFormattingSelection, 1608 + updateUnderlineButtonState, updateStrikethroughButtonState, 1609 + undoManager, evalCache: { clear: () => evalCache.clear() }, 1610 + clearSpillMaps: _clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, renderGrid, 1611 + hideSelectedRows, hideSelectedCols, unhideAdjacentRows, unhideAdjacentCols, 1612 + toggleFilterMode, printSheet, showFindReplaceBar, 1613 + DEFAULT_COLS, DEFAULT_ROWS, 1614 + }); 1985 1615 1986 - updateStatusBar(); 1987 - } 1616 + // Paste event listener (extracted to clipboard-operations.ts) 1617 + _wirePasteListener(_clipboardDeps(), { getEditingCell: () => editingCell, formulaInput }); 1988 1618 1989 1619 function updateFormulaBar() { 1990 1620 const id = cellId(selectedCell.col, selectedCell.row);
+210
src/sheets/selection-navigation.ts
··· 1 + /** 2 + * Selection & Navigation — cell selection, range extension, scrolling, visual highlights. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId, parseRef } from './formulas.js'; 8 + import { normalizeRange } from './selection-utils.js'; 9 + 10 + // ── Types ─────────────────────────────────────────────────── 11 + 12 + export interface SelectionNavigationDeps { 13 + grid: HTMLElement; 14 + cellAddressInput: HTMLInputElement; 15 + sheetContainer: HTMLElement | null; 16 + getActiveSheet: () => any; 17 + getSelectedCell: () => { col: number; row: number }; 18 + setSelectedCell: (cell: { col: number; row: number }) => void; 19 + getSelectionRange: () => any; 20 + setSelectionRange: (range: any) => void; 21 + getCells: () => any; 22 + getColWidth: (col: number) => number; 23 + getRowHeight: (row: number) => number; 24 + updateFormulaBar: () => void; 25 + updateStatusBar: () => void; 26 + updateMergeButtonState: () => void; 27 + updateWrapButtonState: () => void; 28 + updateBoldButtonState: () => void; 29 + updateItalicButtonState: () => void; 30 + updateUnderlineButtonState: () => void; 31 + updateStrikethroughButtonState: () => void; 32 + updateFontSizeSelect: () => void; 33 + updateFontFamilySelect: () => void; 34 + updateVerticalAlignButton: () => void; 35 + ROW_HEADER_WIDTH: number; 36 + DEFAULT_COLS: number; 37 + DEFAULT_ROWS: number; 38 + } 39 + 40 + // ── Selection classes (shared with main.ts for clearPrevSelection) ── 41 + 42 + export const selectionClasses = ['selected', 'in-range', 'range-top', 'range-bottom', 'range-left', 'range-right', 'col-selected', 'row-selected', 'has-fill-handle', 'fill-preview'] as const; 43 + 44 + // ── State managed by this module ──────────────────────────── 45 + 46 + let prevSelectionEls: Element[] = []; 47 + 48 + // ── Functions ─────────────────────────────────────────────── 49 + 50 + export function clearPrevSelection(): void { 51 + for (const el of prevSelectionEls) { 52 + el.classList.remove(...selectionClasses); 53 + el.removeAttribute('aria-selected'); 54 + } 55 + prevSelectionEls = []; 56 + } 57 + 58 + export function getCellEl(grid: HTMLElement, col: number, row: number): Element | null { 59 + return grid.querySelector(`td[data-col="${col}"][data-row="${row}"]`); 60 + } 61 + 62 + export function moveSelection(deps: SelectionNavigationDeps, dCol: number, dRow: number): void { 63 + const sheet = deps.getActiveSheet(); 64 + const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; 65 + const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; 66 + const selected = deps.getSelectedCell(); 67 + const newCol = Math.max(1, Math.min(maxCol, selected.col + dCol)); 68 + const newRow = Math.max(1, Math.min(maxRow, selected.row + dRow)); 69 + deps.setSelectedCell({ col: newCol, row: newRow }); 70 + deps.setSelectionRange({ startCol: newCol, startRow: newRow, endCol: newCol, endRow: newRow }); 71 + updateSelectionVisuals(deps); 72 + deps.updateFormulaBar(); 73 + deps.updateMergeButtonState(); 74 + deps.updateWrapButtonState(); 75 + deps.updateBoldButtonState(); 76 + deps.updateItalicButtonState(); 77 + deps.updateUnderlineButtonState(); 78 + deps.updateStrikethroughButtonState(); 79 + deps.updateFontSizeSelect(); 80 + deps.updateFontFamilySelect(); 81 + deps.updateVerticalAlignButton(); 82 + scrollCellIntoView(deps, newCol, newRow); 83 + } 84 + 85 + export function extendSelection(deps: SelectionNavigationDeps, dCol: number, dRow: number): void { 86 + const sheet = deps.getActiveSheet(); 87 + const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; 88 + const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; 89 + let range = deps.getSelectionRange(); 90 + if (!range) { 91 + const selected = deps.getSelectedCell(); 92 + range = { startCol: selected.col, startRow: selected.row, endCol: selected.col, endRow: selected.row }; 93 + } 94 + range.endCol = Math.max(1, Math.min(maxCol, range.endCol + dCol)); 95 + range.endRow = Math.max(1, Math.min(maxRow, range.endRow + dRow)); 96 + deps.setSelectionRange(range); 97 + updateSelectionVisuals(deps); 98 + scrollCellIntoView(deps, range.endCol, range.endRow); 99 + } 100 + 101 + export function moveSelectionTo(deps: SelectionNavigationDeps, col: number, row: number): void { 102 + deps.setSelectedCell({ col, row }); 103 + deps.setSelectionRange({ startCol: col, startRow: row, endCol: col, endRow: row }); 104 + updateSelectionVisuals(deps); 105 + deps.updateFormulaBar(); 106 + deps.updateBoldButtonState(); 107 + deps.updateItalicButtonState(); 108 + deps.updateUnderlineButtonState(); 109 + deps.updateStrikethroughButtonState(); 110 + deps.updateFontSizeSelect(); 111 + deps.updateFontFamilySelect(); 112 + scrollCellIntoView(deps, col, row); 113 + } 114 + 115 + export function getDataExtent(deps: SelectionNavigationDeps): { col: number; row: number } { 116 + let maxRow = 1, maxCol = 1; 117 + const cells = deps.getCells(); 118 + cells.forEach((_: any, id: string) => { 119 + const ref = parseRef(id); 120 + if (ref) { 121 + if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 122 + if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 123 + } 124 + }); 125 + return { col: maxCol, row: maxRow }; 126 + } 127 + 128 + export function scrollCellIntoView(deps: SelectionNavigationDeps, col: number, row: number): void { 129 + const td = deps.grid.querySelector('td[data-col="' + col + '"][data-row="' + row + '"]'); 130 + if (td) { 131 + td.scrollIntoView({ block: 'nearest', inline: 'nearest' }); 132 + return; 133 + } 134 + // Cell not in DOM (outside virtual range) — scroll to estimated position 135 + if (!deps.sheetContainer) return; 136 + let targetTop = 0; 137 + for (let r = 1; r < row; r++) targetTop += deps.getRowHeight(r); 138 + let targetLeft = deps.ROW_HEADER_WIDTH; 139 + for (let c = 1; c < col; c++) targetLeft += deps.getColWidth(c); 140 + deps.sheetContainer.scrollTop = Math.max(0, targetTop - deps.sheetContainer.clientHeight / 3); 141 + deps.sheetContainer.scrollLeft = Math.max(0, targetLeft - deps.sheetContainer.clientWidth / 3); 142 + } 143 + 144 + export function updateSelectionVisuals(deps: SelectionNavigationDeps): void { 145 + clearPrevSelection(); 146 + 147 + const selectedCell = deps.getSelectedCell(); 148 + const selectionRange = deps.getSelectionRange(); 149 + 150 + const currentTd = getCellEl(deps.grid, selectedCell.col, selectedCell.row); 151 + if (currentTd) { 152 + currentTd.classList.add('selected'); 153 + currentTd.setAttribute('aria-selected', 'true'); 154 + prevSelectionEls.push(currentTd); 155 + } 156 + 157 + if (selectionRange) { 158 + const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 159 + const isMultiCell = startCol !== endCol || startRow !== endRow; 160 + for (let r = startRow; r <= endRow; r++) { 161 + for (let c = startCol; c <= endCol; c++) { 162 + const td = getCellEl(deps.grid, c, r); 163 + if (!td) continue; 164 + td.setAttribute('aria-selected', 'true'); 165 + if (!(c === selectedCell.col && r === selectedCell.row)) td.classList.add('in-range'); 166 + if (isMultiCell) { 167 + if (r === startRow) td.classList.add('range-top'); 168 + if (r === endRow) td.classList.add('range-bottom'); 169 + if (c === startCol) td.classList.add('range-left'); 170 + if (c === endCol) td.classList.add('range-right'); 171 + } 172 + prevSelectionEls.push(td); 173 + } 174 + } 175 + for (let c = startCol; c <= endCol; c++) { 176 + const th = deps.grid.querySelector('thead th[data-col="' + c + '"]'); 177 + if (th) { th.classList.add('col-selected'); prevSelectionEls.push(th); } 178 + } 179 + for (let r = startRow; r <= endRow; r++) { 180 + const th = deps.grid.querySelector('th.row-header[data-row="' + r + '"]'); 181 + if (th) { th.classList.add('row-selected'); prevSelectionEls.push(th); } 182 + } 183 + if (isMultiCell) { 184 + deps.cellAddressInput.value = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 185 + } 186 + } 187 + // Render fill handle on bottom-right cell of selection 188 + const existingHandle = deps.grid.querySelector('.fill-handle'); 189 + if (existingHandle) existingHandle.remove(); 190 + if (selectionRange) { 191 + const { endCol: brCol, endRow: brRow } = normalizeRange(selectionRange); 192 + const brTd = getCellEl(deps.grid, brCol, brRow); 193 + if (brTd) { 194 + brTd.classList.add('has-fill-handle'); 195 + const handle = document.createElement('div'); 196 + handle.className = 'fill-handle'; 197 + (brTd as HTMLElement).appendChild(handle); 198 + } 199 + } else { 200 + const curTd = getCellEl(deps.grid, selectedCell.col, selectedCell.row); 201 + if (curTd) { 202 + curTd.classList.add('has-fill-handle'); 203 + const handle = document.createElement('div'); 204 + handle.className = 'fill-handle'; 205 + (curTd as HTMLElement).appendChild(handle); 206 + } 207 + } 208 + 209 + deps.updateStatusBar(); 210 + }