@humanspeak/svelte-virtual-list 0.3.1-beta.1 → 0.3.2
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/SvelteVirtualList.svelte +262 -191
- package/dist/SvelteVirtualList.svelte.d.ts +5 -5
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/{reactive-height-manager → reactive-list-manager}/INTEGRATION_EXAMPLE.md +10 -10
- package/dist/{reactive-height-manager → reactive-list-manager}/README.md +17 -17
- package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +221 -0
- package/dist/reactive-list-manager/ReactiveListManager.svelte.js +635 -0
- package/dist/reactive-list-manager/RecomputeScheduler.d.ts +12 -0
- package/dist/reactive-list-manager/RecomputeScheduler.js +54 -0
- package/dist/reactive-list-manager/benchmark.d.ts +5 -0
- package/dist/{reactive-height-manager → reactive-list-manager}/benchmark.js +3 -3
- package/dist/{reactive-height-manager → reactive-list-manager}/index.d.ts +8 -12
- package/dist/{reactive-height-manager → reactive-list-manager}/index.js +10 -13
- package/dist/{reactive-height-manager → reactive-list-manager}/test/TestComponent.svelte +9 -9
- package/dist/{reactive-height-manager → reactive-list-manager}/test/TestComponent.svelte.d.ts +5 -5
- package/dist/{reactive-height-manager → reactive-list-manager}/types.d.ts +9 -3
- package/dist/utils/virtualList.d.ts +2 -2
- package/dist/utils/virtualList.js +44 -17
- package/package.json +134 -133
- package/dist/reactive-height-manager/ReactiveHeightManager.svelte.d.ts +0 -116
- package/dist/reactive-height-manager/ReactiveHeightManager.svelte.js +0 -200
- package/dist/reactive-height-manager/benchmark.d.ts +0 -5
- package/dist/utils/resizeObserver.d.ts +0 -89
- package/dist/utils/resizeObserver.js +0 -119
- /package/dist/{reactive-height-manager → reactive-list-manager}/types.js +0 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import { RecomputeScheduler } from './RecomputeScheduler.js';
|
|
2
|
+
/**
|
|
3
|
+
* ReactiveListManager - A standalone reactive height calculation system
|
|
4
|
+
*
|
|
5
|
+
* Efficiently manages height calculations for virtualized lists by:
|
|
6
|
+
* - Tracking measured vs unmeasured items incrementally
|
|
7
|
+
* - Processing only dirty/changed items (O(dirty) instead of O(all))
|
|
8
|
+
* - Providing reactive state updates using Svelte 5 runes
|
|
9
|
+
* - Maintaining accurate total height calculations
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const manager = new ReactiveListManager({ itemLength: 10000, estimatedHeight: 40 })
|
|
14
|
+
*
|
|
15
|
+
* // Process height changes incrementally
|
|
16
|
+
* manager.processDirtyHeights(dirtyResults)
|
|
17
|
+
*
|
|
18
|
+
* // Update calculated item height
|
|
19
|
+
* manager.calculatedItemHeight = 42
|
|
20
|
+
*
|
|
21
|
+
* // Get reactive total height (automatically updates)
|
|
22
|
+
* const totalHeight = manager.totalHeight
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
export class ReactiveListManager {
|
|
26
|
+
// Reactive state using Svelte 5 runes
|
|
27
|
+
_totalMeasuredHeight = $state(0);
|
|
28
|
+
_measuredCount = $state(0);
|
|
29
|
+
_itemLength = $state(0);
|
|
30
|
+
_itemHeight = $state(40);
|
|
31
|
+
_averageHeight = $state(40);
|
|
32
|
+
_totalHeight = $state(0);
|
|
33
|
+
_measuredFlags = null;
|
|
34
|
+
_initialized = $state(false);
|
|
35
|
+
_scrollTop = $state(0);
|
|
36
|
+
_containerElement = $state(null);
|
|
37
|
+
_viewportElement = $state(null);
|
|
38
|
+
_internalDebug = false;
|
|
39
|
+
_isReady = $state(false);
|
|
40
|
+
_dynamicUpdateInProgress = $state(false);
|
|
41
|
+
_dynamicUpdateDepth = $state(0);
|
|
42
|
+
// Grid detection (CSS-first)
|
|
43
|
+
_itemsWrapperElement = $state(null);
|
|
44
|
+
_gridDetected = $state(false);
|
|
45
|
+
_gridColumns = $state(1);
|
|
46
|
+
_gridObserver = null;
|
|
47
|
+
_mutationObserver = null;
|
|
48
|
+
// Internal cache of measured heights by index
|
|
49
|
+
_heightCache = {};
|
|
50
|
+
// Recompute scheduling
|
|
51
|
+
_scheduler = new RecomputeScheduler(() => this.recomputeDerivedHeights());
|
|
52
|
+
recomputeDerivedHeights() {
|
|
53
|
+
const average = this._measuredCount > 0
|
|
54
|
+
? this._totalMeasuredHeight / this._measuredCount
|
|
55
|
+
: this._itemHeight;
|
|
56
|
+
this._averageHeight = average;
|
|
57
|
+
const unmeasuredCount = this._itemLength - this._measuredCount;
|
|
58
|
+
this._totalHeight = this._totalMeasuredHeight + unmeasuredCount * average;
|
|
59
|
+
}
|
|
60
|
+
recomputeIsReady() {
|
|
61
|
+
this._isReady = !!this._containerElement && !!this._viewportElement;
|
|
62
|
+
}
|
|
63
|
+
scheduleRecomputeDerivedHeights() {
|
|
64
|
+
// In jsdom/unit tests, recompute synchronously for determinism
|
|
65
|
+
const isJsdom = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
66
|
+
? /jsdom/i.test(navigator.userAgent)
|
|
67
|
+
: false;
|
|
68
|
+
if (typeof window === 'undefined' || isJsdom) {
|
|
69
|
+
this.recomputeDerivedHeights();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (this._dynamicUpdateDepth > 0) {
|
|
73
|
+
this._scheduler.block();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
this._scheduler.schedule();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Get total measured height of all measured items
|
|
80
|
+
*/
|
|
81
|
+
get totalMeasuredHeight() {
|
|
82
|
+
return this._totalMeasuredHeight;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get count of items that have been measured
|
|
86
|
+
*/
|
|
87
|
+
get measuredCount() {
|
|
88
|
+
return this._measuredCount;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get total number of items in the list
|
|
92
|
+
*/
|
|
93
|
+
get itemLength() {
|
|
94
|
+
return this._itemLength;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Get/Set the height to use for unmeasured items (reactive)
|
|
98
|
+
*/
|
|
99
|
+
get itemHeight() {
|
|
100
|
+
return this._itemHeight;
|
|
101
|
+
}
|
|
102
|
+
set itemHeight(value) {
|
|
103
|
+
this._itemHeight = value;
|
|
104
|
+
this.scheduleRecomputeDerivedHeights();
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get/Set initialized flag
|
|
108
|
+
*/
|
|
109
|
+
get initialized() {
|
|
110
|
+
return this._initialized;
|
|
111
|
+
}
|
|
112
|
+
set initialized(value) {
|
|
113
|
+
if (this._initialized) {
|
|
114
|
+
throw new Error('ReactiveListManager: initialized flag cannot be set to true after it has been set to true');
|
|
115
|
+
}
|
|
116
|
+
this._initialized = value;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Get/Set current scrollTop (reactive)
|
|
120
|
+
*/
|
|
121
|
+
get scrollTop() {
|
|
122
|
+
return this._scrollTop;
|
|
123
|
+
}
|
|
124
|
+
set scrollTop(value) {
|
|
125
|
+
// Debug: warn if the same value is set excessively within a short window
|
|
126
|
+
if (this._internalDebug) {
|
|
127
|
+
this.#debugCheckScrollTopRepeat(value);
|
|
128
|
+
}
|
|
129
|
+
this._scrollTop = value;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Container element reference (reactive, nullable)
|
|
133
|
+
*
|
|
134
|
+
* Why both `containerElement` and `container` exist:
|
|
135
|
+
* - `containerElement` is a nullable, reactive reference intended for Svelte `bind:this` wiring
|
|
136
|
+
* from components. It may be temporarily null during mount/unmount and is safe to read as
|
|
137
|
+
* a possibly-null value. Setting it more than once is prohibited to catch wiring bugs early.
|
|
138
|
+
* - `container` is the non-null accessor for internal consumers that require a definite
|
|
139
|
+
* HTMLElement once the manager is wired. It throws until the manager is `isReady === true`
|
|
140
|
+
* (i.e., both container and viewport are present). Use this when you want a guaranteed DOM node.
|
|
141
|
+
*/
|
|
142
|
+
get containerElement() {
|
|
143
|
+
return this._containerElement;
|
|
144
|
+
}
|
|
145
|
+
get container() {
|
|
146
|
+
if (!this._isReady) {
|
|
147
|
+
throw new Error('ReactiveListManager: container is not ready');
|
|
148
|
+
}
|
|
149
|
+
return this._containerElement;
|
|
150
|
+
}
|
|
151
|
+
set containerElement(el) {
|
|
152
|
+
this._containerElement = el;
|
|
153
|
+
this.recomputeIsReady();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Viewport element reference (reactive, nullable)
|
|
157
|
+
*
|
|
158
|
+
* Why both `viewportElement` and `viewport` exist:
|
|
159
|
+
* - `viewportElement` is a nullable, reactive reference intended for Svelte `bind:this` wiring
|
|
160
|
+
* from components. It may be temporarily null during mount/unmount and is safe to read as
|
|
161
|
+
* a possibly-null value. Setting it more than once is prohibited to catch wiring bugs early.
|
|
162
|
+
* - `viewport` is the non-null accessor for internal consumers that require a definite
|
|
163
|
+
* HTMLElement once the manager is wired. It throws until the manager is `isReady === true`
|
|
164
|
+
* (i.e., both container and viewport are present). Use this when you want a guaranteed DOM node.
|
|
165
|
+
*/
|
|
166
|
+
get viewportElement() {
|
|
167
|
+
return this._viewportElement;
|
|
168
|
+
}
|
|
169
|
+
get viewport() {
|
|
170
|
+
if (!this._isReady) {
|
|
171
|
+
throw new Error('ReactiveListManager: viewport is not ready');
|
|
172
|
+
}
|
|
173
|
+
return this._viewportElement;
|
|
174
|
+
}
|
|
175
|
+
set viewportElement(el) {
|
|
176
|
+
this._viewportElement = el;
|
|
177
|
+
this.recomputeIsReady();
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Items wrapper element reference (reactive, nullable)
|
|
181
|
+
*
|
|
182
|
+
* Used for CSS-based grid detection. When set, the manager will auto-detect
|
|
183
|
+
* whether the items container is a grid and how many columns it defines.
|
|
184
|
+
*/
|
|
185
|
+
get itemsWrapperElement() {
|
|
186
|
+
return this._itemsWrapperElement;
|
|
187
|
+
}
|
|
188
|
+
set itemsWrapperElement(el) {
|
|
189
|
+
// Detach previous observer if element changed
|
|
190
|
+
if (this._itemsWrapperElement !== el) {
|
|
191
|
+
if (this._gridObserver) {
|
|
192
|
+
try {
|
|
193
|
+
this._gridObserver.disconnect();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// no-op
|
|
197
|
+
}
|
|
198
|
+
this._gridObserver = null;
|
|
199
|
+
}
|
|
200
|
+
if (this._mutationObserver) {
|
|
201
|
+
try {
|
|
202
|
+
this._mutationObserver.disconnect();
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
// no-op
|
|
206
|
+
}
|
|
207
|
+
this._mutationObserver = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
this._itemsWrapperElement = el;
|
|
211
|
+
if (!el) {
|
|
212
|
+
this._gridDetected = false;
|
|
213
|
+
this._gridColumns = 1;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
// Attach new observer and detect immediately
|
|
217
|
+
this.#attachGridObserver();
|
|
218
|
+
this.#attachMutationObserver();
|
|
219
|
+
this.#detectGridColumns();
|
|
220
|
+
}
|
|
221
|
+
/** Whether a CSS grid was detected on the items wrapper */
|
|
222
|
+
get gridDetected() {
|
|
223
|
+
return this._gridDetected;
|
|
224
|
+
}
|
|
225
|
+
/** Number of columns when a grid is detected; 1 when not a grid */
|
|
226
|
+
get gridColumns() {
|
|
227
|
+
return this._gridColumns;
|
|
228
|
+
}
|
|
229
|
+
get isReady() {
|
|
230
|
+
return this._isReady;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Whether a dynamic update is currently running.
|
|
234
|
+
* Set to true while `runDynamicUpdate` is executing.
|
|
235
|
+
*/
|
|
236
|
+
get isDynamicUpdateInProgress() {
|
|
237
|
+
return this._dynamicUpdateDepth > 0;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Begin a dynamic update. Handles nested calls: the first call disables UA scroll anchoring,
|
|
241
|
+
* subsequent calls just increment depth. Safe to call when not wired; styles are only toggled
|
|
242
|
+
* when both container and viewport are ready.
|
|
243
|
+
*/
|
|
244
|
+
startDynamicUpdate() {
|
|
245
|
+
const isOuter = this._dynamicUpdateDepth === 0;
|
|
246
|
+
this._dynamicUpdateDepth += 1;
|
|
247
|
+
if (isOuter) {
|
|
248
|
+
this._dynamicUpdateInProgress = true;
|
|
249
|
+
if (this._isReady && this._viewportElement) {
|
|
250
|
+
this._viewportElement.style.setProperty('overflow-anchor', 'none');
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* End a dynamic update started by `startDynamicUpdate`. Handles nesting: only the final
|
|
256
|
+
* corresponding end call re-enables UA scroll anchoring. Guards against underflow.
|
|
257
|
+
*/
|
|
258
|
+
endDynamicUpdate() {
|
|
259
|
+
if (this._dynamicUpdateDepth <= 0) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
this._dynamicUpdateDepth -= 1;
|
|
263
|
+
if (this._dynamicUpdateDepth === 0) {
|
|
264
|
+
if (this._isReady && this._viewportElement) {
|
|
265
|
+
this._viewportElement.style.setProperty('overflow-anchor', 'auto');
|
|
266
|
+
}
|
|
267
|
+
this._dynamicUpdateInProgress = false;
|
|
268
|
+
this._scheduler.unblock();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Run a dynamic update with UA scroll anchoring disabled, then restore it.
|
|
273
|
+
* Accepts a sync or async function and ensures `overflow-anchor` is toggled
|
|
274
|
+
* around the operation. If the manager isn't ready yet, it simply executes `fn`.
|
|
275
|
+
*/
|
|
276
|
+
async runDynamicUpdate(fn) {
|
|
277
|
+
this.startDynamicUpdate();
|
|
278
|
+
try {
|
|
279
|
+
const result = fn();
|
|
280
|
+
return (result instanceof Promise ? await result : result);
|
|
281
|
+
}
|
|
282
|
+
finally {
|
|
283
|
+
this.endDynamicUpdate();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// --- Internal debug helpers (non-exported) ---
|
|
287
|
+
#debugLastScrollValue = null;
|
|
288
|
+
#debugWindowStartMs = 0;
|
|
289
|
+
#debugRepeatCount = 0;
|
|
290
|
+
#debugWarnedThisWindow = false;
|
|
291
|
+
#debugCheckScrollTopRepeat(value) {
|
|
292
|
+
const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
|
|
293
|
+
if (this.#debugLastScrollValue === value) {
|
|
294
|
+
if (now - this.#debugWindowStartMs <= 1000) {
|
|
295
|
+
this.#debugRepeatCount += 1;
|
|
296
|
+
if (this.#debugRepeatCount > 10 && !this.#debugWarnedThisWindow) {
|
|
297
|
+
this.#debugWarnedThisWindow = true;
|
|
298
|
+
console.warn('\n================ SvelteVirtualList DEBUG ================\n' +
|
|
299
|
+
`scrollTop assigned same value ${value} > 10 times within 1s\n` +
|
|
300
|
+
`count=${this.#debugRepeatCount}, windowStart=${Math.round(this.#debugWindowStartMs)}ms\n` +
|
|
301
|
+
'This may indicate redundant updates or feedback loops.\n' +
|
|
302
|
+
'========================================================\n');
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
// New time window for the same value
|
|
307
|
+
this.#debugWindowStartMs = now;
|
|
308
|
+
this.#debugRepeatCount = 1;
|
|
309
|
+
this.#debugWarnedThisWindow = false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
// Different value: reset tracking
|
|
314
|
+
this.#debugLastScrollValue = value;
|
|
315
|
+
this.#debugWindowStartMs = now;
|
|
316
|
+
this.#debugRepeatCount = 1;
|
|
317
|
+
this.#debugWarnedThisWindow = false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Get the calculated average height of measured items
|
|
322
|
+
* Falls back to itemHeight if no items have been measured yet
|
|
323
|
+
*/
|
|
324
|
+
get averageHeight() {
|
|
325
|
+
return this._averageHeight;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Get the reactive total height of all items (measured + estimated)
|
|
329
|
+
* This automatically updates when any dependencies change
|
|
330
|
+
*/
|
|
331
|
+
get totalHeight() {
|
|
332
|
+
return this._totalHeight;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Test helper: force a recompute immediately (bypasses scheduler).
|
|
336
|
+
*/
|
|
337
|
+
flushRecompute = () => {
|
|
338
|
+
this.recomputeDerivedHeights();
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* Read-only view of measured heights cache
|
|
342
|
+
*/
|
|
343
|
+
getHeightCache() {
|
|
344
|
+
return this._heightCache;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Create a new ReactiveListManager instance
|
|
348
|
+
*
|
|
349
|
+
* @param config - Configuration object containing itemLength and itemHeight
|
|
350
|
+
*/
|
|
351
|
+
constructor(config) {
|
|
352
|
+
this._itemLength = config.itemLength;
|
|
353
|
+
this._itemHeight = config.itemHeight;
|
|
354
|
+
this._internalDebug = config.internalDebug ?? false;
|
|
355
|
+
this._measuredFlags = new Uint8Array(Math.max(0, this._itemLength));
|
|
356
|
+
this.recomputeDerivedHeights();
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Process height changes incrementally - O(dirty items) instead of O(all items)
|
|
360
|
+
*
|
|
361
|
+
* This is the core optimization: instead of recalculating totals for all items,
|
|
362
|
+
* we only process the items that have changed, maintaining running totals.
|
|
363
|
+
*
|
|
364
|
+
* Accepts any object that has index, oldHeight, and newHeight properties,
|
|
365
|
+
* allowing consumers to pass objects with additional fields.
|
|
366
|
+
*
|
|
367
|
+
* @param dirtyResults - Array of height changes to process
|
|
368
|
+
*/
|
|
369
|
+
processDirtyHeights(dirtyResults) {
|
|
370
|
+
if (dirtyResults.length === 0)
|
|
371
|
+
return;
|
|
372
|
+
// Batch calculate changes to trigger reactivity only once
|
|
373
|
+
let heightDelta = 0;
|
|
374
|
+
let countDelta = 0;
|
|
375
|
+
for (const change of dirtyResults) {
|
|
376
|
+
const { index, oldHeight, newHeight } = change;
|
|
377
|
+
// Remove old contribution if it existed
|
|
378
|
+
if (oldHeight !== undefined) {
|
|
379
|
+
heightDelta -= oldHeight;
|
|
380
|
+
countDelta -= 1;
|
|
381
|
+
}
|
|
382
|
+
// Add new contribution
|
|
383
|
+
if (newHeight !== undefined) {
|
|
384
|
+
heightDelta += newHeight;
|
|
385
|
+
countDelta += 1;
|
|
386
|
+
this._heightCache[index] = newHeight;
|
|
387
|
+
}
|
|
388
|
+
else {
|
|
389
|
+
// Unset measurement
|
|
390
|
+
delete this._heightCache[index];
|
|
391
|
+
}
|
|
392
|
+
// Track measured flag (best-effort; full coalescing handled separately)
|
|
393
|
+
if (this._measuredFlags && index >= 0 && index < this._measuredFlags.length) {
|
|
394
|
+
this._measuredFlags[index] = 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// IDK... no one can explain it to me,.. but its here like this... it cannot be:
|
|
398
|
+
// if (heightDelta === 0 && countDelta === 0) return
|
|
399
|
+
const isJsdom = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
|
|
400
|
+
? /jsdom/i.test(navigator.userAgent)
|
|
401
|
+
: false;
|
|
402
|
+
const isNonBrowser = typeof window === 'undefined' || isJsdom;
|
|
403
|
+
if (isNonBrowser) {
|
|
404
|
+
if (heightDelta === 0 && countDelta === 0)
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
if (countDelta === 0)
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
// Apply all changes at once - triggers reactivity only once
|
|
412
|
+
this._totalMeasuredHeight += heightDelta;
|
|
413
|
+
this._measuredCount += countDelta;
|
|
414
|
+
this.scheduleRecomputeDerivedHeights();
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Update when items array length changes
|
|
418
|
+
*
|
|
419
|
+
* @param newLength - New total number of items
|
|
420
|
+
*/
|
|
421
|
+
updateItemLength(newLength) {
|
|
422
|
+
this._itemLength = newLength;
|
|
423
|
+
this._measuredFlags = new Uint8Array(Math.max(0, newLength));
|
|
424
|
+
// Immediate recompute so new items become visible without delay
|
|
425
|
+
this.recomputeDerivedHeights();
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Update estimated height for unmeasured items
|
|
429
|
+
*
|
|
430
|
+
* @param newEstimatedHeight - New estimated height
|
|
431
|
+
*/
|
|
432
|
+
updateEstimatedHeight(newEstimatedHeight) {
|
|
433
|
+
// Keep a single source of truth for the estimated height
|
|
434
|
+
this._itemHeight = newEstimatedHeight;
|
|
435
|
+
this.scheduleRecomputeDerivedHeights();
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Set a single measured height and update totals
|
|
439
|
+
*/
|
|
440
|
+
setMeasuredHeight(index, height) {
|
|
441
|
+
if (index < 0 || index >= this._itemLength)
|
|
442
|
+
return;
|
|
443
|
+
const prev = this._heightCache[index];
|
|
444
|
+
if (Number.isFinite(prev) && prev > 0) {
|
|
445
|
+
this._totalMeasuredHeight -= prev;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
this._measuredCount += 1;
|
|
449
|
+
}
|
|
450
|
+
if (Number.isFinite(height) && height > 0) {
|
|
451
|
+
this._heightCache[index] = height;
|
|
452
|
+
this._totalMeasuredHeight += height;
|
|
453
|
+
this.scheduleRecomputeDerivedHeights();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Reset all state to initial values
|
|
458
|
+
*
|
|
459
|
+
* Useful for testing or when completely reinitializing the list
|
|
460
|
+
*/
|
|
461
|
+
reset() {
|
|
462
|
+
this._totalMeasuredHeight = 0;
|
|
463
|
+
this._measuredCount = 0;
|
|
464
|
+
this._measuredFlags = this._itemLength > 0 ? new Uint8Array(this._itemLength) : null;
|
|
465
|
+
// Note: Don't reset _itemLength, _itemHeight as they represent configuration, not measured state
|
|
466
|
+
this.scheduleRecomputeDerivedHeights();
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Get comprehensive debug information
|
|
470
|
+
*
|
|
471
|
+
* @returns Debug information object
|
|
472
|
+
*/
|
|
473
|
+
getDebugInfo() {
|
|
474
|
+
const info = {
|
|
475
|
+
totalMeasuredHeight: this._totalMeasuredHeight,
|
|
476
|
+
measuredCount: this._measuredCount,
|
|
477
|
+
itemLength: this._itemLength,
|
|
478
|
+
coveragePercent: this._itemLength > 0 ? (this._measuredCount / this._itemLength) * 100 : 0,
|
|
479
|
+
itemHeight: this._itemHeight,
|
|
480
|
+
averageHeight: this.averageHeight,
|
|
481
|
+
totalHeight: this.totalHeight,
|
|
482
|
+
gridDetected: this._gridDetected,
|
|
483
|
+
gridColumns: this._gridColumns
|
|
484
|
+
};
|
|
485
|
+
return info;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Get the percentage of items that have been measured
|
|
489
|
+
*
|
|
490
|
+
* @returns Percentage (0-100) of measured items
|
|
491
|
+
*/
|
|
492
|
+
getMeasurementCoverage() {
|
|
493
|
+
return this.getDebugInfo().coveragePercent;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Check if the manager has sufficient measurement data
|
|
497
|
+
*
|
|
498
|
+
* @param threshold - Minimum percentage of items that should be measured (default: 10)
|
|
499
|
+
* @returns true if coverage meets threshold
|
|
500
|
+
*/
|
|
501
|
+
hasSufficientMeasurements(threshold = 10) {
|
|
502
|
+
return this.getMeasurementCoverage() >= threshold;
|
|
503
|
+
}
|
|
504
|
+
/** Public: Re-run CSS grid detection immediately */
|
|
505
|
+
recomputeGridDetection() {
|
|
506
|
+
this.#detectGridColumns();
|
|
507
|
+
}
|
|
508
|
+
// --- Grid detection helpers ---
|
|
509
|
+
#attachGridObserver() {
|
|
510
|
+
const el = this._itemsWrapperElement;
|
|
511
|
+
if (typeof window === 'undefined' || !el)
|
|
512
|
+
return;
|
|
513
|
+
// Observe size changes to recompute column count responsively
|
|
514
|
+
try {
|
|
515
|
+
this._gridObserver = new ResizeObserver(() => {
|
|
516
|
+
this.#detectGridColumns();
|
|
517
|
+
});
|
|
518
|
+
this._gridObserver.observe(el);
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
// Ignore observer failures in non-browser environments
|
|
522
|
+
this._gridObserver = null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
#attachMutationObserver() {
|
|
526
|
+
const el = this._itemsWrapperElement;
|
|
527
|
+
if (typeof window === 'undefined' || !el)
|
|
528
|
+
return;
|
|
529
|
+
try {
|
|
530
|
+
this._mutationObserver = new MutationObserver((records) => {
|
|
531
|
+
for (const rec of records) {
|
|
532
|
+
if (rec.type === 'attributes' &&
|
|
533
|
+
(rec.attributeName === 'class' || rec.attributeName === 'style')) {
|
|
534
|
+
this.#detectGridColumns();
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
this._mutationObserver.observe(el, {
|
|
540
|
+
attributes: true,
|
|
541
|
+
attributeFilter: ['class', 'style']
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
this._mutationObserver = null;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
#detectGridColumns() {
|
|
549
|
+
const el = this._itemsWrapperElement;
|
|
550
|
+
if (!el) {
|
|
551
|
+
this._gridDetected = false;
|
|
552
|
+
this._gridColumns = 1;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
// getComputedStyle based detection
|
|
556
|
+
let detected = false;
|
|
557
|
+
let columns = 1;
|
|
558
|
+
try {
|
|
559
|
+
const style = getComputedStyle(el);
|
|
560
|
+
if (style.display === 'grid') {
|
|
561
|
+
const template = style.gridTemplateColumns;
|
|
562
|
+
const repeatMatch = /repeat\(\s*(\d+)\s*,/i.exec(template);
|
|
563
|
+
if (repeatMatch && repeatMatch[1]) {
|
|
564
|
+
columns = Math.max(1, parseInt(repeatMatch[1], 10));
|
|
565
|
+
detected = true;
|
|
566
|
+
}
|
|
567
|
+
else if (template && template !== 'none') {
|
|
568
|
+
const count = this.#countTracksFromTemplate(template);
|
|
569
|
+
if (Number.isFinite(count) && count > 0) {
|
|
570
|
+
columns = count;
|
|
571
|
+
detected = true;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// Ignore and fall back to geometry detection
|
|
578
|
+
}
|
|
579
|
+
// Fallback: infer from first row geometry if style approach failed
|
|
580
|
+
if (!detected) {
|
|
581
|
+
const children = el.children;
|
|
582
|
+
if (children && children.length > 0) {
|
|
583
|
+
const firstTop = children[0].getBoundingClientRect().top;
|
|
584
|
+
let countSameRow = 0;
|
|
585
|
+
for (let i = 0; i < children.length; i += 1) {
|
|
586
|
+
const top = children[i].getBoundingClientRect().top;
|
|
587
|
+
if (Math.abs(top - firstTop) <= 1) {
|
|
588
|
+
countSameRow += 1;
|
|
589
|
+
}
|
|
590
|
+
else {
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (countSameRow > 0) {
|
|
595
|
+
columns = countSameRow;
|
|
596
|
+
detected = countSameRow > 1;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
// Assign reactive state
|
|
601
|
+
this._gridDetected = detected;
|
|
602
|
+
this._gridColumns = Math.max(1, columns);
|
|
603
|
+
if (this._internalDebug) {
|
|
604
|
+
console.info('[ReactiveListManager] grid detection:', {
|
|
605
|
+
detected: this._gridDetected,
|
|
606
|
+
columns: this._gridColumns
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
#countTracksFromTemplate(template) {
|
|
611
|
+
// Count top-level tokens in grid-template-columns
|
|
612
|
+
let depth = 0;
|
|
613
|
+
let tokens = 0;
|
|
614
|
+
let inToken = false;
|
|
615
|
+
for (let i = 0; i < template.length; i += 1) {
|
|
616
|
+
const ch = template[i];
|
|
617
|
+
if (ch === '(')
|
|
618
|
+
depth += 1;
|
|
619
|
+
else if (ch === ')')
|
|
620
|
+
depth = Math.max(0, depth - 1);
|
|
621
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
622
|
+
if (inToken) {
|
|
623
|
+
tokens += 1;
|
|
624
|
+
inToken = false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
else if (ch !== ' ') {
|
|
628
|
+
inToken = true;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (inToken)
|
|
632
|
+
tokens += 1;
|
|
633
|
+
return tokens;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class RecomputeScheduler {
|
|
2
|
+
private onRecompute;
|
|
3
|
+
private isScheduled;
|
|
4
|
+
private isPending;
|
|
5
|
+
private blockDepth;
|
|
6
|
+
private timeoutId;
|
|
7
|
+
constructor(onRecompute: () => void);
|
|
8
|
+
schedule: () => void;
|
|
9
|
+
block: () => void;
|
|
10
|
+
unblock: () => void;
|
|
11
|
+
cancel: () => void;
|
|
12
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export class RecomputeScheduler {
|
|
2
|
+
onRecompute;
|
|
3
|
+
isScheduled = false;
|
|
4
|
+
isPending = false;
|
|
5
|
+
blockDepth = 0;
|
|
6
|
+
timeoutId = null;
|
|
7
|
+
constructor(onRecompute) {
|
|
8
|
+
this.onRecompute = onRecompute;
|
|
9
|
+
}
|
|
10
|
+
schedule = () => {
|
|
11
|
+
if (this.blockDepth > 0) {
|
|
12
|
+
this.isPending = true;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
if (this.isScheduled)
|
|
16
|
+
return;
|
|
17
|
+
this.isScheduled = true;
|
|
18
|
+
if (this.timeoutId) {
|
|
19
|
+
clearTimeout(this.timeoutId);
|
|
20
|
+
this.timeoutId = null;
|
|
21
|
+
}
|
|
22
|
+
this.timeoutId = setTimeout(() => {
|
|
23
|
+
this.timeoutId = null;
|
|
24
|
+
this.isScheduled = false;
|
|
25
|
+
this.onRecompute();
|
|
26
|
+
}, 0);
|
|
27
|
+
};
|
|
28
|
+
block = () => {
|
|
29
|
+
this.blockDepth += 1;
|
|
30
|
+
if (this.timeoutId) {
|
|
31
|
+
clearTimeout(this.timeoutId);
|
|
32
|
+
this.timeoutId = null;
|
|
33
|
+
this.isScheduled = false;
|
|
34
|
+
this.isPending = true;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
unblock = () => {
|
|
38
|
+
if (this.blockDepth === 0)
|
|
39
|
+
return;
|
|
40
|
+
this.blockDepth -= 1;
|
|
41
|
+
if (this.blockDepth === 0 && this.isPending) {
|
|
42
|
+
this.isPending = false;
|
|
43
|
+
this.onRecompute();
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
cancel = () => {
|
|
47
|
+
if (this.timeoutId) {
|
|
48
|
+
clearTimeout(this.timeoutId);
|
|
49
|
+
this.timeoutId = null;
|
|
50
|
+
}
|
|
51
|
+
this.isScheduled = false;
|
|
52
|
+
this.isPending = false;
|
|
53
|
+
};
|
|
54
|
+
}
|