UNPKG

@tanstack/virtual-core

Version:

Headless UI for virtualizing scrollable elements in TS/JS + Frameworks

1,285 lines (1,284 loc) 45.2 kB
import { createLazyMeasurementsView } from "./lazy-measurements.js"; import { memo, notUndefined, approxEqual, debounce } from "./utils.js"; let _isIOSResult; const isIOSWebKit = () => { if (_isIOSResult !== void 0) return _isIOSResult; if (typeof navigator === "undefined") return _isIOSResult = false; if (/iP(hone|od|ad)/.test(navigator.userAgent)) return _isIOSResult = true; const mtp = navigator.maxTouchPoints; return _isIOSResult = navigator.platform === "MacIntel" && mtp !== void 0 && mtp > 0; }; const _resetIOSDetectionForTests = () => { _isIOSResult = void 0; }; const getRect = (element) => { const { offsetWidth, offsetHeight } = element; return { width: offsetWidth, height: offsetHeight }; }; const defaultKeyExtractor = (index) => index; const defaultRangeExtractor = (range) => { const start = Math.max(range.startIndex - range.overscan, 0); const end = Math.min(range.endIndex + range.overscan, range.count - 1); const len = end - start + 1; const arr = new Array(len); for (let i = 0; i < len; i++) { arr[i] = start + i; } return arr; }; const observeElementRect = (instance, cb) => { const element = instance.scrollElement; if (!element) { return; } const targetWindow = instance.targetWindow; if (!targetWindow) { return; } const handler = (rect) => { const { width, height } = rect; cb({ width: Math.round(width), height: Math.round(height) }); }; handler(getRect(element)); if (!targetWindow.ResizeObserver) { return () => { }; } const observer = new targetWindow.ResizeObserver((entries) => { const run = () => { const entry = entries[0]; if (entry == null ? void 0 : entry.borderBoxSize) { const box = entry.borderBoxSize[0]; if (box) { handler({ width: box.inlineSize, height: box.blockSize }); return; } } handler(getRect(element)); }; instance.options.useAnimationFrameWithResizeObserver ? requestAnimationFrame(run) : run(); }); observer.observe(element, { box: "border-box" }); return () => { observer.unobserve(element); }; }; const addEventListenerOptions = { passive: true }; const observeWindowRect = (instance, cb) => { const element = instance.scrollElement; if (!element) { return; } const handler = () => { cb({ width: element.innerWidth, height: element.innerHeight }); }; handler(); element.addEventListener("resize", handler, addEventListenerOptions); return () => { element.removeEventListener("resize", handler); }; }; const supportsScrollend = typeof window == "undefined" ? true : "onscrollend" in window; const observeOffset = (instance, cb, readOffset) => { const element = instance.scrollElement; if (!element) { return; } const targetWindow = instance.targetWindow; if (!targetWindow) { return; } const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend; let offset = 0; const fallback = registerScrollendEvent ? null : debounce( targetWindow, () => cb(offset, false), instance.options.isScrollingResetDelay ); const createHandler = (isScrolling) => () => { offset = readOffset(element); fallback == null ? void 0 : fallback(); cb(offset, isScrolling); }; const handler = createHandler(true); const endHandler = createHandler(false); element.addEventListener("scroll", handler, addEventListenerOptions); if (registerScrollendEvent) { element.addEventListener("scrollend", endHandler, addEventListenerOptions); } return () => { element.removeEventListener("scroll", handler); if (registerScrollendEvent) { element.removeEventListener("scrollend", endHandler); } }; }; const observeElementOffset = (instance, cb) => observeOffset(instance, cb, (el) => { const { horizontal, isRtl } = instance.options; return horizontal ? el.scrollLeft * (isRtl && -1 || 1) : el.scrollTop; }); const observeWindowOffset = (instance, cb) => observeOffset( instance, cb, (win) => instance.options.horizontal ? win.scrollX : win.scrollY ); const measureElement = (element, entry, instance) => { if (instance.options.useCachedMeasurements) { const index = instance.indexFromElement(element); const key = instance.options.getItemKey(index); return instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index); } if (entry == null ? void 0 : entry.borderBoxSize) { const box = entry.borderBoxSize[0]; if (box) { const size = Math.round( box[instance.options.horizontal ? "inlineSize" : "blockSize"] ); return size; } } if (!entry) { const index = instance.indexFromElement(element); const key = instance.options.getItemKey(index); const cachedSize = instance.itemSizeCache.get(key); if (cachedSize !== void 0) { return cachedSize; } } return element[instance.options.horizontal ? "offsetWidth" : "offsetHeight"]; }; const scrollWithAdjustments = (offset, { adjustments = 0, behavior }, instance) => { var _a, _b; (_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, { [instance.options.horizontal ? "left" : "top"]: offset + adjustments, behavior }); }; const windowScroll = scrollWithAdjustments; const elementScroll = scrollWithAdjustments; class Virtualizer { constructor(opts) { this.unsubs = []; this.scrollElement = null; this.targetWindow = null; this.isScrolling = false; this.scrollState = null; this.measurementsCache = []; this._flatMeasurements = null; this.itemSizeCache = /* @__PURE__ */ new Map(); this.itemSizeCacheVersion = 0; this.laneAssignments = /* @__PURE__ */ new Map(); this.pendingMin = null; this.prevLanes = void 0; this.lanesChangedFlag = false; this.lanesSettling = false; this.pendingScrollAnchor = null; this.scrollRect = null; this.scrollOffset = null; this.scrollDirection = null; this.scrollAdjustments = 0; this._iosDeferredAdjustment = 0; this._iosTouching = false; this._iosJustTouchEnded = false; this._iosTouchEndTimerId = null; this._intendedScrollOffset = null; this.elementsCache = /* @__PURE__ */ new Map(); this.now = () => { var _a, _b, _c; return ((_c = (_b = (_a = this.targetWindow) == null ? void 0 : _a.performance) == null ? void 0 : _b.now) == null ? void 0 : _c.call(_b)) ?? Date.now(); }; this.observer = /* @__PURE__ */ (() => { let _ro = null; const get = () => { if (_ro) { return _ro; } if (!this.targetWindow || !this.targetWindow.ResizeObserver) { return null; } return _ro = new this.targetWindow.ResizeObserver((entries) => { entries.forEach((entry) => { const run = () => { const node = entry.target; const index = this.indexFromElement(node); if (!node.isConnected) { this.observer.unobserve(node); for (const [cacheKey, cachedNode] of this.elementsCache) { if (cachedNode === node) { this.elementsCache.delete(cacheKey); break; } } return; } if (this.shouldMeasureDuringScroll(index)) { this.resizeItem( index, this.options.measureElement(node, entry, this) ); } }; this.options.useAnimationFrameWithResizeObserver ? requestAnimationFrame(run) : run(); }); }); }; return { disconnect: () => { var _a; (_a = get()) == null ? void 0 : _a.disconnect(); _ro = null; }, observe: (target) => { var _a; return (_a = get()) == null ? void 0 : _a.observe(target, { box: "border-box" }); }, unobserve: (target) => { var _a; return (_a = get()) == null ? void 0 : _a.unobserve(target); } }; })(); this.range = null; this.setOptions = (opts2) => { var _a, _b; const merged = { debug: false, initialOffset: 0, overscan: 1, paddingStart: 0, paddingEnd: 0, scrollPaddingStart: 0, scrollPaddingEnd: 0, horizontal: false, getItemKey: defaultKeyExtractor, rangeExtractor: defaultRangeExtractor, onChange: () => { }, measureElement, initialRect: { width: 0, height: 0 }, scrollMargin: 0, gap: 0, indexAttribute: "data-index", initialMeasurementsCache: [], lanes: 1, anchorTo: "start", followOnAppend: false, scrollEndThreshold: 1, isScrollingResetDelay: 150, enabled: true, isRtl: false, useScrollendEvent: false, useAnimationFrameWithResizeObserver: false, laneAssignmentMode: "estimate", useCachedMeasurements: false }; for (const key in opts2) { const v = opts2[key]; if (v !== void 0) merged[key] = v; } const prevOptions = this.options; let anchor = null; let followOnAppend = null; let edgeKeysChanged = false; if (prevOptions !== void 0 && prevOptions.enabled && merged.enabled && merged.anchorTo === "end" && this.scrollElement !== null) { const prevCount = prevOptions.count; const nextCount = merged.count; const measurements = this.getMeasurements(); const prevFirstKey = prevCount > 0 ? ((_a = measurements[0]) == null ? void 0 : _a.key) ?? prevOptions.getItemKey(0) : null; const prevLastKey = prevCount > 0 ? ((_b = measurements[prevCount - 1]) == null ? void 0 : _b.key) ?? prevOptions.getItemKey(prevCount - 1) : null; const didCountChange = nextCount !== prevCount; const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey); if (didEdgeKeysChange) { edgeKeysChanged = true; const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null; if (item) { anchor = [item.key, this.getScrollOffset() - item.start]; } const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null; if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) { followOnAppend = behavior; } } } this.options = merged; if (edgeKeysChanged) { this.pendingMin = 0; this.itemSizeCacheVersion++; } let anchorResolved = false; let anchorDelta = 0; if (anchor && this.scrollOffset !== null) { const [anchorKey, anchorOffset] = anchor; const newMeasurements = this.getMeasurements(); const { count, getItemKey } = this.options; let idx = 0; while (idx < count && getItemKey(idx) !== anchorKey) { idx++; } if (idx < count) { const anchorItem = newMeasurements[idx]; if (anchorItem) { const newOffset = Math.max(0, anchorItem.start + anchorOffset); if (newOffset !== this.scrollOffset) { anchorDelta = newOffset - this.scrollOffset; this.scrollOffset = newOffset; anchorResolved = true; } } } } if (anchorResolved || followOnAppend) { this.pendingScrollAnchor = [ anchorResolved ? anchor[0] : null, anchorResolved ? anchor[1] : 0, followOnAppend, anchorDelta ]; } }; this.notify = (sync) => { var _a, _b; (_b = (_a = this.options).onChange) == null ? void 0 : _b.call(_a, this, sync); }; this.maybeNotify = memo( () => { this.calculateRange(); return [ this.isScrolling, this.range ? this.range.startIndex : null, this.range ? this.range.endIndex : null ]; }, (isScrolling) => { this.notify(isScrolling); }, { key: process.env.NODE_ENV !== "production" && "maybeNotify", debug: () => this.options.debug, initialDeps: [ this.isScrolling, this.range ? this.range.startIndex : null, this.range ? this.range.endIndex : null ] } ); this.cleanup = () => { this.unsubs.filter(Boolean).forEach((d) => d()); this.unsubs = []; this.observer.disconnect(); if (this.rafId != null && this.targetWindow) { this.targetWindow.cancelAnimationFrame(this.rafId); this.rafId = null; } this.scrollState = null; this._iosDeferredAdjustment = 0; this._iosTouching = false; this._iosJustTouchEnded = false; this.scrollElement = null; this.targetWindow = null; }; this._didMount = () => { return () => { this.cleanup(); }; }; this._willUpdate = () => { var _a; const scrollElement = this.options.enabled ? this.options.getScrollElement() : null; if (this.scrollElement !== scrollElement) { this.cleanup(); if (!scrollElement) { this.maybeNotify(); return; } this.scrollElement = scrollElement; if (this.scrollElement && "ownerDocument" in this.scrollElement) { this.targetWindow = this.scrollElement.ownerDocument.defaultView; } else { this.targetWindow = ((_a = this.scrollElement) == null ? void 0 : _a.window) ?? null; } this.elementsCache.forEach((cached) => { this.observer.observe(cached); }); this.unsubs.push( this.options.observeElementRect(this, (rect) => { this.scrollRect = rect; this.maybeNotify(); }) ); this.unsubs.push( this.options.observeElementOffset(this, (offset, isScrolling) => { if (isScrolling && this._intendedScrollOffset === null && offset === this.scrollOffset) { return; } if (this._intendedScrollOffset !== null && Math.abs(offset - this._intendedScrollOffset) < 1.5) { offset = this._intendedScrollOffset; } this._intendedScrollOffset = null; this.scrollAdjustments = 0; const prevOffset = this.getScrollOffset(); this.scrollDirection = isScrolling ? prevOffset === offset ? this.scrollDirection : prevOffset < offset ? "forward" : "backward" : null; this.scrollOffset = offset; this.isScrolling = isScrolling; this._flushIosDeferredIfReady(); if (this.scrollState) { this.scheduleScrollReconcile(); } this.maybeNotify(); }) ); if ("addEventListener" in this.scrollElement) { const scrollEl = this.scrollElement; const onTouchStart = () => { this._iosTouching = true; this._iosJustTouchEnded = false; if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { this.targetWindow.clearTimeout(this._iosTouchEndTimerId); this._iosTouchEndTimerId = null; } }; const onTouchEnd = () => { this._iosTouching = false; if (!isIOSWebKit() || this.targetWindow == null) { return; } this._iosJustTouchEnded = true; this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => { this._iosJustTouchEnded = false; this._iosTouchEndTimerId = null; this._flushIosDeferredIfReady(); }, 150); }; scrollEl.addEventListener( "touchstart", onTouchStart, addEventListenerOptions ); scrollEl.addEventListener( "touchend", onTouchEnd, addEventListenerOptions ); this.unsubs.push(() => { scrollEl.removeEventListener("touchstart", onTouchStart); scrollEl.removeEventListener("touchend", onTouchEnd); if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { this.targetWindow.clearTimeout(this._iosTouchEndTimerId); this._iosTouchEndTimerId = null; } }); } this._scrollToOffset(this.getScrollOffset(), { adjustments: void 0, behavior: void 0 }); } const anchor = this.pendingScrollAnchor; this.pendingScrollAnchor = null; if (anchor && this.scrollElement && this.options.enabled) { const [key, _offset, followOnAppend, anchorDelta] = anchor; if (key !== null && !followOnAppend) { if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) { if (anchorDelta !== 0) { this._iosDeferredAdjustment += anchorDelta; } } else { this._scrollToOffset(this.getScrollOffset(), { adjustments: void 0, behavior: void 0 }); } } if (followOnAppend) { this.scrollToEnd({ behavior: followOnAppend }); } } }; this._flushIosDeferredIfReady = () => { if (this._iosDeferredAdjustment === 0) return; if (this.isScrolling) return; if (this._iosTouching) return; if (this._iosJustTouchEnded) return; const cur = this.getScrollOffset(); const max = this.getMaxScrollOffset(); if (cur < 0 || cur > max) return; if (this._iosDeferredAdjustment < 0 && cur >= max - 1) { this._iosDeferredAdjustment = 0; return; } const delta = this._iosDeferredAdjustment; this._iosDeferredAdjustment = 0; this._scrollToOffset(cur, { adjustments: this.scrollAdjustments += delta, behavior: void 0 }); }; this.rafId = null; this.getSize = () => { if (!this.options.enabled) { this.scrollRect = null; return 0; } this.scrollRect = this.scrollRect ?? this.options.initialRect; return this.scrollRect[this.options.horizontal ? "width" : "height"]; }; this.getScrollOffset = () => { if (!this.options.enabled) { this.scrollOffset = null; return 0; } this.scrollOffset = this.scrollOffset ?? (typeof this.options.initialOffset === "function" ? this.options.initialOffset() : this.options.initialOffset); return this.scrollOffset; }; this.getMeasurementOptions = memo( () => [ this.options.count, this.options.paddingStart, this.options.scrollMargin, this.options.getItemKey, this.options.enabled, this.options.lanes, this.options.laneAssignmentMode, this.options.gap ], (count, paddingStart, scrollMargin, getItemKey, enabled, lanes, laneAssignmentMode, gap) => { const lanesChanged = this.prevLanes !== void 0 && this.prevLanes !== lanes; if (lanesChanged) { this.lanesChangedFlag = true; } this.prevLanes = lanes; this.pendingMin = null; return { count, paddingStart, scrollMargin, getItemKey, enabled, lanes, laneAssignmentMode, gap }; }, { key: false } ); this.getMeasurements = memo( () => [this.getMeasurementOptions(), this.itemSizeCacheVersion], ({ count, paddingStart, scrollMargin, getItemKey, enabled, lanes, laneAssignmentMode, gap }, _itemSizeCacheVersion) => { const itemSizeCache = this.itemSizeCache; if (!enabled) { this.measurementsCache = []; this.itemSizeCache.clear(); this.laneAssignments.clear(); return []; } if (this.laneAssignments.size > count) { for (const index of this.laneAssignments.keys()) { if (index >= count) { this.laneAssignments.delete(index); } } } if (this.lanesChangedFlag) { this.lanesChangedFlag = false; this.lanesSettling = true; this.measurementsCache = []; this.itemSizeCache.clear(); this.laneAssignments.clear(); this.pendingMin = null; } if (this.measurementsCache.length === 0 && !this.lanesSettling) { this.measurementsCache = this.options.initialMeasurementsCache; this.measurementsCache.forEach((item) => { this.itemSizeCache.set(item.key, item.size); }); } const min = this.lanesSettling ? 0 : this.pendingMin ?? 0; this.pendingMin = null; if (this.lanesSettling && this.measurementsCache.length === count) { this.lanesSettling = false; } if (lanes === 1) { const need = count * 2; let flat = this._flatMeasurements; if (!flat || flat.length < need) { const next = new Float64Array(need); if (flat && min > 0) next.set(flat.subarray(0, min * 2)); flat = next; this._flatMeasurements = flat; } let runningStart; if (min === 0) { runningStart = paddingStart + scrollMargin; } else { const prevIdx = min - 1; runningStart = flat[prevIdx * 2] + flat[prevIdx * 2 + 1] + gap; } for (let i = min; i < count; i++) { const key = getItemKey(i); const measuredSize = itemSizeCache.get(key); const size = typeof measuredSize === "number" ? measuredSize : this.options.estimateSize(i); flat[i * 2] = runningStart; flat[i * 2 + 1] = size; runningStart += size + gap; } const view = createLazyMeasurementsView(count, flat, getItemKey); this.measurementsCache = view; return view; } const measurements = this.measurementsCache.slice(0, min); const laneLastIndex = new Array(lanes).fill( void 0 ); const laneEnds = new Float64Array(lanes); let filledLanes = 0; for (let m = 0; m < min; m++) { const item = measurements[m]; if (item) { if (laneLastIndex[item.lane] === void 0) filledLanes++; laneLastIndex[item.lane] = m; laneEnds[item.lane] = item.end; } } for (let i = min; i < count; i++) { const key = getItemKey(i); const cachedLane = this.laneAssignments.get(i); let lane; let start; const shouldCacheLane = laneAssignmentMode === "estimate" || itemSizeCache.has(key); if (cachedLane !== void 0 && this.options.lanes > 1) { lane = cachedLane; const prevIndex = laneLastIndex[lane]; const prevInLane = prevIndex !== void 0 ? measurements[prevIndex] : void 0; start = prevInLane ? prevInLane.end + gap : paddingStart + scrollMargin; } else if (filledLanes === lanes) { let bestLane = 0; let bestEnd = laneEnds[0]; let bestIdx = laneLastIndex[0]; for (let l = 1; l < lanes; l++) { const e = laneEnds[l]; if (e < bestEnd || e === bestEnd && laneLastIndex[l] < bestIdx) { bestLane = l; bestEnd = e; bestIdx = laneLastIndex[l]; } } lane = bestLane; start = bestEnd + gap; if (shouldCacheLane) { this.laneAssignments.set(i, lane); } } else { lane = i % this.options.lanes; start = paddingStart + scrollMargin; if (shouldCacheLane) { this.laneAssignments.set(i, lane); } } const measuredSize = itemSizeCache.get(key); const size = typeof measuredSize === "number" ? measuredSize : this.options.estimateSize(i); const end = start + size; measurements[i] = { index: i, start, size, end, key, lane }; if (laneLastIndex[lane] === void 0) filledLanes++; laneLastIndex[lane] = i; laneEnds[lane] = end; } this.measurementsCache = measurements; return measurements; }, { key: process.env.NODE_ENV !== "production" && "getMeasurements", debug: () => this.options.debug } ); this.calculateRange = memo( () => [ this.getMeasurements(), this.getSize(), this.getScrollOffset(), this.options.lanes ], (measurements, outerSize, scrollOffset, lanes) => { if (measurements.length === 0 || outerSize === 0) { this.range = null; return null; } this.range = calculateRangeImpl( measurements, outerSize, scrollOffset, lanes, // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. lanes === 1 && this._flatMeasurements != null ? this._flatMeasurements : null ); return this.range; }, { key: process.env.NODE_ENV !== "production" && "calculateRange", debug: () => this.options.debug } ); this.getVirtualIndexes = memo( () => { let startIndex = null; let endIndex = null; const range = this.calculateRange(); if (range) { startIndex = range.startIndex; endIndex = range.endIndex; } this.maybeNotify.updateDeps([this.isScrolling, startIndex, endIndex]); return [ this.options.rangeExtractor, this.options.overscan, this.options.count, startIndex, endIndex ]; }, (rangeExtractor, overscan, count, startIndex, endIndex) => { return startIndex === null || endIndex === null ? [] : rangeExtractor({ startIndex, endIndex, overscan, count }); }, { key: process.env.NODE_ENV !== "production" && "getVirtualIndexes", debug: () => this.options.debug } ); this.indexFromElement = (node) => { const attributeName = this.options.indexAttribute; const indexStr = node.getAttribute(attributeName); if (!indexStr) { console.warn( `Missing attribute name '${attributeName}={index}' on measured element.` ); return -1; } return parseInt(indexStr, 10); }; this.shouldMeasureDuringScroll = (index) => { var _a; if (!this.scrollState || this.scrollState.behavior !== "smooth") { return true; } const scrollIndex = this.scrollState.index ?? ((_a = this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)) == null ? void 0 : _a.index); if (scrollIndex !== void 0 && this.range) { const bufferSize = Math.max( this.options.overscan, Math.ceil((this.range.endIndex - this.range.startIndex) / 2) ); const minIndex = Math.max(0, scrollIndex - bufferSize); const maxIndex = Math.min( this.options.count - 1, scrollIndex + bufferSize ); return index >= minIndex && index <= maxIndex; } return true; }; this.measureElement = (node) => { if (!node) { this.elementsCache.forEach((cached, key2) => { if (!cached.isConnected) { this.observer.unobserve(cached); this.elementsCache.delete(key2); } }); return; } const index = this.indexFromElement(node); const key = this.options.getItemKey(index); const prevNode = this.elementsCache.get(key); if (prevNode !== node) { if (prevNode) { this.observer.unobserve(prevNode); } this.observer.observe(node); this.elementsCache.set(key, node); } if ((!this.isScrolling || this.scrollState) && this.shouldMeasureDuringScroll(index)) { this.resizeItem(index, this.options.measureElement(node, void 0, this)); } }; this.resizeItem = (index, size) => { var _a, _b; if (index < 0 || index >= this.options.count) return; let cachedSize; let itemStart; let key; const flat = this._flatMeasurements; if (this.options.lanes === 1 && flat !== null) { key = this.options.getItemKey(index); itemStart = flat[index * 2]; cachedSize = flat[index * 2 + 1]; } else { const item = this.measurementsCache[index]; if (!item) return; key = item.key; itemStart = item.start; cachedSize = item.size; } const itemSize = this.itemSizeCache.get(key) ?? cachedSize; const delta = size - itemSize; if (delta !== 0) { const wasAtEnd = this.options.anchorTo === "end" && ((_a = this.scrollState) == null ? void 0 : _a.behavior) !== "smooth" && this.getVirtualDistanceFromEnd() <= this.options.scrollEndThreshold; const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0; const scrollOffsetWithAdj = this.getScrollOffset() + this.scrollAdjustments; const isFirstMeasure = !this.itemSizeCache.has(key); const defaultShouldAdjust = isFirstMeasure ? ( // First measurement: compensate any item whose top sits above the // fold — the estimate→actual delta must be corrected regardless of // scroll direction, since the whole estimated block was above it. itemStart < scrollOffsetWithAdj ) : ( // Re-measurement: only compensate an item that is ENTIRELY above the // fold. An item that merely *spans* the fold (top above, bottom // below — e.g. a streaming chat message growing at its bottom) // changes size *below* the anchor point, so shifting scrollTop by the // delta would drag the viewport downward on every growth (#1218). // Also skip during backward scroll to avoid the "items jump while // scrolling up" cascade. itemStart + itemSize <= scrollOffsetWithAdj && this.scrollDirection !== "backward" ); const shouldAdjustScroll = ((_b = this.scrollState) == null ? void 0 : _b.behavior) !== "smooth" && (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? this.shouldAdjustScrollPositionOnItemSizeChange( // The callback expects a VirtualItem; build one lazily only // when the consumer actually supplied a custom predicate. this.measurementsCache[index] ?? { index, key, start: itemStart, size: cachedSize, end: itemStart + cachedSize, lane: 0 }, delta, this ) : defaultShouldAdjust); if (this.pendingMin === null || index < this.pendingMin) { this.pendingMin = index; } this.itemSizeCache.set(key, size); this.itemSizeCacheVersion++; if (wasAtEnd) { this.applyScrollAdjustment(this.getTotalSize() - prevTotalSize); } else if (shouldAdjustScroll) { this.applyScrollAdjustment(delta); } this.notify(false); } }; this.getVirtualItems = memo( () => [this.getVirtualIndexes(), this.getMeasurements()], (indexes, measurements) => { const virtualItems = []; for (let k = 0, len = indexes.length; k < len; k++) { const i = indexes[k]; const measurement = measurements[i]; virtualItems.push(measurement); } return virtualItems; }, { key: process.env.NODE_ENV !== "production" && "getVirtualItems", debug: () => this.options.debug } ); this.getVirtualItemForOffset = (offset) => { const measurements = this.getMeasurements(); if (measurements.length === 0) { return void 0; } const flat = this._flatMeasurements; const useFlat = this.options.lanes === 1 && flat != null; const idx = findNearestBinarySearch( 0, measurements.length - 1, useFlat ? (i) => flat[i * 2] : (i) => notUndefined(measurements[i]).start, offset ); return notUndefined(measurements[idx]); }; this.getMaxScrollOffset = () => { if (!this.scrollElement) return 0; if ("scrollHeight" in this.scrollElement) { return this.options.horizontal ? this.scrollElement.scrollWidth - this.scrollElement.clientWidth : this.scrollElement.scrollHeight - this.scrollElement.clientHeight; } else { const doc = this.scrollElement.document.documentElement; return this.options.horizontal ? doc.scrollWidth - this.scrollElement.innerWidth : doc.scrollHeight - this.scrollElement.innerHeight; } }; this.getVirtualDistanceFromEnd = () => { return Math.max( this.getTotalSize() - this.getSize() - this.getScrollOffset(), 0 ); }; this.getDistanceFromEnd = () => { return Math.max(this.getMaxScrollOffset() - this.getScrollOffset(), 0); }; this.isAtEnd = (threshold = this.options.scrollEndThreshold) => { return this.getDistanceFromEnd() <= threshold; }; this.getOffsetForAlignment = (toOffset, align, itemSize = 0) => { if (!this.scrollElement) return 0; const size = this.getSize(); const scrollOffset = this.getScrollOffset(); if (align === "auto") { align = toOffset >= scrollOffset + size ? "end" : "start"; } if (align === "center") { toOffset += (itemSize - size) / 2; } else if (align === "end") { toOffset -= size; } const maxOffset = this.getMaxScrollOffset(); return Math.max(Math.min(maxOffset, toOffset), 0); }; this.getOffsetForIndex = (index, align = "auto") => { index = Math.max(0, Math.min(index, this.options.count - 1)); const size = this.getSize(); const scrollOffset = this.getScrollOffset(); const item = this.measurementsCache[index]; if (!item) return; if (align === "auto") { if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) { align = "end"; } else if (item.start <= scrollOffset + this.options.scrollPaddingStart) { align = "start"; } else { return [scrollOffset, align]; } } if (align === "end" && index === this.options.count - 1) { return [this.getMaxScrollOffset(), align]; } const toOffset = align === "end" ? item.end + this.options.scrollPaddingEnd : item.start - this.options.scrollPaddingStart; return [ this.getOffsetForAlignment(toOffset, align, item.size), align ]; }; this.scrollToOffset = (toOffset, { align = "start", behavior = "auto" } = {}) => { this._iosDeferredAdjustment = 0; const offset = this.getOffsetForAlignment(toOffset, align); const now = this.now(); this.scrollState = { index: null, align, behavior, startedAt: now, lastTargetOffset: offset, stableFrames: 0 }; this._scrollToOffset(offset, { adjustments: void 0, behavior }); this.scheduleScrollReconcile(); }; this.scrollToIndex = (index, { align: initialAlign = "auto", behavior = "auto" } = {}) => { this._iosDeferredAdjustment = 0; index = Math.max(0, Math.min(index, this.options.count - 1)); const offsetInfo = this.getOffsetForIndex(index, initialAlign); if (!offsetInfo) { return; } const [offset, align] = offsetInfo; const now = this.now(); this.scrollState = { index, align, behavior, startedAt: now, lastTargetOffset: offset, stableFrames: 0 }; this._scrollToOffset(offset, { adjustments: void 0, behavior }); this.scheduleScrollReconcile(); }; this.scrollBy = (delta, { behavior = "auto" } = {}) => { const offset = this.getScrollOffset() + delta; const now = this.now(); this.scrollState = { index: null, align: "start", behavior, startedAt: now, lastTargetOffset: offset, stableFrames: 0 }; this._scrollToOffset(offset, { adjustments: void 0, behavior }); this.scheduleScrollReconcile(); }; this.scrollToEnd = ({ behavior = "auto" } = {}) => { if (this.options.count > 0) { this.scrollToIndex(this.options.count - 1, { align: "end", behavior }); return; } this.scrollToOffset(Math.max(this.getTotalSize() - this.getSize(), 0), { behavior }); }; this.getTotalSize = () => { var _a; const measurements = this.getMeasurements(); let end; if (measurements.length === 0) { end = this.options.paddingStart; } else if (this.options.lanes === 1) { const lastIdx = measurements.length - 1; const flat = this._flatMeasurements; if (flat != null) { end = flat[lastIdx * 2] + flat[lastIdx * 2 + 1]; } else { end = ((_a = measurements[lastIdx]) == null ? void 0 : _a.end) ?? 0; } } else { const endByLane = Array(this.options.lanes).fill(null); let endIndex = measurements.length - 1; while (endIndex >= 0 && endByLane.some((val) => val === null)) { const item = measurements[endIndex]; if (endByLane[item.lane] === null) { endByLane[item.lane] = item.end; } endIndex--; } end = Math.max(...endByLane.filter((val) => val !== null)); } return Math.max( end - this.options.scrollMargin + this.options.paddingEnd, 0 ); }; this.takeSnapshot = () => { const snapshot = []; if (this.itemSizeCache.size === 0) return snapshot; const m = this.getMeasurements(); for (const item of m) { if (item && this.itemSizeCache.has(item.key)) { snapshot.push({ index: item.index, key: item.key, start: item.start, size: item.size, end: item.end, lane: item.lane }); } } return snapshot; }; this._scrollToOffset = (offset, { adjustments, behavior }) => { this._intendedScrollOffset = offset + (adjustments ?? 0); this.options.scrollToFn(offset, { behavior, adjustments }, this); }; this.measure = () => { this.pendingMin = null; this.itemSizeCache.clear(); this.laneAssignments.clear(); this.itemSizeCacheVersion++; this.notify(false); }; this.setOptions(opts); } applyScrollAdjustment(delta, behavior) { if (delta === 0) return; if (process.env.NODE_ENV !== "production" && this.options.debug) { console.info("correction", delta); } if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) { this._iosDeferredAdjustment += delta; } else { this._scrollToOffset(this.getScrollOffset(), { adjustments: this.scrollAdjustments += delta, behavior }); if (this.scrollOffset !== null) { this.scrollOffset += this.scrollAdjustments; if (this.scrollOffset < 0) this.scrollOffset = 0; this.scrollAdjustments = 0; } } } scheduleScrollReconcile() { if (!this.targetWindow) { this.scrollState = null; return; } if (this.rafId != null) return; this.rafId = this.targetWindow.requestAnimationFrame(() => { this.rafId = null; this.reconcileScroll(); }); } reconcileScroll() { if (!this.scrollState) return; const el = this.scrollElement; if (!el) return; const MAX_RECONCILE_MS = 5e3; if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) { this.scrollState = null; return; } const offsetInfo = this.scrollState.index != null ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align) : void 0; const targetOffset = offsetInfo ? offsetInfo[0] : this.scrollState.lastTargetOffset; const STABLE_FRAMES = 1; const targetChanged = targetOffset !== this.scrollState.lastTargetOffset; if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) { this.scrollState.stableFrames++; if (this.scrollState.stableFrames >= STABLE_FRAMES) { if (this.getScrollOffset() !== targetOffset) { this._scrollToOffset(targetOffset, { adjustments: void 0, behavior: "auto" }); } this.scrollState = null; return; } } else { this.scrollState.stableFrames = 0; if (targetChanged) { const viewport = this.getSize() || 600; const distance = Math.abs(targetOffset - this.getScrollOffset()); const keepSmooth = this.scrollState.behavior === "smooth" && distance > viewport; this.scrollState.lastTargetOffset = targetOffset; if (!keepSmooth) { this.scrollState.behavior = "auto"; } this._scrollToOffset(targetOffset, { adjustments: void 0, behavior: keepSmooth ? "smooth" : "auto" }); } } this.scheduleScrollReconcile(); } } const findNearestBinarySearch = (low, high, getCurrentValue, value) => { while (low <= high) { const middle = (low + high) / 2 | 0; const currentValue = getCurrentValue(middle); if (currentValue < value) { low = middle + 1; } else if (currentValue > value) { high = middle - 1; } else { return middle; } } if (low > 0) { return low - 1; } else { return 0; } }; function findNearestBinarySearchFlat(flat, high, value) { let low = 0; while (low <= high) { const middle = (low + high) / 2 | 0; const currentValue = flat[middle * 2]; if (currentValue < value) { low = middle + 1; } else if (currentValue > value) { high = middle - 1; } else { return middle; } } return low > 0 ? low - 1 : 0; } function calculateRangeImpl(measurements, outerSize, scrollOffset, lanes, flat) { const lastIndex = measurements.length - 1; if (measurements.length <= lanes) { return { startIndex: 0, endIndex: lastIndex }; } if (lanes === 1 && flat !== null) { const startIndex2 = findNearestBinarySearchFlat( flat, lastIndex, scrollOffset ); let endIndex2 = startIndex2; const limit = scrollOffset + outerSize; while (endIndex2 < lastIndex && flat[endIndex2 * 2] + flat[endIndex2 * 2 + 1] < limit) { endIndex2++; } return { startIndex: startIndex2, endIndex: endIndex2 }; } const getStart = (index) => measurements[index].start; let startIndex = findNearestBinarySearch(0, lastIndex, getStart, scrollOffset); let endIndex = startIndex; if (lanes === 1) { while (endIndex < lastIndex && measurements[endIndex].end < scrollOffset + outerSize) { endIndex++; } } else if (lanes > 1) { const endPerLane = Array(lanes).fill(0); while (endIndex < lastIndex && endPerLane.some((pos) => pos < scrollOffset + outerSize)) { const item = measurements[endIndex]; endPerLane[item.lane] = item.end; endIndex++; } const startPerLane = Array(lanes).fill(scrollOffset + outerSize); while (startIndex >= 0 && startPerLane.some((pos) => pos >= scrollOffset)) { const item = measurements[startIndex]; startPerLane[item.lane] = item.start; startIndex--; } startIndex = Math.max(0, startIndex - startIndex % lanes); endIndex = Math.min(lastIndex, endIndex + (lanes - 1 - endIndex % lanes)); } return { startIndex, endIndex }; } export { Virtualizer, _resetIOSDetectionForTests, approxEqual, debounce, defaultKeyExtractor, defaultRangeExtractor, elementScroll, measureElement, memo, notUndefined, observeElementOffset, observeElementRect, observeWindowOffset, observeWindowRect, windowScroll }; //# sourceMappingURL=index.js.map