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 7 — extract grid rendering, touch, cell editing, mouse events' (#290) from refactor/sheets-decompose-phase7 into main

scott 70058398 53fcaaad

+1311 -1063
+172
src/sheets/cell-editing.ts
··· 1 + /** 2 + * Cell Editing — inline cell editor, commit logic, and formula bar sync. 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 { clearGridHighlights } from './range-highlight.js'; 10 + import { hideTooltip } from './formula-tooltip.js'; 11 + import { parseDateValue, showDatePicker } from './date-picker.js'; 12 + 13 + // ── Types ─────────────────────────────────────────────────── 14 + 15 + export interface CellEditingDeps { 16 + grid: HTMLElement; 17 + formulaInput: HTMLInputElement; 18 + getSelectedCell: () => { col: number; row: number }; 19 + getSelectionRange: () => any; 20 + getEditingCell: () => { col: number; row: number } | null; 21 + setEditingCell: (cell: { col: number; row: number } | null) => void; 22 + getCellData: (id: string) => any; 23 + setCellData: (id: string, data: any) => void; 24 + cellAddressInput: HTMLInputElement; 25 + evalCache: { clear: () => void }; 26 + clearSpillMaps: () => void; 27 + invalidateRecalcEngine: () => void; 28 + refreshVisibleCells: () => void; 29 + moveSelection: (dCol: number, dRow: number) => void; 30 + updateFormulaHighlight: (text: string, useRangeColors?: boolean) => void; 31 + updateFormulaRangeHighlights: (text: string) => void; 32 + updateFormulaTooltip: (text: string, pos: number | null, anchor: HTMLElement) => void; 33 + hideAutocomplete: () => void; 34 + attachCellEditorAutocomplete: (input: HTMLInputElement) => void; 35 + computeDisplayValue: (id: string, data: any) => any; 36 + } 37 + 38 + // ── State ─────────────────────────────────────────────────── 39 + 40 + let _commitInProgress = false; 41 + 42 + // ── Functions ─────────────────────────────────────────────── 43 + 44 + export function startEditing(deps: CellEditingDeps, col: number, row: number): void { 45 + const { grid, formulaInput, getCellData, setEditingCell, getEditingCell } = deps; 46 + 47 + if (getEditingCell()) commitEdit(deps); 48 + setEditingCell({ col, row }); 49 + const id = cellId(col, row); 50 + const td = grid.querySelector('td[data-id="' + id + '"]'); 51 + if (!td) return; 52 + td.classList.add('editing'); 53 + const cellData = getCellData(id); 54 + let value = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 55 + // Show date-formatted cells as readable date strings in the editor 56 + if (!cellData?.f && cellData?.s?.format === 'date' && typeof value === 'number') { 57 + const d = new Date(value); 58 + if (!isNaN(d.getTime())) { 59 + value = d.toLocaleDateString(); 60 + } 61 + } 62 + const input = document.createElement('input'); 63 + input.className = 'cell-editor'; 64 + input.value = value; 65 + input.placeholder = 'Type or = for formula'; 66 + td.appendChild(input); 67 + input.focus(); 68 + input.select(); 69 + formulaInput.value = value; 70 + input.addEventListener('keydown', (e) => onEditKeyDown(deps, e)); 71 + input.addEventListener('blur', () => { deps.hideAutocomplete(); hideTooltip(); commitEdit(deps); }); 72 + // Attach formula autocomplete to cell editor 73 + deps.attachCellEditorAutocomplete(input); 74 + // Attach formula UX enhancements: range highlights + tooltip 75 + attachCellEditorFormulaUX(deps, input, td as HTMLElement); 76 + // Initial highlight/range update (with range colors — editing active) 77 + deps.updateFormulaHighlight(value, true); 78 + deps.updateFormulaRangeHighlights(value); 79 + 80 + // Show date picker for date-like values (#123) 81 + if (!cellData?.f && parseDateValue(String(value))) { 82 + showDatePicker(td as HTMLElement, String(value), { 83 + onSelect: (dateStr) => { 84 + input.value = dateStr; 85 + formulaInput.value = dateStr; 86 + commitEdit(deps); 87 + }, 88 + onClose: () => {}, 89 + }); 90 + } 91 + } 92 + 93 + export function commitEdit(deps: CellEditingDeps): void { 94 + const { grid, getCellData, setCellData, getEditingCell, setEditingCell } = deps; 95 + const editingCell = getEditingCell(); 96 + if (!editingCell || _commitInProgress) return; 97 + _commitInProgress = true; 98 + const id = cellId(editingCell.col, editingCell.row); 99 + const td = grid.querySelector('td[data-id="' + id + '"]'); 100 + const input = td?.querySelector('.cell-editor') as HTMLInputElement | null; 101 + if (input) { 102 + const raw = input.value.trim(); 103 + if (raw.startsWith('=')) { 104 + setCellData(id, { v: '', f: raw.slice(1) }); 105 + } else { 106 + const existingData = getCellData(id); 107 + const numVal = Number(raw); 108 + let value: string | number = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 109 + // If cell has date format and user typed a date string, parse it back to timestamp 110 + if (typeof value === 'string' && value !== '' && existingData?.s?.format === 'date') { 111 + const parsed = Date.parse(value); 112 + if (!isNaN(parsed)) value = parsed; 113 + } 114 + setCellData(id, { v: value, f: '' }); 115 + } 116 + input.remove(); 117 + } 118 + if (td) td.classList.remove('editing'); 119 + setEditingCell(null); 120 + deps.evalCache.clear(); deps.clearSpillMaps(); deps.invalidateRecalcEngine(); 121 + clearGridHighlights(); 122 + hideTooltip(); 123 + deps.refreshVisibleCells(); 124 + _commitInProgress = false; 125 + } 126 + 127 + export function onEditKeyDown(deps: CellEditingDeps, e: KeyboardEvent): void { 128 + const { grid, setEditingCell } = deps; 129 + if (e.key === 'Enter') { e.preventDefault(); commitEdit(deps); deps.moveSelection(0, 1); } 130 + else if (e.key === 'Tab') { e.preventDefault(); commitEdit(deps); deps.moveSelection(e.shiftKey ? -1 : 1, 0); } 131 + else if (e.key === 'Escape') { 132 + setEditingCell(null); 133 + grid.querySelectorAll('.cell-editor').forEach(el => el.remove()); 134 + grid.querySelectorAll('.editing').forEach(el => el.classList.remove('editing')); 135 + clearGridHighlights(); 136 + hideTooltip(); 137 + updateFormulaBar(deps); 138 + } 139 + } 140 + 141 + export function updateFormulaBar(deps: CellEditingDeps): void { 142 + const { cellAddressInput, formulaInput, getCellData, getSelectedCell, getSelectionRange } = deps; 143 + const selectedCell = getSelectedCell(); 144 + const selectionRange = getSelectionRange(); 145 + const id = cellId(selectedCell.col, selectedCell.row); 146 + if (selectionRange) { 147 + const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 148 + if (startCol !== endCol || startRow !== endRow) { 149 + cellAddressInput.value = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 150 + } else { 151 + cellAddressInput.value = id; 152 + } 153 + } else { 154 + cellAddressInput.value = id; 155 + } 156 + const cellData = getCellData(id); 157 + let barValue = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 158 + // Show date-formatted cells as readable date strings in formula bar 159 + if (!cellData?.f && cellData?.s?.format === 'date' && typeof barValue === 'number') { 160 + const d = new Date(barValue); 161 + if (!isNaN(d.getTime())) barValue = d.toLocaleDateString(); 162 + } 163 + formulaInput.value = barValue; 164 + deps.updateFormulaHighlight(formulaInput.value); 165 + } 166 + 167 + export function attachCellEditorFormulaUX(deps: CellEditingDeps, inputEl: HTMLInputElement, anchorTd: HTMLElement): void { 168 + const { formulaInput } = deps; 169 + inputEl.addEventListener('input', () => { const text = inputEl.value; formulaInput.value = text; deps.updateFormulaHighlight(text, true); deps.updateFormulaRangeHighlights(text); deps.updateFormulaTooltip(text, inputEl.selectionStart, anchorTd); }); 170 + inputEl.addEventListener('click', () => { deps.updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); }); 171 + inputEl.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) deps.updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); }); 172 + }
+358
src/sheets/grid-rendering.ts
··· 1 + /** 2 + * Grid Rendering — builds the spreadsheet HTML table and renders it to the DOM. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId, colToLetter } from './formulas.js'; 8 + import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 9 + import { getErrorInfo } from './error-tooltips.js'; 10 + import { renderInteractiveCell } from './rich-cells.js'; 11 + import { getStripedRowClass } from './cell-styles.js'; 12 + import { getCellBgColor, getCellStyle } from './cell-style-utils.js'; 13 + import { computeVisibleRows, computeVisibleCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from './hidden-rows-cols.js'; 14 + import { isCellMatch, isCurrentMatch } from './sheets-find-replace.js'; 15 + import { isSparklineResult, drawSparkline } from './sparkline.js'; 16 + import { escapeHtml } from '../lib/escape-html.js'; 17 + import { validateCell } from './data-validation.js'; 18 + import { isInRange } from './selection-utils.js'; 19 + 20 + // ── Types ─────────────────────────────────────────────────── 21 + 22 + export interface GridRenderingDeps { 23 + grid: HTMLElement; 24 + getActiveSheet: () => any; 25 + getCellData: (id: string) => any; 26 + computeDisplayValue: (id: string, data: any) => any; 27 + getColWidth: (col: number) => number; 28 + getRowHeight: (row: number) => number; 29 + getFreezeRows: () => number; 30 + getFreezeCols: () => number; 31 + getCfRulesArray: () => any[]; 32 + getStripedRows: () => boolean; 33 + getValidationForCell: (id: string) => any; 34 + buildMergeMap: () => Map<string, any>; 35 + buildHiddenRowSet: () => { has: (r: number) => boolean }; 36 + buildHiddenColSet: () => { has: (c: number) => boolean }; 37 + isSpillSource: (id: string) => boolean; 38 + isSpillTarget: (id: string) => boolean; 39 + sheetsFindState: any; 40 + getEditingCell: () => any; 41 + getSelectedCell: () => { col: number; row: number }; 42 + getSelectionRange: () => any; 43 + refreshVisibleCells: () => void; 44 + updateSelectionVisuals: () => void; 45 + updateFreezeToolbarState: () => void; 46 + renderNoteIndicators: () => void; 47 + renderImageCells: () => void; 48 + attachGridEvents: () => void; 49 + DEFAULT_ROWS: number; 50 + DEFAULT_COLS: number; 51 + MIN_COL_WIDTH: number; 52 + ROW_HEADER_WIDTH: number; 53 + } 54 + 55 + // ── State ─────────────────────────────────────────────────── 56 + 57 + let _renderGridTimer: number | null = null; 58 + let _isRendering = false; 59 + let _gridEventsAttached = false; 60 + 61 + // ── Functions ─────────────────────────────────────────────── 62 + 63 + export function scheduleRenderGrid(deps: GridRenderingDeps): void { 64 + if (_renderGridTimer) return; 65 + _renderGridTimer = requestAnimationFrame(() => { 66 + _renderGridTimer = null; 67 + if (deps.getEditingCell()) { deps.refreshVisibleCells(); return; } 68 + renderGrid(deps); 69 + }); 70 + } 71 + 72 + export function renderGrid(deps: GridRenderingDeps): void { 73 + _isRendering = true; 74 + const { 75 + grid, getActiveSheet, getCellData, computeDisplayValue, getColWidth, getRowHeight, 76 + getFreezeRows, getFreezeCols, getCfRulesArray, getStripedRows, 77 + getValidationForCell, buildMergeMap, buildHiddenRowSet, buildHiddenColSet, 78 + isSpillSource, isSpillTarget, sheetsFindState, getSelectedCell, getSelectionRange, 79 + updateSelectionVisuals, updateFreezeToolbarState, renderNoteIndicators, renderImageCells, 80 + attachGridEvents, 81 + DEFAULT_ROWS, DEFAULT_COLS, MIN_COL_WIDTH, ROW_HEADER_WIDTH, 82 + } = deps; 83 + 84 + const sheet = getActiveSheet(); 85 + const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 86 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 87 + const freezeR = getFreezeRows(); 88 + const freezeC = getFreezeCols(); 89 + const mergeMap = buildMergeMap(); 90 + 91 + const hiddenRowSet = buildHiddenRowSet(); 92 + const hiddenColSet = buildHiddenColSet(); 93 + 94 + const { cols: visibleColList, indicators: colIndicators } = computeVisibleCols(1, colCount, hiddenColSet); 95 + const visibleColSet = new Set(visibleColList); 96 + 97 + const frozenLeftOffsets = [0]; 98 + let cumLeft = ROW_HEADER_WIDTH; 99 + for (let c = 1; c <= freezeC; c++) { 100 + frozenLeftOffsets.push(cumLeft); 101 + if (visibleColSet.has(c)) cumLeft += getColWidth(c); 102 + } 103 + 104 + const headerRowHeight = 26; 105 + const bodyRowHeight = 26; 106 + 107 + const rh = (row: number): number => getRowHeight(row); 108 + 109 + const frozenRowTopOffsets: number[] = [0]; 110 + let cumTop = headerRowHeight; 111 + for (let r = 1; r <= freezeR; r++) { 112 + frozenRowTopOffsets.push(cumTop); 113 + cumTop += rh(r); 114 + } 115 + 116 + // --- Build colgroup + thead --- 117 + let headHtml = '<colgroup>'; 118 + headHtml += '<col style="width:' + ROW_HEADER_WIDTH + 'px;min-width:' + ROW_HEADER_WIDTH + 'px">'; 119 + for (let c = 1; c <= colCount; c++) { 120 + if (!visibleColSet.has(c)) continue; 121 + const w = getColWidth(c); 122 + headHtml += '<col style="width:' + w + 'px;min-width:' + MIN_COL_WIDTH + 'px">'; 123 + } 124 + headHtml += '</colgroup>'; 125 + 126 + headHtml += '<thead><tr>'; 127 + const cornerCls = ['corner']; 128 + if (freezeR > 0) cornerCls.push('freeze-border-bottom'); 129 + if (freezeC > 0) cornerCls.push('freeze-border-right'); 130 + headHtml += '<th class="' + cornerCls.join(' ') + '"></th>'; 131 + 132 + const colIndicatorAfter = new Set(); 133 + for (const ind of colIndicators) colIndicatorAfter.add(ind.afterCol); 134 + 135 + for (let c = 1; c <= colCount; c++) { 136 + if (!visibleColSet.has(c)) continue; 137 + 138 + const cls: string[] = []; 139 + if (c <= freezeC) { 140 + cls.push('frozen-col'); 141 + if (c === freezeC) cls.push('freeze-border-right'); 142 + } 143 + if (freezeR > 0) cls.push('freeze-border-bottom'); 144 + if (isAtHiddenColBoundary(c, colCount, hiddenColSet)) cls.push('hidden-col-boundary'); 145 + 146 + const leftStyle = c <= freezeC ? 'left:' + frozenLeftOffsets[c] + 'px;' : ''; 147 + const classAttr = cls.length ? ' class="' + cls.join(' ') + '"' : ''; 148 + const colUnhideIndicator = cls.includes('hidden-col-boundary') ? '<div class="hidden-col-indicator" data-unhide-col="' + c + '"><div class="hidden-col-indicator-bar"></div></div>' : ''; 149 + headHtml += '<th data-col="' + c + '"' + classAttr + ' role="columnheader" aria-colindex="' + c + '" style="' + leftStyle + '">' + colToLetter(c) + '<div class="col-resize-handle" data-resize-col="' + c + '"></div>' + colUnhideIndicator + '</th>'; 150 + } 151 + headHtml += '</tr></thead>'; 152 + 153 + // --- Build tbody --- 154 + let tbodyHtml = ''; 155 + 156 + const cfRules = getCfRulesArray(); 157 + const stripedEnabled = getStripedRows(); 158 + 159 + const colorScaleRule = cfRules.find(r => r.type === 'colorScale'); 160 + let colorScaleStyles: Map<string, { bgColor?: string; textColor?: string }> | null = null; 161 + if (colorScaleRule) { 162 + const allCellValues = new Map<string, unknown>(); 163 + for (let r = 1; r <= rowCount; r++) { 164 + for (let c = 1; c <= colCount; c++) { 165 + const id = cellId(c, r); 166 + const cd = getCellData(id); 167 + if (cd) { 168 + const dv = computeDisplayValue(id, cd); 169 + allCellValues.set(id, dv); 170 + } 171 + } 172 + } 173 + colorScaleStyles = computeColorScale(allCellValues, colorScaleRule); 174 + } 175 + 176 + const allRowsToRender: number[] = []; 177 + for (let r = 1; r <= rowCount; r++) { 178 + if (!hiddenRowSet.has(r)) allRowsToRender.push(r); 179 + } 180 + 181 + const { indicators: rowIndicators } = computeVisibleRows(1, rowCount, hiddenRowSet); 182 + const rowIndicatorMap = new Map(); 183 + for (const ind of rowIndicators) rowIndicatorMap.set(ind.afterRow, ind.hiddenCount); 184 + 185 + const findActive = sheetsFindState.matches.length > 0; 186 + 187 + const selectedCell = getSelectedCell(); 188 + const editingCell = deps.getEditingCell(); 189 + const selectionRange = getSelectionRange(); 190 + 191 + if (rowIndicatorMap.has(0)) { 192 + const count = rowIndicatorMap.get(0); 193 + tbodyHtml += '<tr class="hidden-row-indicator hidden-row-indicator-top" title="' + count + ' hidden row' + (count > 1 ? 's' : '') + '"><td colspan="' + (colCount + 1) + '"><div class="hidden-row-indicator-line"></div></td></tr>'; 194 + } 195 + 196 + for (const r of allRowsToRender) { 197 + const rowH = rh(r); 198 + tbodyHtml += '<tr style="height:' + rowH + 'px">'; 199 + const rhCls = ['row-header']; 200 + if (r <= freezeR) { 201 + rhCls.push('frozen-row'); 202 + if (r === freezeR) rhCls.push('freeze-border-bottom'); 203 + } 204 + if (isAtHiddenRowBoundary(r, rowCount, hiddenRowSet)) rhCls.push('hidden-row-boundary'); 205 + 206 + const rhTop = r <= freezeR ? 'top:' + frozenRowTopOffsets[r] + 'px;' : ''; 207 + const rhHeight = rowH !== bodyRowHeight ? 'height:' + rowH + 'px;' : ''; 208 + tbodyHtml += '<th class="' + rhCls.join(' ') + '" data-row="' + r + '" role="rowheader" aria-rowindex="' + r + '" style="' + rhTop + rhHeight + '">' + r + '<div class="row-resize-handle" data-resize-row="' + r + '"></div></th>'; 209 + 210 + for (let c = 1; c <= colCount; c++) { 211 + if (!visibleColSet.has(c)) continue; 212 + 213 + const id = cellId(c, r); 214 + const mergeInfo = mergeMap.get(id); 215 + if (mergeInfo && mergeInfo.hidden) continue; 216 + 217 + const cellData = getCellData(id); 218 + const displayValue = computeDisplayValue(id, cellData); 219 + 220 + // getCellClasses inline 221 + const tdCls: string[] = []; 222 + if (selectedCell.col === c && selectedCell.row === r) tdCls.push('selected'); 223 + if (editingCell && editingCell.col === c && editingCell.row === r) tdCls.push('editing'); 224 + if (isInRange(c, r, selectionRange)) tdCls.push('in-range'); 225 + 226 + if (r <= freezeR && c <= freezeC) { 227 + tdCls.push('frozen-corner'); 228 + if (r === freezeR) tdCls.push('freeze-border-bottom'); 229 + if (c === freezeC) tdCls.push('freeze-border-right'); 230 + } else if (r <= freezeR) { 231 + tdCls.push('frozen-row'); 232 + if (r === freezeR) tdCls.push('freeze-border-bottom'); 233 + } else if (c <= freezeC) { 234 + tdCls.push('frozen-col'); 235 + if (c === freezeC) tdCls.push('freeze-border-right'); 236 + } 237 + if (mergeInfo && !mergeInfo.hidden) tdCls.push('merged-cell'); 238 + 239 + const stripeClass = getStripedRowClass(r, stripedEnabled); 240 + if (stripeClass) tdCls.push(stripeClass); 241 + 242 + const validation = getValidationForCell(id); 243 + let validationMsg = ''; 244 + if (validation && cellData) { 245 + const valResult = validateCell(displayValue, validation); 246 + if (!valResult.valid) { 247 + tdCls.push('validation-invalid'); 248 + validationMsg = valResult.message || 'Invalid value'; 249 + } 250 + } 251 + 252 + if (isSpillSource(id)) tdCls.push('spill-source'); 253 + if (isSpillTarget(id)) tdCls.push('spill-target'); 254 + 255 + if (findActive) { 256 + if (isCurrentMatch(sheetsFindState, id)) tdCls.push('find-match-active'); 257 + else if (isCellMatch(sheetsFindState, id)) tdCls.push('find-match'); 258 + } 259 + 260 + let tdStyle = ''; 261 + if (r <= freezeR) tdStyle += 'top:' + frozenRowTopOffsets[r] + 'px;'; 262 + if (c <= freezeC) tdStyle += 'left:' + frozenLeftOffsets[c] + 'px;'; 263 + 264 + const cfResult = evaluateRules(displayValue, cfRules) || (colorScaleStyles && colorScaleStyles.get(id)) || null; 265 + const cfStyleStr = buildCfStyle(cfResult); 266 + 267 + const isFrozenCell = c <= freezeC || r <= freezeR; 268 + const cellBg = getCellBgColor(cellData, cfStyleStr); 269 + if (cellBg) { 270 + tdStyle += 'background:' + cellBg + (isFrozenCell ? ' !important' : '') + ';'; 271 + } else if (isFrozenCell) { 272 + tdStyle += 'background:var(--color-bg) !important;'; 273 + } 274 + 275 + const styleAttr = tdStyle ? ' style="' + tdStyle + '"' : ''; 276 + let spanAttrs = ''; 277 + if (mergeInfo && !mergeInfo.hidden) { 278 + if (mergeInfo.colspan > 1) spanAttrs += ' colspan="' + mergeInfo.colspan + '"'; 279 + if (mergeInfo.rowspan > 1) spanAttrs += ' rowspan="' + mergeInfo.rowspan + '"'; 280 + } 281 + const valTitleAttr = validationMsg ? ' title="' + escapeHtml(validationMsg) + '"' : ''; 282 + tbodyHtml += '<td data-col="' + c + '" data-row="' + r + '" data-id="' + id + '" role="gridcell" aria-colindex="' + c + '" aria-rowindex="' + r + '" class="' + tdCls.join(' ') + '"' + styleAttr + spanAttrs + valTitleAttr + '>'; 283 + 284 + if (isSparklineResult(displayValue)) { 285 + tbodyHtml += '<div class="cell-display" style="padding:0;overflow:hidden;' + getCellStyle(cellData, '') + '"><canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas></div>'; 286 + } else { 287 + const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; 288 + 289 + const errInfo = getErrorInfo(String(displayValue)); 290 + const errClass = errInfo ? ' cell-error' : ''; 291 + const errData = errInfo ? ' data-error-title="' + escapeHtml(errInfo.title) + '" data-error-desc="' + escapeHtml(errInfo.description) + '" data-error-hint="' + escapeHtml(errInfo.hint) + '"' : ''; 292 + 293 + if (cellData?.img) { 294 + tbodyHtml += '<div class="cell-display cell-image-container" data-img-cell="' + id + '" data-blob-id="' + escapeHtml(String(cellData.img)) + '" style="' + getCellStyle(cellData, cfStyleStr) + '"></div>'; 295 + tbodyHtml += '</td>'; 296 + continue; 297 + } 298 + 299 + const richHtml = renderInteractiveCell(cellData?.s?.cellType, cellData?.v ?? displayValue); 300 + const cellContent = richHtml ?? escapeHtml(displayValue); 301 + 302 + tbodyHtml += '<div class="cell-display' + wrapClass + errClass + '"' + errData + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + cellContent + '</div>'; 303 + } 304 + 305 + if (validation && validation.type === 'list') { 306 + tbodyHtml += '<div class="cell-dropdown-arrow" data-dropdown-cell="' + id + '">&#9662;</div>'; 307 + } 308 + 309 + tbodyHtml += '</td>'; 310 + } 311 + tbodyHtml += '</tr>'; 312 + 313 + if (rowIndicatorMap.has(r)) { 314 + const count = rowIndicatorMap.get(r); 315 + tbodyHtml += '<tr class="hidden-row-indicator" title="' + count + ' hidden row' + (count > 1 ? 's' : '') + '"><td colspan="' + (colCount + 1) + '"><div class="hidden-row-indicator-line"></div></td></tr>'; 316 + } 317 + } 318 + 319 + // --- Apply to DOM --- 320 + grid.innerHTML = headHtml + '<tbody>' + tbodyHtml + '</tbody>'; 321 + 322 + _isRendering = false; 323 + 324 + if (!_gridEventsAttached) { 325 + attachGridEvents(); 326 + _gridEventsAttached = true; 327 + } 328 + updateSelectionVisuals(); 329 + updateFreezeToolbarState(); 330 + renderNoteIndicators(); 331 + renderSparklines(deps); 332 + renderImageCells(); 333 + } 334 + 335 + export function renderSparklines(deps: GridRenderingDeps): void { 336 + const { grid, getCellData, computeDisplayValue } = deps; 337 + grid.querySelectorAll('canvas.sparkline-canvas').forEach((canvas: Element) => { 338 + const canvasEl = canvas as HTMLCanvasElement; 339 + const id = canvasEl.dataset.sparklineId; 340 + if (!id) return; 341 + const cellData = getCellData(id); 342 + const val = computeDisplayValue(id, cellData); 343 + if (!isSparklineResult(val)) return; 344 + 345 + const parent = canvasEl.parentElement; 346 + if (parent) { 347 + const rect = parent.getBoundingClientRect(); 348 + const dpr = window.devicePixelRatio || 1; 349 + canvasEl.width = rect.width * dpr; 350 + canvasEl.height = rect.height * dpr; 351 + canvasEl.style.width = rect.width + 'px'; 352 + canvasEl.style.height = rect.height + 'px'; 353 + const ctx = canvasEl.getContext('2d'); 354 + if (ctx) ctx.scale(dpr, dpr); 355 + } 356 + drawSparkline(canvasEl, val); 357 + }); 358 + }
+40 -1063
src/sheets/main.ts
··· 78 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 79 import { deleteSelectedCells as _deleteSelectedCells, copySelection as _copySelection, pasteRowsAtSelection as _pasteRowsAtSelection, pasteAtSelection as _pasteAtSelection, showPasteSpecialDialog as _showPasteSpecialDialogCO, wirePasteListener as _wirePasteListener } from './clipboard-operations.js'; 80 80 import { wireSaveStatus } from './save-status-ui.js'; 81 + import { renderGrid as _renderGridGR, scheduleRenderGrid as _scheduleRenderGrid, renderSparklines as _renderSparklines } from './grid-rendering.js'; 82 + import { onGridTouchStart as _onGridTouchStart, wireTouchDoubleTap as _wireTouchDoubleTap } from './touch-events.js'; 83 + import { startEditing as _startEditingCE, commitEdit as _commitEditCE, updateFormulaBar as _updateFormulaBarCE, attachCellEditorFormulaUX as _attachCellEditorFormulaUXCE } from './cell-editing.js'; 84 + import { onGridMouseDown as _onGridMouseDown, onGridDblClick as _onGridDblClick, autoFitColumn as _autoFitColumn, autoFitRow as _autoFitRow, startRowResize as _startRowResize } from './mouse-events.js'; 81 85 import { 82 86 applyStyleToSelection as _applyStyleToSelection, clearFormattingSelection as _clearFormattingSelection, 83 87 closeAllDropdowns as _closeAllDropdowns, sortColumn as _sortColumn, ··· 395 399 const sheetContainer = document.getElementById('sheet-container'); 396 400 const sheetTabsContainer = document.getElementById('sheet-tabs'); 397 401 398 - // --- Grid rendering (debounced to avoid multiple re-renders per edit) --- 399 - let _renderGridTimer = null; 400 - function scheduleRenderGrid() { 401 - if (_renderGridTimer) return; 402 - _renderGridTimer = requestAnimationFrame(() => { 403 - _renderGridTimer = null; 404 - // Don't rebuild the grid while user is editing a cell — it destroys the input 405 - if (editingCell) { refreshVisibleCells(); return; } 406 - renderGrid(); 407 - }); 408 - } 409 - let _isRendering = false; 410 - let _gridEventsAttached = false; 411 - 412 - function renderGrid() { 413 - _isRendering = true; 414 - const sheet = getActiveSheet(); 415 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 416 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 417 - const freezeR = getFreezeRows(); 418 - const freezeC = getFreezeCols(); 419 - const mergeMap = _buildMergeMap(); 420 - 421 - // Build hidden sets for row/col visibility computation 422 - const hiddenRowSet = buildHiddenRowSet(); 423 - const hiddenColSet = buildHiddenColSet(); 424 - 425 - // Compute visible columns (skip hidden) 426 - const { cols: visibleColList, indicators: colIndicators } = computeVisibleCols(1, colCount, hiddenColSet); 427 - const visibleColSet = new Set(visibleColList); 428 - 429 - // Compute cumulative left offsets for frozen columns (only visible frozen cols) 430 - const frozenLeftOffsets = [0]; 431 - let cumLeft = ROW_HEADER_WIDTH; 432 - for (let c = 1; c <= freezeC; c++) { 433 - frozenLeftOffsets.push(cumLeft); 434 - if (visibleColSet.has(c)) cumLeft += getColWidth(c); 435 - } 436 - 437 - const headerRowHeight = 26; 438 - const bodyRowHeight = 26; 439 - 440 - const rh = (row: number): number => getRowHeight(row); 441 - 442 - // Pre-compute cumulative top offsets for frozen rows 443 - const frozenRowTopOffsets: number[] = [0]; // index 0 unused 444 - let cumTop = headerRowHeight; 445 - for (let r = 1; r <= freezeR; r++) { 446 - frozenRowTopOffsets.push(cumTop); 447 - cumTop += rh(r); 448 - } 449 - 450 - // --- Build colgroup + thead --- 451 - let headHtml = '<colgroup>'; 452 - headHtml += '<col style="width:' + ROW_HEADER_WIDTH + 'px;min-width:' + ROW_HEADER_WIDTH + 'px">'; 453 - for (let c = 1; c <= colCount; c++) { 454 - if (!visibleColSet.has(c)) continue; 455 - const w = getColWidth(c); 456 - headHtml += '<col style="width:' + w + 'px;min-width:' + MIN_COL_WIDTH + 'px">'; 457 - } 458 - headHtml += '</colgroup>'; 459 - 460 - headHtml += '<thead><tr>'; 461 - const cornerCls = ['corner']; 462 - if (freezeR > 0) cornerCls.push('freeze-border-bottom'); 463 - if (freezeC > 0) cornerCls.push('freeze-border-right'); 464 - headHtml += '<th class="' + cornerCls.join(' ') + '"></th>'; 465 - 466 - const colIndicatorAfter = new Set(); 467 - for (const ind of colIndicators) colIndicatorAfter.add(ind.afterCol); 468 - 469 - for (let c = 1; c <= colCount; c++) { 470 - if (!visibleColSet.has(c)) continue; 471 - 472 - const cls = []; 473 - if (c <= freezeC) { 474 - cls.push('frozen-col'); 475 - if (c === freezeC) cls.push('freeze-border-right'); 476 - } 477 - if (freezeR > 0) cls.push('freeze-border-bottom'); 478 - if (isAtHiddenColBoundary(c, colCount, hiddenColSet)) cls.push('hidden-col-boundary'); 479 - 480 - const leftStyle = c <= freezeC ? 'left:' + frozenLeftOffsets[c] + 'px;' : ''; 481 - const classAttr = cls.length ? ' class="' + cls.join(' ') + '"' : ''; 482 - const colUnhideIndicator = cls.includes('hidden-col-boundary') ? '<div class="hidden-col-indicator" data-unhide-col="' + c + '"><div class="hidden-col-indicator-bar"></div></div>' : ''; 483 - headHtml += '<th data-col="' + c + '"' + classAttr + ' role="columnheader" aria-colindex="' + c + '" style="' + leftStyle + '">' + colToLetter(c) + '<div class="col-resize-handle" data-resize-col="' + c + '"></div>' + colUnhideIndicator + '</th>'; 484 - } 485 - headHtml += '</tr></thead>'; 486 - 487 - // --- Build tbody (always rebuilt) --- 488 - let tbodyHtml = ''; 489 - 490 - // --- Pre-compute conditional formatting and striped state --- 491 - const cfRules = getCfRulesArray(); 492 - const stripedEnabled = getStripedRows(); 493 - 494 - // Pre-compute color scale styles (needs all cell values for min/max) 495 - const colorScaleRule = cfRules.find(r => r.type === 'colorScale'); 496 - let colorScaleStyles: Map<string, { bgColor?: string; textColor?: string }> | null = null; 497 - if (colorScaleRule) { 498 - const allCellValues = new Map<string, unknown>(); 499 - for (let r = 1; r <= rowCount; r++) { 500 - for (let c = 1; c <= colCount; c++) { 501 - const id = cellId(c, r); 502 - const cd = getCellData(id); 503 - if (cd) { 504 - const dv = computeDisplayValue(id, cd); 505 - allCellValues.set(id, dv); 506 - } 507 - } 508 - } 509 - colorScaleStyles = computeColorScale(allCellValues, colorScaleRule); 510 - } 511 - 512 - // --- All rows rendered; browser handles scroll natively (no JS virtual scroll) --- 513 - // Compute all visible rows (frozen + body), respecting hidden rows 514 - const allRowsToRender: number[] = []; 515 - for (let r = 1; r <= rowCount; r++) { 516 - if (!hiddenRowSet.has(r)) allRowsToRender.push(r); 517 - } 518 - 519 - // Build hidden-row indicator map 520 - const { indicators: rowIndicators } = computeVisibleRows(1, rowCount, hiddenRowSet); 521 - const rowIndicatorMap = new Map(); 522 - for (const ind of rowIndicators) rowIndicatorMap.set(ind.afterRow, ind.hiddenCount); 523 - 524 - // Find & replace match state for highlighting 525 - const findActive = sheetsFindState.matches.length > 0; 526 - 527 - // If rows at the very start are hidden, insert indicator before the first visible row 528 - if (rowIndicatorMap.has(0)) { 529 - const count = rowIndicatorMap.get(0); 530 - tbodyHtml += '<tr class="hidden-row-indicator hidden-row-indicator-top" title="' + count + ' hidden row' + (count > 1 ? 's' : '') + '"><td colspan="' + (colCount + 1) + '"><div class="hidden-row-indicator-line"></div></td></tr>'; 531 - } 532 - 533 - for (const r of allRowsToRender) { 534 - const rowH = rh(r); 535 - tbodyHtml += '<tr style="height:' + rowH + 'px">'; 536 - const rhCls = ['row-header']; 537 - if (r <= freezeR) { 538 - rhCls.push('frozen-row'); 539 - if (r === freezeR) rhCls.push('freeze-border-bottom'); 540 - } 541 - // Add hidden-row boundary indicator 542 - if (isAtHiddenRowBoundary(r, rowCount, hiddenRowSet)) rhCls.push('hidden-row-boundary'); 543 - 544 - const rhTop = r <= freezeR ? 'top:' + frozenRowTopOffsets[r] + 'px;' : ''; 545 - const rhHeight = rowH !== bodyRowHeight ? 'height:' + rowH + 'px;' : ''; 546 - tbodyHtml += '<th class="' + rhCls.join(' ') + '" data-row="' + r + '" role="rowheader" aria-rowindex="' + r + '" style="' + rhTop + rhHeight + '">' + r + '<div class="row-resize-handle" data-resize-row="' + r + '"></div></th>'; 547 - 548 - for (let c = 1; c <= colCount; c++) { 549 - if (!visibleColSet.has(c)) continue; // skip hidden columns 550 - 551 - const id = cellId(c, r); 552 - const mergeInfo = mergeMap.get(id); 553 - if (mergeInfo && mergeInfo.hidden) continue; 554 - 555 - const cellData = getCellData(id); 556 - const displayValue = computeDisplayValue(id, cellData); 557 - const baseCls = getCellClasses(c, r, cellData); 558 - const tdCls = baseCls ? [baseCls] : []; 559 - 560 - if (r <= freezeR && c <= freezeC) { 561 - tdCls.push('frozen-corner'); 562 - if (r === freezeR) tdCls.push('freeze-border-bottom'); 563 - if (c === freezeC) tdCls.push('freeze-border-right'); 564 - } else if (r <= freezeR) { 565 - tdCls.push('frozen-row'); 566 - if (r === freezeR) tdCls.push('freeze-border-bottom'); 567 - } else if (c <= freezeC) { 568 - tdCls.push('frozen-col'); 569 - if (c === freezeC) tdCls.push('freeze-border-right'); 570 - } 571 - if (mergeInfo && !mergeInfo.hidden) tdCls.push('merged-cell'); 572 - 573 - // Striped rows 574 - const stripeClass = getStripedRowClass(r, stripedEnabled); 575 - if (stripeClass) tdCls.push(stripeClass); 576 - 577 - // Data validation — check for invalid and dropdown 578 - const validation = getValidationForCell(id); 579 - let validationMsg = ''; 580 - if (validation && cellData) { 581 - const valResult = validateCell(displayValue, validation); 582 - if (!valResult.valid) { 583 - tdCls.push('validation-invalid'); 584 - validationMsg = valResult.message || 'Invalid value'; 585 - } 586 - } 587 - 588 - // Spill range styling 589 - if (_isSpillSource(id)) tdCls.push('spill-source'); 590 - if (_isSpillTarget(id)) tdCls.push('spill-target'); 591 - 592 - // Find & replace highlighting 593 - if (findActive) { 594 - if (isCurrentMatch(sheetsFindState, id)) tdCls.push('find-match-active'); 595 - else if (isCellMatch(sheetsFindState, id)) tdCls.push('find-match'); 596 - } 597 - 598 - let tdStyle = ''; 599 - if (r <= freezeR) tdStyle += 'top:' + frozenRowTopOffsets[r] + 'px;'; 600 - if (c <= freezeC) tdStyle += 'left:' + frozenLeftOffsets[c] + 'px;'; 601 - 602 - // Conditional formatting (computed early so bg can go on td) 603 - const cfResult = evaluateRules(displayValue, cfRules) || (colorScaleStyles && colorScaleStyles.get(id)) || null; 604 - const cfStyleStr = buildCfStyle(cfResult); 605 - 606 - // Background on td so inset box-shadow grid lines paint on top. 607 - // Frozen cells use !important so CSS rules (.in-range etc.) can't replace with semi-transparent bg. 608 - const isFrozenCell = c <= freezeC || r <= freezeR; 609 - const cellBg = getCellBgColor(cellData, cfStyleStr); 610 - if (cellBg) { 611 - tdStyle += 'background:' + cellBg + (isFrozenCell ? ' !important' : '') + ';'; 612 - } else if (isFrozenCell) { 613 - tdStyle += 'background:var(--color-bg) !important;'; 614 - } 615 - 616 - const styleAttr = tdStyle ? ' style="' + tdStyle + '"' : ''; 617 - let spanAttrs = ''; 618 - if (mergeInfo && !mergeInfo.hidden) { 619 - if (mergeInfo.colspan > 1) spanAttrs += ' colspan="' + mergeInfo.colspan + '"'; 620 - if (mergeInfo.rowspan > 1) spanAttrs += ' rowspan="' + mergeInfo.rowspan + '"'; 621 - } 622 - const valTitleAttr = validationMsg ? ' title="' + escapeHtml(validationMsg) + '"' : ''; 623 - tbodyHtml += '<td data-col="' + c + '" data-row="' + r + '" data-id="' + id + '" role="gridcell" aria-colindex="' + c + '" aria-rowindex="' + r + '" class="' + tdCls.join(' ') + '"' + styleAttr + spanAttrs + valTitleAttr + '>'; 624 - 625 - // Sparkline: render canvas instead of text 626 - if (isSparklineResult(displayValue)) { 627 - tbodyHtml += '<div class="cell-display" style="padding:0;overflow:hidden;' + getCellStyle(cellData, '') + '"><canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas></div>'; 628 - } else { 629 - // Wrap text class 630 - const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; 631 - 632 - // Formula error tooltip 633 - const errInfo = getErrorInfo(String(displayValue)); 634 - const errClass = errInfo ? ' cell-error' : ''; 635 - const errData = errInfo ? ' data-error-title="' + escapeHtml(errInfo.title) + '" data-error-desc="' + escapeHtml(errInfo.description) + '" data-error-hint="' + escapeHtml(errInfo.hint) + '"' : ''; 636 - 637 - // Image cells 638 - if (cellData?.img) { 639 - tbodyHtml += '<div class="cell-display cell-image-container" data-img-cell="' + id + '" data-blob-id="' + escapeHtml(String(cellData.img)) + '" style="' + getCellStyle(cellData, cfStyleStr) + '"></div>'; 640 - tbodyHtml += '</td>'; 641 - continue; 642 - } 643 - 644 - // Rich cell types (checkbox, rating, progress bar) 645 - const richHtml = renderInteractiveCell(cellData?.s?.cellType, cellData?.v ?? displayValue); 646 - const cellContent = richHtml ?? escapeHtml(displayValue); 647 - 648 - tbodyHtml += '<div class="cell-display' + wrapClass + errClass + '"' + errData + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + cellContent + '</div>'; 649 - } 650 - 651 - // Dropdown arrow for list validation 652 - if (validation && validation.type === 'list') { 653 - tbodyHtml += '<div class="cell-dropdown-arrow" data-dropdown-cell="' + id + '">&#9662;</div>'; 654 - } 655 - 656 - tbodyHtml += '</td>'; 657 - } 658 - tbodyHtml += '</tr>'; 659 - 660 - // Insert hidden-row indicator after this row if needed 661 - if (rowIndicatorMap.has(r)) { 662 - const count = rowIndicatorMap.get(r); 663 - tbodyHtml += '<tr class="hidden-row-indicator" title="' + count + ' hidden row' + (count > 1 ? 's' : '') + '"><td colspan="' + (colCount + 1) + '"><div class="hidden-row-indicator-line"></div></td></tr>'; 664 - } 665 - 666 - } 667 - 668 - // --- Apply to DOM --- 669 - grid.innerHTML = headHtml + '<tbody>' + tbodyHtml + '</tbody>'; 670 - 671 - _isRendering = false; 672 - 673 - // Attach event listeners only once (they use event delegation on the table) 674 - if (!_gridEventsAttached) { 675 - attachGridEvents(); 676 - _gridEventsAttached = true; 677 - } 678 - updateSelectionVisuals(); 679 - updateFreezeToolbarState(); 680 - renderNoteIndicators(); 681 - renderSparklines(); 682 - renderImageCells(); 402 + // --- Grid rendering — extracted to grid-rendering.ts --- 403 + function _gridRenderingDeps() { 404 + return { 405 + grid, getActiveSheet, getCellData, computeDisplayValue, 406 + getColWidth, getRowHeight, getFreezeRows, getFreezeCols, 407 + getCfRulesArray, getStripedRows, getValidationForCell, 408 + buildMergeMap: _buildMergeMap, buildHiddenRowSet, buildHiddenColSet, 409 + isSpillSource: _isSpillSource, isSpillTarget: _isSpillTarget, 410 + sheetsFindState, getEditingCell: () => editingCell, 411 + getSelectedCell: () => selectedCell, getSelectionRange: () => selectionRange, 412 + refreshVisibleCells, updateSelectionVisuals, updateFreezeToolbarState, 413 + renderNoteIndicators, renderImageCells, attachGridEvents, 414 + DEFAULT_ROWS, DEFAULT_COLS, MIN_COL_WIDTH, ROW_HEADER_WIDTH, 415 + }; 683 416 } 417 + function scheduleRenderGrid() { _scheduleRenderGrid(_gridRenderingDeps()); } 418 + function renderGrid() { _renderGridGR(_gridRenderingDeps()); } 684 419 685 420 function getCellData(id) { 686 421 const cells = getCells(); ··· 857 592 grid.addEventListener('touchstart', onGridTouchStart, { passive: false }); 858 593 } 859 594 860 - // --- Touch event support (#148) --- 861 - let _touchTimer: ReturnType<typeof setTimeout> | null = null; 862 - let _touchMoved = false; 863 - let _touchStartCell: { col: number; row: number } | null = null; 864 - 865 - function onGridTouchStart(e: TouchEvent) { 866 - if (e.touches.length !== 1) return; 867 - const touch = e.touches[0]; 868 - const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement; 869 - if (!target) return; 870 - 871 - // Handle col/row resize handles 872 - const colHandle = target.closest('.col-resize-handle') as HTMLElement; 873 - if (colHandle) { 874 - e.preventDefault(); 875 - startColumnResizeTouch(colHandle, touch); 876 - return; 877 - } 878 - const rowHandle = target.closest('.row-resize-handle') as HTMLElement; 879 - if (rowHandle) { 880 - e.preventDefault(); 881 - startRowResizeTouch(rowHandle, touch); 882 - return; 883 - } 884 - 885 - // Cell selection 886 - const td = target.closest('td[data-id]') as HTMLElement; 887 - if (!td) return; 888 - 889 - _touchMoved = false; 890 - _touchStartCell = { col: parseInt(td.dataset.col!), row: parseInt(td.dataset.row!) }; 891 - 892 - // Long-press opens context menu (500ms) 893 - _touchTimer = setTimeout(() => { 894 - _touchTimer = null; 895 - if (!_touchMoved && _touchStartCell) { 896 - const contextEvent = new MouseEvent('contextmenu', { 897 - clientX: touch.clientX, 898 - clientY: touch.clientY, 899 - bubbles: true, 900 - }); 901 - td.dispatchEvent(contextEvent); 902 - } 903 - }, 500); 904 - 905 - if (editingCell) commitEdit(); 906 - selectedCell = { col: _touchStartCell.col, row: _touchStartCell.row }; 907 - selectionRange = { startCol: _touchStartCell.col, startRow: _touchStartCell.row, endCol: _touchStartCell.col, endRow: _touchStartCell.row }; 908 - isSelecting = true; 909 - updateSelectionVisuals(); 910 - updateFormulaBar(); 911 - 912 - const onTouchMove = (ev: TouchEvent) => { 913 - _touchMoved = true; 914 - if (_touchTimer) { clearTimeout(_touchTimer); _touchTimer = null; } 915 - if (ev.touches.length !== 1) return; 916 - ev.preventDefault(); 917 - const t = ev.touches[0]; 918 - const el = document.elementFromPoint(t.clientX, t.clientY) as HTMLElement; 919 - if (!el) return; 920 - const moveTd = el.closest('td[data-id]') as HTMLElement; 921 - if (moveTd) { 922 - selectionRange.endCol = parseInt(moveTd.dataset.col!); 923 - selectionRange.endRow = parseInt(moveTd.dataset.row!); 924 - updateSelectionVisuals(); 925 - } 926 - }; 927 - 928 - const onTouchEnd = () => { 929 - if (_touchTimer) { clearTimeout(_touchTimer); _touchTimer = null; } 930 - isSelecting = false; 931 - updateMergeButtonState(); 932 - document.removeEventListener('touchmove', onTouchMove); 933 - document.removeEventListener('touchend', onTouchEnd); 934 - document.removeEventListener('touchcancel', onTouchEnd); 935 - }; 936 - 937 - document.addEventListener('touchmove', onTouchMove, { passive: false }); 938 - document.addEventListener('touchend', onTouchEnd); 939 - document.addEventListener('touchcancel', onTouchEnd); 940 - } 941 - 942 - // Touch-based column resize 943 - function startColumnResizeTouch(handle: HTMLElement, touch: Touch) { 944 - const col = parseInt(handle.dataset.resizeCol!); 945 - const startX = touch.clientX; 946 - const startWidth = getColWidth(col); 947 - handle.classList.add('active'); 948 - 949 - const onTouchMove = (ev: TouchEvent) => { 950 - if (ev.touches.length !== 1) return; 951 - ev.preventDefault(); 952 - }; 953 - 954 - const onTouchEnd = (ev: TouchEvent) => { 955 - const endTouch = ev.changedTouches[0]; 956 - const delta = endTouch.clientX - startX; 957 - const newWidth = Math.max(MIN_COL_WIDTH, startWidth + delta); 958 - setColWidth(col, newWidth); 959 - handle.classList.remove('active'); 960 - renderGrid(); 961 - document.removeEventListener('touchmove', onTouchMove); 962 - document.removeEventListener('touchend', onTouchEnd); 963 - document.removeEventListener('touchcancel', onTouchEnd); 964 - }; 965 - 966 - document.addEventListener('touchmove', onTouchMove, { passive: false }); 967 - document.addEventListener('touchend', onTouchEnd); 968 - document.addEventListener('touchcancel', onTouchEnd); 969 - } 595 + // Touch events — extracted to touch-events.ts 596 + function onGridTouchStart(e) { _onGridTouchStart(_touchEventsDeps(), e); } 597 + _wireTouchDoubleTap(_touchEventsDeps()); 970 598 971 - // Touch-based row resize 972 - function startRowResizeTouch(handle: HTMLElement, touch: Touch) { 973 - const row = parseInt(handle.dataset.resizeRow!); 974 - const startY = touch.clientY; 975 - const startHeight = getRowHeight(row); 976 - handle.classList.add('active'); 599 + // onGridMouseDown, onGridDblClick, column/row resize, auto-fit, cell mouse down, 600 + // drag-to-fill, onCellDblClick — extracted to mouse-events.ts 601 + function onGridMouseDown(e) { _onGridMouseDown(_mouseEventsDeps(), e); } 602 + function onGridDblClick(e) { _onGridDblClick(_mouseEventsDeps(), e); } 603 + function autoFitColumn(col) { _autoFitColumn(_mouseEventsDeps(), col); } 604 + function autoFitRow(row) { _autoFitRow(_mouseEventsDeps(), row); } 977 605 978 - const onTouchMove = (ev: TouchEvent) => { 979 - if (ev.touches.length !== 1) return; 980 - ev.preventDefault(); 981 - }; 982 - 983 - const onTouchEnd = (ev: TouchEvent) => { 984 - const endTouch = ev.changedTouches[0]; 985 - const delta = endTouch.clientY - startY; 986 - const newHeight = Math.max(14, startHeight + delta); 987 - setRowHeight(row, newHeight); 988 - handle.classList.remove('active'); 989 - renderGrid(); 990 - document.removeEventListener('touchmove', onTouchMove); 991 - document.removeEventListener('touchend', onTouchEnd); 992 - document.removeEventListener('touchcancel', onTouchEnd); 993 - }; 994 - 995 - document.addEventListener('touchmove', onTouchMove, { passive: false }); 996 - document.addEventListener('touchend', onTouchEnd); 997 - document.addEventListener('touchcancel', onTouchEnd); 998 - } 999 - 1000 - // Double-tap to edit cell 1001 - let _lastTapTime = 0; 1002 - let _lastTapCell = ''; 1003 - grid.addEventListener('touchend', (e: TouchEvent) => { 1004 - if (_touchMoved) return; 1005 - const touch = e.changedTouches[0]; 1006 - const el = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement; 1007 - if (!el) return; 1008 - const td = el.closest('td[data-id]') as HTMLElement; 1009 - if (!td) return; 1010 - const now = Date.now(); 1011 - const cellKey = td.dataset.id!; 1012 - if (now - _lastTapTime < 400 && cellKey === _lastTapCell) { 1013 - startEditing(parseInt(td.dataset.col!), parseInt(td.dataset.row!)); 1014 - _lastTapTime = 0; 1015 - _lastTapCell = ''; 1016 - } else { 1017 - _lastTapTime = now; 1018 - _lastTapCell = cellKey; 1019 - } 1020 - }); 1021 - 1022 - function onGridMouseDown(e) { 1023 - // Click hidden-row indicator to unhide 1024 - const hiddenIndicator = e.target.closest('.hidden-row-indicator-line'); 1025 - if (hiddenIndicator) { 1026 - e.preventDefault(); 1027 - const indicatorRow = hiddenIndicator.closest('.hidden-row-indicator'); 1028 - // Find adjacent visible row above the indicator 1029 - const prevRow = indicatorRow?.previousElementSibling; 1030 - if (prevRow) { 1031 - const lastTh = prevRow.querySelector('th[data-row]'); 1032 - if (lastTh) { 1033 - const row = parseInt(lastTh.dataset.row); 1034 - unhideAdjacentRows(row); 1035 - showToast('Rows unhidden'); 1036 - } 1037 - } else { 1038 - // Indicator at the very top — hidden rows start from row 1. 1039 - // Find the first visible row after the indicator and unhide rows above it. 1040 - const nextRow = indicatorRow?.nextElementSibling; 1041 - if (nextRow) { 1042 - const firstTh = nextRow.querySelector('th[data-row]'); 1043 - if (firstTh) { 1044 - unhideAdjacentRows(parseInt(firstTh.dataset.row)); 1045 - showToast('Rows unhidden'); 1046 - } 1047 - } 1048 - } 1049 - return; 1050 - } 1051 - // Click hidden-col indicator to unhide 1052 - const colIndicator = e.target.closest('.hidden-col-indicator'); 1053 - if (colIndicator) { 1054 - e.preventDefault(); 1055 - e.stopPropagation(); 1056 - const col = parseInt(colIndicator.dataset.unhideCol); 1057 - if (!isNaN(col)) { 1058 - unhideAdjacentCols(col); 1059 - showToast('Columns unhidden'); 1060 - } 1061 - return; 1062 - } 1063 - // Fill handle drag 1064 - const fillHandle = e.target.closest('.fill-handle'); 1065 - if (fillHandle) { 1066 - e.preventDefault(); 1067 - e.stopPropagation(); 1068 - startFillDrag(e); 1069 - return; 1070 - } 1071 - const handle = e.target.closest('.col-resize-handle'); 1072 - if (handle) { 1073 - e.preventDefault(); 1074 - e.stopPropagation(); 1075 - startColumnResize(handle, e); 1076 - return; 1077 - } 1078 - const rowHandle = e.target.closest('.row-resize-handle'); 1079 - if (rowHandle) { 1080 - e.preventDefault(); 1081 - e.stopPropagation(); 1082 - startRowResize(rowHandle, e); 1083 - return; 1084 - } 1085 - // Header click for entire row/column selection (#18) 1086 - const colHeader = e.target.closest('thead th[data-col]'); 1087 - const rowHeader = e.target.closest('th.row-header[data-row]'); 1088 - if (colHeader) { 1089 - const col = parseInt(colHeader.dataset.col); 1090 - const sheet = getActiveSheet(); 1091 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 1092 - if (editingCell) commitEdit(); 1093 - selectedCell = { col, row: 1 }; 1094 - selectionRange = { startCol: col, startRow: 1, endCol: col, endRow: rowCount }; 1095 - updateSelectionVisuals(); 1096 - updateFormulaBar(); 1097 - updateMergeButtonState(); 1098 - return; 1099 - } 1100 - if (rowHeader) { 1101 - const row = parseInt(rowHeader.dataset.row); 1102 - const sheet = getActiveSheet(); 1103 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 1104 - if (editingCell) commitEdit(); 1105 - selectedCell = { col: 1, row }; 1106 - selectionRange = { startCol: 1, startRow: row, endCol: colCount, endRow: row }; 1107 - updateSelectionVisuals(); 1108 - updateFormulaBar(); 1109 - updateMergeButtonState(); 1110 - return; 1111 - } 1112 - onCellMouseDown(e); 1113 - } 1114 - 1115 - function onGridDblClick(e) { 1116 - const colHandle = e.target.closest('.col-resize-handle'); 1117 - if (colHandle) { 1118 - e.preventDefault(); 1119 - e.stopPropagation(); 1120 - autoFitColumn(parseInt(colHandle.dataset.resizeCol)); 1121 - return; 1122 - } 1123 - const rowHandle = e.target.closest('.row-resize-handle'); 1124 - if (rowHandle) { 1125 - e.preventDefault(); 1126 - e.stopPropagation(); 1127 - autoFitRow(parseInt(rowHandle.dataset.resizeRow)); 1128 - return; 1129 - } 1130 - onCellDblClick(e); 1131 - } 1132 - 1133 - // --- Column resize (Issue #6) --- 1134 - function startColumnResize(handle, e) { 1135 - const col = parseInt(handle.dataset.resizeCol); 1136 - const startX = e.clientX; 1137 - const startWidth = getColWidth(col); 1138 - handle.classList.add('active'); 1139 - 1140 - const guide = document.createElement('div'); 1141 - guide.className = 'col-resize-guide'; 1142 - sheetContainer.appendChild(guide); 1143 - 1144 - const updateGuide = (clientX) => { 1145 - const containerRect = sheetContainer.getBoundingClientRect(); 1146 - guide.style.left = (clientX - containerRect.left + sheetContainer.scrollLeft) + 'px'; 1147 - }; 1148 - updateGuide(e.clientX); 1149 - 1150 - const onMouseMove = (ev) => { 1151 - ev.preventDefault(); 1152 - updateGuide(ev.clientX); 1153 - }; 1154 - 1155 - const onMouseUp = (ev) => { 1156 - const delta = ev.clientX - startX; 1157 - const newWidth = Math.max(MIN_COL_WIDTH, startWidth + delta); 1158 - setColWidth(col, newWidth); 1159 - handle.classList.remove('active'); 1160 - guide.remove(); 1161 - document.removeEventListener('mousemove', onMouseMove); 1162 - document.removeEventListener('mouseup', onMouseUp); 1163 - document.body.style.cursor = ''; 1164 - renderGrid(); 1165 - }; 1166 - 1167 - document.addEventListener('mousemove', onMouseMove); 1168 - document.addEventListener('mouseup', onMouseUp); 1169 - document.body.style.cursor = 'col-resize'; 1170 - } 1171 - 1172 - // --- Column auto-fit (Issue #19) --- 1173 - function autoFitColumn(col) { 1174 - const sheet = getActiveSheet(); 1175 - const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 1176 - const PADDING = 16; 1177 - 1178 - measureCtx.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 1179 - 1180 - let maxWidth = MIN_COL_WIDTH; 1181 - 1182 - const headerText = colToLetter(col); 1183 - const headerWidth = measureCtx.measureText(headerText).width + PADDING + 16; 1184 - maxWidth = Math.max(maxWidth, headerWidth); 1185 - 1186 - for (let r = 1; r <= rowCount; r++) { 1187 - const id = cellId(col, r); 1188 - const cellData = getCellData(id); 1189 - const displayValue = computeDisplayValue(id, cellData); 1190 - if (displayValue) { 1191 - if (cellData?.s?.bold) { 1192 - measureCtx.font = '600 0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 1193 - } 1194 - const textWidth = measureCtx.measureText(String(displayValue)).width + PADDING; 1195 - maxWidth = Math.max(maxWidth, textWidth); 1196 - if (cellData?.s?.bold) { 1197 - measureCtx.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 1198 - } 1199 - } 1200 - } 1201 - 1202 - const MAX_AUTO_WIDTH = 500; 1203 - setColWidth(col, Math.min(MAX_AUTO_WIDTH, Math.ceil(maxWidth))); 1204 - renderGrid(); 1205 - } 1206 - 1207 - // --- Row auto-fit: measure content height based on text wrapping --- 1208 - function autoFitRow(row) { 1209 - const sheet = getActiveSheet(); 1210 - const colCount = sheet.get('colCount') || DEFAULT_COLS; 1211 - const MIN_ROW_HEIGHT = 26; 1212 - const LINE_HEIGHT = 18; // approx line height for 0.8rem mono 1213 - const PADDING = 8; 1214 - 1215 - measureCtx.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 1216 - 1217 - let maxHeight = MIN_ROW_HEIGHT; 1218 - 1219 - for (let c = 1; c <= colCount; c++) { 1220 - const id = cellId(c, row); 1221 - const cellData = getCellData(id); 1222 - const displayValue = computeDisplayValue(id, cellData); 1223 - if (displayValue && cellData?.s?.wrap) { 1224 - // For wrapped cells, estimate line count 1225 - const colWidth = getColWidth(c) - PADDING; 1226 - const textWidth = measureCtx.measureText(String(displayValue)).width; 1227 - const lines = Math.max(1, Math.ceil(textWidth / colWidth)); 1228 - const neededHeight = lines * LINE_HEIGHT + PADDING; 1229 - maxHeight = Math.max(maxHeight, neededHeight); 1230 - } 1231 - } 1232 - 1233 - setRowHeight(row, Math.ceil(maxHeight)); 1234 - renderGrid(); 1235 - } 1236 - 1237 - function onCellMouseDown(e) { 1238 - const td = e.target.closest('td[data-id]'); 1239 - if (!td) return; 1240 - const col = parseInt(td.dataset.col); 1241 - const row = parseInt(td.dataset.row); 1242 - if (editingCell) commitEdit(); 1243 - 1244 - // Format painter: apply format to clicked cell 1245 - if (_getFormatPainterFormat()) { 1246 - _applyFormatPainterToCell(_toolbarDeps(), col, row); 1247 - return; 1248 - } 1249 - 1250 - if (e.shiftKey) { 1251 - selectionRange = { startCol: selectedCell.col, startRow: selectedCell.row, endCol: col, endRow: row }; 1252 - } else { 1253 - selectedCell = { col, row }; 1254 - selectionRange = { startCol: col, startRow: row, endCol: col, endRow: row }; 1255 - isSelecting = true; 1256 - } 1257 - 1258 - updateSelectionVisuals(); 1259 - updateFormulaBar(); 1260 - 1261 - let _dragScrollTimer = null; 1262 - const SCROLL_EDGE = 40; // px from edge to trigger auto-scroll 1263 - const SCROLL_SPEED = 8; // px per frame 1264 - 1265 - const onMouseMove = (ev) => { 1266 - const moveTd = ev.target.closest('td[data-id]'); 1267 - if (moveTd) { 1268 - selectionRange.endCol = parseInt(moveTd.dataset.col); 1269 - selectionRange.endRow = parseInt(moveTd.dataset.row); 1270 - updateSelectionVisuals(); 1271 - } 1272 - 1273 - // Auto-scroll when dragging near container edges 1274 - if (!sheetContainer) return; 1275 - const rect = sheetContainer.getBoundingClientRect(); 1276 - let scrollX = 0, scrollY = 0; 1277 - if (ev.clientY > rect.bottom - SCROLL_EDGE) scrollY = SCROLL_SPEED; 1278 - else if (ev.clientY < rect.top + SCROLL_EDGE) scrollY = -SCROLL_SPEED; 1279 - if (ev.clientX > rect.right - SCROLL_EDGE) scrollX = SCROLL_SPEED; 1280 - else if (ev.clientX < rect.left + SCROLL_EDGE) scrollX = -SCROLL_SPEED; 1281 - 1282 - if (scrollX || scrollY) { 1283 - if (!_dragScrollTimer) { 1284 - _dragScrollTimer = setInterval(() => { 1285 - sheetContainer.scrollTop += scrollY; 1286 - sheetContainer.scrollLeft += scrollX; 1287 - }, 16); 1288 - } 1289 - } else if (_dragScrollTimer) { 1290 - clearInterval(_dragScrollTimer); 1291 - _dragScrollTimer = null; 1292 - } 1293 - }; 1294 - 1295 - const onMouseUp = () => { 1296 - isSelecting = false; 1297 - if (_dragScrollTimer) { clearInterval(_dragScrollTimer); _dragScrollTimer = null; } 1298 - document.removeEventListener('mousemove', onMouseMove); 1299 - document.removeEventListener('mouseup', onMouseUp); 1300 - updateMergeButtonState(); 1301 - }; 1302 - 1303 - document.addEventListener('mousemove', onMouseMove); 1304 - document.addEventListener('mouseup', onMouseUp); 1305 - } 1306 - 1307 - // --- Drag-to-Fill --- 1308 - 1309 - function startFillDrag(e) { 1310 - if (!selectionRange && !selectedCell) return; 1311 - isFillDragging = true; 1312 - 1313 - const sourceRange = selectionRange 1314 - ? normalizeRange(selectionRange) 1315 - : { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 1316 - 1317 - let _fillScrollTimer = null; 1318 - const SCROLL_EDGE = 40; 1319 - const SCROLL_SPEED = 8; 1320 - 1321 - const onMouseMove = (ev) => { 1322 - const moveTd = ev.target.closest('td[data-id]'); 1323 - if (moveTd) { 1324 - const targetRow = parseInt(moveTd.dataset.row); 1325 - // Vertical only: determine fill direction 1326 - if (targetRow > sourceRange.endRow) { 1327 - fillPreviewRange = { startCol: sourceRange.startCol, startRow: sourceRange.endRow + 1, endCol: sourceRange.endCol, endRow: targetRow }; 1328 - } else if (targetRow < sourceRange.startRow) { 1329 - fillPreviewRange = { startCol: sourceRange.startCol, startRow: targetRow, endCol: sourceRange.endCol, endRow: sourceRange.startRow - 1 }; 1330 - } else { 1331 - fillPreviewRange = null; 1332 - } 1333 - updateFillPreviewVisuals(); 1334 - } 1335 - 1336 - // Auto-scroll near edges 1337 - const container = sheetContainer; 1338 - const rect = container.getBoundingClientRect(); 1339 - const nearBottom = ev.clientY > rect.bottom - SCROLL_EDGE; 1340 - const nearTop = ev.clientY < rect.top + SCROLL_EDGE; 1341 - if (nearBottom || nearTop) { 1342 - if (!_fillScrollTimer) { 1343 - _fillScrollTimer = setInterval(() => { 1344 - container.scrollTop += nearBottom ? SCROLL_SPEED : -SCROLL_SPEED; 1345 - }, 16); 1346 - } 1347 - } else if (_fillScrollTimer) { 1348 - clearInterval(_fillScrollTimer); 1349 - _fillScrollTimer = null; 1350 - } 1351 - }; 1352 - 1353 - const onMouseUp = () => { 1354 - isFillDragging = false; 1355 - if (_fillScrollTimer) { clearInterval(_fillScrollTimer); _fillScrollTimer = null; } 1356 - document.removeEventListener('mousemove', onMouseMove); 1357 - document.removeEventListener('mouseup', onMouseUp); 1358 - 1359 - if (fillPreviewRange) { 1360 - const targetRange = { ...fillPreviewRange }; 1361 - clearFillPreviewVisuals(); 1362 - fillPreviewRange = null; 1363 - executeFill(sourceRange, targetRange); 1364 - } else { 1365 - clearFillPreviewVisuals(); 1366 - fillPreviewRange = null; 1367 - } 1368 - }; 1369 - 1370 - document.addEventListener('mousemove', onMouseMove); 1371 - document.addEventListener('mouseup', onMouseUp); 1372 - } 1373 - 1374 - function updateFillPreviewVisuals() { 1375 - clearFillPreviewVisuals(); 1376 - if (!fillPreviewRange) return; 1377 - for (let r = fillPreviewRange.startRow; r <= fillPreviewRange.endRow; r++) { 1378 - for (let c = fillPreviewRange.startCol; c <= fillPreviewRange.endCol; c++) { 1379 - const td = getCellEl(c, r); 1380 - if (td) { 1381 - td.classList.add('fill-preview'); 1382 - prevSelectionEls.push(td); 1383 - } 1384 - } 1385 - } 1386 - } 1387 - 1388 - function clearFillPreviewVisuals() { 1389 - grid.querySelectorAll('.fill-preview').forEach(el => el.classList.remove('fill-preview')); 1390 - } 1391 - 1392 - function executeFill(sourceRange, targetRange) { 1393 - const direction = targetRange.startRow > sourceRange.endRow ? 'forward' : 'backward'; 1394 - const fillCount = targetRange.endRow - targetRange.startRow + 1; 1395 - if (fillCount <= 0) return; 1396 - 1397 - ydoc.transact(() => { 1398 - for (let c = sourceRange.startCol; c <= sourceRange.endCol; c++) { 1399 - // Collect source values for this column 1400 - const sourceValues = []; 1401 - for (let r = sourceRange.startRow; r <= sourceRange.endRow; r++) { 1402 - const id = cellId(c, r); 1403 - const cellData = getCellData(id); 1404 - if (cellData?.f) { 1405 - sourceValues.push({ f: cellData.f, v: cellData.v }); 1406 - } else if (cellData?.v !== undefined && cellData?.v !== '') { 1407 - sourceValues.push(cellData.v); 1408 - } else { 1409 - sourceValues.push(''); 1410 - } 1411 - } 1412 - 1413 - const pattern = detectPattern(sourceValues); 1414 - const fillValues = generateFillValues(sourceValues, pattern, fillCount, direction); 1415 - 1416 - for (let i = 0; i < fillCount; i++) { 1417 - const targetRow = targetRange.startRow + i; 1418 - const id = cellId(c, targetRow); 1419 - 1420 - if (pattern.type === PATTERN_TYPES.FORMULA_ADJUST) { 1421 - // For formulas, adjust references based on row offset 1422 - const sourceIdx = i % (sourceRange.endRow - sourceRange.startRow + 1); 1423 - const sourceRow = sourceRange.startRow + sourceIdx; 1424 - const sourceId = cellId(c, sourceRow); 1425 - const sourceCellData = getCellData(sourceId); 1426 - if (sourceCellData?.f) { 1427 - const dRow = targetRow - sourceRow; 1428 - const newFormula = adjustFormulaRef(sourceCellData.f, 0, dRow); 1429 - setCellData(id, { f: newFormula, v: '' }); 1430 - } 1431 - } else { 1432 - const val = fillValues[i]; 1433 - setCellData(id, { v: val, f: '' }); 1434 - } 1435 - } 1436 - } 1437 - }); 1438 - 1439 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1440 - refreshVisibleCells(); 1441 - 1442 - // Extend selection to include filled area 1443 - const newEndRow = Math.max(sourceRange.endRow, targetRange.endRow); 1444 - const newStartRow = Math.min(sourceRange.startRow, targetRange.startRow); 1445 - selectionRange = { startCol: sourceRange.startCol, startRow: newStartRow, endCol: sourceRange.endCol, endRow: newEndRow }; 1446 - updateSelectionVisuals(); 1447 - updateFormulaBar(); 1448 - showToast(`Filled ${fillCount} cell${fillCount > 1 ? 's' : ''}`); 1449 - } 1450 - 1451 - function onCellDblClick(e) { 1452 - const td = e.target.closest('td[data-id]'); 1453 - if (!td) return; 1454 - startEditing(parseInt(td.dataset.col), parseInt(td.dataset.row)); 1455 - } 1456 - 1457 - function startEditing(col, row) { 1458 - if (editingCell) commitEdit(); 1459 - editingCell = { col, row }; 1460 - const id = cellId(col, row); 1461 - const td = grid.querySelector('td[data-id="' + id + '"]'); 1462 - if (!td) return; 1463 - td.classList.add('editing'); 1464 - const cellData = getCellData(id); 1465 - let value = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 1466 - // Show date-formatted cells as readable date strings in the editor 1467 - if (!cellData?.f && cellData?.s?.format === 'date' && typeof value === 'number') { 1468 - const d = new Date(value); 1469 - if (!isNaN(d.getTime())) { 1470 - value = d.toLocaleDateString(); 1471 - } 1472 - } 1473 - const input = document.createElement('input'); 1474 - input.className = 'cell-editor'; 1475 - input.value = value; 1476 - input.placeholder = 'Type or = for formula'; 1477 - td.appendChild(input); 1478 - input.focus(); 1479 - input.select(); 1480 - formulaInput.value = value; 1481 - input.addEventListener('keydown', onEditKeyDown); 1482 - input.addEventListener('blur', () => { hideAutocomplete(); hideTooltip(); commitEdit(); }); 1483 - // Attach formula autocomplete to cell editor 1484 - attachCellEditorAutocomplete(input); 1485 - // Attach formula UX enhancements: range highlights + tooltip 1486 - attachCellEditorFormulaUX(input, td); 1487 - // Initial highlight/range update (with range colors — editing active) 1488 - updateFormulaHighlight(value, true); 1489 - updateFormulaRangeHighlights(value); 1490 - 1491 - // Show date picker for date-like values (#123) 1492 - if (!cellData?.f && parseDateValue(String(value))) { 1493 - showDatePicker(td as HTMLElement, String(value), { 1494 - onSelect: (dateStr) => { 1495 - input.value = dateStr; 1496 - formulaInput.value = dateStr; 1497 - commitEdit(); 1498 - }, 1499 - onClose: () => {}, 1500 - }); 1501 - } 1502 - } 1503 - 1504 - let _commitInProgress = false; 1505 - function commitEdit() { 1506 - if (!editingCell || _commitInProgress) return; 1507 - _commitInProgress = true; 1508 - const id = cellId(editingCell.col, editingCell.row); 1509 - const td = grid.querySelector('td[data-id="' + id + '"]'); 1510 - const input = td?.querySelector('.cell-editor'); 1511 - if (input) { 1512 - const raw = input.value.trim(); 1513 - if (raw.startsWith('=')) { 1514 - setCellData(id, { v: '', f: raw.slice(1) }); 1515 - } else { 1516 - const existingData = getCellData(id); 1517 - const numVal = Number(raw); 1518 - let value: string | number = raw === '' ? '' : (!isNaN(numVal) && raw !== '' ? numVal : raw); 1519 - // If cell has date format and user typed a date string, parse it back to timestamp 1520 - if (typeof value === 'string' && value !== '' && existingData?.s?.format === 'date') { 1521 - const parsed = Date.parse(value); 1522 - if (!isNaN(parsed)) value = parsed; 1523 - } 1524 - setCellData(id, { v: value, f: '' }); 1525 - } 1526 - input.remove(); 1527 - } 1528 - if (td) td.classList.remove('editing'); 1529 - editingCell = null; 1530 - evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1531 - clearGridHighlights(); 1532 - hideTooltip(); 1533 - refreshVisibleCells(); 1534 - _commitInProgress = false; 1535 - } 1536 - 1537 - function onEditKeyDown(e) { 1538 - if (e.key === 'Enter') { e.preventDefault(); commitEdit(); moveSelection(0, 1); } 1539 - else if (e.key === 'Tab') { e.preventDefault(); commitEdit(); moveSelection(e.shiftKey ? -1 : 1, 0); } 1540 - else if (e.key === 'Escape') { 1541 - editingCell = null; 1542 - grid.querySelectorAll('.cell-editor').forEach(el => el.remove()); 1543 - grid.querySelectorAll('.editing').forEach(el => el.classList.remove('editing')); 1544 - clearGridHighlights(); 1545 - hideTooltip(); 1546 - updateFormulaBar(); 1547 - } 1548 - } 606 + // startEditing, commitEdit, onEditKeyDown — extracted to cell-editing.ts 607 + function startEditing(col, row) { _startEditingCE(_cellEditingDeps(), col, row); } 608 + function commitEdit() { _commitEditCE(_cellEditingDeps()); } 1549 609 1550 610 // --- Selection & navigation (extracted to selection-navigation.ts) --- 1551 611 function _selNavDeps() { ··· 1616 676 // Paste event listener (extracted to clipboard-operations.ts) 1617 677 _wirePasteListener(_clipboardDeps(), { getEditingCell: () => editingCell, formulaInput }); 1618 678 1619 - function updateFormulaBar() { 1620 - const id = cellId(selectedCell.col, selectedCell.row); 1621 - if (selectionRange) { 1622 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 1623 - if (startCol !== endCol || startRow !== endRow) { 1624 - cellAddressInput.value = cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 1625 - } else { 1626 - cellAddressInput.value = id; 1627 - } 1628 - } else { 1629 - cellAddressInput.value = id; 1630 - } 1631 - const cellData = getCellData(id); 1632 - let barValue = cellData?.f ? '=' + cellData.f : (cellData?.v ?? ''); 1633 - // Show date-formatted cells as readable date strings in formula bar 1634 - if (!cellData?.f && cellData?.s?.format === 'date' && typeof barValue === 'number') { 1635 - const d = new Date(barValue); 1636 - if (!isNaN(d.getTime())) barValue = d.toLocaleDateString(); 1637 - } 1638 - formulaInput.value = barValue; 1639 - updateFormulaHighlight(formulaInput.value); 1640 - } 679 + // updateFormulaBar — extracted to cell-editing.ts 680 + function updateFormulaBar() { _updateFormulaBarCE(_cellEditingDeps()); } 1641 681 1642 682 // --- Formula bar + highlighting (extracted to formula-bar-ui.ts) --- 1643 683 const formulaHighlightLayer = document.getElementById('formula-highlight-layer'); ··· 2234 1274 formulaInput.addEventListener('focus', () => { updateFormulaHighlight(formulaInput.value, true); updateFormulaRangeHighlights(formulaInput.value); }); 2235 1275 formulaInput.addEventListener('blur', () => { hideTooltip(); clearGridHighlights(); updateFormulaHighlight(formulaInput.value); }); 2236 1276 2237 - function attachCellEditorFormulaUX(inputEl, anchorTd) { 2238 - inputEl.addEventListener('input', () => { const text = inputEl.value; formulaInput.value = text; updateFormulaHighlight(text, true); updateFormulaRangeHighlights(text); updateFormulaTooltip(text, inputEl.selectionStart, anchorTd); }); 2239 - inputEl.addEventListener('click', () => { updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); }); 2240 - inputEl.addEventListener('keyup', (e) => { if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) updateFormulaTooltip(inputEl.value, inputEl.selectionStart, anchorTd); }); 2241 - } 1277 + // attachCellEditorFormulaUX — extracted to cell-editing.ts 1278 + function attachCellEditorFormulaUX(inputEl, anchorTd) { _attachCellEditorFormulaUXCE(_cellEditingDeps(), inputEl, anchorTd); } 2242 1279 2243 1280 // ── Cell Notes (extracted to cell-notes-ui.ts) ── 2244 1281 function _cellNotesDeps() { return { getActiveSheet, grid }; } ··· 2248 1285 function showNoteDialog(id) { _showNoteDialogUI(_cellNotesDeps(), id, renderNoteIndicators); } 2249 1286 function renderNoteIndicators() { _renderNoteIndicatorsUI(_cellNotesDeps()); } 2250 1287 2251 - function renderSparklines() { 2252 - grid.querySelectorAll('canvas.sparkline-canvas').forEach(canvas => { 2253 - const id = canvas.dataset.sparklineId; 2254 - if (!id) return; 2255 - const cellData = getCellData(id); 2256 - const val = computeDisplayValue(id, cellData); 2257 - if (!isSparklineResult(val)) return; 2258 - 2259 - // Size canvas to actual pixel dimensions of parent 2260 - const parent = canvas.parentElement; 2261 - if (parent) { 2262 - const rect = parent.getBoundingClientRect(); 2263 - const dpr = window.devicePixelRatio || 1; 2264 - canvas.width = rect.width * dpr; 2265 - canvas.height = rect.height * dpr; 2266 - canvas.style.width = rect.width + 'px'; 2267 - canvas.style.height = rect.height + 'px'; 2268 - const ctx = canvas.getContext('2d'); 2269 - if (ctx) ctx.scale(dpr, dpr); 2270 - } 2271 - drawSparkline(canvas, val); 2272 - }); 2273 - } 1288 + // renderSparklines — extracted to grid-rendering.ts 1289 + function renderSparklines() { _renderSparklines(_gridRenderingDeps()); } 2274 1290 2275 1291 // ── Note hover + Error tooltip (extracted to cell-notes-ui.ts) ── 2276 1292 _wireNoteHover(_cellNotesDeps()); ··· 2322 1338 function hideFindReplaceBar() { _hideFindReplaceBarUI(_findReplaceDeps(), findBar); } 2323 1339 _wireFindReplaceBar(_findReplaceDeps(), findBar); 2324 1340 2325 - // ======================================================== 2326 - // Row Resize (drag on row header border) 2327 - // ======================================================== 2328 - 2329 - function startRowResize(handle, e) { 2330 - const row = parseInt(handle.dataset.resizeRow); 2331 - const startY = e.clientY; 2332 - const startHeight = getRowHeight(row); 2333 - handle.classList.add('active'); 2334 - 2335 - const guide = document.createElement('div'); 2336 - guide.className = 'row-resize-guide'; 2337 - sheetContainer.appendChild(guide); 2338 - 2339 - const updateGuide = (clientY) => { 2340 - const containerRect = sheetContainer.getBoundingClientRect(); 2341 - guide.style.top = (clientY - containerRect.top + sheetContainer.scrollTop) + 'px'; 2342 - }; 2343 - updateGuide(e.clientY); 2344 - 2345 - const onMouseMove = (ev) => { 2346 - ev.preventDefault(); 2347 - updateGuide(ev.clientY); 2348 - }; 2349 - 2350 - const onMouseUp = (ev) => { 2351 - const delta = ev.clientY - startY; 2352 - const newHeight = Math.max(14, startHeight + delta); 2353 - setRowHeight(row, newHeight); 2354 - handle.classList.remove('active'); 2355 - guide.remove(); 2356 - document.removeEventListener('mousemove', onMouseMove); 2357 - document.removeEventListener('mouseup', onMouseUp); 2358 - document.body.style.cursor = ''; 2359 - renderGrid(); 2360 - }; 2361 - 2362 - document.addEventListener('mousemove', onMouseMove); 2363 - document.addEventListener('mouseup', onMouseUp); 2364 - document.body.style.cursor = 'row-resize'; 2365 - } 1341 + // Row resize — extracted to mouse-events.ts 1342 + function startRowResize(handle, e) { _startRowResize(_mouseEventsDeps(), handle, e); } 2366 1343 2367 1344 // ── AI Chat Panel ──────────────────────────────────────────────────────── 2368 1345
+548
src/sheets/mouse-events.ts
··· 1 + /** 2 + * Mouse Events — mouse handlers for grid interaction, resize, drag-to-fill. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + import { cellId, colToLetter } from './formulas.js'; 8 + import { normalizeRange } from './selection-utils.js'; 9 + import { showToast } from './import-export.js'; 10 + import { detectPattern, generateFillValues, adjustFormulaRef, PATTERN_TYPES } from './drag-fill.js'; 11 + 12 + // ── Types ─────────────────────────────────────────────────── 13 + 14 + export interface MouseEventsDeps { 15 + grid: HTMLElement; 16 + sheetContainer: HTMLElement | null; 17 + measureCtx: CanvasRenderingContext2D | null; 18 + getActiveSheet: () => any; 19 + getSelectedCell: () => { col: number; row: number }; 20 + setSelectedCell: (cell: { col: number; row: number }) => void; 21 + getSelectionRange: () => any; 22 + setSelectionRange: (range: any) => void; 23 + setIsSelecting: (v: boolean) => void; 24 + getEditingCell: () => any; 25 + commitEdit: () => void; 26 + startEditing: (col: number, row: number) => void; 27 + getCellData: (id: string) => any; 28 + setCellData: (id: string, data: any) => void; 29 + computeDisplayValue: (id: string, data: any) => any; 30 + getColWidth: (col: number) => number; 31 + setColWidth: (col: number, width: number) => void; 32 + getRowHeight: (row: number) => number; 33 + setRowHeight: (row: number, height: number) => void; 34 + getCellEl: (col: number, row: number) => Element | null; 35 + getFormatPainterFormat: () => any; 36 + applyFormatPainterToCell: (col: number, row: number) => void; 37 + updateSelectionVisuals: () => void; 38 + updateFormulaBar: () => void; 39 + updateMergeButtonState: () => void; 40 + unhideAdjacentRows: (row: number) => void; 41 + unhideAdjacentCols: (col: number) => void; 42 + renderGrid: () => void; 43 + refreshVisibleCells: () => void; 44 + autoFitColumn: (col: number) => void; 45 + autoFitRow: (row: number) => void; 46 + ydoc: any; 47 + evalCache: { clear: () => void }; 48 + clearSpillMaps: () => void; 49 + invalidateRecalcEngine: () => void; 50 + getFillPreviewRange: () => any; 51 + setFillPreviewRange: (range: any) => void; 52 + setIsFillDragging: (v: boolean) => void; 53 + DEFAULT_ROWS: number; 54 + DEFAULT_COLS: number; 55 + MIN_COL_WIDTH: number; 56 + } 57 + 58 + // ── Functions ─────────────────────────────────────────────── 59 + 60 + export function onGridMouseDown(deps: MouseEventsDeps, e: MouseEvent): void { 61 + const { getActiveSheet, setSelectedCell, setSelectionRange, 62 + getEditingCell, commitEdit: doCommitEdit, updateSelectionVisuals, updateFormulaBar, 63 + updateMergeButtonState, unhideAdjacentRows, unhideAdjacentCols, 64 + DEFAULT_ROWS, DEFAULT_COLS } = deps; 65 + const target = e.target as HTMLElement; 66 + 67 + // Click hidden-row indicator to unhide 68 + const hiddenIndicator = target.closest('.hidden-row-indicator-line'); 69 + if (hiddenIndicator) { 70 + e.preventDefault(); 71 + const indicatorRow = hiddenIndicator.closest('.hidden-row-indicator'); 72 + const prevRow = indicatorRow?.previousElementSibling; 73 + if (prevRow) { 74 + const lastTh = prevRow.querySelector('th[data-row]') as HTMLElement; 75 + if (lastTh) { 76 + const row = parseInt(lastTh.dataset.row!); 77 + unhideAdjacentRows(row); 78 + showToast('Rows unhidden'); 79 + } 80 + } else { 81 + const nextRow = indicatorRow?.nextElementSibling; 82 + if (nextRow) { 83 + const firstTh = nextRow.querySelector('th[data-row]') as HTMLElement; 84 + if (firstTh) { 85 + unhideAdjacentRows(parseInt(firstTh.dataset.row!)); 86 + showToast('Rows unhidden'); 87 + } 88 + } 89 + } 90 + return; 91 + } 92 + // Click hidden-col indicator to unhide 93 + const colIndicator = target.closest('.hidden-col-indicator') as HTMLElement; 94 + if (colIndicator) { 95 + e.preventDefault(); 96 + e.stopPropagation(); 97 + const col = parseInt(colIndicator.dataset.unhideCol!); 98 + if (!isNaN(col)) { 99 + unhideAdjacentCols(col); 100 + showToast('Columns unhidden'); 101 + } 102 + return; 103 + } 104 + // Fill handle drag 105 + const fillHandle = target.closest('.fill-handle'); 106 + if (fillHandle) { 107 + e.preventDefault(); 108 + e.stopPropagation(); 109 + startFillDrag(deps, e); 110 + return; 111 + } 112 + const handle = target.closest('.col-resize-handle') as HTMLElement; 113 + if (handle) { 114 + e.preventDefault(); 115 + e.stopPropagation(); 116 + startColumnResize(deps, handle, e); 117 + return; 118 + } 119 + const rowHandle = target.closest('.row-resize-handle') as HTMLElement; 120 + if (rowHandle) { 121 + e.preventDefault(); 122 + e.stopPropagation(); 123 + startRowResize(deps, rowHandle, e); 124 + return; 125 + } 126 + // Header click for entire row/column selection (#18) 127 + const colHeader = target.closest('thead th[data-col]') as HTMLElement; 128 + const rowHeader = target.closest('th.row-header[data-row]') as HTMLElement; 129 + if (colHeader) { 130 + const col = parseInt(colHeader.dataset.col!); 131 + const sheet = getActiveSheet(); 132 + const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 133 + if (getEditingCell()) doCommitEdit(); 134 + setSelectedCell({ col, row: 1 }); 135 + setSelectionRange({ startCol: col, startRow: 1, endCol: col, endRow: rowCount }); 136 + updateSelectionVisuals(); 137 + updateFormulaBar(); 138 + updateMergeButtonState(); 139 + return; 140 + } 141 + if (rowHeader) { 142 + const row = parseInt(rowHeader.dataset.row!); 143 + const sheet = getActiveSheet(); 144 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 145 + if (getEditingCell()) doCommitEdit(); 146 + setSelectedCell({ col: 1, row }); 147 + setSelectionRange({ startCol: 1, startRow: row, endCol: colCount, endRow: row }); 148 + updateSelectionVisuals(); 149 + updateFormulaBar(); 150 + updateMergeButtonState(); 151 + return; 152 + } 153 + onCellMouseDown(deps, e); 154 + } 155 + 156 + export function onGridDblClick(deps: MouseEventsDeps, e: MouseEvent): void { 157 + const target = e.target as HTMLElement; 158 + const colHandle = target.closest('.col-resize-handle') as HTMLElement; 159 + if (colHandle) { 160 + e.preventDefault(); 161 + e.stopPropagation(); 162 + deps.autoFitColumn(parseInt(colHandle.dataset.resizeCol!)); 163 + return; 164 + } 165 + const rowHandle = target.closest('.row-resize-handle') as HTMLElement; 166 + if (rowHandle) { 167 + e.preventDefault(); 168 + e.stopPropagation(); 169 + deps.autoFitRow(parseInt(rowHandle.dataset.resizeRow!)); 170 + return; 171 + } 172 + onCellDblClick(deps, e); 173 + } 174 + 175 + export function startColumnResize(deps: MouseEventsDeps, handle: HTMLElement, e: MouseEvent): void { 176 + const { sheetContainer, getColWidth, setColWidth, renderGrid, MIN_COL_WIDTH } = deps; 177 + const col = parseInt(handle.dataset.resizeCol!); 178 + const startX = e.clientX; 179 + const startWidth = getColWidth(col); 180 + handle.classList.add('active'); 181 + 182 + const guide = document.createElement('div'); 183 + guide.className = 'col-resize-guide'; 184 + sheetContainer!.appendChild(guide); 185 + 186 + const updateGuide = (clientX: number) => { 187 + const containerRect = sheetContainer!.getBoundingClientRect(); 188 + guide.style.left = (clientX - containerRect.left + sheetContainer!.scrollLeft) + 'px'; 189 + }; 190 + updateGuide(e.clientX); 191 + 192 + const onMouseMove = (ev: MouseEvent) => { 193 + ev.preventDefault(); 194 + updateGuide(ev.clientX); 195 + }; 196 + 197 + const onMouseUp = (ev: MouseEvent) => { 198 + const delta = ev.clientX - startX; 199 + const newWidth = Math.max(MIN_COL_WIDTH, startWidth + delta); 200 + setColWidth(col, newWidth); 201 + handle.classList.remove('active'); 202 + guide.remove(); 203 + document.removeEventListener('mousemove', onMouseMove); 204 + document.removeEventListener('mouseup', onMouseUp); 205 + document.body.style.cursor = ''; 206 + renderGrid(); 207 + }; 208 + 209 + document.addEventListener('mousemove', onMouseMove); 210 + document.addEventListener('mouseup', onMouseUp); 211 + document.body.style.cursor = 'col-resize'; 212 + } 213 + 214 + export function autoFitColumn(deps: MouseEventsDeps, col: number): void { 215 + const { getActiveSheet, getCellData, computeDisplayValue, measureCtx, setColWidth, renderGrid, DEFAULT_ROWS, MIN_COL_WIDTH } = deps; 216 + const sheet = getActiveSheet(); 217 + const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 218 + const PADDING = 16; 219 + 220 + measureCtx!.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 221 + 222 + let maxWidth = MIN_COL_WIDTH; 223 + 224 + const headerText = colToLetter(col); 225 + const headerWidth = measureCtx!.measureText(headerText).width + PADDING + 16; 226 + maxWidth = Math.max(maxWidth, headerWidth); 227 + 228 + for (let r = 1; r <= rowCount; r++) { 229 + const id = cellId(col, r); 230 + const cellData = getCellData(id); 231 + const displayValue = computeDisplayValue(id, cellData); 232 + if (displayValue) { 233 + if (cellData?.s?.bold) { 234 + measureCtx!.font = '600 0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 235 + } 236 + const textWidth = measureCtx!.measureText(String(displayValue)).width + PADDING; 237 + maxWidth = Math.max(maxWidth, textWidth); 238 + if (cellData?.s?.bold) { 239 + measureCtx!.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 240 + } 241 + } 242 + } 243 + 244 + const MAX_AUTO_WIDTH = 500; 245 + setColWidth(col, Math.min(MAX_AUTO_WIDTH, Math.ceil(maxWidth))); 246 + renderGrid(); 247 + } 248 + 249 + export function autoFitRow(deps: MouseEventsDeps, row: number): void { 250 + const { getActiveSheet, getCellData, computeDisplayValue, measureCtx, getColWidth, setRowHeight, renderGrid, DEFAULT_COLS } = deps; 251 + const sheet = getActiveSheet(); 252 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 253 + const MIN_ROW_HEIGHT = 26; 254 + const LINE_HEIGHT = 18; 255 + const PADDING = 8; 256 + 257 + measureCtx!.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 258 + 259 + let maxHeight = MIN_ROW_HEIGHT; 260 + 261 + for (let c = 1; c <= colCount; c++) { 262 + const id = cellId(c, row); 263 + const cellData = getCellData(id); 264 + const displayValue = computeDisplayValue(id, cellData); 265 + if (displayValue && cellData?.s?.wrap) { 266 + const colWidth = getColWidth(c) - PADDING; 267 + const textWidth = measureCtx!.measureText(String(displayValue)).width; 268 + const lines = Math.max(1, Math.ceil(textWidth / colWidth)); 269 + const neededHeight = lines * LINE_HEIGHT + PADDING; 270 + maxHeight = Math.max(maxHeight, neededHeight); 271 + } 272 + } 273 + 274 + setRowHeight(row, Math.ceil(maxHeight)); 275 + renderGrid(); 276 + } 277 + 278 + function onCellMouseDown(deps: MouseEventsDeps, e: MouseEvent): void { 279 + const { sheetContainer, getSelectedCell, setSelectedCell, setSelectionRange, 280 + setIsSelecting, getEditingCell, commitEdit: doCommitEdit, 281 + getFormatPainterFormat, applyFormatPainterToCell, 282 + updateSelectionVisuals, updateFormulaBar, updateMergeButtonState } = deps; 283 + const target = e.target as HTMLElement; 284 + const td = target.closest('td[data-id]') as HTMLElement; 285 + if (!td) return; 286 + const col = parseInt(td.dataset.col!); 287 + const row = parseInt(td.dataset.row!); 288 + if (getEditingCell()) doCommitEdit(); 289 + 290 + // Format painter: apply format to clicked cell 291 + if (getFormatPainterFormat()) { 292 + applyFormatPainterToCell(col, row); 293 + return; 294 + } 295 + 296 + if (e.shiftKey) { 297 + const selected = getSelectedCell(); 298 + setSelectionRange({ startCol: selected.col, startRow: selected.row, endCol: col, endRow: row }); 299 + } else { 300 + setSelectedCell({ col, row }); 301 + setSelectionRange({ startCol: col, startRow: row, endCol: col, endRow: row }); 302 + setIsSelecting(true); 303 + } 304 + 305 + updateSelectionVisuals(); 306 + updateFormulaBar(); 307 + 308 + let _dragScrollTimer: ReturnType<typeof setInterval> | null = null; 309 + const SCROLL_EDGE = 40; 310 + const SCROLL_SPEED = 8; 311 + 312 + const onMouseMove = (ev: MouseEvent) => { 313 + const moveTd = (ev.target as HTMLElement).closest('td[data-id]') as HTMLElement; 314 + if (moveTd) { 315 + const range = deps.getSelectionRange(); 316 + range.endCol = parseInt(moveTd.dataset.col!); 317 + range.endRow = parseInt(moveTd.dataset.row!); 318 + updateSelectionVisuals(); 319 + } 320 + 321 + // Auto-scroll when dragging near container edges 322 + if (!sheetContainer) return; 323 + const rect = sheetContainer.getBoundingClientRect(); 324 + let scrollX = 0, scrollY = 0; 325 + if (ev.clientY > rect.bottom - SCROLL_EDGE) scrollY = SCROLL_SPEED; 326 + else if (ev.clientY < rect.top + SCROLL_EDGE) scrollY = -SCROLL_SPEED; 327 + if (ev.clientX > rect.right - SCROLL_EDGE) scrollX = SCROLL_SPEED; 328 + else if (ev.clientX < rect.left + SCROLL_EDGE) scrollX = -SCROLL_SPEED; 329 + 330 + if (scrollX || scrollY) { 331 + if (!_dragScrollTimer) { 332 + _dragScrollTimer = setInterval(() => { 333 + sheetContainer.scrollTop += scrollY; 334 + sheetContainer.scrollLeft += scrollX; 335 + }, 16); 336 + } 337 + } else if (_dragScrollTimer) { 338 + clearInterval(_dragScrollTimer); 339 + _dragScrollTimer = null; 340 + } 341 + }; 342 + 343 + const onMouseUp = () => { 344 + setIsSelecting(false); 345 + if (_dragScrollTimer) { clearInterval(_dragScrollTimer); _dragScrollTimer = null; } 346 + document.removeEventListener('mousemove', onMouseMove); 347 + document.removeEventListener('mouseup', onMouseUp); 348 + updateMergeButtonState(); 349 + }; 350 + 351 + document.addEventListener('mousemove', onMouseMove); 352 + document.addEventListener('mouseup', onMouseUp); 353 + } 354 + 355 + // --- Drag-to-Fill --- 356 + 357 + function startFillDrag(deps: MouseEventsDeps, e: MouseEvent): void { 358 + const { sheetContainer, getSelectedCell, getSelectionRange, setIsFillDragging, setFillPreviewRange, getFillPreviewRange } = deps; 359 + const selectedCell = getSelectedCell(); 360 + const selectionRange = getSelectionRange(); 361 + if (!selectionRange && !selectedCell) return; 362 + setIsFillDragging(true); 363 + 364 + const sourceRange = selectionRange 365 + ? normalizeRange(selectionRange) 366 + : { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 367 + 368 + let _fillScrollTimer: ReturnType<typeof setInterval> | null = null; 369 + const SCROLL_EDGE = 40; 370 + const SCROLL_SPEED = 8; 371 + 372 + const onMouseMove = (ev: MouseEvent) => { 373 + const moveTd = (ev.target as HTMLElement).closest('td[data-id]') as HTMLElement; 374 + if (moveTd) { 375 + const targetRow = parseInt(moveTd.dataset.row!); 376 + if (targetRow > sourceRange.endRow) { 377 + setFillPreviewRange({ startCol: sourceRange.startCol, startRow: sourceRange.endRow + 1, endCol: sourceRange.endCol, endRow: targetRow }); 378 + } else if (targetRow < sourceRange.startRow) { 379 + setFillPreviewRange({ startCol: sourceRange.startCol, startRow: targetRow, endCol: sourceRange.endCol, endRow: sourceRange.startRow - 1 }); 380 + } else { 381 + setFillPreviewRange(null); 382 + } 383 + updateFillPreviewVisuals(deps); 384 + } 385 + 386 + // Auto-scroll near edges 387 + const container = sheetContainer; 388 + if (!container) return; 389 + const rect = container.getBoundingClientRect(); 390 + const nearBottom = ev.clientY > rect.bottom - SCROLL_EDGE; 391 + const nearTop = ev.clientY < rect.top + SCROLL_EDGE; 392 + if (nearBottom || nearTop) { 393 + if (!_fillScrollTimer) { 394 + _fillScrollTimer = setInterval(() => { 395 + container.scrollTop += nearBottom ? SCROLL_SPEED : -SCROLL_SPEED; 396 + }, 16); 397 + } 398 + } else if (_fillScrollTimer) { 399 + clearInterval(_fillScrollTimer); 400 + _fillScrollTimer = null; 401 + } 402 + }; 403 + 404 + const onMouseUp = () => { 405 + setIsFillDragging(false); 406 + if (_fillScrollTimer) { clearInterval(_fillScrollTimer); _fillScrollTimer = null; } 407 + document.removeEventListener('mousemove', onMouseMove); 408 + document.removeEventListener('mouseup', onMouseUp); 409 + 410 + const fillPreviewRange = getFillPreviewRange(); 411 + if (fillPreviewRange) { 412 + const targetRange = { ...fillPreviewRange }; 413 + clearFillPreviewVisuals(deps); 414 + setFillPreviewRange(null); 415 + executeFill(deps, sourceRange, targetRange); 416 + } else { 417 + clearFillPreviewVisuals(deps); 418 + setFillPreviewRange(null); 419 + } 420 + }; 421 + 422 + document.addEventListener('mousemove', onMouseMove); 423 + document.addEventListener('mouseup', onMouseUp); 424 + } 425 + 426 + function updateFillPreviewVisuals(deps: MouseEventsDeps): void { 427 + const { getCellEl, getFillPreviewRange } = deps; 428 + clearFillPreviewVisuals(deps); 429 + const fillPreviewRange = getFillPreviewRange(); 430 + if (!fillPreviewRange) return; 431 + for (let r = fillPreviewRange.startRow; r <= fillPreviewRange.endRow; r++) { 432 + for (let c = fillPreviewRange.startCol; c <= fillPreviewRange.endCol; c++) { 433 + const td = getCellEl(c, r); 434 + if (td) { 435 + td.classList.add('fill-preview'); 436 + } 437 + } 438 + } 439 + } 440 + 441 + function clearFillPreviewVisuals(deps: MouseEventsDeps): void { 442 + deps.grid.querySelectorAll('.fill-preview').forEach(el => el.classList.remove('fill-preview')); 443 + } 444 + 445 + function executeFill(deps: MouseEventsDeps, sourceRange: any, targetRange: any): void { 446 + const { ydoc, getCellData, setCellData, setSelectionRange, 447 + evalCache, clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 448 + updateSelectionVisuals, updateFormulaBar } = deps; 449 + 450 + const direction = targetRange.startRow > sourceRange.endRow ? 'forward' : 'backward'; 451 + const fillCount = targetRange.endRow - targetRange.startRow + 1; 452 + if (fillCount <= 0) return; 453 + 454 + ydoc.transact(() => { 455 + for (let c = sourceRange.startCol; c <= sourceRange.endCol; c++) { 456 + const sourceValues: any[] = []; 457 + for (let r = sourceRange.startRow; r <= sourceRange.endRow; r++) { 458 + const id = cellId(c, r); 459 + const cellData = getCellData(id); 460 + if (cellData?.f) { 461 + sourceValues.push({ f: cellData.f, v: cellData.v }); 462 + } else if (cellData?.v !== undefined && cellData?.v !== '') { 463 + sourceValues.push(cellData.v); 464 + } else { 465 + sourceValues.push(''); 466 + } 467 + } 468 + 469 + const pattern = detectPattern(sourceValues); 470 + const fillValues = generateFillValues(sourceValues, pattern, fillCount, direction); 471 + 472 + for (let i = 0; i < fillCount; i++) { 473 + const targetRow = targetRange.startRow + i; 474 + const id = cellId(c, targetRow); 475 + 476 + if (pattern.type === PATTERN_TYPES.FORMULA_ADJUST) { 477 + const sourceIdx = i % (sourceRange.endRow - sourceRange.startRow + 1); 478 + const sourceRow = sourceRange.startRow + sourceIdx; 479 + const sourceId = cellId(c, sourceRow); 480 + const sourceCellData = getCellData(sourceId); 481 + if (sourceCellData?.f) { 482 + const dRow = targetRow - sourceRow; 483 + const newFormula = adjustFormulaRef(sourceCellData.f, 0, dRow); 484 + setCellData(id, { f: newFormula, v: '' }); 485 + } 486 + } else { 487 + const val = fillValues[i]; 488 + setCellData(id, { v: val, f: '' }); 489 + } 490 + } 491 + } 492 + }); 493 + 494 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 495 + refreshVisibleCells(); 496 + 497 + const newEndRow = Math.max(sourceRange.endRow, targetRange.endRow); 498 + const newStartRow = Math.min(sourceRange.startRow, targetRange.startRow); 499 + setSelectionRange({ startCol: sourceRange.startCol, startRow: newStartRow, endCol: sourceRange.endCol, endRow: newEndRow }); 500 + updateSelectionVisuals(); 501 + updateFormulaBar(); 502 + showToast(`Filled ${fillCount} cell${fillCount > 1 ? 's' : ''}`); 503 + } 504 + 505 + function onCellDblClick(deps: MouseEventsDeps, e: MouseEvent): void { 506 + const td = (e.target as HTMLElement).closest('td[data-id]') as HTMLElement; 507 + if (!td) return; 508 + deps.startEditing(parseInt(td.dataset.col!), parseInt(td.dataset.row!)); 509 + } 510 + 511 + export function startRowResize(deps: MouseEventsDeps, handle: HTMLElement, e: MouseEvent): void { 512 + const { sheetContainer, getRowHeight, setRowHeight, renderGrid } = deps; 513 + const row = parseInt(handle.dataset.resizeRow!); 514 + const startY = e.clientY; 515 + const startHeight = getRowHeight(row); 516 + handle.classList.add('active'); 517 + 518 + const guide = document.createElement('div'); 519 + guide.className = 'row-resize-guide'; 520 + sheetContainer!.appendChild(guide); 521 + 522 + const updateGuide = (clientY: number) => { 523 + const containerRect = sheetContainer!.getBoundingClientRect(); 524 + guide.style.top = (clientY - containerRect.top + sheetContainer!.scrollTop) + 'px'; 525 + }; 526 + updateGuide(e.clientY); 527 + 528 + const onMouseMove = (ev: MouseEvent) => { 529 + ev.preventDefault(); 530 + updateGuide(ev.clientY); 531 + }; 532 + 533 + const onMouseUp = (ev: MouseEvent) => { 534 + const delta = ev.clientY - startY; 535 + const newHeight = Math.max(14, startHeight + delta); 536 + setRowHeight(row, newHeight); 537 + handle.classList.remove('active'); 538 + guide.remove(); 539 + document.removeEventListener('mousemove', onMouseMove); 540 + document.removeEventListener('mouseup', onMouseUp); 541 + document.body.style.cursor = ''; 542 + renderGrid(); 543 + }; 544 + 545 + document.addEventListener('mousemove', onMouseMove); 546 + document.addEventListener('mouseup', onMouseUp); 547 + document.body.style.cursor = 'row-resize'; 548 + }
+193
src/sheets/touch-events.ts
··· 1 + /** 2 + * Touch Events — touch handlers for mobile/tablet spreadsheet interaction. 3 + * 4 + * Extracted from main.ts for decomposition. 5 + */ 6 + 7 + // ── Types ─────────────────────────────────────────────────── 8 + 9 + export interface TouchEventsDeps { 10 + grid: HTMLElement; 11 + getSelectedCell: () => { col: number; row: number }; 12 + setSelectedCell: (cell: { col: number; row: number }) => void; 13 + getSelectionRange: () => any; 14 + setSelectionRange: (range: any) => void; 15 + setIsSelecting: (v: boolean) => void; 16 + getEditingCell: () => any; 17 + commitEdit: () => void; 18 + startEditing: (col: number, row: number) => void; 19 + getColWidth: (col: number) => number; 20 + setColWidth: (col: number, width: number) => void; 21 + getRowHeight: (row: number) => number; 22 + setRowHeight: (row: number, height: number) => void; 23 + updateSelectionVisuals: () => void; 24 + updateFormulaBar: () => void; 25 + updateMergeButtonState: () => void; 26 + renderGrid: () => void; 27 + MIN_COL_WIDTH: number; 28 + } 29 + 30 + // ── State ─────────────────────────────────────────────────── 31 + 32 + let _touchTimer: ReturnType<typeof setTimeout> | null = null; 33 + let _touchMoved = false; 34 + let _touchStartCell: { col: number; row: number } | null = null; 35 + let _lastTapTime = 0; 36 + let _lastTapCell = ''; 37 + 38 + // ── Functions ─────────────────────────────────────────────── 39 + 40 + export function onGridTouchStart(deps: TouchEventsDeps, e: TouchEvent): void { 41 + if (e.touches.length !== 1) return; 42 + const touch = e.touches[0]; 43 + const target = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement; 44 + if (!target) return; 45 + 46 + // Handle col/row resize handles 47 + const colHandle = target.closest('.col-resize-handle') as HTMLElement; 48 + if (colHandle) { 49 + e.preventDefault(); 50 + startColumnResizeTouch(deps, colHandle, touch); 51 + return; 52 + } 53 + const rowHandle = target.closest('.row-resize-handle') as HTMLElement; 54 + if (rowHandle) { 55 + e.preventDefault(); 56 + startRowResizeTouch(deps, rowHandle, touch); 57 + return; 58 + } 59 + 60 + // Cell selection 61 + const td = target.closest('td[data-id]') as HTMLElement; 62 + if (!td) return; 63 + 64 + _touchMoved = false; 65 + _touchStartCell = { col: parseInt(td.dataset.col!), row: parseInt(td.dataset.row!) }; 66 + 67 + // Long-press opens context menu (500ms) 68 + _touchTimer = setTimeout(() => { 69 + _touchTimer = null; 70 + if (!_touchMoved && _touchStartCell) { 71 + const contextEvent = new MouseEvent('contextmenu', { 72 + clientX: touch.clientX, 73 + clientY: touch.clientY, 74 + bubbles: true, 75 + }); 76 + td.dispatchEvent(contextEvent); 77 + } 78 + }, 500); 79 + 80 + if (deps.getEditingCell()) deps.commitEdit(); 81 + deps.setSelectedCell({ col: _touchStartCell.col, row: _touchStartCell.row }); 82 + deps.setSelectionRange({ startCol: _touchStartCell.col, startRow: _touchStartCell.row, endCol: _touchStartCell.col, endRow: _touchStartCell.row }); 83 + deps.setIsSelecting(true); 84 + deps.updateSelectionVisuals(); 85 + deps.updateFormulaBar(); 86 + 87 + const onTouchMove = (ev: TouchEvent) => { 88 + _touchMoved = true; 89 + if (_touchTimer) { clearTimeout(_touchTimer); _touchTimer = null; } 90 + if (ev.touches.length !== 1) return; 91 + ev.preventDefault(); 92 + const t = ev.touches[0]; 93 + const el = document.elementFromPoint(t.clientX, t.clientY) as HTMLElement; 94 + if (!el) return; 95 + const moveTd = el.closest('td[data-id]') as HTMLElement; 96 + if (moveTd) { 97 + const range = deps.getSelectionRange(); 98 + range.endCol = parseInt(moveTd.dataset.col!); 99 + range.endRow = parseInt(moveTd.dataset.row!); 100 + deps.updateSelectionVisuals(); 101 + } 102 + }; 103 + 104 + const onTouchEnd = () => { 105 + if (_touchTimer) { clearTimeout(_touchTimer); _touchTimer = null; } 106 + deps.setIsSelecting(false); 107 + deps.updateMergeButtonState(); 108 + document.removeEventListener('touchmove', onTouchMove); 109 + document.removeEventListener('touchend', onTouchEnd); 110 + document.removeEventListener('touchcancel', onTouchEnd); 111 + }; 112 + 113 + document.addEventListener('touchmove', onTouchMove, { passive: false }); 114 + document.addEventListener('touchend', onTouchEnd); 115 + document.addEventListener('touchcancel', onTouchEnd); 116 + } 117 + 118 + function startColumnResizeTouch(deps: TouchEventsDeps, handle: HTMLElement, touch: Touch): void { 119 + const col = parseInt(handle.dataset.resizeCol!); 120 + const startX = touch.clientX; 121 + const startWidth = deps.getColWidth(col); 122 + handle.classList.add('active'); 123 + 124 + const onTouchMove = (ev: TouchEvent) => { 125 + if (ev.touches.length !== 1) return; 126 + ev.preventDefault(); 127 + }; 128 + 129 + const onTouchEnd = (ev: TouchEvent) => { 130 + const endTouch = ev.changedTouches[0]; 131 + const delta = endTouch.clientX - startX; 132 + const newWidth = Math.max(deps.MIN_COL_WIDTH, startWidth + delta); 133 + deps.setColWidth(col, newWidth); 134 + handle.classList.remove('active'); 135 + deps.renderGrid(); 136 + document.removeEventListener('touchmove', onTouchMove); 137 + document.removeEventListener('touchend', onTouchEnd); 138 + document.removeEventListener('touchcancel', onTouchEnd); 139 + }; 140 + 141 + document.addEventListener('touchmove', onTouchMove, { passive: false }); 142 + document.addEventListener('touchend', onTouchEnd); 143 + document.addEventListener('touchcancel', onTouchEnd); 144 + } 145 + 146 + function startRowResizeTouch(deps: TouchEventsDeps, handle: HTMLElement, touch: Touch): void { 147 + const row = parseInt(handle.dataset.resizeRow!); 148 + const startY = touch.clientY; 149 + const startHeight = deps.getRowHeight(row); 150 + handle.classList.add('active'); 151 + 152 + const onTouchMove = (ev: TouchEvent) => { 153 + if (ev.touches.length !== 1) return; 154 + ev.preventDefault(); 155 + }; 156 + 157 + const onTouchEnd = (ev: TouchEvent) => { 158 + const endTouch = ev.changedTouches[0]; 159 + const delta = endTouch.clientY - startY; 160 + const newHeight = Math.max(14, startHeight + delta); 161 + deps.setRowHeight(row, newHeight); 162 + handle.classList.remove('active'); 163 + deps.renderGrid(); 164 + document.removeEventListener('touchmove', onTouchMove); 165 + document.removeEventListener('touchend', onTouchEnd); 166 + document.removeEventListener('touchcancel', onTouchEnd); 167 + }; 168 + 169 + document.addEventListener('touchmove', onTouchMove, { passive: false }); 170 + document.addEventListener('touchend', onTouchEnd); 171 + document.addEventListener('touchcancel', onTouchEnd); 172 + } 173 + 174 + export function wireTouchDoubleTap(deps: TouchEventsDeps): void { 175 + deps.grid.addEventListener('touchend', (e: TouchEvent) => { 176 + if (_touchMoved) return; 177 + const touch = e.changedTouches[0]; 178 + const el = document.elementFromPoint(touch.clientX, touch.clientY) as HTMLElement; 179 + if (!el) return; 180 + const td = el.closest('td[data-id]') as HTMLElement; 181 + if (!td) return; 182 + const now = Date.now(); 183 + const cellKey = td.dataset.id!; 184 + if (now - _lastTapTime < 400 && cellKey === _lastTapCell) { 185 + deps.startEditing(parseInt(td.dataset.col!), parseInt(td.dataset.row!)); 186 + _lastTapTime = 0; 187 + _lastTapCell = ''; 188 + } else { 189 + _lastTapTime = now; 190 + _lastTapCell = cellKey; 191 + } 192 + }); 193 + }