virtua
Version:
A zero-config, fast and small (~3kB) virtual list (and grid) component for React, Vue, Solid and Svelte.
1,288 lines (1,282 loc) • 62.1 kB
JSX
import { mergeProps, createEffect, onCleanup, createMemo, createSignal, onMount, createComputed, on, untrack, For, splitProps } from 'solid-js';
import { Dynamic } from 'solid-js/web';
/** @internal */
const NULL = null;
/** @internal */
const { min, max, abs, floor } = Math;
/**
* @internal
*/
const clamp = (value, minValue, maxValue) => min(maxValue, max(minValue, value));
/**
* @internal
*/
const sort = (arr) => {
return [...arr].sort((a, b) => a - b);
};
/**
* @internal
*/
const microtask = typeof queueMicrotask === "function"
? queueMicrotask
: (fn) => {
Promise.resolve().then(fn);
};
/**
* @internal
*/
const createPromise = () => {
let resolve;
const promise = new Promise((res) => {
resolve = res;
});
return [promise, resolve];
};
/**
* @internal
*/
const once = (fn) => {
let cache;
return () => {
if (fn) {
cache = fn();
fn = undefined;
}
return cache;
};
};
/** @internal */
const UNCACHED = -1;
const fill = (array, length, prepend) => {
const key = prepend ? "unshift" : "push";
for (let i = 0; i < length; i++) {
array[key](UNCACHED);
}
return array;
};
/**
* @internal
*/
const getItemSize = (cache, index) => {
const size = cache._sizes[index];
return size === UNCACHED ? cache._defaultItemSize : size;
};
/**
* @internal
*/
const setItemSize = (cache, index, size) => {
const isInitialMeasurement = cache._sizes[index] === UNCACHED;
cache._sizes[index] = size;
// mark as dirty
cache._computedOffsetIndex = min(index, cache._computedOffsetIndex);
return isInitialMeasurement;
};
/**
* @internal
*/
const getItemOffset = (cache, index) => {
if (!cache._length)
return 0;
if (cache._computedOffsetIndex >= index) {
return cache._offsets[index];
}
if (cache._computedOffsetIndex < 0) {
// first offset must be 0 to avoid returning NaN, which can cause infinite rerender.
// https://github.com/inokawa/virtua/pull/160
cache._offsets[0] = 0;
cache._computedOffsetIndex = 0;
}
let i = cache._computedOffsetIndex;
let top = cache._offsets[i];
while (i < index) {
top += getItemSize(cache, i);
cache._offsets[++i] = top;
}
// mark as measured
cache._computedOffsetIndex = index;
return top;
};
/**
* Finds the index of an item in the cache whose computed offset is closest to the specified offset.
*
* @internal
*/
const findIndex = (cache, offset, low = 0, high = cache._length - 1) => {
// Find with binary search
let found = low;
while (low <= high) {
const mid = floor((low + high) / 2);
if (getItemOffset(cache, mid) <= offset) {
found = mid;
low = mid + 1;
}
else {
high = mid - 1;
}
}
return clamp(found, 0, cache._length - 1);
};
/**
* @internal
*/
const computeRange = (cache, startOffset, endOffset, prevStartIndex) => {
// Clamp because prevStartIndex may exceed the limit when children decreased a lot after scrolling
prevStartIndex = min(prevStartIndex, cache._length - 1);
if (getItemOffset(cache, prevStartIndex) <= startOffset) {
// search forward
// start <= end, prevStartIndex <= start
const end = findIndex(cache, endOffset, prevStartIndex);
return [findIndex(cache, startOffset, prevStartIndex, end), end];
}
else {
// search backward
// start <= end, start <= prevStartIndex
const start = findIndex(cache, startOffset, undefined, prevStartIndex);
return [start, findIndex(cache, endOffset, start)];
}
};
/**
* @internal
*/
const estimateDefaultItemSize = (cache, startIndex) => {
let measuredCountBeforeStart = 0;
// This function will be called after measurement so measured size array must be longer than 0
const measuredSizes = [];
cache._sizes.forEach((s, i) => {
if (s !== UNCACHED) {
measuredSizes.push(s);
if (i < startIndex) {
measuredCountBeforeStart++;
}
}
});
// Discard cache for now
cache._computedOffsetIndex = -1;
// Calculate median
const sorted = sort(measuredSizes);
const len = sorted.length;
const mid = (len / 2) | 0;
const median = len % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
const prevDefaultItemSize = cache._defaultItemSize;
// Calculate diff of unmeasured items before start
return (((cache._defaultItemSize = median) - prevDefaultItemSize) *
max(startIndex - measuredCountBeforeStart, 0));
};
/**
* @internal
*/
const initCache = (length, itemSize, sizes) => {
return {
_defaultItemSize: itemSize,
_sizes: sizes
? // https://github.com/inokawa/virtua/issues/441
fill(sizes.slice(0, min(length, sizes.length)), max(0, length - sizes.length))
: fill([], length),
_length: length,
_computedOffsetIndex: -1,
_offsets: fill([], length + 1),
};
};
/**
* @internal
*/
const takeCacheSnapshot = (cache) => {
return [cache._sizes.slice(), cache._defaultItemSize];
};
/**
* @internal
*/
const updateCacheLength = (cache, length, isShift) => {
const diff = length - cache._length;
cache._computedOffsetIndex = isShift
? // Discard cache for now
-1
: min(length - 1, cache._computedOffsetIndex);
cache._length = length;
if (diff > 0) {
// Added
fill(cache._offsets, diff);
fill(cache._sizes, diff, isShift);
return cache._defaultItemSize * diff;
}
else {
// Removed
cache._offsets.splice(diff);
return (isShift ? cache._sizes.splice(0, -diff) : cache._sizes.splice(diff)).reduce((acc, removed) => acc - (removed === UNCACHED ? cache._defaultItemSize : removed), 0);
}
};
/**
* @internal
*/
const isBrowser = typeof window !== "undefined";
/**
* @internal
*/
const getDocumentElement = (doc) => doc.documentElement;
/**
* @internal
*/
const getCurrentDocument = (node) => node.ownerDocument;
/**
* @internal
*/
const getCurrentWindow = (doc) => doc.defaultView;
/**
* Currently, all browsers on iOS/iPadOS are WebKit, including WebView.
* @internal
*/
const isIOSWebKit = /*#__PURE__*/ once(() => {
if (/iP(hone|od|ad)/.test(navigator.userAgent)) {
return true;
}
// Modern iPad detection (iPadOS 13+)
// iPadOS 13+ reports the same userAgent/platform information as macOS, to enable desktop sites.
// So we treat devices that have macOS like information but with touch support as iPadOS.
// https://stackoverflow.com/questions/57776001/how-to-detect-ipad-pro-as-ipad-using-javascript
return navigator.platform === "MacIntel" && navigator.maxTouchPoints > 0;
});
/**
* @internal
*/
const isSmoothScrollSupported = /*#__PURE__*/ once(() => {
return "scrollBehavior" in getDocumentElement(document).style;
});
const MAX_INT_32 = 0x7fffffff;
const SCROLL_IDLE = 0;
const SCROLL_DOWN = 1;
const SCROLL_UP = 2;
const SCROLL_BY_NATIVE = 0;
const SCROLL_BY_MANUAL_SCROLL = 1;
const SCROLL_BY_SHIFT = 2;
/** @internal */
const ACTION_SCROLL = 1;
/** @internal */
const ACTION_SCROLL_END = 2;
/** @internal */
const ACTION_ITEM_RESIZE = 3;
/** @internal */
const ACTION_VIEWPORT_RESIZE = 4;
/** @internal */
const ACTION_ITEMS_LENGTH_CHANGE = 5;
/** @internal */
const ACTION_START_OFFSET_CHANGE = 6;
/** @internal */
const ACTION_MANUAL_SCROLL = 7;
/** @internal */
const ACTION_BEFORE_MANUAL_SMOOTH_SCROLL = 8;
/** @internal */
const UPDATE_VIRTUAL_STATE = 0b0001;
/** @internal */
const UPDATE_SIZE_EVENT = 0b0010;
/** @internal */
const UPDATE_SCROLL_EVENT = 0b0100;
/** @internal */
const UPDATE_SCROLL_END_EVENT = 0b1000;
/**
* @internal
*/
const getScrollSize = (store) => {
return max(store.$getTotalSize(), store.$getViewportSize());
};
/**
* @internal
*/
const createVirtualStore = (elementsCount, itemSize = 40, ssrCount = 0, cacheSnapshot, shouldAutoEstimateItemSize = false) => {
let isSSR = !!ssrCount;
let stateVersion = 1;
let viewportSize = 0;
let startSpacerSize = 0;
let scrollOffset = 0;
let jump = 0;
let pendingJump = 0;
let _flushedJump = 0;
let _scrollDirection = SCROLL_IDLE;
let _scrollMode = SCROLL_BY_NATIVE;
let _frozenRange = NULL;
let _prevRange = [0, isSSR ? max(ssrCount - 1, 0) : -1];
let _totalMeasuredSize = 0;
let _isViewportMeasured = false;
const cache = initCache(elementsCount, cacheSnapshot
? cacheSnapshot[1]
: itemSize, cacheSnapshot && cacheSnapshot[0]);
const subscribers = new Set();
const getRelativeScrollOffset = () => scrollOffset - startSpacerSize;
const getVisibleOffset = () => getRelativeScrollOffset() + pendingJump + jump;
const getRange = (startOffset, endOffset) => {
return computeRange(cache, startOffset, endOffset, _prevRange[0]);
};
const getTotalSize = () => getItemOffset(cache, cache._length);
const getItemOffset$1 = (index, fromEnd) => {
const offset = getItemOffset(cache, index) - pendingJump;
if (fromEnd) {
return getTotalSize() - offset - getItemSize$1(index);
}
return offset;
};
const getItemSize$1 = (index) => {
return getItemSize(cache, index);
};
const isSizeEqual = (index, value = UNCACHED) => {
return cache._sizes[index] === value;
};
const applyJump = (j) => {
if (j) {
if (
// In iOS WebKit browsers, updating scroll position will stop scrolling so it have to be deferred during scrolling.
(isIOSWebKit() && _scrollDirection !== SCROLL_IDLE) ||
// Before imperative smooth scrolling, we measure all items which may be visible during scrolling.
// However, especially in Firefox, there are rare cases where items resize while scrolling, which can stop smooth scrolling.
(_frozenRange && _scrollMode === SCROLL_BY_MANUAL_SCROLL)) {
pendingJump += j;
}
else {
jump += j;
}
}
};
return {
$dispose: () => {
subscribers.clear();
},
$getStateVersion: () => stateVersion,
$getCacheSnapshot: () => {
return takeCacheSnapshot(cache);
},
$getRange: (bufferSize = 200) => {
if (!_isViewportMeasured || isSSR) {
// Return range for SSR, or return [0, -1] to render nothing, until the scroll offset and viewport size are determined.
// https://github.com/inokawa/virtua/issues/415
// https://github.com/inokawa/virtua/pull/818
return _prevRange;
}
let startIndex;
let endIndex;
if (_flushedJump) {
// Return previous range for consistent render until next scroll event comes in.
// And it must be clamped. https://github.com/inokawa/virtua/issues/597
[startIndex, endIndex] = _prevRange;
}
else {
let startOffset = max(0, getVisibleOffset());
let endOffset = startOffset + viewportSize;
// For faster initial render pass, returns without buffer if measurement seems to be in progress.
if (!shouldAutoEstimateItemSize) {
bufferSize = max(0, bufferSize);
if (_scrollDirection !== SCROLL_DOWN) {
startOffset -= bufferSize;
}
if (_scrollDirection !== SCROLL_UP) {
endOffset += bufferSize;
}
}
[startIndex, endIndex] = _prevRange = getRange(max(0, startOffset), max(0, endOffset));
if (_frozenRange) {
startIndex = min(startIndex, _frozenRange[0]);
endIndex = max(endIndex, _frozenRange[1]);
}
}
return [max(startIndex, 0), min(endIndex, cache._length - 1)];
},
$findItemIndex: (offset) => findIndex(cache, offset - startSpacerSize),
$isUnmeasuredItem: isSizeEqual,
$getItemOffset: getItemOffset$1,
$getItemSize: getItemSize$1,
$getItemsLength: () => cache._length,
$getScrollOffset: () => scrollOffset,
$isScrolling: () => _scrollDirection !== SCROLL_IDLE,
$getViewportSize: () => viewportSize,
$getStartSpacerSize: () => startSpacerSize,
$getTotalSize: getTotalSize,
_flushJump: () => {
_flushedJump = jump;
jump = 0;
return [_flushedJump, _scrollMode === SCROLL_BY_SHIFT];
},
$subscribe: (target, cb) => {
const sub = [target, cb];
subscribers.add(sub);
return () => {
subscribers.delete(sub);
};
},
$update: (type, payload) => {
let shouldFlushPendingJump;
let shouldSync;
let mutated = 0;
switch (type) {
case ACTION_SCROLL: {
if (payload === scrollOffset && _scrollMode === SCROLL_BY_NATIVE) {
// Ignore scroll events from different direction
break;
}
const flushedJump = _flushedJump;
_flushedJump = 0;
const delta = payload - scrollOffset;
const distance = abs(delta);
// Scroll event after jump compensation is not reliable because it may result in the opposite direction.
// The delta of artificial scroll may not be equal with the jump because it may be batched with other scrolls.
// And at least in latest Chrome/Firefox/Safari in 2023, setting value to scrollTop/scrollLeft can lose subpixel because its integer (sometimes float probably depending on dpr).
const isJustJumped = flushedJump && distance < abs(flushedJump) + 1;
// Scroll events are dispatched enough so it's ok to skip some of them.
if (!isJustJumped &&
// Ignore until manual scrolling
_scrollMode === SCROLL_BY_NATIVE) {
_scrollDirection = delta < 0 ? SCROLL_UP : SCROLL_DOWN;
}
// TODO This will cause glitch in reverse infinite scrolling. Disable this until better solution is found.
// if (
// pendingJump &&
// ((_scrollDirection === SCROLL_UP &&
// payload - max(pendingJump, 0) <= 0) ||
// (_scrollDirection === SCROLL_DOWN &&
// payload - min(pendingJump, 0) >= getScrollOffsetMax()))
// ) {
// // Flush if almost reached to start or end
// shouldFlushPendingJump = true;
// }
if (isSSR) {
isSSR = false;
}
scrollOffset = payload;
mutated = UPDATE_SCROLL_EVENT;
// Skip if offset is not changed
// Scroll offset may exceed min or max especially in Safari's elastic scrolling.
const relativeOffset = getRelativeScrollOffset();
if (relativeOffset >= -viewportSize &&
relativeOffset <= getTotalSize()) {
mutated += UPDATE_VIRTUAL_STATE;
// Update synchronously if scrolled a lot
shouldSync = distance > viewportSize;
}
break;
}
case ACTION_SCROLL_END: {
mutated = UPDATE_SCROLL_END_EVENT;
if (_scrollDirection !== SCROLL_IDLE) {
shouldFlushPendingJump = true;
mutated += UPDATE_VIRTUAL_STATE;
}
_scrollDirection = SCROLL_IDLE;
_scrollMode = SCROLL_BY_NATIVE;
_frozenRange = NULL;
break;
}
case ACTION_ITEM_RESIZE: {
const updated = payload.filter(([index, size]) => !isSizeEqual(index, size));
// Skip if all items are cached and not updated
if (!updated.length) {
break;
}
// Calculate jump by resize to minimize junks in appearance
applyJump(updated.reduce((acc, [index, size]) => {
let shouldKeep;
if (
// Keep distance from end during shifting
_scrollMode === SCROLL_BY_SHIFT) {
shouldKeep = true;
}
else if (_frozenRange &&
_scrollMode === SCROLL_BY_MANUAL_SCROLL) {
// https://github.com/inokawa/virtua/issues/380
// https://github.com/inokawa/virtua/issues/758
shouldKeep = index < _frozenRange[0];
}
else {
// Otherwise we should maintain visible position
const start = getRelativeScrollOffset();
const itemOffset = getItemOffset$1(index);
const itemSize = getItemSize$1(index);
shouldKeep =
_scrollDirection !== SCROLL_DOWN &&
_scrollMode === SCROLL_BY_NATIVE
? // https://github.com/inokawa/virtua/issues/385
// https://github.com/inokawa/virtua/discussions/865
itemOffset + itemSize < start
: // https://github.com/inokawa/virtua/pull/868
itemOffset < start &&
itemOffset + itemSize < start + viewportSize;
}
if (shouldKeep) {
acc += size - getItemSize$1(index);
}
return acc;
}, 0));
// Update item sizes
for (const [index, size] of updated) {
const prevSize = getItemSize$1(index);
const isInitialMeasurement = setItemSize(cache, index, size);
if (shouldAutoEstimateItemSize) {
_totalMeasuredSize += isInitialMeasurement
? size
: size - prevSize;
}
}
// Estimate initial item size from measured sizes
if (shouldAutoEstimateItemSize &&
viewportSize &&
// If the total size is lower than the viewport, the item may be a empty state
_totalMeasuredSize > viewportSize) {
applyJump(estimateDefaultItemSize(cache, findIndex(cache, getVisibleOffset())));
shouldAutoEstimateItemSize = false;
}
mutated = UPDATE_VIRTUAL_STATE + UPDATE_SIZE_EVENT;
// Synchronous update is necessary in current design to minimize visible glitch in concurrent rendering.
// However this seems to be the main cause of the errors from ResizeObserver.
// https://github.com/inokawa/virtua/issues/470
//
// And in React, synchronous update with flushSync after asynchronous update will overtake the asynchronous one.
// If items resize happens just after scroll, race condition can occur depending on implementation.
shouldSync = true;
break;
}
case ACTION_VIEWPORT_RESIZE: {
if (viewportSize !== payload) {
if (!viewportSize) {
_isViewportMeasured = shouldSync = true;
}
viewportSize = payload;
mutated = UPDATE_VIRTUAL_STATE + UPDATE_SIZE_EVENT;
}
break;
}
case ACTION_ITEMS_LENGTH_CHANGE: {
if (payload[1]) {
applyJump(updateCacheLength(cache, payload[0], true));
_scrollMode = SCROLL_BY_SHIFT;
mutated = UPDATE_VIRTUAL_STATE;
}
else {
updateCacheLength(cache, payload[0]);
// https://github.com/inokawa/virtua/issues/552
// https://github.com/inokawa/virtua/issues/557
mutated = UPDATE_VIRTUAL_STATE;
}
break;
}
case ACTION_START_OFFSET_CHANGE: {
startSpacerSize = payload;
break;
}
case ACTION_MANUAL_SCROLL: {
_scrollMode = SCROLL_BY_MANUAL_SCROLL;
break;
}
case ACTION_BEFORE_MANUAL_SMOOTH_SCROLL: {
_frozenRange = getRange(payload, payload + viewportSize);
mutated = UPDATE_VIRTUAL_STATE;
break;
}
}
if (mutated) {
stateVersion = (stateVersion & MAX_INT_32) + 1;
if (shouldFlushPendingJump && pendingJump) {
jump += pendingJump;
pendingJump = 0;
}
subscribers.forEach(([target, cb]) => {
// Early return to skip React's computation
if (!(mutated & target)) {
return;
}
// https://github.com/facebook/react/issues/25191
// https://github.com/facebook/react/blob/a5fc797db14c6e05d4d5c4dbb22a0dd70d41f5d5/packages/react-reconciler/src/ReactFiberWorkLoop.js#L1443-L1447
cb(shouldSync);
});
}
},
};
};
const timeout = setTimeout;
const debounce = (fn, ms) => {
let id;
const cancel = () => {
if (id != NULL) {
clearTimeout(id);
}
};
const debouncedFn = () => {
cancel();
id = timeout(() => {
id = NULL;
fn();
}, ms);
};
debouncedFn._cancel = cancel;
return debouncedFn;
};
/**
* scrollTop/scrollLeft can be negative value under certain styles.
* - direction: rtl https://github.com/othree/jquery.rtl-scroll-type
* - writing-mode https://people.igalia.com/fwang/scrollable-elements-in-non-default-writing-modes/
* - flex-direction: column-reverse/row-reverse
*
* top/left bottom/right
* 0 100 spec compliant bottom/right overflow, or possibly top/left overflow in Chrome earlier than v85
* -100 0 spec compliant top/left overflow
* https://drafts.csswg.org/cssom-view/#scroll-an-element
*/
const normalizeScrollOffset = (offset, isNegative) => {
return isNegative ? -offset : offset;
};
const createScrollObserver = (store, viewport, isHorizontal, getScrollOffset, updateScrollOffset, getStartOffset) => {
const now = Date.now;
let lastScrollTime = 0;
let wheeling = false;
let touching = false;
let justTouchEnded = false;
let stillMomentumScrolling = false;
const onScrollEnd = debounce(() => {
if (wheeling || touching) {
wheeling = false;
// Wait while wheeling or touching
onScrollEnd();
return;
}
justTouchEnded = false;
store.$update(ACTION_SCROLL_END);
}, 150);
const onScroll = () => {
lastScrollTime = now();
if (justTouchEnded) {
stillMomentumScrolling = true;
}
if (getStartOffset) {
store.$update(ACTION_START_OFFSET_CHANGE, getStartOffset());
}
store.$update(ACTION_SCROLL, getScrollOffset());
onScrollEnd();
};
// Infer scroll state also from wheel events
// Sometimes scroll events do not fire when frame dropped even if the visual have been already scrolled
const onWheel = ((e) => {
if (wheeling ||
// Scroll start should be detected with scroll event
!store.$isScrolling() ||
// Probably a pinch-to-zoom gesture
e.ctrlKey) {
return;
}
const timeDelta = now() - lastScrollTime;
if (
// Check if wheel event occurs some time after scrolling
150 > timeDelta &&
50 < timeDelta &&
// Get delta before checking deltaMode for firefox behavior
// https://github.com/w3c/uievents/issues/181#issuecomment-392648065
// https://bugzilla.mozilla.org/show_bug.cgi?id=1392460#c34
(isHorizontal ? e.deltaX : e.deltaY)) {
wheeling = true;
}
}); // FIXME type error. why only here?
const onTouchStart = () => {
touching = true;
justTouchEnded = stillMomentumScrolling = false;
};
const onTouchEnd = () => {
touching = false;
if (isIOSWebKit()) {
justTouchEnded = true;
}
};
viewport.addEventListener("scroll", onScroll);
viewport.addEventListener("wheel", onWheel, { passive: true });
viewport.addEventListener("touchstart", onTouchStart, { passive: true });
viewport.addEventListener("touchend", onTouchEnd, { passive: true });
return {
_dispose: () => {
viewport.removeEventListener("scroll", onScroll);
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("touchstart", onTouchStart);
viewport.removeEventListener("touchend", onTouchEnd);
onScrollEnd._cancel();
},
_fixScrollJump: () => {
const [jump, shift] = store._flushJump();
if (!jump)
return;
updateScrollOffset(jump, shift, stillMomentumScrolling);
stillMomentumScrolling = false;
if (shift && store.$getViewportSize() > store.$getTotalSize()) {
// In this case applying jump may not cause scroll.
// Current logic expects scroll event occurs after applying jump so we dispatch it manually.
store.$update(ACTION_SCROLL, getScrollOffset());
}
},
};
};
const createScrollScheduler = (store, initialized, scroll) => {
let cancelScroll;
// The given offset will be clamped by browser
// https://drafts.csswg.org/cssom-view/#dom-element-scrolltop
return [
async (getTargetOffset, smooth) => {
// Wait for element assign. The element may be undefined if scrollRef prop is used and scroll is scheduled on mount.
// https://github.com/inokawa/virtua/pull/733
// https://github.com/inokawa/virtua/pull/750
if (!(await initialized())) {
return;
}
if (cancelScroll) {
// Cancel waiting scrollTo
cancelScroll();
}
const waitForMeasurement = () => {
// Wait for the scroll destination items to be measured.
// The measurement will be done asynchronously and the timing is not predictable so we use promise.
const [promise, resolve] = createPromise();
cancelScroll = () => {
resolve(false);
};
// Resize event may not happen when the window/tab is not visible, or during browser back in Safari.
// We have to wait for the initial measurement to avoid failing imperative scroll on mount.
// https://github.com/inokawa/virtua/issues/450
if (store.$getViewportSize()) {
// Cancel when items around scroll destination completely measured
timeout(cancelScroll, 150);
}
return [
promise,
store.$subscribe(UPDATE_SIZE_EVENT, () => {
resolve(true);
}),
];
};
if (smooth && isSmoothScrollSupported()) {
store.$update(ACTION_BEFORE_MANUAL_SMOOTH_SCROLL, getTargetOffset());
// https://github.com/inokawa/virtua/issues/590
microtask(async () => {
while (true) {
let done = true;
for (let [i, end] = store.$getRange(); i <= end; i++) {
if (store.$isUnmeasuredItem(i)) {
done = false;
break;
}
}
if (done) {
break;
}
const [promise, unsubscribe] = waitForMeasurement();
try {
if (!(await promise)) {
// canceled
return;
}
}
finally {
unsubscribe();
}
}
store.$update(ACTION_MANUAL_SCROLL);
scroll(getTargetOffset(), smooth);
});
}
else {
while (true) {
const [promise, unsubscribe] = waitForMeasurement();
try {
store.$update(ACTION_MANUAL_SCROLL);
scroll(getTargetOffset());
if (!(await promise)) {
// canceled or finished
return;
}
}
finally {
unsubscribe();
}
}
}
},
() => {
cancelScroll && cancelScroll();
},
];
};
/**
* @internal
*/
const createScroller = (store, isHorizontal) => {
let viewportElement;
let scrollObserver;
let initialized = createPromise();
let isNegative = false;
const scrollOffsetKey = isHorizontal ? "scrollLeft" : "scrollTop";
const overflowKey = isHorizontal ? "overflowX" : "overflowY";
const [scheduleScroll, cancelScroll] = createScrollScheduler(store, () => initialized[0], (offset, smooth) => {
offset = normalizeScrollOffset(offset, isNegative);
if (smooth) {
viewportElement.scrollTo({
[isHorizontal ? "left" : "top"]: offset,
behavior: "smooth",
});
}
else {
viewportElement[scrollOffsetKey] = offset;
}
});
return {
$observe(_, viewport) {
viewportElement = viewport;
if (isHorizontal) {
isNegative = getComputedStyle(viewport).direction === "rtl";
}
scrollObserver = createScrollObserver(store, viewport, isHorizontal, () => normalizeScrollOffset(viewport[scrollOffsetKey], isNegative), (jump, shift, isMomentumScrolling) => {
// If we update scroll position while touching on iOS, the position will be reverted.
// However iOS WebKit fires touch events only once at the beginning of momentum scrolling.
// That means we have no reliable way to confirm still touched or not if user touches more than once during momentum scrolling...
// This is a hack for the suspectable situations, inspired by https://github.com/prud/ios-overflow-scroll-to-top
if (isMomentumScrolling) {
const style = viewport.style;
const prev = style[overflowKey];
style[overflowKey] = "hidden";
timeout(() => {
style[overflowKey] = prev;
});
}
// Use absolute position not to exceed scrollable bounds
// https://github.com/inokawa/virtua/discussions/475
viewport[scrollOffsetKey] = normalizeScrollOffset(store.$getScrollOffset() + jump, isNegative);
if (shift) {
// https://github.com/inokawa/virtua/issues/357
cancelScroll();
}
});
initialized[1](true);
},
$dispose() {
scrollObserver && scrollObserver._dispose();
initialized[1](false);
// https://github.com/inokawa/virtua/pull/765
initialized = createPromise();
},
$isNegative: () => isNegative,
$scrollTo(offset) {
scheduleScroll(() => offset);
},
$scrollBy(offset) {
offset += store.$getScrollOffset();
scheduleScroll(() => offset);
},
$scrollToIndex(index, { align, smooth, offset = 0 } = {}) {
index = clamp(index, 0, store.$getItemsLength() - 1);
if (align === "nearest") {
const itemOffset = store.$getItemOffset(index);
const scrollOffset = store.$getScrollOffset();
if (itemOffset < scrollOffset) {
align = "start";
}
else if (itemOffset + store.$getItemSize(index) >
scrollOffset + store.$getViewportSize()) {
align = "end";
}
else {
// already completely visible
return;
}
}
scheduleScroll(() => {
return (offset +
store.$getStartSpacerSize() +
store.$getItemOffset(index) +
(align === "end"
? store.$getItemSize(index) - store.$getViewportSize()
: align === "center"
? (store.$getItemSize(index) - store.$getViewportSize()) / 2
: 0));
}, smooth);
},
$fixScrollJump: () => {
scrollObserver && scrollObserver._fixScrollJump();
},
};
};
/**
* @internal
*/
const createWindowScroller = (store, isHorizontal) => {
let containerElement;
let scrollObserver;
let initialized = createPromise();
let isNegative = false;
const scrollToKey = isHorizontal ? "left" : "top";
const [scheduleScroll] = createScrollScheduler(store, () => initialized[0], (offset, smooth) => {
offset = normalizeScrollOffset(offset, isNegative);
const window = getCurrentWindow(getCurrentDocument(containerElement));
if (smooth) {
window.scroll({
[scrollToKey]: offset,
behavior: "smooth",
});
}
else {
window.scroll({
[scrollToKey]: offset,
});
}
});
const calcOffsetToViewport = (node, viewport, window, isHorizontal, offset = 0) => {
// TODO calc offset only when it changes (maybe impossible)
const offsetKey = isHorizontal ? "offsetLeft" : "offsetTop";
const offsetSum = offset +
(isHorizontal && isNegative
? window.innerWidth - node[offsetKey] - node.offsetWidth
: node[offsetKey]);
const parent = node.offsetParent;
if (node === viewport || !parent) {
return offsetSum;
}
return calcOffsetToViewport(parent, viewport, window, isHorizontal, offsetSum);
};
return {
$observe(container) {
containerElement = container;
const scrollOffsetKey = isHorizontal ? "scrollX" : "scrollY";
const document = getCurrentDocument(container);
const window = getCurrentWindow(document);
if (isHorizontal) {
// Detect RTL document
isNegative =
getComputedStyle(getDocumentElement(document)).direction === "rtl";
}
scrollObserver = createScrollObserver(store, window, isHorizontal, () => normalizeScrollOffset(window[scrollOffsetKey], isNegative), (jump, shift) => {
// TODO support case two window scrollers exist in the same view
if (shift) {
// Use absolute position not to exceed scrollable bounds
window.scroll({
[scrollToKey]: normalizeScrollOffset(store.$getScrollOffset() + jump, isNegative),
});
}
else {
// Use window.scrollBy here, which causes less layout shift for some reason.
window.scrollBy({
[scrollToKey]: normalizeScrollOffset(jump, isNegative),
});
}
}, () => calcOffsetToViewport(container, document.body, window, isHorizontal));
initialized[1](true);
},
$dispose() {
scrollObserver && scrollObserver._dispose();
containerElement = undefined;
initialized[1](false);
// https://github.com/inokawa/virtua/pull/765
initialized = createPromise();
},
$isNegative: () => isNegative,
$fixScrollJump: () => {
scrollObserver && scrollObserver._fixScrollJump();
},
$scrollToIndex(index, { align, smooth, offset = 0 } = {}) {
if (!containerElement)
return;
index = clamp(index, 0, store.$getItemsLength() - 1);
if (align === "nearest") {
const itemOffset = store.$getItemOffset(index);
const scrollOffset = store.$getScrollOffset();
if (itemOffset < scrollOffset) {
align = "start";
}
else if (itemOffset + store.$getItemSize(index) >
scrollOffset + store.$getViewportSize()) {
align = "end";
}
else {
return;
}
}
const document = getCurrentDocument(containerElement);
const window = getCurrentWindow(document);
const html = getDocumentElement(document);
const getScrollbarSize = () => store.$getViewportSize() -
(isHorizontal ? html.clientWidth : html.clientHeight);
scheduleScroll(() => {
return (offset +
// Calculate target scroll position including container's offset from document
calcOffsetToViewport(containerElement, document.body, window, isHorizontal) +
// store._getStartSpacerSize() +
store.$getItemOffset(index) +
(align === "end"
? store.$getItemSize(index) -
(store.$getViewportSize() - getScrollbarSize())
: align === "center"
? (store.$getItemSize(index) -
(store.$getViewportSize() - getScrollbarSize())) /
2
: 0));
}, smooth);
},
};
};
/**
* @internal
*/
const createGridScroller = (rowStore, colStore) => {
const rowScroller = createScroller(rowStore, false);
const colScroller = createScroller(colStore, true);
return {
$observe(container, viewport) {
rowScroller.$observe(container, viewport);
colScroller.$observe(container, viewport);
},
$dispose() {
rowScroller.$dispose();
colScroller.$dispose();
},
$isNegative: colScroller.$isNegative,
$scrollTo(row, col) {
if (row != null) {
rowScroller.$scrollTo(row);
}
if (col != null) {
colScroller.$scrollTo(col);
}
},
$scrollBy(row, col) {
if (row != null) {
rowScroller.$scrollBy(row);
}
if (col != null) {
colScroller.$scrollBy(col);
}
},
$scrollToIndex(row, col) {
if (row != null) {
rowScroller.$scrollToIndex(row);
}
if (col != null) {
colScroller.$scrollToIndex(col);
}
},
$fixScrollJump() {
rowScroller.$fixScrollJump();
colScroller.$fixScrollJump();
},
};
};
const createResizeObserver = (cb) => {
let ro;
return {
_observe(e) {
// Initialize ResizeObserver lazily for SSR
// https://www.w3.org/TR/resize-observer/#intro
(ro ||
// https://bugs.chromium.org/p/chromium/issues/detail?id=1491739
(ro = new (getCurrentWindow(getCurrentDocument(e)).ResizeObserver)(cb))).observe(e);
},
_unobserve(e) {
ro.unobserve(e);
},
_dispose() {
ro && ro.disconnect();
},
};
};
/**
* @internal
*/
const createResizer = (store, isHorizontal) => {
let viewportElement;
const sizeKey = isHorizontal ? "width" : "height";
const mountedIndexes = new WeakMap();
const resizeObserver = createResizeObserver((entries) => {
const resizes = [];
for (const { target, contentRect } of entries) {
// Skip zero-sized rects that may be observed under `display: none` style
if (!target.offsetParent)
continue;
if (target === viewportElement) {
store.$update(ACTION_VIEWPORT_RESIZE, contentRect[sizeKey]);
}
else {
const index = mountedIndexes.get(target);
if (index != NULL) {
resizes.push([index, contentRect[sizeKey]]);
}
}
}
if (resizes.length) {
store.$update(ACTION_ITEM_RESIZE, resizes);
}
});
return {
$observeRoot(viewport) {
resizeObserver._observe((viewportElement = viewport));
},
$observeItem: (el, i) => {
mountedIndexes.set(el, i);
resizeObserver._observe(el);
return () => {
mountedIndexes.delete(el);
resizeObserver._unobserve(el);
};
},
$dispose: resizeObserver._dispose,
};
};
/**
* @internal
*/
const createWindowResizer = (store, isHorizontal) => {
const sizeKey = isHorizontal ? "width" : "height";
const windowSizeKey = isHorizontal ? "innerWidth" : "innerHeight";
const mountedIndexes = new WeakMap();
const resizeObserver = createResizeObserver((entries) => {
const resizes = [];
for (const { target, contentRect } of entries) {
// Skip zero-sized rects that may be observed under `display: none` style
if (!target.offsetParent)
continue;
const index = mountedIndexes.get(target);
if (index != NULL) {
resizes.push([index, contentRect[sizeKey]]);
}
}
if (resizes.length) {
store.$update(ACTION_ITEM_RESIZE, resizes);
}
});
let cleanupOnWindowResize;
return {
$observeRoot(container) {
const window = getCurrentWindow(getCurrentDocument(container));
const onWindowResize = () => {
store.$update(ACTION_VIEWPORT_RESIZE, window[windowSizeKey]);
};
window.addEventListener("resize", onWindowResize);
// https://github.com/inokawa/virtua/issues/792
microtask(onWindowResize);
cleanupOnWindowResize = () => {
window.removeEventListener("resize", onWindowResize);
};
},
$observeItem: (el, i) => {
mountedIndexes.set(el, i);
resizeObserver._observe(el);
return () => {
mountedIndexes.delete(el);
resizeObserver._unobserve(el);
};
},
$dispose() {
cleanupOnWindowResize && cleanupOnWindowResize();
resizeObserver._dispose();
},
};
};
/**
* @internal
*/
const createGridResizer = (rowStore, colStore) => {
let viewportElement;
const mountedIndexes = new WeakMap();
const maybeCachedRowIndexes = new Set();
const maybeCachedColIndexes = new Set();
const sizeCache = new Map();
const getKey = (rowIndex, colIndex) => `${rowIndex}-${colIndex}`;
const resizeObserver = createResizeObserver((entries) => {
const resizedRows = new Set();
const resizedCols = new Set();
for (const { target, contentRect: { width, height }, } of entries) {
// Skip zero-sized rects that may be observed under `display: none` style
if (!target.offsetParent)
continue;
if (target === viewportElement) {
rowStore.$update(ACTION_VIEWPORT_RESIZE, height);
colStore.$update(ACTION_VIEWPORT_RESIZE, width);
}
else {
const cell = mountedIndexes.get(target);
if (cell) {
const [rowIndex, colIndex] = cell;
const key = getKey(rowIndex, colIndex);
const prevSize = sizeCache.get(key);
let rowResized;
let colResized;
if (!prevSize) {
rowResized = colResized = true;
}
else {
if (prevSize[0] !== height) {
rowResized = true;
}
if (prevSize[1] !== width) {
colResized = true;
}
}
if (rowResized) {
resizedRows.add(rowIndex);
}
if (colResized) {
resizedCols.add(colIndex);
}
if (rowResized || colResized) {
sizeCache.set(key, [height, width]);
}
}
}
}
if (resizedRows.size) {
const heightResizes = [];
resizedRows.forEach((rowIndex) => {
let maxHeight = 0;
maybeCachedColIndexes.forEach((colIndex) => {
const size = sizeCache.get(getKey(rowIndex, colIndex));
if (size) {
maxHeight = max(maxHeight, size[0]);
}
});
if (maxHeight) {
heightResizes.push([rowIndex, maxHeight]);
}
});
rowStore.$update(ACTION_ITEM_RESIZE, heightResizes);
}
if (resizedCols.size) {
const widthResizes = [];
resizedCols.forEach((colIndex) => {
let maxWidth = 0;
maybeCachedRowIndexes.forEach((rowIndex) => {
const size = sizeCache.get(getKey(rowIndex, colIndex));
if (size) {
maxWidth = max(maxWidth, size[1]);
}
});
if (maxWidth) {
widthResizes.push([colIndex, maxWidth]);
}
});
colStore.$update(ACTION_ITEM_RESIZE, widthResizes);
}
});
return {
$observeRoot(viewport) {
resizeObserver._observe((viewportElement = viewport));
},
$observeItem(el, rowIndex, colIndex) {
mountedIndexes.set(el, [rowIndex, colIndex]);
maybeCachedRowIndexes.add(rowIndex);
maybeCachedColIndexes.add(colIndex);
resizeObserver._observe(el);
return () => {
mountedIndexes.delete(el);
resizeObserver._unobserve(el);
};
},
$resizeCols(cols) {
for (const [c] of cols) {
for (let r = 0; r < rowStore.$getItemsLength(); r++) {
sizeCache.delete(getKey(r, c));
}
}
colStore.$update(ACTION_ITEM_RESIZE, cols);
},
$resizeRows(rows) {
for (const [r] of rows) {
for (let c = 0; c < colStore.$getItemsLength(); c++) {
sizeCache.delete(getKey(r, c));
}
}
rowStore.$update(ACTION_ITEM_RESIZE, rows);
},
$dispose: re