selective-ui 1.2.3 → 1.2.5
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.map +1 -1
- package/dist/selective-ui.esm.js +5462 -1043
- 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.js +2 -2
- package/dist/selective-ui.min.js.br +0 -0
- package/dist/selective-ui.umd.js +5463 -1044
- package/dist/selective-ui.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/ts/adapter/mixed-adapter.ts +312 -65
- package/src/ts/components/accessorybox.ts +248 -28
- package/src/ts/components/directive.ts +91 -11
- package/src/ts/components/option-handle.ts +191 -28
- package/src/ts/components/placeholder.ts +111 -16
- package/src/ts/components/popup/empty-state.ts +162 -0
- package/src/ts/components/popup/loading-state.ts +160 -0
- package/src/ts/components/{popup.ts → popup/popup.ts} +167 -71
- package/src/ts/components/searchbox.ts +225 -20
- package/src/ts/components/selectbox.ts +498 -120
- package/src/ts/core/base/adapter.ts +200 -53
- package/src/ts/core/base/fenwick.ts +147 -0
- package/src/ts/core/base/lifecycle.ts +258 -0
- package/src/ts/core/base/model.ts +120 -31
- package/src/ts/core/base/recyclerview.ts +55 -18
- package/src/ts/core/base/view.ts +87 -19
- package/src/ts/core/base/virtual-recyclerview.ts +475 -202
- package/src/ts/core/model-manager.ts +166 -85
- package/src/ts/core/search-controller.ts +236 -38
- package/src/ts/global.ts +6 -6
- package/src/ts/index.ts +6 -6
- package/src/ts/models/group-model.ts +159 -32
- package/src/ts/models/option-model.ts +213 -54
- package/src/ts/services/dataset-observer.ts +72 -10
- package/src/ts/services/ea-observer.ts +92 -15
- package/src/ts/services/effector.ts +181 -32
- package/src/ts/services/refresher.ts +30 -6
- package/src/ts/services/resize-observer.ts +132 -15
- package/src/ts/services/select-observer.ts +115 -50
- package/src/ts/types/components/searchbox.type.ts +1 -1
- package/src/ts/types/core/base/adapter.type.ts +2 -1
- package/src/ts/types/core/base/lifecycle.type.ts +62 -0
- package/src/ts/types/core/base/model.type.ts +3 -1
- package/src/ts/types/core/base/recyclerview.type.ts +2 -8
- package/src/ts/types/core/base/view.type.ts +36 -24
- package/src/ts/types/utils/ievents.type.ts +6 -1
- package/src/ts/utils/callback-scheduler.ts +112 -34
- package/src/ts/utils/ievents.ts +91 -29
- package/src/ts/utils/istorage.ts +1 -1
- package/src/ts/utils/selective.ts +474 -88
- package/src/ts/views/group-view.ts +170 -21
- package/src/ts/views/option-view.ts +349 -68
- package/src/ts/components/empty-state.ts +0 -68
- package/src/ts/components/loading-state.ts +0 -66
- /package/src/css/components/{empty-state.css → popup/empty-state.css} +0 -0
- /package/src/css/components/{loading-state.css → popup/loading-state.css} +0 -0
- /package/src/css/components/{popup.css → popup/popup.css} +0 -0
- /package/src/css/{components/optgroup.css → views/group-view.css} +0 -0
- /package/src/css/{components/option.css → views/option-view.css} +0 -0
|
@@ -3,81 +3,72 @@ import { RecyclerView } from "./recyclerview";
|
|
|
3
3
|
import { AdapterContract } from "src/ts/types/core/base/adapter.type";
|
|
4
4
|
import { Libs } from "src/ts/utils/libs";
|
|
5
5
|
import { VirtualOptions, VirtualRecyclerViewTags } from "src/ts/types/core/base/virtual-recyclerview.type";
|
|
6
|
+
import { Lifecycle } from "./lifecycle";
|
|
7
|
+
import { LifecycleState } from "src/ts/types/core/base/lifecycle.type";
|
|
8
|
+
import { Fenwick } from "./fenwick";
|
|
6
9
|
|
|
7
10
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
11
|
+
* Virtualized RecyclerView with windowing and dynamic-height support.
|
|
12
|
+
*
|
|
13
|
+
* This recycler only keeps the **visible window** mounted in the DOM, plus an overscan buffer,
|
|
14
|
+
* while simulating the full scroll height using top/bottom padding elements.
|
|
15
|
+
*
|
|
16
|
+
* ### Responsibility
|
|
17
|
+
* - Maintain a viewport window `[start..end]` over adapter items and mount/unmount DOM accordingly.
|
|
18
|
+
* - Support **variable row heights** using measured outer heights (including vertical margins).
|
|
19
|
+
* - Provide stable scrolling under height changes via an **anchor correction** strategy.
|
|
20
|
+
* - Integrate with item visibility (filtering): invisible items are treated as height `0` and are not mounted.
|
|
21
|
+
*
|
|
22
|
+
* ### Virtualization strategy
|
|
23
|
+
* - **Prefix sums** over heights are maintained in a {@link Fenwick} tree:
|
|
24
|
+
* - `offsetTopOf(i)` → prefix sum for heights before item `i`
|
|
25
|
+
* - `findFirstVisibleIndex(scrollTop)` → lower-bound over prefix sums (then forward-scan to visible)
|
|
26
|
+
* - **Overscan** is expressed in item multiples and converted to pixels using the current estimate:
|
|
27
|
+
* `overscanPx = overscan * estimate`.
|
|
28
|
+
* - **Adaptive estimate** can be enabled to use the running average of measured items as the estimate.
|
|
29
|
+
*
|
|
30
|
+
* ### Dynamic heights (measurement)
|
|
31
|
+
* - When enabled, visible items are measured using `getBoundingClientRect()` + computed margins.
|
|
32
|
+
* - A {@link ResizeObserver} observes the host container and schedules re-measurement on the next animation frame.
|
|
33
|
+
* - Height updates are applied incrementally to the Fenwick tree in **O(log n)** per item.
|
|
34
|
+
*
|
|
35
|
+
* ### Anchor correction (scroll stability)
|
|
36
|
+
* - An "anchor index" (first visible item) is derived from the current scroll position.
|
|
37
|
+
* - After re-render and potential height changes, scrollTop is adjusted so the anchor remains visually stable,
|
|
38
|
+
* preventing "jumping" during measurement-driven reflows.
|
|
39
|
+
*
|
|
40
|
+
* ### Lifecycle / idempotency
|
|
41
|
+
* - Mounted scaffold elements are created when an adapter is set via {@link setAdapter}.
|
|
42
|
+
* - `refresh()` is safe to call repeatedly; it rebuilds internal structures and schedules a window update.
|
|
43
|
+
* - `destroy()` is idempotent once in {@link LifecycleState.DESTROYED} and removes scaffold DOM nodes.
|
|
44
|
+
*
|
|
45
|
+
* ### DOM side effects
|
|
46
|
+
* - Mutates DOM under `viewElement` by creating three nodes:
|
|
47
|
+
* - `PadTop`, `ItemsHost`, `PadBottom`
|
|
48
|
+
* - Mounts/unmounts item nodes inside `ItemsHost`
|
|
49
|
+
* - Attaches/removes a scroll listener on the resolved scroll container
|
|
50
|
+
* - Uses `scrollIntoView`/scrollTop assignments when asked to bring an item into view
|
|
51
|
+
*
|
|
52
|
+
* @template TItem - Model type for list items.
|
|
53
|
+
* @template TAdapter - Adapter providing view holders and binding logic.
|
|
54
|
+
*
|
|
55
|
+
* @extends {RecyclerView<TItem, TAdapter>}
|
|
56
|
+
* @see {@link VirtualOptions}
|
|
57
|
+
* @see {@link RecyclerView}
|
|
76
58
|
*/
|
|
77
59
|
export class VirtualRecyclerView<
|
|
78
60
|
TItem extends ModelContract<any, any>,
|
|
79
61
|
TAdapter extends AdapterContract<TItem>
|
|
80
62
|
> extends RecyclerView<TItem, TAdapter> {
|
|
63
|
+
/**
|
|
64
|
+
* Virtualization settings (materialized to `Required<VirtualOptions>`).
|
|
65
|
+
*
|
|
66
|
+
* - `scrollEl` : External scroll container (if omitted, inferred from DOM)
|
|
67
|
+
* - `estimateItemHeight` : Initial/fallback item height in pixels
|
|
68
|
+
* - `overscan` : Extra viewport height (in item multiples) rendered above/below
|
|
69
|
+
* - `dynamicHeights` : Enable measuring items with ResizeObserver
|
|
70
|
+
* - `adaptiveEstimate` : Use average of measured items as the running estimate
|
|
71
|
+
*/
|
|
81
72
|
private opts: Required<VirtualOptions> = {
|
|
82
73
|
scrollEl: undefined as HTMLElement,
|
|
83
74
|
estimateItemHeight: 36,
|
|
@@ -86,22 +77,42 @@ export class VirtualRecyclerView<
|
|
|
86
77
|
adaptiveEstimate: true,
|
|
87
78
|
};
|
|
88
79
|
|
|
80
|
+
/** Top padding element (simulates offscreen items above). */
|
|
89
81
|
private PadTop!: HTMLDivElement;
|
|
82
|
+
/** Host container where visible item elements are mounted. */
|
|
90
83
|
private ItemsHost!: HTMLDivElement;
|
|
84
|
+
/** Bottom padding element (simulates offscreen items below). */
|
|
91
85
|
private PadBottom!: HTMLDivElement;
|
|
86
|
+
/** Scroll container used for viewport calculations. */
|
|
92
87
|
private scrollEl!: HTMLElement;
|
|
93
88
|
|
|
89
|
+
/** Cache of measured heights per item index (undefined when not measured). */
|
|
94
90
|
private heightCache: Array<number | undefined> = [];
|
|
91
|
+
/**
|
|
92
|
+
* Fenwick tree storing current height values (in pixels).
|
|
93
|
+
* Invisible items are encoded as height 0.
|
|
94
|
+
*/
|
|
95
95
|
private fenwick = new Fenwick(0);
|
|
96
|
+
/**
|
|
97
|
+
* Map of currently mounted DOM elements keyed by item index.
|
|
98
|
+
* Used to avoid re-creating nodes and to manage ordering within the host.
|
|
99
|
+
*/
|
|
96
100
|
private created = new Map<number, HTMLElement>();
|
|
97
101
|
|
|
102
|
+
/** Whether an initial height probe has been performed. */
|
|
98
103
|
private firstMeasured = false;
|
|
104
|
+
/** Current window bounds (inclusive) in item index space. */
|
|
99
105
|
private start = 0;
|
|
106
|
+
/** Current window end (inclusive). -1 means not initialized. */
|
|
100
107
|
private end = -1;
|
|
108
|
+
/** Observer used to detect resize events that may change item heights. */
|
|
101
109
|
private resizeObs?: ResizeObserver;
|
|
102
110
|
|
|
111
|
+
/** Pending animation frame ids for window and measurement. */
|
|
103
112
|
private rafId: number | null = null;
|
|
104
113
|
private measureRaf: number | null = null;
|
|
114
|
+
|
|
115
|
+
/** Re-entrancy/suspension flags used to prevent feedback loops. */
|
|
105
116
|
private updating = false;
|
|
106
117
|
private suppressResize = false;
|
|
107
118
|
private lastRenderCount = 0;
|
|
@@ -109,30 +120,64 @@ export class VirtualRecyclerView<
|
|
|
109
120
|
private boundOnScroll?: () => void;
|
|
110
121
|
private resumeResizeAfter = false;
|
|
111
122
|
|
|
123
|
+
/** Small cache for sticky header height (≈16ms TTL) to limit layout reads. */
|
|
112
124
|
private stickyCacheTick = 0;
|
|
113
125
|
private stickyCacheVal = 0;
|
|
114
126
|
|
|
127
|
+
/** Stats for adaptive estimator (sum of measured heights / count of measured items). */
|
|
115
128
|
private measuredSum = 0;
|
|
116
129
|
private measuredCount = 0;
|
|
117
130
|
|
|
131
|
+
/** Epsilon threshold for height-change significance (px). */
|
|
118
132
|
private static readonly EPS = 0.5;
|
|
133
|
+
/** Attribute stored on each mounted element indicating its item index. */
|
|
119
134
|
private static readonly ATTR_INDEX = "data-vindex";
|
|
120
135
|
|
|
121
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Creates a virtual recycler view.
|
|
138
|
+
*
|
|
139
|
+
* Note: The virtualization scaffold is built when an adapter is set via {@link setAdapter}.
|
|
140
|
+
*
|
|
141
|
+
* @param {HTMLDivElement | null} [viewElement=null] - Optional root container for the recycler view.
|
|
142
|
+
*/
|
|
122
143
|
constructor(viewElement: HTMLDivElement | null = null) {
|
|
123
144
|
super(viewElement);
|
|
124
145
|
}
|
|
125
146
|
|
|
126
|
-
/**
|
|
127
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Updates virtualization settings (overscan, estimates, dynamic heights, etc.).
|
|
149
|
+
*
|
|
150
|
+
* This only updates internal configuration; consumers should call {@link refresh}
|
|
151
|
+
* to apply changes immediately if needed.
|
|
152
|
+
*
|
|
153
|
+
* @param {Partial<VirtualOptions>} opts - Partial configuration merged into current options.
|
|
154
|
+
* @returns {void}
|
|
155
|
+
*/
|
|
156
|
+
public configure(opts: Partial<VirtualOptions>): void {
|
|
128
157
|
this.opts = { ...this.opts, ...opts } as Required<VirtualOptions>;
|
|
129
158
|
}
|
|
130
159
|
|
|
131
|
-
/**
|
|
132
|
-
* Binds adapter and initializes virtualization scaffold.
|
|
133
|
-
*
|
|
160
|
+
/**
|
|
161
|
+
* Binds an adapter and initializes the virtualization scaffold.
|
|
162
|
+
*
|
|
163
|
+
* ### Flow
|
|
164
|
+
* 1) Dispose previous listeners/observers if an adapter was already attached
|
|
165
|
+
* 2) Call `super.setAdapter(adapter)` to wire base recycler state
|
|
166
|
+
* 3) Build the scaffold elements (PadTop, ItemsHost, PadBottom)
|
|
167
|
+
* 4) Resolve `scrollEl` (configured `opts.scrollEl` → nearest popup → parentElement)
|
|
168
|
+
* 5) Attach scroll listener, perform initial refresh, attach resize observer
|
|
169
|
+
* 6) Subscribe to adapter visibility updates (if supported) to hard-refresh windowing state
|
|
170
|
+
*
|
|
171
|
+
* DOM side effects:
|
|
172
|
+
* - Clears `viewElement` children and replaces with scaffold nodes.
|
|
173
|
+
* - Attaches a `scroll` listener to `scrollEl` (`passive: true`).
|
|
174
|
+
*
|
|
175
|
+
* @param {TAdapter} adapter - Adapter that provides items and view binding.
|
|
176
|
+
* @returns {void}
|
|
177
|
+
* @throws {Error} If no scroll container can be resolved.
|
|
178
|
+
* @override
|
|
134
179
|
*/
|
|
135
|
-
public override setAdapter(adapter: TAdapter) {
|
|
180
|
+
public override setAdapter(adapter: TAdapter): void {
|
|
136
181
|
if (this.adapter) this.dispose();
|
|
137
182
|
|
|
138
183
|
super.setAdapter(adapter);
|
|
@@ -143,17 +188,17 @@ export class VirtualRecyclerView<
|
|
|
143
188
|
this.viewElement.replaceChildren();
|
|
144
189
|
|
|
145
190
|
const nodeMounted = Libs.mountNode({
|
|
146
|
-
PadTop:
|
|
147
|
-
ItemsHost:
|
|
148
|
-
PadBottom:
|
|
191
|
+
PadTop: { tag: { node: "div", classList: "selective-ui-virtual-pad-top" } },
|
|
192
|
+
ItemsHost:{ tag: { node: "div", classList: "selective-ui-virtual-items" } },
|
|
193
|
+
PadBottom:{ tag: { node: "div", classList: "selective-ui-virtual-pad-bottom" } },
|
|
149
194
|
}, this.viewElement) as VirtualRecyclerViewTags;
|
|
150
195
|
|
|
151
196
|
this.PadTop = nodeMounted.PadTop;
|
|
152
197
|
this.ItemsHost = nodeMounted.ItemsHost;
|
|
153
198
|
this.PadBottom = nodeMounted.PadBottom;
|
|
154
199
|
|
|
155
|
-
this.scrollEl = this.opts.scrollEl
|
|
156
|
-
?? (this.viewElement.closest(".selective-ui-popup") as HTMLElement)
|
|
200
|
+
this.scrollEl = this.opts.scrollEl
|
|
201
|
+
?? (this.viewElement.closest(".selective-ui-popup") as HTMLElement)
|
|
157
202
|
?? (this.viewElement.parentElement as HTMLElement);
|
|
158
203
|
|
|
159
204
|
if (!this.scrollEl) throw new Error("VirtualRecyclerView: scrollEl not found");
|
|
@@ -166,14 +211,20 @@ export class VirtualRecyclerView<
|
|
|
166
211
|
(adapter as any)?.onVisibilityChanged?.(() => this.refreshItem());
|
|
167
212
|
}
|
|
168
213
|
|
|
169
|
-
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
214
|
+
/**
|
|
215
|
+
* Suspends scroll/resize processing to prevent window updates during batch operations.
|
|
216
|
+
*
|
|
217
|
+
* Behavior:
|
|
218
|
+
* - Cancels any scheduled animation frames.
|
|
219
|
+
* - Detaches the scroll listener (if attached).
|
|
220
|
+
* - Disconnects ResizeObserver and remembers to restore it on {@link resume}.
|
|
221
|
+
*
|
|
222
|
+
* @returns {void}
|
|
172
223
|
*/
|
|
173
|
-
public suspend() {
|
|
224
|
+
public suspend(): void {
|
|
174
225
|
this.suspended = true;
|
|
175
226
|
this.cancelFrames();
|
|
176
|
-
|
|
227
|
+
|
|
177
228
|
if (this.scrollEl && this.boundOnScroll) {
|
|
178
229
|
this.scrollEl.removeEventListener("scroll", this.boundOnScroll);
|
|
179
230
|
}
|
|
@@ -184,11 +235,17 @@ export class VirtualRecyclerView<
|
|
|
184
235
|
}
|
|
185
236
|
}
|
|
186
237
|
|
|
187
|
-
/**
|
|
188
|
-
* Resumes
|
|
189
|
-
*
|
|
238
|
+
/**
|
|
239
|
+
* Resumes processing after {@link suspend}.
|
|
240
|
+
*
|
|
241
|
+
* Behavior:
|
|
242
|
+
* - Re-attaches the scroll listener (if available).
|
|
243
|
+
* - Restores ResizeObserver when it was previously disconnected.
|
|
244
|
+
* - Schedules a window recalculation on the next animation frame.
|
|
245
|
+
*
|
|
246
|
+
* @returns {void}
|
|
190
247
|
*/
|
|
191
|
-
public resume() {
|
|
248
|
+
public resume(): void {
|
|
192
249
|
this.suspended = false;
|
|
193
250
|
|
|
194
251
|
if (this.scrollEl && this.boundOnScroll) {
|
|
@@ -204,10 +261,19 @@ export class VirtualRecyclerView<
|
|
|
204
261
|
}
|
|
205
262
|
|
|
206
263
|
/**
|
|
207
|
-
* Rebuilds internal state and schedules render update.
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
264
|
+
* Rebuilds internal virtualization state and schedules a render update.
|
|
265
|
+
*
|
|
266
|
+
* Behavior:
|
|
267
|
+
* - When `isUpdate === false`, triggers a hard refresh via {@link refreshItem} (reset + rebuild).
|
|
268
|
+
* - Updates caches to match the adapter item count.
|
|
269
|
+
* - Probes initial item height on first run to seed a better estimate.
|
|
270
|
+
* - Rebuilds Fenwick prefix sums and schedules window computation.
|
|
271
|
+
*
|
|
272
|
+
* No-op if adapter or `viewElement` is missing.
|
|
273
|
+
*
|
|
274
|
+
* @param {boolean} isUpdate - `true` when called due to incremental data update; `false` for initial setup/full replace.
|
|
275
|
+
* @returns {void}
|
|
276
|
+
* @override
|
|
211
277
|
*/
|
|
212
278
|
public override refresh(isUpdate: boolean): void {
|
|
213
279
|
if (!this.adapter || !this.viewElement) return;
|
|
@@ -218,6 +284,7 @@ export class VirtualRecyclerView<
|
|
|
218
284
|
|
|
219
285
|
if (count === 0) {
|
|
220
286
|
this.resetState();
|
|
287
|
+
this.update();
|
|
221
288
|
return;
|
|
222
289
|
}
|
|
223
290
|
|
|
@@ -230,22 +297,37 @@ export class VirtualRecyclerView<
|
|
|
230
297
|
|
|
231
298
|
this.rebuildFenwick(count);
|
|
232
299
|
this.scheduleUpdateWindow();
|
|
300
|
+
this.update();
|
|
233
301
|
}
|
|
234
302
|
|
|
235
|
-
/**
|
|
236
|
-
* Ensures item at index is
|
|
237
|
-
*
|
|
303
|
+
/**
|
|
304
|
+
* Ensures the item at `index` is mounted, and optionally scrolls it into view.
|
|
305
|
+
*
|
|
306
|
+
* This is primarily used by navigation/highlight flows where the target may not be rendered
|
|
307
|
+
* due to virtualization.
|
|
308
|
+
*
|
|
309
|
+
* @param {number} index - Item index to ensure visible/mounted.
|
|
310
|
+
* @param {{ scrollIntoView?: boolean }} [opt] - Optional behavior controls.
|
|
311
|
+
* @returns {void}
|
|
238
312
|
*/
|
|
239
|
-
public ensureRendered(index: number, opt?: { scrollIntoView?: boolean }) {
|
|
313
|
+
public ensureRendered(index: number, opt?: { scrollIntoView?: boolean }): void {
|
|
240
314
|
this.mountRange(index, index);
|
|
241
315
|
if (opt?.scrollIntoView) this.scrollToIndex(index);
|
|
242
316
|
}
|
|
243
317
|
|
|
244
|
-
/**
|
|
245
|
-
* Scrolls container to
|
|
246
|
-
*
|
|
318
|
+
/**
|
|
319
|
+
* Scrolls the scroll container to align the item at `index` into view.
|
|
320
|
+
*
|
|
321
|
+
* Calculation notes:
|
|
322
|
+
* - Computes target top using prefix sums (`offsetTopOf`) and container offset relative to scrollEl.
|
|
323
|
+
* - Clamps scrollTop to the scrollable range to avoid overshoot.
|
|
324
|
+
*
|
|
325
|
+
* No-op when itemCount is 0.
|
|
326
|
+
*
|
|
327
|
+
* @param {number} index - Item index to bring into view.
|
|
328
|
+
* @returns {void}
|
|
247
329
|
*/
|
|
248
|
-
public scrollToIndex(index: number) {
|
|
330
|
+
public scrollToIndex(index: number): void {
|
|
249
331
|
const count = this.adapter?.itemCount?.() ?? 0;
|
|
250
332
|
if (count <= 0) return;
|
|
251
333
|
|
|
@@ -253,32 +335,77 @@ export class VirtualRecyclerView<
|
|
|
253
335
|
const containerTop = this.containerTopInScroll();
|
|
254
336
|
const target = containerTop + topInContainer;
|
|
255
337
|
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
256
|
-
|
|
338
|
+
|
|
257
339
|
this.scrollEl.scrollTop = Math.min(Math.max(0, target), maxScroll);
|
|
258
340
|
}
|
|
259
341
|
|
|
260
|
-
/**
|
|
261
|
-
*
|
|
262
|
-
*
|
|
342
|
+
/**
|
|
343
|
+
* Disposes runtime resources without destroying the instance.
|
|
344
|
+
*
|
|
345
|
+
* Intended for adapter swaps or teardown sequencing:
|
|
346
|
+
* - cancels pending frames,
|
|
347
|
+
* - removes scroll listeners,
|
|
348
|
+
* - disconnects ResizeObserver,
|
|
349
|
+
* - removes mounted item elements and clears internal maps.
|
|
350
|
+
*
|
|
351
|
+
* @returns {void}
|
|
263
352
|
*/
|
|
264
|
-
public dispose() {
|
|
353
|
+
public dispose(): void {
|
|
265
354
|
this.cancelFrames();
|
|
266
|
-
|
|
355
|
+
|
|
267
356
|
if (this.scrollEl && this.boundOnScroll) {
|
|
268
357
|
this.scrollEl.removeEventListener("scroll", this.boundOnScroll);
|
|
269
358
|
}
|
|
270
|
-
|
|
359
|
+
|
|
271
360
|
this.resizeObs?.disconnect();
|
|
272
361
|
this.created.forEach(el => el.remove());
|
|
273
362
|
this.created.clear();
|
|
274
363
|
}
|
|
275
364
|
|
|
276
365
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
366
|
+
* Destroys the virtual recycler view and releases all resources.
|
|
367
|
+
*
|
|
368
|
+
* Behavior:
|
|
369
|
+
* - Idempotent: returns early if already in {@link LifecycleState.DESTROYED}.
|
|
370
|
+
* - Resets internal caches and disposes listeners/observers.
|
|
371
|
+
* - Removes scaffold DOM nodes (PadTop, ItemsHost, PadBottom).
|
|
372
|
+
* - Completes lifecycle teardown via {@link Lifecycle.destroy}.
|
|
373
|
+
*
|
|
374
|
+
* @returns {void}
|
|
375
|
+
* @override
|
|
376
|
+
*/
|
|
377
|
+
public override destroy(): void {
|
|
378
|
+
if (this.is(LifecycleState.DESTROYED)) {
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
this.resetState();
|
|
383
|
+
this.dispose();
|
|
384
|
+
|
|
385
|
+
this.PadTop.remove();
|
|
386
|
+
this.ItemsHost.remove();
|
|
387
|
+
this.PadBottom.remove();
|
|
388
|
+
|
|
389
|
+
this.PadTop = null as HTMLDivElement;
|
|
390
|
+
this.ItemsHost = null as HTMLDivElement;
|
|
391
|
+
this.PadBottom = null as HTMLDivElement;
|
|
392
|
+
|
|
393
|
+
super.destroy();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Hard reset used after large visibility changes (e.g., search/filter cleared).
|
|
398
|
+
*
|
|
399
|
+
* This recalculates padding and height structures by:
|
|
400
|
+
* - suspending processing,
|
|
401
|
+
* - resetting state and removing invisible elements,
|
|
402
|
+
* - recomputing estimator stats from cache,
|
|
403
|
+
* - rebuilding Fenwick prefix sums,
|
|
404
|
+
* - resetting window bounds and resuming updates.
|
|
405
|
+
*
|
|
406
|
+
* @returns {void}
|
|
280
407
|
*/
|
|
281
|
-
public refreshItem() {
|
|
408
|
+
public refreshItem(): void {
|
|
282
409
|
if (!this.adapter) return;
|
|
283
410
|
const count = this.adapter.itemCount();
|
|
284
411
|
if (count <= 0) return;
|
|
@@ -293,8 +420,8 @@ export class VirtualRecyclerView<
|
|
|
293
420
|
this.resume();
|
|
294
421
|
}
|
|
295
422
|
|
|
296
|
-
/** Cancels
|
|
297
|
-
private cancelFrames() {
|
|
423
|
+
/** Cancels any pending animation frames for window calculation and measurement. */
|
|
424
|
+
private cancelFrames(): void {
|
|
298
425
|
if (this.rafId != null) {
|
|
299
426
|
cancelAnimationFrame(this.rafId);
|
|
300
427
|
this.rafId = null;
|
|
@@ -305,8 +432,16 @@ export class VirtualRecyclerView<
|
|
|
305
432
|
}
|
|
306
433
|
}
|
|
307
434
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
435
|
+
/**
|
|
436
|
+
* Resets internal state: mounted elements, caches, Fenwick sums, padding, and estimator stats.
|
|
437
|
+
*
|
|
438
|
+
* DOM side effects:
|
|
439
|
+
* - Removes all currently mounted item elements tracked in {@link created}.
|
|
440
|
+
* - Resets pad heights to `0px`.
|
|
441
|
+
*
|
|
442
|
+
* @returns {void}
|
|
443
|
+
*/
|
|
444
|
+
private resetState(): void {
|
|
310
445
|
this.created.forEach(el => el.remove());
|
|
311
446
|
this.created.clear();
|
|
312
447
|
this.heightCache = [];
|
|
@@ -318,11 +453,17 @@ export class VirtualRecyclerView<
|
|
|
318
453
|
this.measuredCount = 0;
|
|
319
454
|
}
|
|
320
455
|
|
|
321
|
-
/**
|
|
322
|
-
* Measures first item to
|
|
323
|
-
*
|
|
456
|
+
/**
|
|
457
|
+
* Measures the first item to seed a better initial height estimate.
|
|
458
|
+
*
|
|
459
|
+
* Strategy:
|
|
460
|
+
* - Temporarily mounts index 0, measures its outer height, and updates `estimateItemHeight`.
|
|
461
|
+
* - If `dynamicHeights` is disabled, the probe element is removed and the model/view init flags
|
|
462
|
+
* are reverted for that item to avoid treating the probe as a real render.
|
|
463
|
+
*
|
|
464
|
+
* @returns {void}
|
|
324
465
|
*/
|
|
325
|
-
private probeInitialHeight() {
|
|
466
|
+
private probeInitialHeight(): void {
|
|
326
467
|
const probe = 0;
|
|
327
468
|
this.mountIndexOnce(probe);
|
|
328
469
|
|
|
@@ -343,18 +484,26 @@ export class VirtualRecyclerView<
|
|
|
343
484
|
}
|
|
344
485
|
}
|
|
345
486
|
|
|
346
|
-
/**
|
|
347
|
-
*
|
|
348
|
-
*
|
|
487
|
+
/**
|
|
488
|
+
* Whether the item at `index` is visible (i.e., not filtered/hidden).
|
|
489
|
+
*
|
|
490
|
+
* Visibility convention:
|
|
491
|
+
* - If `item.visible` is undefined, the item is treated as visible.
|
|
492
|
+
*
|
|
493
|
+
* @param {number} index - 0-based item index.
|
|
494
|
+
* @returns {boolean} True if visible; otherwise false.
|
|
349
495
|
*/
|
|
350
496
|
private isIndexVisible(index: number): boolean {
|
|
351
497
|
const item = this.adapter?.items?.[index];
|
|
352
|
-
return item?.visible ?? true;
|
|
498
|
+
return (item as any)?.visible ?? true;
|
|
353
499
|
}
|
|
354
500
|
|
|
355
|
-
/**
|
|
356
|
-
* Finds next visible item index starting from
|
|
357
|
-
*
|
|
501
|
+
/**
|
|
502
|
+
* Finds the next visible item index starting from `index`.
|
|
503
|
+
*
|
|
504
|
+
* @param {number} index - Start index (0-based).
|
|
505
|
+
* @param {number} count - Total item count.
|
|
506
|
+
* @returns {number} Next visible index, or -1 if none exist.
|
|
358
507
|
*/
|
|
359
508
|
private nextVisibleFrom(index: number, count: number): number {
|
|
360
509
|
for (let i = Math.max(0, index); i < count; i++) {
|
|
@@ -363,11 +512,15 @@ export class VirtualRecyclerView<
|
|
|
363
512
|
return -1;
|
|
364
513
|
}
|
|
365
514
|
|
|
366
|
-
/**
|
|
367
|
-
*
|
|
368
|
-
*
|
|
515
|
+
/**
|
|
516
|
+
* Recomputes running estimator stats from the current height cache.
|
|
517
|
+
*
|
|
518
|
+
* Only counts **visible** items; invisible items do not contribute to adaptive estimation.
|
|
519
|
+
*
|
|
520
|
+
* @param {number} count - Total item count.
|
|
521
|
+
* @returns {void}
|
|
369
522
|
*/
|
|
370
|
-
private recomputeMeasuredStats(count: number) {
|
|
523
|
+
private recomputeMeasuredStats(count: number): void {
|
|
371
524
|
this.measuredSum = 0;
|
|
372
525
|
this.measuredCount = 0;
|
|
373
526
|
for (let i = 0; i < count; i++) {
|
|
@@ -380,16 +533,25 @@ export class VirtualRecyclerView<
|
|
|
380
533
|
}
|
|
381
534
|
}
|
|
382
535
|
|
|
383
|
-
/**
|
|
536
|
+
/**
|
|
537
|
+
* Returns the view container's top offset relative to the scroll container.
|
|
538
|
+
*
|
|
539
|
+
* This is used to convert absolute scrollTop to a scrollTop relative to the recycler's own container.
|
|
540
|
+
*
|
|
541
|
+
* @returns {number} Top offset in pixels (non-negative).
|
|
542
|
+
*/
|
|
384
543
|
private containerTopInScroll(): number {
|
|
385
544
|
const a = this.viewElement!.getBoundingClientRect();
|
|
386
545
|
const b = this.scrollEl.getBoundingClientRect();
|
|
387
546
|
return Math.max(0, a.top - b.top + this.scrollEl.scrollTop);
|
|
388
547
|
}
|
|
389
548
|
|
|
390
|
-
/**
|
|
391
|
-
* Returns sticky header height with
|
|
392
|
-
*
|
|
549
|
+
/**
|
|
550
|
+
* Returns sticky header height with a short cache window (~16ms) to avoid layout thrashing.
|
|
551
|
+
*
|
|
552
|
+
* Used to adjust effective viewport height (so windowing math accounts for a visible sticky header).
|
|
553
|
+
*
|
|
554
|
+
* @returns {number} Sticky header height in pixels.
|
|
393
555
|
*/
|
|
394
556
|
private stickyTopHeight(): number {
|
|
395
557
|
const now = performance.now();
|
|
@@ -401,8 +563,16 @@ export class VirtualRecyclerView<
|
|
|
401
563
|
return this.stickyCacheVal;
|
|
402
564
|
}
|
|
403
565
|
|
|
404
|
-
/**
|
|
405
|
-
|
|
566
|
+
/**
|
|
567
|
+
* Schedules a window update on the next animation frame.
|
|
568
|
+
*
|
|
569
|
+
* No-op if:
|
|
570
|
+
* - a frame is already scheduled, or
|
|
571
|
+
* - the recycler is currently suspended.
|
|
572
|
+
*
|
|
573
|
+
* @returns {void}
|
|
574
|
+
*/
|
|
575
|
+
private scheduleUpdateWindow(): void {
|
|
406
576
|
if (this.rafId != null || this.suspended) return;
|
|
407
577
|
this.rafId = requestAnimationFrame(() => {
|
|
408
578
|
this.rafId = null;
|
|
@@ -410,9 +580,11 @@ export class VirtualRecyclerView<
|
|
|
410
580
|
});
|
|
411
581
|
}
|
|
412
582
|
|
|
413
|
-
/**
|
|
414
|
-
* Measures element's
|
|
415
|
-
*
|
|
583
|
+
/**
|
|
584
|
+
* Measures an element's "outer height" including vertical margins.
|
|
585
|
+
*
|
|
586
|
+
* @param {HTMLElement} el - Element to measure.
|
|
587
|
+
* @returns {number} Total outer height in pixels (minimum 1).
|
|
416
588
|
*/
|
|
417
589
|
private measureOuterHeight(el: HTMLElement): number {
|
|
418
590
|
const rect = el.getBoundingClientRect();
|
|
@@ -422,9 +594,14 @@ export class VirtualRecyclerView<
|
|
|
422
594
|
return Math.max(1, rect.height + mt + mb);
|
|
423
595
|
}
|
|
424
596
|
|
|
425
|
-
/**
|
|
426
|
-
* Returns height estimate for unmeasured items.
|
|
427
|
-
*
|
|
597
|
+
/**
|
|
598
|
+
* Returns the current height estimate for unmeasured items.
|
|
599
|
+
*
|
|
600
|
+
* - When adaptive estimation is enabled and at least one item was measured,
|
|
601
|
+
* returns the running average.
|
|
602
|
+
* - Otherwise returns the configured fixed estimate.
|
|
603
|
+
*
|
|
604
|
+
* @returns {number} Estimated item height in pixels (minimum 1).
|
|
428
605
|
*/
|
|
429
606
|
private getEstimate(): number {
|
|
430
607
|
if (this.opts.adaptiveEstimate && this.measuredCount > 0) {
|
|
@@ -433,27 +610,39 @@ export class VirtualRecyclerView<
|
|
|
433
610
|
return this.opts.estimateItemHeight;
|
|
434
611
|
}
|
|
435
612
|
|
|
436
|
-
/**
|
|
437
|
-
* Rebuilds Fenwick
|
|
438
|
-
*
|
|
613
|
+
/**
|
|
614
|
+
* Rebuilds Fenwick prefix sums from current cache/estimate and visibility.
|
|
615
|
+
*
|
|
616
|
+
* Encoding:
|
|
617
|
+
* - Invisible items contribute `0` height.
|
|
618
|
+
* - Visible items contribute either cached measured height, or the current estimate.
|
|
619
|
+
*
|
|
620
|
+
* @param {number} count - Total number of items.
|
|
621
|
+
* @returns {void}
|
|
439
622
|
*/
|
|
440
|
-
private rebuildFenwick(count: number) {
|
|
623
|
+
private rebuildFenwick(count: number): void {
|
|
441
624
|
const est = this.getEstimate();
|
|
442
|
-
const arr = Array.from({ length: count }, (_, i) =>
|
|
625
|
+
const arr = Array.from({ length: count }, (_, i) =>
|
|
443
626
|
this.isIndexVisible(i) ? (this.heightCache[i] ?? est) : 0
|
|
444
627
|
);
|
|
445
628
|
this.fenwick.buildFrom(arr);
|
|
446
629
|
}
|
|
447
630
|
|
|
448
631
|
/**
|
|
449
|
-
* Updates cached height at index and applies delta to Fenwick tree.
|
|
450
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
632
|
+
* Updates cached height at `index` and applies delta to the Fenwick tree.
|
|
633
|
+
*
|
|
634
|
+
* Behavior:
|
|
635
|
+
* - Ignores invisible items (no-op).
|
|
636
|
+
* - Applies an epsilon threshold to avoid jitter from sub-pixel / minor changes.
|
|
637
|
+
* - Updates adaptive estimator stats and Fenwick sums in **O(log n)**.
|
|
638
|
+
*
|
|
639
|
+
* @param {number} index - 0-based item index to update.
|
|
640
|
+
* @param {number} newH - Newly measured outer height (px).
|
|
641
|
+
* @returns {boolean} True if the height changed beyond the epsilon threshold.
|
|
453
642
|
*/
|
|
454
643
|
private updateHeightAt(index: number, newH: number): boolean {
|
|
455
644
|
if (!this.isIndexVisible(index)) return false;
|
|
456
|
-
|
|
645
|
+
|
|
457
646
|
const est = this.getEstimate();
|
|
458
647
|
const oldH = this.heightCache[index] ?? est;
|
|
459
648
|
|
|
@@ -473,8 +662,15 @@ export class VirtualRecyclerView<
|
|
|
473
662
|
}
|
|
474
663
|
|
|
475
664
|
/**
|
|
476
|
-
* Finds first visible item at or after scroll offset.
|
|
477
|
-
*
|
|
665
|
+
* Finds the first visible item at or after a scroll-relative offset.
|
|
666
|
+
*
|
|
667
|
+
* Strategy:
|
|
668
|
+
* - Use Fenwick lower-bound to approximate a candidate index by cumulative height,
|
|
669
|
+
* - Then advance to the next visible item.
|
|
670
|
+
*
|
|
671
|
+
* @param {number} stRel - ScrollTop relative to the view container (px).
|
|
672
|
+
* @param {number} count - Total item count.
|
|
673
|
+
* @returns {number} A visible index (best-effort); falls back to clamped candidate when needed.
|
|
478
674
|
*/
|
|
479
675
|
private findFirstVisibleIndex(stRel: number, count: number): number {
|
|
480
676
|
const k = this.fenwick.lowerBoundPrefix(Math.max(0, stRel));
|
|
@@ -484,10 +680,18 @@ export class VirtualRecyclerView<
|
|
|
484
680
|
}
|
|
485
681
|
|
|
486
682
|
/**
|
|
487
|
-
* Inserts element into
|
|
488
|
-
*
|
|
683
|
+
* Inserts an element into {@link ItemsHost} maintaining increasing index order.
|
|
684
|
+
*
|
|
685
|
+
* Heuristics:
|
|
686
|
+
* - Prefer inserting after the previous index element if present.
|
|
687
|
+
* - Else insert before the next index element if present.
|
|
688
|
+
* - Else scan children to find the first element with a larger `data-vindex`.
|
|
689
|
+
*
|
|
690
|
+
* @param {number} index - Item index.
|
|
691
|
+
* @param {HTMLElement} el - Element to insert.
|
|
692
|
+
* @returns {void}
|
|
489
693
|
*/
|
|
490
|
-
private insertIntoHostByIndex(index: number, el: HTMLElement) {
|
|
694
|
+
private insertIntoHostByIndex(index: number, el: HTMLElement): void {
|
|
491
695
|
el.setAttribute(VirtualRecyclerView.ATTR_INDEX, String(index));
|
|
492
696
|
|
|
493
697
|
const prev = this.created.get(index - 1);
|
|
@@ -514,10 +718,15 @@ export class VirtualRecyclerView<
|
|
|
514
718
|
}
|
|
515
719
|
|
|
516
720
|
/**
|
|
517
|
-
* Ensures element is in correct DOM position for its index.
|
|
518
|
-
*
|
|
721
|
+
* Ensures the element is in the correct DOM position for its index.
|
|
722
|
+
*
|
|
723
|
+
* Reinserts the element when adjacent siblings indicate an out-of-order position.
|
|
724
|
+
*
|
|
725
|
+
* @param {number} index - Item index.
|
|
726
|
+
* @param {HTMLElement} el - Element to validate/reinsert.
|
|
727
|
+
* @returns {void}
|
|
519
728
|
*/
|
|
520
|
-
private ensureDomOrder(index: number, el: HTMLElement) {
|
|
729
|
+
private ensureDomOrder(index: number, el: HTMLElement): void {
|
|
521
730
|
if (el.parentElement !== this.ItemsHost) {
|
|
522
731
|
this.insertIntoHostByIndex(index, el);
|
|
523
732
|
return;
|
|
@@ -528,7 +737,7 @@ export class VirtualRecyclerView<
|
|
|
528
737
|
const prev = el.previousElementSibling as HTMLElement | null;
|
|
529
738
|
const next = el.nextElementSibling as HTMLElement | null;
|
|
530
739
|
|
|
531
|
-
const needsReorder =
|
|
740
|
+
const needsReorder =
|
|
532
741
|
(prev && Number(prev.getAttribute(VirtualRecyclerView.ATTR_INDEX)) > index) ||
|
|
533
742
|
(next && Number(next.getAttribute(VirtualRecyclerView.ATTR_INDEX)) < index);
|
|
534
743
|
|
|
@@ -539,15 +748,23 @@ export class VirtualRecyclerView<
|
|
|
539
748
|
}
|
|
540
749
|
|
|
541
750
|
/**
|
|
542
|
-
* Attaches
|
|
543
|
-
*
|
|
751
|
+
* Attaches a {@link ResizeObserver} used for dynamic-height measurement.
|
|
752
|
+
*
|
|
753
|
+
* Singleton behavior:
|
|
754
|
+
* - Only creates/attaches the observer once per instance.
|
|
755
|
+
*
|
|
756
|
+
* Scheduling:
|
|
757
|
+
* - Observer callback schedules measurement on the next animation frame to batch DOM reads.
|
|
758
|
+
* - No-op when suppressed or suspended.
|
|
759
|
+
*
|
|
760
|
+
* @returns {void}
|
|
544
761
|
*/
|
|
545
|
-
private attachResizeObserverOnce() {
|
|
762
|
+
private attachResizeObserverOnce(): void {
|
|
546
763
|
if (this.resizeObs) return;
|
|
547
764
|
|
|
548
765
|
this.resizeObs = new ResizeObserver(() => {
|
|
549
766
|
if (this.suppressResize || this.suspended || !this.adapter || this.measureRaf != null) return;
|
|
550
|
-
|
|
767
|
+
|
|
551
768
|
this.measureRaf = requestAnimationFrame(() => {
|
|
552
769
|
this.measureRaf = null;
|
|
553
770
|
this.measureVisibleAndUpdate();
|
|
@@ -558,10 +775,15 @@ export class VirtualRecyclerView<
|
|
|
558
775
|
}
|
|
559
776
|
|
|
560
777
|
/**
|
|
561
|
-
* Measures all currently rendered items and updates height cache.
|
|
562
|
-
*
|
|
778
|
+
* Measures all currently rendered items and updates the height cache.
|
|
779
|
+
*
|
|
780
|
+
* If any height changed:
|
|
781
|
+
* - Rebuilds Fenwick sums when adaptive estimation is enabled.
|
|
782
|
+
* - Schedules a window recalculation.
|
|
783
|
+
*
|
|
784
|
+
* @returns {void}
|
|
563
785
|
*/
|
|
564
|
-
private measureVisibleAndUpdate() {
|
|
786
|
+
private measureVisibleAndUpdate(): void {
|
|
565
787
|
if (!this.adapter) return;
|
|
566
788
|
const count = this.adapter.itemCount();
|
|
567
789
|
if (count <= 0) return;
|
|
@@ -572,7 +794,7 @@ export class VirtualRecyclerView<
|
|
|
572
794
|
if (!this.isIndexVisible(i)) continue;
|
|
573
795
|
|
|
574
796
|
const item = this.adapter.items[i];
|
|
575
|
-
const el = item?.view?.getView?.() as HTMLElement | undefined;
|
|
797
|
+
const el = (item as any)?.view?.getView?.() as HTMLElement | undefined;
|
|
576
798
|
if (!el) continue;
|
|
577
799
|
|
|
578
800
|
const newH = this.measureOuterHeight(el);
|
|
@@ -585,23 +807,33 @@ export class VirtualRecyclerView<
|
|
|
585
807
|
}
|
|
586
808
|
}
|
|
587
809
|
|
|
588
|
-
/**
|
|
589
|
-
|
|
810
|
+
/**
|
|
811
|
+
* Scroll event handler. Schedules a window update on the next frame.
|
|
812
|
+
*
|
|
813
|
+
* @returns {void}
|
|
814
|
+
*/
|
|
815
|
+
private onScroll(): void {
|
|
590
816
|
this.scheduleUpdateWindow();
|
|
591
817
|
}
|
|
592
818
|
|
|
593
819
|
/**
|
|
594
|
-
* Core
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
820
|
+
* Core window update routine: computes the visible range and reconciles mounted DOM.
|
|
821
|
+
*
|
|
822
|
+
* High-level steps:
|
|
823
|
+
* 1) Compute scroll-relative viewport bounds (accounting for sticky header height).
|
|
824
|
+
* 2) Capture an anchor item and its visual delta relative to scrollTop.
|
|
825
|
+
* 3) Compute new start/end with overscan.
|
|
826
|
+
* 4) Mount missing items and unmount items outside the window.
|
|
827
|
+
* 5) Measure visible items (optional) and update pad heights.
|
|
828
|
+
* 6) Apply anchor correction to keep scroll position stable after height changes.
|
|
829
|
+
*
|
|
830
|
+
* Guarding:
|
|
831
|
+
* - Prevents re-entrancy via `updating`.
|
|
832
|
+
* - No-op while `suspended`.
|
|
833
|
+
*
|
|
834
|
+
* @returns {void}
|
|
603
835
|
*/
|
|
604
|
-
private updateWindowInternal() {
|
|
836
|
+
private updateWindowInternal(): void {
|
|
605
837
|
if (this.updating || this.suspended) return;
|
|
606
838
|
this.updating = true;
|
|
607
839
|
|
|
@@ -611,6 +843,7 @@ export class VirtualRecyclerView<
|
|
|
611
843
|
const count = this.adapter.itemCount();
|
|
612
844
|
if (count <= 0) return;
|
|
613
845
|
|
|
846
|
+
// Handle item count changes (e.g., add/remove)
|
|
614
847
|
if (this.lastRenderCount !== count) {
|
|
615
848
|
this.lastRenderCount = count;
|
|
616
849
|
this.heightCache.length = count;
|
|
@@ -665,6 +898,7 @@ export class VirtualRecyclerView<
|
|
|
665
898
|
this.suppressResize = false;
|
|
666
899
|
}
|
|
667
900
|
|
|
901
|
+
// Keep anchor item stable to prevent scroll jump
|
|
668
902
|
const anchorTopNew = this.offsetTopOf(anchorIndex);
|
|
669
903
|
const targetScroll = this.containerTopInScroll() + anchorTopNew - anchorDelta;
|
|
670
904
|
const maxScroll = Math.max(0, this.scrollEl.scrollHeight - this.scrollEl.clientHeight);
|
|
@@ -681,16 +915,24 @@ export class VirtualRecyclerView<
|
|
|
681
915
|
}
|
|
682
916
|
}
|
|
683
917
|
|
|
684
|
-
/** Mounts all items in inclusive range [start..end]
|
|
685
|
-
private mountRange(start: number, end: number) {
|
|
918
|
+
/** Mounts all items in the inclusive range `[start..end]`. */
|
|
919
|
+
private mountRange(start: number, end: number): void {
|
|
686
920
|
for (let i = start; i <= end; i++) this.mountIndexOnce(i);
|
|
687
921
|
}
|
|
688
922
|
|
|
689
923
|
/**
|
|
690
|
-
* Mounts single item
|
|
691
|
-
*
|
|
924
|
+
* Mounts/rebinds a single item at `index`.
|
|
925
|
+
*
|
|
926
|
+
* Behavior:
|
|
927
|
+
* - If the item is invisible, ensures it is removed/untracked (no-op otherwise).
|
|
928
|
+
* - Reuses an existing DOM element when present and the model already has a view.
|
|
929
|
+
* - Creates a new view holder on first mount (`item.isInit === false`) and binds via `adapter.onViewHolder`.
|
|
930
|
+
* - Ensures DOM order within {@link ItemsHost} and updates the {@link created} map.
|
|
931
|
+
*
|
|
932
|
+
* @param {number} index - Item index to mount/rebind.
|
|
933
|
+
* @returns {void}
|
|
692
934
|
*/
|
|
693
|
-
private mountIndexOnce(index: number) {
|
|
935
|
+
private mountIndexOnce(index: number): void {
|
|
694
936
|
if (!this.isIndexVisible(index)) {
|
|
695
937
|
const existing = this.created.get(index);
|
|
696
938
|
if (existing?.parentElement === this.ItemsHost) existing.remove();
|
|
@@ -703,34 +945,40 @@ export class VirtualRecyclerView<
|
|
|
703
945
|
const existing = this.created.get(index);
|
|
704
946
|
|
|
705
947
|
if (existing) {
|
|
706
|
-
if (!item?.view) {
|
|
948
|
+
if (!(item as any)?.view) {
|
|
707
949
|
existing.remove();
|
|
708
950
|
this.created.delete(index);
|
|
709
951
|
} else {
|
|
710
952
|
this.ensureDomOrder(index, existing);
|
|
711
|
-
this.adapter.onViewHolder(item, item.view, index);
|
|
953
|
+
this.adapter.onViewHolder(item, (item as any).view, index);
|
|
712
954
|
}
|
|
713
955
|
return;
|
|
714
956
|
}
|
|
715
957
|
|
|
716
|
-
if (!item.isInit) {
|
|
958
|
+
if (!(item as any).isInit) {
|
|
717
959
|
const viewer = this.adapter!.viewHolder(this.ItemsHost, item);
|
|
718
|
-
item.view = viewer;
|
|
960
|
+
(item as any).view = viewer;
|
|
719
961
|
this.adapter!.onViewHolder(item, viewer, index);
|
|
720
|
-
item.isInit = true;
|
|
721
|
-
} else if (item.view) {
|
|
722
|
-
this.adapter!.onViewHolder(item, item.view, index);
|
|
962
|
+
(item as any).isInit = true;
|
|
963
|
+
} else if ((item as any).view) {
|
|
964
|
+
this.adapter!.onViewHolder(item, (item as any).view, index);
|
|
723
965
|
}
|
|
724
966
|
|
|
725
|
-
const el = item.view?.getView?.() as HTMLElement | undefined;
|
|
967
|
+
const el = (item as any).view?.getView?.() as HTMLElement | undefined;
|
|
726
968
|
if (el) {
|
|
727
969
|
this.ensureDomOrder(index, el);
|
|
728
970
|
this.created.set(index, el);
|
|
729
971
|
}
|
|
730
972
|
}
|
|
731
973
|
|
|
732
|
-
/**
|
|
733
|
-
|
|
974
|
+
/**
|
|
975
|
+
* Unmounts all mounted items outside the inclusive range `[start..end]`.
|
|
976
|
+
*
|
|
977
|
+
* @param {number} start - Window start (inclusive).
|
|
978
|
+
* @param {number} end - Window end (inclusive).
|
|
979
|
+
* @returns {void}
|
|
980
|
+
*/
|
|
981
|
+
private unmountOutside(start: number, end: number): void {
|
|
734
982
|
this.created.forEach((el, idx) => {
|
|
735
983
|
if (idx < start || idx > end) {
|
|
736
984
|
if (el.parentElement === this.ItemsHost) el.remove();
|
|
@@ -739,8 +987,12 @@ export class VirtualRecyclerView<
|
|
|
739
987
|
});
|
|
740
988
|
}
|
|
741
989
|
|
|
742
|
-
/**
|
|
743
|
-
|
|
990
|
+
/**
|
|
991
|
+
* Removes all currently mounted items that are now marked invisible.
|
|
992
|
+
*
|
|
993
|
+
* @returns {void}
|
|
994
|
+
*/
|
|
995
|
+
private cleanupInvisibleItems(): void {
|
|
744
996
|
this.created.forEach((el, idx) => {
|
|
745
997
|
if (!this.isIndexVisible(idx)) {
|
|
746
998
|
if (el.parentElement === this.ItemsHost) el.remove();
|
|
@@ -749,17 +1001,38 @@ export class VirtualRecyclerView<
|
|
|
749
1001
|
});
|
|
750
1002
|
}
|
|
751
1003
|
|
|
752
|
-
/**
|
|
1004
|
+
/**
|
|
1005
|
+
* Returns cumulative height from the start of the list to the **top** of item at `index`.
|
|
1006
|
+
*
|
|
1007
|
+
* Indexing note:
|
|
1008
|
+
* - Uses Fenwick prefix sum with a 1-based contract.
|
|
1009
|
+
* - Passing a 0-based `index` to `sum(index)` yields the sum of heights for items `[0..index-1]`,
|
|
1010
|
+
* which corresponds to the CSS `offsetTop` for item `index` in a stacked list.
|
|
1011
|
+
*
|
|
1012
|
+
* @param {number} index - Item index (0-based).
|
|
1013
|
+
* @returns {number} Offset from the top of the list to the top of the item (px).
|
|
1014
|
+
*/
|
|
753
1015
|
private offsetTopOf(index: number): number {
|
|
754
1016
|
return this.fenwick.sum(index);
|
|
755
1017
|
}
|
|
756
1018
|
|
|
757
|
-
/**
|
|
1019
|
+
/**
|
|
1020
|
+
* Returns the total height of items in the inclusive range `[start..end]`.
|
|
1021
|
+
*
|
|
1022
|
+
* @param {number} start - Start index (0-based).
|
|
1023
|
+
* @param {number} end - End index (0-based).
|
|
1024
|
+
* @returns {number} Total height in pixels.
|
|
1025
|
+
*/
|
|
758
1026
|
private windowHeight(start: number, end: number): number {
|
|
759
1027
|
return this.fenwick.rangeSum(start + 1, end + 1);
|
|
760
1028
|
}
|
|
761
1029
|
|
|
762
|
-
/**
|
|
1030
|
+
/**
|
|
1031
|
+
* Returns total scrollable height for all items.
|
|
1032
|
+
*
|
|
1033
|
+
* @param {number} count - Total item count.
|
|
1034
|
+
* @returns {number} Total height in pixels.
|
|
1035
|
+
*/
|
|
763
1036
|
private totalHeight(count: number): number {
|
|
764
1037
|
return this.fenwick.sum(count);
|
|
765
1038
|
}
|