/** * Selection & Navigation — cell selection, range extension, scrolling, visual highlights. * * Extracted from main.ts for decomposition. */ import { cellId, parseRef } from './formulas.js'; import { normalizeRange } from './selection-utils.js'; // ── Types ─────────────────────────────────────────────────── export interface SelectionNavigationDeps { grid: HTMLElement; cellAddressInput: HTMLInputElement; sheetContainer: HTMLElement | null; getActiveSheet: () => any; getSelectedCell: () => { col: number; row: number }; setSelectedCell: (cell: { col: number; row: number }) => void; getSelectionRange: () => any; setSelectionRange: (range: any) => void; getCells: () => any; getColWidth: (col: number) => number; getRowHeight: (row: number) => number; updateFormulaBar: () => void; updateStatusBar: () => void; updateMergeButtonState: () => void; updateWrapButtonState: () => void; updateBoldButtonState: () => void; updateItalicButtonState: () => void; updateUnderlineButtonState: () => void; updateStrikethroughButtonState: () => void; updateFontSizeSelect: () => void; updateFontFamilySelect: () => void; updateVerticalAlignButton: () => void; updateColorPickerState: () => void; ROW_HEADER_WIDTH: number; DEFAULT_COLS: number; DEFAULT_ROWS: number; } // ── Selection classes (shared with main.ts for clearPrevSelection) ── 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; // ── State managed by this module ──────────────────────────── let prevSelectionEls: Element[] = []; // ── Functions ─────────────────────────────────────────────── export function clearPrevSelection(): void { for (const el of prevSelectionEls) { el.classList.remove(...selectionClasses); el.removeAttribute('aria-selected'); } prevSelectionEls = []; } export function getCellEl(grid: HTMLElement, col: number, row: number): Element | null { return grid.querySelector(`td[data-col="${col}"][data-row="${row}"]`); } export function moveSelection(deps: SelectionNavigationDeps, dCol: number, dRow: number): void { const sheet = deps.getActiveSheet(); const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; const selected = deps.getSelectedCell(); const newCol = Math.max(1, Math.min(maxCol, selected.col + dCol)); const newRow = Math.max(1, Math.min(maxRow, selected.row + dRow)); deps.setSelectedCell({ col: newCol, row: newRow }); deps.setSelectionRange({ startCol: newCol, startRow: newRow, endCol: newCol, endRow: newRow }); updateSelectionVisuals(deps); deps.updateFormulaBar(); deps.updateMergeButtonState(); deps.updateWrapButtonState(); deps.updateBoldButtonState(); deps.updateItalicButtonState(); deps.updateUnderlineButtonState(); deps.updateStrikethroughButtonState(); deps.updateFontSizeSelect(); deps.updateFontFamilySelect(); deps.updateVerticalAlignButton(); deps.updateColorPickerState(); scrollCellIntoView(deps, newCol, newRow); } export function extendSelection(deps: SelectionNavigationDeps, dCol: number, dRow: number): void { const sheet = deps.getActiveSheet(); const maxCol = sheet.get('colCount') || deps.DEFAULT_COLS; const maxRow = sheet.get('rowCount') || deps.DEFAULT_ROWS; let range = deps.getSelectionRange(); if (!range) { const selected = deps.getSelectedCell(); range = { startCol: selected.col, startRow: selected.row, endCol: selected.col, endRow: selected.row }; } range.endCol = Math.max(1, Math.min(maxCol, range.endCol + dCol)); range.endRow = Math.max(1, Math.min(maxRow, range.endRow + dRow)); deps.setSelectionRange(range); updateSelectionVisuals(deps); scrollCellIntoView(deps, range.endCol, range.endRow); } export function moveSelectionTo(deps: SelectionNavigationDeps, col: number, row: number): void { deps.setSelectedCell({ col, row }); deps.setSelectionRange({ startCol: col, startRow: row, endCol: col, endRow: row }); updateSelectionVisuals(deps); deps.updateFormulaBar(); deps.updateBoldButtonState(); deps.updateItalicButtonState(); deps.updateUnderlineButtonState(); deps.updateStrikethroughButtonState(); deps.updateFontSizeSelect(); deps.updateFontFamilySelect(); deps.updateColorPickerState(); scrollCellIntoView(deps, col, row); } export function getDataExtent(deps: SelectionNavigationDeps): { col: number; row: number } { let maxRow = 1, maxCol = 1; const cells = deps.getCells(); cells.forEach((_: any, id: string) => { const ref = parseRef(id); if (ref) { if (ref.row + 1 > maxRow) maxRow = ref.row + 1; if (ref.col + 1 > maxCol) maxCol = ref.col + 1; } }); return { col: maxCol, row: maxRow }; } export function scrollCellIntoView(deps: SelectionNavigationDeps, col: number, row: number): void { const td = deps.grid.querySelector('td[data-col="' + col + '"][data-row="' + row + '"]'); if (td) { td.scrollIntoView({ block: 'nearest', inline: 'nearest' }); return; } // Cell not in DOM (outside virtual range) — scroll to estimated position if (!deps.sheetContainer) return; let targetTop = 0; for (let r = 1; r < row; r++) targetTop += deps.getRowHeight(r); let targetLeft = deps.ROW_HEADER_WIDTH; for (let c = 1; c < col; c++) targetLeft += deps.getColWidth(c); deps.sheetContainer.scrollTop = Math.max(0, targetTop - deps.sheetContainer.clientHeight / 3); deps.sheetContainer.scrollLeft = Math.max(0, targetLeft - deps.sheetContainer.clientWidth / 3); } export function updateSelectionVisuals(deps: SelectionNavigationDeps): void { clearPrevSelection(); const selectedCell = deps.getSelectedCell(); const selectionRange = deps.getSelectionRange(); const currentTd = getCellEl(deps.grid, selectedCell.col, selectedCell.row); if (currentTd) { currentTd.classList.add('selected'); currentTd.setAttribute('aria-selected', 'true'); prevSelectionEls.push(currentTd); } if (selectionRange) { const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); const isMultiCell = startCol !== endCol || startRow !== endRow; for (let r = startRow; r <= endRow; r++) { for (let c = startCol; c <= endCol; c++) { const td = getCellEl(deps.grid, c, r); if (!td) continue; td.setAttribute('aria-selected', 'true'); if (!(c === selectedCell.col && r === selectedCell.row)) td.classList.add('in-range'); if (isMultiCell) { if (r === startRow) td.classList.add('range-top'); if (r === endRow) td.classList.add('range-bottom'); if (c === startCol) td.classList.add('range-left'); if (c === endCol) td.classList.add('range-right'); } prevSelectionEls.push(td); } } for (let c = startCol; c <= endCol; c++) { const th = deps.grid.querySelector('thead th[data-col="' + c + '"]'); if (th) { th.classList.add('col-selected'); prevSelectionEls.push(th); } } for (let r = startRow; r <= endRow; r++) { const th = deps.grid.querySelector('th.row-header[data-row="' + r + '"]'); if (th) { th.classList.add('row-selected'); prevSelectionEls.push(th); } } if (isMultiCell) { deps.cellAddressInput.value = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); } } // Render fill handle on bottom-right cell of selection const existingHandle = deps.grid.querySelector('.fill-handle'); if (existingHandle) existingHandle.remove(); if (selectionRange) { const { endCol: brCol, endRow: brRow } = normalizeRange(selectionRange); const brTd = getCellEl(deps.grid, brCol, brRow); if (brTd) { brTd.classList.add('has-fill-handle'); const handle = document.createElement('div'); handle.className = 'fill-handle'; (brTd as HTMLElement).appendChild(handle); } } else { const curTd = getCellEl(deps.grid, selectedCell.col, selectedCell.row); if (curTd) { curTd.classList.add('has-fill-handle'); const handle = document.createElement('div'); handle.className = 'fill-handle'; (curTd as HTMLElement).appendChild(handle); } } deps.updateStatusBar(); }