···11+/**
22+ * Escape HTML special characters for safe insertion into the DOM.
33+ *
44+ * Accepts any value — null, undefined, and non-strings are coerced
55+ * to a string first (null/undefined become '').
66+ */
77+export function escapeHtml(value: unknown): string {
88+ if (value === null || value === undefined) return '';
99+ const str = typeof value === 'string' ? value : String(value);
1010+ return str
1111+ .replace(/&/g, '&')
1212+ .replace(/</g, '<')
1313+ .replace(/>/g, '>')
1414+ .replace(/"/g, '"');
1515+}
+117
src/sheets/cell-style-utils.ts
···11+/**
22+ * Pure cell style utilities — computes inline CSS from cell data and CF rules.
33+ * No DOM, no shared state.
44+ */
55+import type { CellData, CellStyle } from './types.js';
66+import { buildBorderStyle } from './cell-styles.js';
77+88+const FONT_FAMILIES: Record<string, string> = {
99+ 'sans-serif': 'system-ui, sans-serif',
1010+ 'serif': 'Charter, Georgia, serif',
1111+ 'monospace': 'ui-monospace, "SF Mono", monospace',
1212+};
1313+1414+const VALIGN_MAP: Record<string, string> = {
1515+ top: 'flex-start',
1616+ middle: 'center',
1717+ bottom: 'flex-end',
1818+};
1919+2020+/** Parse a hex color (#rgb or #rrggbb) to WCAG 2.1 relative luminance. */
2121+export function hexLuminance(hex: string): number {
2222+ const h = hex.replace('#', '');
2323+ let r: number, g: number, b: number;
2424+ if (h.length === 3) {
2525+ r = parseInt(h[0] + h[0], 16);
2626+ g = parseInt(h[1] + h[1], 16);
2727+ b = parseInt(h[2] + h[2], 16);
2828+ } else {
2929+ r = parseInt(h.slice(0, 2), 16);
3030+ g = parseInt(h.slice(2, 4), 16);
3131+ b = parseInt(h.slice(4, 6), 16);
3232+ }
3333+ const toLinear = (c: number) => {
3434+ const s = c / 255;
3535+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
3636+ };
3737+ return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
3838+}
3939+4040+/** Returns a contrasting text color (dark or light) for the given background hex. */
4141+export function contrastTextColor(bgHex: string): string {
4242+ return hexLuminance(bgHex) > 0.179 ? '#1a1815' : '#ffffff';
4343+}
4444+4545+/** Resolve background color for a cell (explicit style > CF). Returns hex or ''. */
4646+export function getCellBgColor(cellData: Partial<CellData> | null | undefined, cfStyleStr: string): string {
4747+ if (cellData?.s?.bg) return cellData.s.bg;
4848+ if (cfStyleStr) {
4949+ const bgMatch = cfStyleStr.match(/background:([^;]+);/);
5050+ if (bgMatch) return bgMatch[1]!;
5151+ }
5252+ return '';
5353+}
5454+5555+/** Background CSS for the <td> — inset box-shadow grid lines paint on top. */
5656+export function getCellBgStyle(cellData: Partial<CellData> | null | undefined, cfStyleStr: string): string {
5757+ const bg = getCellBgColor(cellData, cfStyleStr);
5858+ return bg ? 'background:' + bg + ';' : '';
5959+}
6060+6161+/** Build complete inline style string for a cell's display content. */
6262+export function getCellStyle(cellData: Partial<CellData> | null | undefined, cfStyleStr: string): string {
6363+ let style = '';
6464+ let hasExplicitColor = false;
6565+6666+ if (cellData?.s) {
6767+ const s = cellData.s as CellStyle;
6868+ // Skip emitting inline color when it matches a theme default — let CSS variable handle it
6969+ if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') {
7070+ style += 'color:' + s.color + ';';
7171+ hasExplicitColor = true;
7272+ }
7373+ if (s.bold) style += 'font-weight:600;';
7474+ if (s.italic) style += 'font-style:italic;';
7575+ if (s.fontSize) style += 'font-size:' + s.fontSize + 'pt;';
7676+ if (s.fontFamily) {
7777+ style += 'font-family:' + (FONT_FAMILIES[s.fontFamily] || 'system-ui, sans-serif') + ';';
7878+ }
7979+ // text-decoration: combine underline + strikethrough
8080+ const decorations: string[] = [];
8181+ if (s.underline) decorations.push('underline');
8282+ if (s.strikethrough) decorations.push('line-through');
8383+ if (decorations.length > 0) style += 'text-decoration:' + decorations.join(' ') + ';';
8484+ if (s.align) style += 'justify-content:' + (s.align === 'left' ? 'flex-start' : s.align === 'right' ? 'flex-end' : 'center') + ';';
8585+ if (s.verticalAlign) {
8686+ style += 'align-items:' + (VALIGN_MAP[s.verticalAlign] || 'flex-start') + ';';
8787+ }
8888+ if (s.borders) style += buildBorderStyle(s.borders);
8989+ if (s.wrap) style += 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;';
9090+ }
9191+9292+ // Conditional formatting — bg is handled by getCellBgStyle on the td;
9393+ // only apply non-bg CF styles (color, etc.) here on .cell-display
9494+ if (cfStyleStr) {
9595+ const cfNoBg = cfStyleStr.replace(/background:[^;]+;?/g, '');
9696+ if (cfNoBg) {
9797+ if (!cellData?.s?.color && cfNoBg.includes('color:')) {
9898+ const colorMatch = cfNoBg.match(/(?:^|;)(color:[^;]+;)/);
9999+ if (colorMatch) { style += colorMatch[1]; hasExplicitColor = true; }
100100+ } else if (!cellData?.s?.color) {
101101+ if (cfNoBg.includes('color:')) hasExplicitColor = true;
102102+ style += cfNoBg;
103103+ }
104104+ }
105105+ }
106106+107107+ // Auto-contrast: when a cell has a background but no explicit text color,
108108+ // pick black or white based on the background luminance.
109109+ if (!hasExplicitColor) {
110110+ const bg = getCellBgColor(cellData, cfStyleStr);
111111+ if (bg && bg.startsWith('#')) {
112112+ style += 'color:' + contrastTextColor(bg) + ';';
113113+ }
114114+ }
115115+116116+ return style;
117117+}
+1-14
src/sheets/clipboard-copy.ts
···55 * TSV text from selected cells for pasting into other spreadsheets.
66 */
7788-/**
99- * Escape a string for safe inclusion in HTML.
1010- *
1111- * @param {string} str
1212- * @returns {string}
1313- */
1414-function escapeHtml(str) {
1515- if (typeof str !== 'string') return String(str ?? '');
1616- return str
1717- .replace(/&/g, '&')
1818- .replace(/</g, '<')
1919- .replace(/>/g, '>')
2020- .replace(/"/g, '"');
2121-}
88+import { escapeHtml } from '../lib/escape-html.js';
2292310/**
2411 * Convert a CellStyle object to an inline CSS style string.
+14
src/sheets/conditional-format.ts
···211211212212 return result;
213213}
214214+215215+/** Format a conditional formatting rule as a human-readable label. */
216216+export function formatRuleLabel(rule: CfRule): string {
217217+ switch (rule.type) {
218218+ case 'greaterThan': return 'Greater than ' + (rule.value ?? '');
219219+ case 'lessThan': return 'Less than ' + (rule.value ?? '');
220220+ case 'equalTo': return 'Equal to ' + (rule.value ?? '');
221221+ case 'between': return 'Between ' + (rule.value ?? '') + ' and ' + (rule.value2 ?? '');
222222+ case 'textContains': return 'Text contains "' + (rule.value ?? '') + '"';
223223+ case 'isEmpty': return 'Is empty';
224224+ case 'isNotEmpty': return 'Is not empty';
225225+ default: return rule.type;
226226+ }
227227+}
+58
src/sheets/csv-utils.ts
···11+/**
22+ * CSV/TSV parsing utilities — pure functions, no DOM or state.
33+ */
44+55+/**
66+ * Parse a single CSV line, handling quoted fields with escaped quotes.
77+ */
88+export function parseCSVLine(line: string): string[] {
99+ const fields: string[] = [];
1010+ let field = '';
1111+ let inQuotes = false;
1212+ for (let i = 0; i < line.length; i++) {
1313+ const ch = line[i];
1414+ if (inQuotes) {
1515+ if (ch === '"') {
1616+ if (i + 1 < line.length && line[i + 1] === '"') {
1717+ field += '"';
1818+ i++;
1919+ } else {
2020+ inQuotes = false;
2121+ }
2222+ } else {
2323+ field += ch;
2424+ }
2525+ } else {
2626+ if (ch === '"') {
2727+ inQuotes = true;
2828+ } else if (ch === ',') {
2929+ fields.push(field);
3030+ field = '';
3131+ } else {
3232+ field += ch;
3333+ }
3434+ }
3535+ }
3636+ fields.push(field);
3737+ return fields;
3838+}
3939+4040+/**
4141+ * Detect if the first row of parsed CSV data looks like column headers.
4242+ * Returns true if first row is all text, has unique values, and subsequent rows contain numbers.
4343+ */
4444+export function detectHeaders(parsedRows: string[][]): boolean {
4545+ if (parsedRows.length < 2) return false;
4646+ const firstRow = parsedRows[0].map(v => v.trim());
4747+ const allFirstRowText = firstRow.every(val => val === '' || isNaN(Number(val)));
4848+ if (!allFirstRowText) return false;
4949+ const nonEmpty = firstRow.filter(v => v !== '');
5050+ if (nonEmpty.length === 0) return false;
5151+ const uniqueVals = new Set(nonEmpty.map(v => v.toLowerCase()));
5252+ if (uniqueVals.size !== nonEmpty.length) return false;
5353+ const dataRows = parsedRows.slice(1, Math.min(parsedRows.length, 11));
5454+ return dataRows.some(row => row.some(val => {
5555+ const t = val.trim();
5656+ return t !== '' && !isNaN(Number(t));
5757+ }));
5858+}
+1-11
src/sheets/formula-highlighter.ts
···77 */
8899import type { HighlightToken, HighlightTokenType } from './types.js';
1010+import { escapeHtml } from '../lib/escape-html.js';
10111112// Known error values in spreadsheets
1213const ERROR_PATTERN = /^#(REF!|N\/A|VALUE!|ERROR!|NAME\?|NULL!|NUM!|DIV\/0!)/;
···237238 }
238239239240 return tokens;
240240-}
241241-242242-/**
243243- * Escape HTML special characters for safe insertion.
244244- */
245245-function escapeHtml(text: string): string {
246246- return text
247247- .replace(/&/g, '&')
248248- .replace(/</g, '<')
249249- .replace(/>/g, '>')
250250- .replace(/"/g, '"');
251241}
252242253243/**
+73-285
src/sheets/main.ts
···1919import { validateChartConfig, parseDataRange, extractChartData, transformChartData, buildChartJsConfig, CHART_TYPES, CHART_COLORS } from './charts.js';
2020import { getUniqueColumnValues, applyFilters, clearColumnFilter, clearAllFilters, buildFilterState } from './filter.js';
2121import { multiColumnSort } from './sort.js';
2222-import { evaluateRules, buildCfStyle, computeColorScale } from './conditional-format.js';
2222+import { evaluateRules, buildCfStyle, computeColorScale, formatRuleLabel } from './conditional-format.js';
2323import { isErrorValue, getErrorInfo, formatErrorTooltip } from './error-tooltips.js';
2424import { renderInteractiveCell, handleRichCellClick } from './rich-cells.js';
2525import { parseDateValue, showDatePicker } from './date-picker.js';
2626import { validateCell, getDropdownItems, parseListItems } from './data-validation.js';
2727import { buildBorderStyle, applyBorderPreset, getWrapStyle, getStripedRowClass } from './cell-styles.js';
2828+import { normalizeRange, isInRange } from './selection-utils.js';
2929+import { hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle } from './cell-style-utils.js';
3030+import { buildMergeMap, findCellMerge } from './merge-utils.js';
3131+import { createSpillState, clearSpillMaps, registerSpill, isSpillSource, isSpillTarget, getSpillTargetValue } from './spill-tracking.js';
3232+import { formatSaveTimestamp, getSaveDisplayText } from './save-indicator.js';
3333+import { parseCSVLine, detectHeaders } from './csv-utils.js';
3434+import type { SpillState } from './spill-tracking.js';
2835import { computeSelectionStats, formatStatValue } from './status-bar.js';
2936import { FORMULA_FUNCTIONS, filterFunctions, navigateAutocomplete, getSelectedFunction } from './formula-autocomplete.js';
3037import { createNote, updateNote, deleteNote, getNote, hasNote, getAllNotes } from './cell-notes.js';
···4855import {
4956 createChatSidebar, createChatState, loadConfig, isConfigured,
5057 buildSystemMessage, streamChat, appendMessage, appendStreamingBubble,
5151- renderMarkdown, appendActionCard, escapeHtml, initChatWiring,
5858+ renderMarkdown, appendActionCard, initChatWiring,
5259} from '../lib/ai-chat.js';
6060+import { escapeHtml } from '../lib/escape-html.js';
5361import { splitResponse, isSheetAction } from '../lib/ai-actions.js';
5462import { executeSheetAction } from './ai-sheet-actions.js';
5563import { computePivot, formatAggregateValue } from './pivot-table.js';
···344352 return sheet.get('merges');
345353}
346354347347-function buildMergeMap() {
348348- const merges = getMerges();
349349- const map = new Map();
350350- merges.forEach((mergeData) => {
351351- const m = typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData;
352352- for (let r = m.startRow; r <= m.endRow; r++) {
353353- for (let c = m.startCol; c <= m.endCol; c++) {
354354- const id = cellId(c, r);
355355- if (c === m.startCol && r === m.startRow) {
356356- map.set(id, { hidden: false, merge: m, colspan: m.endCol - m.startCol + 1, rowspan: m.endRow - m.startRow + 1 });
357357- } else {
358358- map.set(id, { hidden: true, merge: m });
359359- }
360360- }
361361- }
362362- });
363363- return map;
355355+// buildMergeMap and findCellMerge extracted to merge-utils.ts
356356+// Wrappers that pass Yjs data:
357357+function _buildMergeMap() {
358358+ return buildMergeMap(getMerges().entries());
364359}
365365-366360function isCellMerged(col, row) {
367367- const merges = getMerges();
368368- let result = null;
369369- merges.forEach((mergeData) => {
370370- const m = typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData;
371371- if (col >= m.startCol && col <= m.endCol && row >= m.startRow && row <= m.endRow) result = m;
372372- });
373373- return result;
361361+ return findCellMerge(col, row, getMerges().entries());
374362}
375363376364// --- Hidden canvas for text measurement (auto-fit) ---
···405393 const colCount = sheet.get('colCount') || DEFAULT_COLS;
406394 const freezeR = getFreezeRows();
407395 const freezeC = getFreezeCols();
408408- const mergeMap = buildMergeMap();
396396+ const mergeMap = _buildMergeMap();
409397410398 // Build hidden sets for row/col visibility computation
411399 const hiddenRowSet = buildHiddenRowSet();
···575563 }
576564577565 // Spill range styling
578578- if (isSpillSource(id)) tdCls.push('spill-source');
579579- if (isSpillTarget(id)) tdCls.push('spill-target');
566566+ if (_isSpillSource(id)) tdCls.push('spill-source');
567567+ if (_isSpillTarget(id)) tdCls.push('spill-target');
580568581569 // Find & replace highlighting
582570 if (findActive) {
···716704function computeDisplayValue(id, cellData) {
717705 if (!cellData) {
718706 // Check if this cell is a spill target
719719- const spillInfo = spillTargetMap.get(id);
707707+ const spillInfo = _spillState.targets.get(id);
720708 if (spillInfo) return formatCell(spillInfo.value, undefined);
721709 return '';
722710 }
···726714 if (isSparklineResult(val)) return val;
727715 // Array results: register spill and display first element
728716 if (Array.isArray(val) && (val as any)._rangeRows) {
729729- registerSpill(id, val);
730730- const spillInfo = spillMap.get(id);
717717+ _registerSpill(id, val);
718718+ const spillInfo = _spillState.sources.get(id);
731719 if (spillInfo && spillInfo.data[0] === '#SPILL!') return '#SPILL!';
732720 return formatCell(val[0], cellData.s?.format);
733721 }
···735723 }
736724 // Check if this cell is a spill target (cell exists but has no formula/value)
737725 if (!cellData.v && !cellData.f) {
738738- const spillInfo = spillTargetMap.get(id);
726726+ const spillInfo = _spillState.targets.get(id);
739727 if (spillInfo) return formatCell(spillInfo.value, cellData.s?.format);
740728 }
741729 return formatCell(cellData.v, cellData.s?.format);
···743731744732const evalCache = new Map();
745733746746-// --- Spill tracking (display layer) ---
747747-// sourceId → { rows, cols, data: flat array }
748748-const spillMap = new Map<string, { rows: number; cols: number; data: unknown[] }>();
749749-// targetId → { source, value }
750750-const spillTargetMap = new Map<string, { source: string; value: unknown }>();
751751-752752-function clearSpillMaps() {
753753- spillMap.clear();
754754- spillTargetMap.clear();
755755-}
756756-757757-/**
758758- * Register a spill range from a formula that returned an array.
759759- * Populates spillMap and spillTargetMap for display.
760760- */
761761-function registerSpill(sourceId: string, arr: unknown[]): void {
762762- const rows = (arr as any)._rangeRows || arr.length;
763763- const cols = (arr as any)._rangeCols || 1;
764764- const ref = parseRef(sourceId);
765765- if (!ref) return;
734734+// --- Spill tracking extracted to spill-tracking.ts ---
735735+const _spillState = createSpillState();
766736767767- spillMap.set(sourceId, { rows, cols, data: arr });
768768-737737+// Wrappers that pass local state/deps:
738738+function __clearSpillMaps() { clearSpillMaps(_spillState); }
739739+function _registerSpill(sourceId: string, arr: unknown[]): void {
769740 const sheet = getActiveSheet();
770741 const maxRows = sheet.get('rowCount') || 100;
771742 const maxCols = sheet.get('colCount') || 26;
772772-773773- for (let r = 0; r < rows; r++) {
774774- for (let c = 0; c < cols; c++) {
775775- if (r === 0 && c === 0) continue;
776776- // Bounds check: skip spill targets beyond sheet dimensions
777777- if (ref.row + r > maxRows || ref.col + c > maxCols) continue;
778778- const targetId = colToLetter(ref.col + c) + (ref.row + r);
779779- const idx = r * cols + c;
780780- // Check for collision: target has real data
781781- const targetData = getCellData(targetId);
782782- if (targetData && (targetData.f || (targetData.v !== '' && targetData.v !== undefined && targetData.v !== null))) {
783783- // Collision — mark source as #SPILL!
784784- spillMap.set(sourceId, { rows: 0, cols: 0, data: ['#SPILL!'] });
785785- // Clear any targets already registered for this source
786786- for (const [tid, info] of spillTargetMap) {
787787- if (info.source === sourceId) spillTargetMap.delete(tid);
788788- }
789789- return;
790790- }
791791- spillTargetMap.set(targetId, { source: sourceId, value: arr[idx] ?? '' });
792792- }
793793- }
743743+ registerSpill(_spillState, sourceId, arr as any, getCellData, maxRows, maxCols);
794744}
795795-796796-/**
797797- * Check if a cell is a spill source (has spilled array results).
798798- */
799799-function isSpillSource(cellId: string): boolean {
800800- const info = spillMap.get(cellId);
801801- return !!info && info.rows > 0;
802802-}
803803-804804-/**
805805- * Check if a cell is a spill target.
806806- */
807807-function isSpillTarget(cellId: string): boolean {
808808- return spillTargetMap.has(cellId);
809809-}
745745+function _isSpillSource(id: string): boolean { return isSpillSource(_spillState, id); }
746746+function _isSpillTarget(id: string): boolean { return isSpillTarget(_spillState, id); }
810747811748// --- Recalc engine integration ---
812749function buildRecalcCellStore() {
···876813 const classes = [];
877814 if (selectedCell.col === col && selectedCell.row === row) classes.push('selected');
878815 if (editingCell && editingCell.col === col && editingCell.row === row) classes.push('editing');
879879- if (isInRange(col, row)) classes.push('in-range');
816816+ if (_isInRange(col, row)) classes.push('in-range');
880817 return classes.join(' ');
881818}
882819883883-function isInRange(col, row) {
884884- if (!selectionRange) return false;
885885- const { startCol, startRow, endCol, endRow } = normalizeRange(selectionRange);
886886- return col >= startCol && col <= endCol && row >= startRow && row <= endRow;
887887-}
888888-889889-function normalizeRange(range) {
890890- return {
891891- startCol: Math.min(range.startCol, range.endCol),
892892- startRow: Math.min(range.startRow, range.endRow),
893893- endCol: Math.max(range.startCol, range.endCol),
894894- endRow: Math.max(range.startRow, range.endRow),
895895- };
896896-}
897897-898898-/** Parse a hex color (#rgb or #rrggbb) to WCAG 2.1 relative luminance. */
899899-function hexLuminance(hex: string): number {
900900- const h = hex.replace('#', '');
901901- let r: number, g: number, b: number;
902902- if (h.length === 3) {
903903- r = parseInt(h[0] + h[0], 16);
904904- g = parseInt(h[1] + h[1], 16);
905905- b = parseInt(h[2] + h[2], 16);
906906- } else {
907907- r = parseInt(h.slice(0, 2), 16);
908908- g = parseInt(h.slice(2, 4), 16);
909909- b = parseInt(h.slice(4, 6), 16);
910910- }
911911- const toLinear = (c: number) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); };
912912- return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
913913-}
914914-915915-/** Returns a contrasting text color for the given background hex. */
916916-function contrastTextColor(bgHex: string): string {
917917- return hexLuminance(bgHex) > 0.179 ? '#1a1815' : '#ffffff';
918918-}
919919-920920-// Resolve background color for a cell (explicit style > CF), returns color string or ''
921921-function getCellBgColor(cellData, cfStyleStr) {
922922- if (cellData?.s?.bg) return cellData.s.bg;
923923- if (cfStyleStr) {
924924- const bgMatch = cfStyleStr.match(/background:([^;]+);/);
925925- if (bgMatch) return bgMatch[1];
926926- }
927927- return '';
928928-}
929929-930930-// Background CSS for the <td> — so inset box-shadow grid lines paint on top of it
931931-function getCellBgStyle(cellData, cfStyleStr) {
932932- const bg = getCellBgColor(cellData, cfStyleStr);
933933- return bg ? 'background:' + bg + ';' : '';
934934-}
935935-936936-function getCellStyle(cellData, cfStyleStr) {
937937- let style = '';
938938- let hasExplicitColor = false;
939939- if (cellData?.s) {
940940- const s = cellData.s;
941941- // Skip emitting inline color when it matches a theme default — let CSS variable handle it
942942- if (s.color && s.color !== '#1a1815' && s.color !== '#ddd8ce') {
943943- style += 'color:' + s.color + ';';
944944- hasExplicitColor = true;
945945- }
946946- // bg is now on the <td>, not .cell-display
947947- if (s.bold) style += 'font-weight:600;';
948948- if (s.italic) style += 'font-style:italic;';
949949- if (s.fontSize) style += 'font-size:' + s.fontSize + 'pt;';
950950- if (s.fontFamily) {
951951- const families = {
952952- 'sans-serif': 'system-ui, sans-serif',
953953- 'serif': 'Charter, Georgia, serif',
954954- 'monospace': 'ui-monospace, "SF Mono", monospace',
955955- };
956956- style += 'font-family:' + (families[s.fontFamily] || 'system-ui, sans-serif') + ';';
957957- }
958958- // text-decoration: combine underline + strikethrough
959959- const decorations = [];
960960- if (s.underline) decorations.push('underline');
961961- if (s.strikethrough) decorations.push('line-through');
962962- if (decorations.length > 0) style += 'text-decoration:' + decorations.join(' ') + ';';
963963- if (s.align) style += 'justify-content:' + (s.align === 'left' ? 'flex-start' : s.align === 'right' ? 'flex-end' : 'center') + ';';
964964- if (s.verticalAlign) {
965965- const vaMap = { top: 'flex-start', middle: 'center', bottom: 'flex-end' };
966966- style += 'align-items:' + (vaMap[s.verticalAlign] || 'flex-start') + ';';
967967- }
968968- if (s.borders) style += buildBorderStyle(s.borders);
969969- if (s.wrap) style += 'white-space:normal;word-wrap:break-word;overflow-wrap:break-word;';
970970- }
971971- // Conditional formatting — bg is handled by getCellBgStyle on the td;
972972- // only apply non-bg CF styles (color, etc.) here on .cell-display
973973- if (cfStyleStr) {
974974- // Strip background from CF string — it goes on the td via getCellBgStyle
975975- const cfNoBg = cfStyleStr.replace(/background:[^;]+;?/g, '');
976976- if (cfNoBg) {
977977- if (!cellData?.s?.color && cfNoBg.includes('color:')) {
978978- const colorMatch = cfNoBg.match(/(?:^|;)(color:[^;]+;)/);
979979- if (colorMatch) { style += colorMatch[1]; hasExplicitColor = true; }
980980- } else if (!cellData?.s?.color) {
981981- if (cfNoBg.includes('color:')) hasExplicitColor = true;
982982- style += cfNoBg;
983983- }
984984- }
985985- }
986986- // Auto-contrast: when a cell has a background color but no explicit text color,
987987- // pick black or white based on the background luminance. This prevents light-on-light
988988- // (dark mode default text on light cell bg) and dark-on-dark scenarios.
989989- if (!hasExplicitColor) {
990990- const bg = getCellBgColor(cellData, cfStyleStr);
991991- if (bg && bg.startsWith('#')) {
992992- style += 'color:' + contrastTextColor(bg) + ';';
993993- }
994994- }
995995- return style;
820820+// normalizeRange and isInRange extracted to selection-utils.ts
821821+// Wrapper that captures local selectionRange:
822822+function _isInRange(col, row) {
823823+ return isInRange(col, row, selectionRange);
996824}
997825998998-function escapeHtml(str) {
999999- if (str === null || str === undefined) return '';
10001000- return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
10011001-}
826826+// hexLuminance, contrastTextColor, getCellBgColor, getCellBgStyle, getCellStyle
827827+// extracted to cell-style-utils.ts (imported above)
10028281003829// --- Grid events ---
1004830function attachGridEvents() {
···15941420 }
15951421 });
1596142215971597- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
14231423+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
15981424 refreshVisibleCells();
1599142516001426 // Extend selection to include filled area
···16851511 }
16861512 if (td) td.classList.remove('editing');
16871513 editingCell = null;
16881688- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
15141514+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
16891515 clearGridHighlights();
16901516 hideTooltip();
16911517 refreshVisibleCells();
···17991625 // Undo: Cmd+Z (Mac) / Ctrl+Z
18001626 if ((e.metaKey || e.ctrlKey) && key === 'z' && !e.shiftKey) {
18011627 e.preventDefault();
18021802- if (undoManager) { undoManager.undo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); }
16281628+ if (undoManager) { undoManager.undo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); }
18031629 }
18041630 // Redo: Cmd+Shift+Z (Mac) / Ctrl+Y (Windows/Linux)
18051631 if (((e.metaKey || e.ctrlKey) && e.shiftKey && key === 'z') || (e.ctrlKey && !e.metaKey && key === 'y')) {
18061632 e.preventDefault();
18071807- if (undoManager) { undoManager.redo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); }
16331633+ if (undoManager) { undoManager.redo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); }
18081634 }
18091635 // Hide rows: Cmd+9
18101636 if ((e.metaKey || e.ctrlKey) && key === '9' && !e.shiftKey) { e.preventDefault(); hideSelectedRows(); }
···19611787 }
19621788 }
19631789 });
19641964- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
17901790+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
19651791 refreshVisibleCells();
19661792}
19671793···20451871 }
20461872 }
20471873 });
20482048- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
18741874+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
20491875 refreshVisibleCells();
20501876}
20511877···24422268 setCellData(id, { v: value, f: '' });
24432269 }
2444227024452445- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
22712271+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
24462272 refreshVisibleCells();
24472273}
24482274···25552381 }
25562382}
25572383document.getElementById('tb-undo').addEventListener('click', () => {
25582558- if (undoManager) { undoManager.undo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); }
23842384+ if (undoManager) { undoManager.undo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); }
25592385});
25602386document.getElementById('tb-redo').addEventListener('click', () => {
25612561- if (undoManager) { undoManager.redo(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); }
23872387+ if (undoManager) { undoManager.redo(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); updateUndoRedoState(); }
25622388});
25632389// Update undo/redo state whenever stacks change
25642390if (undoManager) {
···27902616 }
27912617 });
27922618 });
27932793- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
26192619+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
27942620 refreshVisibleCells();
27952621}
27962622···28262652 rowColInsertRow(getCells, setCellData, rowIndex, colCount);
28272653 });
28282654 sheet.set('rowCount', (sheet.get('rowCount') || DEFAULT_ROWS) + 1);
28292829- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
26552655+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
28302656 renderGrid();
28312657}
28322658···28392665 rowColDeleteRow(getCells, setCellData, rowIndex, colCount);
28402666 });
28412667 sheet.set('rowCount', rowCount - 1);
28422842- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
26682668+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
28432669 renderGrid();
28442670}
28452671···28502676 rowColInsertColumn(getCells, setCellData, colIndex, rowCount);
28512677 });
28522678 sheet.set('colCount', (sheet.get('colCount') || DEFAULT_COLS) + 1);
28532853- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
26792679+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
28542680 renderGrid();
28552681}
28562682···28632689 rowColDeleteColumn(getCells, setCellData, colIndex, rowCount);
28642690 });
28652691 sheet.set('colCount', colCount - 1);
28662866- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
26922692+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
28672693 renderGrid();
28682694}
28692695···30172843 const newActive = deleteSheet(ydoc, ySheets, sheetIdx, activeSheetIdx);
30182844 if (newActive >= 0) {
30192845 activeSheetIdx = newActive;
30203020- evalCache.clear(); clearSpillMaps();
28462846+ evalCache.clear(); _clearSpillMaps();
30212847 invalidateRecalcEngine();
30222848 renderSheetTabs();
30232849 renderGrid();
···30312857 const newSheet = duplicateSheet(ydoc, ySheets, sheetIdx, targetIdx);
30322858 if (newSheet) {
30332859 activeSheetIdx = targetIdx;
30343034- evalCache.clear(); clearSpillMaps();
28602860+ evalCache.clear(); _clearSpillMaps();
30352861 invalidateRecalcEngine();
30362862 renderSheetTabs();
30372863 renderGrid();
···31012927 tab.appendChild(label);
3102292831032929 tab.draggable = true;
31043104- tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); });
29302930+ tab.addEventListener('click', () => { activeSheetIdx = i; renderSheetTabs(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid(); });
3105293131062932 // Double-click for inline rename
31072933 tab.addEventListener('dblclick', (e) => {
···31903016 activeSheetIdx++;
31913017 }
3192301831933193- evalCache.clear(); clearSpillMaps();
30193019+ evalCache.clear(); _clearSpillMaps();
31943020 invalidateRecalcEngine();
31953021 renderSheetTabs();
31963022 renderGrid();
···32723098document.getElementById('add-sheet').addEventListener('click', () => {
32733099 let count = 0;
32743100 ySheets.forEach((_, key) => { if (key.startsWith('sheet_')) count++; });
32753275- ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid();
31013101+ ensureSheet(count); activeSheetIdx = count; renderSheetTabs(); evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid();
32763102});
3277310332783104// --- Document title ---
···33143140 statusText.textContent = 'Synced';
33153141 // Re-attach ALL observers after sync — the snapshot may have replaced the Y.Map/Y.Array
33163142 // objects that were observed during initial setup (before data loaded from peers)
33173317- getCells().observeDeep(() => { evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); });
31433143+ getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); });
33183144 ySheets.observe(() => { renderSheetTabs(); });
33193145 ySheets.observeDeep((events) => {
33203146 for (const event of events) {
···33263152 }
33273153 }
33283154 if (event.target && (event.target === getCfRules() || event.target === getValidations())) {
33293329- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return;
31553155+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return;
33303156 }
33313157 }
33323158 });
···33773203 setCellData(cellId, data as any);
33783204 }
33793205 });
33803380- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid();
32063206+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid();
33813207 }
33823208 } catch { /* ignore invalid template */ }
33833209 }
···34563282function exportCSV() { const name = getActiveSheet().get('name') || 'sheet'; downloadFile(sheetToDelimited(','), name + '.csv', 'text/csv;charset=utf-8'); }
34573283function exportTSV() { const name = getActiveSheet().get('name') || 'sheet'; downloadFile(sheetToDelimited('\t'), name + '.tsv', 'text/tab-separated-values;charset=utf-8'); }
3458328434593459-function parseCSVLine(line) {
34603460- const fields = []; let field = ''; let inQuotes = false;
34613461- for (let i = 0; i < line.length; i++) {
34623462- const ch = line[i];
34633463- if (inQuotes) { if (ch === '"') { if (i + 1 < line.length && line[i + 1] === '"') { field += '"'; i++; } else { inQuotes = false; } } else { field += ch; } }
34643464- else { if (ch === '"') { inQuotes = true; } else if (ch === ',') { fields.push(field); field = ''; } else { field += ch; } }
34653465- }
34663466- fields.push(field); return fields;
34673467-}
34683468-34693469-/**
34703470- * Detect if the first row of parsed CSV data looks like column headers.
34713471- */
34723472-function detectHeaders(parsedRows) {
34733473- if (parsedRows.length < 2) return false;
34743474- const firstRow = parsedRows[0].map(v => v.trim());
34753475- const allFirstRowText = firstRow.every(val => val === '' || isNaN(Number(val)));
34763476- if (!allFirstRowText) return false;
34773477- const nonEmpty = firstRow.filter(v => v !== '');
34783478- if (nonEmpty.length === 0) return false;
34793479- const uniqueVals = new Set(nonEmpty.map(v => v.toLowerCase()));
34803480- if (uniqueVals.size !== nonEmpty.length) return false;
34813481- const dataRows = parsedRows.slice(1, Math.min(parsedRows.length, 11));
34823482- return dataRows.some(row => row.some(val => { const t = val.trim(); return t !== '' && !isNaN(Number(t)); }));
34833483-}
32853285+// parseCSVLine and detectHeaders extracted to csv-utils.ts
3484328634853287function showToast(message, duration = 3000) {
34863288 const existing = document.querySelector('.toast-notification');
···35173319 if (neededRows > (sheet.get('rowCount') || DEFAULT_ROWS)) sheet.set('rowCount', neededRows);
35183320 if (neededCols > (sheet.get('colCount') || DEFAULT_COLS)) sheet.set('colCount', neededCols);
35193321 });
35203520- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); renderGrid();
33223322+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); renderGrid();
3521332335223324 if (hasHeaders) {
35233325 ydoc.transact(() => {
···3595339735963398function buildPrintData(): SheetsPrintData {
35973399 const sheet = getActiveSheet();
35983598- const mergeMap = buildMergeMap();
34003400+ const mergeMap = _buildMergeMap();
3599340136003402 // Find actual data extent to avoid printing huge empty grids
36013403 let maxRow = 0, maxCol = 0;
···37243526});
3725352737263528// --- React to Yjs changes ---
37273727-getCells().observeDeep(() => { evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); });
35293529+getCells().observeDeep(() => { evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); scheduleRenderGrid(); updateFormulaBar(); });
37283530ySheets.observe(() => { renderSheetTabs(); });
3729353137303532// Re-render when colWidths, freeze state, CF rules, validations, or stripedRows change from remote collaborators
···37393541 }
37403542 // CF rules or validations changed
37413543 if (event.target && (event.target === getCfRules() || event.target === getValidations())) {
37423742- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return;
35443544+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine(); refreshVisibleCells(); return;
37433545 }
37443546 }
37453547});
···38093611 saveIndicator.classList.remove('saved', 'saving', 'unsaved');
38103612 saveIndicator.classList.add(state);
38113613 if (state === 'saved') { lastSaveTime = time || Date.now(); updateSaveTimestamp(); }
38123812- else if (state === 'saving') { saveTextEl.textContent = 'Saving\u2026'; }
38133813- else { saveTextEl.textContent = 'Unsaved changes'; }
36143614+ else { saveTextEl.textContent = getSaveDisplayText(state) || ''; }
38143615}
3815361638163617function updateSaveTimestamp() {
38173618 if (saveState !== 'saved') return;
38183619 const prefix = !provider.connected ? 'Saved locally' : 'Saved';
38193620 const seconds = Math.floor((Date.now() - lastSaveTime) / 1000);
38203820- if (seconds < 5) saveTextEl.textContent = prefix;
38213821- else if (seconds < 60) saveTextEl.textContent = prefix + ' ' + seconds + 's ago';
38223822- else saveTextEl.textContent = prefix + ' ' + Math.floor(seconds / 60) + ' min ago';
36213621+ saveTextEl.textContent = formatSaveTimestamp(seconds, prefix);
38233622}
3824362338253624setInterval(updateSaveTimestamp, 30_000);
···48894688 });
48904689 });
4891469048924892- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
46914691+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
48934692 refreshVisibleCells();
48944693 overlay.remove();
48954694 });
···50594858 const yArr = getCfRules();
50604859 ydoc.transact(() => { yArr.delete(idx, 1); });
50614860 renderCfModal();
50625062- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
48614861+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
50634862 refreshVisibleCells();
50644863 });
50654864 });
···50774876 const yArr = getCfRules();
50784877 ydoc.transact(() => { yArr.push([JSON.stringify(rule)]); });
50794878 renderCfModal();
50805080- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
48794879+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
50814880 refreshVisibleCells();
50824881 });
50834882···50934892 document.body.appendChild(overlay);
50944893}
5095489450965096-function formatRuleLabel(rule) {
50975097- switch (rule.type) {
50985098- case 'greaterThan': return 'Greater than ' + (rule.value ?? '');
50995099- case 'lessThan': return 'Less than ' + (rule.value ?? '');
51005100- case 'equalTo': return 'Equal to ' + (rule.value ?? '');
51015101- case 'between': return 'Between ' + (rule.value ?? '') + ' and ' + (rule.value2 ?? '');
51025102- case 'textContains': return 'Text contains "' + (rule.value ?? '') + '"';
51035103- case 'isEmpty': return 'Is empty';
51045104- case 'isNotEmpty': return 'Is not empty';
51055105- default: return rule.type;
51065106- }
51075107-}
48954895+// formatRuleLabel extracted to conditional-format.ts
5108489651094897document.getElementById('tb-cf').addEventListener('click', () => { closeAllDropdowns(); showCfModal(); });
51104898···52915079 const numVal = Number(item);
52925080 const value = item === '' ? '' : (!isNaN(numVal) && item !== '' ? numVal : item);
52935081 setCellData(cellIdStr, { v: value, f: '' });
52945294- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
50825082+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
52955083 refreshVisibleCells();
52965084 dropdown.remove();
52975085 });
···61085896 const numVal = Number(result.newValue);
61095897 const value = result.newValue === '' ? '' : (!isNaN(numVal) && result.newValue !== '' ? numVal : result.newValue);
61105898 setCellData(result.cellId, { v: value, f: '' });
61116111- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
58995899+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
61125900 runSheetsFind(); // re-search after replace
61135901 }
61145902});
···61245912 setCellData(r.cellId, { v: value, f: '' });
61255913 }
61265914 });
61276127- evalCache.clear(); clearSpillMaps(); invalidateRecalcEngine();
59155915+ evalCache.clear(); _clearSpillMaps(); invalidateRecalcEngine();
61285916 showToast('Replaced ' + results.length + ' match' + (results.length > 1 ? 'es' : ''));
61295917 runSheetsFind();
61305918 }
+53
src/sheets/merge-utils.ts
···11+/**
22+ * Pure merge utility functions — operates on merge data iterables, not Yjs directly.
33+ * No DOM, no shared state.
44+ */
55+import type { MergeData, MergeMapEntry } from './types.js';
66+import { cellId } from './formulas.js';
77+88+/** Parse a merge entry (may be JSON string or object). */
99+function parseMerge(mergeData: unknown): MergeData {
1010+ return typeof mergeData === 'string' ? JSON.parse(mergeData) : mergeData as MergeData;
1111+}
1212+1313+/**
1414+ * Build a lookup map from cell ID to merge info.
1515+ * The origin cell gets { hidden: false, colspan, rowspan }.
1616+ * All other cells in the merge get { hidden: true }.
1717+ */
1818+export function buildMergeMap(merges: Iterable<[string, unknown]>): Map<string, MergeMapEntry> {
1919+ const map = new Map<string, MergeMapEntry>();
2020+ for (const [, mergeData] of merges) {
2121+ const m = parseMerge(mergeData);
2222+ for (let r = m.startRow; r <= m.endRow; r++) {
2323+ for (let c = m.startCol; c <= m.endCol; c++) {
2424+ const id = cellId(c, r);
2525+ if (c === m.startCol && r === m.startRow) {
2626+ map.set(id, {
2727+ hidden: false,
2828+ merge: m,
2929+ colspan: m.endCol - m.startCol + 1,
3030+ rowspan: m.endRow - m.startRow + 1,
3131+ });
3232+ } else {
3333+ map.set(id, { hidden: true, merge: m });
3434+ }
3535+ }
3636+ }
3737+ }
3838+ return map;
3939+}
4040+4141+/**
4242+ * Check if a cell at (col, row) is inside any merge region.
4343+ * Returns the MergeData if found, null otherwise.
4444+ */
4545+export function findCellMerge(col: number, row: number, merges: Iterable<[string, unknown]>): MergeData | null {
4646+ for (const [, mergeData] of merges) {
4747+ const m = parseMerge(mergeData);
4848+ if (col >= m.startCol && col <= m.endCol && row >= m.startRow && row <= m.endRow) {
4949+ return m;
5050+ }
5151+ }
5252+ return null;
5353+}
+22
src/sheets/save-indicator.ts
···11+/**
22+ * Pure save-indicator formatting — no DOM, no shared state.
33+ */
44+55+export type SaveState = 'saving' | 'saved' | 'unsaved';
66+77+/** Format seconds since last save into a human-readable timestamp string. */
88+export function formatSaveTimestamp(seconds: number, prefix = 'Saved'): string {
99+ if (seconds < 5) return prefix;
1010+ if (seconds < 60) return `${prefix} ${seconds}s ago`;
1111+ return `${prefix} ${Math.floor(seconds / 60)} min ago`;
1212+}
1313+1414+/** Get display text for a given save state, or null if the timestamp should be used. */
1515+export function getSaveDisplayText(state: SaveState): string | null {
1616+ switch (state) {
1717+ case 'saving': return 'Saving\u2026';
1818+ case 'unsaved': return 'Unsaved changes';
1919+ case 'saved': return null;
2020+ default: return null;
2121+ }
2222+}
+41
src/sheets/selection-utils.ts
···11+/**
22+ * Pure selection utility functions — no DOM, no shared state.
33+ */
44+import type { SelectionRange } from './types.js';
55+import { cellId } from './formulas.js';
66+77+export interface NormalizedRange {
88+ startCol: number;
99+ startRow: number;
1010+ endCol: number;
1111+ endRow: number;
1212+}
1313+1414+/** Normalize a selection range so start <= end on both axes. */
1515+export function normalizeRange(range: SelectionRange): NormalizedRange {
1616+ return {
1717+ startCol: Math.min(range.startCol, range.endCol),
1818+ startRow: Math.min(range.startRow, range.endRow),
1919+ endCol: Math.max(range.startCol, range.endCol),
2020+ endRow: Math.max(range.startRow, range.endRow),
2121+ };
2222+}
2323+2424+/** Check whether a cell (col, row) falls inside a selection range. */
2525+export function isInRange(col: number, row: number, range: SelectionRange | null): boolean {
2626+ if (!range) return false;
2727+ const { startCol, startRow, endCol, endRow } = normalizeRange(range);
2828+ return col >= startCol && col <= endCol && row >= startRow && row <= endRow;
2929+}
3030+3131+/**
3232+ * Build a range reference string like "A1" or "A1:C5".
3333+ * Normalizes backward selections before generating the string.
3434+ */
3535+export function getRangeRefString(range: SelectionRange): string {
3636+ const { startCol, startRow, endCol, endRow } = normalizeRange(range);
3737+ if (startCol === endCol && startRow === endRow) {
3838+ return cellId(startCol, startRow);
3939+ }
4040+ return cellId(startCol, startRow) + ':' + cellId(endCol, endRow);
4141+}
+96
src/sheets/spill-tracking.ts
···11+/**
22+ * Spill tracking for array formula results.
33+ *
44+ * Manages the display-layer mapping from source cells to their spilled targets.
55+ * Pure logic — receives all dependencies as parameters.
66+ */
77+import type { CellData, RangeArray } from './types.js';
88+import { parseRef, colToLetter } from './formulas.js';
99+1010+export interface SpillInfo {
1111+ rows: number;
1212+ cols: number;
1313+ data: unknown[];
1414+}
1515+1616+export interface SpillTargetInfo {
1717+ source: string;
1818+ value: unknown;
1919+}
2020+2121+export interface SpillState {
2222+ /** sourceId → spill dimensions + flat data array */
2323+ sources: Map<string, SpillInfo>;
2424+ /** targetId → { source, value } */
2525+ targets: Map<string, SpillTargetInfo>;
2626+}
2727+2828+export function createSpillState(): SpillState {
2929+ return {
3030+ sources: new Map(),
3131+ targets: new Map(),
3232+ };
3333+}
3434+3535+export function clearSpillMaps(state: SpillState): void {
3636+ state.sources.clear();
3737+ state.targets.clear();
3838+}
3939+4040+/**
4141+ * Register a spill range from a formula that returned an array.
4242+ * Populates sources and targets maps for display.
4343+ */
4444+export function registerSpill(
4545+ state: SpillState,
4646+ sourceId: string,
4747+ arr: RangeArray,
4848+ getCellData: (id: string) => Partial<CellData> | null,
4949+ maxRows: number,
5050+ maxCols: number,
5151+): void {
5252+ const rows = arr._rangeRows || arr.length;
5353+ const cols = arr._rangeCols || 1;
5454+ const ref = parseRef(sourceId);
5555+ if (!ref) return;
5656+5757+ state.sources.set(sourceId, { rows, cols, data: arr });
5858+5959+ for (let r = 0; r < rows; r++) {
6060+ for (let c = 0; c < cols; c++) {
6161+ if (r === 0 && c === 0) continue;
6262+ // Bounds check: skip spill targets beyond sheet dimensions
6363+ if (ref.row + r > maxRows || ref.col + c > maxCols) continue;
6464+ const targetId = colToLetter(ref.col + c) + (ref.row + r);
6565+ const idx = r * cols + c;
6666+ // Check for collision: target has real data
6767+ const targetData = getCellData(targetId);
6868+ if (targetData && (targetData.f || (targetData.v !== '' && targetData.v !== undefined && targetData.v !== null))) {
6969+ // Collision — mark source as #SPILL!
7070+ state.sources.set(sourceId, { rows: 0, cols: 0, data: ['#SPILL!'] });
7171+ // Clear any targets already registered for this source
7272+ for (const [tid, info] of state.targets) {
7373+ if (info.source === sourceId) state.targets.delete(tid);
7474+ }
7575+ return;
7676+ }
7777+ state.targets.set(targetId, { source: sourceId, value: arr[idx] ?? '' });
7878+ }
7979+ }
8080+}
8181+8282+/** Check if a cell is a spill source (has spilled array results). */
8383+export function isSpillSource(state: SpillState, id: string): boolean {
8484+ const info = state.sources.get(id);
8585+ return !!info && info.rows > 0;
8686+}
8787+8888+/** Check if a cell is a spill target (displays a value from another cell's spill). */
8989+export function isSpillTarget(state: SpillState, id: string): boolean {
9090+ return state.targets.has(id);
9191+}
9292+9393+/** Get the display value for a spill target cell, or undefined if not a target. */
9494+export function getSpillTargetValue(state: SpillState, id: string): unknown | undefined {
9595+ return state.targets.get(id)?.value;
9696+}
+146
tests/cell-style-utils.test.ts
···11+import { describe, it, expect } from 'vitest';
22+import {
33+ hexLuminance,
44+ contrastTextColor,
55+ getCellBgColor,
66+ getCellBgStyle,
77+ getCellStyle,
88+} from '../src/sheets/cell-style-utils.js';
99+1010+describe('hexLuminance', () => {
1111+ it('returns 0 for black', () => {
1212+ expect(hexLuminance('#000000')).toBeCloseTo(0, 4);
1313+ });
1414+1515+ it('returns 1 for white', () => {
1616+ expect(hexLuminance('#ffffff')).toBeCloseTo(1, 4);
1717+ });
1818+1919+ it('handles shorthand hex (#rgb)', () => {
2020+ expect(hexLuminance('#fff')).toBeCloseTo(1, 4);
2121+ expect(hexLuminance('#000')).toBeCloseTo(0, 4);
2222+ });
2323+2424+ it('returns mid-range for grey', () => {
2525+ const lum = hexLuminance('#808080');
2626+ expect(lum).toBeGreaterThan(0.1);
2727+ expect(lum).toBeLessThan(0.5);
2828+ });
2929+});
3030+3131+describe('contrastTextColor', () => {
3232+ it('returns dark text for light backgrounds', () => {
3333+ expect(contrastTextColor('#ffffff')).toBe('#1a1815');
3434+ expect(contrastTextColor('#ffff00')).toBe('#1a1815');
3535+ });
3636+3737+ it('returns white text for dark backgrounds', () => {
3838+ expect(contrastTextColor('#000000')).toBe('#ffffff');
3939+ expect(contrastTextColor('#003366')).toBe('#ffffff');
4040+ });
4141+});
4242+4343+describe('getCellBgColor', () => {
4444+ it('returns explicit cell bg color', () => {
4545+ expect(getCellBgColor({ s: { bg: '#ff0000' } } as any, '')).toBe('#ff0000');
4646+ });
4747+4848+ it('returns CF background when no explicit bg', () => {
4949+ expect(getCellBgColor(null, 'background:#00ff00;color:red;')).toBe('#00ff00');
5050+ });
5151+5252+ it('prefers explicit bg over CF', () => {
5353+ expect(getCellBgColor({ s: { bg: '#ff0000' } } as any, 'background:#00ff00;')).toBe('#ff0000');
5454+ });
5555+5656+ it('returns empty string when no bg', () => {
5757+ expect(getCellBgColor(null, '')).toBe('');
5858+ expect(getCellBgColor(undefined, '')).toBe('');
5959+ });
6060+});
6161+6262+describe('getCellBgStyle', () => {
6363+ it('returns background CSS when color exists', () => {
6464+ expect(getCellBgStyle({ s: { bg: '#ff0000' } } as any, '')).toBe('background:#ff0000;');
6565+ });
6666+6767+ it('returns empty string when no bg', () => {
6868+ expect(getCellBgStyle(null, '')).toBe('');
6969+ });
7070+});
7171+7272+describe('getCellStyle', () => {
7373+ it('returns empty string for null cell data and no CF', () => {
7474+ expect(getCellStyle(null, '')).toBe('');
7575+ });
7676+7777+ it('renders bold', () => {
7878+ const style = getCellStyle({ s: { bold: true } } as any, '');
7979+ expect(style).toContain('font-weight:600;');
8080+ });
8181+8282+ it('renders italic', () => {
8383+ const style = getCellStyle({ s: { italic: true } } as any, '');
8484+ expect(style).toContain('font-style:italic;');
8585+ });
8686+8787+ it('renders font size', () => {
8888+ const style = getCellStyle({ s: { fontSize: 14 } } as any, '');
8989+ expect(style).toContain('font-size:14pt;');
9090+ });
9191+9292+ it('renders font family', () => {
9393+ const style = getCellStyle({ s: { fontFamily: 'monospace' } } as any, '');
9494+ expect(style).toContain('font-family:ui-monospace');
9595+ });
9696+9797+ it('combines underline and strikethrough', () => {
9898+ const style = getCellStyle({ s: { underline: true, strikethrough: true } } as any, '');
9999+ expect(style).toContain('text-decoration:underline line-through;');
100100+ });
101101+102102+ it('renders text alignment', () => {
103103+ expect(getCellStyle({ s: { align: 'right' } } as any, '')).toContain('justify-content:flex-end;');
104104+ expect(getCellStyle({ s: { align: 'center' } } as any, '')).toContain('justify-content:center;');
105105+ expect(getCellStyle({ s: { align: 'left' } } as any, '')).toContain('justify-content:flex-start;');
106106+ });
107107+108108+ it('renders vertical alignment', () => {
109109+ expect(getCellStyle({ s: { verticalAlign: 'middle' } } as any, '')).toContain('align-items:center;');
110110+ expect(getCellStyle({ s: { verticalAlign: 'bottom' } } as any, '')).toContain('align-items:flex-end;');
111111+ });
112112+113113+ it('renders word wrap', () => {
114114+ const style = getCellStyle({ s: { wrap: true } } as any, '');
115115+ expect(style).toContain('white-space:normal;');
116116+ expect(style).toContain('word-wrap:break-word;');
117117+ });
118118+119119+ it('applies explicit text color', () => {
120120+ const style = getCellStyle({ s: { color: '#ff0000' } } as any, '');
121121+ expect(style).toContain('color:#ff0000;');
122122+ });
123123+124124+ it('skips theme-default colors', () => {
125125+ // #1a1815 is the light-mode default — should not emit inline color
126126+ expect(getCellStyle({ s: { color: '#1a1815' } } as any, '')).not.toContain('color:#1a1815');
127127+ expect(getCellStyle({ s: { color: '#ddd8ce' } } as any, '')).not.toContain('color:#ddd8ce');
128128+ });
129129+130130+ it('auto-contrasts: adds white text on dark bg', () => {
131131+ const style = getCellStyle({ s: { bg: '#000000' } } as any, '');
132132+ expect(style).toContain('color:#ffffff;');
133133+ });
134134+135135+ it('auto-contrasts: adds dark text on light bg', () => {
136136+ const style = getCellStyle({ s: { bg: '#ffff00' } } as any, '');
137137+ expect(style).toContain('color:#1a1815;');
138138+ });
139139+140140+ it('strips CF background from inline style (bg goes on td)', () => {
141141+ const style = getCellStyle(null, 'background:#ff0000;color:#ffffff;');
142142+ // CF color should be applied, but background should NOT be in the cell style
143143+ expect(style).not.toContain('background:');
144144+ expect(style).toContain('color:');
145145+ });
146146+});
+39
tests/conditional-format.test.ts
···66import {
77 evaluateRule, evaluateRules, buildCfStyle,
88 parseHex, toHex, lerpColor, colorScaleBg, computeColorScale,
99+ formatRuleLabel,
910} from '../src/sheets/conditional-format.js';
10111112describe('evaluateRule', () => {
···448449 expect(result.get('A2')!.bgColor).toBe('#63be7b'); // Excel default green
449450 });
450451});
452452+453453+describe('formatRuleLabel', () => {
454454+ it('formats greaterThan', () => {
455455+ expect(formatRuleLabel({ type: 'greaterThan', value: '10' } as any)).toBe('Greater than 10');
456456+ });
457457+458458+ it('formats lessThan', () => {
459459+ expect(formatRuleLabel({ type: 'lessThan', value: '5' } as any)).toBe('Less than 5');
460460+ });
461461+462462+ it('formats equalTo', () => {
463463+ expect(formatRuleLabel({ type: 'equalTo', value: 'abc' } as any)).toBe('Equal to abc');
464464+ });
465465+466466+ it('formats between', () => {
467467+ expect(formatRuleLabel({ type: 'between', value: '1', value2: '10' } as any)).toBe('Between 1 and 10');
468468+ });
469469+470470+ it('formats textContains', () => {
471471+ expect(formatRuleLabel({ type: 'textContains', value: 'hello' } as any)).toBe('Text contains "hello"');
472472+ });
473473+474474+ it('formats isEmpty', () => {
475475+ expect(formatRuleLabel({ type: 'isEmpty' } as any)).toBe('Is empty');
476476+ });
477477+478478+ it('formats isNotEmpty', () => {
479479+ expect(formatRuleLabel({ type: 'isNotEmpty' } as any)).toBe('Is not empty');
480480+ });
481481+482482+ it('falls back to type name for unknown types', () => {
483483+ expect(formatRuleLabel({ type: 'custom' } as any)).toBe('custom');
484484+ });
485485+486486+ it('handles missing value gracefully', () => {
487487+ expect(formatRuleLabel({ type: 'greaterThan' } as any)).toBe('Greater than ');
488488+ });
489489+});