@af-utils/react-virtual-headless
Version:
React components for rendering large scrollable data
376 lines (370 loc) • 14.4 kB
JavaScript
import { useRef as t, useLayoutEffect as s, useEffect as i, useMemo as e } from "react";
import { useSyncExternalStore as h } from "use-sync-external-store/shim";
const o = (t, s, i) => {
const e = [];
let {from: h} = t;
const {to: o} = t;
if (i) for (let i = t.getOffset(h); o > h; ) e.push(s(h, i)), i += t.getSize(h++); else for (;o > h; ) e.push(s(h++, 0));
return e;
}, r = 2147483647, n = [ 0 /* Event.RANGE */ , 1 /* Event.SCROLL_SIZE */ , 2 /* Event.SIZES */ ], l = Uint32Array, c = "test" === process.env.NODE_ENV ? class {
constructor(t) {}
observe() {}
unobserve() {}
disconnect() {}
} : ResizeObserver, a = t => t(), u = (t, s) => {
if (!t) throw Error(s);
}, f = t => t instanceof Element, _ = (t, s) => {
if (f(t)) {
const i = new c(s);
return i.observe(t), () => i.disconnect();
}
// resizeObserver has required 1st call
return s(), t.addEventListener("resize", s), () => t.removeEventListener("resize", s);
}, m = (t, s, i) => {
const e = new l(s);
return e.set(t), e.fill(i, t.length), e;
}, d = t => {
const s = t.length + 1, i = new l(s);
i.set(t, 1);
for (let t, e = 1; s > e; e++) t = e + (e & -e), s > t && (i[t] += i[e]);
return i;
}, S = (t, s, i, e) => {
for (;e > s; s += s & -s) t[s] += i;
}, z = (t, s, i) => {
for (;i > s; s += s & -s) ;
return Math.min(s, t.length);
}, p = (t, s) => t.getBoundingClientRect()[s], b = (t, s, i, e) => s && t && t !== s ? t[i] + Math.round(p(s, e) - (f(t) ? p(t, e) : 0)) : 0, v = new Set, x = {
t: 0,
i() {
this.t++;
},
h() {
0 == --this.t && (v.forEach(a), v.clear());
},
o: t => v.add(t)
}, y = {
box: "border-box"
}, T = {
passive: !0
}, g = new l(0), E = [ "offsetHeight" /* ScrollElementSizeKey.ELEMENT_VERTICAL */ , "offsetWidth" /* ScrollElementSizeKey.ELEMENT_HORIZONTAL */ , "innerHeight" /* ScrollElementSizeKey.WINDOW_VERTICAL */ , "innerWidth" /* ScrollElementSizeKey.WINDOW_HORIZONTAL */ ], w = [ "scrollTop" /* ScrollKey.ELEMENT_VERTICAL */ , "scrollLeft" /* ScrollKey.ELEMENT_HORIZONTAL */ , "scrollY" /* ScrollKey.WINDOW_VERTICAL */ , "scrollX" /* ScrollKey.WINDOW_HORIZONTAL */ ], M = [ "blockSize" /* ResizeObserverSizeKey.VERTICAL */ , "inlineSize" /* ResizeObserverSizeKey.HORIZONTAL */ ], O = [ "top" /* ScrollToKey.VERTICAL */ , "left" /* ScrollToKey.HORIZONTAL */ ], k = (t, s) => Math.round(t[0][s]);
class C {
l=E[0];
u=w[0];
_=M[0];
m=O[0];
/*
When using window scroll mode, some blocks may go before & after virtual container.
[ ---- window start ---- ] |.|
Some header |s|
Another header |c|
<Virtual> |r|
item 1 [o]
item 2 [l]
item 3 [l]
... [b]
</Virtual> |a|
Some footer |r|
[ ---- window end ---- ] |.|
Actually any div may be used instead of window, but offset should be calculated.
*/
/* It is more useful to store scrollPos - scrollElementOffset in one variable for future calculations */
S=0;
p=0;
v=0;
T=0;
M=0;
O=0;
k=0;
C=3;
I=40;
R=null;
F=null;
K=g;
H=g;
/**
* Most significant bit of this._itemCount;
* caching it to avoid Math.clz32 calculations on every getIndex call
*/
P=0;
/** Scroll direction */
horizontal=!1;
/** Sum of all item sizes */
scrollSize=0;
/** Items range start */
from=0;
/** Items range end */
to=0;
/** Hash of item sizes. Changed when at least one visible item is resized. */
sizesHash=0;
W=new Map;
B=new Map;
/* header and footer; lengths are hardcoded */
L=[ null, null ];
q=[ 0, 0 ];
A=new c((t => {
let s = 0, i = 0, e = 0;
for (const {target: h, borderBoxSize: o} of t) s = this.L.indexOf(h), -1 !== s && (i = k(o, this._) - this.q[s],
i && (this.q[s] += i, e += i));
this.G(e);
}));
U=new c((t => {
let s = 0, i = !1;
const e = /*@__NOINLINE__*/ z(this.H, this.from + 1, this.to);
/*
TODO: check perf of borderBoxSize vs offsetWidth/offsetHeight
*/ for (const {target: h, borderBoxSize: o} of t) {
// cannot be undefined, because element is being added to this map before getting into ResizeObserver
const t = this.W.get(h);
/*
ResizeObserver may give us elements, which are not in visible range => will be unmounted soon.
Should not take them into account.
This is done for performance + updateItemHeight hack would not work without it
*/ if (e > t) {
const h = k(o, this._) - this.K[t];
h && (i = !0, this.K[t] += h, s += h, S(this.H, t + 1, h, e));
}
}
i && (x.i(), 0 !== s && (S(this.H, e, s, this.H.length), this.v += s, this.scrollSize += s,
this.X(1 /* Event.SCROLL_SIZE */), 0 > s &&
/*
If visible item sizes reduced - holes may appear, so rerender is a must.
No holes possible if item sizes increased => no need to rerender.
*/
this.Y())
/*
Modulo is used to prevent sizesHash from growing too much.
Using bitwise hack to optimize modulo.
5 % 2 === 5 & 1 && 9 % 4 === 9 & 3
*/ , this.sizesHash = this.sizesHash + 1 & 2147483647, this.X(2 /* Event.SIZES */),
x.h());
}));
$=n.map((() => []));
/**
* Update property names for resize events, dimensions and scroll position extraction
*
* @remarks
*
* `window.resize` event must be used for window scroller, `ResizeObserver` must be used in other cases.
* `offsetWidth` is used as item size in horizontal mode, `offsetHeight` - in vertical.
*/
j() {
const t = this.horizontal ? 1 : 0, s = t + 2 * (f(this.R) ? 0 : 1);
this.l = E[s], this.u = w[s], this._ = M[t], this.m = O[t];
}
D=() => {
const t = (s =
// casting type here because this stuff is used only as scrollElement resize event handler
this.R, i = this.l, e = this.T, s[i] - e);
var s, i, e;
t !== this.O && (this.O = t, this.Y());
};
G(t) {
t && (x.i(), this.T += t, this.O -= t, this.scrollSize += t, this.X(1 /* Event.SCROLL_SIZE */),
this.Y(), x.h());
}
J=() => {};
constructor(t) {
t && (this.horizontal = !!t.horizontal,
// stickyOffset is included;
this.p = t.estimatedScrollElementOffset || 0, this.O = t.estimatedWidgetSize ?? 200,
this.set(t));
}
/**
* Subscribe to model events
*
* @param callBack - event to be triggered
* @param events - events to subscribe
*
* @returns unsubscribe function
*/ on(t, s) {
return s.forEach((s => this.$[s].push(t))), () => s.forEach((s => this.$[s].splice(this.$[s].indexOf(t), 1)));
}
/**
* Call all `event` subscribers
* @param event - event to emit
*/ X(t) {
this.$[t].forEach(0 === x.t ? a : x.o);
}
/**
* Get item index by pixel offset
*
* @param offset - pixel offset
* @returns item index
*/ getIndex(t) {
if (0 >= t) return 0;
if (t >= this.v) return this.M - 1;
let s = 0;
for (let i = this.P, e = 0; i > 0; i >>= 1) e = s + i, e <= this.M && t > this.H[e] && (s = e,
t -= this.H[e]);
return s;
}
/**
* Get pixel offset by item index
*
* @param index - item index
* @returns pixel offset
*/ getOffset(t) {
"production" !== process.env.NODE_ENV && u(t <= this.M, "index must not be > itemCount");
let s = 0;
for (;t > 0; t -= t & -t) s += this.H[t];
return s;
}
/**
* @param itemIndex - item index
* @returns last cached item size
*/ getSize(t) {
return "production" !== process.env.NODE_ENV && u(t < this.K.length, "itemIndex must be < itemCount in getSize"),
this.K[t];
}
/**
* Get snapshot of current scroll position.
*
* @remarks
*
* For example `5.3` stands for item at index `5` + `30%` of its size.
* Used to remember scroll position before prepending elements.
*
* @returns visible item index (double number)
*/ get visibleFrom() {
const t = this.N;
return t + (this.S - this.getOffset(t)) / this.K[t];
}
/**
* Synchronize current scroll position with visible range
*/ V=() => {
/*
scrollElement may not be null here.
Math.round, because scrollY/scrollX may be float on Safari
*/
const t = this.S, s = Math.round(this.R[this.u]) - this.p;
s !== t && (this.S = s, s > t ? this.Y() : this.Z());
};
/*
Performs as destructor when null is passed
will ne used as callback, so using =>
*/
setScroller=t => {
t !== this.R && (this.J(), this.R?.removeEventListener("scroll", this.V), this.R = t,
t ? (this.j(), this.J = /*@__NOINLINE__*/ _(t, this.D), t.addEventListener("scroll", this.V, T),
this.tt(), this.V()) : (this.U.disconnect(), this.A.disconnect(), clearTimeout(this.k)));
};
setContainer=t => {
t !== this.F && (this.F = t, this.updateScrollerOffset());
};
tt() {
const t = /*@__NOINLINE__*/ b(this.R, this.F, this.u, this.m) - this.p;
return this.p += t, this.S -= t, t;
}
updateScrollerOffset() {
this.tt() && this.R && this.V();
}
/**
* Start observing size of `element` at `index`. Observing is finished if element is falsy.
* @param index - item index
* @param element - element for item
*/ el(t, s) {
const i = this.B.get(t);
i && (this.B.delete(t), this.W.delete(i), this.U.unobserve(i)), s && (this.W.set(s, t),
this.B.set(t, s), this.U.observe(s, y));
}
st(t, s) {
const i = this.L[t];
i && (this.A.unobserve(i), this.G(-this.q[t]), this.L[t] = null, this.q[t] = 0),
s && this.A.observe(this.L[t] = s, y);
}
/**
* Start observing size of sticky header `element`. Observing is finished if element is falsy.
* @param element - header element
*/ setStickyHeader(t) {
this.st(0, t);
}
/**
* Start observing size of sticky footer `element`. Observing is finished if element is falsy.
* @param element - footer element
*/ setStickyFooter(t) {
this.st(1, t);
}
/**
* Get first visible item index (without overscan)
* @returns first visible item index
*/ get N() {
return this.getIndex(this.S);
}
/**
* Get last visible item index (without overscan)
* @returns last visible item index
*/ get it() {
return this.M && 1 + this.getIndex(this.S + this.O);
}
/**
* Used to update current visible items range when scrolling down/right;
* adds overscan reserve forward to reduce rerenders quantity
*/ Y() {
const {it: t} = this;
t > this.to && (this.to = Math.min(this.M, t + this.C), this.from = this.N, this.X(0 /* Event.RANGE */));
}
/**
* Used to update current visible items range when scrolling up/left;
* adds overscan reserve backward to reduce rerenders quantity
*/ Z() {
const {N: t} = this;
t < this.from && (this.from = Math.max(0, t - this.C), this.to = this.it, this.X(0 /* Event.RANGE */));
}
/**
* Scroll to pixel offset
*
* @param offset - offset to scroll to
* @param smooth - should smooth scroll be used
*/ scrollToOffset(t, s) {
this.R?.scroll({
[this.m]: this.p + t,
behavior: s ? "smooth" : void 0
});
}
et(t, s, i) {
clearTimeout(this.k);
const e = 0 | s, h = Math.min(this.scrollSize - this.O, this.getOffset(e) + Math.round(this.K[e] * (s - e)));
h !== this.S && this.R && --t && (this.scrollToOffset(h, i), this.k = setTimeout((() => this.et(t, s, i)), i ? 512 : 32));
}
/**
* Scroll to item index
*
* @param index - item index to scroll to
* @param smooth - should smooth scroll be used
*/ scrollToIndex(t, s) {
this.et(16, t, s);
}
/**
* Notify model about items quantity change
* @param itemCount - new items quantity
*/ setItemCount(t) {
this.M !== t && (x.i(), u(r >= t, `itemCount must be <= 2147483647. Got: ${t}.`),
this.M = t, this.P = t && 1 << 31 - Math.clz32(t), t > this.K.length && (this.K = /*@__NOINLINE__*/ m(this.K, Math.min(t + 32, r), this.I || 40),
this.H = /*@__NOINLINE__*/ d(this.K)), this.v = this.getOffset(t), this.scrollSize = this.v + this.T,
this.X(1 /* Event.SCROLL_SIZE */), this.to > t && (
// after this range would be 100% updated
this.to = -1), this.Y(), x.h());
}
/**
* Synchronize runtime parameters
* @param runtimeParams - runtime parameters
*/ set(t) {
let {overscanCount: s, itemCount: i, estimatedItemSize: e} = t;
e && (
// must not be falsy, so not checking for undefined here.
this.I = e), void 0 !== s && (this.C = s), void 0 !== i && this.setItemCount(i);
}
}
const I = s => t().current ||= new C(s), R = s, F = t => {
const s = I(t);
return R((() => s.set(t))), s;
}, K = (t, s, e) => i((() => {
if (e) return e(), t.on(e, s);
}), [ t, e, s ]), H = function(t, s) {
void 0 === s && (s = n);
const [i, o] = e((() => [ i => t.on(i, s), () => s.reduce(((s, i) => s + "_" + (0 /* Event.RANGE */ === i ? t.to ** 2 + t.from : 1 /* Event.SCROLL_SIZE */ === i ? t.scrollSize : t.sizesHash)), "") ]), [ t, s ]);
h(i, o, o);
}, P = (t, s) => R((() => (t.setScroller(s), () => t.setScroller(null))), [ t, s ]), W = t => (H(t.model, t.events),
t.children()), B = 0 /* Event.RANGE */ , L = 1 /* Event.SCROLL_SIZE */ , q = 2 /* Event.SIZES */;
export { n as EVT_ALL, B as EVT_RANGE, L as EVT_SCROLL_SIZE, q as EVT_SIZES, W as Subscription, C as VirtualScroller, o as mapVisibleRange, H as useComponentSubscription, P as useScroller, K as useSubscription, F as useVirtual, I as useVirtualModel };
//# sourceMappingURL=index.js.map