/** * jspreadsheet-ce v5 plugin: row-window virtualization. * * Why this exists: jspreadsheet's built-in `lazyLoading` either does not engage * (depends on a constrained scroll container) or is insufficient for sheets with * thousands of rows × dozens of editable columns. With every row in the DOM the * sheet stalls on every interaction (the timeslot editor was the trigger). * * Strategy: lean on the browser's native `content-visibility: auto` so off-screen * rows skip layout AND paint without us having to track scroll position or * synthesise spacer rows. The plugin's only job is to tag each data row with a * marker class on init (and re-tag after data mutations); CSS does the rest. * * Pros over a hand-rolled windowed approach: * - Zero work on scroll — no rAF, no listener, no diff-set churn. Scrolling is * as smooth as native because the browser is in charge. * - Scroll bar reflects all rows (because off-screen rows still take up space * via `contain-intrinsic-size`, no spacer rows needed). * - Editing / selection / `getData()` / `instance.tbody.rows[i]` all keep * working — the rows are real DOM elements; only their *rendering* is * skipped while off-screen. * * Caveats: * - `content-visibility: auto` is Chromium 85+ / Firefox 125+ / Safari 18+. * Older browsers fall back to "render everything" — slow but correct. * - `contain-intrinsic-size` must approximate the row height, otherwise the * scroll bar over-/under-estimates total height. We measure the first row * on init and use that for all rows. * * Configurable via `windowSize` — kept as an option for API compatibility, but * with content-visibility-based virtualization the value is informational only * (the browser decides when a row is "near" the viewport). Pass `0` to disable * the plugin entirely from the consumer side. */ export interface VirtualizationOptions { /** * Hint for how many rows worth of buffer to keep ready around the viewport. * Translated into `contain-intrinsic-size` row-height × (windowSize / 4) so * the browser pre-renders rows that are about to scroll into view. Default * 300; smaller values save memory at the cost of more "blank flashes" during * fast scroll, larger values smooth the scroll at the cost of memory. */ windowSize: number; } const DEFAULT_ROW_HEIGHT_PX = 24; const ROW_CLASS = 'jss-virt-row'; /** * Lazily-injected stylesheet so the plugin works without callers wiring up CSS. * `content-visibility: auto` lets the browser skip layout and paint for rows * that are not in or near the viewport. `contain-intrinsic-size` tells it the * size to assume for skipped rows so the scroll bar height stays accurate. * * `contain: layout style paint` keeps rows independent of each other for * layout purposes — a side effect: forms inside a TR can no longer overflow * outside it, which we explicitly want here (each row is self-contained). */ const ensureStyle = (rowHeight: number): void => { if (typeof document === 'undefined') { return; } const id = 'jss-virtualization-plugin-style'; const existing = document.getElementById(id) as HTMLStyleElement | null; // `contain-intrinsic-size: auto Xpx`: Chromium ≥109 / Firefox ≥125 keeps the // last-rendered size after a row scrolls out, falling back to `Xpx` only on // first paint. Avoids the visible "snap" when scrolling back through rows // whose actual height ended up slightly different from our estimate. // // `.jss-virt-scroll-host` is set on whichever element is identified as the // scroll container at init time. The combo of `will-change: transform`, // `overflow-anchor: none`, `overscroll-behavior: contain` shifts the table // onto its own compositor layer and disables two browser features (scroll // anchoring, scroll chaining) that occasionally stutter on virtualized lists. const css = ` tr.${ROW_CLASS} { content-visibility: auto; contain-intrinsic-size: auto ${Math.max(rowHeight, 1)}px; } .jss-virt-scroll-host { will-change: transform; overflow-anchor: none; overscroll-behavior: contain; } `; if (existing != null) { if (existing.textContent !== css) { existing.textContent = css; } return; } const style = document.createElement('style'); style.id = id; style.textContent = css; document.head.appendChild(style); }; interface RuntimeState { tbody: HTMLTableSectionElement | null; scrollHost: HTMLElement | null; mutationObserver: MutationObserver | null; rowHeight: number; } const createState = (): RuntimeState => ({ tbody: null, scrollHost: null, mutationObserver: null, rowHeight: DEFAULT_ROW_HEIGHT_PX, }); /** * Walks up from `start` to the first ancestor whose computed `overflow` is * `auto` or `scroll` AND whose `clientHeight` is smaller than its scroll * content — i.e. the element that actually scrolls. Returns null when no such * ancestor exists (the page itself is the scroll container). */ const findScrollableAncestor = (start: HTMLElement | null): HTMLElement | null => { if (start == null || typeof window === 'undefined') { return null; } let node: HTMLElement | null = start.parentElement; while (node != null && node !== document.body) { const style = window.getComputedStyle(node); const overflowY = style.overflowY; if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { return node; } node = node.parentElement; } return null; }; /** * Tags every direct `