/** * 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 `` of the tbody with the marker class so CSS picks them * up. Idempotent — re-running after `setData` / `insertRow` is cheap because * `add` on classList is a no-op for rows that already have the class. */ const tagRows = (state: RuntimeState): void => { const tbody = state.tbody; if (tbody == null) { return; } const rows = tbody.children; for (let i = 0; i < rows.length; i++) { const r = rows[i]; if (r instanceof HTMLTableRowElement) { r.classList.add(ROW_CLASS); } } // Refresh row-height measurement off the first concrete row so the CSS // `contain-intrinsic-size` matches reality. const firstRow = rows[0] as HTMLTableRowElement | undefined; if (firstRow != null) { const rect = firstRow.getBoundingClientRect(); if (rect.height > 0 && Math.abs(rect.height - state.rowHeight) > 0.5) { state.rowHeight = rect.height; ensureStyle(state.rowHeight); } } }; /** * Plugin factory. Each call returns a fresh plugin so multiple sheets on the * same page get isolated state. */ export const createVirtualizationPlugin = (options: VirtualizationOptions): any => { const state = createState(); // `windowSize` is an API compat hint — content-visibility decides the actual // near-viewport buffer. We still record it for diagnostics / future use. void options.windowSize; ensureStyle(DEFAULT_ROW_HEIGHT_PX); const teardown = (): void => { state.mutationObserver?.disconnect(); state.mutationObserver = null; state.scrollHost?.classList.remove('jss-virt-scroll-host'); state.scrollHost = null; }; return { // Required: the host queries this when serialising state. We carry no config. getConfig: () => ({}), init: (instance: any) => { const tbodyEl: HTMLTableSectionElement | null = instance?.tbody ?? null; if (tbodyEl == null) { return; } state.tbody = tbodyEl; tagRows(state); // Tag the scroll container so the host-level CSS (will-change, // overflow-anchor, overscroll-behavior) applies. Falls back to // `instance.content` if no scrolling ancestor is found — even though // `instance.content` itself doesn't scroll, the class is harmless there. const fallback: HTMLElement | null = instance?.content ?? instance?.element?.querySelector('.jss_content') ?? null; const scrollHost = findScrollableAncestor(tbodyEl) ?? fallback; if (scrollHost != null) { scrollHost.classList.add('jss-virt-scroll-host'); state.scrollHost = scrollHost; } // jspreadsheet rebuilds rows on setData / insertRow / sort — re-tag when // that happens so the marker class is present on freshly-created rows. if (typeof MutationObserver !== 'undefined') { state.mutationObserver = new MutationObserver(() => { tagRows(state); }); state.mutationObserver.observe(tbodyEl, { childList: true }); } }, // Re-tag after data-mutating events so newly added rows pick up the class. // Cheap because `tagRows` is a class-set add (idempotent). onevent: (event: string) => { if (event === 'onload' || event === 'oninsertrow' || event === 'ondeleterow') { tagRows(state); } }, // Custom destroy hook — jspreadsheet plugins don't have one declared, but // the host calls `destroy` on plugins via `destroyAll()`. Defining it makes // listener cleanup deterministic in tests / hot reload. destroy: teardown, }; };