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): polish virtual scroll — tbody-only updates, larger buffer' (#79) from fix/scroll-polish into main

scott 58e44c2c 4f6663e8

+79 -57
+79 -57
src/sheets/main.ts
··· 365 365 }); 366 366 } 367 367 let _isRendering = false; 368 + let _gridEventsAttached = false; 368 369 369 - function renderGrid() { 370 + function renderGrid(scrollOnly = false) { 370 371 _isRendering = true; 371 372 const sheet = getActiveSheet(); 372 373 const rowCount = sheet.get('rowCount') || DEFAULT_ROWS; ··· 383 384 const { cols: visibleColList, indicators: colIndicators } = computeVisibleCols(1, colCount, hiddenColSet); 384 385 const visibleColSet = new Set(visibleColList); 385 386 386 - // Build colgroup for column widths (hidden cols get 0 width) 387 - let html = '<colgroup>'; 388 - html += '<col style="width:' + ROW_HEADER_WIDTH + 'px;min-width:' + ROW_HEADER_WIDTH + 'px">'; 389 - for (let c = 1; c <= colCount; c++) { 390 - if (!visibleColSet.has(c)) { 391 - html += '<col style="width:0;min-width:0;max-width:0;overflow:hidden;visibility:collapse">'; 392 - } else { 393 - const w = getColWidth(c); 394 - html += '<col style="width:' + w + 'px;min-width:' + MIN_COL_WIDTH + 'px">'; 395 - } 396 - } 397 - html += '</colgroup>'; 398 - 399 387 // Compute cumulative left offsets for frozen columns (only visible frozen cols) 400 388 const frozenLeftOffsets = [0]; 401 389 let cumLeft = ROW_HEADER_WIDTH; ··· 417 405 cumTop += rh(r); 418 406 } 419 407 420 - // --- Header row --- 421 - html += '<thead><tr>'; 422 - const cornerCls = ['corner']; 423 - if (freezeR > 0) cornerCls.push('freeze-border-bottom'); 424 - if (freezeC > 0) cornerCls.push('freeze-border-right'); 425 - html += '<th class="' + cornerCls.join(' ') + '"></th>'; 408 + // --- Build colgroup + thead (only on full renders) --- 409 + let headHtml = ''; 410 + if (!scrollOnly) { 411 + headHtml += '<colgroup>'; 412 + headHtml += '<col style="width:' + ROW_HEADER_WIDTH + 'px;min-width:' + ROW_HEADER_WIDTH + 'px">'; 413 + for (let c = 1; c <= colCount; c++) { 414 + if (!visibleColSet.has(c)) { 415 + headHtml += '<col style="width:0;min-width:0;max-width:0;overflow:hidden;visibility:collapse">'; 416 + } else { 417 + const w = getColWidth(c); 418 + headHtml += '<col style="width:' + w + 'px;min-width:' + MIN_COL_WIDTH + 'px">'; 419 + } 420 + } 421 + headHtml += '</colgroup>'; 426 422 427 - // Track which visible columns have hidden-column indicators before them 428 - const colIndicatorAfter = new Set(); 429 - for (const ind of colIndicators) colIndicatorAfter.add(ind.afterCol); 423 + headHtml += '<thead><tr>'; 424 + const cornerCls = ['corner']; 425 + if (freezeR > 0) cornerCls.push('freeze-border-bottom'); 426 + if (freezeC > 0) cornerCls.push('freeze-border-right'); 427 + headHtml += '<th class="' + cornerCls.join(' ') + '"></th>'; 430 428 431 - for (let c = 1; c <= colCount; c++) { 432 - if (!visibleColSet.has(c)) continue; // skip hidden columns in header 429 + const colIndicatorAfter = new Set(); 430 + for (const ind of colIndicators) colIndicatorAfter.add(ind.afterCol); 433 431 434 - const cls = []; 435 - if (c <= freezeC) { 436 - cls.push('frozen-col'); 437 - if (c === freezeC) cls.push('freeze-border-right'); 438 - } 439 - if (freezeR > 0) cls.push('freeze-border-bottom'); 440 - // Add hidden-col boundary indicator class 441 - if (isAtHiddenColBoundary(c, colCount, hiddenColSet)) cls.push('hidden-col-boundary'); 432 + for (let c = 1; c <= colCount; c++) { 433 + if (!visibleColSet.has(c)) continue; 442 434 443 - const leftStyle = c <= freezeC ? 'left:' + frozenLeftOffsets[c] + 'px;' : ''; 444 - const classAttr = cls.length ? ' class="' + cls.join(' ') + '"' : ''; 445 - html += '<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>'; 435 + const cls = []; 436 + if (c <= freezeC) { 437 + cls.push('frozen-col'); 438 + if (c === freezeC) cls.push('freeze-border-right'); 439 + } 440 + if (freezeR > 0) cls.push('freeze-border-bottom'); 441 + if (isAtHiddenColBoundary(c, colCount, hiddenColSet)) cls.push('hidden-col-boundary'); 442 + 443 + const leftStyle = c <= freezeC ? 'left:' + frozenLeftOffsets[c] + 'px;' : ''; 444 + const classAttr = cls.length ? ' class="' + cls.join(' ') + '"' : ''; 445 + 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 + } 447 + headHtml += '</tr></thead>'; 446 448 } 447 - html += '</tr></thead><tbody>'; 449 + 450 + // --- Build tbody (always rebuilt) --- 451 + let tbodyHtml = ''; 448 452 449 453 // --- Pre-compute conditional formatting and striped state --- 450 454 const cfRules = getCfRulesArray(); ··· 458 462 viewportHeight, 459 463 totalRows: rowCount, 460 464 getRowHeight: (r) => rh(r), 461 - bufferRows: 5, 465 + bufferRows: 15, 462 466 }); 463 467 464 468 // Always render frozen rows + visible range ··· 474 478 for (let sr = freezeR + 1; sr < renderStartRow; sr++) { 475 479 if (!hiddenRowSet.has(sr)) spacerHeight += rh(sr); 476 480 } 477 - html += '<tr class="virtual-spacer-top" style="height:' + spacerHeight + 'px"><td colspan="' + (colCount + 1) + '"></td></tr>'; 481 + tbodyHtml += '<tr class="virtual-spacer-top" style="height:' + spacerHeight + 'px"><td colspan="' + (colCount + 1) + '"></td></tr>'; 478 482 } 479 483 480 484 // --- Body rows (frozen rows always rendered, then visible range) --- ··· 498 502 499 503 for (const r of allRowsToRender) { 500 504 const rowH = rh(r); 501 - html += '<tr style="height:' + rowH + 'px">'; 505 + tbodyHtml += '<tr style="height:' + rowH + 'px">'; 502 506 const rhCls = ['row-header']; 503 507 if (r <= freezeR) { 504 508 rhCls.push('frozen-row'); ··· 509 513 510 514 const rhTop = r <= freezeR ? 'top:' + frozenRowTopOffsets[r] + 'px;' : ''; 511 515 const rhHeight = rowH !== bodyRowHeight ? 'height:' + rowH + 'px;' : ''; 512 - html += '<th class="' + rhCls.join(' ') + '" data-row="' + r + '" role="rowheader" aria-rowindex="' + r + '" style="' + rhTop + rhHeight + '">' + r + '<div class="row-resize-handle" data-resize-row="' + r + '"></div></th>'; 516 + tbodyHtml += '<th class="' + rhCls.join(' ') + '" data-row="' + r + '" role="rowheader" aria-rowindex="' + r + '" style="' + rhTop + rhHeight + '">' + r + '<div class="row-resize-handle" data-resize-row="' + r + '"></div></th>'; 513 517 514 518 for (let c = 1; c <= colCount; c++) { 515 519 if (!visibleColSet.has(c)) continue; // skip hidden columns ··· 572 576 if (mergeInfo.rowspan > 1) spanAttrs += ' rowspan="' + mergeInfo.rowspan + '"'; 573 577 } 574 578 const valTitleAttr = validationMsg ? ' title="' + escapeHtml(validationMsg) + '"' : ''; 575 - html += '<td data-col="' + c + '" data-row="' + r + '" data-id="' + id + '" role="gridcell" aria-colindex="' + c + '" aria-rowindex="' + r + '" class="' + tdCls.join(' ') + '"' + styleAttr + spanAttrs + valTitleAttr + '>'; 579 + tbodyHtml += '<td data-col="' + c + '" data-row="' + r + '" data-id="' + id + '" role="gridcell" aria-colindex="' + c + '" aria-rowindex="' + r + '" class="' + tdCls.join(' ') + '"' + styleAttr + spanAttrs + valTitleAttr + '>'; 576 580 577 581 // Sparkline: render canvas instead of text 578 582 if (isSparklineResult(displayValue)) { 579 - html += '<div class="cell-display" style="padding:0;overflow:hidden;' + getCellStyle(cellData, '') + '"><canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas></div>'; 583 + tbodyHtml += '<div class="cell-display" style="padding:0;overflow:hidden;' + getCellStyle(cellData, '') + '"><canvas class="sparkline-canvas" data-sparkline-id="' + id + '" style="width:100%;height:100%;display:block;"></canvas></div>'; 580 584 } else { 581 585 // Wrap text class 582 586 const wrapClass = cellData?.s?.wrap ? ' cell-wrap' : ''; ··· 585 589 const errTooltip = getFormulaErrorTooltip(String(displayValue)); 586 590 const titleAttr = errTooltip ? ' title="' + escapeHtml(errTooltip) + '"' : ''; 587 591 588 - html += '<div class="cell-display' + wrapClass + '"' + titleAttr + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 592 + tbodyHtml += '<div class="cell-display' + wrapClass + '"' + titleAttr + ' style="' + getCellStyle(cellData, cfStyleStr) + '">' + escapeHtml(displayValue) + '</div>'; 589 593 } 590 594 591 595 // Dropdown arrow for list validation 592 596 if (validation && validation.type === 'list') { 593 - html += '<div class="cell-dropdown-arrow" data-dropdown-cell="' + id + '">&#9662;</div>'; 597 + tbodyHtml += '<div class="cell-dropdown-arrow" data-dropdown-cell="' + id + '">&#9662;</div>'; 594 598 } 595 599 596 - html += '</td>'; 600 + tbodyHtml += '</td>'; 597 601 } 598 - html += '</tr>'; 602 + tbodyHtml += '</tr>'; 599 603 600 604 // Insert hidden-row indicator after this row if needed 601 605 if (rowIndicatorMap.has(r)) { 602 606 const count = rowIndicatorMap.get(r); 603 - html += '<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>'; 607 + 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 608 } 605 609 606 610 prevRenderedRow = r; ··· 615 619 for (let sr = renderEndRow + 1; sr <= rowCount; sr++) { 616 620 if (!hiddenRowSet.has(sr)) bottomSpacerHeight += rh(sr); 617 621 } 618 - html += '<tr class="virtual-spacer-bottom" style="height:' + bottomSpacerHeight + 'px"><td colspan="' + (colCount + 1) + '"></td></tr>'; 622 + tbodyHtml += '<tr class="virtual-spacer-bottom" style="height:' + bottomSpacerHeight + 'px"><td colspan="' + (colCount + 1) + '"></td></tr>'; 619 623 } 620 624 621 - html += '</tbody>'; 622 - 623 - // Preserve scroll position across innerHTML replacement to prevent jumpiness 625 + // --- Apply to DOM --- 624 626 const savedScrollTop = sheetContainer ? sheetContainer.scrollTop : 0; 625 627 const savedScrollLeft = sheetContainer ? sheetContainer.scrollLeft : 0; 626 - grid.innerHTML = html; 628 + 629 + if (scrollOnly) { 630 + // Scroll-only: replace just tbody content, keeping colgroup+thead stable 631 + const tbody = grid.querySelector('tbody'); 632 + if (tbody) { 633 + tbody.innerHTML = tbodyHtml; 634 + } else { 635 + // Fallback: no tbody yet, do full render 636 + grid.innerHTML = headHtml + '<tbody>' + tbodyHtml + '</tbody>'; 637 + } 638 + } else { 639 + // Full render: rebuild everything 640 + grid.innerHTML = headHtml + '<tbody>' + tbodyHtml + '</tbody>'; 641 + } 642 + 627 643 if (sheetContainer) { 628 644 sheetContainer.scrollTop = savedScrollTop; 629 645 sheetContainer.scrollLeft = savedScrollLeft; ··· 633 649 _lastRenderedRange = { startRow: renderStartRow, endRow: renderEndRow }; 634 650 _isRendering = false; 635 651 636 - attachGridEvents(); 652 + // Attach event listeners only once (they use event delegation on the table) 653 + if (!_gridEventsAttached) { 654 + attachGridEvents(); 655 + _gridEventsAttached = true; 656 + } 637 657 updateSelectionVisuals(); 638 - updateFreezeToolbarState(); 658 + if (!scrollOnly) { 659 + updateFreezeToolbarState(); 660 + } 639 661 renderNoteIndicators(); 640 662 renderSparklines(); 641 663 } ··· 4612 4634 viewportHeight: vp, 4613 4635 totalRows: rc, 4614 4636 getRowHeight: (r) => getRowHeight(r), 4615 - bufferRows: 5, 4637 + bufferRows: 15, 4616 4638 }); 4617 4639 if (range.startRow === _lastRenderedRange.startRow && range.endRow === _lastRenderedRange.endRow) return; 4618 - renderGrid(); 4640 + renderGrid(true); 4619 4641 }); 4620 4642 }); 4621 4643 }