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

Configure Feed

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

refactor(sheets): decompose formulas.ts and mouse-events.ts into focused modules

Extract tokenizer, parser, resize, autofit, and fill logic into dedicated files:
- formula-tokenizer.ts: lexical analysis (TokenType, tokenize)
- formula-parser.ts: recursive descent Parser class
- resize-handlers.ts: column/row resize with visual guide
- autofit-handlers.ts: auto-fit column width and row height
- fill-handlers.ts: drag-to-fill logic and preview visuals

formulas.ts: 794 → 195 lines (keeps function dispatch, evaluate, extractRefs, formatCell)
mouse-events.ts: 548 → 261 lines (keeps event routing and cell selection)

+956 -896
+72
src/sheets/autofit-handlers.ts
··· 1 + /** 2 + * Autofit Handlers — auto-fit column width and row height based on content. 3 + * 4 + * Extracted from mouse-events.ts for decomposition. 5 + */ 6 + 7 + import { cellId, colToLetter } from './formulas.js'; 8 + import type { MouseEventsDeps } from './mouse-events.js'; 9 + 10 + export function autoFitColumn(deps: MouseEventsDeps, col: number): void { 11 + const { getActiveSheet, getCellData, computeDisplayValue, measureCtx, setColWidth, renderGrid, DEFAULT_ROWS, MIN_COL_WIDTH } = deps; 12 + const sheet = getActiveSheet(); 13 + const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 14 + const PADDING = 16; 15 + 16 + measureCtx!.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 17 + 18 + let maxWidth = MIN_COL_WIDTH; 19 + 20 + const headerText = colToLetter(col); 21 + const headerWidth = measureCtx!.measureText(headerText).width + PADDING + 16; 22 + maxWidth = Math.max(maxWidth, headerWidth); 23 + 24 + for (let r = 1; r <= rowCount; r++) { 25 + const id = cellId(col, r); 26 + const cellData = getCellData(id); 27 + const displayValue = computeDisplayValue(id, cellData); 28 + if (displayValue) { 29 + if (cellData?.s?.bold) { 30 + measureCtx!.font = '600 0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 31 + } 32 + const textWidth = measureCtx!.measureText(String(displayValue)).width + PADDING; 33 + maxWidth = Math.max(maxWidth, textWidth); 34 + if (cellData?.s?.bold) { 35 + measureCtx!.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 36 + } 37 + } 38 + } 39 + 40 + const MAX_AUTO_WIDTH = 500; 41 + setColWidth(col, Math.min(MAX_AUTO_WIDTH, Math.ceil(maxWidth))); 42 + renderGrid(); 43 + } 44 + 45 + export function autoFitRow(deps: MouseEventsDeps, row: number): void { 46 + const { getActiveSheet, getCellData, computeDisplayValue, measureCtx, getColWidth, setRowHeight, renderGrid, DEFAULT_COLS } = deps; 47 + const sheet = getActiveSheet(); 48 + const colCount = sheet.get('colCount') || DEFAULT_COLS; 49 + const MIN_ROW_HEIGHT = 26; 50 + const LINE_HEIGHT = 18; 51 + const PADDING = 8; 52 + 53 + measureCtx!.font = '0.8rem ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace'; 54 + 55 + let maxHeight = MIN_ROW_HEIGHT; 56 + 57 + for (let c = 1; c <= colCount; c++) { 58 + const id = cellId(c, row); 59 + const cellData = getCellData(id); 60 + const displayValue = computeDisplayValue(id, cellData); 61 + if (displayValue && cellData?.s?.wrap) { 62 + const colWidth = getColWidth(c) - PADDING; 63 + const textWidth = measureCtx!.measureText(String(displayValue)).width; 64 + const lines = Math.max(1, Math.ceil(textWidth / colWidth)); 65 + const neededHeight = lines * LINE_HEIGHT + PADDING; 66 + maxHeight = Math.max(maxHeight, neededHeight); 67 + } 68 + } 69 + 70 + setRowHeight(row, Math.ceil(maxHeight)); 71 + renderGrid(); 72 + }
+159
src/sheets/fill-handlers.ts
··· 1 + /** 2 + * Fill Handlers — drag-to-fill logic, preview visuals, and fill execution. 3 + * 4 + * Extracted from mouse-events.ts for decomposition. 5 + */ 6 + 7 + import { cellId } 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 + import type { MouseEventsDeps } from './mouse-events.js'; 12 + 13 + export function startFillDrag(deps: MouseEventsDeps, e: MouseEvent): void { 14 + const { sheetContainer, getSelectedCell, getSelectionRange, setIsFillDragging, setFillPreviewRange, getFillPreviewRange } = deps; 15 + const selectedCell = getSelectedCell(); 16 + const selectionRange = getSelectionRange(); 17 + if (!selectionRange && !selectedCell) return; 18 + setIsFillDragging(true); 19 + 20 + const sourceRange = selectionRange 21 + ? normalizeRange(selectionRange) 22 + : { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 23 + 24 + let _fillScrollTimer: ReturnType<typeof setInterval> | null = null; 25 + const SCROLL_EDGE = 40; 26 + const SCROLL_SPEED = 8; 27 + 28 + const onMouseMove = (ev: MouseEvent) => { 29 + const moveTd = (ev.target as HTMLElement).closest('td[data-id]') as HTMLElement; 30 + if (moveTd) { 31 + const targetRow = parseInt(moveTd.dataset.row!); 32 + if (targetRow > sourceRange.endRow) { 33 + setFillPreviewRange({ startCol: sourceRange.startCol, startRow: sourceRange.endRow + 1, endCol: sourceRange.endCol, endRow: targetRow }); 34 + } else if (targetRow < sourceRange.startRow) { 35 + setFillPreviewRange({ startCol: sourceRange.startCol, startRow: targetRow, endCol: sourceRange.endCol, endRow: sourceRange.startRow - 1 }); 36 + } else { 37 + setFillPreviewRange(null); 38 + } 39 + updateFillPreviewVisuals(deps); 40 + } 41 + 42 + // Auto-scroll near edges 43 + const container = sheetContainer; 44 + if (!container) return; 45 + const rect = container.getBoundingClientRect(); 46 + const nearBottom = ev.clientY > rect.bottom - SCROLL_EDGE; 47 + const nearTop = ev.clientY < rect.top + SCROLL_EDGE; 48 + if (nearBottom || nearTop) { 49 + if (!_fillScrollTimer) { 50 + _fillScrollTimer = setInterval(() => { 51 + container.scrollTop += nearBottom ? SCROLL_SPEED : -SCROLL_SPEED; 52 + }, 16); 53 + } 54 + } else if (_fillScrollTimer) { 55 + clearInterval(_fillScrollTimer); 56 + _fillScrollTimer = null; 57 + } 58 + }; 59 + 60 + const onMouseUp = () => { 61 + setIsFillDragging(false); 62 + if (_fillScrollTimer) { clearInterval(_fillScrollTimer); _fillScrollTimer = null; } 63 + document.removeEventListener('mousemove', onMouseMove); 64 + document.removeEventListener('mouseup', onMouseUp); 65 + 66 + const fillPreviewRange = getFillPreviewRange(); 67 + if (fillPreviewRange) { 68 + const targetRange = { ...fillPreviewRange }; 69 + clearFillPreviewVisuals(deps); 70 + setFillPreviewRange(null); 71 + executeFill(deps, sourceRange, targetRange); 72 + } else { 73 + clearFillPreviewVisuals(deps); 74 + setFillPreviewRange(null); 75 + } 76 + }; 77 + 78 + document.addEventListener('mousemove', onMouseMove); 79 + document.addEventListener('mouseup', onMouseUp); 80 + } 81 + 82 + export function updateFillPreviewVisuals(deps: MouseEventsDeps): void { 83 + const { getCellEl, getFillPreviewRange } = deps; 84 + clearFillPreviewVisuals(deps); 85 + const fillPreviewRange = getFillPreviewRange(); 86 + if (!fillPreviewRange) return; 87 + for (let r = fillPreviewRange.startRow; r <= fillPreviewRange.endRow; r++) { 88 + for (let c = fillPreviewRange.startCol; c <= fillPreviewRange.endCol; c++) { 89 + const td = getCellEl(c, r); 90 + if (td) { 91 + td.classList.add('fill-preview'); 92 + } 93 + } 94 + } 95 + } 96 + 97 + export function clearFillPreviewVisuals(deps: MouseEventsDeps): void { 98 + deps.grid.querySelectorAll('.fill-preview').forEach(el => el.classList.remove('fill-preview')); 99 + } 100 + 101 + export function executeFill(deps: MouseEventsDeps, sourceRange: any, targetRange: any): void { 102 + const { ydoc, getCellData, setCellData, setSelectionRange, 103 + evalCache, clearSpillMaps, invalidateRecalcEngine, refreshVisibleCells, 104 + updateSelectionVisuals, updateFormulaBar } = deps; 105 + 106 + const direction = targetRange.startRow > sourceRange.endRow ? 'forward' : 'backward'; 107 + const fillCount = targetRange.endRow - targetRange.startRow + 1; 108 + if (fillCount <= 0) return; 109 + 110 + ydoc.transact(() => { 111 + for (let c = sourceRange.startCol; c <= sourceRange.endCol; c++) { 112 + const sourceValues: any[] = []; 113 + for (let r = sourceRange.startRow; r <= sourceRange.endRow; r++) { 114 + const id = cellId(c, r); 115 + const cellData = getCellData(id); 116 + if (cellData?.f) { 117 + sourceValues.push({ f: cellData.f, v: cellData.v }); 118 + } else if (cellData?.v !== undefined && cellData?.v !== '') { 119 + sourceValues.push(cellData.v); 120 + } else { 121 + sourceValues.push(''); 122 + } 123 + } 124 + 125 + const pattern = detectPattern(sourceValues); 126 + const fillValues = generateFillValues(sourceValues, pattern, fillCount, direction); 127 + 128 + for (let i = 0; i < fillCount; i++) { 129 + const targetRow = targetRange.startRow + i; 130 + const id = cellId(c, targetRow); 131 + 132 + if (pattern.type === PATTERN_TYPES.FORMULA_ADJUST) { 133 + const sourceIdx = i % (sourceRange.endRow - sourceRange.startRow + 1); 134 + const sourceRow = sourceRange.startRow + sourceIdx; 135 + const sourceId = cellId(c, sourceRow); 136 + const sourceCellData = getCellData(sourceId); 137 + if (sourceCellData?.f) { 138 + const dRow = targetRow - sourceRow; 139 + const newFormula = adjustFormulaRef(sourceCellData.f, 0, dRow); 140 + setCellData(id, { f: newFormula, v: '' }); 141 + } 142 + } else { 143 + const val = fillValues[i]; 144 + setCellData(id, { v: val, f: '' }); 145 + } 146 + } 147 + } 148 + }); 149 + 150 + evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 151 + refreshVisibleCells(); 152 + 153 + const newEndRow = Math.max(sourceRange.endRow, targetRange.endRow); 154 + const newStartRow = Math.min(sourceRange.startRow, targetRange.startRow); 155 + setSelectionRange({ startCol: sourceRange.startCol, startRow: newStartRow, endCol: sourceRange.endCol, endRow: newEndRow }); 156 + updateSelectionVisuals(); 157 + updateFormulaBar(); 158 + showToast(`Filled ${fillCount} cell${fillCount > 1 ? 's' : ''}`); 159 + }
+441
src/sheets/formula-parser.ts
··· 1 + /** 2 + * Formula Parser — recursive descent parser for spreadsheet formulas. 3 + * 4 + * Extracted from formulas.ts for decomposition. 5 + */ 6 + 7 + import type { CellValue, CrossSheetResolver, NamedRangesMap, RangeArray } from './types.js'; 8 + import { 9 + toNum, coerceForComparison, 10 + parseRef, colToLetter, 11 + } from './formula-helpers.js'; 12 + import type { Token, TokenTypeValue } from './formula-tokenizer.js'; 13 + import { TokenType } from './formula-tokenizer.js'; 14 + 15 + export class Parser { 16 + tokens: Token[]; 17 + pos: number; 18 + getCellValue: (ref: string) => CellValue | ''; 19 + crossSheetResolver: CrossSheetResolver | null; 20 + namedRanges: NamedRangesMap; 21 + _letScope: Record<string, unknown> | null; 22 + _callFunction: (name: string, args: unknown[]) => unknown; 23 + 24 + constructor( 25 + tokens: Token[], 26 + getCellValue: (ref: string) => CellValue | '', 27 + crossSheetResolver: CrossSheetResolver | null | undefined, 28 + namedRanges: NamedRangesMap | null | undefined, 29 + callFunction: (name: string, args: unknown[]) => unknown, 30 + ) { 31 + this.tokens = tokens; 32 + this.pos = 0; 33 + this.getCellValue = getCellValue; 34 + this.crossSheetResolver = crossSheetResolver || null; 35 + this.namedRanges = namedRanges || {}; 36 + this._letScope = null; 37 + this._callFunction = callFunction; 38 + } 39 + 40 + peek(): Token { return this.tokens[this.pos]; } 41 + advance(): Token { return this.tokens[this.pos++]; } 42 + 43 + expect(type: TokenTypeValue): Token { 44 + const t = this.advance(); 45 + if (t.type !== type) throw new Error(`Expected ${type}, got ${t.type}`); 46 + return t; 47 + } 48 + 49 + parse(): unknown { return this.expression(); } 50 + 51 + expression(): unknown { return this.comparison(); } 52 + 53 + comparison(): unknown { 54 + let left = this.concat(); 55 + const t = this.peek(); 56 + if (t.type === TokenType.OPERATOR && ['=', '<>', '<', '>', '<=', '>='].includes(t.value as string)) { 57 + this.advance(); 58 + const right = this.concat(); 59 + const [cl, cr] = coerceForComparison(left, right); 60 + switch (t.value) { 61 + case '=': return cl === cr; 62 + case '<>': return cl !== cr; 63 + case '<': return cl < cr; 64 + case '>': return cl > cr; 65 + case '<=': return cl <= cr; 66 + case '>=': return cl >= cr; 67 + } 68 + } 69 + return left; 70 + } 71 + 72 + concat(): unknown { 73 + let left = this.addition(); 74 + while (this.peek().type === TokenType.OPERATOR && this.peek().value === '&') { 75 + this.advance(); 76 + const right = this.addition(); 77 + left = String(left) + String(right); 78 + } 79 + return left; 80 + } 81 + 82 + addition(): unknown { 83 + let left = this.multiplication(); 84 + while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '+' || this.peek().value === '-')) { 85 + const op = this.advance().value; 86 + const right = this.multiplication(); 87 + left = op === '+' ? toNum(left) + toNum(right) : toNum(left) - toNum(right); 88 + } 89 + return left; 90 + } 91 + 92 + multiplication(): unknown { 93 + let left = this.power(); 94 + while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '*' || this.peek().value === '/')) { 95 + const op = this.advance().value; 96 + const right = this.power(); 97 + if (op === '*') { 98 + left = toNum(left) * toNum(right); 99 + } else { 100 + const divisor = toNum(right); 101 + left = divisor === 0 ? '#DIV/0!' : toNum(left) / divisor; 102 + } 103 + } 104 + return left; 105 + } 106 + 107 + power(): unknown { 108 + const left = this.unary(); 109 + if (this.peek().type === TokenType.OPERATOR && this.peek().value === '^') { 110 + this.advance(); 111 + const right = this.power(); 112 + return Math.pow(toNum(left), toNum(right)); 113 + } 114 + return left; 115 + } 116 + 117 + unary(): unknown { 118 + if (this.peek().type === TokenType.OPERATOR && (this.peek().value === '-' || this.peek().value === '+')) { 119 + const op = this.advance().value; 120 + const val = this.unary(); 121 + return op === '-' ? -toNum(val) : toNum(val); 122 + } 123 + return this.primary(); 124 + } 125 + 126 + primary(): unknown { 127 + const t = this.peek(); 128 + 129 + if (t.type === TokenType.NUMBER) { this.advance(); return t.value; } 130 + if (t.type === TokenType.STRING) { this.advance(); return t.value; } 131 + if (t.type === TokenType.BOOLEAN) { this.advance(); return t.value; } 132 + 133 + if (t.type === TokenType.CROSS_SHEET_REF) { 134 + this.advance(); 135 + const { sheetName, ref } = t.value as { sheetName: string; ref: string }; 136 + if (ref.includes(':')) return this.resolveCrossSheetRange(sheetName, ref); 137 + return this.resolveCrossSheetCell(sheetName, ref); 138 + } 139 + 140 + if (t.type === TokenType.CELL_REF) { 141 + this.advance(); 142 + if (this.peek().type === TokenType.COLON) { 143 + this.advance(); 144 + const end = this.expect(TokenType.CELL_REF); 145 + return this.resolveRange(t.value as string, end.value as string); 146 + } 147 + return this.getCellValue(t.value as string); 148 + } 149 + 150 + if (t.type === TokenType.IDENTIFIER) { 151 + this.advance(); 152 + const identLower = (t.value as string).toLowerCase(); 153 + if (this._letScope && identLower in this._letScope) return this._letScope[identLower]; 154 + return this.resolveNamedRange(t.value as string); 155 + } 156 + 157 + if (t.type === TokenType.FUNCTION) { 158 + this.advance(); 159 + if (t.value === 'LET') return this.parseLet(); 160 + if (t.value === 'LAMBDA') return this.parseLambda(); 161 + if (t.value === 'INDIRECT') return this.parseIndirect(); 162 + if (t.value === 'ROW' || t.value === 'COLUMN') return this.parseRowColumn(t.value as 'ROW' | 'COLUMN'); 163 + this.expect(TokenType.LPAREN); 164 + const args: unknown[] = []; 165 + if (this.peek().type !== TokenType.RPAREN) { 166 + if (this.peek().type === TokenType.COMMA) { args.push(undefined); } 167 + else { args.push(this.parseFunctionArg()); } 168 + while (this.peek().type === TokenType.COMMA) { 169 + this.advance(); 170 + if (this.peek().type === TokenType.COMMA || this.peek().type === TokenType.RPAREN) { args.push(undefined); } 171 + else { args.push(this.parseFunctionArg()); } 172 + } 173 + } 174 + this.expect(TokenType.RPAREN); 175 + return this._callFunction(t.value as string, args); 176 + } 177 + 178 + if (t.type === TokenType.LPAREN) { 179 + this.advance(); 180 + const val = this.expression(); 181 + this.expect(TokenType.RPAREN); 182 + return val; 183 + } 184 + 185 + throw new Error(`Unexpected token: ${t.type}`); 186 + } 187 + 188 + parseFunctionArg(): unknown { 189 + if (this.peek().type === TokenType.CROSS_SHEET_REF) { 190 + const saved = this.pos; 191 + const t = this.advance(); 192 + const { sheetName, ref } = t.value as { sheetName: string; ref: string }; 193 + if (ref.includes(':')) return this.resolveCrossSheetRange(sheetName, ref); 194 + this.pos = saved; 195 + return this.expression(); 196 + } 197 + if (this.peek().type === TokenType.CELL_REF) { 198 + const saved = this.pos; 199 + const start = this.advance(); 200 + if (this.peek().type === TokenType.COLON) { 201 + this.advance(); 202 + if (this.peek().type === TokenType.CELL_REF) { 203 + const end = this.advance(); 204 + return this.resolveRange(start.value as string, end.value as string); 205 + } 206 + } 207 + this.pos = saved; 208 + } 209 + if (this.peek().type === TokenType.IDENTIFIER) { 210 + const saved = this.pos; 211 + const t = this.advance(); 212 + const resolved = this.resolveNamedRange(t.value as string); 213 + if (Array.isArray(resolved)) return resolved; 214 + this.pos = saved; 215 + } 216 + return this.expression(); 217 + } 218 + 219 + parseLet(): unknown { 220 + this.expect(TokenType.LPAREN); 221 + const prevScope = this._letScope ? { ...this._letScope } : null; 222 + if (!this._letScope) this._letScope = {}; 223 + 224 + while (true) { 225 + const nameToken = this.peek(); 226 + let varName: string; 227 + if (nameToken.type === TokenType.IDENTIFIER || nameToken.type === TokenType.FUNCTION) { 228 + this.advance(); 229 + varName = (nameToken.value as string).toLowerCase(); 230 + } else if (nameToken.type === TokenType.CELL_REF) { 231 + this.advance(); 232 + varName = (nameToken.value as string).toLowerCase(); 233 + } else { 234 + throw new Error('LET: expected variable name'); 235 + } 236 + 237 + this.expect(TokenType.COMMA); 238 + const val = this.parseFunctionArg(); 239 + this._letScope[varName] = val; 240 + 241 + if (this.peek().type === TokenType.COMMA) { 242 + this.advance(); 243 + const nextToken = this.peek(); 244 + if ((nextToken.type === TokenType.IDENTIFIER || nextToken.type === TokenType.FUNCTION) && 245 + this.tokens[this.pos + 1]?.type === TokenType.COMMA) { 246 + continue; 247 + } 248 + const result = this.parseFunctionArg(); 249 + this.expect(TokenType.RPAREN); 250 + this._letScope = prevScope; 251 + return result; 252 + } else if (this.peek().type === TokenType.RPAREN) { 253 + this.advance(); 254 + this._letScope = prevScope; 255 + return val; 256 + } 257 + } 258 + } 259 + 260 + parseLambda(): unknown { 261 + this.expect(TokenType.LPAREN); 262 + const paramNames: string[] = []; 263 + let depth = 1; 264 + 265 + let scanPos = this.pos; 266 + const argBoundaries: number[] = [scanPos]; 267 + while (scanPos < this.tokens.length) { 268 + const tok = this.tokens[scanPos]; 269 + if (tok.type === TokenType.LPAREN) depth++; 270 + else if (tok.type === TokenType.RPAREN) { 271 + depth--; 272 + if (depth === 0) break; 273 + } else if (tok.type === TokenType.COMMA && depth === 1) { 274 + argBoundaries.push(scanPos + 1); 275 + } 276 + scanPos++; 277 + } 278 + const rparenPos = scanPos; 279 + 280 + for (let i = 0; i < argBoundaries.length - 1; i++) { 281 + const tok = this.tokens[argBoundaries[i]]; 282 + if (tok.type === TokenType.IDENTIFIER || tok.type === TokenType.FUNCTION || tok.type === TokenType.CELL_REF) { 283 + paramNames.push(String(tok.value).toLowerCase()); 284 + } 285 + } 286 + 287 + this.pos = argBoundaries[argBoundaries.length - 1]; 288 + const bodyTokens = this.tokens.slice(this.pos, rparenPos); 289 + 290 + this.pos = rparenPos; 291 + this.expect(TokenType.RPAREN); 292 + 293 + if (this.peek().type === TokenType.LPAREN) { 294 + this.advance(); 295 + const callArgs: unknown[] = []; 296 + if (this.peek().type !== TokenType.RPAREN) { 297 + callArgs.push(this.parseFunctionArg()); 298 + while (this.peek().type === TokenType.COMMA) { 299 + this.advance(); 300 + callArgs.push(this.parseFunctionArg()); 301 + } 302 + } 303 + this.expect(TokenType.RPAREN); 304 + 305 + const prevScope = this._letScope ? { ...this._letScope } : {}; 306 + this._letScope = this._letScope || {}; 307 + for (let i = 0; i < paramNames.length; i++) { 308 + this._letScope[paramNames[i]] = callArgs[i] ?? 0; 309 + } 310 + 311 + const bodyParser = new Parser( 312 + [...bodyTokens, { type: TokenType.EOF, value: undefined }], 313 + this.getCellValue, 314 + this.crossSheetResolver, 315 + this.namedRanges, 316 + this._callFunction, 317 + ); 318 + bodyParser._letScope = { ...this._letScope }; 319 + const result = bodyParser.parse(); 320 + 321 + this._letScope = prevScope; 322 + return result; 323 + } 324 + 325 + return '#VALUE!'; 326 + } 327 + 328 + parseIndirect(): unknown { 329 + this.expect(TokenType.LPAREN); 330 + const refText = String(this.expression()); 331 + this.expect(TokenType.RPAREN); 332 + 333 + if (!refText) return '#REF!'; 334 + 335 + const bangIdx = refText.indexOf('!'); 336 + if (bangIdx !== -1) { 337 + let sheetName: string; 338 + let cellRefStr: string; 339 + 340 + if (refText.startsWith("'")) { 341 + const closeQuote = refText.indexOf("'", 1); 342 + if (closeQuote === -1 || refText[closeQuote + 1] !== '!') return '#REF!'; 343 + sheetName = refText.slice(1, closeQuote); 344 + cellRefStr = refText.slice(closeQuote + 2).toUpperCase(); 345 + } else { 346 + sheetName = refText.slice(0, bangIdx); 347 + cellRefStr = refText.slice(bangIdx + 1).toUpperCase(); 348 + } 349 + 350 + const parsed = parseRef(cellRefStr); 351 + if (!parsed) return '#REF!'; 352 + if (!this.crossSheetResolver) return '#REF!'; 353 + if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 354 + return this.crossSheetResolver.getSheetCellValue(sheetName, cellRefStr); 355 + } 356 + 357 + const cleaned = refText.toUpperCase().replace(/\$/g, ''); 358 + const parsed = parseRef(cleaned); 359 + if (!parsed) return '#REF!'; 360 + return this.getCellValue(cleaned); 361 + } 362 + 363 + parseRowColumn(fn: 'ROW' | 'COLUMN'): unknown { 364 + this.expect(TokenType.LPAREN); 365 + const t = this.peek(); 366 + if (t.type === TokenType.CELL_REF) { 367 + this.advance(); 368 + this.expect(TokenType.RPAREN); 369 + const ref = parseRef(t.value as string); 370 + if (!ref) return '#REF!'; 371 + return fn === 'ROW' ? ref.row : ref.col; 372 + } 373 + const val = this.expression(); 374 + this.expect(TokenType.RPAREN); 375 + const refStr = String(val).toUpperCase().replace(/\$/g, ''); 376 + const ref = parseRef(refStr); 377 + if (!ref) return '#REF!'; 378 + return fn === 'ROW' ? ref.row : ref.col; 379 + } 380 + 381 + resolveRange(startRef: string, endRef: string): RangeArray { 382 + const start = parseRef(startRef); 383 + const end = parseRef(endRef); 384 + if (!start || !end) return ['#REF!'] as unknown as RangeArray; 385 + const values: RangeArray = []; 386 + const rowMin = Math.min(start.row, end.row); 387 + const rowMax = Math.max(start.row, end.row); 388 + const colMin = Math.min(start.col, end.col); 389 + const colMax = Math.max(start.col, end.col); 390 + if ((rowMax - rowMin + 1) * (colMax - colMin + 1) > 10000) return ['#VALUE!'] as unknown as RangeArray; 391 + for (let r = rowMin; r <= rowMax; r++) { 392 + for (let c = colMin; c <= colMax; c++) { 393 + const ref = colToLetter(c) + r; 394 + values.push(this.getCellValue(ref)); 395 + } 396 + } 397 + values._rangeRows = rowMax - rowMin + 1; 398 + values._rangeCols = colMax - colMin + 1; 399 + return values; 400 + } 401 + 402 + resolveCrossSheetCell(sheetName: string, cellRef: string): unknown { 403 + if (!this.crossSheetResolver) return '#REF!'; 404 + if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 405 + return this.crossSheetResolver.getSheetCellValue(sheetName, cellRef); 406 + } 407 + 408 + resolveCrossSheetRange(sheetName: string, rangeStr: string): RangeArray | string { 409 + if (!this.crossSheetResolver) return '#REF!'; 410 + if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 411 + const parts = rangeStr.split(':'); 412 + if (parts.length !== 2) return '#REF!'; 413 + const start = parseRef(parts[0]); 414 + const end = parseRef(parts[1]); 415 + if (!start || !end) return '#REF!'; 416 + const values: RangeArray = []; 417 + const rowMin = Math.min(start.row, end.row); 418 + const rowMax = Math.max(start.row, end.row); 419 + const colMin = Math.min(start.col, end.col); 420 + const colMax = Math.max(start.col, end.col); 421 + for (let r = rowMin; r <= rowMax; r++) { 422 + for (let c = colMin; c <= colMax; c++) { 423 + const ref = colToLetter(c) + r; 424 + values.push(this.crossSheetResolver.getSheetCellValue(sheetName, ref)); 425 + } 426 + } 427 + values._rangeRows = rowMax - rowMin + 1; 428 + values._rangeCols = colMax - colMin + 1; 429 + return values; 430 + } 431 + 432 + resolveNamedRange(name: string): unknown { 433 + const key = name.toLowerCase(); 434 + const entry = this.namedRanges[key]; 435 + if (!entry) return `#NAME? (${name})`; 436 + const rangeStr = entry.range; 437 + const parts = rangeStr.split(':'); 438 + if (parts.length === 2) return this.resolveRange(parts[0], parts[1]); 439 + return this.getCellValue(rangeStr); 440 + } 441 + }
+189
src/sheets/formula-tokenizer.ts
··· 1 + /** 2 + * Formula Tokenizer — lexical analysis for spreadsheet formulas. 3 + * 4 + * Extracted from formulas.ts for decomposition. 5 + */ 6 + 7 + // --- Token types --- 8 + 9 + export type TokenTypeValue = 'NUMBER' | 'STRING' | 'BOOLEAN' | 'CELL_REF' | 'CROSS_SHEET_REF' | 'RANGE' | 'FUNCTION' | 'IDENTIFIER' | 'OPERATOR' | 'LPAREN' | 'RPAREN' | 'COMMA' | 'COLON' | 'BANG' | 'EOF'; 10 + 11 + export interface CrossSheetTokenValue { 12 + sheetName: string; 13 + ref: string; 14 + } 15 + 16 + export interface Token { 17 + type: TokenTypeValue; 18 + value?: string | number | boolean | CrossSheetTokenValue; 19 + } 20 + 21 + export const TokenType: Record<string, TokenTypeValue> = { 22 + NUMBER: 'NUMBER', 23 + STRING: 'STRING', 24 + BOOLEAN: 'BOOLEAN', 25 + CELL_REF: 'CELL_REF', 26 + CROSS_SHEET_REF: 'CROSS_SHEET_REF', 27 + RANGE: 'RANGE', 28 + FUNCTION: 'FUNCTION', 29 + IDENTIFIER: 'IDENTIFIER', 30 + OPERATOR: 'OPERATOR', 31 + LPAREN: 'LPAREN', 32 + RPAREN: 'RPAREN', 33 + COMMA: 'COMMA', 34 + COLON: 'COLON', 35 + BANG: 'BANG', 36 + EOF: 'EOF', 37 + }; 38 + 39 + // --- Tokenizer --- 40 + 41 + export function tokenize(formula: string): Token[] { 42 + const tokens: Token[] = []; 43 + let i = 0; 44 + const s = formula; 45 + 46 + while (i < s.length) { 47 + // Whitespace 48 + if (s[i] === ' ' || s[i] === '\t') { i++; continue; } 49 + 50 + // Quoted sheet name: 'Sheet Name'!A1 51 + if (s[i] === "'") { 52 + let sheetName = ''; 53 + i++; // skip opening quote 54 + while (i < s.length && s[i] !== "'") { 55 + sheetName += s[i++]; 56 + } 57 + i++; // skip closing quote 58 + if (i < s.length && s[i] === '!') { 59 + i++; // skip ! 60 + let cellRef = ''; 61 + while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 62 + cellRef += s[i++]; 63 + } 64 + tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName, ref: cellRef.toUpperCase().replace(/\$/g, '') } }); 65 + } 66 + continue; 67 + } 68 + 69 + // String literal (supports both "" Excel-style and \ backslash escaping) 70 + if (s[i] === '"') { 71 + let str = ''; 72 + i++; // skip opening quote 73 + while (i < s.length) { 74 + if (s[i] === '"') { 75 + if (i + 1 < s.length && s[i + 1] === '"') { str += '"'; i += 2; continue; } 76 + break; 77 + } 78 + if (s[i] === '\\' && i + 1 < s.length) { str += s[++i]; i++; continue; } 79 + str += s[i]; 80 + i++; 81 + } 82 + i++; // skip closing quote 83 + tokens.push({ type: TokenType.STRING, value: str }); 84 + continue; 85 + } 86 + 87 + // Number 88 + if (/[0-9.]/.test(s[i])) { 89 + let num = ''; 90 + let hasDot = false; 91 + let hasE = false; 92 + while (i < s.length && /[0-9.eE+-]/.test(s[i])) { 93 + if ((s[i] === '+' || s[i] === '-') && num.length > 0 && !/[eE]/.test(num[num.length - 1])) break; 94 + if (s[i] === '.' && hasDot) break; 95 + if (s[i] === '.') hasDot = true; 96 + if ((s[i] === 'e' || s[i] === 'E') && hasE) break; 97 + if (s[i] === 'e' || s[i] === 'E') hasE = true; 98 + num += s[i++]; 99 + } 100 + tokens.push({ type: TokenType.NUMBER, value: parseFloat(num) }); 101 + continue; 102 + } 103 + 104 + // Cell ref, function, boolean, identifier, or cross-sheet ref (Name!A1) 105 + if (/[A-Za-z$_]/.test(s[i])) { 106 + let word = ''; 107 + while (i < s.length && /[A-Za-z0-9$_.]/.test(s[i])) { 108 + word += s[i++]; 109 + } 110 + 111 + const upper = word.toUpperCase(); 112 + 113 + if (upper === 'TRUE') { tokens.push({ type: TokenType.BOOLEAN, value: true }); continue; } 114 + if (upper === 'FALSE') { tokens.push({ type: TokenType.BOOLEAN, value: false }); continue; } 115 + 116 + // Cross-sheet ref: word!cellRef 117 + if (i < s.length && s[i] === '!') { 118 + i++; 119 + let cellRef = ''; 120 + while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 121 + cellRef += s[i++]; 122 + } 123 + tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName: word, ref: cellRef.toUpperCase().replace(/\$/g, '') } }); 124 + continue; 125 + } 126 + 127 + // Check if next non-space char is '(' -> function 128 + let peek = i; 129 + while (peek < s.length && s[peek] === ' ') peek++; 130 + if (peek < s.length && s[peek] === '(') { 131 + tokens.push({ type: TokenType.FUNCTION, value: upper }); 132 + continue; 133 + } 134 + 135 + // Cell reference (e.g., A1, $B$2, AA100) 136 + const cellRefPattern = /^\$?[A-Z]{1,3}\$?[0-9]+$/i; 137 + if (cellRefPattern.test(word.replace(/\$/g, ''))) { 138 + tokens.push({ type: TokenType.CELL_REF, value: upper.replace(/\$/g, '') }); 139 + continue; 140 + } 141 + 142 + // Unknown identifier — could be a named range 143 + tokens.push({ type: TokenType.IDENTIFIER, value: word }); 144 + continue; 145 + } 146 + 147 + // Operators 148 + if (s[i] === '+' || s[i] === '-' || s[i] === '*' || s[i] === '/' || s[i] === '^' || s[i] === '&') { 149 + tokens.push({ type: TokenType.OPERATOR, value: s[i++] }); 150 + continue; 151 + } 152 + 153 + // Comparison operators 154 + if (s[i] === '<') { 155 + if (s[i + 1] === '=') { tokens.push({ type: TokenType.OPERATOR, value: '<=' }); i += 2; } 156 + else if (s[i + 1] === '>') { tokens.push({ type: TokenType.OPERATOR, value: '<>' }); i += 2; } 157 + else { tokens.push({ type: TokenType.OPERATOR, value: '<' }); i++; } 158 + continue; 159 + } 160 + if (s[i] === '>') { 161 + if (s[i + 1] === '=') { tokens.push({ type: TokenType.OPERATOR, value: '>=' }); i += 2; } 162 + else { tokens.push({ type: TokenType.OPERATOR, value: '>' }); i++; } 163 + continue; 164 + } 165 + if (s[i] === '=') { tokens.push({ type: TokenType.OPERATOR, value: '=' }); i++; continue; } 166 + 167 + if (s[i] === '(') { tokens.push({ type: TokenType.LPAREN }); i++; continue; } 168 + if (s[i] === ')') { tokens.push({ type: TokenType.RPAREN }); i++; continue; } 169 + if (s[i] === ',') { tokens.push({ type: TokenType.COMMA }); i++; continue; } 170 + if (s[i] === ':') { tokens.push({ type: TokenType.COLON }); i++; continue; } 171 + if (s[i] === '!') { tokens.push({ type: TokenType.BANG }); i++; continue; } 172 + 173 + // Error literals: #REF!, #VALUE!, #N/A, #DIV/0!, #ERROR!, #NUM!, #NAME? 174 + if (s[i] === '#') { 175 + let end = i + 1; 176 + while (end < s.length && /[A-Za-z0-9/]/.test(s[end])) end++; 177 + if (end < s.length && (s[end] === '!' || s[end] === '?')) end++; 178 + const errStr = s.slice(i, end); 179 + tokens.push({ type: TokenType.STRING, value: errStr }); 180 + i = end; 181 + continue; 182 + } 183 + 184 + throw new Error(`Unknown character: ${s[i]}`); 185 + } 186 + 187 + tokens.push({ type: TokenType.EOF }); 188 + return tokens; 189 + }
+3 -602
src/sheets/formulas.ts
··· 29 29 import { callLogicalFunction } from './formula-logical.js'; 30 30 import { callFinancialFunction } from './formula-financial.js'; 31 31 import { callArrayFunction } from './formula-array.js'; 32 + import { tokenize, TokenType } from './formula-tokenizer.js'; 33 + import { Parser } from './formula-parser.js'; 32 34 33 35 // Re-export cell reference utilities (many files import these from formulas.js) 34 36 export const parseRef = _parseRef; ··· 36 38 export const letterToCol = _letterToCol; 37 39 export const cellId = _cellId; 38 40 39 - // --- Tokenizer --- 40 - type TokenTypeValue = 'NUMBER' | 'STRING' | 'BOOLEAN' | 'CELL_REF' | 'CROSS_SHEET_REF' | 'RANGE' | 'FUNCTION' | 'IDENTIFIER' | 'OPERATOR' | 'LPAREN' | 'RPAREN' | 'COMMA' | 'COLON' | 'BANG' | 'EOF'; 41 - 42 - interface CrossSheetTokenValue { 43 - sheetName: string; 44 - ref: string; 45 - } 46 - 47 - interface Token { 48 - type: TokenTypeValue; 49 - value?: string | number | boolean | CrossSheetTokenValue; 50 - } 51 - 52 - const TokenType = { 53 - NUMBER: 'NUMBER', 54 - STRING: 'STRING', 55 - BOOLEAN: 'BOOLEAN', 56 - CELL_REF: 'CELL_REF', 57 - CROSS_SHEET_REF: 'CROSS_SHEET_REF', 58 - RANGE: 'RANGE', 59 - FUNCTION: 'FUNCTION', 60 - IDENTIFIER: 'IDENTIFIER', 61 - OPERATOR: 'OPERATOR', 62 - LPAREN: 'LPAREN', 63 - RPAREN: 'RPAREN', 64 - COMMA: 'COMMA', 65 - COLON: 'COLON', 66 - BANG: 'BANG', 67 - EOF: 'EOF', 68 - }; 69 - 70 - function tokenize(formula: string): Token[] { 71 - const tokens: Token[] = []; 72 - let i = 0; 73 - const s = formula; 74 - 75 - while (i < s.length) { 76 - // Whitespace 77 - if (s[i] === ' ' || s[i] === '\t') { i++; continue; } 78 - 79 - // Quoted sheet name: 'Sheet Name'!A1 80 - if (s[i] === "'") { 81 - let sheetName = ''; 82 - i++; // skip opening quote 83 - while (i < s.length && s[i] !== "'") { 84 - sheetName += s[i++]; 85 - } 86 - i++; // skip closing quote 87 - if (i < s.length && s[i] === '!') { 88 - i++; // skip ! 89 - let cellRef = ''; 90 - while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 91 - cellRef += s[i++]; 92 - } 93 - tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName, ref: cellRef.toUpperCase().replace(/\$/g, '') } }); 94 - } 95 - continue; 96 - } 97 - 98 - // String literal (supports both "" Excel-style and \ backslash escaping) 99 - if (s[i] === '"') { 100 - let str = ''; 101 - i++; // skip opening quote 102 - while (i < s.length) { 103 - if (s[i] === '"') { 104 - if (i + 1 < s.length && s[i + 1] === '"') { str += '"'; i += 2; continue; } 105 - break; 106 - } 107 - if (s[i] === '\\' && i + 1 < s.length) { str += s[++i]; i++; continue; } 108 - str += s[i]; 109 - i++; 110 - } 111 - i++; // skip closing quote 112 - tokens.push({ type: TokenType.STRING, value: str }); 113 - continue; 114 - } 115 - 116 - // Number 117 - if (/[0-9.]/.test(s[i])) { 118 - let num = ''; 119 - let hasDot = false; 120 - let hasE = false; 121 - while (i < s.length && /[0-9.eE+-]/.test(s[i])) { 122 - if ((s[i] === '+' || s[i] === '-') && num.length > 0 && !/[eE]/.test(num[num.length - 1])) break; 123 - if (s[i] === '.' && hasDot) break; 124 - if (s[i] === '.') hasDot = true; 125 - if ((s[i] === 'e' || s[i] === 'E') && hasE) break; 126 - if (s[i] === 'e' || s[i] === 'E') hasE = true; 127 - num += s[i++]; 128 - } 129 - tokens.push({ type: TokenType.NUMBER, value: parseFloat(num) }); 130 - continue; 131 - } 132 - 133 - // Cell ref, function, boolean, identifier, or cross-sheet ref (Name!A1) 134 - if (/[A-Za-z$_]/.test(s[i])) { 135 - let word = ''; 136 - while (i < s.length && /[A-Za-z0-9$_.]/.test(s[i])) { 137 - word += s[i++]; 138 - } 139 - 140 - const upper = word.toUpperCase(); 141 - 142 - if (upper === 'TRUE') { tokens.push({ type: TokenType.BOOLEAN, value: true }); continue; } 143 - if (upper === 'FALSE') { tokens.push({ type: TokenType.BOOLEAN, value: false }); continue; } 144 - 145 - // Cross-sheet ref: word!cellRef 146 - if (i < s.length && s[i] === '!') { 147 - i++; 148 - let cellRef = ''; 149 - while (i < s.length && /[A-Za-z0-9$:]/.test(s[i])) { 150 - cellRef += s[i++]; 151 - } 152 - tokens.push({ type: TokenType.CROSS_SHEET_REF, value: { sheetName: word, ref: cellRef.toUpperCase().replace(/\$/g, '') } }); 153 - continue; 154 - } 155 - 156 - // Check if next non-space char is '(' -> function 157 - let peek = i; 158 - while (peek < s.length && s[peek] === ' ') peek++; 159 - if (peek < s.length && s[peek] === '(') { 160 - tokens.push({ type: TokenType.FUNCTION, value: upper }); 161 - continue; 162 - } 163 - 164 - // Cell reference (e.g., A1, $B$2, AA100) 165 - const cellRefPattern = /^\$?[A-Z]{1,3}\$?[0-9]+$/i; 166 - if (cellRefPattern.test(word.replace(/\$/g, ''))) { 167 - tokens.push({ type: TokenType.CELL_REF, value: upper.replace(/\$/g, '') }); 168 - continue; 169 - } 170 - 171 - // Unknown identifier — could be a named range 172 - tokens.push({ type: TokenType.IDENTIFIER, value: word }); 173 - continue; 174 - } 175 - 176 - // Operators 177 - if (s[i] === '+' || s[i] === '-' || s[i] === '*' || s[i] === '/' || s[i] === '^' || s[i] === '&') { 178 - tokens.push({ type: TokenType.OPERATOR, value: s[i++] }); 179 - continue; 180 - } 181 - 182 - // Comparison operators 183 - if (s[i] === '<') { 184 - if (s[i + 1] === '=') { tokens.push({ type: TokenType.OPERATOR, value: '<=' }); i += 2; } 185 - else if (s[i + 1] === '>') { tokens.push({ type: TokenType.OPERATOR, value: '<>' }); i += 2; } 186 - else { tokens.push({ type: TokenType.OPERATOR, value: '<' }); i++; } 187 - continue; 188 - } 189 - if (s[i] === '>') { 190 - if (s[i + 1] === '=') { tokens.push({ type: TokenType.OPERATOR, value: '>=' }); i += 2; } 191 - else { tokens.push({ type: TokenType.OPERATOR, value: '>' }); i++; } 192 - continue; 193 - } 194 - if (s[i] === '=') { tokens.push({ type: TokenType.OPERATOR, value: '=' }); i++; continue; } 195 - 196 - if (s[i] === '(') { tokens.push({ type: TokenType.LPAREN }); i++; continue; } 197 - if (s[i] === ')') { tokens.push({ type: TokenType.RPAREN }); i++; continue; } 198 - if (s[i] === ',') { tokens.push({ type: TokenType.COMMA }); i++; continue; } 199 - if (s[i] === ':') { tokens.push({ type: TokenType.COLON }); i++; continue; } 200 - if (s[i] === '!') { tokens.push({ type: TokenType.BANG }); i++; continue; } 201 - 202 - // Error literals: #REF!, #VALUE!, #N/A, #DIV/0!, #ERROR!, #NUM!, #NAME? 203 - if (s[i] === '#') { 204 - let end = i + 1; 205 - while (end < s.length && /[A-Za-z0-9/]/.test(s[end])) end++; 206 - if (end < s.length && (s[end] === '!' || s[end] === '?')) end++; 207 - const errStr = s.slice(i, end); 208 - tokens.push({ type: TokenType.STRING, value: errStr }); 209 - i = end; 210 - continue; 211 - } 212 - 213 - throw new Error(`Unknown character: ${s[i]}`); 214 - } 215 - 216 - tokens.push({ type: TokenType.EOF }); 217 - return tokens; 218 - } 219 - 220 - // --- Parser (recursive descent) --- 221 - class Parser { 222 - tokens: Token[]; 223 - pos: number; 224 - getCellValue: (ref: string) => CellValue | ''; 225 - crossSheetResolver: CrossSheetResolver | null; 226 - namedRanges: NamedRangesMap; 227 - _letScope: Record<string, unknown> | null; 228 - 229 - constructor(tokens: Token[], getCellValue: (ref: string) => CellValue | '', crossSheetResolver: CrossSheetResolver | null | undefined, namedRanges: NamedRangesMap | null | undefined) { 230 - this.tokens = tokens; 231 - this.pos = 0; 232 - this.getCellValue = getCellValue; 233 - this.crossSheetResolver = crossSheetResolver || null; 234 - this.namedRanges = namedRanges || {}; 235 - this._letScope = null; 236 - } 237 - 238 - peek(): Token { return this.tokens[this.pos]; } 239 - advance(): Token { return this.tokens[this.pos++]; } 240 - 241 - expect(type: TokenTypeValue): Token { 242 - const t = this.advance(); 243 - if (t.type !== type) throw new Error(`Expected ${type}, got ${t.type}`); 244 - return t; 245 - } 246 - 247 - parse(): unknown { return this.expression(); } 248 - 249 - expression(): unknown { return this.comparison(); } 250 - 251 - comparison(): unknown { 252 - let left = this.concat(); 253 - const t = this.peek(); 254 - if (t.type === TokenType.OPERATOR && ['=', '<>', '<', '>', '<=', '>='].includes(t.value)) { 255 - this.advance(); 256 - const right = this.concat(); 257 - const [cl, cr] = coerceForComparison(left, right); 258 - switch (t.value) { 259 - case '=': return cl === cr; 260 - case '<>': return cl !== cr; 261 - case '<': return cl < cr; 262 - case '>': return cl > cr; 263 - case '<=': return cl <= cr; 264 - case '>=': return cl >= cr; 265 - } 266 - } 267 - return left; 268 - } 269 - 270 - concat(): unknown { 271 - let left = this.addition(); 272 - while (this.peek().type === TokenType.OPERATOR && this.peek().value === '&') { 273 - this.advance(); 274 - const right = this.addition(); 275 - left = String(left) + String(right); 276 - } 277 - return left; 278 - } 279 - 280 - addition(): unknown { 281 - let left = this.multiplication(); 282 - while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '+' || this.peek().value === '-')) { 283 - const op = this.advance().value; 284 - const right = this.multiplication(); 285 - left = op === '+' ? toNum(left) + toNum(right) : toNum(left) - toNum(right); 286 - } 287 - return left; 288 - } 289 - 290 - multiplication(): unknown { 291 - let left = this.power(); 292 - while (this.peek().type === TokenType.OPERATOR && (this.peek().value === '*' || this.peek().value === '/')) { 293 - const op = this.advance().value; 294 - const right = this.power(); 295 - if (op === '*') { 296 - left = toNum(left) * toNum(right); 297 - } else { 298 - const divisor = toNum(right); 299 - left = divisor === 0 ? '#DIV/0!' : toNum(left) / divisor; 300 - } 301 - } 302 - return left; 303 - } 304 - 305 - power(): unknown { 306 - const left = this.unary(); 307 - if (this.peek().type === TokenType.OPERATOR && this.peek().value === '^') { 308 - this.advance(); 309 - const right = this.power(); 310 - return Math.pow(toNum(left), toNum(right)); 311 - } 312 - return left; 313 - } 314 - 315 - unary(): unknown { 316 - if (this.peek().type === TokenType.OPERATOR && (this.peek().value === '-' || this.peek().value === '+')) { 317 - const op = this.advance().value; 318 - const val = this.unary(); 319 - return op === '-' ? -toNum(val) : toNum(val); 320 - } 321 - return this.primary(); 322 - } 323 - 324 - primary(): unknown { 325 - const t = this.peek(); 326 - 327 - if (t.type === TokenType.NUMBER) { this.advance(); return t.value; } 328 - if (t.type === TokenType.STRING) { this.advance(); return t.value; } 329 - if (t.type === TokenType.BOOLEAN) { this.advance(); return t.value; } 330 - 331 - if (t.type === TokenType.CROSS_SHEET_REF) { 332 - this.advance(); 333 - const { sheetName, ref } = t.value; 334 - if (ref.includes(':')) return this.resolveCrossSheetRange(sheetName, ref); 335 - return this.resolveCrossSheetCell(sheetName, ref); 336 - } 337 - 338 - if (t.type === TokenType.CELL_REF) { 339 - this.advance(); 340 - if (this.peek().type === TokenType.COLON) { 341 - this.advance(); 342 - const end = this.expect(TokenType.CELL_REF); 343 - return this.resolveRange(t.value, end.value); 344 - } 345 - return this.getCellValue(t.value); 346 - } 347 - 348 - if (t.type === TokenType.IDENTIFIER) { 349 - this.advance(); 350 - const identLower = t.value.toLowerCase(); 351 - if (this._letScope && identLower in this._letScope) return this._letScope[identLower]; 352 - return this.resolveNamedRange(t.value); 353 - } 354 - 355 - if (t.type === TokenType.FUNCTION) { 356 - this.advance(); 357 - if (t.value === 'LET') return this.parseLet(); 358 - if (t.value === 'LAMBDA') return this.parseLambda(); 359 - if (t.value === 'INDIRECT') return this.parseIndirect(); 360 - if (t.value === 'ROW' || t.value === 'COLUMN') return this.parseRowColumn(t.value as 'ROW' | 'COLUMN'); 361 - this.expect(TokenType.LPAREN); 362 - const args = []; 363 - if (this.peek().type !== TokenType.RPAREN) { 364 - if (this.peek().type === TokenType.COMMA) { args.push(undefined); } 365 - else { args.push(this.parseFunctionArg()); } 366 - while (this.peek().type === TokenType.COMMA) { 367 - this.advance(); 368 - if (this.peek().type === TokenType.COMMA || this.peek().type === TokenType.RPAREN) { args.push(undefined); } 369 - else { args.push(this.parseFunctionArg()); } 370 - } 371 - } 372 - this.expect(TokenType.RPAREN); 373 - return callFunction(t.value, args); 374 - } 375 - 376 - if (t.type === TokenType.LPAREN) { 377 - this.advance(); 378 - const val = this.expression(); 379 - this.expect(TokenType.RPAREN); 380 - return val; 381 - } 382 - 383 - throw new Error(`Unexpected token: ${t.type}`); 384 - } 385 - 386 - parseFunctionArg(): unknown { 387 - if (this.peek().type === TokenType.CROSS_SHEET_REF) { 388 - const saved = this.pos; 389 - const t = this.advance(); 390 - const { sheetName, ref } = t.value; 391 - if (ref.includes(':')) return this.resolveCrossSheetRange(sheetName, ref); 392 - this.pos = saved; 393 - return this.expression(); 394 - } 395 - if (this.peek().type === TokenType.CELL_REF) { 396 - const saved = this.pos; 397 - const start = this.advance(); 398 - if (this.peek().type === TokenType.COLON) { 399 - this.advance(); 400 - if (this.peek().type === TokenType.CELL_REF) { 401 - const end = this.advance(); 402 - return this.resolveRange(start.value, end.value); 403 - } 404 - } 405 - this.pos = saved; 406 - } 407 - if (this.peek().type === TokenType.IDENTIFIER) { 408 - const saved = this.pos; 409 - const t = this.advance(); 410 - const resolved = this.resolveNamedRange(t.value); 411 - if (Array.isArray(resolved)) return resolved; 412 - this.pos = saved; 413 - } 414 - return this.expression(); 415 - } 416 - 417 - parseLet(): unknown { 418 - this.expect(TokenType.LPAREN); 419 - const prevScope = this._letScope ? { ...this._letScope } : null; 420 - if (!this._letScope) this._letScope = {}; 421 - 422 - while (true) { 423 - const nameToken = this.peek(); 424 - let varName; 425 - if (nameToken.type === TokenType.IDENTIFIER || nameToken.type === TokenType.FUNCTION) { 426 - this.advance(); 427 - varName = nameToken.value.toLowerCase(); 428 - } else if (nameToken.type === TokenType.CELL_REF) { 429 - this.advance(); 430 - varName = nameToken.value.toLowerCase(); 431 - } else { 432 - throw new Error('LET: expected variable name'); 433 - } 434 - 435 - this.expect(TokenType.COMMA); 436 - const val = this.parseFunctionArg(); 437 - this._letScope[varName] = val; 438 - 439 - if (this.peek().type === TokenType.COMMA) { 440 - this.advance(); 441 - const nextToken = this.peek(); 442 - if ((nextToken.type === TokenType.IDENTIFIER || nextToken.type === TokenType.FUNCTION) && 443 - this.tokens[this.pos + 1]?.type === TokenType.COMMA) { 444 - continue; 445 - } 446 - const result = this.parseFunctionArg(); 447 - this.expect(TokenType.RPAREN); 448 - this._letScope = prevScope; 449 - return result; 450 - } else if (this.peek().type === TokenType.RPAREN) { 451 - this.advance(); 452 - this._letScope = prevScope; 453 - return val; 454 - } 455 - } 456 - } 457 - 458 - parseLambda(): unknown { 459 - this.expect(TokenType.LPAREN); 460 - const paramNames: string[] = []; 461 - let depth = 1; 462 - 463 - let scanPos = this.pos; 464 - const argBoundaries: number[] = [scanPos]; 465 - while (scanPos < this.tokens.length) { 466 - const tok = this.tokens[scanPos]; 467 - if (tok.type === TokenType.LPAREN) depth++; 468 - else if (tok.type === TokenType.RPAREN) { 469 - depth--; 470 - if (depth === 0) break; 471 - } else if (tok.type === TokenType.COMMA && depth === 1) { 472 - argBoundaries.push(scanPos + 1); 473 - } 474 - scanPos++; 475 - } 476 - const rparenPos = scanPos; 477 - 478 - for (let i = 0; i < argBoundaries.length - 1; i++) { 479 - const tok = this.tokens[argBoundaries[i]]; 480 - if (tok.type === TokenType.IDENTIFIER || tok.type === TokenType.FUNCTION || tok.type === TokenType.CELL_REF) { 481 - paramNames.push(String(tok.value).toLowerCase()); 482 - } 483 - } 484 - 485 - this.pos = argBoundaries[argBoundaries.length - 1]; 486 - const bodyTokens = this.tokens.slice(this.pos, rparenPos); 487 - 488 - this.pos = rparenPos; 489 - this.expect(TokenType.RPAREN); 490 - 491 - if (this.peek().type === TokenType.LPAREN) { 492 - this.advance(); 493 - const callArgs: unknown[] = []; 494 - if (this.peek().type !== TokenType.RPAREN) { 495 - callArgs.push(this.parseFunctionArg()); 496 - while (this.peek().type === TokenType.COMMA) { 497 - this.advance(); 498 - callArgs.push(this.parseFunctionArg()); 499 - } 500 - } 501 - this.expect(TokenType.RPAREN); 502 - 503 - const prevScope = this._letScope ? { ...this._letScope } : {}; 504 - this._letScope = this._letScope || {}; 505 - for (let i = 0; i < paramNames.length; i++) { 506 - this._letScope[paramNames[i]] = callArgs[i] ?? 0; 507 - } 508 - 509 - const bodyParser = new Parser( 510 - [...bodyTokens, { type: TokenType.EOF, value: undefined }], 511 - this.getCellValue, 512 - this.crossSheetResolver, 513 - this.namedRanges, 514 - ); 515 - bodyParser._letScope = { ...this._letScope }; 516 - const result = bodyParser.parse(); 517 - 518 - this._letScope = prevScope; 519 - return result; 520 - } 521 - 522 - return '#VALUE!'; 523 - } 524 - 525 - parseIndirect(): unknown { 526 - this.expect(TokenType.LPAREN); 527 - const refText = String(this.expression()); 528 - this.expect(TokenType.RPAREN); 529 - 530 - if (!refText) return '#REF!'; 531 - 532 - const bangIdx = refText.indexOf('!'); 533 - if (bangIdx !== -1) { 534 - let sheetName: string; 535 - let cellRefStr: string; 536 - 537 - if (refText.startsWith("'")) { 538 - const closeQuote = refText.indexOf("'", 1); 539 - if (closeQuote === -1 || refText[closeQuote + 1] !== '!') return '#REF!'; 540 - sheetName = refText.slice(1, closeQuote); 541 - cellRefStr = refText.slice(closeQuote + 2).toUpperCase(); 542 - } else { 543 - sheetName = refText.slice(0, bangIdx); 544 - cellRefStr = refText.slice(bangIdx + 1).toUpperCase(); 545 - } 546 - 547 - const parsed = parseRef(cellRefStr); 548 - if (!parsed) return '#REF!'; 549 - if (!this.crossSheetResolver) return '#REF!'; 550 - if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 551 - return this.crossSheetResolver.getSheetCellValue(sheetName, cellRefStr); 552 - } 553 - 554 - const cleaned = refText.toUpperCase().replace(/\$/g, ''); 555 - const parsed = parseRef(cleaned); 556 - if (!parsed) return '#REF!'; 557 - return this.getCellValue(cleaned); 558 - } 559 - 560 - parseRowColumn(fn: 'ROW' | 'COLUMN'): unknown { 561 - this.expect(TokenType.LPAREN); 562 - const t = this.peek(); 563 - if (t.type === TokenType.CELL_REF) { 564 - this.advance(); 565 - this.expect(TokenType.RPAREN); 566 - const ref = parseRef(t.value as string); 567 - if (!ref) return '#REF!'; 568 - return fn === 'ROW' ? ref.row : ref.col; 569 - } 570 - const val = this.expression(); 571 - this.expect(TokenType.RPAREN); 572 - const refStr = String(val).toUpperCase().replace(/\$/g, ''); 573 - const ref = parseRef(refStr); 574 - if (!ref) return '#REF!'; 575 - return fn === 'ROW' ? ref.row : ref.col; 576 - } 577 - 578 - resolveRange(startRef: string, endRef: string): RangeArray { 579 - const start = parseRef(startRef); 580 - const end = parseRef(endRef); 581 - if (!start || !end) return ['#REF!'] as unknown as RangeArray; 582 - const values: RangeArray = []; 583 - const rowMin = Math.min(start.row, end.row); 584 - const rowMax = Math.max(start.row, end.row); 585 - const colMin = Math.min(start.col, end.col); 586 - const colMax = Math.max(start.col, end.col); 587 - if ((rowMax - rowMin + 1) * (colMax - colMin + 1) > 10000) return ['#VALUE!'] as unknown as RangeArray; 588 - for (let r = rowMin; r <= rowMax; r++) { 589 - for (let c = colMin; c <= colMax; c++) { 590 - const ref = colToLetter(c) + r; 591 - values.push(this.getCellValue(ref)); 592 - } 593 - } 594 - values._rangeRows = rowMax - rowMin + 1; 595 - values._rangeCols = colMax - colMin + 1; 596 - return values; 597 - } 598 - 599 - resolveCrossSheetCell(sheetName: string, cellRef: string): unknown { 600 - if (!this.crossSheetResolver) return '#REF!'; 601 - if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 602 - return this.crossSheetResolver.getSheetCellValue(sheetName, cellRef); 603 - } 604 - 605 - resolveCrossSheetRange(sheetName: string, rangeStr: string): RangeArray | string { 606 - if (!this.crossSheetResolver) return '#REF!'; 607 - if (!this.crossSheetResolver.sheetExists(sheetName)) return '#REF!'; 608 - const parts = rangeStr.split(':'); 609 - if (parts.length !== 2) return '#REF!'; 610 - const start = parseRef(parts[0]); 611 - const end = parseRef(parts[1]); 612 - if (!start || !end) return '#REF!'; 613 - const values: RangeArray = []; 614 - const rowMin = Math.min(start.row, end.row); 615 - const rowMax = Math.max(start.row, end.row); 616 - const colMin = Math.min(start.col, end.col); 617 - const colMax = Math.max(start.col, end.col); 618 - for (let r = rowMin; r <= rowMax; r++) { 619 - for (let c = colMin; c <= colMax; c++) { 620 - const ref = colToLetter(c) + r; 621 - values.push(this.crossSheetResolver.getSheetCellValue(sheetName, ref)); 622 - } 623 - } 624 - values._rangeRows = rowMax - rowMin + 1; 625 - values._rangeCols = colMax - colMin + 1; 626 - return values; 627 - } 628 - 629 - resolveNamedRange(name: string): unknown { 630 - const key = name.toLowerCase(); 631 - const entry = this.namedRanges[key]; 632 - if (!entry) return `#NAME? (${name})`; 633 - const rangeStr = entry.range; 634 - const parts = rangeStr.split(':'); 635 - if (parts.length === 2) return this.resolveRange(parts[0], parts[1]); 636 - return this.getCellValue(rangeStr); 637 - } 638 - } 639 - 640 41 // --- Function dispatch --- 641 42 642 43 function callFunction(name: string, args: unknown[]): unknown { ··· 699 100 export function evaluate(formula: string, getCellValue: (ref: string) => CellValue | '', crossSheetResolver?: CrossSheetResolver | null, namedRanges?: NamedRangesMap | null): unknown { 700 101 try { 701 102 const tokens = tokenize(formula); 702 - const parser = new Parser(tokens, getCellValue, crossSheetResolver, namedRanges); 103 + const parser = new Parser(tokens, getCellValue, crossSheetResolver, namedRanges, callFunction); 703 104 return parser.parse(); 704 105 } catch (err) { 705 106 return '#ERROR!';
+7 -294
src/sheets/mouse-events.ts
··· 4 4 * Extracted from main.ts for decomposition. 5 5 */ 6 6 7 - import { cellId, colToLetter } from './formulas.js'; 8 - import { normalizeRange } from './selection-utils.js'; 9 7 import { showToast } from './import-export.js'; 10 - import { detectPattern, generateFillValues, adjustFormulaRef, PATTERN_TYPES } from './drag-fill.js'; 8 + import { startColumnResize, startRowResize } from './resize-handlers.js'; 9 + import { autoFitColumn, autoFitRow } from './autofit-handlers.js'; 10 + import { startFillDrag, updateFillPreviewVisuals, clearFillPreviewVisuals, executeFill } from './fill-handlers.js'; 11 11 12 12 // ── Types ─────────────────────────────────────────────────── 13 13 ··· 172 172 onCellDblClick(deps, e); 173 173 } 174 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 - } 175 + // Re-export extracted functions for backward compatibility 176 + export { startColumnResize } from './resize-handlers.js'; 177 + export { startRowResize } from './resize-handlers.js'; 178 + export { autoFitColumn, autoFitRow } from './autofit-handlers.js'; 277 179 278 180 function onCellMouseDown(deps: MouseEventsDeps, e: MouseEvent): void { 279 181 const { sheetContainer, getSelectedCell, setSelectedCell, setSelectionRange, ··· 352 254 document.addEventListener('mouseup', onMouseUp); 353 255 } 354 256 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 257 function onCellDblClick(deps: MouseEventsDeps, e: MouseEvent): void { 506 258 const td = (e.target as HTMLElement).closest('td[data-id]') as HTMLElement; 507 259 if (!td) return; 508 260 deps.startEditing(parseInt(td.dataset.col!), parseInt(td.dataset.row!)); 509 261 } 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 - }
+85
src/sheets/resize-handlers.ts
··· 1 + /** 2 + * Resize Handlers — column and row resize with visual guide. 3 + * 4 + * Extracted from mouse-events.ts for decomposition. 5 + */ 6 + 7 + import type { MouseEventsDeps } from './mouse-events.js'; 8 + 9 + export function startColumnResize(deps: MouseEventsDeps, handle: HTMLElement, e: MouseEvent): void { 10 + const { sheetContainer, getColWidth, setColWidth, renderGrid, MIN_COL_WIDTH } = deps; 11 + const col = parseInt(handle.dataset.resizeCol!); 12 + const startX = e.clientX; 13 + const startWidth = getColWidth(col); 14 + handle.classList.add('active'); 15 + 16 + const guide = document.createElement('div'); 17 + guide.className = 'col-resize-guide'; 18 + sheetContainer!.appendChild(guide); 19 + 20 + const updateGuide = (clientX: number) => { 21 + const containerRect = sheetContainer!.getBoundingClientRect(); 22 + guide.style.left = (clientX - containerRect.left + sheetContainer!.scrollLeft) + 'px'; 23 + }; 24 + updateGuide(e.clientX); 25 + 26 + const onMouseMove = (ev: MouseEvent) => { 27 + ev.preventDefault(); 28 + updateGuide(ev.clientX); 29 + }; 30 + 31 + const onMouseUp = (ev: MouseEvent) => { 32 + const delta = ev.clientX - startX; 33 + const newWidth = Math.max(MIN_COL_WIDTH, startWidth + delta); 34 + setColWidth(col, newWidth); 35 + handle.classList.remove('active'); 36 + guide.remove(); 37 + document.removeEventListener('mousemove', onMouseMove); 38 + document.removeEventListener('mouseup', onMouseUp); 39 + document.body.style.cursor = ''; 40 + renderGrid(); 41 + }; 42 + 43 + document.addEventListener('mousemove', onMouseMove); 44 + document.addEventListener('mouseup', onMouseUp); 45 + document.body.style.cursor = 'col-resize'; 46 + } 47 + 48 + export function startRowResize(deps: MouseEventsDeps, handle: HTMLElement, e: MouseEvent): void { 49 + const { sheetContainer, getRowHeight, setRowHeight, renderGrid } = deps; 50 + const row = parseInt(handle.dataset.resizeRow!); 51 + const startY = e.clientY; 52 + const startHeight = getRowHeight(row); 53 + handle.classList.add('active'); 54 + 55 + const guide = document.createElement('div'); 56 + guide.className = 'row-resize-guide'; 57 + sheetContainer!.appendChild(guide); 58 + 59 + const updateGuide = (clientY: number) => { 60 + const containerRect = sheetContainer!.getBoundingClientRect(); 61 + guide.style.top = (clientY - containerRect.top + sheetContainer!.scrollTop) + 'px'; 62 + }; 63 + updateGuide(e.clientY); 64 + 65 + const onMouseMove = (ev: MouseEvent) => { 66 + ev.preventDefault(); 67 + updateGuide(ev.clientY); 68 + }; 69 + 70 + const onMouseUp = (ev: MouseEvent) => { 71 + const delta = ev.clientY - startY; 72 + const newHeight = Math.max(14, startHeight + delta); 73 + setRowHeight(row, newHeight); 74 + handle.classList.remove('active'); 75 + guide.remove(); 76 + document.removeEventListener('mousemove', onMouseMove); 77 + document.removeEventListener('mouseup', onMouseUp); 78 + document.body.style.cursor = ''; 79 + renderGrid(); 80 + }; 81 + 82 + document.addEventListener('mousemove', onMouseMove); 83 + document.addEventListener('mouseup', onMouseUp); 84 + document.body.style.cursor = 'row-resize'; 85 + }