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 main.ts monolith into focused modules (#280)

scott 9c173ce4 91b1150b

+1241 -590
+15
src/lib/escape-html.ts
··· 1 + /** 2 + * Escape HTML special characters for safe insertion into the DOM. 3 + * 4 + * Accepts any value — null, undefined, and non-strings are coerced 5 + * to a string first (null/undefined become ''). 6 + */ 7 + export function escapeHtml(value: unknown): string { 8 + if (value === null || value === undefined) return ''; 9 + const str = typeof value === 'string' ? value : String(value); 10 + return str 11 + .replace(/&/g, '&amp;') 12 + .replace(/</g, '&lt;') 13 + .replace(/>/g, '&gt;') 14 + .replace(/"/g, '&quot;'); 15 + }
+117
src/sheets/cell-style-utils.ts
··· 1 + /** 2 + * Pure cell style utilities — computes inline CSS from cell data and CF rules. 3 + * No DOM, no shared state. 4 + */ 5 + import type { CellData, CellStyle } from './types.js'; 6 + import { buildBorderStyle } from './cell-styles.js'; 7 + 8 + const FONT_FAMILIES: Record<string, string> = { 9 + 'sans-serif': 'system-ui, sans-serif', 10 + 'serif': 'Charter, Georgia, serif', 11 + 'monospace': 'ui-monospace, "SF Mono", monospace', 12 + }; 13 + 14 + const VALIGN_MAP: Record<string, string> = { 15 + top: 'flex-start', 16 + middle: 'center', 17 + bottom: 'flex-end', 18 + }; 19 + 20 + /** Parse a hex color (#rgb or #rrggbb) to WCAG 2.1 relative luminance. */ 21 + export function hexLuminance(hex: string): number { 22 + const h = hex.replace('#', ''); 23 + let r: number, g: number, b: number; 24 + if (h.length === 3) { 25 + r = parseInt(h[0] + h[0], 16); 26 + g = parseInt(h[1] + h[1], 16); 27 + b = parseInt(h[2] + h[2], 16); 28 + } else { 29 + r = parseInt(h.slice(0, 2), 16); 30 + g = parseInt(h.slice(2, 4), 16); 31 + b = parseInt(h.slice(4, 6), 16); 32 + } 33 + const toLinear = (c: number) => { 34 + const s = c / 255; 35 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); 36 + }; 37 + return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); 38 + } 39 + 40 + /** Returns a contrasting text color (dark or light) for the given background hex. */ 41 + export function contrastTextColor(bgHex: string): string { 42 + return hexLuminance(bgHex) > 0.179 ? '#1a1815' : '#ffffff'; 43 + } 44 + 45 + /** Resolve background color for a cell (explicit style > CF). Returns hex or ''. */ 46 + export function getCellBgColor(cellData: Partial<CellData> | null | undefined, cfStyleStr: string): string { 47 + if (cellData?.s?.bg) return cellData.s.bg; 48 + if (cfStyleStr) { 49 + const bgMatch = cfStyleStr.match(/background:([^;]+);/); 50 + if (bgMatch) return bgMatch[1]!; 51 + } 52 + return ''; 53 + } 54 + 55 + /** Background CSS for the <td> — inset box-shadow grid lines paint on top. */ 56 + export function getCellBgStyle(cellData: Partial<CellData> | null | undefined, cfStyleStr: string): string { 57 + const bg = getCellBgColor(cellData, cfStyleStr); 58 + return bg ? 'background:' + bg + ';' : ''; 59 + } 60 + 61 + /** Build complete inline style string for a cell's display content. */ 62 + export function getCellStyle(cellData: Partial<CellData> | null | undefined, cfStyleStr: string): string { 63 + let style = ''; 64 + let hasExplicitColor = false; 65 + 66 + if (cellData?.s) { 67 + const s = cellData.s as CellStyle; 68 + // Skip emitting inline color when it matches a theme default — let CSS variable handle it 69 + if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') { 70 + style += 'color:' + s.color + ';'; 71 + hasExplicitColor = true; 72 + } 73 + if (s.bold) style += 'font-weight:600;'; 74 + if (s.italic) style += 'font-style:italic;'; 75 + if (s.fontSize) style += 'font-size:' + s.fontSize + 'pt;'; 76 + if (s.fontFamily) { 77 + style += 'font-family:' + (FONT_FAMILIES[s.fontFamily] || 'system-ui, sans-serif') + ';'; 78 + } 79 + // text-decoration: combine underline + strikethrough 80 + const decorations: string[] = []; 81 + if (s.underline) decorations.push('underline'); 82 + if (s.strikethrough) decorations.push('line-through'); 83 + if (decorations.length > 0) style += 'text-decoration:' + decorations.join(' ') + ';'; 84 + if (s.align) style += 'justify-content:' + (s.align === 'left' ? 'flex-start' : s.align === 'right' ? 'flex-end' : 'center') + ';'; 85 + if (s.verticalAlign) { 86 + style += 'align-items:' + (VALIGN_MAP[s.verticalAlign] || 'flex-start') + ';'; 87 + } 88 + if (s.borders) style += buildBorderStyle(s.borders); 89 + if (s.wrap) style += 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;'; 90 + } 91 + 92 + // Conditional formatting — bg is handled by getCellBgStyle on the td; 93 + // only apply non-bg CF styles (color, etc.) here on .cell-display 94 + if (cfStyleStr) { 95 + const cfNoBg = cfStyleStr.replace(/background:[^;]+;?/g, ''); 96 + if (cfNoBg) { 97 + if (!cellData?.s?.color && cfNoBg.includes('color:')) { 98 + const colorMatch = cfNoBg.match(/(?:^|;)(color:[^;]+;)/); 99 + if (colorMatch) { style += colorMatch[1]; hasExplicitColor = true; } 100 + } else if (!cellData?.s?.color) { 101 + if (cfNoBg.includes('color:')) hasExplicitColor = true; 102 + style += cfNoBg; 103 + } 104 + } 105 + } 106 + 107 + // Auto-contrast: when a cell has a background but no explicit text color, 108 + // pick black or white based on the background luminance. 109 + if (!hasExplicitColor) { 110 + const bg = getCellBgColor(cellData, cfStyleStr); 111 + if (bg && bg.startsWith('#')) { 112 + style += 'color:' + contrastTextColor(bg) + ';'; 113 + } 114 + } 115 + 116 + return style; 117 + }
+1 -14
src/sheets/clipboard-copy.ts
··· 5 5 * TSV text from selected cells for pasting into other spreadsheets. 6 6 */ 7 7 8 - /** 9 - * Escape a string for safe inclusion in HTML. 10 - * 11 - * @param {string} str 12 - * @returns {string} 13 - */ 14 - function escapeHtml(str) { 15 - if (typeof str !== 'string') return String(str ?? ''); 16 - return str 17 - .replace(/&/g, '&amp;') 18 - .replace(/</g, '&lt;') 19 - .replace(/>/g, '&gt;') 20 - .replace(/"/g, '&quot;'); 21 - } 8 + import { escapeHtml } from '../lib/escape-html.js'; 22 9 23 10 /** 24 11 * Convert a CellStyle object to an inline CSS style string.
+14
src/sheets/conditional-format.ts
··· 211 211 212 212 return result; 213 213 } 214 + 215 + /** Format a conditional formatting rule as a human-readable label. */ 216 + export function formatRuleLabel(rule: CfRule): string { 217 + switch (rule.type) { 218 + case 'greaterThan': return 'Greater than ' + (rule.value ?? ''); 219 + case 'lessThan': return 'Less than ' + (rule.value ?? ''); 220 + case 'equalTo': return 'Equal to ' + (rule.value ?? ''); 221 + case 'between': return 'Between ' + (rule.value ?? '') + ' and ' + (rule.value2 ?? ''); 222 + case 'textContains': return 'Text contains "' + (rule.value ?? '') + '"'; 223 + case 'isEmpty': return 'Is empty'; 224 + case 'isNotEmpty': return 'Is not empty'; 225 + default: return rule.type; 226 + } 227 + }
+58
src/sheets/csv-utils.ts
··· 1 + /** 2 + * CSV/TSV parsing utilities — pure functions, no DOM or state. 3 + */ 4 + 5 + /** 6 + * Parse a single CSV line, handling quoted fields with escaped quotes. 7 + */ 8 + export function parseCSVLine(line: string): string[] { 9 + const fields: string[] = []; 10 + let field = ''; 11 + let inQuotes = false; 12 + for (let i = 0; i < line.length; i++) { 13 + const ch = line[i]; 14 + if (inQuotes) { 15 + if (ch === '"') { 16 + if (i + 1 < line.length && line[i + 1] === '"') { 17 + field += '"'; 18 + i++; 19 + } else { 20 + inQuotes = false; 21 + } 22 + } else { 23 + field += ch; 24 + } 25 + } else { 26 + if (ch === '"') { 27 + inQuotes = true; 28 + } else if (ch === ',') { 29 + fields.push(field); 30 + field = ''; 31 + } else { 32 + field += ch; 33 + } 34 + } 35 + } 36 + fields.push(field); 37 + return fields; 38 + } 39 + 40 + /** 41 + * Detect if the first row of parsed CSV data looks like column headers. 42 + * Returns true if first row is all text, has unique values, and subsequent rows contain numbers. 43 + */ 44 + export function detectHeaders(parsedRows: string[][]): boolean { 45 + if (parsedRows.length < 2) return false; 46 + const firstRow = parsedRows[0].map(v => v.trim()); 47 + const allFirstRowText = firstRow.every(val => val === '' || isNaN(Number(val))); 48 + if (!allFirstRowText) return false; 49 + const nonEmpty = firstRow.filter(v => v !== ''); 50 + if (nonEmpty.length === 0) return false; 51 + const uniqueVals = new Set(nonEmpty.map(v => v.toLowerCase())); 52 + if (uniqueVals.size !== nonEmpty.length) return false; 53 + const dataRows = parsedRows.slice(1, Math.min(parsedRows.length, 11)); 54 + return dataRows.some(row => row.some(val => { 55 + const t = val.trim(); 56 + return t !== '' && !isNaN(Number(t)); 57 + })); 58 + }
+1 -11
src/sheets/formula-highlighter.ts
··· 7 7 */ 8 8 9 9 import type { HighlightToken, HighlightTokenType } from './types.js'; 10 + import { escapeHtml } from '../lib/escape-html.js'; 10 11 11 12 // Known error values in spreadsheets 12 13 const ERROR_PATTERN = /^#(REF!|N\/A|VALUE!|ERROR!|NAME\?|NULL!|NUM!|DIV\/0!)/; ··· 237 238 } 238 239 239 240 return tokens; 240 - } 241 - 242 - /** 243 - * Escape HTML special characters for safe insertion. 244 - */ 245 - function escapeHtml(text: string): string { 246 - return text 247 - .replace(/&/g, '&amp;') 248 - .replace(/</g, '&lt;') 249 - .replace(/>/g, '&gt;') 250 - .replace(/"/g, '&quot;'); 251 241 } 252 242 253 243 /**
+73 -285
src/sheets/main.ts
··· 19 19 import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js'; 20 20 import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js'; 21 21 import { multiColumnSort } from './sort.js'; 22 - import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js'; 22 + import { evaluateRules, buildCfStyle, computeColorScale, formatRuleLabel } from './conditional-format.js'; 23 23 import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js'; 24 24 import { renderInteractiveCell, handleRichCellClick } from './rich-cells.js'; 25 25 import { parseDateValue, showDatePicker } from './date-picker.js'; 26 26 import { validateCell, getDropdownItems, parseListItems } from './data-validation.js'; 27 27 import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js'; 28 + import { normalizeRange, isInRange } from './selection-utils.js'; 29 + import { hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle } from './cell-style-utils.js'; 30 + import { buildMergeMap, findCellMerge } from './merge-utils.js'; 31 + import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget, getSpillTargetValue } from './spill-tracking.js'; 32 + import { formatSaveTimestamp, getSaveDisplayText } from './save-indicator.js'; 33 + import { parseCSVLine, detectHeaders } from './csv-utils.js'; 34 + import type { SpillState } from './spill-tracking.js'; 28 35 import { computeSelectionStats, formatStatValue } from './status-bar.js'; 29 36 import { FORMULA_FUNCTIONS, filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js'; 30 37 import { createNote, updateNote, deleteNote, getNote, hasNote, getAllNotes } from './cell-notes.js'; ··· 48 55 import { 49 56 createChatSidebar, createChatState, loadConfig, isConfigured, 50 57 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble, 51 - renderMarkdown, appendActionCard, escapeHtml, initChatWiring, 58 + renderMarkdown, appendActionCard, initChatWiring, 52 59 } from '../lib/ai-chat.js'; 60 + import { escapeHtml } from '../lib/escape-html.js'; 53 61 import { splitResponse, isSheetAction } from '../lib/ai-actions.js'; 54 62 import { executeSheetAction } from './ai-sheet-actions.js'; 55 63 import { computePivot, formatAggregateValue } from './pivot-table.js'; ··· 344 352 return sheet.get('merges'); 345 353 } 346 354 347 - function buildMergeMap() { 348 - const merges = getMerges(); 349 - const map = new Map(); 350 - merges.forEach((mergeData) => { 351 - const m = typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData; 352 - for (let r = m.startRow; r <= m.endRow; r++) { 353 - for (let c = m.startCol; c <= m.endCol; c++) { 354 - const id = cellId(c, r); 355 - if (c === m.startCol && r === m.startRow) { 356 - map.set(id, { hidden: false, merge: m, colspan: m.endCol - m.startCol + 1, rowspan: m.endRow - m.startRow + 1 }); 357 - } else { 358 - map.set(id, { hidden: true, merge: m }); 359 - } 360 - } 361 - } 362 - }); 363 - return map; 355 + // buildMergeMap and findCellMerge extracted to merge-utils.ts 356 + // Wrappers that pass Yjs data: 357 + function _buildMergeMap() { 358 + return buildMergeMap(getMerges().entries()); 364 359 } 365 - 366 360 function isCellMerged(col, row) { 367 - const merges = getMerges(); 368 - let result = null; 369 - merges.forEach((mergeData) => { 370 - const m = typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData; 371 - if (col >= m.startCol && col <= m.endCol && row >= m.startRow && row <= m.endRow) result = m; 372 - }); 373 - return result; 361 + return findCellMerge(col, row, getMerges().entries()); 374 362 } 375 363 376 364 // --- Hidden canvas for text measurement (auto-fit) --- ··· 405 393 const colCount = sheet.get('colCount') || DEFAULT_COLS; 406 394 const freezeR = getFreezeRows(); 407 395 const freezeC = getFreezeCols(); 408 - const mergeMap = buildMergeMap(); 396 + const mergeMap = _buildMergeMap(); 409 397 410 398 // Build hidden sets for row/col visibility computation 411 399 const hiddenRowSet = buildHiddenRowSet(); ··· 575 563 } 576 564 577 565 // Spill range styling 578 - if (isSpillSource(id)) tdCls.push('spill-source'); 579 - if (isSpillTarget(id)) tdCls.push('spill-target'); 566 + if (_isSpillSource(id)) tdCls.push('spill-source'); 567 + if (_isSpillTarget(id)) tdCls.push('spill-target'); 580 568 581 569 // Find & replace highlighting 582 570 if (findActive) { ··· 716 704 function computeDisplayValue(id, cellData) { 717 705 if (!cellData) { 718 706 // Check if this cell is a spill target 719 - const spillInfo = spillTargetMap.get(id); 707 + const spillInfo = _spillState.targets.get(id); 720 708 if (spillInfo) return formatCell(spillInfo.value, undefined); 721 709 return ''; 722 710 } ··· 726 714 if (isSparklineResult(val)) return val; 727 715 // Array results: register spill and display first element 728 716 if (Array.isArray(val) && (val as any)._rangeRows) { 729 - registerSpill(id, val); 730 - const spillInfo = spillMap.get(id); 717 + _registerSpill(id, val); 718 + const spillInfo = _spillState.sources.get(id); 731 719 if (spillInfo && spillInfo.data[0] === '#SPILL!') return '#SPILL!'; 732 720 return formatCell(val[0], cellData.s?.format); 733 721 } ··· 735 723 } 736 724 // Check if this cell is a spill target (cell exists but has no formula/value) 737 725 if (!cellData.v && !cellData.f) { 738 - const spillInfo = spillTargetMap.get(id); 726 + const spillInfo = _spillState.targets.get(id); 739 727 if (spillInfo) return formatCell(spillInfo.value, cellData.s?.format); 740 728 } 741 729 return formatCell(cellData.v, cellData.s?.format); ··· 743 731 744 732 const evalCache = new Map(); 745 733 746 - // --- Spill tracking (display layer) --- 747 - // sourceId → { rows, cols, data: flat array } 748 - const spillMap = new Map<string, { rows: number; cols: number; data: unknown[] }>(); 749 - // targetId → { source, value } 750 - const spillTargetMap = new Map<string, { source: string; value: unknown }>(); 751 - 752 - function clearSpillMaps() { 753 - spillMap.clear(); 754 - spillTargetMap.clear(); 755 - } 756 - 757 - /** 758 - * Register a spill range from a formula that returned an array. 759 - * Populates spillMap and spillTargetMap for display. 760 - */ 761 - function registerSpill(sourceId: string, arr: unknown[]): void { 762 - const rows = (arr as any)._rangeRows || arr.length; 763 - const cols = (arr as any)._rangeCols || 1; 764 - const ref = parseRef(sourceId); 765 - if (!ref) return; 734 + // --- Spill tracking extracted to spill-tracking.ts --- 735 + const _spillState = createSpillState(); 766 736 767 - spillMap.set(sourceId, { rows, cols, data: arr }); 768 - 737 + // Wrappers that pass local state/deps: 738 + function __clearSpillMaps() { clearSpillMaps(_spillState); } 739 + function _registerSpill(sourceId: string, arr: unknown[]): void { 769 740 const sheet = getActiveSheet(); 770 741 const maxRows = sheet.get('rowCount') || 100; 771 742 const maxCols = sheet.get('colCount') || 26; 772 - 773 - for (let r = 0; r < rows; r++) { 774 - for (let c = 0; c < cols; c++) { 775 - if (r === 0 && c === 0) continue; 776 - // Bounds check: skip spill targets beyond sheet dimensions 777 - if (ref.row + r > maxRows || ref.col + c > maxCols) continue; 778 - const targetId = colToLetter(ref.col + c) + (ref.row + r); 779 - const idx = r * cols + c; 780 - // Check for collision: target has real data 781 - const targetData = getCellData(targetId); 782 - if (targetData && (targetData.f || (targetData.v !== '' && targetData.v !== undefined && targetData.v !== null))) { 783 - // Collision — mark source as #SPILL! 784 - spillMap.set(sourceId, { rows: 0, cols: 0, data: ['#SPILL!'] }); 785 - // Clear any targets already registered for this source 786 - for (const [tid, info] of spillTargetMap) { 787 - if (info.source === sourceId) spillTargetMap.delete(tid); 788 - } 789 - return; 790 - } 791 - spillTargetMap.set(targetId, { source: sourceId, value: arr[idx] ?? '' }); 792 - } 793 - } 743 + registerSpill(_spillState, sourceId, arr as any, getCellData, maxRows, maxCols); 794 744 } 795 - 796 - /** 797 - * Check if a cell is a spill source (has spilled array results). 798 - */ 799 - function isSpillSource(cellId: string): boolean { 800 - const info = spillMap.get(cellId); 801 - return !!info && info.rows > 0; 802 - } 803 - 804 - /** 805 - * Check if a cell is a spill target. 806 - */ 807 - function isSpillTarget(cellId: string): boolean { 808 - return spillTargetMap.has(cellId); 809 - } 745 + function _isSpillSource(id: string): boolean { return isSpillSource(_spillState, id); } 746 + function _isSpillTarget(id: string): boolean { return isSpillTarget(_spillState, id); } 810 747 811 748 // --- Recalc engine integration --- 812 749 function buildRecalcCellStore() { ··· 876 813 const classes = []; 877 814 if (selectedCell.col === col && selectedCell.row === row) classes.push('selected'); 878 815 if (editingCell && editingCell.col === col && editingCell.row === row) classes.push('editing'); 879 - if (isInRange(col, row)) classes.push('in-range'); 816 + if (_isInRange(col, row)) classes.push('in-range'); 880 817 return classes.join(' '); 881 818 } 882 819 883 - function isInRange(col, row) { 884 - if (!selectionRange) return false; 885 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 886 - return col >= startCol && col <= endCol && row >= startRow && row <= endRow; 887 - } 888 - 889 - function normalizeRange(range) { 890 - return { 891 - startCol: Math.min(range.startCol, range.endCol), 892 - startRow: Math.min(range.startRow, range.endRow), 893 - endCol: Math.max(range.startCol, range.endCol), 894 - endRow: Math.max(range.startRow, range.endRow), 895 - }; 896 - } 897 - 898 - /** Parse a hex color (#rgb or #rrggbb) to WCAG 2.1 relative luminance. */ 899 - function hexLuminance(hex: string): number { 900 - const h = hex.replace('#', ''); 901 - let r: number, g: number, b: number; 902 - if (h.length === 3) { 903 - r = parseInt(h[0] + h[0], 16); 904 - g = parseInt(h[1] + h[1], 16); 905 - b = parseInt(h[2] + h[2], 16); 906 - } else { 907 - r = parseInt(h.slice(0, 2), 16); 908 - g = parseInt(h.slice(2, 4), 16); 909 - b = parseInt(h.slice(4, 6), 16); 910 - } 911 - const toLinear = (c: number) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; 912 - return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b); 913 - } 914 - 915 - /** Returns a contrasting text color for the given background hex. */ 916 - function contrastTextColor(bgHex: string): string { 917 - return hexLuminance(bgHex) > 0.179 ? '#1a1815' : '#ffffff'; 918 - } 919 - 920 - // Resolve background color for a cell (explicit style > CF), returns color string or '' 921 - function getCellBgColor(cellData, cfStyleStr) { 922 - if (cellData?.s?.bg) return cellData.s.bg; 923 - if (cfStyleStr) { 924 - const bgMatch = cfStyleStr.match(/background:([^;]+);/); 925 - if (bgMatch) return bgMatch[1]; 926 - } 927 - return ''; 928 - } 929 - 930 - // Background CSS for the <td> — so inset box-shadow grid lines paint on top of it 931 - function getCellBgStyle(cellData, cfStyleStr) { 932 - const bg = getCellBgColor(cellData, cfStyleStr); 933 - return bg ? 'background:' + bg + ';' : ''; 934 - } 935 - 936 - function getCellStyle(cellData, cfStyleStr) { 937 - let style = ''; 938 - let hasExplicitColor = false; 939 - if (cellData?.s) { 940 - const s = cellData.s; 941 - // Skip emitting inline color when it matches a theme default — let CSS variable handle it 942 - if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') { 943 - style += 'color:' + s.color + ';'; 944 - hasExplicitColor = true; 945 - } 946 - // bg is now on the <td>, not .cell-display 947 - if (s.bold) style += 'font-weight:600;'; 948 - if (s.italic) style += 'font-style:italic;'; 949 - if (s.fontSize) style += 'font-size:' + s.fontSize + 'pt;'; 950 - if (s.fontFamily) { 951 - const families = { 952 - 'sans-serif': 'system-ui, sans-serif', 953 - 'serif': 'Charter, Georgia, serif', 954 - 'monospace': 'ui-monospace, "SF Mono", monospace', 955 - }; 956 - style += 'font-family:' + (families[s.fontFamily] || 'system-ui, sans-serif') + ';'; 957 - } 958 - // text-decoration: combine underline + strikethrough 959 - const decorations = []; 960 - if (s.underline) decorations.push('underline'); 961 - if (s.strikethrough) decorations.push('line-through'); 962 - if (decorations.length > 0) style += 'text-decoration:' + decorations.join(' ') + ';'; 963 - if (s.align) style += 'justify-content:' + (s.align === 'left' ? 'flex-start' : s.align === 'right' ? 'flex-end' : 'center') + ';'; 964 - if (s.verticalAlign) { 965 - const vaMap = { top: 'flex-start', middle: 'center', bottom: 'flex-end' }; 966 - style += 'align-items:' + (vaMap[s.verticalAlign] || 'flex-start') + ';'; 967 - } 968 - if (s.borders) style += buildBorderStyle(s.borders); 969 - if (s.wrap) style += 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;'; 970 - } 971 - // Conditional formatting — bg is handled by getCellBgStyle on the td; 972 - // only apply non-bg CF styles (color, etc.) here on .cell-display 973 - if (cfStyleStr) { 974 - // Strip background from CF string — it goes on the td via getCellBgStyle 975 - const cfNoBg = cfStyleStr.replace(/background:[^;]+;?/g, ''); 976 - if (cfNoBg) { 977 - if (!cellData?.s?.color && cfNoBg.includes('color:')) { 978 - const colorMatch = cfNoBg.match(/(?:^|;)(color:[^;]+;)/); 979 - if (colorMatch) { style += colorMatch[1]; hasExplicitColor = true; } 980 - } else if (!cellData?.s?.color) { 981 - if (cfNoBg.includes('color:')) hasExplicitColor = true; 982 - style += cfNoBg; 983 - } 984 - } 985 - } 986 - // Auto-contrast: when a cell has a background color but no explicit text color, 987 - // pick black or white based on the background luminance. This prevents light-on-light 988 - // (dark mode default text on light cell bg) and dark-on-dark scenarios. 989 - if (!hasExplicitColor) { 990 - const bg = getCellBgColor(cellData, cfStyleStr); 991 - if (bg && bg.startsWith('#')) { 992 - style += 'color:' + contrastTextColor(bg) + ';'; 993 - } 994 - } 995 - return style; 820 + // normalizeRange and isInRange extracted to selection-utils.ts 821 + // Wrapper that captures local selectionRange: 822 + function _isInRange(col, row) { 823 + return isInRange(col, row, selectionRange); 996 824 } 997 825 998 - function escapeHtml(str) { 999 - if (str === null || str === undefined) return ''; 1000 - return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 1001 - } 826 + // hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle 827 + // extracted to cell-style-utils.ts (imported above) 1002 828 1003 829 // --- Grid events --- 1004 830 function attachGridEvents() { ··· 1594 1420 } 1595 1421 }); 1596 1422 1597 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1423 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1598 1424 refreshVisibleCells(); 1599 1425 1600 1426 // Extend selection to include filled area ··· 1685 1511 } 1686 1512 if (td) td.classList.remove('editing'); 1687 1513 editingCell = null; 1688 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1514 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1689 1515 clearGridHighlights(); 1690 1516 hideTooltip(); 1691 1517 refreshVisibleCells(); ··· 1799 1625 // Undo: Cmd+Z (Mac) / Ctrl+Z 1800 1626 if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) { 1801 1627 e.preventDefault(); 1802 - if (undoManager) { undoManager.undo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1628 + if (undoManager) { undoManager.undo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1803 1629 } 1804 1630 // Redo: Cmd+Shift+Z (Mac) / Ctrl+Y (Windows/Linux) 1805 1631 if (((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'z') || (e.ctrlKey && !e.metaKey && key === 'y')) { 1806 1632 e.preventDefault(); 1807 - if (undoManager) { undoManager.redo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1633 + if (undoManager) { undoManager.redo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); } 1808 1634 } 1809 1635 // Hide rows: Cmd+9 1810 1636 if ((e.metaKey || e.ctrlKey) && key === '9' && !e.shiftKey) { e.preventDefault(); hideSelectedRows(); } ··· 1961 1787 } 1962 1788 } 1963 1789 }); 1964 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1790 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 1965 1791 refreshVisibleCells(); 1966 1792 } 1967 1793 ··· 2045 1871 } 2046 1872 } 2047 1873 }); 2048 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 1874 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2049 1875 refreshVisibleCells(); 2050 1876 } 2051 1877 ··· 2442 2268 setCellData(id, { v: value, f: '' }); 2443 2269 } 2444 2270 2445 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2271 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2446 2272 refreshVisibleCells(); 2447 2273 } 2448 2274 ··· 2555 2381 } 2556 2382 } 2557 2383 document.getElementById('tb-undo').addEventListener('click', () => { 2558 - if (undoManager) { undoManager.undo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2384 + if (undoManager) { undoManager.undo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2559 2385 }); 2560 2386 document.getElementById('tb-redo').addEventListener('click', () => { 2561 - if (undoManager) { undoManager.redo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2387 + if (undoManager) { undoManager.redo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); } 2562 2388 }); 2563 2389 // Update undo/redo state whenever stacks change 2564 2390 if (undoManager) { ··· 2790 2616 } 2791 2617 }); 2792 2618 }); 2793 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2619 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2794 2620 refreshVisibleCells(); 2795 2621 } 2796 2622 ··· 2826 2652 rowColInsertRow(getCells, setCellData, rowIndex, colCount); 2827 2653 }); 2828 2654 sheet.set('rowCount', (sheet.get('rowCount') || DEFAULT_ROWS) + 1); 2829 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2655 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2830 2656 renderGrid(); 2831 2657 } 2832 2658 ··· 2839 2665 rowColDeleteRow(getCells, setCellData, rowIndex, colCount); 2840 2666 }); 2841 2667 sheet.set('rowCount', rowCount - 1); 2842 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2668 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2843 2669 renderGrid(); 2844 2670 } 2845 2671 ··· 2850 2676 rowColInsertColumn(getCells, setCellData, colIndex, rowCount); 2851 2677 }); 2852 2678 sheet.set('colCount', (sheet.get('colCount') || DEFAULT_COLS) + 1); 2853 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2679 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2854 2680 renderGrid(); 2855 2681 } 2856 2682 ··· 2863 2689 rowColDeleteColumn(getCells, setCellData, colIndex, rowCount); 2864 2690 }); 2865 2691 sheet.set('colCount', colCount - 1); 2866 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 2692 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 2867 2693 renderGrid(); 2868 2694 } 2869 2695 ··· 3017 2843 const newActive = deleteSheet(ydoc, ySheets, sheetIdx, activeSheetIdx); 3018 2844 if (newActive >= 0) { 3019 2845 activeSheetIdx = newActive; 3020 - evalCache.clear(); clearSpillMaps(); 2846 + evalCache.clear(); _clearSpillMaps(); 3021 2847 invalidateRecalcEngine(); 3022 2848 renderSheetTabs(); 3023 2849 renderGrid(); ··· 3031 2857 const newSheet = duplicateSheet(ydoc, ySheets, sheetIdx, targetIdx); 3032 2858 if (newSheet) { 3033 2859 activeSheetIdx = targetIdx; 3034 - evalCache.clear(); clearSpillMaps(); 2860 + evalCache.clear(); _clearSpillMaps(); 3035 2861 invalidateRecalcEngine(); 3036 2862 renderSheetTabs(); 3037 2863 renderGrid(); ··· 3101 2927 tab.appendChild(label); 3102 2928 3103 2929 tab.draggable = true; 3104 - tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); }); 2930 + tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); }); 3105 2931 3106 2932 // Double-click for inline rename 3107 2933 tab.addEventListener('dblclick', (e) => { ··· 3190 3016 activeSheetIdx++; 3191 3017 } 3192 3018 3193 - evalCache.clear(); clearSpillMaps(); 3019 + evalCache.clear(); _clearSpillMaps(); 3194 3020 invalidateRecalcEngine(); 3195 3021 renderSheetTabs(); 3196 3022 renderGrid(); ··· 3272 3098 document.getElementById('add-sheet').addEventListener('click', () => { 3273 3099 let count = 0; 3274 3100 ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) count++; }); 3275 - ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3101 + ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3276 3102 }); 3277 3103 3278 3104 // --- Document title --- ··· 3314 3140 statusText.textContent = 'Synced'; 3315 3141 // Re-attach ALL observers after sync — the snapshot may have replaced the Y.Map/Y.Array 3316 3142 // objects that were observed during initial setup (before data loaded from peers) 3317 - getCells().observeDeep(() => { evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 3143 + getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 3318 3144 ySheets.observe(() => { renderSheetTabs(); }); 3319 3145 ySheets.observeDeep((events) => { 3320 3146 for (const event of events) { ··· 3326 3152 } 3327 3153 } 3328 3154 if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 3329 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3155 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3330 3156 } 3331 3157 } 3332 3158 }); ··· 3377 3203 setCellData(cellId, data as any); 3378 3204 } 3379 3205 }); 3380 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3206 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3381 3207 } 3382 3208 } catch { /* ignore invalid template */ } 3383 3209 } ··· 3456 3282 function exportCSV() { const name = getActiveSheet().get('name') || 'sheet'; downloadFile(sheetToDelimited(','), name + '.csv', 'text/csv;charset=utf-8'); } 3457 3283 function exportTSV() { const name = getActiveSheet().get('name') || 'sheet'; downloadFile(sheetToDelimited('\t'), name + '.tsv', 'text/tab-separated-values;charset=utf-8'); } 3458 3284 3459 - function parseCSVLine(line) { 3460 - const fields = []; let field = ''; let inQuotes = false; 3461 - for (let i = 0; i < line.length; i++) { 3462 - const ch = line[i]; 3463 - if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } } else { field += ch; } } 3464 - else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { fields.push(field); field = ''; } else { field += ch; } } 3465 - } 3466 - fields.push(field); return fields; 3467 - } 3468 - 3469 - /** 3470 - * Detect if the first row of parsed CSV data looks like column headers. 3471 - */ 3472 - function detectHeaders(parsedRows) { 3473 - if (parsedRows.length < 2) return false; 3474 - const firstRow = parsedRows[0].map(v => v.trim()); 3475 - const allFirstRowText = firstRow.every(val => val === '' || isNaN(Number(val))); 3476 - if (!allFirstRowText) return false; 3477 - const nonEmpty = firstRow.filter(v => v !== ''); 3478 - if (nonEmpty.length === 0) return false; 3479 - const uniqueVals = new Set(nonEmpty.map(v => v.toLowerCase())); 3480 - if (uniqueVals.size !== nonEmpty.length) return false; 3481 - const dataRows = parsedRows.slice(1, Math.min(parsedRows.length, 11)); 3482 - return dataRows.some(row => row.some(val => { const t = val.trim(); return t !== '' && !isNaN(Number(t)); })); 3483 - } 3285 + // parseCSVLine and detectHeaders extracted to csv-utils.ts 3484 3286 3485 3287 function showToast(message, duration = 3000) { 3486 3288 const existing = document.querySelector('.toast-notification'); ··· 3517 3319 if (neededRows > (sheet.get('rowCount') || DEFAULT_ROWS)) sheet.set('rowCount', neededRows); 3518 3320 if (neededCols > (sheet.get('colCount') || DEFAULT_COLS)) sheet.set('colCount', neededCols); 3519 3321 }); 3520 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3322 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); 3521 3323 3522 3324 if (hasHeaders) { 3523 3325 ydoc.transact(() => { ··· 3595 3397 3596 3398 function buildPrintData(): SheetsPrintData { 3597 3399 const sheet = getActiveSheet(); 3598 - const mergeMap = buildMergeMap(); 3400 + const mergeMap = _buildMergeMap(); 3599 3401 3600 3402 // Find actual data extent to avoid printing huge empty grids 3601 3403 let maxRow = 0, maxCol = 0; ··· 3724 3526 }); 3725 3527 3726 3528 // --- React to Yjs changes --- 3727 - getCells().observeDeep(() => { evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 3529 + getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); }); 3728 3530 ySheets.observe(() => { renderSheetTabs(); }); 3729 3531 3730 3532 // Re-render when colWidths, freeze state, CF rules, validations, or stripedRows change from remote collaborators ··· 3739 3541 } 3740 3542 // CF rules or validations changed 3741 3543 if (event.target && (event.target === getCfRules() || event.target === getValidations())) { 3742 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3544 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return; 3743 3545 } 3744 3546 } 3745 3547 }); ··· 3809 3611 saveIndicator.classList.remove('saved', 'saving', 'unsaved'); 3810 3612 saveIndicator.classList.add(state); 3811 3613 if (state === 'saved') { lastSaveTime = time || Date.now(); updateSaveTimestamp(); } 3812 - else if (state === 'saving') { saveTextEl.textContent = 'Saving\u2026'; } 3813 - else { saveTextEl.textContent = 'Unsaved changes'; } 3614 + else { saveTextEl.textContent = getSaveDisplayText(state) || ''; } 3814 3615 } 3815 3616 3816 3617 function updateSaveTimestamp() { 3817 3618 if (saveState !== 'saved') return; 3818 3619 const prefix = !provider.connected ? 'Saved locally' : 'Saved'; 3819 3620 const seconds = Math.floor((Date.now() - lastSaveTime) / 1000); 3820 - if (seconds < 5) saveTextEl.textContent = prefix; 3821 - else if (seconds < 60) saveTextEl.textContent = prefix + ' ' + seconds + 's ago'; 3822 - else saveTextEl.textContent = prefix + ' ' + Math.floor(seconds / 60) + ' min ago'; 3621 + saveTextEl.textContent = formatSaveTimestamp(seconds, prefix); 3823 3622 } 3824 3623 3825 3624 setInterval(updateSaveTimestamp, 30_000); ··· 4889 4688 }); 4890 4689 }); 4891 4690 4892 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 4691 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 4893 4692 refreshVisibleCells(); 4894 4693 overlay.remove(); 4895 4694 }); ··· 5059 4858 const yArr = getCfRules(); 5060 4859 ydoc.transact(() => { yArr.delete(idx, 1); }); 5061 4860 renderCfModal(); 5062 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 4861 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 5063 4862 refreshVisibleCells(); 5064 4863 }); 5065 4864 }); ··· 5077 4876 const yArr = getCfRules(); 5078 4877 ydoc.transact(() => { yArr.push([JSON.stringify(rule)]); }); 5079 4878 renderCfModal(); 5080 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 4879 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 5081 4880 refreshVisibleCells(); 5082 4881 }); 5083 4882 ··· 5093 4892 document.body.appendChild(overlay); 5094 4893 } 5095 4894 5096 - function formatRuleLabel(rule) { 5097 - switch (rule.type) { 5098 - case 'greaterThan': return 'Greater than ' + (rule.value ?? ''); 5099 - case 'lessThan': return 'Less than ' + (rule.value ?? ''); 5100 - case 'equalTo': return 'Equal to ' + (rule.value ?? ''); 5101 - case 'between': return 'Between ' + (rule.value ?? '') + ' and ' + (rule.value2 ?? ''); 5102 - case 'textContains': return 'Text contains "' + (rule.value ?? '') + '"'; 5103 - case 'isEmpty': return 'Is empty'; 5104 - case 'isNotEmpty': return 'Is not empty'; 5105 - default: return rule.type; 5106 - } 5107 - } 4895 + // formatRuleLabel extracted to conditional-format.ts 5108 4896 5109 4897 document.getElementById('tb-cf').addEventListener('click', () => { closeAllDropdowns(); showCfModal(); }); 5110 4898 ··· 5291 5079 const numVal = Number(item); 5292 5080 const value = item === '' ? '' : (!isNaN(numVal) && item !== '' ? numVal : item); 5293 5081 setCellData(cellIdStr, { v: value, f: '' }); 5294 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 5082 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 5295 5083 refreshVisibleCells(); 5296 5084 dropdown.remove(); 5297 5085 }); ··· 6108 5896 const numVal = Number(result.newValue); 6109 5897 const value = result.newValue === '' ? '' : (!isNaN(numVal) && result.newValue !== '' ? numVal : result.newValue); 6110 5898 setCellData(result.cellId, { v: value, f: '' }); 6111 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 5899 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 6112 5900 runSheetsFind(); // re-search after replace 6113 5901 } 6114 5902 }); ··· 6124 5912 setCellData(r.cellId, { v: value, f: '' }); 6125 5913 } 6126 5914 }); 6127 - evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); 5915 + evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); 6128 5916 showToast('Replaced ' + results.length + ' match' + (results.length > 1 ? 'es' : '')); 6129 5917 runSheetsFind(); 6130 5918 }
+53
src/sheets/merge-utils.ts
··· 1 + /** 2 + * Pure merge utility functions — operates on merge data iterables, not Yjs directly. 3 + * No DOM, no shared state. 4 + */ 5 + import type { MergeData, MergeMapEntry } from './types.js'; 6 + import { cellId } from './formulas.js'; 7 + 8 + /** Parse a merge entry (may be JSON string or object). */ 9 + function parseMerge(mergeData: unknown): MergeData { 10 + return typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData as MergeData; 11 + } 12 + 13 + /** 14 + * Build a lookup map from cell ID to merge info. 15 + * The origin cell gets { hidden: false, colspan, rowspan }. 16 + * All other cells in the merge get { hidden: true }. 17 + */ 18 + export function buildMergeMap(merges: Iterable<[string, unknown]>): Map<string, MergeMapEntry> { 19 + const map = new Map<string, MergeMapEntry>(); 20 + for (const [, mergeData] of merges) { 21 + const m = parseMerge(mergeData); 22 + for (let r = m.startRow; r <= m.endRow; r++) { 23 + for (let c = m.startCol; c <= m.endCol; c++) { 24 + const id = cellId(c, r); 25 + if (c === m.startCol && r === m.startRow) { 26 + map.set(id, { 27 + hidden: false, 28 + merge: m, 29 + colspan: m.endCol - m.startCol + 1, 30 + rowspan: m.endRow - m.startRow + 1, 31 + }); 32 + } else { 33 + map.set(id, { hidden: true, merge: m }); 34 + } 35 + } 36 + } 37 + } 38 + return map; 39 + } 40 + 41 + /** 42 + * Check if a cell at (col, row) is inside any merge region. 43 + * Returns the MergeData if found, null otherwise. 44 + */ 45 + export function findCellMerge(col: number, row: number, merges: Iterable<[string, unknown]>): MergeData | null { 46 + for (const [, mergeData] of merges) { 47 + const m = parseMerge(mergeData); 48 + if (col >= m.startCol && col <= m.endCol && row >= m.startRow && row <= m.endRow) { 49 + return m; 50 + } 51 + } 52 + return null; 53 + }
+22
src/sheets/save-indicator.ts
··· 1 + /** 2 + * Pure save-indicator formatting — no DOM, no shared state. 3 + */ 4 + 5 + export type SaveState = 'saving' | 'saved' | 'unsaved'; 6 + 7 + /** Format seconds since last save into a human-readable timestamp string. */ 8 + export function formatSaveTimestamp(seconds: number, prefix = 'Saved'): string { 9 + if (seconds < 5) return prefix; 10 + if (seconds < 60) return `${prefix} ${seconds}s ago`; 11 + return `${prefix} ${Math.floor(seconds / 60)} min ago`; 12 + } 13 + 14 + /** Get display text for a given save state, or null if the timestamp should be used. */ 15 + export function getSaveDisplayText(state: SaveState): string | null { 16 + switch (state) { 17 + case 'saving': return 'Saving\u2026'; 18 + case 'unsaved': return 'Unsaved changes'; 19 + case 'saved': return null; 20 + default: return null; 21 + } 22 + }
+41
src/sheets/selection-utils.ts
··· 1 + /** 2 + * Pure selection utility functions — no DOM, no shared state. 3 + */ 4 + import type { SelectionRange } from './types.js'; 5 + import { cellId } from './formulas.js'; 6 + 7 + export interface NormalizedRange { 8 + startCol: number; 9 + startRow: number; 10 + endCol: number; 11 + endRow: number; 12 + } 13 + 14 + /** Normalize a selection range so start <= end on both axes. */ 15 + export function normalizeRange(range: SelectionRange): NormalizedRange { 16 + return { 17 + startCol: Math.min(range.startCol, range.endCol), 18 + startRow: Math.min(range.startRow, range.endRow), 19 + endCol: Math.max(range.startCol, range.endCol), 20 + endRow: Math.max(range.startRow, range.endRow), 21 + }; 22 + } 23 + 24 + /** Check whether a cell (col, row) falls inside a selection range. */ 25 + export function isInRange(col: number, row: number, range: SelectionRange | null): boolean { 26 + if (!range) return false; 27 + const { startCol, startRow, endCol, endRow } = normalizeRange(range); 28 + return col >= startCol && col <= endCol && row >= startRow && row <= endRow; 29 + } 30 + 31 + /** 32 + * Build a range reference string like "A1" or "A1:C5". 33 + * Normalizes backward selections before generating the string. 34 + */ 35 + export function getRangeRefString(range: SelectionRange): string { 36 + const { startCol, startRow, endCol, endRow } = normalizeRange(range); 37 + if (startCol === endCol && startRow === endRow) { 38 + return cellId(startCol, startRow); 39 + } 40 + return cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 41 + }
+96
src/sheets/spill-tracking.ts
··· 1 + /** 2 + * Spill tracking for array formula results. 3 + * 4 + * Manages the display-layer mapping from source cells to their spilled targets. 5 + * Pure logic — receives all dependencies as parameters. 6 + */ 7 + import type { CellData, RangeArray } from './types.js'; 8 + import { parseRef, colToLetter } from './formulas.js'; 9 + 10 + export interface SpillInfo { 11 + rows: number; 12 + cols: number; 13 + data: unknown[]; 14 + } 15 + 16 + export interface SpillTargetInfo { 17 + source: string; 18 + value: unknown; 19 + } 20 + 21 + export interface SpillState { 22 + /** sourceId → spill dimensions + flat data array */ 23 + sources: Map<string, SpillInfo>; 24 + /** targetId → { source, value } */ 25 + targets: Map<string, SpillTargetInfo>; 26 + } 27 + 28 + export function createSpillState(): SpillState { 29 + return { 30 + sources: new Map(), 31 + targets: new Map(), 32 + }; 33 + } 34 + 35 + export function clearSpillMaps(state: SpillState): void { 36 + state.sources.clear(); 37 + state.targets.clear(); 38 + } 39 + 40 + /** 41 + * Register a spill range from a formula that returned an array. 42 + * Populates sources and targets maps for display. 43 + */ 44 + export function registerSpill( 45 + state: SpillState, 46 + sourceId: string, 47 + arr: RangeArray, 48 + getCellData: (id: string) => Partial<CellData> | null, 49 + maxRows: number, 50 + maxCols: number, 51 + ): void { 52 + const rows = arr._rangeRows || arr.length; 53 + const cols = arr._rangeCols || 1; 54 + const ref = parseRef(sourceId); 55 + if (!ref) return; 56 + 57 + state.sources.set(sourceId, { rows, cols, data: arr }); 58 + 59 + for (let r = 0; r < rows; r++) { 60 + for (let c = 0; c < cols; c++) { 61 + if (r === 0 && c === 0) continue; 62 + // Bounds check: skip spill targets beyond sheet dimensions 63 + if (ref.row + r > maxRows || ref.col + c > maxCols) continue; 64 + const targetId = colToLetter(ref.col + c) + (ref.row + r); 65 + const idx = r * cols + c; 66 + // Check for collision: target has real data 67 + const targetData = getCellData(targetId); 68 + if (targetData && (targetData.f || (targetData.v !== '' && targetData.v !== undefined && targetData.v !== null))) { 69 + // Collision — mark source as #SPILL! 70 + state.sources.set(sourceId, { rows: 0, cols: 0, data: ['#SPILL!'] }); 71 + // Clear any targets already registered for this source 72 + for (const [tid, info] of state.targets) { 73 + if (info.source === sourceId) state.targets.delete(tid); 74 + } 75 + return; 76 + } 77 + state.targets.set(targetId, { source: sourceId, value: arr[idx] ?? '' }); 78 + } 79 + } 80 + } 81 + 82 + /** Check if a cell is a spill source (has spilled array results). */ 83 + export function isSpillSource(state: SpillState, id: string): boolean { 84 + const info = state.sources.get(id); 85 + return !!info && info.rows > 0; 86 + } 87 + 88 + /** Check if a cell is a spill target (displays a value from another cell's spill). */ 89 + export function isSpillTarget(state: SpillState, id: string): boolean { 90 + return state.targets.has(id); 91 + } 92 + 93 + /** Get the display value for a spill target cell, or undefined if not a target. */ 94 + export function getSpillTargetValue(state: SpillState, id: string): unknown | undefined { 95 + return state.targets.get(id)?.value; 96 + }
+146
tests/cell-style-utils.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + hexLuminance, 4 + contrastTextColor, 5 + getCellBgColor, 6 + getCellBgStyle, 7 + getCellStyle, 8 + } from '../src/sheets/cell-style-utils.js'; 9 + 10 + describe('hexLuminance', () => { 11 + it('returns 0 for black', () => { 12 + expect(hexLuminance('#000000')).toBeCloseTo(0, 4); 13 + }); 14 + 15 + it('returns 1 for white', () => { 16 + expect(hexLuminance('#ffffff')).toBeCloseTo(1, 4); 17 + }); 18 + 19 + it('handles shorthand hex (#rgb)', () => { 20 + expect(hexLuminance('#fff')).toBeCloseTo(1, 4); 21 + expect(hexLuminance('#000')).toBeCloseTo(0, 4); 22 + }); 23 + 24 + it('returns mid-range for grey', () => { 25 + const lum = hexLuminance('#808080'); 26 + expect(lum).toBeGreaterThan(0.1); 27 + expect(lum).toBeLessThan(0.5); 28 + }); 29 + }); 30 + 31 + describe('contrastTextColor', () => { 32 + it('returns dark text for light backgrounds', () => { 33 + expect(contrastTextColor('#ffffff')).toBe('#1a1815'); 34 + expect(contrastTextColor('#ffff00')).toBe('#1a1815'); 35 + }); 36 + 37 + it('returns white text for dark backgrounds', () => { 38 + expect(contrastTextColor('#000000')).toBe('#ffffff'); 39 + expect(contrastTextColor('#003366')).toBe('#ffffff'); 40 + }); 41 + }); 42 + 43 + describe('getCellBgColor', () => { 44 + it('returns explicit cell bg color', () => { 45 + expect(getCellBgColor({ s: { bg: '#ff0000' } } as any, '')).toBe('#ff0000'); 46 + }); 47 + 48 + it('returns CF background when no explicit bg', () => { 49 + expect(getCellBgColor(null, 'background:#00ff00;color:red;')).toBe('#00ff00'); 50 + }); 51 + 52 + it('prefers explicit bg over CF', () => { 53 + expect(getCellBgColor({ s: { bg: '#ff0000' } } as any, 'background:#00ff00;')).toBe('#ff0000'); 54 + }); 55 + 56 + it('returns empty string when no bg', () => { 57 + expect(getCellBgColor(null, '')).toBe(''); 58 + expect(getCellBgColor(undefined, '')).toBe(''); 59 + }); 60 + }); 61 + 62 + describe('getCellBgStyle', () => { 63 + it('returns background CSS when color exists', () => { 64 + expect(getCellBgStyle({ s: { bg: '#ff0000' } } as any, '')).toBe('background:#ff0000;'); 65 + }); 66 + 67 + it('returns empty string when no bg', () => { 68 + expect(getCellBgStyle(null, '')).toBe(''); 69 + }); 70 + }); 71 + 72 + describe('getCellStyle', () => { 73 + it('returns empty string for null cell data and no CF', () => { 74 + expect(getCellStyle(null, '')).toBe(''); 75 + }); 76 + 77 + it('renders bold', () => { 78 + const style = getCellStyle({ s: { bold: true } } as any, ''); 79 + expect(style).toContain('font-weight:600;'); 80 + }); 81 + 82 + it('renders italic', () => { 83 + const style = getCellStyle({ s: { italic: true } } as any, ''); 84 + expect(style).toContain('font-style:italic;'); 85 + }); 86 + 87 + it('renders font size', () => { 88 + const style = getCellStyle({ s: { fontSize: 14 } } as any, ''); 89 + expect(style).toContain('font-size:14pt;'); 90 + }); 91 + 92 + it('renders font family', () => { 93 + const style = getCellStyle({ s: { fontFamily: 'monospace' } } as any, ''); 94 + expect(style).toContain('font-family:ui-monospace'); 95 + }); 96 + 97 + it('combines underline and strikethrough', () => { 98 + const style = getCellStyle({ s: { underline: true, strikethrough: true } } as any, ''); 99 + expect(style).toContain('text-decoration:underline line-through;'); 100 + }); 101 + 102 + it('renders text alignment', () => { 103 + expect(getCellStyle({ s: { align: 'right' } } as any, '')).toContain('justify-content:flex-end;'); 104 + expect(getCellStyle({ s: { align: 'center' } } as any, '')).toContain('justify-content:center;'); 105 + expect(getCellStyle({ s: { align: 'left' } } as any, '')).toContain('justify-content:flex-start;'); 106 + }); 107 + 108 + it('renders vertical alignment', () => { 109 + expect(getCellStyle({ s: { verticalAlign: 'middle' } } as any, '')).toContain('align-items:center;'); 110 + expect(getCellStyle({ s: { verticalAlign: 'bottom' } } as any, '')).toContain('align-items:flex-end;'); 111 + }); 112 + 113 + it('renders word wrap', () => { 114 + const style = getCellStyle({ s: { wrap: true } } as any, ''); 115 + expect(style).toContain('white-space:normal;'); 116 + expect(style).toContain('word-wrap:break-word;'); 117 + }); 118 + 119 + it('applies explicit text color', () => { 120 + const style = getCellStyle({ s: { color: '#ff0000' } } as any, ''); 121 + expect(style).toContain('color:#ff0000;'); 122 + }); 123 + 124 + it('skips theme-default colors', () => { 125 + // #1a1815 is the light-mode default — should not emit inline color 126 + expect(getCellStyle({ s: { color: '#1a1815' } } as any, '')).not.toContain('color:#1a1815'); 127 + expect(getCellStyle({ s: { color: '#ddd8ce' } } as any, '')).not.toContain('color:#ddd8ce'); 128 + }); 129 + 130 + it('auto-contrasts: adds white text on dark bg', () => { 131 + const style = getCellStyle({ s: { bg: '#000000' } } as any, ''); 132 + expect(style).toContain('color:#ffffff;'); 133 + }); 134 + 135 + it('auto-contrasts: adds dark text on light bg', () => { 136 + const style = getCellStyle({ s: { bg: '#ffff00' } } as any, ''); 137 + expect(style).toContain('color:#1a1815;'); 138 + }); 139 + 140 + it('strips CF background from inline style (bg goes on td)', () => { 141 + const style = getCellStyle(null, 'background:#ff0000;color:#ffffff;'); 142 + // CF color should be applied, but background should NOT be in the cell style 143 + expect(style).not.toContain('background:'); 144 + expect(style).toContain('color:'); 145 + }); 146 + });
+39
tests/conditional-format.test.ts
··· 6 6 import { 7 7 evaluateRule, evaluateRules, buildCfStyle, 8 8 parseHex, toHex, lerpColor, colorScaleBg, computeColorScale, 9 + formatRuleLabel, 9 10 } from '../src/sheets/conditional-format.js'; 10 11 11 12 describe('evaluateRule', () => { ··· 448 449 expect(result.get('A2')!.bgColor).toBe('#63be7b'); // Excel default green 449 450 }); 450 451 }); 452 + 453 + describe('formatRuleLabel', () => { 454 + it('formats greaterThan', () => { 455 + expect(formatRuleLabel({ type: 'greaterThan', value: '10' } as any)).toBe('Greater than 10'); 456 + }); 457 + 458 + it('formats lessThan', () => { 459 + expect(formatRuleLabel({ type: 'lessThan', value: '5' } as any)).toBe('Less than 5'); 460 + }); 461 + 462 + it('formats equalTo', () => { 463 + expect(formatRuleLabel({ type: 'equalTo', value: 'abc' } as any)).toBe('Equal to abc'); 464 + }); 465 + 466 + it('formats between', () => { 467 + expect(formatRuleLabel({ type: 'between', value: '1', value2: '10' } as any)).toBe('Between 1 and 10'); 468 + }); 469 + 470 + it('formats textContains', () => { 471 + expect(formatRuleLabel({ type: 'textContains', value: 'hello' } as any)).toBe('Text contains "hello"'); 472 + }); 473 + 474 + it('formats isEmpty', () => { 475 + expect(formatRuleLabel({ type: 'isEmpty' } as any)).toBe('Is empty'); 476 + }); 477 + 478 + it('formats isNotEmpty', () => { 479 + expect(formatRuleLabel({ type: 'isNotEmpty' } as any)).toBe('Is not empty'); 480 + }); 481 + 482 + it('falls back to type name for unknown types', () => { 483 + expect(formatRuleLabel({ type: 'custom' } as any)).toBe('custom'); 484 + }); 485 + 486 + it('handles missing value gracefully', () => { 487 + expect(formatRuleLabel({ type: 'greaterThan' } as any)).toBe('Greater than '); 488 + }); 489 + });
+97
tests/csv-utils.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { parseCSVLine, detectHeaders } from '../src/sheets/csv-utils.js'; 3 + 4 + describe('parseCSVLine', () => { 5 + it('parses simple comma-separated values', () => { 6 + expect(parseCSVLine('a,b,c')).toEqual(['a', 'b', 'c']); 7 + }); 8 + 9 + it('handles quoted fields', () => { 10 + expect(parseCSVLine('"hello","world"')).toEqual(['hello', 'world']); 11 + }); 12 + 13 + it('handles commas inside quotes', () => { 14 + expect(parseCSVLine('"a,b",c')).toEqual(['a,b', 'c']); 15 + }); 16 + 17 + it('handles escaped double quotes', () => { 18 + expect(parseCSVLine('"say ""hello""",ok')).toEqual(['say "hello"', 'ok']); 19 + }); 20 + 21 + it('handles empty fields', () => { 22 + expect(parseCSVLine('a,,c')).toEqual(['a', '', 'c']); 23 + }); 24 + 25 + it('handles single field', () => { 26 + expect(parseCSVLine('hello')).toEqual(['hello']); 27 + }); 28 + 29 + it('handles empty string', () => { 30 + expect(parseCSVLine('')).toEqual(['']); 31 + }); 32 + 33 + it('handles mixed quoted and unquoted', () => { 34 + expect(parseCSVLine('a,"b,c",d')).toEqual(['a', 'b,c', 'd']); 35 + }); 36 + 37 + it('handles newlines inside quotes', () => { 38 + expect(parseCSVLine('"line1\nline2",b')).toEqual(['line1\nline2', 'b']); 39 + }); 40 + }); 41 + 42 + describe('detectHeaders', () => { 43 + it('returns true for text header with numeric data', () => { 44 + expect(detectHeaders([ 45 + ['Name', 'Age', 'Score'], 46 + ['Alice', '30', '95'], 47 + ['Bob', '25', '87'], 48 + ])).toBe(true); 49 + }); 50 + 51 + it('returns false for single row', () => { 52 + expect(detectHeaders([['Name', 'Age']])).toBe(false); 53 + }); 54 + 55 + it('returns false when first row has numbers', () => { 56 + expect(detectHeaders([ 57 + ['1', '2', '3'], 58 + ['4', '5', '6'], 59 + ])).toBe(false); 60 + }); 61 + 62 + it('returns false when first row is all empty', () => { 63 + expect(detectHeaders([ 64 + ['', '', ''], 65 + ['a', 'b', 'c'], 66 + ])).toBe(false); 67 + }); 68 + 69 + it('returns false when first row has duplicate values', () => { 70 + expect(detectHeaders([ 71 + ['Name', 'Name', 'Score'], 72 + ['Alice', 'Bob', '95'], 73 + ])).toBe(false); 74 + }); 75 + 76 + it('returns false when data rows have no numbers', () => { 77 + expect(detectHeaders([ 78 + ['Col1', 'Col2'], 79 + ['abc', 'def'], 80 + ['ghi', 'jkl'], 81 + ])).toBe(false); 82 + }); 83 + 84 + it('treats empty first-row cells as non-blocking', () => { 85 + expect(detectHeaders([ 86 + ['Name', '', 'Score'], 87 + ['Alice', '', '95'], 88 + ])).toBe(true); 89 + }); 90 + 91 + it('is case-insensitive for duplicate detection', () => { 92 + expect(detectHeaders([ 93 + ['name', 'Name', 'score'], 94 + ['a', 'b', '1'], 95 + ])).toBe(false); 96 + }); 97 + });
+51
tests/escape-html.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { escapeHtml } from '../src/lib/escape-html.js'; 3 + 4 + describe('escapeHtml', () => { 5 + it('escapes ampersands', () => { 6 + expect(escapeHtml('A & B')).toBe('A &amp; B'); 7 + }); 8 + 9 + it('escapes less-than', () => { 10 + expect(escapeHtml('a < b')).toBe('a &lt; b'); 11 + }); 12 + 13 + it('escapes greater-than', () => { 14 + expect(escapeHtml('a > b')).toBe('a &gt; b'); 15 + }); 16 + 17 + it('escapes double quotes', () => { 18 + expect(escapeHtml('say "hello"')).toBe('say &quot;hello&quot;'); 19 + }); 20 + 21 + it('escapes all special characters together', () => { 22 + expect(escapeHtml('<script>alert("xss")</script>')).toBe( 23 + '&lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;' 24 + ); 25 + }); 26 + 27 + it('returns empty string for null', () => { 28 + expect(escapeHtml(null)).toBe(''); 29 + }); 30 + 31 + it('returns empty string for undefined', () => { 32 + expect(escapeHtml(undefined)).toBe(''); 33 + }); 34 + 35 + it('coerces numbers to strings', () => { 36 + expect(escapeHtml(42)).toBe('42'); 37 + expect(escapeHtml(0)).toBe('0'); 38 + }); 39 + 40 + it('coerces booleans to strings', () => { 41 + expect(escapeHtml(true)).toBe('true'); 42 + }); 43 + 44 + it('leaves normal text unchanged', () => { 45 + expect(escapeHtml('Hello world')).toBe('Hello world'); 46 + }); 47 + 48 + it('handles empty string', () => { 49 + expect(escapeHtml('')).toBe(''); 50 + }); 51 + });
+107
tests/merge-utils.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { buildMergeMap, findCellMerge } from '../src/sheets/merge-utils.js'; 3 + 4 + describe('buildMergeMap', () => { 5 + it('returns empty map for no merges', () => { 6 + const map = buildMergeMap([]); 7 + expect(map.size).toBe(0); 8 + }); 9 + 10 + it('maps a simple 2x2 merge', () => { 11 + const merges: [string, unknown][] = [ 12 + ['m1', { startCol: 1, startRow: 1, endCol: 2, endRow: 2 }], 13 + ]; 14 + const map = buildMergeMap(merges); 15 + expect(map.size).toBe(4); 16 + 17 + // Origin cell: not hidden, has colspan/rowspan 18 + const origin = map.get('A1'); 19 + expect(origin).toBeDefined(); 20 + expect(origin!.hidden).toBe(false); 21 + expect(origin!.colspan).toBe(2); 22 + expect(origin!.rowspan).toBe(2); 23 + 24 + // Other cells: hidden 25 + expect(map.get('B1')!.hidden).toBe(true); 26 + expect(map.get('A2')!.hidden).toBe(true); 27 + expect(map.get('B2')!.hidden).toBe(true); 28 + }); 29 + 30 + it('maps a 1x3 horizontal merge', () => { 31 + const merges: [string, unknown][] = [ 32 + ['m1', { startCol: 2, startRow: 5, endCol: 4, endRow: 5 }], 33 + ]; 34 + const map = buildMergeMap(merges); 35 + expect(map.size).toBe(3); 36 + 37 + const origin = map.get('B5'); 38 + expect(origin!.hidden).toBe(false); 39 + expect(origin!.colspan).toBe(3); 40 + expect(origin!.rowspan).toBe(1); 41 + 42 + expect(map.get('C5')!.hidden).toBe(true); 43 + expect(map.get('D5')!.hidden).toBe(true); 44 + }); 45 + 46 + it('handles JSON-string merge data', () => { 47 + const merges: [string, unknown][] = [ 48 + ['m1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 1, endRow: 3 })], 49 + ]; 50 + const map = buildMergeMap(merges); 51 + expect(map.size).toBe(3); 52 + expect(map.get('A1')!.hidden).toBe(false); 53 + expect(map.get('A1')!.rowspan).toBe(3); 54 + expect(map.get('A2')!.hidden).toBe(true); 55 + expect(map.get('A3')!.hidden).toBe(true); 56 + }); 57 + 58 + it('handles multiple non-overlapping merges', () => { 59 + const merges: [string, unknown][] = [ 60 + ['m1', { startCol: 1, startRow: 1, endCol: 2, endRow: 1 }], 61 + ['m2', { startCol: 4, startRow: 3, endCol: 5, endRow: 4 }], 62 + ]; 63 + const map = buildMergeMap(merges); 64 + expect(map.size).toBe(6); // 2 from m1 + 4 from m2 65 + 66 + expect(map.get('A1')!.colspan).toBe(2); 67 + expect(map.get('D3')!.colspan).toBe(2); 68 + expect(map.get('D3')!.rowspan).toBe(2); 69 + }); 70 + }); 71 + 72 + describe('findCellMerge', () => { 73 + const merges: [string, unknown][] = [ 74 + ['m1', { startCol: 2, startRow: 2, endCol: 4, endRow: 3 }], 75 + ]; 76 + 77 + it('returns merge data for cell inside merge', () => { 78 + const result = findCellMerge(3, 2, merges); 79 + expect(result).toEqual({ startCol: 2, startRow: 2, endCol: 4, endRow: 3 }); 80 + }); 81 + 82 + it('returns merge data for origin cell', () => { 83 + expect(findCellMerge(2, 2, merges)).not.toBeNull(); 84 + }); 85 + 86 + it('returns merge data for corner cell', () => { 87 + expect(findCellMerge(4, 3, merges)).not.toBeNull(); 88 + }); 89 + 90 + it('returns null for cell outside merge', () => { 91 + expect(findCellMerge(1, 1, merges)).toBeNull(); 92 + expect(findCellMerge(5, 2, merges)).toBeNull(); 93 + expect(findCellMerge(3, 4, merges)).toBeNull(); 94 + }); 95 + 96 + it('returns null for empty merges', () => { 97 + expect(findCellMerge(1, 1, [])).toBeNull(); 98 + }); 99 + 100 + it('handles JSON-string merge data', () => { 101 + const jsonMerges: [string, unknown][] = [ 102 + ['m1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 2, endRow: 2 })], 103 + ]; 104 + expect(findCellMerge(1, 1, jsonMerges)).not.toBeNull(); 105 + expect(findCellMerge(3, 3, jsonMerges)).toBeNull(); 106 + }); 107 + });
+53
tests/save-indicator.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { formatSaveTimestamp, getSaveDisplayText } from '../src/sheets/save-indicator.js'; 3 + 4 + describe('formatSaveTimestamp', () => { 5 + it('shows "Saved" for less than 5 seconds', () => { 6 + expect(formatSaveTimestamp(0)).toBe('Saved'); 7 + expect(formatSaveTimestamp(1)).toBe('Saved'); 8 + expect(formatSaveTimestamp(4)).toBe('Saved'); 9 + }); 10 + 11 + it('shows seconds for 5-59 seconds', () => { 12 + expect(formatSaveTimestamp(5)).toBe('Saved 5s ago'); 13 + expect(formatSaveTimestamp(30)).toBe('Saved 30s ago'); 14 + expect(formatSaveTimestamp(59)).toBe('Saved 59s ago'); 15 + }); 16 + 17 + it('shows minutes for 60+ seconds', () => { 18 + expect(formatSaveTimestamp(60)).toBe('Saved 1 min ago'); 19 + expect(formatSaveTimestamp(90)).toBe('Saved 1 min ago'); 20 + expect(formatSaveTimestamp(120)).toBe('Saved 2 min ago'); 21 + expect(formatSaveTimestamp(3600)).toBe('Saved 60 min ago'); 22 + }); 23 + 24 + it('uses custom prefix', () => { 25 + expect(formatSaveTimestamp(0, 'Saved locally')).toBe('Saved locally'); 26 + expect(formatSaveTimestamp(10, 'Saved locally')).toBe('Saved locally 10s ago'); 27 + expect(formatSaveTimestamp(120, 'Saved locally')).toBe('Saved locally 2 min ago'); 28 + }); 29 + 30 + it('handles boundary at 5 seconds', () => { 31 + expect(formatSaveTimestamp(4)).toBe('Saved'); 32 + expect(formatSaveTimestamp(5)).toBe('Saved 5s ago'); 33 + }); 34 + 35 + it('handles boundary at 60 seconds', () => { 36 + expect(formatSaveTimestamp(59)).toBe('Saved 59s ago'); 37 + expect(formatSaveTimestamp(60)).toBe('Saved 1 min ago'); 38 + }); 39 + }); 40 + 41 + describe('getSaveDisplayText', () => { 42 + it('returns "Saving…" for saving state', () => { 43 + expect(getSaveDisplayText('saving')).toBe('Saving\u2026'); 44 + }); 45 + 46 + it('returns "Unsaved changes" for unsaved state', () => { 47 + expect(getSaveDisplayText('unsaved')).toBe('Unsaved changes'); 48 + }); 49 + 50 + it('returns null for saved state (defers to timestamp)', () => { 51 + expect(getSaveDisplayText('saved')).toBeNull(); 52 + }); 53 + });
+57
tests/selection-utils.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { normalizeRange, isInRange } from '../src/sheets/selection-utils.js'; 3 + 4 + describe('normalizeRange', () => { 5 + it('returns same values when already normalized', () => { 6 + expect(normalizeRange({ startCol: 1, startRow: 1, endCol: 3, endRow: 5 })) 7 + .toEqual({ startCol: 1, startRow: 1, endCol: 3, endRow: 5 }); 8 + }); 9 + 10 + it('swaps when start > end', () => { 11 + expect(normalizeRange({ startCol: 5, startRow: 10, endCol: 2, endRow: 3 })) 12 + .toEqual({ startCol: 2, startRow: 3, endCol: 5, endRow: 10 }); 13 + }); 14 + 15 + it('handles mixed: col reversed, row normal', () => { 16 + expect(normalizeRange({ startCol: 4, startRow: 1, endCol: 1, endRow: 3 })) 17 + .toEqual({ startCol: 1, startRow: 1, endCol: 4, endRow: 3 }); 18 + }); 19 + 20 + it('handles single cell (start === end)', () => { 21 + expect(normalizeRange({ startCol: 3, startRow: 7, endCol: 3, endRow: 7 })) 22 + .toEqual({ startCol: 3, startRow: 7, endCol: 3, endRow: 7 }); 23 + }); 24 + }); 25 + 26 + describe('isInRange', () => { 27 + it('returns true for cell inside range', () => { 28 + const range = { startCol: 2, startRow: 2, endCol: 5, endRow: 5 }; 29 + expect(isInRange(3, 3, range)).toBe(true); 30 + }); 31 + 32 + it('returns true for cell on range boundary', () => { 33 + const range = { startCol: 2, startRow: 2, endCol: 5, endRow: 5 }; 34 + expect(isInRange(2, 2, range)).toBe(true); 35 + expect(isInRange(5, 5, range)).toBe(true); 36 + expect(isInRange(2, 5, range)).toBe(true); 37 + expect(isInRange(5, 2, range)).toBe(true); 38 + }); 39 + 40 + it('returns false for cell outside range', () => { 41 + const range = { startCol: 2, startRow: 2, endCol: 5, endRow: 5 }; 42 + expect(isInRange(1, 3, range)).toBe(false); 43 + expect(isInRange(6, 3, range)).toBe(false); 44 + expect(isInRange(3, 1, range)).toBe(false); 45 + expect(isInRange(3, 6, range)).toBe(false); 46 + }); 47 + 48 + it('returns false for null range', () => { 49 + expect(isInRange(1, 1, null)).toBe(false); 50 + }); 51 + 52 + it('works with reversed range (auto-normalizes)', () => { 53 + const range = { startCol: 5, startRow: 5, endCol: 2, endRow: 2 }; 54 + expect(isInRange(3, 3, range)).toBe(true); 55 + expect(isInRange(1, 1, range)).toBe(false); 56 + }); 57 + });
+37 -280
tests/sheets-logic.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 2 import { cellId, colToLetter, parseRef, letterToCol } from '../src/sheets/formulas.js'; 3 - 4 - /** 5 - * Tests for spreadsheet pure logic functions: 6 - * - Cell merging (buildMergeMap, isCellMerged) 7 - * - Selection range (normalizeRange, range reference string) 8 - * - Autosave indicator (relative time formatting) 9 - * - Keyboard shortcuts modal (data structure) 10 - * 11 - * These functions are defined inline in main.js but we replicate 12 - * the pure logic here for testing. 13 - */ 14 - 15 - // ============================================================ 16 - // normalizeRange — ensures startCol <= endCol, startRow <= endRow 17 - // ============================================================ 18 - function normalizeRange(range) { 19 - return { 20 - startCol: Math.min(range.startCol, range.endCol), 21 - startRow: Math.min(range.startRow, range.endRow), 22 - endCol: Math.max(range.startCol, range.endCol), 23 - endRow: Math.max(range.startRow, range.endRow), 24 - }; 25 - } 3 + import { normalizeRange, isInRange, getRangeRefString } from '../src/sheets/selection-utils.js'; 4 + import { buildMergeMap, findCellMerge } from '../src/sheets/merge-utils.js'; 5 + import { formatSaveTimestamp, getSaveDisplayText } from '../src/sheets/save-indicator.js'; 26 6 27 7 describe('normalizeRange', () => { 28 8 it('returns same range when already normalized', () => { ··· 51 31 }); 52 32 }); 53 33 54 - // ============================================================ 55 - // Range reference string generation ("A1:C5") 56 - // ============================================================ 57 - function getRangeRefString(range) { 58 - const { startCol, startRow, endCol, endRow } = normalizeRange(range); 59 - if (startCol === endCol && startRow === endRow) { 60 - return cellId(startCol, startRow); 61 - } 62 - return cellId(startCol, startRow) + ':' + cellId(endCol, endRow); 63 - } 64 - 65 34 describe('getRangeRefString', () => { 66 35 it('returns single cell for 1x1 range', () => { 67 36 expect(getRangeRefString({ startCol: 1, startRow: 1, endCol: 1, endRow: 1 })).toBe('A1'); ··· 89 58 }); 90 59 }); 91 60 92 - // ============================================================ 93 - // isInRange — checks if a cell is within a selection range 94 - // ============================================================ 95 - function isInRange(col, row, selectionRange) { 96 - if (!selectionRange) return false; 97 - const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange); 98 - return col >= startCol && col <= endCol && row >= startRow && row <= endRow; 99 - } 100 - 101 61 describe('isInRange', () => { 102 62 const range = { startCol: 2, startRow: 3, endCol: 5, endRow: 8 }; 103 63 ··· 125 85 }); 126 86 }); 127 87 128 - // ============================================================ 129 - // Cell merge logic (replicated from main.js) 130 - // ============================================================ 131 - function buildMergeMap(merges) { 132 - const map = new Map(); 133 - for (const [key, mergeJson] of merges) { 134 - const m = typeof mergeJson === 'string' ? JSON.parse(mergeJson) : mergeJson; 135 - for (let r = m.startRow; r <= m.endRow; r++) { 136 - for (let c = m.startCol; c <= m.endCol; c++) { 137 - const id = cellId(c, r); 138 - if (c === m.startCol && r === m.startRow) { 139 - map.set(id, { 140 - hidden: false, 141 - merge: m, 142 - colspan: m.endCol - m.startCol + 1, 143 - rowspan: m.endRow - m.startRow + 1, 144 - }); 145 - } else { 146 - map.set(id, { hidden: true, merge: m }); 147 - } 148 - } 149 - } 150 - } 151 - return map; 152 - } 153 - 154 - function isCellMerged(col, row, merges) { 155 - for (const [key, mergeJson] of merges) { 156 - const m = typeof mergeJson === 'string' ? JSON.parse(mergeJson) : mergeJson; 157 - if (col >= m.startCol && col <= m.endCol && row >= m.startRow && row <= m.endRow) return m; 158 - } 159 - return null; 160 - } 161 - 162 - describe('buildMergeMap', () => { 88 + describe('buildMergeMap (imported)', () => { 163 89 it('creates entries for a simple 2x2 merge', () => { 164 - const merges = new Map([ 90 + const merges: [string, unknown][] = [ 165 91 ['A1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 2, endRow: 2 })], 166 - ]); 92 + ]; 167 93 const map = buildMergeMap(merges); 168 94 169 - expect(map.size).toBe(4); // A1, B1, A2, B2 170 - expect(map.get('A1').hidden).toBe(false); 171 - expect(map.get('A1').colspan).toBe(2); 172 - expect(map.get('A1').rowspan).toBe(2); 173 - expect(map.get('B1').hidden).toBe(true); 174 - expect(map.get('A2').hidden).toBe(true); 175 - expect(map.get('B2').hidden).toBe(true); 95 + expect(map.size).toBe(4); 96 + expect(map.get('A1')!.hidden).toBe(false); 97 + expect(map.get('A1')!.colspan).toBe(2); 98 + expect(map.get('A1')!.rowspan).toBe(2); 99 + expect(map.get('B1')!.hidden).toBe(true); 100 + expect(map.get('A2')!.hidden).toBe(true); 101 + expect(map.get('B2')!.hidden).toBe(true); 176 102 }); 177 103 178 - it('creates entries for a 1x3 horizontal merge', () => { 179 - const merges = new Map([ 180 - ['A1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 3, endRow: 1 })], 181 - ]); 104 + it('handles object merge data (not JSON string)', () => { 105 + const merges: [string, unknown][] = [ 106 + ['A1', { startCol: 1, startRow: 1, endCol: 2, endRow: 2 }], 107 + ]; 182 108 const map = buildMergeMap(merges); 183 - 184 - expect(map.size).toBe(3); 185 - expect(map.get('A1').hidden).toBe(false); 186 - expect(map.get('A1').colspan).toBe(3); 187 - expect(map.get('A1').rowspan).toBe(1); 188 - expect(map.get('B1').hidden).toBe(true); 189 - expect(map.get('C1').hidden).toBe(true); 190 - }); 191 - 192 - it('creates entries for a 3x1 vertical merge', () => { 193 - const merges = new Map([ 194 - ['A1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 1, endRow: 3 })], 195 - ]); 196 - const map = buildMergeMap(merges); 197 - 198 - expect(map.size).toBe(3); 199 - expect(map.get('A1').hidden).toBe(false); 200 - expect(map.get('A1').colspan).toBe(1); 201 - expect(map.get('A1').rowspan).toBe(3); 202 - expect(map.get('A2').hidden).toBe(true); 203 - expect(map.get('A3').hidden).toBe(true); 204 - }); 205 - 206 - it('handles multiple non-overlapping merges', () => { 207 - const merges = new Map([ 208 - ['A1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 2, endRow: 1 })], 209 - ['D1', JSON.stringify({ startCol: 4, startRow: 1, endCol: 5, endRow: 2 })], 210 - ]); 211 - const map = buildMergeMap(merges); 212 - 213 - expect(map.size).toBe(6); // 2 for first + 4 for second 214 - expect(map.get('A1').hidden).toBe(false); 215 - expect(map.get('B1').hidden).toBe(true); 216 - expect(map.get('D1').hidden).toBe(false); 217 - expect(map.get('E1').hidden).toBe(true); 218 - expect(map.get('D2').hidden).toBe(true); 219 - expect(map.get('E2').hidden).toBe(true); 109 + expect(map.size).toBe(4); 110 + expect(map.get('A1')!.hidden).toBe(false); 220 111 }); 221 112 222 113 it('returns empty map for no merges', () => { 223 - const map = buildMergeMap(new Map()); 114 + const map = buildMergeMap([]); 224 115 expect(map.size).toBe(0); 225 116 }); 226 - 227 - it('handles object merge data (not JSON string)', () => { 228 - const merges = new Map([ 229 - ['A1', { startCol: 1, startRow: 1, endCol: 2, endRow: 2 }], 230 - ]); 231 - const map = buildMergeMap(merges); 232 - expect(map.size).toBe(4); 233 - expect(map.get('A1').hidden).toBe(false); 234 - }); 235 117 }); 236 118 237 - describe('isCellMerged', () => { 238 - const merges = new Map([ 119 + describe('findCellMerge (imported)', () => { 120 + const merges: [string, unknown][] = [ 239 121 ['A1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 3, endRow: 3 })], 240 - ]); 122 + ]; 241 123 242 124 it('returns merge info for cells within merge region', () => { 243 - expect(isCellMerged(1, 1, merges)).toEqual({ startCol: 1, startRow: 1, endCol: 3, endRow: 3 }); 244 - expect(isCellMerged(2, 2, merges)).toEqual({ startCol: 1, startRow: 1, endCol: 3, endRow: 3 }); 245 - expect(isCellMerged(3, 3, merges)).toEqual({ startCol: 1, startRow: 1, endCol: 3, endRow: 3 }); 125 + expect(findCellMerge(1, 1, merges)).toEqual({ startCol: 1, startRow: 1, endCol: 3, endRow: 3 }); 126 + expect(findCellMerge(2, 2, merges)).toEqual({ startCol: 1, startRow: 1, endCol: 3, endRow: 3 }); 127 + expect(findCellMerge(3, 3, merges)).toEqual({ startCol: 1, startRow: 1, endCol: 3, endRow: 3 }); 246 128 }); 247 129 248 130 it('returns null for cells outside merge region', () => { 249 - expect(isCellMerged(4, 1, merges)).toBeNull(); 250 - expect(isCellMerged(1, 4, merges)).toBeNull(); 251 - expect(isCellMerged(10, 10, merges)).toBeNull(); 131 + expect(findCellMerge(4, 1, merges)).toBeNull(); 132 + expect(findCellMerge(1, 4, merges)).toBeNull(); 133 + expect(findCellMerge(10, 10, merges)).toBeNull(); 252 134 }); 253 135 254 136 it('returns null when no merges exist', () => { 255 - expect(isCellMerged(1, 1, new Map())).toBeNull(); 137 + expect(findCellMerge(1, 1, [])).toBeNull(); 256 138 }); 257 139 258 140 it('finds correct merge when multiple exist', () => { 259 - const multiMerges = new Map([ 141 + const multiMerges: [string, unknown][] = [ 260 142 ['A1', JSON.stringify({ startCol: 1, startRow: 1, endCol: 2, endRow: 2 })], 261 143 ['D4', JSON.stringify({ startCol: 4, startRow: 4, endCol: 5, endRow: 5 })], 262 - ]); 263 - expect(isCellMerged(1, 1, multiMerges)).toEqual({ startCol: 1, startRow: 1, endCol: 2, endRow: 2 }); 264 - expect(isCellMerged(4, 5, multiMerges)).toEqual({ startCol: 4, startRow: 4, endCol: 5, endRow: 5 }); 265 - expect(isCellMerged(3, 3, multiMerges)).toBeNull(); 144 + ]; 145 + expect(findCellMerge(1, 1, multiMerges)).toEqual({ startCol: 1, startRow: 1, endCol: 2, endRow: 2 }); 146 + expect(findCellMerge(4, 5, multiMerges)).toEqual({ startCol: 4, startRow: 4, endCol: 5, endRow: 5 }); 147 + expect(findCellMerge(3, 3, multiMerges)).toBeNull(); 266 148 }); 267 149 }); 268 - 269 - // ============================================================ 270 - // Autosave indicator — relative time formatting 271 - // ============================================================ 272 - function formatSaveTimestamp(seconds) { 273 - if (seconds < 5) return 'Saved'; 274 - if (seconds < 60) return `Saved ${seconds}s ago`; 275 - return `Saved ${Math.floor(seconds / 60)} min ago`; 276 - } 277 150 278 151 describe('formatSaveTimestamp', () => { 279 152 it('shows "Saved" for less than 5 seconds', () => { ··· 306 179 }); 307 180 }); 308 181 309 - // ============================================================ 310 - // Save state machine 311 - // ============================================================ 312 - function getSaveDisplayText(state) { 313 - switch (state) { 314 - case 'saving': return 'Saving\u2026'; 315 - case 'unsaved': return 'Unsaved changes'; 316 - case 'saved': return null; // uses timestamp function 317 - default: return null; 318 - } 319 - } 320 - 321 182 describe('Save state display text', () => { 322 183 it('shows "Saving..." for saving state', () => { 323 184 expect(getSaveDisplayText('saving')).toBe('Saving\u2026'); ··· 406 267 }); 407 268 }); 408 269 409 - // ============================================================ 410 - // getCellStyle — inline style generation 411 - // ============================================================ 412 - function getCellStyle(cellData) { 413 - if (!cellData?.s) return ''; 414 - const s = cellData.s; 415 - let style = ''; 416 - if (s.color) style += 'color:' + s.color + ';'; 417 - if (s.bg) style += 'background:' + s.bg + ';'; 418 - if (s.bold) style += 'font-weight:600;'; 419 - if (s.italic) style += 'font-style:italic;'; 420 - if (s.align) style += 'justify-content:' + (s.align === 'left' ? 'flex-start' : s.align === 'right' ? 'flex-end' : 'center') + ';'; 421 - return style; 422 - } 423 - 424 - describe('getCellStyle', () => { 425 - it('returns empty string for null/undefined cell data', () => { 426 - expect(getCellStyle(null)).toBe(''); 427 - expect(getCellStyle(undefined)).toBe(''); 428 - expect(getCellStyle({})).toBe(''); 429 - }); 430 - 431 - it('returns empty string for cell with no style', () => { 432 - expect(getCellStyle({ v: 42 })).toBe(''); 433 - expect(getCellStyle({ s: {} })).toBe(''); 434 - }); 435 - 436 - it('generates color style', () => { 437 - expect(getCellStyle({ s: { color: '#ff0000' } })).toBe('color:#ff0000;'); 438 - }); 439 - 440 - it('generates background style', () => { 441 - expect(getCellStyle({ s: { bg: '#00ff00' } })).toBe('background:#00ff00;'); 442 - }); 443 - 444 - it('generates bold style', () => { 445 - expect(getCellStyle({ s: { bold: true } })).toBe('font-weight:600;'); 446 - }); 447 - 448 - it('generates italic style', () => { 449 - expect(getCellStyle({ s: { italic: true } })).toBe('font-style:italic;'); 450 - }); 451 - 452 - it('generates left alignment', () => { 453 - expect(getCellStyle({ s: { align: 'left' } })).toBe('justify-content:flex-start;'); 454 - }); 455 - 456 - it('generates right alignment', () => { 457 - expect(getCellStyle({ s: { align: 'right' } })).toBe('justify-content:flex-end;'); 458 - }); 459 - 460 - it('generates center alignment', () => { 461 - expect(getCellStyle({ s: { align: 'center' } })).toBe('justify-content:center;'); 462 - }); 463 - 464 - it('combines multiple styles', () => { 465 - const style = getCellStyle({ s: { color: 'red', bold: true, italic: true } }); 466 - expect(style).toContain('color:red;'); 467 - expect(style).toContain('font-weight:600;'); 468 - expect(style).toContain('font-style:italic;'); 469 - }); 470 - 471 - it('does not include false/undefined style properties', () => { 472 - expect(getCellStyle({ s: { bold: false } })).toBe(''); 473 - expect(getCellStyle({ s: { color: '' } })).toBe(''); 474 - }); 475 - }); 476 - 477 - // ============================================================ 478 - // escapeHtml — HTML escaping for cell display 479 - // ============================================================ 480 - function escapeHtml(str) { 481 - if (str === null || str === undefined) return ''; 482 - return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); 483 - } 484 - 485 - describe('escapeHtml', () => { 486 - it('escapes ampersands', () => { 487 - expect(escapeHtml('A & B')).toBe('A &amp; B'); 488 - }); 489 - 490 - it('escapes less-than', () => { 491 - expect(escapeHtml('a < b')).toBe('a &lt; b'); 492 - }); 493 - 494 - it('escapes greater-than', () => { 495 - expect(escapeHtml('a > b')).toBe('a &gt; b'); 496 - }); 497 - 498 - it('escapes multiple special characters', () => { 499 - expect(escapeHtml('<script>alert("xss")</script>')).toBe('&lt;script&gt;alert("xss")&lt;/script&gt;'); 500 - }); 501 - 502 - it('returns empty string for null/undefined', () => { 503 - expect(escapeHtml(null)).toBe(''); 504 - expect(escapeHtml(undefined)).toBe(''); 505 - }); 506 - 507 - it('converts numbers to strings', () => { 508 - expect(escapeHtml(42)).toBe('42'); 509 - }); 510 - 511 - it('leaves normal text unchanged', () => { 512 - expect(escapeHtml('Hello world')).toBe('Hello world'); 513 - }); 514 - }); 270 + // getCellStyle tests moved to tests/cell-style-utils.test.ts (22 focused tests) 271 + // escapeHtml tests moved to tests/escape-html.test.ts
+163
tests/spill-tracking.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createSpillState, 4 + clearSpillMaps, 5 + registerSpill, 6 + isSpillSource, 7 + isSpillTarget, 8 + getSpillTargetValue, 9 + } from '../src/sheets/spill-tracking.js'; 10 + import type { RangeArray } from '../src/sheets/types.js'; 11 + 12 + function makeArray(values: unknown[], rows: number, cols: number): RangeArray { 13 + const arr = [...values] as RangeArray; 14 + arr._rangeRows = rows; 15 + arr._rangeCols = cols; 16 + return arr; 17 + } 18 + 19 + describe('createSpillState', () => { 20 + it('creates empty state', () => { 21 + const state = createSpillState(); 22 + expect(state.sources.size).toBe(0); 23 + expect(state.targets.size).toBe(0); 24 + }); 25 + }); 26 + 27 + describe('clearSpillMaps', () => { 28 + it('clears both maps', () => { 29 + const state = createSpillState(); 30 + state.sources.set('A1', { rows: 1, cols: 1, data: [1] }); 31 + state.targets.set('A2', { source: 'A1', value: 2 }); 32 + clearSpillMaps(state); 33 + expect(state.sources.size).toBe(0); 34 + expect(state.targets.size).toBe(0); 35 + }); 36 + }); 37 + 38 + describe('registerSpill', () => { 39 + const noData = () => null; 40 + 41 + it('registers a vertical spill (3 rows, 1 col)', () => { 42 + const state = createSpillState(); 43 + const arr = makeArray([10, 20, 30], 3, 1); 44 + registerSpill(state, 'A1', arr, noData, 100, 26); 45 + 46 + expect(state.sources.get('A1')).toEqual({ rows: 3, cols: 1, data: arr }); 47 + expect(state.targets.has('A2')).toBe(true); 48 + expect(state.targets.get('A2')!.value).toBe(20); 49 + expect(state.targets.has('A3')).toBe(true); 50 + expect(state.targets.get('A3')!.value).toBe(30); 51 + // Source cell itself is NOT a target 52 + expect(state.targets.has('A1')).toBe(false); 53 + }); 54 + 55 + it('registers a horizontal spill (1 row, 3 cols)', () => { 56 + const state = createSpillState(); 57 + const arr = makeArray([10, 20, 30], 1, 3); 58 + registerSpill(state, 'A1', arr, noData, 100, 26); 59 + 60 + expect(state.targets.has('B1')).toBe(true); 61 + expect(state.targets.get('B1')!.value).toBe(20); 62 + expect(state.targets.has('C1')).toBe(true); 63 + expect(state.targets.get('C1')!.value).toBe(30); 64 + }); 65 + 66 + it('registers a 2x2 grid spill', () => { 67 + const state = createSpillState(); 68 + const arr = makeArray([1, 2, 3, 4], 2, 2); 69 + registerSpill(state, 'B2', arr, noData, 100, 26); 70 + 71 + // B2 is source (not a target) 72 + expect(state.targets.has('B2')).toBe(false); 73 + // C2 = value at [0,1] = 2 74 + expect(state.targets.get('C2')!.value).toBe(2); 75 + // B3 = value at [1,0] = 3 76 + expect(state.targets.get('B3')!.value).toBe(3); 77 + // C3 = value at [1,1] = 4 78 + expect(state.targets.get('C3')!.value).toBe(4); 79 + }); 80 + 81 + it('detects collision and marks #SPILL!', () => { 82 + const state = createSpillState(); 83 + const arr = makeArray([10, 20, 30], 3, 1); 84 + // A2 has existing data 85 + const getCellData = (id: string) => id === 'A2' ? { v: 'occupied', f: '', s: {} } : null; 86 + registerSpill(state, 'A1', arr, getCellData, 100, 26); 87 + 88 + // Source should be marked as SPILL 89 + expect(state.sources.get('A1')!.rows).toBe(0); 90 + expect(state.sources.get('A1')!.data[0]).toBe('#SPILL!'); 91 + // No targets should be registered 92 + expect(state.targets.size).toBe(0); 93 + }); 94 + 95 + it('skips targets beyond sheet bounds', () => { 96 + const state = createSpillState(); 97 + const arr = makeArray([1, 2, 3, 4, 5], 5, 1); 98 + // Sheet only has 3 rows 99 + registerSpill(state, 'A1', arr, noData, 3, 26); 100 + 101 + // A2 and A3 should be registered (rows 2 and 3 are within bounds) 102 + expect(state.targets.has('A2')).toBe(true); 103 + expect(state.targets.has('A3')).toBe(true); 104 + // A4 and A5 should be skipped (beyond maxRows=3) 105 + expect(state.targets.has('A4')).toBe(false); 106 + expect(state.targets.has('A5')).toBe(false); 107 + }); 108 + 109 + it('treats empty/undefined cell data as no collision', () => { 110 + const state = createSpillState(); 111 + const arr = makeArray([10, 20], 2, 1); 112 + const getCellData = (id: string) => id === 'A2' ? { v: '', f: '', s: {} } : null; 113 + registerSpill(state, 'A1', arr, getCellData, 100, 26); 114 + 115 + // v is empty string — not a collision 116 + expect(state.targets.has('A2')).toBe(true); 117 + }); 118 + }); 119 + 120 + describe('isSpillSource', () => { 121 + it('returns true for registered source with rows > 0', () => { 122 + const state = createSpillState(); 123 + state.sources.set('A1', { rows: 3, cols: 1, data: [1, 2, 3] }); 124 + expect(isSpillSource(state, 'A1')).toBe(true); 125 + }); 126 + 127 + it('returns false for SPILL error source (rows === 0)', () => { 128 + const state = createSpillState(); 129 + state.sources.set('A1', { rows: 0, cols: 0, data: ['#SPILL!'] }); 130 + expect(isSpillSource(state, 'A1')).toBe(false); 131 + }); 132 + 133 + it('returns false for unknown cell', () => { 134 + const state = createSpillState(); 135 + expect(isSpillSource(state, 'Z99')).toBe(false); 136 + }); 137 + }); 138 + 139 + describe('isSpillTarget', () => { 140 + it('returns true for registered target', () => { 141 + const state = createSpillState(); 142 + state.targets.set('A2', { source: 'A1', value: 42 }); 143 + expect(isSpillTarget(state, 'A2')).toBe(true); 144 + }); 145 + 146 + it('returns false for non-target', () => { 147 + const state = createSpillState(); 148 + expect(isSpillTarget(state, 'A1')).toBe(false); 149 + }); 150 + }); 151 + 152 + describe('getSpillTargetValue', () => { 153 + it('returns value for target cell', () => { 154 + const state = createSpillState(); 155 + state.targets.set('B3', { source: 'A1', value: 42 }); 156 + expect(getSpillTargetValue(state, 'B3')).toBe(42); 157 + }); 158 + 159 + it('returns undefined for non-target', () => { 160 + const state = createSpillState(); 161 + expect(getSpillTargetValue(state, 'Z99')).toBeUndefined(); 162 + }); 163 + });