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 'fix(sheets): eliminate scroll jank — remove JS virtual scroll' (#82) from fix/transform-scroll into main

scott 7bcbd435 2ff30c6f

+19 -123
+1 -8
src/css/app.css
··· 1805 1805 inset 0 -1px 0 var(--color-grid-line); 1806 1806 } 1807 1807 1808 - /* Virtual scroll spacer rows: ensure they maintain their declared height */ 1809 - .sheet-grid .virtual-spacer-top td, 1810 - .sheet-grid .virtual-spacer-bottom td { 1811 - border: none; 1812 - box-shadow: none; 1813 - padding: 0; 1814 - line-height: 0; 1815 - } 1808 + /* All rows rendered — browser scrolls natively, no virtual scroll spacers needed */ 1816 1809 1817 1810 .sheet-grid td .cell-display { 1818 1811 padding: 0.15rem 0.4rem;
+18 -115
src/sheets/main.ts
··· 27 27 import { extractFormulaRanges, assignRangeColors, renderGridHighlights, clearGridHighlights } from './range-highlight.js'; 28 28 import { detectCurrentFunction, renderTooltip, hideTooltip } from './formula-tooltip.js'; 29 29 import { extractFormat, applyFormat } from './format-painter.js'; 30 - import { calculateVisibleRange, calculateSpacerHeight, DEFAULT_ROW_HEIGHT, DEFAULT_BUFFER_ROWS } from './virtual-scroll.js'; 30 + // virtual-scroll.ts still exists but is no longer used — all rows are rendered 31 + // and the browser handles scroll natively (no JS during scroll). 31 32 import { insertRow as rowColInsertRow, deleteRow as rowColDeleteRow, insertColumn as rowColInsertColumn, deleteColumn as rowColDeleteColumn } from './row-col-ops.js'; 32 33 import { createContextMenu, buildSheetsCellItems, buildSheetsColumnHeaderItems, buildSheetsRowHeaderItems, SEPARATOR } from '../lib/context-menu.js'; 33 34 import type { MenuItem, MenuItemConfig } from '../lib/context-menu.js'; ··· 367 368 let _isRendering = false; 368 369 let _gridEventsAttached = false; 369 370 370 - function renderGrid(scrollOnly = false) { 371 + function renderGrid() { 371 372 _isRendering = true; 372 373 const sheet = getActiveSheet(); 373 374 const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; ··· 405 406 cumTop += rh(r); 406 407 } 407 408 408 - // --- Build colgroup + thead (only on full renders) --- 409 - let headHtml = ''; 410 - if (!scrollOnly) { 411 - headHtml += '<colgroup>'; 409 + // --- Build colgroup + thead --- 410 + let headHtml = '<colgroup>'; 412 411 headHtml += '<col style="width:' + ROW_HEADER_WIDTH + 'px;min-width:' + ROW_HEADER_WIDTH + 'px">'; 413 412 for (let c = 1; c <= colCount; c++) { 414 413 if (!visibleColSet.has(c)) { ··· 444 443 const classAttr = cls.length ? ' class="' + cls.join(' ') + '"' : ''; 445 444 headHtml += '<th data-col="' + c + '"' + classAttr + ' role="columnheader" aria-colindex="' + c + '" style="' + leftStyle + '">' + colToLetter(c) + '<div class="col-resize-handle" data-resize-col="' + c + '"></div></th>'; 446 445 } 447 - headHtml += '</tr></thead>'; 448 - } 446 + headHtml += '</tr></thead>'; 449 447 450 448 // --- Build tbody (always rebuilt) --- 451 449 let tbodyHtml = ''; 452 450 453 451 // --- Pre-compute conditional formatting and striped state --- 454 - // Skip expensive CF/validation during scroll-only renders for performance 455 - const cfRules = scrollOnly ? [] : getCfRulesArray(); 452 + const cfRules = getCfRulesArray(); 456 453 const stripedEnabled = getStripedRows(); 457 454 458 - // --- Virtual scrolling: calculate visible row range (#146) --- 459 - const viewportHeight = sheetContainer ? sheetContainer.clientHeight : 600; 460 - const scrollTop = sheetContainer ? sheetContainer.scrollTop : 0; 461 - const visibleRange = calculateVisibleRange({ 462 - scrollTop, 463 - viewportHeight, 464 - totalRows: rowCount, 465 - getRowHeight: (r) => rh(r), 466 - bufferRows: 15, 467 - }); 468 - 469 - // Always render frozen rows + visible range 470 - const renderStartRow = Math.max(freezeR + 1, visibleRange.startRow); 471 - const renderEndRow = visibleRange.endRow; 472 - 473 - // Top spacer: always present (even with 0 height) for consistent DOM structure 474 - let topSpacerHeight = 0; 475 - for (let sr = freezeR + 1; sr < renderStartRow; sr++) { 476 - if (!hiddenRowSet.has(sr)) topSpacerHeight += rh(sr); 455 + // --- All rows rendered; browser handles scroll natively (no JS virtual scroll) --- 456 + // Compute all visible rows (frozen + body), respecting hidden rows 457 + const allRowsToRender: number[] = []; 458 + for (let r = 1; r <= rowCount; r++) { 459 + if (!hiddenRowSet.has(r)) allRowsToRender.push(r); 477 460 } 478 - tbodyHtml += '<tr class="virtual-spacer-top" style="height:' + topSpacerHeight + 'px"><td colspan="' + (colCount + 1) + '"></td></tr>'; 479 461 480 - // --- Body rows (frozen rows always rendered, then visible range) --- 481 - const frozenRowsToRender = []; 482 - for (let r = 1; r <= freezeR; r++) { 483 - if (!hiddenRowSet.has(r)) frozenRowsToRender.push(r); 484 - } 485 - 486 - // Compute visible rows in the virtual range (excluding hidden) 487 - const { rows: bodyVisibleRows, indicators: rowIndicators } = computeVisibleRows(renderStartRow, renderEndRow, hiddenRowSet); 488 - const allRowsToRender = [...frozenRowsToRender, ...bodyVisibleRows]; 489 - 490 - // Build a set of rows after which we need hidden-row indicators 462 + // Build hidden-row indicator map 463 + const { indicators: rowIndicators } = computeVisibleRows(1, rowCount, hiddenRowSet); 491 464 const rowIndicatorMap = new Map(); 492 465 for (const ind of rowIndicators) rowIndicatorMap.set(ind.afterRow, ind.hiddenCount); 493 466 494 467 // Find & replace match state for highlighting 495 468 const findActive = sheetsFindState.matches.length > 0; 496 - 497 - let prevRenderedRow = frozenRowsToRender.length > 0 ? frozenRowsToRender[frozenRowsToRender.length - 1] : renderStartRow - 1; 498 469 499 470 for (const r of allRowsToRender) { 500 471 const rowH = rh(r); ··· 603 574 tbodyHtml += '<tr class="hidden-row-indicator" title="' + count + ' hidden row' + (count > 1 ? 's' : '') + '"><td colspan="' + (colCount + 1) + '"><div class="hidden-row-indicator-line"></div></td></tr>'; 604 575 } 605 576 606 - prevRenderedRow = r; 607 577 } 608 578 609 - // Bottom spacer: always present (even with 0 height) for consistent DOM structure 610 - let bottomSpacerHeight = 0; 611 - for (let sr = renderEndRow + 1; sr <= rowCount; sr++) { 612 - if (!hiddenRowSet.has(sr)) bottomSpacerHeight += rh(sr); 613 - } 614 - tbodyHtml += '<tr class="virtual-spacer-bottom" style="height:' + bottomSpacerHeight + 'px"><td colspan="' + (colCount + 1) + '"></td></tr>'; 615 - 616 579 // --- Apply to DOM --- 617 - // Compute total body height for all non-hidden rows (pinned on the table to prevent 618 - // scroll position clamping during DOM swap — the table height never changes) 619 - let totalBodyHeight = 0; 620 - for (let r = 1; r <= rowCount; r++) { 621 - if (!hiddenRowSet.has(r)) totalBodyHeight += rh(r); 622 - } 623 - grid.style.minHeight = (headerRowHeight + totalBodyHeight) + 'px'; 580 + grid.innerHTML = headHtml + '<tbody>' + tbodyHtml + '</tbody>'; 624 581 625 - const savedScrollTop = sheetContainer ? sheetContainer.scrollTop : 0; 626 - const savedScrollLeft = sheetContainer ? sheetContainer.scrollLeft : 0; 627 - 628 - if (scrollOnly) { 629 - // Scroll-only: atomic tbody swap — build offscreen, then replaceChild 630 - const oldTbody = grid.querySelector('tbody'); 631 - if (oldTbody) { 632 - const newTbody = document.createElement('tbody'); 633 - newTbody.innerHTML = tbodyHtml; 634 - grid.replaceChild(newTbody, oldTbody); 635 - } else { 636 - grid.innerHTML = headHtml + '<tbody>' + tbodyHtml + '</tbody>'; 637 - } 638 - } else { 639 - // Full render: rebuild everything 640 - grid.innerHTML = headHtml + '<tbody>' + tbodyHtml + '</tbody>'; 641 - } 642 - 643 - if (sheetContainer) { 644 - sheetContainer.scrollTop = savedScrollTop; 645 - sheetContainer.scrollLeft = savedScrollLeft; 646 - } 647 - 648 - // Update the rendered range cache so scroll handler can skip no-op re-renders 649 - _lastRenderedRange = { startRow: renderStartRow, endRow: renderEndRow }; 650 582 _isRendering = false; 651 583 652 584 // Attach event listeners only once (they use event delegation on the table) ··· 655 587 _gridEventsAttached = true; 656 588 } 657 589 updateSelectionVisuals(); 658 - if (!scrollOnly) { 659 - updateFreezeToolbarState(); 660 - } 590 + updateFreezeToolbarState(); 661 591 renderNoteIndicators(); 662 592 renderSparklines(); 663 593 } ··· 4612 4542 // Observe notes changes from collaborators 4613 4543 getNotesMap().observe(() => renderNoteIndicators()); 4614 4544 4615 - // --- Virtual scroll: re-render on scroll (#146) --- 4616 - let _scrollRenderTimer = null; 4617 - let _lastRenderedRange = { startRow: -1, endRow: -1 }; 4618 - if (sheetContainer) { 4619 - sheetContainer.addEventListener('scroll', () => { 4620 - // Skip if we're inside a renderGrid() call (scroll position restore fires this) 4621 - if (_isRendering) return; 4622 - if (_scrollRenderTimer) return; 4623 - _scrollRenderTimer = requestAnimationFrame(() => { 4624 - _scrollRenderTimer = null; 4625 - if (_isRendering) return; 4626 - if (editingCell) return; 4627 - // Skip re-render if visible range hasn't changed (prevents scroll jank) 4628 - const sheet = getActiveSheet(); 4629 - const rc = sheet.get('rowCount') || DEFAULT_ROWS; 4630 - const vp = sheetContainer.clientHeight; 4631 - const st = sheetContainer.scrollTop; 4632 - const range = calculateVisibleRange({ 4633 - scrollTop: st, 4634 - viewportHeight: vp, 4635 - totalRows: rc, 4636 - getRowHeight: (r) => getRowHeight(r), 4637 - bufferRows: 15, 4638 - }); 4639 - if (range.startRow === _lastRenderedRange.startRow && range.endRow === _lastRenderedRange.endRow) return; 4640 - renderGrid(true); 4641 - }); 4642 - }); 4643 - } 4545 + // No scroll handler — all rows are rendered upfront and the browser 4546 + // handles scrolling natively. Zero JS during scroll = zero jank. 4644 4547 4645 4548 // ======================================================== 4646 4549 // Find & Replace Bar (Sheets)