@tanstack/virtual-core
Version:
Headless UI for virtualizing scrollable elements in TS/JS + Frameworks
812 lines (811 loc) • 26.4 kB
JavaScript
import { debounce, memo, notUndefined, approxEqual } from "./utils.js";
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 arr = [];
for (let i = start; i <= end; i++) {
arr.push(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 observeElementOffset = (instance, cb) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const targetWindow = instance.targetWindow;
if (!targetWindow) {
return;
}
let offset = 0;
const fallback = instance.options.useScrollendEvent && supportsScrollend ? () => void 0 : debounce(
targetWindow,
() => {
cb(offset, false);
},
instance.options.isScrollingResetDelay
);
const createHandler = (isScrolling) => () => {
const { horizontal, isRtl } = instance.options;
offset = horizontal ? element["scrollLeft"] * (isRtl && -1 || 1) : element["scrollTop"];
fallback();
cb(offset, isScrolling);
};
const handler = createHandler(true);
const endHandler = createHandler(false);
endHandler();
element.addEventListener("scroll", handler, addEventListenerOptions);
const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend;
if (registerScrollendEvent) {
element.addEventListener("scrollend", endHandler, addEventListenerOptions);
}
return () => {
element.removeEventListener("scroll", handler);
if (registerScrollendEvent) {
element.removeEventListener("scrollend", endHandler);
}
};
};
const observeWindowOffset = (instance, cb) => {
const element = instance.scrollElement;
if (!element) {
return;
}
const targetWindow = instance.targetWindow;
if (!targetWindow) {
return;
}
let offset = 0;
const fallback = instance.options.useScrollendEvent && supportsScrollend ? () => void 0 : debounce(
targetWindow,
() => {
cb(offset, false);
},
instance.options.isScrollingResetDelay
);
const createHandler = (isScrolling) => () => {
offset = element[instance.options.horizontal ? "scrollX" : "scrollY"];
fallback();
cb(offset, isScrolling);
};
const handler = createHandler(true);
const endHandler = createHandler(false);
endHandler();
element.addEventListener("scroll", handler, addEventListenerOptions);
const registerScrollendEvent = instance.options.useScrollendEvent && supportsScrollend;
if (registerScrollendEvent) {
element.addEventListener("scrollend", endHandler, addEventListenerOptions);
}
return () => {
element.removeEventListener("scroll", handler);
if (registerScrollendEvent) {
element.removeEventListener("scrollend", endHandler);
}
};
};
const measureElement = (element, entry, instance) => {
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;
}
}
return element[instance.options.horizontal ? "offsetWidth" : "offsetHeight"];
};
const windowScroll = (offset, {
adjustments = 0,
behavior
}, instance) => {
var _a, _b;
const toOffset = offset + adjustments;
(_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, {
[instance.options.horizontal ? "left" : "top"]: toOffset,
behavior
});
};
const elementScroll = (offset, {
adjustments = 0,
behavior
}, instance) => {
var _a, _b;
const toOffset = offset + adjustments;
(_b = (_a = instance.scrollElement) == null ? void 0 : _a.scrollTo) == null ? void 0 : _b.call(_a, {
[instance.options.horizontal ? "left" : "top"]: toOffset,
behavior
});
};
class Virtualizer {
constructor(opts) {
this.unsubs = [];
this.scrollElement = null;
this.targetWindow = null;
this.isScrolling = false;
this.measurementsCache = [];
this.itemSizeCache = /* @__PURE__ */ new Map();
this.pendingMeasuredCacheIndexes = [];
this.scrollRect = null;
this.scrollOffset = null;
this.scrollDirection = null;
this.scrollAdjustments = 0;
this.elementsCache = /* @__PURE__ */ new Map();
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 = () => {
this._measureElement(entry.target, entry);
};
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) => {
Object.entries(opts2).forEach(([key, value]) => {
if (typeof value === "undefined") delete opts2[key];
});
this.options = {
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,
isScrollingResetDelay: 150,
enabled: true,
isRtl: false,
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
...opts2
};
};
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();
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._scrollToOffset(this.getScrollOffset(), {
adjustments: void 0,
behavior: void 0
});
this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
this.scrollRect = rect;
this.maybeNotify();
})
);
this.unsubs.push(
this.options.observeElementOffset(this, (offset, isScrolling) => {
this.scrollAdjustments = 0;
this.scrollDirection = isScrolling ? this.getScrollOffset() < offset ? "forward" : "backward" : null;
this.scrollOffset = offset;
this.isScrolling = isScrolling;
this.maybeNotify();
})
);
}
};
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.getFurthestMeasurement = (measurements, index) => {
const furthestMeasurementsFound = /* @__PURE__ */ new Map();
const furthestMeasurements = /* @__PURE__ */ new Map();
for (let m = index - 1; m >= 0; m--) {
const measurement = measurements[m];
if (furthestMeasurementsFound.has(measurement.lane)) {
continue;
}
const previousFurthestMeasurement = furthestMeasurements.get(
measurement.lane
);
if (previousFurthestMeasurement == null || measurement.end > previousFurthestMeasurement.end) {
furthestMeasurements.set(measurement.lane, measurement);
} else if (measurement.end < previousFurthestMeasurement.end) {
furthestMeasurementsFound.set(measurement.lane, true);
}
if (furthestMeasurementsFound.size === this.options.lanes) {
break;
}
}
return furthestMeasurements.size === this.options.lanes ? Array.from(furthestMeasurements.values()).sort((a, b) => {
if (a.end === b.end) {
return a.index - b.index;
}
return a.end - b.end;
})[0] : void 0;
};
this.getMeasurementOptions = memo(
() => [
this.options.count,
this.options.paddingStart,
this.options.scrollMargin,
this.options.getItemKey,
this.options.enabled
],
(count, paddingStart, scrollMargin, getItemKey, enabled) => {
this.pendingMeasuredCacheIndexes = [];
return {
count,
paddingStart,
scrollMargin,
getItemKey,
enabled
};
},
{
key: false
}
);
this.getMeasurements = memo(
() => [this.getMeasurementOptions(), this.itemSizeCache],
({ count, paddingStart, scrollMargin, getItemKey, enabled }, itemSizeCache) => {
if (!enabled) {
this.measurementsCache = [];
this.itemSizeCache.clear();
return [];
}
if (this.measurementsCache.length === 0) {
this.measurementsCache = this.options.initialMeasurementsCache;
this.measurementsCache.forEach((item) => {
this.itemSizeCache.set(item.key, item.size);
});
}
const min = this.pendingMeasuredCacheIndexes.length > 0 ? Math.min(...this.pendingMeasuredCacheIndexes) : 0;
this.pendingMeasuredCacheIndexes = [];
const measurements = this.measurementsCache.slice(0, min);
for (let i = min; i < count; i++) {
const key = getItemKey(i);
const furthestMeasurement = this.options.lanes === 1 ? measurements[i - 1] : this.getFurthestMeasurement(measurements, i);
const start = furthestMeasurement ? furthestMeasurement.end + this.options.gap : paddingStart + scrollMargin;
const measuredSize = itemSizeCache.get(key);
const size = typeof measuredSize === "number" ? measuredSize : this.options.estimateSize(i);
const end = start + size;
const lane = furthestMeasurement ? furthestMeasurement.lane : i % this.options.lanes;
measurements[i] = {
index: i,
start,
size,
end,
key,
lane
};
}
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) => {
return this.range = measurements.length > 0 && outerSize > 0 ? calculateRange({
measurements,
outerSize,
scrollOffset,
lanes
}) : null;
},
{
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._measureElement = (node, entry) => {
const index = this.indexFromElement(node);
const item = this.measurementsCache[index];
if (!item) {
return;
}
const key = item.key;
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 (node.isConnected) {
this.resizeItem(index, this.options.measureElement(node, entry, this));
}
};
this.resizeItem = (index, size) => {
const item = this.measurementsCache[index];
if (!item) {
return;
}
const itemSize = this.itemSizeCache.get(item.key) ?? item.size;
const delta = size - itemSize;
if (delta !== 0) {
if (this.shouldAdjustScrollPositionOnItemSizeChange !== void 0 ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) : item.start < this.getScrollOffset() + this.scrollAdjustments) {
if (process.env.NODE_ENV !== "production" && this.options.debug) {
console.info("correction", delta);
}
this._scrollToOffset(this.getScrollOffset(), {
adjustments: this.scrollAdjustments += delta,
behavior: void 0
});
}
this.pendingMeasuredCacheIndexes.push(item.index);
this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size));
this.notify(false);
}
};
this.measureElement = (node) => {
if (!node) {
this.elementsCache.forEach((cached, key) => {
if (!cached.isConnected) {
this.observer.unobserve(cached);
this.elementsCache.delete(key);
}
});
return;
}
this._measureElement(node, void 0);
};
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;
}
return notUndefined(
measurements[findNearestBinarySearch(
0,
measurements.length - 1,
(index) => notUndefined(measurements[index]).start,
offset
)]
);
};
this.getOffsetForAlignment = (toOffset, align, itemSize = 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.getTotalSize() + this.options.scrollMargin - size;
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 item = this.measurementsCache[index];
if (!item) {
return void 0;
}
const size = this.getSize();
const scrollOffset = this.getScrollOffset();
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];
}
}
const toOffset = align === "end" ? item.end + this.options.scrollPaddingEnd : item.start - this.options.scrollPaddingStart;
return [
this.getOffsetForAlignment(toOffset, align, item.size),
align
];
};
this.isDynamicMode = () => this.elementsCache.size > 0;
this.scrollToOffset = (toOffset, { align = "start", behavior } = {}) => {
if (behavior === "smooth" && this.isDynamicMode()) {
console.warn(
"The `smooth` scroll behavior is not fully supported with dynamic size."
);
}
this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), {
adjustments: void 0,
behavior
});
};
this.scrollToIndex = (index, { align: initialAlign = "auto", behavior } = {}) => {
if (behavior === "smooth" && this.isDynamicMode()) {
console.warn(
"The `smooth` scroll behavior is not fully supported with dynamic size."
);
}
index = Math.max(0, Math.min(index, this.options.count - 1));
let attempts = 0;
const maxAttempts = 10;
const tryScroll = (currentAlign) => {
if (!this.targetWindow) return;
const offsetInfo = this.getOffsetForIndex(index, currentAlign);
if (!offsetInfo) {
console.warn("Failed to get offset for index:", index);
return;
}
const [offset, align] = offsetInfo;
this._scrollToOffset(offset, { adjustments: void 0, behavior });
this.targetWindow.requestAnimationFrame(() => {
const currentOffset = this.getScrollOffset();
const afterInfo = this.getOffsetForIndex(index, align);
if (!afterInfo) {
console.warn("Failed to get offset for index:", index);
return;
}
if (!approxEqual(afterInfo[0], currentOffset)) {
scheduleRetry(align);
}
});
};
const scheduleRetry = (align) => {
if (!this.targetWindow) return;
attempts++;
if (attempts < maxAttempts) {
if (process.env.NODE_ENV !== "production" && this.options.debug) {
console.info("Schedule retry", attempts, maxAttempts);
}
this.targetWindow.requestAnimationFrame(() => tryScroll(align));
} else {
console.warn(
`Failed to scroll to index ${index} after ${maxAttempts} attempts.`
);
}
};
tryScroll(initialAlign);
};
this.scrollBy = (delta, { behavior } = {}) => {
if (behavior === "smooth" && this.isDynamicMode()) {
console.warn(
"The `smooth` scroll behavior is not fully supported with dynamic size."
);
}
this._scrollToOffset(this.getScrollOffset() + delta, {
adjustments: void 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) {
end = ((_a = measurements[measurements.length - 1]) == 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._scrollToOffset = (offset, {
adjustments,
behavior
}) => {
this.options.scrollToFn(offset, { behavior, adjustments }, this);
};
this.measure = () => {
this.itemSizeCache = /* @__PURE__ */ new Map();
this.notify(false);
};
this.setOptions(opts);
}
}
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 calculateRange({
measurements,
outerSize,
scrollOffset,
lanes
}) {
const lastIndex = measurements.length - 1;
const getOffset = (index) => measurements[index].start;
if (measurements.length <= lanes) {
return {
startIndex: 0,
endIndex: lastIndex
};
}
let startIndex = findNearestBinarySearch(
0,
lastIndex,
getOffset,
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,
approxEqual,
debounce,
defaultKeyExtractor,
defaultRangeExtractor,
elementScroll,
measureElement,
memo,
notUndefined,
observeElementOffset,
observeElementRect,
observeWindowOffset,
observeWindowRect,
windowScroll
};
//# sourceMappingURL=index.js.map