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

Configure Feed

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

Merge pull request 'v0.6.1: Fix sheets scrolling, add context menu freeze/hide, UX improvements' (#63) from fix/sheets-ux-improvements into main

scott 622130e6 6202ecb0

+577 -31
+23
CHANGELOG.md
··· 7 7 8 8 ## [Unreleased] 9 9 10 + ## [0.6.1] — 2026-03-19 11 + 12 + ### Fixed 13 + - **Sticky column headers**: fixed `position: relative` → `position: sticky` on `thead th` that caused headers to scroll away 14 + - **Variable-height virtual scroll**: scroll engine now walks cumulative row heights instead of assuming uniform 26px, fixing misalignment with imported XLSX rows 15 + - **Frozen pane transparency**: frozen rows/columns now use opaque background to prevent content bleed-through during scroll 16 + - **Row resize handles**: added missing CSS for row resize handle visibility (handles existed in DOM but were invisible) 17 + - **Hidden row/column indicators**: added missing CSS for teal indicator lines (DOM elements existed but had no styles) 18 + - **Off-screen cell navigation**: `scrollCellIntoView` now estimates position for cells outside virtual DOM range 19 + - **Scroll render cache**: reset `_lastRenderedRange` on direct `renderGrid()` calls to prevent stale renders 20 + 21 + ### Added 22 + - **Context menu freeze/unfreeze**: right-click row/column headers to freeze or unfreeze panes 23 + - **Context menu hide/unhide**: right-click headers to hide rows/columns, with unhide at boundaries 24 + - **Drag-select auto-scroll**: holding mouse near container edges during selection automatically scrolls 25 + - **Selection range highlighting**: multi-cell selections show tinted background on all cells in range 26 + - **Keyboard navigation**: Shift+Arrow extends selection, PageUp/Down, Home, End, Ctrl+Home/End for data extent 27 + 28 + ### Tests 29 + - 2986 unit tests across 99 test files (+20 from v0.6.0) 30 + - Variable-height virtual scroll: 8 tests (uniform compat, variable heights, tall rows, clamping) 31 + - Sheets UX improvements: 12 tests (scroll edge cases, hidden row interaction, context menu labels, selection logic) 32 + 10 33 ## [0.6.0] — 2026-03-19 11 34 12 35 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.6.0", 3 + "version": "0.6.1", 4 4 "private": true, 5 5 "type": "module", 6 6 "scripts": {
+61 -4
src/css/app.css
··· 1646 1646 1647 1647 .sheet-grid thead th { 1648 1648 top: 0; 1649 - position: relative; 1649 + position: sticky; 1650 + z-index: 3; 1651 + background: var(--color-bg-subtle); 1650 1652 } 1651 1653 1652 1654 /* Column header resize handle */ ··· 1677 1679 pointer-events: none; 1678 1680 } 1679 1681 1682 + /* Row resize handle (mirrors column resize handle pattern) */ 1683 + .sheet-grid th.row-header .row-resize-handle { 1684 + position: absolute; 1685 + bottom: -3px; 1686 + left: 0; 1687 + width: 100%; 1688 + height: 6px; 1689 + cursor: row-resize; 1690 + z-index: 5; 1691 + } 1692 + .sheet-grid th.row-header .row-resize-handle:hover, 1693 + .sheet-grid th.row-header .row-resize-handle.active { 1694 + background: var(--color-teal); 1695 + opacity: 0.5; 1696 + } 1697 + 1698 + /* Row resize guide line */ 1699 + .row-resize-guide { 1700 + position: absolute; 1701 + left: 0; 1702 + height: 2px; 1703 + width: 100%; 1704 + background: var(--color-teal); 1705 + z-index: 100; 1706 + pointer-events: none; 1707 + opacity: 0.7; 1708 + } 1709 + 1710 + /* Hidden row/column indicators */ 1711 + .hidden-row-indicator { height: 4px; } 1712 + .hidden-row-indicator td { padding: 0 !important; border: none !important; } 1713 + .hidden-row-indicator-line { 1714 + height: 3px; 1715 + background: var(--color-teal); 1716 + opacity: 0.5; 1717 + cursor: pointer; 1718 + border-radius: 1px; 1719 + } 1720 + .hidden-row-indicator-line:hover { opacity: 0.8; } 1721 + .hidden-col-boundary { border-right: 2px solid var(--color-teal) !important; } 1722 + .hidden-row-boundary { border-bottom: 2px solid var(--color-teal) !important; } 1723 + 1680 1724 .sheet-grid .row-header { 1681 1725 position: sticky; 1682 1726 left: 0; ··· 1724 1768 z-index: 1; 1725 1769 } 1726 1770 1771 + /* Multi-cell selection range highlight */ 1772 + .sheet-grid td.in-range { 1773 + background: oklch(0.75 0.1 195 / 0.12) !important; 1774 + } 1775 + [data-theme="dark"] .sheet-grid td.in-range { 1776 + background: oklch(0.45 0.1 195 / 0.2) !important; 1777 + } 1778 + @media (prefers-color-scheme: dark) { 1779 + :root:not([data-theme="light"]) .sheet-grid td.in-range { 1780 + background: oklch(0.45 0.1 195 / 0.2) !important; 1781 + } 1782 + } 1783 + 1727 1784 .sheet-grid td.editing .cell-display { display: none; } 1728 1785 1729 1786 .cell-editor { ··· 1744 1801 .sheet-grid td.frozen-col, 1745 1802 .sheet-grid td.frozen-corner { 1746 1803 position: sticky; 1747 - background: oklch(0.95 0.008 195 / 0.35); 1804 + background: var(--color-bg, #fff); 1748 1805 } 1749 1806 1750 1807 [data-theme="dark"] .sheet-grid td.frozen-row, 1751 1808 [data-theme="dark"] .sheet-grid td.frozen-col, 1752 1809 [data-theme="dark"] .sheet-grid td.frozen-corner { 1753 - background: oklch(0.22 0.01 195 / 0.4); 1810 + background: var(--color-bg, #1a1815); 1754 1811 } 1755 1812 1756 1813 @media (prefers-color-scheme: dark) { 1757 1814 :root:not([data-theme="light"]) .sheet-grid td.frozen-row, 1758 1815 :root:not([data-theme="light"]) .sheet-grid td.frozen-col, 1759 1816 :root:not([data-theme="light"]) .sheet-grid td.frozen-corner { 1760 - background: oklch(0.22 0.01 195 / 0.4); 1817 + background: var(--color-bg, #1a1815); 1761 1818 } 1762 1819 } 1763 1820
+1 -1
src/docs/index.html
··· 433 433 updateIcon(); 434 434 })(); 435 435 </script> 436 - <div class="version-badge">v0.3.0</div> 436 + <div class="version-badge">v0.6.1</div> 437 437 </body> 438 438 </html>
+1 -1
src/index.html
··· 126 126 </div> 127 127 </div> 128 128 129 - <div class="version-badge">v0.3.0</div> 129 + <div class="version-badge">v0.6.1</div> 130 130 131 131 <!-- Drag-and-drop import overlay --> 132 132 <div class="drop-overlay" id="drop-overlay" style="display:none;">
+1 -1
src/sheets/index.html
··· 363 363 navigator.serviceWorker.register('/sw.js').catch(function() {}); 364 364 } 365 365 </script> 366 - <div class="version-badge">v0.3.0</div> 366 + <div class="version-badge">v0.6.1</div> 367 367 </body> 368 368 </html>
+145 -11
src/sheets/main.ts
··· 364 364 }); 365 365 } 366 366 function renderGrid() { 367 + // Reset scroll cache so direct calls always re-render 368 + if (typeof _lastRenderedRange !== 'undefined') _lastRenderedRange = { startRow: -1, endRow: -1 }; 367 369 const sheet = getActiveSheet(); 368 370 const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; 369 371 const colCount = sheet.get('colCount') || DEFAULT_COLS; ··· 453 455 scrollTop, 454 456 viewportHeight, 455 457 totalRows: rowCount, 456 - rowHeight: bodyRowHeight, 458 + getRowHeight: (r) => rh(r), 457 459 bufferRows: 5, 458 460 }); 459 461 ··· 959 961 updateSelectionVisuals(); 960 962 updateFormulaBar(); 961 963 964 + let _dragScrollTimer = null; 965 + const SCROLL_EDGE = 40; // px from edge to trigger auto-scroll 966 + const SCROLL_SPEED = 8; // px per frame 967 + 962 968 const onMouseMove = (ev) => { 963 969 const moveTd = ev.target.closest('td[data-id]'); 964 - if (!moveTd) return; 965 - selectionRange.endCol = parseInt(moveTd.dataset.col); 966 - selectionRange.endRow = parseInt(moveTd.dataset.row); 967 - updateSelectionVisuals(); 970 + if (moveTd) { 971 + selectionRange.endCol = parseInt(moveTd.dataset.col); 972 + selectionRange.endRow = parseInt(moveTd.dataset.row); 973 + updateSelectionVisuals(); 974 + } 975 + 976 + // Auto-scroll when dragging near container edges 977 + if (!sheetContainer) return; 978 + const rect = sheetContainer.getBoundingClientRect(); 979 + let scrollX = 0, scrollY = 0; 980 + if (ev.clientY > rect.bottom - SCROLL_EDGE) scrollY = SCROLL_SPEED; 981 + else if (ev.clientY < rect.top + SCROLL_EDGE) scrollY = -SCROLL_SPEED; 982 + if (ev.clientX > rect.right - SCROLL_EDGE) scrollX = SCROLL_SPEED; 983 + else if (ev.clientX < rect.left + SCROLL_EDGE) scrollX = -SCROLL_SPEED; 984 + 985 + if (scrollX || scrollY) { 986 + if (!_dragScrollTimer) { 987 + _dragScrollTimer = setInterval(() => { 988 + sheetContainer.scrollTop += scrollY; 989 + sheetContainer.scrollLeft += scrollX; 990 + }, 16); 991 + } 992 + } else if (_dragScrollTimer) { 993 + clearInterval(_dragScrollTimer); 994 + _dragScrollTimer = null; 995 + } 968 996 }; 969 997 970 998 const onMouseUp = () => { 971 999 isSelecting = false; 1000 + if (_dragScrollTimer) { clearInterval(_dragScrollTimer); _dragScrollTimer = null; } 972 1001 document.removeEventListener('mousemove', onMouseMove); 973 1002 document.removeEventListener('mouseup', onMouseUp); 974 1003 updateMergeButtonState(); ··· 1064 1093 // Skip if find-replace bar inputs are focused 1065 1094 if (document.activeElement && document.activeElement.closest('.sheets-find-bar')) return; 1066 1095 1067 - if (key === 'ArrowUp') { e.preventDefault(); moveSelection(0, -1); } 1068 - else if (key === 'ArrowDown') { e.preventDefault(); moveSelection(0, 1); } 1069 - else if (key === 'ArrowLeft') { e.preventDefault(); moveSelection(-1, 0); } 1070 - else if (key === 'ArrowRight') { e.preventDefault(); moveSelection(1, 0); } 1096 + if (key === 'ArrowUp' || key === 'ArrowDown' || key === 'ArrowLeft' || key === 'ArrowRight') { 1097 + e.preventDefault(); 1098 + const dCol = key === 'ArrowLeft' ? -1 : key === 'ArrowRight' ? 1 : 0; 1099 + const dRow = key === 'ArrowUp' ? -1 : key === 'ArrowDown' ? 1 : 0; 1100 + if (e.shiftKey) { 1101 + extendSelection(dCol, dRow); 1102 + } else { 1103 + moveSelection(dCol, dRow); 1104 + } 1105 + } 1106 + else if (key === 'PageDown' || key === 'PageUp') { 1107 + e.preventDefault(); 1108 + const pageRows = Math.max(1, Math.floor((sheetContainer?.clientHeight || 600) / bodyRowHeight) - 2); 1109 + const dir = key === 'PageDown' ? pageRows : -pageRows; 1110 + if (e.shiftKey) { 1111 + extendSelection(0, dir); 1112 + } else { 1113 + moveSelection(0, dir); 1114 + } 1115 + } 1116 + else if (key === 'Home') { 1117 + e.preventDefault(); 1118 + if (e.metaKey || e.ctrlKey) { 1119 + // Ctrl/Cmd+Home → go to A1 1120 + selectedCell = { col: 1, row: 1 }; 1121 + selectionRange = { startCol: 1, startRow: 1, endCol: 1, endRow: 1 }; 1122 + updateSelectionVisuals(); updateFormulaBar(); scrollCellIntoView(1, 1); 1123 + } else { 1124 + // Home → go to column 1 in current row 1125 + moveSelectionTo(1, selectedCell.row); 1126 + } 1127 + } 1128 + else if (key === 'End') { 1129 + e.preventDefault(); 1130 + if (e.metaKey || e.ctrlKey) { 1131 + // Ctrl/Cmd+End → go to last used cell 1132 + const extent = getDataExtent(); 1133 + moveSelectionTo(extent.col, extent.row); 1134 + } 1135 + } 1071 1136 else if (key === 'Tab') { e.preventDefault(); moveSelection(e.shiftKey ? -1 : 1, 0); } 1072 1137 else if (key === 'Enter') { e.preventDefault(); startEditing(selectedCell.col, selectedCell.row); } 1073 1138 else if (key === 'Delete' || key === 'Backspace') { e.preventDefault(); deleteSelectedCells(); } ··· 1145 1210 scrollCellIntoView(newCol, newRow); 1146 1211 } 1147 1212 1213 + function extendSelection(dCol, dRow) { 1214 + const sheet = getActiveSheet(); 1215 + const maxCol = sheet.get('colCount') || DEFAULT_COLS; 1216 + const maxRow = sheet.get('rowCount') || DEFAULT_ROWS; 1217 + if (!selectionRange) { 1218 + selectionRange = { startCol: selectedCell.col, startRow: selectedCell.row, endCol: selectedCell.col, endRow: selectedCell.row }; 1219 + } 1220 + selectionRange.endCol = Math.max(1, Math.min(maxCol, selectionRange.endCol + dCol)); 1221 + selectionRange.endRow = Math.max(1, Math.min(maxRow, selectionRange.endRow + dRow)); 1222 + updateSelectionVisuals(); 1223 + scrollCellIntoView(selectionRange.endCol, selectionRange.endRow); 1224 + } 1225 + 1226 + function moveSelectionTo(col, row) { 1227 + selectedCell = { col, row }; 1228 + selectionRange = { startCol: col, startRow: row, endCol: col, endRow: row }; 1229 + updateSelectionVisuals(); 1230 + updateFormulaBar(); 1231 + scrollCellIntoView(col, row); 1232 + } 1233 + 1234 + function getDataExtent() { 1235 + let maxRow = 1, maxCol = 1; 1236 + const cells = getCells(); 1237 + cells.forEach((_, id) => { 1238 + const ref = parseRef(id); 1239 + if (ref) { 1240 + if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 1241 + if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 1242 + } 1243 + }); 1244 + return { col: maxCol, row: maxRow }; 1245 + } 1246 + 1148 1247 function scrollCellIntoView(col, row) { 1149 1248 const td = grid.querySelector('td[data-col="' + col + '"][data-row="' + row + '"]'); 1150 - if (td) td.scrollIntoView({ block: 'nearest', inline: 'nearest' }); 1249 + if (td) { 1250 + td.scrollIntoView({ block: 'nearest', inline: 'nearest' }); 1251 + return; 1252 + } 1253 + // Cell not in DOM (outside virtual range) — scroll to estimated position 1254 + if (!sheetContainer) return; 1255 + let targetTop = 0; 1256 + for (let r = 1; r < row; r++) targetTop += rh(r); 1257 + let targetLeft = ROW_HEADER_WIDTH; 1258 + for (let c = 1; c < col; c++) targetLeft += getColWidth(c); 1259 + sheetContainer.scrollTop = Math.max(0, targetTop - sheetContainer.clientHeight / 3); 1260 + sheetContainer.scrollLeft = Math.max(0, targetLeft - sheetContainer.clientWidth / 3); 1151 1261 } 1152 1262 1153 1263 function deleteSelectedCells() { ··· 4151 4261 SEPARATOR, 4152 4262 { label: 'Hide Column', shortcut: '\u2318+0', action: () => { selectedCell = { col, row: 1 }; selectionRange = { startCol: col, startRow: 1, endCol: col, endRow: rowCount }; hideSelectedCols(); } }, 4153 4263 ...(hasAdjacentHiddenCol ? [{ label: 'Unhide Columns', shortcut: '\u2318\u21e7+0', action: () => unhideAdjacentCols(col) }] : []), 4264 + SEPARATOR, 4265 + ...(getFreezeCols() !== col ? [{ label: 'Freeze up to column ' + colToLetter(col), action: () => { setFreezeCols(col); renderGrid(); } }] : []), 4266 + ...(getFreezeCols() > 0 ? [{ label: 'Unfreeze Columns', action: () => { setFreezeCols(0); renderGrid(); } }] : []), 4267 + { label: 'Resize Column\u2026', action: () => { const w = prompt('Column width (px):', String(getColWidth(col))); if (w && !isNaN(Number(w))) { setColWidth(col, Number(w)); renderGrid(); } } }, 4154 4268 ]; 4155 4269 } else if (rowHeader) { 4156 4270 // Row header right-click ··· 4163 4277 SEPARATOR, 4164 4278 { label: 'Hide Row', shortcut: '\u2318+9', action: () => { selectedCell = { col: 1, row }; selectionRange = { startCol: 1, startRow: row, endCol: colCount, endRow: row }; hideSelectedRows(); } }, 4165 4279 ...(hasAdjacentHiddenRow ? [{ label: 'Unhide Rows', shortcut: '\u2318\u21e7+9', action: () => unhideAdjacentRows(row) }] : []), 4280 + SEPARATOR, 4281 + ...(getFreezeRows() !== row ? [{ label: 'Freeze at row ' + row, action: () => { setFreezeRows(row); renderGrid(); } }] : []), 4282 + ...(getFreezeRows() > 0 ? [{ label: 'Unfreeze Rows', action: () => { setFreezeRows(0); renderGrid(); } }] : []), 4283 + { label: 'Resize Row\u2026', action: () => { const h = prompt('Row height (px):', String(rh(row))); if (h && !isNaN(Number(h))) { setRowHeight(row, Number(h)); renderGrid(); } } }, 4166 4284 ]; 4167 4285 } else if (td) { 4168 4286 // Cell right-click ··· 4213 4331 4214 4332 // --- Virtual scroll: re-render on scroll (#146) --- 4215 4333 let _scrollRenderTimer = null; 4334 + let _lastRenderedRange = { startRow: -1, endRow: -1 }; 4216 4335 if (sheetContainer) { 4217 4336 sheetContainer.addEventListener('scroll', () => { 4218 4337 if (_scrollRenderTimer) return; 4219 4338 _scrollRenderTimer = requestAnimationFrame(() => { 4220 4339 _scrollRenderTimer = null; 4221 - if (!editingCell) renderGrid(); 4340 + if (editingCell) return; 4341 + // Skip re-render if visible range hasn't changed (prevents scroll jank) 4342 + const sheet = getActiveSheet(); 4343 + const rc = sheet.get('rowCount') || DEFAULT_ROWS; 4344 + const vp = sheetContainer.clientHeight; 4345 + const st = sheetContainer.scrollTop; 4346 + const range = calculateVisibleRange({ 4347 + scrollTop: st, 4348 + viewportHeight: vp, 4349 + totalRows: rc, 4350 + getRowHeight: (r) => rh(r), 4351 + bufferRows: 5, 4352 + }); 4353 + if (range.startRow === _lastRenderedRange.startRow && range.endRow === _lastRenderedRange.endRow) return; 4354 + _lastRenderedRange = range; 4355 + renderGrid(); 4222 4356 }); 4223 4357 }); 4224 4358 }
+53 -12
src/sheets/virtual-scroll.ts
··· 14 14 export const DEFAULT_BUFFER_ROWS = 10; // extra rows above and below viewport 15 15 16 16 /** 17 + * Parameters for variable-height virtual scroll. 18 + */ 19 + export interface VariableHeightScrollParams { 20 + scrollTop: number; 21 + viewportHeight: number; 22 + totalRows: number; 23 + /** Return the pixel height for a 1-based row number */ 24 + getRowHeight: (row: number) => number; 25 + bufferRows?: number; 26 + } 27 + 28 + /** 17 29 * Calculate which rows should be rendered based on scroll position. 30 + * Supports variable row heights via a callback. 18 31 */ 19 - export function calculateVisibleRange({ 20 - scrollTop, 21 - viewportHeight, 22 - totalRows, 23 - rowHeight = DEFAULT_ROW_HEIGHT, 24 - bufferRows = DEFAULT_BUFFER_ROWS, 25 - }: VirtualScrollParams): VisibleRange { 26 - // Calculate first visible row (0-based index) 27 - const firstVisibleIndex = Math.floor(scrollTop / rowHeight); 32 + export function calculateVisibleRange(params: VirtualScrollParams | VariableHeightScrollParams): VisibleRange { 33 + const { scrollTop, viewportHeight, totalRows, bufferRows = DEFAULT_BUFFER_ROWS } = params; 34 + 35 + if ('getRowHeight' in params) { 36 + // Variable-height path: walk cumulative heights to find first visible row 37 + const getH = params.getRowHeight; 38 + let cumHeight = 0; 39 + let firstVisibleIndex = 0; 40 + 41 + // Find first visible row by accumulating heights (1-based rows) 42 + for (let r = 1; r <= totalRows; r++) { 43 + const h = getH(r); 44 + if (cumHeight + h > scrollTop) { 45 + firstVisibleIndex = r - 1; // 0-based 46 + break; 47 + } 48 + cumHeight += h; 49 + if (r === totalRows) firstVisibleIndex = totalRows - 1; 50 + } 51 + 52 + // Find how many rows fit in viewport from the first visible 53 + let filled = 0; 54 + let visibleCount = 0; 55 + for (let r = firstVisibleIndex + 1; r <= totalRows; r++) { 56 + filled += getH(r); 57 + visibleCount++; 58 + if (filled >= viewportHeight) break; 59 + } 28 60 29 - // Calculate number of rows that fit in the viewport 61 + const startIndex = Math.max(0, firstVisibleIndex - bufferRows); 62 + const endIndex = Math.min(totalRows - 1, firstVisibleIndex + visibleCount - 1 + bufferRows); 63 + 64 + return { 65 + startRow: startIndex + 1, 66 + endRow: Math.max(startIndex + 1, endIndex + 1), 67 + }; 68 + } 69 + 70 + // Uniform-height path (original behavior) 71 + const rowHeight = params.rowHeight || DEFAULT_ROW_HEIGHT; 72 + const firstVisibleIndex = Math.floor(scrollTop / rowHeight); 30 73 const visibleCount = Math.ceil(viewportHeight / rowHeight); 31 74 32 - // Apply buffer 33 75 const startIndex = Math.max(0, firstVisibleIndex - bufferRows); 34 76 const endIndex = Math.min(totalRows - 1, firstVisibleIndex + visibleCount - 1 + bufferRows); 35 77 36 - // Convert to 1-based row numbers 37 78 return { 38 79 startRow: startIndex + 1, 39 80 endRow: Math.max(startIndex + 1, endIndex + 1),
+163
tests/sheets-ux-improvements.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { calculateVisibleRange } from '../src/sheets/virtual-scroll.js'; 3 + import { computeVisibleRows, computeVisibleCols, isAtHiddenRowBoundary, isAtHiddenColBoundary } from '../src/sheets/hidden-rows-cols.js'; 4 + 5 + describe('variable-height virtual scroll edge cases', () => { 6 + it('alternating tall/short rows', () => { 7 + const getRowHeight = (r: number) => r % 2 === 0 ? 50 : 10; 8 + const result = calculateVisibleRange({ 9 + scrollTop: 0, 10 + viewportHeight: 200, 11 + totalRows: 100, 12 + getRowHeight, 13 + bufferRows: 3, 14 + }); 15 + // Row heights: 10, 50, 10, 50, 10, 50, 10, 50... (avg 30) 16 + // ~6-7 rows visible in 200px + 3 buffer 17 + expect(result.startRow).toBe(1); 18 + expect(result.endRow).toBeGreaterThanOrEqual(8); 19 + expect(result.endRow).toBeLessThanOrEqual(15); 20 + }); 21 + 22 + it('scrolled midway with variable heights', () => { 23 + // Row heights: all 26 except row 5 = 100 24 + const getRowHeight = (r: number) => r === 5 ? 100 : 26; 25 + // Scroll to 130px: rows 1-4 = 104px, row 5 starts at 104 26 + // At 130px we're inside row 5 27 + const result = calculateVisibleRange({ 28 + scrollTop: 130, 29 + viewportHeight: 260, 30 + totalRows: 50, 31 + getRowHeight, 32 + bufferRows: 2, 33 + }); 34 + // First visible row is row 5, minus 2 buffer = row 3 35 + expect(result.startRow).toBeLessThanOrEqual(5); 36 + expect(result.startRow).toBeGreaterThanOrEqual(1); 37 + }); 38 + 39 + it('zero scrollTop with very tall first row', () => { 40 + const getRowHeight = (r: number) => r === 1 ? 500 : 26; 41 + const result = calculateVisibleRange({ 42 + scrollTop: 0, 43 + viewportHeight: 260, 44 + totalRows: 50, 45 + getRowHeight, 46 + bufferRows: 2, 47 + }); 48 + expect(result.startRow).toBe(1); 49 + // Only row 1 fits (500px > 260px viewport), but buffer adds a few more 50 + expect(result.endRow).toBeGreaterThanOrEqual(1); 51 + }); 52 + }); 53 + 54 + describe('hidden rows interact with virtual scroll', () => { 55 + it('computeVisibleRows skips hidden rows', () => { 56 + const hiddenSet = { has: (r: number) => r === 3 || r === 5 }; 57 + const { rows } = computeVisibleRows(1, 10, hiddenSet); 58 + expect(rows).not.toContain(3); 59 + expect(rows).not.toContain(5); 60 + expect(rows).toContain(1); 61 + expect(rows).toContain(2); 62 + expect(rows).toContain(4); 63 + }); 64 + 65 + it('isAtHiddenRowBoundary detects boundaries', () => { 66 + const hiddenSet = { has: (r: number) => r === 3 || r === 4 }; 67 + // Row 2 is adjacent to hidden row 3 68 + expect(isAtHiddenRowBoundary(2, 10, hiddenSet)).toBe(true); 69 + // Row 5 is adjacent to hidden row 4 70 + expect(isAtHiddenRowBoundary(5, 10, hiddenSet)).toBe(true); 71 + // Row 1 is not adjacent to any hidden row 72 + expect(isAtHiddenRowBoundary(1, 10, hiddenSet)).toBe(false); 73 + }); 74 + }); 75 + 76 + describe('context menu freeze/unfreeze items', () => { 77 + // These test the logic patterns used in the context menu item generation 78 + it('freeze column generates correct label', () => { 79 + const col = 3; 80 + const colToLetter = (c: number) => String.fromCharCode(64 + c); 81 + const label = 'Freeze up to column ' + colToLetter(col); 82 + expect(label).toBe('Freeze up to column C'); 83 + }); 84 + 85 + it('freeze row generates correct label', () => { 86 + const row = 5; 87 + const label = 'Freeze at row ' + row; 88 + expect(label).toBe('Freeze at row 5'); 89 + }); 90 + 91 + it('unfreeze items only shown when freeze is active', () => { 92 + // Simulate the spread pattern used in context menus 93 + const freezeRows = 3; 94 + const items = [ 95 + ...(freezeRows > 0 ? [{ label: 'Unfreeze Rows' }] : []), 96 + ]; 97 + expect(items).toHaveLength(1); 98 + expect(items[0].label).toBe('Unfreeze Rows'); 99 + 100 + const noFreeze = 0; 101 + const items2 = [ 102 + ...(noFreeze > 0 ? [{ label: 'Unfreeze Rows' }] : []), 103 + ]; 104 + expect(items2).toHaveLength(0); 105 + }); 106 + }); 107 + 108 + describe('selection extension logic', () => { 109 + it('extendSelection clamps within bounds', () => { 110 + const maxCol = 26, maxRow = 100; 111 + // Simulate extending selection right from col 25 112 + let endCol = 25; 113 + endCol = Math.max(1, Math.min(maxCol, endCol + 1)); 114 + expect(endCol).toBe(26); 115 + // Cannot go past max 116 + endCol = Math.max(1, Math.min(maxCol, endCol + 1)); 117 + expect(endCol).toBe(26); 118 + }); 119 + 120 + it('page navigation calculates correct jump', () => { 121 + const viewportHeight = 520; // px 122 + const rowHeight = 26; 123 + const pageRows = Math.max(1, Math.floor(viewportHeight / rowHeight) - 2); 124 + expect(pageRows).toBe(18); // 20 - 2 = 18 rows per page 125 + }); 126 + 127 + it('data extent finds last used cell', () => { 128 + // Simulate getDataExtent logic 129 + const cellIds = ['A1', 'C5', 'B10', 'Z1']; 130 + let maxRow = 1, maxCol = 1; 131 + // Simplified parseRef simulation 132 + const refs = [ 133 + { col: 1, row: 0 }, // A1 134 + { col: 3, row: 4 }, // C5 135 + { col: 2, row: 9 }, // B10 136 + { col: 26, row: 0 }, // Z1 137 + ]; 138 + for (const ref of refs) { 139 + if (ref.row + 1 > maxRow) maxRow = ref.row + 1; 140 + if (ref.col + 1 > maxCol) maxCol = ref.col + 1; 141 + } 142 + expect(maxRow).toBe(10); 143 + expect(maxCol).toBe(27); 144 + }); 145 + }); 146 + 147 + describe('scrollCellIntoView estimation', () => { 148 + it('calculates target position for off-screen cell', () => { 149 + const rowHeight = 26; 150 + const colWidth = 96; 151 + const ROW_HEADER_WIDTH = 48; 152 + const targetRow = 50; 153 + const targetCol = 10; 154 + 155 + let targetTop = 0; 156 + for (let r = 1; r < targetRow; r++) targetTop += rowHeight; 157 + expect(targetTop).toBe(49 * 26); // 1274px 158 + 159 + let targetLeft = ROW_HEADER_WIDTH; 160 + for (let c = 1; c < targetCol; c++) targetLeft += colWidth; 161 + expect(targetLeft).toBe(48 + 9 * 96); // 912px 162 + }); 163 + });
+128
tests/virtual-scroll-variable.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { calculateVisibleRange, DEFAULT_ROW_HEIGHT } from '../src/sheets/virtual-scroll.js'; 3 + 4 + describe('calculateVisibleRange with variable row heights', () => { 5 + const uniformHeight = (_r: number) => 26; 6 + const variableHeight = (r: number) => { 7 + if (r === 3) return 52; // double-height row 8 + if (r === 7) return 13; // half-height row 9 + return 26; 10 + }; 11 + 12 + it('uniform heights match original behavior', () => { 13 + const result = calculateVisibleRange({ 14 + scrollTop: 0, 15 + viewportHeight: 260, 16 + totalRows: 100, 17 + getRowHeight: uniformHeight, 18 + bufferRows: 5, 19 + }); 20 + expect(result.startRow).toBe(1); 21 + // ~10 visible rows + 5 buffer below 22 + expect(result.endRow).toBeGreaterThanOrEqual(15); 23 + }); 24 + 25 + it('finds correct first visible row with variable heights', () => { 26 + // scrollTop = 78 (rows 1-3: 26 + 26 + 52 = 104, so row 3 is still partially visible) 27 + // Row heights: row1=26, row2=26, row3=52, row4=26... 28 + // At scrollTop=78, we're inside row 3 (starts at 52, ends at 104) 29 + const result = calculateVisibleRange({ 30 + scrollTop: 78, 31 + viewportHeight: 260, 32 + totalRows: 100, 33 + getRowHeight: variableHeight, 34 + bufferRows: 2, 35 + }); 36 + // First visible should be row 3 (index 2), minus 2 buffer = row 1 37 + expect(result.startRow).toBe(1); 38 + }); 39 + 40 + it('accounts for tall rows consuming more viewport', () => { 41 + // All rows are 100px tall — only ~2-3 fit in 260px viewport 42 + const tallHeight = (_r: number) => 100; 43 + const result = calculateVisibleRange({ 44 + scrollTop: 0, 45 + viewportHeight: 260, 46 + totalRows: 50, 47 + getRowHeight: tallHeight, 48 + bufferRows: 2, 49 + }); 50 + // 3 visible + 2 buffer below = endRow ~5 51 + expect(result.endRow).toBeLessThanOrEqual(7); 52 + expect(result.endRow).toBeGreaterThanOrEqual(4); 53 + }); 54 + 55 + it('handles scrolling past many rows', () => { 56 + // Scroll 2600px down with 26px rows = roughly row 100 57 + const result = calculateVisibleRange({ 58 + scrollTop: 2600, 59 + viewportHeight: 260, 60 + totalRows: 200, 61 + getRowHeight: uniformHeight, 62 + bufferRows: 5, 63 + }); 64 + // Should be around row 95-105 65 + expect(result.startRow).toBeGreaterThanOrEqual(90); 66 + expect(result.endRow).toBeLessThanOrEqual(120); 67 + }); 68 + 69 + it('clamps to totalRows at the bottom', () => { 70 + const result = calculateVisibleRange({ 71 + scrollTop: 5000, 72 + viewportHeight: 260, 73 + totalRows: 50, 74 + getRowHeight: uniformHeight, 75 + bufferRows: 5, 76 + }); 77 + expect(result.endRow).toBe(50); 78 + }); 79 + 80 + it('handles single row', () => { 81 + const result = calculateVisibleRange({ 82 + scrollTop: 0, 83 + viewportHeight: 260, 84 + totalRows: 1, 85 + getRowHeight: uniformHeight, 86 + bufferRows: 5, 87 + }); 88 + expect(result.startRow).toBe(1); 89 + expect(result.endRow).toBe(1); 90 + }); 91 + }); 92 + 93 + describe('calculateVisibleRange with uniform rowHeight (backwards compat)', () => { 94 + it('still works with rowHeight param', () => { 95 + const result = calculateVisibleRange({ 96 + scrollTop: 0, 97 + viewportHeight: 260, 98 + totalRows: 100, 99 + rowHeight: 26, 100 + bufferRows: 5, 101 + }); 102 + expect(result.startRow).toBe(1); 103 + expect(result.endRow).toBeGreaterThanOrEqual(15); 104 + }); 105 + }); 106 + 107 + describe('keyboard navigation helpers', () => { 108 + // These test the logic patterns used by extendSelection/moveSelectionTo 109 + it('extendSelection clamps to bounds', () => { 110 + const maxCol = 26, maxRow = 100; 111 + let endCol = 1, endRow = 1; 112 + // Extend right 113 + endCol = Math.max(1, Math.min(maxCol, endCol + 1)); 114 + expect(endCol).toBe(2); 115 + // Extend past max 116 + endCol = Math.max(1, Math.min(maxCol, 26 + 1)); 117 + expect(endCol).toBe(26); 118 + // Extend left past min 119 + endCol = Math.max(1, Math.min(maxCol, 1 - 1)); 120 + expect(endCol).toBe(1); 121 + // Page down 122 + endRow = Math.max(1, Math.min(maxRow, 1 + 20)); 123 + expect(endRow).toBe(21); 124 + // Page up from top 125 + endRow = Math.max(1, Math.min(maxRow, 5 - 20)); 126 + expect(endRow).toBe(1); 127 + }); 128 + });