UNPKG

@af-utils/react-virtual-headless

Version:

React components for rendering large scrollable data

376 lines (370 loc) 14.4 kB
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