selective-ui 1.1.6 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/selective-ui.css +7 -1
- package/dist/selective-ui.css.map +1 -1
- package/dist/selective-ui.esm.js +794 -57
- package/dist/selective-ui.esm.js.map +1 -1
- package/dist/selective-ui.esm.min.js +2 -2
- package/dist/selective-ui.esm.min.js.br +0 -0
- package/dist/selective-ui.min.css +1 -1
- package/dist/selective-ui.min.css.br +0 -0
- package/dist/selective-ui.min.js +2 -2
- package/dist/selective-ui.min.js.br +0 -0
- package/dist/selective-ui.umd.js +795 -58
- package/dist/selective-ui.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/css/components/popup.css +7 -1
- package/src/ts/adapter/mixed-adapter.ts +29 -18
- package/src/ts/components/empty-state.ts +5 -4
- package/src/ts/components/loading-state.ts +4 -4
- package/src/ts/components/option-handle.ts +4 -4
- package/src/ts/components/popup.ts +35 -6
- package/src/ts/components/searchbox.ts +2 -0
- package/src/ts/components/selectbox.ts +23 -9
- package/src/ts/core/base/adapter.ts +8 -5
- package/src/ts/core/base/model.ts +19 -1
- package/src/ts/core/base/recyclerview.ts +3 -1
- package/src/ts/core/base/virtual-recyclerview.ts +763 -0
- package/src/ts/core/model-manager.ts +24 -16
- package/src/ts/core/search-controller.ts +5 -8
- package/src/ts/models/option-model.ts +22 -3
- package/src/ts/services/effector.ts +7 -7
- package/src/ts/types/components/state.box.type.ts +1 -18
- package/src/ts/types/core/base/adapter.type.ts +14 -0
- package/src/ts/types/core/base/model.type.ts +5 -0
- package/src/ts/types/core/base/recyclerview.type.ts +3 -1
- package/src/ts/types/core/base/view.type.ts +6 -0
- package/src/ts/types/core/base/virtual-recyclerview.type.ts +66 -0
- package/src/ts/types/utils/istorage.type.ts +1 -0
- package/src/ts/utils/istorage.ts +3 -2
- package/src/ts/utils/libs.ts +26 -25
- package/src/ts/utils/selective.ts +7 -7
- package/src/ts/views/option-view.ts +8 -8
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { ModelContract } from "src/ts/types/core/base/model.type";
|
|
2
|
+
import { RecyclerView } from "./recyclerview";
|
|
3
|
+
import { AdapterContract } from "src/ts/types/core/base/adapter.type";
|
|
4
|
+
import { Libs } from "src/ts/utils/libs";
|
|
5
|
+
import { VirtualOptions, VirtualRecyclerViewTags } from "src/ts/types/core/base/virtual-recyclerview.type";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fenwick tree (Binary Indexed Tree) for efficient prefix sum queries.
|
|
9
|
+
* Supports O(log n) update and query operations for cumulative item heights.
|
|
10
|
+
* Uses 1-based indexing internally for BIT operations.
|
|
11
|
+
*/
|
|
12
|
+
class Fenwick {
|
|
13
|
+
private bit: number[] = [];
|
|
14
|
+
private n = 0;
|
|
15
|
+
|
|
16
|
+
constructor(n = 0) { this.reset(n); }
|
|
17
|
+
|
|
18
|
+
/** Resets tree to new size, clearing all values. */
|
|
19
|
+
reset(n: number) {
|
|
20
|
+
this.n = n;
|
|
21
|
+
this.bit = new Array(n + 1).fill(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Adds delta to element at 1-based index i. */
|
|
25
|
+
add(i: number, delta: number) {
|
|
26
|
+
for (let x = i; x <= this.n; x += x & -x) this.bit[x] += delta;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Returns prefix sum for range [1..i]. */
|
|
30
|
+
sum(i: number): number {
|
|
31
|
+
let s = 0;
|
|
32
|
+
for (let x = i; x > 0; x -= x & -x) s += this.bit[x];
|
|
33
|
+
return s;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Returns sum in range [l..r] (1-based, inclusive). */
|
|
37
|
+
rangeSum(l: number, r: number): number {
|
|
38
|
+
return r < l ? 0 : this.sum(r) - this.sum(l - 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Builds tree from 0-based array in O(n log n). */
|
|
42
|
+
buildFrom(arr: number[]) {
|
|
43
|
+
this.reset(arr.length);
|
|
44
|
+
arr.forEach((val, i) => this.add(i + 1, val));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Binary search to find largest index where prefix sum <= target.
|
|
49
|
+
* Returns count of items that fit within target height.
|
|
50
|
+
*/
|
|
51
|
+
lowerBoundPrefix(target: number): number {
|
|
52
|
+
let idx = 0, bitMask = 1;
|
|
53
|
+
while (bitMask << 1 <= this.n) bitMask <<= 1;
|
|
54
|
+
|
|
55
|
+
let cur = 0;
|
|
56
|
+
for (let step = bitMask; step !== 0; step >>= 1) {
|
|
57
|
+
const next = idx + step;
|
|
58
|
+
if (next <= this.n && cur + this.bit[next] <= target) {
|
|
59
|
+
idx = next;
|
|
60
|
+
cur += this.bit[next];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return idx;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Virtual RecyclerView with efficient windowing and dynamic height support.
|
|
69
|
+
*
|
|
70
|
+
* Only renders items visible in viewport plus overscan buffer, using padding
|
|
71
|
+
* elements to simulate scroll height. Supports variable item heights with
|
|
72
|
+
* adaptive estimation and maintains scroll position during height changes.
|
|
73
|
+
*
|
|
74
|
+
* @template TItem - Model type for list items
|
|
75
|
+
* @template TAdapter - Adapter managing item views
|
|
76
|
+
*/
|
|
77
|
+
export class VirtualRecyclerView<
|
|
78
|
+
TItem extends ModelContract<any, any>,
|
|
79
|
+
TAdapter extends AdapterContract<TItem>
|
|
80
|
+
> extends RecyclerView<TItem, TAdapter> {
|
|
81
|
+
private opts: Required<VirtualOptions> = {
|
|
82
|
+
scrollEl: undefined as HTMLElement,
|
|
83
|
+
estimateItemHeight: 36,
|
|
84
|
+
overscan: 8,
|
|
85
|
+
dynamicHeights: true,
|
|
86
|
+
adaptiveEstimate: true,
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
private PadTop!: HTMLDivElement;
|
|
90
|
+
private ItemsHost!: HTMLDivElement;
|
|
91
|
+
private PadBottom!: HTMLDivElement;
|
|
92
|
+
private scrollEl!: HTMLElement;
|
|
93
|
+
|
|
94
|
+
private heightCache: Array<number | undefined> = [];
|
|
95
|
+
private fenwick = new Fenwick(0);
|
|
96
|
+
private created = new Map<number, HTMLElement>();
|
|
97
|
+
|
|
98
|
+
private firstMeasured = false;
|
|
99
|
+
private start = 0;
|
|
100
|
+
private end = -1;
|
|
101
|
+
private resizeObs?: ResizeObserver;
|
|
102
|
+
|
|
103
|
+
private _rafId: number | null = null;
|
|
104
|
+
private _measureRaf: number | null = null;
|
|
105
|
+
private _updating = false;
|
|
106
|
+
private _suppressResize = false;
|
|
107
|
+
private _lastRenderCount = 0;
|
|
108
|
+
private _suspended = false;
|
|
109
|
+
private _boundOnScroll?: () => void;
|
|
110
|
+
private _resumeResizeAfter = false;
|
|
111
|
+
|
|
112
|
+
private _stickyCacheTick = 0;
|
|
113
|
+
private _stickyCacheVal = 0;
|
|
114
|
+
|
|
115
|
+
private measuredSum = 0;
|
|
116
|
+
private measuredCount = 0;
|
|
117
|
+
|
|
118
|
+
private static readonly EPS = 0.5;
|
|
119
|
+
private static readonly ATTR_INDEX = "data-vindex";
|
|
120
|
+
|
|
121
|
+
/** Creates virtual recycler view with optional root element. */
|
|
122
|
+
constructor(viewElement: HTMLDivElement | null = null) {
|
|
123
|
+
super(viewElement);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Updates virtualization settings (overscan, heights, etc). */
|
|
127
|
+
public configure(opts: Partial<VirtualOptions>) {
|
|
128
|
+
this.opts = { ...this.opts, ...opts } as Required<VirtualOptions>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Binds adapter and initializes virtualization scaffold.
|
|
133
|
+
* Removes previous adapter if exists, sets up scroll listeners and DOM structure.
|
|
134
|
+
*/
|
|
135
|
+
override setAdapter(adapter: TAdapter) {
|
|
136
|
+
if (this.adapter) this.dispose();
|
|
137
|
+
|
|
138
|
+
super.setAdapter(adapter);
|
|
139
|
+
adapter.recyclerView = this;
|
|
140
|
+
|
|
141
|
+
if (!this.viewElement) return;
|
|
142
|
+
|
|
143
|
+
this.viewElement.replaceChildren();
|
|
144
|
+
|
|
145
|
+
const nodeMounted = Libs.mountNode({
|
|
146
|
+
PadTop: { tag: { node: "div", classList: "selective-ui-virtual-pad-top" } },
|
|
147
|
+
ItemsHost: { tag: { node: "div", classList: "selective-ui-virtual-items" } },
|
|
148
|
+
PadBottom: { tag: { node: "div", classList: "selective-ui-virtual-pad-bottom" } },
|
|
149
|
+
}, this.viewElement) as VirtualRecyclerViewTags;
|
|
150
|
+
|
|
151
|
+
this.PadTop = nodeMounted.PadTop;
|
|
152
|
+
this.ItemsHost = nodeMounted.ItemsHost;
|
|
153
|
+
this.PadBottom = nodeMounted.PadBottom;
|
|
154
|
+
|
|
155
|
+
this.scrollEl = this.opts.scrollEl
|
|
156
|
+
?? (this.viewElement.closest(".selective-ui-popup") as HTMLElement)
|
|
157
|
+
?? (this.viewElement.parentElement as HTMLElement);
|
|
158
|
+
|
|
159
|
+
if (!this.scrollEl) throw new Error("VirtualRecyclerView: scrollEl not found");
|
|
160
|
+
|
|
161
|
+
this._boundOnScroll = this.onScroll.bind(this);
|
|
162
|
+
this.scrollEl.addEventListener("scroll", this._boundOnScroll, { passive: true });
|
|
163
|
+
|
|
164
|
+
this.refresh(false);
|
|
165
|
+
this.attachResizeObserverOnce();
|
|
166
|
+
(adapter as any)?.onVisibilityChanged?.(() => this.refreshItem());
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pauses scroll/resize processing to prevent updates during batch operations.
|
|
171
|
+
* Cancels pending frames and disconnects observers.
|
|
172
|
+
*/
|
|
173
|
+
public suspend() {
|
|
174
|
+
this._suspended = true;
|
|
175
|
+
this.cancelFrames();
|
|
176
|
+
|
|
177
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
178
|
+
this.scrollEl.removeEventListener("scroll", this._boundOnScroll);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (this.resizeObs) {
|
|
182
|
+
this.resizeObs.disconnect();
|
|
183
|
+
this._resumeResizeAfter = true;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resumes scroll/resize processing after suspension.
|
|
189
|
+
* Re-attaches listeners and schedules window update.
|
|
190
|
+
*/
|
|
191
|
+
public resume() {
|
|
192
|
+
this._suspended = false;
|
|
193
|
+
|
|
194
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
195
|
+
this.scrollEl.addEventListener("scroll", this._boundOnScroll, { passive: true });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (this._resumeResizeAfter) {
|
|
199
|
+
this.attachResizeObserverOnce();
|
|
200
|
+
this._resumeResizeAfter = false;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
this.scheduleUpdateWindow();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Rebuilds internal state and schedules render update.
|
|
208
|
+
* Probes initial item height on first run, rebuilds Fenwick tree.
|
|
209
|
+
*
|
|
210
|
+
* @param isUpdate - True if called from data update, false on initial setup
|
|
211
|
+
*/
|
|
212
|
+
override refresh(isUpdate: boolean): void {
|
|
213
|
+
if (!this.adapter || !this.viewElement) return;
|
|
214
|
+
if (!isUpdate) this.refreshItem();
|
|
215
|
+
|
|
216
|
+
const count = this.adapter.itemCount();
|
|
217
|
+
this._lastRenderCount = count;
|
|
218
|
+
|
|
219
|
+
if (count === 0) {
|
|
220
|
+
this.resetState();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
this.heightCache.length = count;
|
|
225
|
+
|
|
226
|
+
if (!this.firstMeasured) {
|
|
227
|
+
this.probeInitialHeight();
|
|
228
|
+
this.firstMeasured = true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
this.rebuildFenwick(count);
|
|
232
|
+
this.scheduleUpdateWindow();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Ensures item at index is rendered and optionally scrolls into view.
|
|
237
|
+
* Useful for programmatic navigation to specific items.
|
|
238
|
+
*/
|
|
239
|
+
public ensureRendered(index: number, opt?: { scrollIntoView?: boolean }) {
|
|
240
|
+
this.mountRange(index, index);
|
|
241
|
+
if (opt?.scrollIntoView) this.scrollToIndex(index);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Scrolls container to make item at index visible.
|
|
246
|
+
* Calculates target scroll position accounting for container offset.
|
|
247
|
+
*/
|
|
248
|
+
public scrollToIndex(index: number) {
|
|
249
|
+
const count = this.adapter?.itemCount?.() ?? 0;
|
|
250
|
+
if (count <= 0) return;
|
|
251
|
+
|
|
252
|
+
const topInContainer = this.offsetTopOf(index);
|
|
253
|
+
const containerTop = this.containerTopInScroll();
|
|
254
|
+
const target = containerTop + topInContainer;
|
|
255
|
+
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
256
|
+
|
|
257
|
+
this.scrollEl.scrollTop = Math.min(Math.max(0, target), maxScroll);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Cleans up all resources: listeners, observers, DOM elements.
|
|
262
|
+
* Call before removing component to prevent memory leaks.
|
|
263
|
+
*/
|
|
264
|
+
public dispose() {
|
|
265
|
+
this.cancelFrames();
|
|
266
|
+
|
|
267
|
+
if (this.scrollEl && this._boundOnScroll) {
|
|
268
|
+
this.scrollEl.removeEventListener("scroll", this._boundOnScroll);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.resizeObs?.disconnect();
|
|
272
|
+
this.created.forEach(el => el.remove());
|
|
273
|
+
this.created.clear();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Hard reset after visibility changes (e.g., search/filter cleared).
|
|
278
|
+
* Rebuilds all height structures and remounts visible window.
|
|
279
|
+
* Essential for fixing padding calculations after bulk visibility changes.
|
|
280
|
+
*/
|
|
281
|
+
public refreshItem() {
|
|
282
|
+
if (!this.adapter) return;
|
|
283
|
+
const count = this.adapter.itemCount();
|
|
284
|
+
if (count <= 0) return;
|
|
285
|
+
|
|
286
|
+
this.suspend();
|
|
287
|
+
this.resetState();
|
|
288
|
+
this.cleanupInvisibleItems();
|
|
289
|
+
this.recomputeMeasuredStats(count);
|
|
290
|
+
this.rebuildFenwick(count);
|
|
291
|
+
this.start = 0;
|
|
292
|
+
this.end = -1;
|
|
293
|
+
this.resume();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Cancels all pending animation frames. */
|
|
297
|
+
private cancelFrames() {
|
|
298
|
+
if (this._rafId != null) {
|
|
299
|
+
cancelAnimationFrame(this._rafId);
|
|
300
|
+
this._rafId = null;
|
|
301
|
+
}
|
|
302
|
+
if (this._measureRaf != null) {
|
|
303
|
+
cancelAnimationFrame(this._measureRaf);
|
|
304
|
+
this._measureRaf = null;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Resets all internal state: DOM, caches, measurements. */
|
|
309
|
+
private resetState() {
|
|
310
|
+
this.created.forEach(el => el.remove());
|
|
311
|
+
this.created.clear();
|
|
312
|
+
this.heightCache = [];
|
|
313
|
+
this.fenwick.reset(0);
|
|
314
|
+
this.PadTop.style.height = "0px";
|
|
315
|
+
this.PadBottom.style.height = "0px";
|
|
316
|
+
this.firstMeasured = false;
|
|
317
|
+
this.measuredSum = 0;
|
|
318
|
+
this.measuredCount = 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Measures first item to set initial height estimate.
|
|
323
|
+
* Removes probe element if dynamic heights disabled.
|
|
324
|
+
*/
|
|
325
|
+
private probeInitialHeight() {
|
|
326
|
+
const probe = 0;
|
|
327
|
+
this.mountIndexOnce(probe);
|
|
328
|
+
|
|
329
|
+
const el = this.created.get(probe);
|
|
330
|
+
if (!el) return;
|
|
331
|
+
|
|
332
|
+
const h = this.measureOuterHeight(el);
|
|
333
|
+
if (!isNaN(h)) this.opts.estimateItemHeight = h;
|
|
334
|
+
|
|
335
|
+
if (!this.opts.dynamicHeights) {
|
|
336
|
+
el.remove();
|
|
337
|
+
this.created.delete(probe);
|
|
338
|
+
const item = this.adapter.items[probe];
|
|
339
|
+
if (item) {
|
|
340
|
+
item.isInit = false;
|
|
341
|
+
item.view = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Checks if item is visible (not filtered/hidden).
|
|
348
|
+
* Defaults to visible if property undefined.
|
|
349
|
+
*/
|
|
350
|
+
private isIndexVisible(index: number): boolean {
|
|
351
|
+
const item = this.adapter?.items?.[index];
|
|
352
|
+
return item?.visible ?? true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Finds next visible item index starting from given index.
|
|
357
|
+
* Returns -1 if no visible items found.
|
|
358
|
+
*/
|
|
359
|
+
private nextVisibleFrom(index: number, count: number): number {
|
|
360
|
+
for (let i = Math.max(0, index); i < count; i++) {
|
|
361
|
+
if (this.isIndexVisible(i)) return i;
|
|
362
|
+
}
|
|
363
|
+
return -1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Recalculates total measured height and count from cache.
|
|
368
|
+
* Only counts visible items for adaptive estimation.
|
|
369
|
+
*/
|
|
370
|
+
private recomputeMeasuredStats(count: number) {
|
|
371
|
+
this.measuredSum = 0;
|
|
372
|
+
this.measuredCount = 0;
|
|
373
|
+
for (let i = 0; i < count; i++) {
|
|
374
|
+
if (!this.isIndexVisible(i)) continue;
|
|
375
|
+
const h = this.heightCache[i];
|
|
376
|
+
if (h != null) {
|
|
377
|
+
this.measuredSum += h;
|
|
378
|
+
this.measuredCount++;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Returns view container's top offset relative to scroll container. */
|
|
384
|
+
private containerTopInScroll(): number {
|
|
385
|
+
const a = this.viewElement!.getBoundingClientRect();
|
|
386
|
+
const b = this.scrollEl.getBoundingClientRect();
|
|
387
|
+
return a.top - b.top + this.scrollEl.scrollTop;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Returns sticky header height with 16ms cache to avoid DOM thrashing.
|
|
392
|
+
* Used to adjust viewport calculations.
|
|
393
|
+
*/
|
|
394
|
+
private stickyTopHeight(): number {
|
|
395
|
+
const now = performance.now();
|
|
396
|
+
if (now - this._stickyCacheTick < 16) return this._stickyCacheVal;
|
|
397
|
+
|
|
398
|
+
const sticky = this.scrollEl.querySelector(".selective-ui-option-handle:not(.hide)") as HTMLElement | null;
|
|
399
|
+
this._stickyCacheVal = sticky?.offsetHeight ?? 0;
|
|
400
|
+
this._stickyCacheTick = now;
|
|
401
|
+
return this._stickyCacheVal;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Schedules window update on next frame if not already scheduled. */
|
|
405
|
+
private scheduleUpdateWindow() {
|
|
406
|
+
if (this._rafId != null || this._suspended) return;
|
|
407
|
+
this._rafId = requestAnimationFrame(() => {
|
|
408
|
+
this._rafId = null;
|
|
409
|
+
this.updateWindowInternal();
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Measures element's total height including margins.
|
|
415
|
+
* Used for accurate item height tracking.
|
|
416
|
+
*/
|
|
417
|
+
private measureOuterHeight(el: HTMLElement): number {
|
|
418
|
+
const rect = el.getBoundingClientRect();
|
|
419
|
+
const style = getComputedStyle(el);
|
|
420
|
+
const mt = parseFloat(style.marginTop) || 0;
|
|
421
|
+
const mb = parseFloat(style.marginBottom) || 0;
|
|
422
|
+
return Math.max(1, rect.height + mt + mb);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Returns height estimate for unmeasured items.
|
|
427
|
+
* Uses adaptive average if enabled, otherwise fixed estimate.
|
|
428
|
+
*/
|
|
429
|
+
private getEstimate(): number {
|
|
430
|
+
if (this.opts.adaptiveEstimate && this.measuredCount > 0) {
|
|
431
|
+
return Math.max(1, this.measuredSum / this.measuredCount);
|
|
432
|
+
}
|
|
433
|
+
return this.opts.estimateItemHeight;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Rebuilds Fenwick tree with current heights and estimates.
|
|
438
|
+
* Invisible items get 0 height, others use cached or estimated height.
|
|
439
|
+
*/
|
|
440
|
+
private rebuildFenwick(count: number) {
|
|
441
|
+
const est = this.getEstimate();
|
|
442
|
+
const arr = Array.from({ length: count }, (_, i) =>
|
|
443
|
+
this.isIndexVisible(i) ? (this.heightCache[i] ?? est) : 0
|
|
444
|
+
);
|
|
445
|
+
this.fenwick.buildFrom(arr);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Updates cached height at index and applies delta to Fenwick tree.
|
|
450
|
+
* Updates running average for adaptive estimation.
|
|
451
|
+
*
|
|
452
|
+
* @returns True if height changed beyond epsilon threshold
|
|
453
|
+
*/
|
|
454
|
+
private updateHeightAt(index: number, newH: number): boolean {
|
|
455
|
+
if (!this.isIndexVisible(index)) return false;
|
|
456
|
+
|
|
457
|
+
const est = this.getEstimate();
|
|
458
|
+
const oldH = this.heightCache[index] ?? est;
|
|
459
|
+
|
|
460
|
+
if (Math.abs(newH - oldH) <= VirtualRecyclerView.EPS) return false;
|
|
461
|
+
|
|
462
|
+
const prevMeasured = this.heightCache[index];
|
|
463
|
+
if (prevMeasured == null) {
|
|
464
|
+
this.measuredSum += newH;
|
|
465
|
+
this.measuredCount++;
|
|
466
|
+
} else {
|
|
467
|
+
this.measuredSum += newH - prevMeasured;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
this.heightCache[index] = newH;
|
|
471
|
+
this.fenwick.add(index + 1, newH - oldH);
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Finds first visible item at or after scroll offset.
|
|
477
|
+
* Uses Fenwick binary search then adjusts for visibility.
|
|
478
|
+
*/
|
|
479
|
+
private findFirstVisibleIndex(stRel: number, count: number): number {
|
|
480
|
+
const k = this.fenwick.lowerBoundPrefix(Math.max(0, stRel));
|
|
481
|
+
const raw = Math.min(count - 1, k);
|
|
482
|
+
const v = this.nextVisibleFrom(raw, count);
|
|
483
|
+
return v === -1 ? Math.max(0, raw) : v;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Inserts element into DOM maintaining index order.
|
|
488
|
+
* Tries adjacent siblings first, then scans for insertion point.
|
|
489
|
+
*/
|
|
490
|
+
private insertIntoHostByIndex(index: number, el: HTMLElement) {
|
|
491
|
+
el.setAttribute(VirtualRecyclerView.ATTR_INDEX, String(index));
|
|
492
|
+
|
|
493
|
+
const prev = this.created.get(index - 1);
|
|
494
|
+
if (prev?.parentElement === this.ItemsHost) {
|
|
495
|
+
prev.after(el);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const next = this.created.get(index + 1);
|
|
500
|
+
if (next?.parentElement === this.ItemsHost) {
|
|
501
|
+
this.ItemsHost.insertBefore(el, next);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const children = Array.from(this.ItemsHost.children) as HTMLElement[];
|
|
506
|
+
for (const child of children) {
|
|
507
|
+
const v = child.getAttribute(VirtualRecyclerView.ATTR_INDEX);
|
|
508
|
+
if (v && Number(v) > index) {
|
|
509
|
+
this.ItemsHost.insertBefore(el, child);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
this.ItemsHost.appendChild(el);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Ensures element is in correct DOM position for its index.
|
|
518
|
+
* Reinserts if siblings indicate wrong position.
|
|
519
|
+
*/
|
|
520
|
+
private ensureDomOrder(index: number, el: HTMLElement) {
|
|
521
|
+
if (el.parentElement !== this.ItemsHost) {
|
|
522
|
+
this.insertIntoHostByIndex(index, el);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
el.setAttribute(VirtualRecyclerView.ATTR_INDEX, String(index));
|
|
527
|
+
|
|
528
|
+
const prev = el.previousElementSibling as HTMLElement | null;
|
|
529
|
+
const next = el.nextElementSibling as HTMLElement | null;
|
|
530
|
+
|
|
531
|
+
const needsReorder =
|
|
532
|
+
(prev && Number(prev.getAttribute(VirtualRecyclerView.ATTR_INDEX)) > index) ||
|
|
533
|
+
(next && Number(next.getAttribute(VirtualRecyclerView.ATTR_INDEX)) < index);
|
|
534
|
+
|
|
535
|
+
if (needsReorder) {
|
|
536
|
+
el.remove();
|
|
537
|
+
this.insertIntoHostByIndex(index, el);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Attaches ResizeObserver to measure items when they resize.
|
|
543
|
+
* Singleton pattern - only creates once per instance.
|
|
544
|
+
*/
|
|
545
|
+
private attachResizeObserverOnce() {
|
|
546
|
+
if (this.resizeObs) return;
|
|
547
|
+
|
|
548
|
+
this.resizeObs = new ResizeObserver(() => {
|
|
549
|
+
if (this._suppressResize || this._suspended || !this.adapter || this._measureRaf != null) return;
|
|
550
|
+
|
|
551
|
+
this._measureRaf = requestAnimationFrame(() => {
|
|
552
|
+
this._measureRaf = null;
|
|
553
|
+
this.measureVisibleAndUpdate();
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
this.resizeObs.observe(this.ItemsHost);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Measures all currently rendered items and updates height cache.
|
|
562
|
+
* Triggers window update if any heights changed.
|
|
563
|
+
*/
|
|
564
|
+
private measureVisibleAndUpdate() {
|
|
565
|
+
if (!this.adapter) return;
|
|
566
|
+
const count = this.adapter.itemCount();
|
|
567
|
+
if (count <= 0) return;
|
|
568
|
+
|
|
569
|
+
let changed = false;
|
|
570
|
+
|
|
571
|
+
for (let i = this.start; i <= this.end; i++) {
|
|
572
|
+
if (!this.isIndexVisible(i)) continue;
|
|
573
|
+
|
|
574
|
+
const item = this.adapter.items[i];
|
|
575
|
+
const el = item?.view?.getView?.() as HTMLElement | undefined;
|
|
576
|
+
if (!el) continue;
|
|
577
|
+
|
|
578
|
+
const newH = this.measureOuterHeight(el);
|
|
579
|
+
if (this.updateHeightAt(i, newH)) changed = true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (changed) {
|
|
583
|
+
if (this.opts.adaptiveEstimate) this.rebuildFenwick(count);
|
|
584
|
+
this.scheduleUpdateWindow();
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Scroll event handler - schedules render update. */
|
|
589
|
+
private onScroll() {
|
|
590
|
+
this.scheduleUpdateWindow();
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Core rendering logic - calculates and updates visible window.
|
|
595
|
+
*
|
|
596
|
+
* 1. Calculates viewport bounds accounting for scroll and sticky headers
|
|
597
|
+
* 2. Uses anchor item to prevent scroll jumping during height changes
|
|
598
|
+
* 3. Determines start/end indices with overscan buffer
|
|
599
|
+
* 4. Mounts/unmounts items as needed
|
|
600
|
+
* 5. Measures visible items if dynamic heights enabled
|
|
601
|
+
* 6. Updates padding elements to maintain total scroll height
|
|
602
|
+
* 7. Adjusts scroll position to maintain anchor item position
|
|
603
|
+
*/
|
|
604
|
+
private updateWindowInternal() {
|
|
605
|
+
if (this._updating || this._suspended) return;
|
|
606
|
+
this._updating = true;
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
if (!this.adapter) return;
|
|
610
|
+
|
|
611
|
+
const count = this.adapter.itemCount();
|
|
612
|
+
if (count <= 0) return;
|
|
613
|
+
|
|
614
|
+
if (this._lastRenderCount !== count) {
|
|
615
|
+
this._lastRenderCount = count;
|
|
616
|
+
this.heightCache.length = count;
|
|
617
|
+
this.rebuildFenwick(count);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const containerTop = this.containerTopInScroll();
|
|
621
|
+
const stRel = Math.max(0, this.scrollEl.scrollTop - containerTop);
|
|
622
|
+
const stickyH = this.stickyTopHeight();
|
|
623
|
+
const vhEff = Math.max(0, this.scrollEl.clientHeight - stickyH);
|
|
624
|
+
|
|
625
|
+
const anchorIndex = this.findFirstVisibleIndex(stRel, count);
|
|
626
|
+
const anchorTop = this.offsetTopOf(anchorIndex);
|
|
627
|
+
const anchorDelta = containerTop + anchorTop - this.scrollEl.scrollTop;
|
|
628
|
+
|
|
629
|
+
const firstVis = this.findFirstVisibleIndex(stRel, count);
|
|
630
|
+
if (firstVis === -1) {
|
|
631
|
+
this.resetState();
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const est = this.getEstimate();
|
|
636
|
+
const overscanPx = this.opts.overscan * est;
|
|
637
|
+
|
|
638
|
+
let startIndex = this.nextVisibleFrom(
|
|
639
|
+
Math.min(count - 1, this.fenwick.lowerBoundPrefix(Math.max(0, stRel - overscanPx))),
|
|
640
|
+
count
|
|
641
|
+
) ?? firstVis;
|
|
642
|
+
|
|
643
|
+
let endIndex = Math.min(count - 1, this.fenwick.lowerBoundPrefix(stRel + vhEff + overscanPx));
|
|
644
|
+
|
|
645
|
+
if (startIndex === this.start && endIndex === this.end) return;
|
|
646
|
+
|
|
647
|
+
this.start = startIndex;
|
|
648
|
+
this.end = endIndex;
|
|
649
|
+
|
|
650
|
+
this._suppressResize = true;
|
|
651
|
+
try {
|
|
652
|
+
this.mountRange(this.start, this.end);
|
|
653
|
+
this.unmountOutside(this.start, this.end);
|
|
654
|
+
|
|
655
|
+
if (this.opts.dynamicHeights) this.measureVisibleAndUpdate();
|
|
656
|
+
|
|
657
|
+
const topPx = this.offsetTopOf(this.start);
|
|
658
|
+
const windowPx = this.windowHeight(this.start, this.end);
|
|
659
|
+
const total = this.totalHeight(count);
|
|
660
|
+
const bottomPx = Math.max(0, total - topPx - windowPx);
|
|
661
|
+
|
|
662
|
+
this.PadTop.style.height = `${topPx}px`;
|
|
663
|
+
this.PadBottom.style.height = `${bottomPx}px`;
|
|
664
|
+
} finally {
|
|
665
|
+
this._suppressResize = false;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const anchorTopNew = this.offsetTopOf(anchorIndex);
|
|
669
|
+
const targetScroll = this.containerTopInScroll() + anchorTopNew - anchorDelta;
|
|
670
|
+
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
671
|
+
const clamped = Math.min(Math.max(0, targetScroll), maxScroll);
|
|
672
|
+
|
|
673
|
+
if (Math.abs(this.scrollEl.scrollTop - clamped) > 0.5) {
|
|
674
|
+
this.scrollEl.scrollTop = clamped;
|
|
675
|
+
}
|
|
676
|
+
} finally {
|
|
677
|
+
this._updating = false;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Mounts all items in inclusive range [start..end]. */
|
|
682
|
+
private mountRange(start: number, end: number) {
|
|
683
|
+
for (let i = start; i <= end; i++) this.mountIndexOnce(i);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Mounts single item, reusing existing element if available.
|
|
688
|
+
* Creates view holder on first mount, rebinds on subsequent renders.
|
|
689
|
+
*/
|
|
690
|
+
private mountIndexOnce(index: number) {
|
|
691
|
+
if (!this.isIndexVisible(index)) {
|
|
692
|
+
const existing = this.created.get(index);
|
|
693
|
+
if (existing?.parentElement === this.ItemsHost) existing.remove();
|
|
694
|
+
this.created.delete(index);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const item = this.adapter!.items[index];
|
|
699
|
+
if (!item) return;
|
|
700
|
+
const existing = this.created.get(index);
|
|
701
|
+
|
|
702
|
+
if (existing) {
|
|
703
|
+
if (!item?.view) {
|
|
704
|
+
existing.remove();
|
|
705
|
+
this.created.delete(index);
|
|
706
|
+
} else {
|
|
707
|
+
this.ensureDomOrder(index, existing);
|
|
708
|
+
this.adapter.onViewHolder(item, item.view, index);
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (!item.isInit) {
|
|
714
|
+
const viewer = this.adapter!.viewHolder(this.ItemsHost, item);
|
|
715
|
+
item.view = viewer;
|
|
716
|
+
this.adapter!.onViewHolder(item, viewer, index);
|
|
717
|
+
item.isInit = true;
|
|
718
|
+
} else if (item.view) {
|
|
719
|
+
this.adapter!.onViewHolder(item, item.view, index);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const el = item.view?.getView?.() as HTMLElement | undefined;
|
|
723
|
+
if (el) {
|
|
724
|
+
this.ensureDomOrder(index, el);
|
|
725
|
+
this.created.set(index, el);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Removes all mounted items outside [start..end] range. */
|
|
730
|
+
private unmountOutside(start: number, end: number) {
|
|
731
|
+
this.created.forEach((el, idx) => {
|
|
732
|
+
if (idx < start || idx > end) {
|
|
733
|
+
if (el.parentElement === this.ItemsHost) el.remove();
|
|
734
|
+
this.created.delete(idx);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Removes all items marked as invisible from DOM. */
|
|
740
|
+
private cleanupInvisibleItems() {
|
|
741
|
+
this.created.forEach((el, idx) => {
|
|
742
|
+
if (!this.isIndexVisible(idx)) {
|
|
743
|
+
if (el.parentElement === this.ItemsHost) el.remove();
|
|
744
|
+
this.created.delete(idx);
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Returns cumulative height from start to top of item at index. */
|
|
750
|
+
private offsetTopOf(index: number): number {
|
|
751
|
+
return this.fenwick.sum(index);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Returns total height of items in range [start..end]. */
|
|
755
|
+
private windowHeight(start: number, end: number): number {
|
|
756
|
+
return this.fenwick.rangeSum(start + 1, end + 1);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Returns total scrollable height for all items. */
|
|
760
|
+
private totalHeight(count: number): number {
|
|
761
|
+
return this.fenwick.sum(count);
|
|
762
|
+
}
|
|
763
|
+
}
|