virtua
Version:
A zero-config, fast and small (~3kB) virtual list (and grid) component for React, Vue, Solid and Svelte.
1,317 lines (1,310 loc) • 61.8 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 computeOffset = (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;
};
/**
* @internal
*/
const computeTotalSize = (cache) => {
if (!cache._length)
return 0;
return (computeOffset(cache, cache._length - 1) +
getItemSize(cache, cache._length - 1));
};
/**
* 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
while (low <= high) {
const mid = floor((low + high) / 2);
const itemOffset = computeOffset(cache, mid);
if (itemOffset <= offset) {
if (itemOffset + getItemSize(cache, mid) > offset) {
return mid;
}
low = mid + 1;
}
else {
high = mid - 1;
}
}
return clamp(low, 0, cache._length - 1);
};
/**
* @internal
*/
const computeRange = (cache, scrollOffset, viewportSize, prevStartIndex) => {
// Clamp because prevStartIndex may exceed the limit when children decreased a lot after scrolling
prevStartIndex = min(prevStartIndex, cache._length - 1);
if (computeOffset(cache, prevStartIndex) <= scrollOffset) {
// search forward
// start <= end, prevStartIndex <= start
const end = findIndex(cache, scrollOffset + viewportSize, prevStartIndex);
return [findIndex(cache, scrollOffset, prevStartIndex, end), end];
}
else {
// search backward
// start <= end, start <= prevStartIndex
const start = findIndex(cache, scrollOffset, undefined, prevStartIndex);
return [start, findIndex(cache, scrollOffset + viewportSize, 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, snapshot) => {
return {
_defaultItemSize: snapshot ? snapshot[1] : itemSize,
_sizes: snapshot && snapshot[0]
? // https://github.com/inokawa/virtua/issues/441
fill(snapshot[0].slice(0, min(length, snapshot[0].length)), max(0, length - snapshot[0].length))
: fill([], length),
_length: length,
_computedOffsetIndex: -1,
_offsets: fill([], length),
};
};
/**
* @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";
const getDocumentElement = () => document.documentElement;
/**
* @internal
*/
const getCurrentDocument = (node) => node.ownerDocument;
/**
* @internal
*/
const getCurrentWindow = (doc) => doc.defaultView;
/**
* @internal
*/
const isRTLDocument = /*#__PURE__*/ once(() => {
// TODO support SSR in rtl
return isBrowser
? getComputedStyle(getDocumentElement()).direction === "rtl"
: false;
});
/**
* Currently, all browsers on iOS/iPadOS are WebKit, including WebView.
* @internal
*/
const isIOSWebKit = /*#__PURE__*/ once(() => {
return /iP(hone|od|ad)/.test(navigator.userAgent);
});
/**
* @internal
*/
const isSmoothScrollSupported = /*#__PURE__*/ once(() => {
return "scrollBehavior" in getDocumentElement().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 isInitialMeasurementDone = (store) => {
return !!store.$getViewportSize();
};
/**
* @internal
*/
const createVirtualStore = (elementsCount, itemSize = 40, overscan = 4, 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 = isSSR
? [0, max(ssrCount - 1, 0)]
: NULL;
let _prevRange = [0, 0];
let _totalMeasuredSize = 0;
const cache = initCache(elementsCount, itemSize, cacheSnapshot);
const subscribers = new Set();
const getRelativeScrollOffset = () => scrollOffset - startSpacerSize;
const getVisibleOffset = () => getRelativeScrollOffset() + pendingJump + jump;
const getRange = (offset) => {
return computeRange(cache, offset, viewportSize, _prevRange[0]);
};
const getTotalSize = () => computeTotalSize(cache);
const getItemOffset = (index) => {
return computeOffset(cache, index) - pendingJump;
};
const getItemSize$1 = (index) => {
return getItemSize(cache, index);
};
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 {
$getStateVersion: () => stateVersion,
$getCacheSnapshot: () => {
return takeCacheSnapshot(cache);
},
$getRange: () => {
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 {
[startIndex, endIndex] = _prevRange = getRange(max(0, getVisibleOffset()));
if (_frozenRange) {
startIndex = min(startIndex, _frozenRange[0]);
endIndex = max(endIndex, _frozenRange[1]);
}
}
if (_scrollDirection !== SCROLL_DOWN) {
startIndex -= max(0, overscan);
}
if (_scrollDirection !== SCROLL_UP) {
endIndex += max(0, overscan);
}
return [max(startIndex, 0), min(endIndex, cache._length - 1)];
},
$findStartIndex: () => findIndex(cache, getVisibleOffset()),
$findEndIndex: () => findIndex(cache, getVisibleOffset() + viewportSize),
$isUnmeasuredItem: (index) => cache._sizes[index] === UNCACHED,
$getItemOffset: getItemOffset,
$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: {
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) {
_frozenRange = NULL;
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]) => cache._sizes[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]) => {
if (
// Keep distance from end during shifting
_scrollMode === SCROLL_BY_SHIFT ||
(_frozenRange && _scrollMode === SCROLL_BY_MANUAL_SCROLL
? // https://github.com/inokawa/virtua/issues/380
// https://github.com/inokawa/virtua/issues/758
index < _frozenRange[0]
: // Otherwise we should maintain visible position
getItemOffset(index) +
// https://github.com/inokawa/virtua/issues/385
(_scrollDirection === SCROLL_IDLE &&
_scrollMode === SCROLL_BY_NATIVE
? getItemSize$1(index)
: 0) <
getRelativeScrollOffset())) {
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) {
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);
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;
};
/**
* scrollLeft is negative value in rtl direction.
*
* left right
* 0 100 spec compliant (ltr)
* -100 0 spec compliant (rtl)
* https://github.com/othree/jquery.rtl-scroll-type
*/
const normalizeOffset = (offset, isHorizontal) => {
if (isHorizontal && isRTLDocument()) {
return -offset;
}
else {
return 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(normalizeOffset(jump, isHorizontal), 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());
}
},
};
};
/**
* @internal
*/
const createScroller = (store, isHorizontal) => {
let viewportElement;
let scrollObserver;
let cancelScroll;
let initialized = createPromise();
const scrollOffsetKey = isHorizontal ? "scrollLeft" : "scrollTop";
const overflowKey = isHorizontal ? "overflowX" : "overflowY";
// The given offset will be clamped by browser
// https://drafts.csswg.org/cssom-view/#dom-element-scrolltop
const scheduleImperativeScroll = 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[0])) {
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 (isInitialMeasurementDone(store)) {
// 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);
viewportElement.scrollTo({
[isHorizontal ? "left" : "top"]: normalizeOffset(getTargetOffset(), isHorizontal),
behavior: "smooth",
});
});
}
else {
while (true) {
const [promise, unsubscribe] = waitForMeasurement();
try {
store.$update(ACTION_MANUAL_SCROLL);
viewportElement[scrollOffsetKey] = normalizeOffset(getTargetOffset(), isHorizontal);
if (!(await promise)) {
// canceled or finished
return;
}
}
finally {
unsubscribe();
}
}
}
};
return {
$observe(viewport) {
viewportElement = viewport;
scrollObserver = createScrollObserver(store, viewport, isHorizontal, () => normalizeOffset(viewport[scrollOffsetKey], isHorizontal), (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] = store.$getScrollOffset() + jump;
if (shift) {
// https://github.com/inokawa/virtua/issues/357
cancelScroll && cancelScroll();
}
});
initialized[1](true);
},
$dispose() {
scrollObserver && scrollObserver._dispose();
initialized[1](false);
// https://github.com/inokawa/virtua/pull/765
initialized = createPromise();
},
$scrollTo(offset) {
scheduleImperativeScroll(() => offset);
},
$scrollBy(offset) {
offset += store.$getScrollOffset();
scheduleImperativeScroll(() => 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;
}
}
scheduleImperativeScroll(() => {
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 cancelScroll;
let initialized = createPromise();
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 && isRTLDocument()
? 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);
};
const scheduleImperativeScroll = 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[0])) {
return;
}
if (cancelScroll) {
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 (isInitialMeasurementDone(store)) {
// Cancel when items around scroll destination completely measured
timeout(cancelScroll, 150);
}
return [
promise,
store.$subscribe(UPDATE_SIZE_EVENT, () => {
resolve(true);
}),
];
};
const window = getCurrentWindow(getCurrentDocument(containerElement));
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);
window.scroll({
[isHorizontal ? "left" : "top"]: normalizeOffset(getTargetOffset(), isHorizontal),
behavior: "smooth",
});
});
}
else {
while (true) {
const [promise, unsubscribe] = waitForMeasurement();
try {
store.$update(ACTION_MANUAL_SCROLL);
window.scroll({
[isHorizontal ? "left" : "top"]: normalizeOffset(getTargetOffset(), isHorizontal),
});
if (!(await promise)) {
return;
}
}
finally {
unsubscribe();
}
}
}
};
return {
$observe(container) {
containerElement = container;
const scrollOffsetKey = isHorizontal ? "scrollX" : "scrollY";
const document = getCurrentDocument(container);
const window = getCurrentWindow(document);
const documentBody = document.body;
scrollObserver = createScrollObserver(store, window, isHorizontal, () => normalizeOffset(window[scrollOffsetKey], isHorizontal), (jump) => {
// Use absolute position not to exceed scrollable bounds
// https://github.com/inokawa/virtua/discussions/475
// TODO support case two window scrollers exist in the same view
window.scroll({
[isHorizontal ? "left" : "top"]: store.$getScrollOffset() + jump,
});
}, () => calcOffsetToViewport(container, documentBody, window, isHorizontal));
initialized[1](true);
},
$dispose() {
scrollObserver && scrollObserver._dispose();
containerElement = undefined;
initialized[1](false);
// https://github.com/inokawa/virtua/pull/765
initialized = createPromise();
},
$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 = document.documentElement;
const getScrollbarSize = () => store.$getViewportSize() -
(isHorizontal ? html.clientWidth : html.clientHeight);
scheduleImperativeScroll(() => {
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 = (vStore, hStore) => {
const vScroller = createScroller(vStore, false);
const hScroller = createScroller(hStore, true);
return {
$observe(viewportElement) {
vScroller.$observe(viewportElement);
hScroller.$observe(viewportElement);
},
$dispose() {
vScroller.$dispose();
hScroller.$dispose();
},
$scrollTo(offsetX, offsetY) {
vScroller.$scrollTo(offsetY);
hScroller.$scrollTo(offsetX);
},
$scrollBy(offsetX, offsetY) {
vScroller.$scrollBy(offsetY);
hScroller.$scrollBy(offsetX);
},
$scrollToIndex(indexX, indexY) {
vScroller.$scrollToIndex(indexY);
hScroller.$scrollToIndex(indexX);
},
$fixScrollJump() {
vScroller.$fixScrollJump();
hScroller.$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);
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 = (vStore, hStore) => {
let viewportElement;
const heightKey = "height";
const widthKey = "width";
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 } of entries) {
// Skip zero-sized rects that may be observed under `display: none` style
if (!target.offsetParent)
continue;
if (target === viewportElement) {
vStore.$update(ACTION_VIEWPORT_RESIZE, contentRect[heightKey]);
hStore.$update(ACTION_VIEWPORT_RESIZE, contentRect[widthKey]);
}
else {
const cell = mountedIndexes.get(target);
if (cell) {
const [rowIndex, colIndex] = cell;
const key = getKey(rowIndex, colIndex);
const prevSize = sizeCache.get(key);
const size = [
contentRect[heightKey],
contentRect[widthKey],
];
let rowResized;
let colResized;
if (!prevSize) {
rowResized = colResized = true;
}
else {
if (prevSize[0] !== size[0]) {
rowResized = true;
}
if (prevSize[1] !== size[1]) {
colResized = true;
}
}
if (rowResized) {
resizedRows.add(rowIndex);
}
if (colResized) {
resizedCols.add(colIndex);
}
if (rowResized || colResized) {
sizeCache.set(key, size);
}
}
}
}
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]);
}
});
vStore.$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]);
}
});
hStore.$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 < vStore.$getItemsLength(); r++) {
sizeCache.delete(getKey(r, c));
}
}
hStore.$update(ACTION_ITEM_RESIZE, cols);
},
$resizeRows(rows) {
for (const [r] of rows) {
for (let c = 0; c < hStore.$getItemsLength(); c++) {
sizeCache.delete(getKey(r, c));
}
}
vStore.$update(ACTION_ITEM_RESIZE, rows);
},
$dispose: resizeObserver._dispose,
};
};
/**
* @jsxImportSource solid-js
*/
/**
* @internal
*/
const ListItem = (props) => {
let elementRef;
props = mergeProps({ _as: "div" }, props);
// The index may be changed if elements are inserted to or removed from the start of props.children
createEffect(() => {
if (!elementRef)
return;
onCleanup(props._resizer(elementRef, props._index));
});
const style = createMemo(() => {
const isHorizontal = props._isHorizontal;
const style = {
contain: "layout style",
position: "absolute",
[isHorizontal ? "height" : "width"]: "100%",
[isHorizontal ? "top" : "left"]: "0px",
[isHorizontal ? (isRTLDocument() ? "right"