@humanspeak/svelte-virtual-list 0.2.6 → 0.3.1-beta.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/README.md +50 -13
- package/dist/SvelteVirtualList.svelte +619 -179
- package/dist/SvelteVirtualList.svelte.d.ts +156 -65
- package/dist/reactive-height-manager/INTEGRATION_EXAMPLE.md +136 -0
- package/dist/reactive-height-manager/README.md +324 -0
- package/dist/reactive-height-manager/ReactiveHeightManager.svelte.d.ts +116 -0
- package/dist/reactive-height-manager/ReactiveHeightManager.svelte.js +200 -0
- package/dist/reactive-height-manager/benchmark.d.ts +5 -0
- package/dist/reactive-height-manager/benchmark.js +25 -0
- package/dist/reactive-height-manager/index.d.ts +50 -0
- package/dist/reactive-height-manager/index.js +55 -0
- package/dist/reactive-height-manager/test/TestComponent.svelte +78 -0
- package/dist/reactive-height-manager/test/TestComponent.svelte.d.ts +23 -0
- package/dist/reactive-height-manager/types.d.ts +41 -0
- package/dist/reactive-height-manager/types.js +1 -0
- package/dist/types.d.ts +24 -5
- package/dist/utils/heightCalculation.d.ts +18 -8
- package/dist/utils/heightCalculation.js +18 -11
- package/dist/utils/heightChangeDetection.d.ts +12 -0
- package/dist/utils/heightChangeDetection.js +20 -0
- package/dist/utils/resizeObserver.d.ts +89 -0
- package/dist/utils/resizeObserver.js +119 -0
- package/dist/utils/scrollCalculation.d.ts +47 -0
- package/dist/utils/scrollCalculation.js +167 -0
- package/dist/utils/throttle.d.ts +95 -0
- package/dist/utils/throttle.js +155 -0
- package/dist/utils/types.d.ts +0 -6
- package/dist/utils/virtualList.d.ts +20 -23
- package/dist/utils/virtualList.js +153 -61
- package/dist/utils/virtualListDebug.d.ts +12 -7
- package/dist/utils/virtualListDebug.js +19 -9
- package/package.json +33 -31
|
@@ -41,15 +41,16 @@
|
|
|
41
41
|
- Only visible items + buffer are mounted in the DOM
|
|
42
42
|
- Height caching and estimation for dynamic content
|
|
43
43
|
- Handles resize events and dynamic content changes
|
|
44
|
-
-
|
|
45
|
-
-
|
|
44
|
+
- Optimized for very large lists through virtualization
|
|
45
|
+
- Modular architecture with extracted utility functions
|
|
46
46
|
- Bi-directional support: mode="topToBottom" or "bottomToTop"
|
|
47
47
|
- Designed for extensibility and easy debugging
|
|
48
48
|
|
|
49
49
|
=============================
|
|
50
50
|
== For Contributors ==
|
|
51
51
|
=============================
|
|
52
|
-
-
|
|
52
|
+
- Complex logic is extracted to dedicated utility files in src/lib/utils/
|
|
53
|
+
- Scroll positioning logic is in scrollCalculation.ts (well-tested)
|
|
53
54
|
- Add new features behind feature flags or as optional props
|
|
54
55
|
- Write tests for all new features (see /test and /tests/scroll)
|
|
55
56
|
- Use TypeScript and Svelte 5 runes for all new code
|
|
@@ -60,7 +61,7 @@
|
|
|
60
61
|
MIT License © Humanspeak, Inc.
|
|
61
62
|
-->
|
|
62
63
|
|
|
63
|
-
<script lang="ts">
|
|
64
|
+
<script lang="ts" generics="TItem = any">
|
|
64
65
|
/**
|
|
65
66
|
* SvelteVirtualList Implementation Journey
|
|
66
67
|
*
|
|
@@ -110,7 +111,14 @@
|
|
|
110
111
|
* - Added comprehensive documentation
|
|
111
112
|
* - Optimized debug output to reduce noise
|
|
112
113
|
*
|
|
113
|
-
* 9.
|
|
114
|
+
* 9. Architecture Refactoring ✓
|
|
115
|
+
* - Extracted scroll calculation logic to scrollCalculation.ts utility
|
|
116
|
+
* - Extracted ResizeObserver utilities to resizeObserver.ts
|
|
117
|
+
* - Added comprehensive test coverage for extracted utilities
|
|
118
|
+
* - Improved separation of concerns and maintainability
|
|
119
|
+
* - Simplified initialization (removed unnecessary chunked processing)
|
|
120
|
+
*
|
|
121
|
+
* 10. Future Improvements (Planned)
|
|
114
122
|
* - Add horizontal scrolling support
|
|
115
123
|
* - Implement variable-sized item caching
|
|
116
124
|
* - Add keyboard navigation support
|
|
@@ -128,42 +136,53 @@
|
|
|
128
136
|
* - Debug output optimization
|
|
129
137
|
* - Accurate size calculations with caching
|
|
130
138
|
* - Responsive size adjustments
|
|
139
|
+
* - Modular architecture with testable utility functions
|
|
131
140
|
*
|
|
132
141
|
* Current Architecture:
|
|
133
142
|
* - Four-layer DOM structure for optimal performance
|
|
134
143
|
* - State management using Svelte 5's $state
|
|
135
144
|
* - Reactive height and scroll calculations
|
|
136
145
|
* - Configurable buffer zones for smooth scrolling
|
|
137
|
-
* -
|
|
138
|
-
*
|
|
146
|
+
* - Modular utility system with dedicated helper files:
|
|
147
|
+
* * scrollCalculation.ts: Complex scroll positioning logic
|
|
148
|
+
* * resizeObserver.ts: ResizeObserver management utilities
|
|
149
|
+
* * heightCalculation.ts: Debounced height measurement
|
|
150
|
+
* * virtualList.ts: Core virtual list calculations
|
|
151
|
+
* * virtualListDebug.ts: Debug information utilities
|
|
139
152
|
* - Height caching and estimation system
|
|
140
153
|
* - Progressive size adjustment system
|
|
141
154
|
*/
|
|
142
155
|
|
|
143
156
|
import {
|
|
144
157
|
DEFAULT_SCROLL_OPTIONS,
|
|
158
|
+
type SvelteVirtualListPreviousVisibleRange,
|
|
145
159
|
type SvelteVirtualListProps,
|
|
146
160
|
type SvelteVirtualListScrollOptions
|
|
147
161
|
} from './types.js'
|
|
148
162
|
import { calculateAverageHeightDebounced } from './utils/heightCalculation.js'
|
|
149
163
|
import { createRafScheduler } from './utils/raf.js'
|
|
164
|
+
import { isSignificantHeightChange } from './utils/heightChangeDetection.js'
|
|
150
165
|
import {
|
|
151
166
|
calculateScrollPosition,
|
|
152
167
|
calculateTransformY,
|
|
153
168
|
calculateVisibleRange,
|
|
154
|
-
getScrollOffsetForIndex,
|
|
155
|
-
processChunked,
|
|
156
169
|
updateHeightAndScroll as utilsUpdateHeightAndScroll
|
|
157
170
|
} from './utils/virtualList.js'
|
|
158
171
|
import { createDebugInfo, shouldShowDebugInfo } from './utils/virtualListDebug.js'
|
|
172
|
+
import { calculateScrollTarget } from './utils/scrollCalculation.js'
|
|
173
|
+
import { createAdvancedThrottledCallback } from './utils/throttle.js'
|
|
174
|
+
import { ReactiveHeightManager } from './reactive-height-manager/index.js'
|
|
159
175
|
import { BROWSER } from 'esm-env'
|
|
160
|
-
import { onMount, tick } from 'svelte'
|
|
176
|
+
import { onMount, tick, untrack } from 'svelte'
|
|
161
177
|
|
|
162
178
|
const rafSchedule = createRafScheduler()
|
|
163
|
-
|
|
179
|
+
// Package-specific debug flag - safe for library distribution
|
|
180
|
+
// Enable with: NODE_ENV=development SVELTE_VIRTUAL_LIST_DEBUG=true
|
|
181
|
+
const INTERNAL_DEBUG =
|
|
182
|
+
import.meta.env.DEV && import.meta.env.VITE_SVELTE_VIRTUAL_LIST_DEBUG === 'true'
|
|
164
183
|
/**
|
|
165
184
|
* Core configuration props with default values
|
|
166
|
-
* @type {SvelteVirtualListProps}
|
|
185
|
+
* @type {SvelteVirtualListProps<TItem>}
|
|
167
186
|
*/
|
|
168
187
|
const {
|
|
169
188
|
items = [], // Array of items to be rendered in the virtual list
|
|
@@ -178,7 +197,7 @@
|
|
|
178
197
|
mode = 'topToBottom', // Scroll direction mode
|
|
179
198
|
bufferSize = 20, // Number of items to render outside visible area
|
|
180
199
|
testId // Base test ID for component elements (undefined = no data-testid attributes)
|
|
181
|
-
}: SvelteVirtualListProps = $props()
|
|
200
|
+
}: SvelteVirtualListProps<TItem> = $props()
|
|
182
201
|
|
|
183
202
|
/**
|
|
184
203
|
* DOM References and Core State
|
|
@@ -201,59 +220,379 @@
|
|
|
201
220
|
let isCalculatingHeight = $state(false) // Prevents concurrent height calculations
|
|
202
221
|
let isScrolling = $state(false) // Tracks active scrolling state
|
|
203
222
|
let lastMeasuredIndex = $state(-1) // Index of last measured item
|
|
223
|
+
let lastScrollTopSnapshot = $state(0) // Previous scroll position snapshot
|
|
204
224
|
|
|
205
225
|
/**
|
|
206
226
|
* Timers and Observers
|
|
207
227
|
*/
|
|
208
228
|
let heightUpdateTimeout: ReturnType<typeof setTimeout> | null = null // Debounce timer for height updates
|
|
209
229
|
let resizeObserver: ResizeObserver | null = null // Watches for container size changes
|
|
230
|
+
let itemResizeObserver: ResizeObserver | null = null // Watches for individual item size changes
|
|
210
231
|
|
|
211
232
|
/**
|
|
212
233
|
* Performance Optimization State
|
|
213
234
|
*/
|
|
214
235
|
let heightCache = $state<Record<number, number>>({}) // Cache of measured item heights
|
|
215
|
-
|
|
216
|
-
let
|
|
236
|
+
let dirtyItems = $state(new Set<number>()) // Set of item indices that need height recalculation
|
|
237
|
+
let dirtyItemsCount = $state(0) // Reactive count of dirty items
|
|
217
238
|
|
|
218
|
-
let prevVisibleRange = $state<
|
|
239
|
+
let prevVisibleRange = $state<SvelteVirtualListPreviousVisibleRange | null>(null)
|
|
219
240
|
let prevHeight = $state<number>(0)
|
|
220
241
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Reactive Height Manager - O(1) height calculation system
|
|
244
|
+
* Replaces O(n) totalHeight loop with incremental updates
|
|
245
|
+
*/
|
|
246
|
+
let heightManager = new ReactiveHeightManager({
|
|
247
|
+
itemLength: items.length,
|
|
248
|
+
itemHeight: defaultEstimatedItemHeight
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
// Dynamic update coordination to avoid UA scroll anchoring interference
|
|
252
|
+
let dynamicUpdateInProgress = $state(false)
|
|
253
|
+
let suppressBottomAnchoringUntilMs = $state(0)
|
|
254
|
+
function beginDynamicUpdate(): void {
|
|
255
|
+
dynamicUpdateInProgress = true
|
|
256
|
+
if (viewportElement) {
|
|
257
|
+
viewportElement.style.setProperty('overflow-anchor', 'none')
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
function endDynamicUpdate(): void {
|
|
261
|
+
dynamicUpdateInProgress = false
|
|
262
|
+
if (viewportElement) {
|
|
263
|
+
viewportElement.style.setProperty('overflow-anchor', 'auto')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Handles scroll position corrections when item heights change, ensuring proper positioning
|
|
269
|
+
* relative to the user's scroll context. This function calculates the cumulative impact of
|
|
270
|
+
* height changes above the current viewport and adjusts the scroll position accordingly.
|
|
271
|
+
*
|
|
272
|
+
* The correction logic considers:
|
|
273
|
+
* - Height changes occurring above the visible area (which would shift content)
|
|
274
|
+
* - The current scroll position and visible range
|
|
275
|
+
* - Whether height changes warrant a scroll adjustment
|
|
276
|
+
*
|
|
277
|
+
* This prevents jarring jumps when items resize, maintaining the user's visual context
|
|
278
|
+
* and where they are positioned relative to the current scroll position.
|
|
279
|
+
*/
|
|
280
|
+
const handleHeightChangesScrollCorrection = (
|
|
281
|
+
heightChanges: Array<{ index: number; oldHeight: number; newHeight: number; delta: number }>
|
|
282
|
+
) => {
|
|
283
|
+
if (!viewportElement || !initialized || userHasScrolledAway) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* CRITICAL: BottomToTop Mode Height Change Fix
|
|
289
|
+
* ============================================
|
|
290
|
+
*
|
|
291
|
+
* Problem: In bottomToTop mode, when items change height while user is at bottom,
|
|
292
|
+
* the list would jump to middle positions (e.g. items 1032-1096) instead of
|
|
293
|
+
* staying anchored at bottom showing Item 0.
|
|
294
|
+
*
|
|
295
|
+
* Root Cause: Height calculations using simple averages (items.length * calculatedItemHeight)
|
|
296
|
+
* were drastically skewed by single item changes. Example:
|
|
297
|
+
* - 1 item changes from 20px to 100px (+80px actual change)
|
|
298
|
+
* - Average jumps from 20px to 22.35px (+2.35px per item)
|
|
299
|
+
* - Across 10,000 items: 2.35px × 10,000 = 23,500px total height error!
|
|
300
|
+
* - This caused massive scroll position overshoots and incorrect positioning
|
|
301
|
+
*
|
|
302
|
+
* Solution: Two-step native scrollIntoView approach
|
|
303
|
+
* 1. Fixed skewed height calculations using actual heightCache measurements (see totalHeight)
|
|
304
|
+
* 2. When wasAtBottomBeforeHeightChange=true (captured before any height processing):
|
|
305
|
+
* a) First scroll to approximate bottom position to render Item 0 in virtual viewport
|
|
306
|
+
* b) Use native scrollIntoView() with block:'end' for precise bottom alignment
|
|
307
|
+
*
|
|
308
|
+
* Why This Works:
|
|
309
|
+
* - Uses browser's native scroll logic instead of error-prone manual calculations
|
|
310
|
+
* - Two-step ensures Item 0 exists in DOM before attempting to scroll to it
|
|
311
|
+
* - Native scrollIntoView handles all edge cases (subpixel precision, browser differences)
|
|
312
|
+
* - Eliminates complex math that was accumulating rounding errors
|
|
313
|
+
* - Smooth behavior provides better UX than instant jumps
|
|
314
|
+
*
|
|
315
|
+
* Dependencies:
|
|
316
|
+
* - wasAtBottomBeforeHeightChange: Set to true when first item marked dirty, prevents cascading corrections
|
|
317
|
+
* - totalHeight(): Uses actual heightCache measurements instead of skewed averages
|
|
318
|
+
* - Aggressive scroll correction: Blocked when wasAtBottomBeforeHeightChange=true
|
|
319
|
+
*
|
|
320
|
+
* ⚠️ DO NOT MODIFY WITHOUT EXTENSIVE TESTING ⚠️
|
|
321
|
+
* This fix resolves a complex interaction between:
|
|
322
|
+
* - Virtual list rendering (only ~20 items visible, rest virtualized)
|
|
323
|
+
* - Height change calculations (prone to average skewing with large datasets)
|
|
324
|
+
* - Multiple scroll correction mechanisms (specific vs aggressive)
|
|
325
|
+
* - Bottom anchor positioning in reversed list mode (bottomToTop)
|
|
326
|
+
*
|
|
327
|
+
* Test coverage: tests/bottomToTop/firstItemHeightChange.spec.ts (45 comprehensive tests)
|
|
328
|
+
* Related fixes: See aggressive scroll correction logic ~line 410 with !wasAtBottomBeforeHeightChange
|
|
329
|
+
*/
|
|
330
|
+
if (
|
|
331
|
+
mode === 'bottomToTop' &&
|
|
332
|
+
wasAtBottomBeforeHeightChange &&
|
|
333
|
+
!programmaticScrollInProgress &&
|
|
334
|
+
performance.now() >= suppressBottomAnchoringUntilMs &&
|
|
335
|
+
!dynamicUpdateInProgress
|
|
336
|
+
) {
|
|
337
|
+
// Step 1: Scroll to approximate position to ensure Item 0 gets rendered in virtual viewport
|
|
338
|
+
const approximateScrollTop = Math.max(0, totalHeight() - height)
|
|
339
|
+
viewportElement.scrollTop = approximateScrollTop
|
|
340
|
+
scrollTop = approximateScrollTop
|
|
341
|
+
|
|
342
|
+
// Step 2: Use native scrollIntoView for precise bottom-edge positioning after DOM updates
|
|
343
|
+
tick().then(() => {
|
|
344
|
+
const item0Element = viewportElement.querySelector('[data-original-index="0"]')
|
|
345
|
+
if (item0Element) {
|
|
346
|
+
// Native browser API handles all positioning edge cases perfectly
|
|
347
|
+
item0Element.scrollIntoView({
|
|
348
|
+
block: 'end', // Align Item 0 to bottom edge of viewport
|
|
349
|
+
behavior: 'smooth', // Smooth animation for better UX
|
|
350
|
+
inline: 'nearest' // Minimal horizontal adjustment
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// Sync our internal scroll state with actual DOM position
|
|
354
|
+
scrollTop = viewportElement.scrollTop
|
|
236
355
|
}
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
return // Skip remaining scroll correction logic - we've handled bottomToTop case
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const currentScrollTop = viewportElement.scrollTop
|
|
362
|
+
const maxScrollTop = Math.max(0, totalHeight() - height)
|
|
363
|
+
|
|
364
|
+
// Calculate total height change impact above current visible area
|
|
365
|
+
let heightChangeAboveViewport = 0
|
|
366
|
+
const currentVisibleRange = visibleItems()
|
|
367
|
+
|
|
368
|
+
for (const change of heightChanges) {
|
|
369
|
+
// Only consider items that are above the current visible range
|
|
370
|
+
if (change.index < currentVisibleRange.start) {
|
|
371
|
+
heightChangeAboveViewport += change.delta
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// If there are height changes above the viewport, adjust scroll to maintain position
|
|
376
|
+
if (Math.abs(heightChangeAboveViewport) > 1) {
|
|
377
|
+
const newScrollTop = Math.min(
|
|
378
|
+
maxScrollTop,
|
|
379
|
+
Math.max(0, currentScrollTop + heightChangeAboveViewport)
|
|
237
380
|
)
|
|
381
|
+
|
|
382
|
+
viewportElement.scrollTop = newScrollTop
|
|
383
|
+
scrollTop = newScrollTop
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Height update function - removed throttling to fix race condition on initial load
|
|
388
|
+
// Create throttled height update function with trailing execution to ensure measurement always happens
|
|
389
|
+
const triggerHeightUpdate = createAdvancedThrottledCallback(
|
|
390
|
+
() => {
|
|
391
|
+
if (BROWSER && dirtyItemsCount > 0) {
|
|
392
|
+
// Capture bottom state before any height processing to prevent cascading corrections
|
|
393
|
+
wasAtBottomBeforeHeightChange = atBottom
|
|
394
|
+
beginDynamicUpdate()
|
|
395
|
+
updateHeight()
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
16,
|
|
399
|
+
{
|
|
400
|
+
leading: true, // Execute immediately for responsiveness
|
|
401
|
+
trailing: true // CRUCIAL: Execute the last call after delay to ensure measurement always happens
|
|
238
402
|
}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
// Trigger height calculation when dirty items are added
|
|
406
|
+
$effect(() => {
|
|
407
|
+
triggerHeightUpdate()
|
|
239
408
|
})
|
|
240
409
|
|
|
241
|
-
//
|
|
410
|
+
// Keep height manager synchronized with items length
|
|
242
411
|
$effect(() => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
412
|
+
heightManager.updateItemLength(items.length)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
const updateHeight = () => {
|
|
416
|
+
heightUpdateTimeout = calculateAverageHeightDebounced(
|
|
417
|
+
isCalculatingHeight,
|
|
418
|
+
heightUpdateTimeout,
|
|
419
|
+
visibleItems,
|
|
420
|
+
itemElements,
|
|
421
|
+
heightCache,
|
|
422
|
+
lastMeasuredIndex,
|
|
423
|
+
heightManager.averageHeight,
|
|
424
|
+
(result) => {
|
|
425
|
+
// Critical updates that must trigger reactive effects immediately
|
|
426
|
+
heightManager.itemHeight = result.newHeight
|
|
427
|
+
lastMeasuredIndex = result.newLastMeasuredIndex
|
|
428
|
+
heightCache = result.updatedHeightCache
|
|
429
|
+
|
|
430
|
+
// Handle height changes for scroll correction (needs updated heightCache)
|
|
431
|
+
if (result.heightChanges.length > 0 && mode === 'bottomToTop') {
|
|
432
|
+
handleHeightChangesScrollCorrection(result.heightChanges)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Non-critical updates wrapped in untrack to prevent reactive cascades
|
|
436
|
+
untrack(() => {
|
|
437
|
+
// Process height changes with ReactiveHeightManager (O(dirty) instead of O(n)!)
|
|
438
|
+
if (result.heightChanges.length > 0) {
|
|
439
|
+
heightManager.processDirtyHeights(result.heightChanges)
|
|
253
440
|
}
|
|
441
|
+
|
|
442
|
+
// Clear processed dirty items (all dirty items were processed)
|
|
443
|
+
dirtyItems.clear()
|
|
444
|
+
dirtyItemsCount = 0
|
|
445
|
+
|
|
446
|
+
// Reset bottom state flag
|
|
447
|
+
wasAtBottomBeforeHeightChange = false
|
|
254
448
|
})
|
|
449
|
+
endDynamicUpdate()
|
|
450
|
+
},
|
|
451
|
+
100, // debounceTime
|
|
452
|
+
dirtyItems, // Pass dirty items for processing
|
|
453
|
+
0, // Don't pass ReactiveHeightManager state - let each system manage its own totals
|
|
454
|
+
0, // Don't pass ReactiveHeightManager state - let each system manage its own totals
|
|
455
|
+
mode // Pass mode for correct element indexing
|
|
456
|
+
)
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Add new effect to handle height changes
|
|
460
|
+
// Track if user has scrolled away from bottom to prevent snap-back
|
|
461
|
+
let userHasScrolledAway = $state(false)
|
|
462
|
+
let programmaticScrollInProgress = $state(false) // Prevent bottom-anchoring during programmatic scrolls
|
|
463
|
+
let lastCalculatedHeight = $state(0)
|
|
464
|
+
let lastItemsLength = $state(0)
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* CRITICAL: O(1) Reactive Total Height Calculation
|
|
468
|
+
* ===============================================
|
|
469
|
+
*
|
|
470
|
+
* Uses ReactiveHeightManager for O(1) height calculations instead of O(n) loops.
|
|
471
|
+
* This fixes the root cause of massive scroll jumps in bottomToTop mode.
|
|
472
|
+
*
|
|
473
|
+
* Problem with Previous O(n) Approach:
|
|
474
|
+
* - Looped through ALL items on every reactive update
|
|
475
|
+
* - Used simple: items.length * calculatedItemHeight
|
|
476
|
+
* - When 1 item changes from 20px to 100px in 10,000 items:
|
|
477
|
+
* - calculatedItemHeight jumps from 20 to 22.35 (+2.35px)
|
|
478
|
+
* - Total height jumps from 200,000px to 223,500px (+23,500px!)
|
|
479
|
+
* - This 23,500px error caused massive scroll position overshoots
|
|
480
|
+
*
|
|
481
|
+
* Solution with ReactiveHeightManager:
|
|
482
|
+
* - O(1) reactive calculations using incremental updates
|
|
483
|
+
* - Uses actual measured heights from heightCache where available
|
|
484
|
+
* - Only estimates heights for items that haven't been measured yet
|
|
485
|
+
* - Processes only dirty/changed heights instead of all items
|
|
486
|
+
*
|
|
487
|
+
* Example with O(1) Approach:
|
|
488
|
+
* - 20 items measured: 19 × 20px + 1 × 100px = 460px measured
|
|
489
|
+
* - 9,980 unmeasured: 9,980 × 23px (avg of measured) = 229,540px estimated
|
|
490
|
+
* - Total: 460px + 229,540px = 230,000px (only +30,000px vs +23,500px error)
|
|
491
|
+
* - Much smaller error that doesn't cause massive scroll jumps
|
|
492
|
+
* - Updates incrementally using processDirtyHeights() instead of recalculating all
|
|
493
|
+
*
|
|
494
|
+
* This getter is reactive and updates whenever heightManager's internal state changes.
|
|
495
|
+
* Used by: atBottom calculation, scroll corrections, maxScrollTop calculations
|
|
496
|
+
*/
|
|
497
|
+
let totalHeight = $derived(() => heightManager.totalHeight)
|
|
498
|
+
|
|
499
|
+
let atTop = $derived(scrollTop <= 1)
|
|
500
|
+
let atBottom = $derived(scrollTop >= totalHeight() - height - 1)
|
|
501
|
+
let wasAtBottomBeforeHeightChange = false
|
|
502
|
+
let lastVisibleRange: SvelteVirtualListPreviousVisibleRange | null = null
|
|
503
|
+
|
|
504
|
+
$inspect('scrollState: atTop', atTop)
|
|
505
|
+
$inspect('scrollState: atBottom', atBottom)
|
|
506
|
+
|
|
507
|
+
$effect(() => {
|
|
508
|
+
if (BROWSER && initialized && mode === 'bottomToTop' && viewportElement) {
|
|
509
|
+
const targetScrollTop = Math.max(0, totalHeight() - height)
|
|
510
|
+
const currentScrollTop = viewportElement.scrollTop
|
|
511
|
+
const scrollDifference = Math.abs(currentScrollTop - targetScrollTop)
|
|
512
|
+
|
|
513
|
+
// Only correct scroll if:
|
|
514
|
+
// 1. Item height changed significantly (not just user scrolling)
|
|
515
|
+
// 2. User hasn't intentionally scrolled away from bottom
|
|
516
|
+
// 3. We're significantly off target
|
|
517
|
+
// 4. We're not at the bottom (where height changes should be handled more carefully)
|
|
518
|
+
const heightChanged = Math.abs(calculatedItemHeight - lastCalculatedHeight) > 1
|
|
519
|
+
const maxScrollTop = Math.max(0, totalHeight() - height)
|
|
520
|
+
|
|
521
|
+
// In bottomToTop mode, we're "at bottom" when scroll is at max position
|
|
522
|
+
const isAtBottom = Math.abs(currentScrollTop - maxScrollTop) < calculatedItemHeight
|
|
523
|
+
const shouldCorrect =
|
|
524
|
+
heightChanged &&
|
|
525
|
+
!userHasScrolledAway &&
|
|
526
|
+
!isAtBottom && // Don't apply aggressive correction when at bottom
|
|
527
|
+
!programmaticScrollInProgress && // Don't interfere with programmatic scrolls
|
|
528
|
+
performance.now() >= suppressBottomAnchoringUntilMs &&
|
|
529
|
+
!dynamicUpdateInProgress &&
|
|
530
|
+
scrollDifference > calculatedItemHeight * 3
|
|
531
|
+
|
|
532
|
+
if (shouldCorrect) {
|
|
533
|
+
// Round to avoid subpixel positioning issues in bottomToTop mode
|
|
534
|
+
const roundedTargetScrollTop = Math.round(targetScrollTop)
|
|
535
|
+
viewportElement.scrollTop = roundedTargetScrollTop
|
|
536
|
+
scrollTop = roundedTargetScrollTop
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Track if user has scrolled significantly away from bottom
|
|
540
|
+
if (scrollDifference > calculatedItemHeight * 5) {
|
|
541
|
+
userHasScrolledAway = true
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
lastCalculatedHeight = calculatedItemHeight
|
|
545
|
+
}
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
// Handle items being added/removed in bottomToTop mode
|
|
549
|
+
$effect(() => {
|
|
550
|
+
// Only track items.length to prevent re-runs on other reactive changes
|
|
551
|
+
const currentItemsLength = items.length
|
|
552
|
+
|
|
553
|
+
if (
|
|
554
|
+
BROWSER &&
|
|
555
|
+
initialized &&
|
|
556
|
+
mode === 'bottomToTop' &&
|
|
557
|
+
viewportElement &&
|
|
558
|
+
lastItemsLength > 0
|
|
559
|
+
) {
|
|
560
|
+
const itemsAdded = currentItemsLength - lastItemsLength
|
|
561
|
+
|
|
562
|
+
if (itemsAdded !== 0) {
|
|
563
|
+
// Capture all reactive values immediately to prevent re-triggering
|
|
564
|
+
const currentScrollTop = viewportElement.scrollTop
|
|
565
|
+
const currentCalculatedItemHeight = calculatedItemHeight
|
|
566
|
+
const currentHeight = height
|
|
567
|
+
const currentTotalHeight = totalHeight()
|
|
568
|
+
const maxScrollTop = Math.max(0, currentTotalHeight - currentHeight)
|
|
569
|
+
|
|
570
|
+
// Check if user was at/near the bottom before items were added
|
|
571
|
+
const wasNearBottom =
|
|
572
|
+
Math.abs(
|
|
573
|
+
currentScrollTop -
|
|
574
|
+
Math.max(
|
|
575
|
+
0,
|
|
576
|
+
lastItemsLength * currentCalculatedItemHeight - currentHeight
|
|
577
|
+
)
|
|
578
|
+
) <
|
|
579
|
+
currentCalculatedItemHeight * 2
|
|
580
|
+
|
|
581
|
+
if (wasNearBottom || currentScrollTop === 0) {
|
|
582
|
+
// User was at bottom, keep them at bottom after new items are added
|
|
583
|
+
beginDynamicUpdate()
|
|
584
|
+
const newScrollTop = maxScrollTop
|
|
585
|
+
viewportElement.scrollTop = newScrollTop
|
|
586
|
+
scrollTop = newScrollTop
|
|
587
|
+
|
|
588
|
+
// Reset the "scrolled away" flag since we're actively managing position
|
|
589
|
+
userHasScrolledAway = false
|
|
590
|
+
endDynamicUpdate()
|
|
591
|
+
}
|
|
255
592
|
}
|
|
256
593
|
}
|
|
594
|
+
|
|
595
|
+
lastItemsLength = currentItemsLength
|
|
257
596
|
})
|
|
258
597
|
|
|
259
598
|
// Update container height when element is mounted
|
|
@@ -273,8 +612,7 @@
|
|
|
273
612
|
items.length &&
|
|
274
613
|
!initialized
|
|
275
614
|
) {
|
|
276
|
-
const
|
|
277
|
-
const targetScrollTop = Math.max(0, totalHeight - height)
|
|
615
|
+
const targetScrollTop = Math.max(0, totalHeight() - height)
|
|
278
616
|
|
|
279
617
|
// Add delay to ensure layout is complete
|
|
280
618
|
tick().then(() => {
|
|
@@ -313,20 +651,49 @@
|
|
|
313
651
|
* console.log(`Rendering items from ${range.start} to ${range.end}`)
|
|
314
652
|
* ```
|
|
315
653
|
*
|
|
316
|
-
* @returns {
|
|
654
|
+
* @returns {SvelteVirtualListPreviousVisibleRange} Object containing start and end indices of visible items
|
|
317
655
|
*/
|
|
318
|
-
const visibleItems = $derived(() => {
|
|
319
|
-
if (!items.length) return { start: 0, end: 0 }
|
|
656
|
+
const visibleItems = $derived((): SvelteVirtualListPreviousVisibleRange => {
|
|
657
|
+
if (!items.length) return { start: 0, end: 0 } as SvelteVirtualListPreviousVisibleRange
|
|
320
658
|
const viewportHeight = height || 0
|
|
321
659
|
|
|
322
|
-
|
|
660
|
+
// For bottomToTop mode, don't calculate visible range until properly initialized
|
|
661
|
+
// This prevents showing wrong items when scrollTop starts at 0
|
|
662
|
+
if (mode === 'bottomToTop' && !initialized && scrollTop === 0 && viewportHeight > 0) {
|
|
663
|
+
// Calculate what the correct scroll position should be
|
|
664
|
+
const targetScrollTop = Math.max(0, totalHeight() - viewportHeight)
|
|
665
|
+
|
|
666
|
+
// Use the target scroll position for visible range calculation
|
|
667
|
+
lastVisibleRange = calculateVisibleRange(
|
|
668
|
+
targetScrollTop,
|
|
669
|
+
viewportHeight,
|
|
670
|
+
heightManager.averageHeight,
|
|
671
|
+
items.length,
|
|
672
|
+
bufferSize,
|
|
673
|
+
mode,
|
|
674
|
+
atBottom,
|
|
675
|
+
wasAtBottomBeforeHeightChange,
|
|
676
|
+
lastVisibleRange,
|
|
677
|
+
totalHeight()
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
return lastVisibleRange
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
lastVisibleRange = calculateVisibleRange(
|
|
323
684
|
scrollTop,
|
|
324
685
|
viewportHeight,
|
|
325
|
-
|
|
686
|
+
heightManager.averageHeight,
|
|
326
687
|
items.length,
|
|
327
688
|
bufferSize,
|
|
328
|
-
mode
|
|
689
|
+
mode,
|
|
690
|
+
atBottom,
|
|
691
|
+
wasAtBottomBeforeHeightChange,
|
|
692
|
+
lastVisibleRange,
|
|
693
|
+
totalHeight()
|
|
329
694
|
)
|
|
695
|
+
|
|
696
|
+
return lastVisibleRange
|
|
330
697
|
})
|
|
331
698
|
|
|
332
699
|
/**
|
|
@@ -356,7 +723,16 @@
|
|
|
356
723
|
if (!isScrolling) {
|
|
357
724
|
isScrolling = true
|
|
358
725
|
rafSchedule(() => {
|
|
359
|
-
|
|
726
|
+
const current = viewportElement.scrollTop
|
|
727
|
+
if (mode === 'bottomToTop') {
|
|
728
|
+
const delta = lastScrollTopSnapshot - current
|
|
729
|
+
if (delta > 0.5) {
|
|
730
|
+
suppressBottomAnchoringUntilMs = performance.now() + 300
|
|
731
|
+
userHasScrolledAway = true
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
lastScrollTopSnapshot = current
|
|
735
|
+
scrollTop = current
|
|
360
736
|
isScrolling = false
|
|
361
737
|
})
|
|
362
738
|
}
|
|
@@ -435,52 +811,51 @@
|
|
|
435
811
|
)
|
|
436
812
|
}
|
|
437
813
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
* For datasets larger than 1000 items, this method is automatically used
|
|
446
|
-
* instead of immediate initialization. The chunk size is controlled by the
|
|
447
|
-
* component's chunkSize state (default: 50).
|
|
448
|
-
*
|
|
449
|
-
* @async
|
|
450
|
-
* @example
|
|
451
|
-
* ```typescript
|
|
452
|
-
* // Component initialization
|
|
453
|
-
* $effect(() => {
|
|
454
|
-
* if (BROWSER && items.length > 1000) {
|
|
455
|
-
* initializeChunked()
|
|
456
|
-
* } else {
|
|
457
|
-
* initialized = true
|
|
458
|
-
* }
|
|
459
|
-
* })
|
|
460
|
-
* ```
|
|
461
|
-
*
|
|
462
|
-
* @throws {Error} If processChunked fails to complete initialization
|
|
463
|
-
* @returns {Promise<void>} Resolves when all chunks have been processed
|
|
464
|
-
*/
|
|
465
|
-
const initializeChunked = async () => {
|
|
466
|
-
if (!items.length) return
|
|
814
|
+
// Create itemResizeObserver immediately when in browser
|
|
815
|
+
if (BROWSER) {
|
|
816
|
+
// Watch for individual item size changes
|
|
817
|
+
itemResizeObserver = new ResizeObserver((entries) => {
|
|
818
|
+
tick().then(() => {
|
|
819
|
+
let shouldRecalculate = false
|
|
820
|
+
const visibleRange = visibleItems() // Cache once to avoid reactive loops
|
|
467
821
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
() => (initialized = true)
|
|
473
|
-
)
|
|
474
|
-
}
|
|
822
|
+
for (const entry of entries) {
|
|
823
|
+
const element = entry.target as HTMLElement
|
|
824
|
+
const elementIndex = itemElements.indexOf(element)
|
|
825
|
+
const actualIndex = parseInt(element.dataset.originalIndex || '-1', 10)
|
|
475
826
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
827
|
+
if (elementIndex !== -1) {
|
|
828
|
+
if (actualIndex >= 0) {
|
|
829
|
+
const currentHeight = element.getBoundingClientRect().height
|
|
830
|
+
const isSignificant = isSignificantHeightChange(
|
|
831
|
+
actualIndex,
|
|
832
|
+
currentHeight,
|
|
833
|
+
heightCache
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
// Only mark as dirty if height change is significant
|
|
837
|
+
if (isSignificant) {
|
|
838
|
+
// Capture bottom state when FIRST item gets marked dirty
|
|
839
|
+
if (dirtyItemsCount === 0) {
|
|
840
|
+
wasAtBottomBeforeHeightChange = atBottom
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
dirtyItems.add(actualIndex)
|
|
844
|
+
dirtyItemsCount = dirtyItems.size
|
|
845
|
+
shouldRecalculate = true
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (shouldRecalculate) {
|
|
852
|
+
rafSchedule(() => {
|
|
853
|
+
updateHeight()
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
})
|
|
857
|
+
})
|
|
858
|
+
}
|
|
484
859
|
|
|
485
860
|
// Setup and cleanup
|
|
486
861
|
onMount(() => {
|
|
@@ -502,13 +877,16 @@
|
|
|
502
877
|
if (resizeObserver) {
|
|
503
878
|
resizeObserver.disconnect()
|
|
504
879
|
}
|
|
880
|
+
if (itemResizeObserver) {
|
|
881
|
+
itemResizeObserver.disconnect()
|
|
882
|
+
}
|
|
505
883
|
}
|
|
506
884
|
}
|
|
507
885
|
})
|
|
508
886
|
|
|
509
887
|
// Add the effect in the script section
|
|
510
888
|
$effect(() => {
|
|
511
|
-
if (
|
|
889
|
+
if (INTERNAL_DEBUG) {
|
|
512
890
|
prevVisibleRange = visibleItems()
|
|
513
891
|
prevHeight = calculatedItemHeight
|
|
514
892
|
}
|
|
@@ -582,10 +960,10 @@
|
|
|
582
960
|
* {/snippet}
|
|
583
961
|
* </SvelteVirtualList>
|
|
584
962
|
*
|
|
585
|
-
* @returns {void}
|
|
963
|
+
* @returns {Promise<void>} Promise that resolves when scrolling is complete
|
|
586
964
|
* @throws {Error} If the index is out of bounds and shouldThrowOnBounds is true
|
|
587
965
|
*/
|
|
588
|
-
export const scroll = (options: SvelteVirtualListScrollOptions): void => {
|
|
966
|
+
export const scroll = async (options: SvelteVirtualListScrollOptions): Promise<void> => {
|
|
589
967
|
const { index, smoothScroll, shouldThrowOnBounds, align } = {
|
|
590
968
|
...DEFAULT_SCROLL_OPTIONS,
|
|
591
969
|
...options
|
|
@@ -613,73 +991,92 @@
|
|
|
613
991
|
}
|
|
614
992
|
|
|
615
993
|
const { start: firstVisibleIndex, end: lastVisibleIndex } = visibleItems()
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
calculatedItemHeight,
|
|
655
|
-
targetIndex + 1
|
|
656
|
-
)
|
|
657
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
658
|
-
} else {
|
|
659
|
-
// Already in view, do nothing
|
|
660
|
-
return
|
|
994
|
+
|
|
995
|
+
// Use extracted scroll calculation utility
|
|
996
|
+
const scrollTarget = calculateScrollTarget({
|
|
997
|
+
mode,
|
|
998
|
+
align: align || 'auto',
|
|
999
|
+
targetIndex,
|
|
1000
|
+
itemsLength: items.length,
|
|
1001
|
+
calculatedItemHeight: heightManager.averageHeight, // Use dynamic average from ReactiveHeightManager
|
|
1002
|
+
height,
|
|
1003
|
+
scrollTop,
|
|
1004
|
+
firstVisibleIndex,
|
|
1005
|
+
lastVisibleIndex,
|
|
1006
|
+
heightCache
|
|
1007
|
+
})
|
|
1008
|
+
|
|
1009
|
+
// Handle early return for 'nearest' alignment when item is already visible
|
|
1010
|
+
if (scrollTarget === null) {
|
|
1011
|
+
return
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Prevent bottom-anchoring logic from interfering with programmatic scroll
|
|
1015
|
+
programmaticScrollInProgress = true
|
|
1016
|
+
|
|
1017
|
+
// CROSS-BROWSER COMPATIBILITY FIX:
|
|
1018
|
+
// All major browsers (Chrome, Firefox, Safari) have inconsistent behavior with scrollTo()
|
|
1019
|
+
// in bottomToTop mode when using smooth scrolling. Using scrollIntoView() on the highest
|
|
1020
|
+
// visible element provides consistent cross-browser smooth scrolling behavior.
|
|
1021
|
+
// This approach works universally and maintains the user's expected smooth scroll experience.
|
|
1022
|
+
if (mode === 'bottomToTop' && smoothScroll) {
|
|
1023
|
+
// Find the element with the highest original-index in the current viewport
|
|
1024
|
+
const visibleElements = viewportElement.querySelectorAll('[data-original-index]')
|
|
1025
|
+
let maxIndex = -1
|
|
1026
|
+
let maxElement: HTMLElement | null = null
|
|
1027
|
+
for (const el of visibleElements) {
|
|
1028
|
+
const index = parseInt(el.getAttribute('data-original-index') || '-1')
|
|
1029
|
+
if (index > maxIndex) {
|
|
1030
|
+
maxIndex = index
|
|
1031
|
+
maxElement = el as HTMLElement
|
|
661
1032
|
}
|
|
662
|
-
} else if (align === 'top') {
|
|
663
|
-
scrollTarget = getScrollOffsetForIndex(
|
|
664
|
-
heightCache,
|
|
665
|
-
calculatedItemHeight,
|
|
666
|
-
targetIndex
|
|
667
|
-
)
|
|
668
|
-
} else if (align === 'bottom') {
|
|
669
|
-
const itemBottom = getScrollOffsetForIndex(
|
|
670
|
-
heightCache,
|
|
671
|
-
calculatedItemHeight,
|
|
672
|
-
targetIndex + 1
|
|
673
|
-
)
|
|
674
|
-
scrollTarget = Math.max(0, itemBottom - height)
|
|
675
1033
|
}
|
|
676
|
-
}
|
|
677
1034
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
top: scrollTarget,
|
|
681
|
-
behavior: smoothScroll ? 'smooth' : 'auto'
|
|
1035
|
+
maxElement?.scrollIntoView({
|
|
1036
|
+
behavior: 'smooth'
|
|
682
1037
|
})
|
|
1038
|
+
await tick()
|
|
1039
|
+
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1040
|
+
await tick()
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
viewportElement.scrollTo({
|
|
1044
|
+
top: scrollTarget,
|
|
1045
|
+
behavior: smoothScroll ? 'smooth' : 'auto'
|
|
1046
|
+
})
|
|
1047
|
+
|
|
1048
|
+
// Update scrollTop state in next frame to avoid synchronous re-renders
|
|
1049
|
+
requestAnimationFrame(() => {
|
|
1050
|
+
scrollTop = scrollTarget
|
|
1051
|
+
})
|
|
1052
|
+
|
|
1053
|
+
// Clear the flag after scroll completes
|
|
1054
|
+
setTimeout(
|
|
1055
|
+
() => {
|
|
1056
|
+
programmaticScrollInProgress = false
|
|
1057
|
+
},
|
|
1058
|
+
smoothScroll ? 500 : 100
|
|
1059
|
+
)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Custom Svelte action to automatically observe item elements for size changes.
|
|
1064
|
+
* This action is applied to each item element to detect when its dimensions change.
|
|
1065
|
+
*
|
|
1066
|
+
* @param element - The HTML element to observe
|
|
1067
|
+
* @returns {{ destroy: () => void }} Object with destroy method for cleanup
|
|
1068
|
+
*/
|
|
1069
|
+
function autoObserveItemResize(element: HTMLElement) {
|
|
1070
|
+
if (itemResizeObserver) {
|
|
1071
|
+
itemResizeObserver.observe(element)
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return {
|
|
1075
|
+
destroy() {
|
|
1076
|
+
if (itemResizeObserver) {
|
|
1077
|
+
itemResizeObserver.unobserve(element)
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
683
1080
|
}
|
|
684
1081
|
}
|
|
685
1082
|
</script>
|
|
@@ -710,43 +1107,80 @@
|
|
|
710
1107
|
id="virtual-list-content"
|
|
711
1108
|
{...testId ? { 'data-testid': `${testId}-content` } : {}}
|
|
712
1109
|
class={contentClass ?? 'virtual-list-content'}
|
|
713
|
-
style:height="{
|
|
1110
|
+
style:height="{(() => {
|
|
1111
|
+
// Use ReactiveHeightManager's accurate total height for better cross-browser compatibility
|
|
1112
|
+
return Math.max(height, totalHeight())
|
|
1113
|
+
})()}px"
|
|
714
1114
|
>
|
|
715
1115
|
<!-- Items container is translated to show correct items -->
|
|
716
1116
|
<div
|
|
717
1117
|
id="virtual-list-items"
|
|
718
1118
|
{...testId ? { 'data-testid': `${testId}-items` } : {}}
|
|
719
1119
|
class={itemsClass ?? 'virtual-list-items'}
|
|
720
|
-
style:
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
visibleItems()
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
1120
|
+
style:visibility={height === 0 && mode === 'bottomToTop' ? 'hidden' : 'visible'}
|
|
1121
|
+
style:transform="translateY({(() => {
|
|
1122
|
+
const viewportHeight = height || 0
|
|
1123
|
+
const visibleRange = visibleItems()
|
|
1124
|
+
|
|
1125
|
+
// For bottomToTop mode with few items, provide reasonable initial positioning
|
|
1126
|
+
// even when height is not yet measured to prevent flash
|
|
1127
|
+
let effectiveHeight = viewportHeight
|
|
1128
|
+
if (mode === 'bottomToTop' && viewportHeight === 0 && containerElement) {
|
|
1129
|
+
// Measure height synchronously if available
|
|
1130
|
+
effectiveHeight = containerElement.getBoundingClientRect().height || 400
|
|
1131
|
+
} else if (mode === 'bottomToTop' && viewportHeight === 0) {
|
|
1132
|
+
// Fallback to reasonable default height estimate for initial positioning
|
|
1133
|
+
effectiveHeight = 400
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const transform = calculateTransformY(
|
|
1137
|
+
mode,
|
|
1138
|
+
items.length,
|
|
1139
|
+
visibleRange.end,
|
|
1140
|
+
visibleRange.start,
|
|
1141
|
+
heightManager.averageHeight,
|
|
1142
|
+
effectiveHeight,
|
|
1143
|
+
totalHeight() // Pass ReactiveHeightManager's accurate total height
|
|
1144
|
+
)
|
|
1145
|
+
|
|
1146
|
+
return transform
|
|
1147
|
+
})()}px)"
|
|
727
1148
|
>
|
|
728
|
-
{#each
|
|
729
|
-
|
|
730
|
-
|
|
1149
|
+
{#each (() => {
|
|
1150
|
+
const visibleRange = visibleItems()
|
|
1151
|
+
const slice = mode === 'bottomToTop' ? items
|
|
1152
|
+
.slice(visibleRange.start, visibleRange.end)
|
|
1153
|
+
.reverse() : items.slice(visibleRange.start, visibleRange.end)
|
|
1154
|
+
|
|
1155
|
+
// Map each item with its original index for proper DOM element tracking
|
|
1156
|
+
const itemsWithOriginalIndex = slice.map( (item, sliceIndex) => ({ item, originalIndex: mode === 'bottomToTop' ? visibleRange.end - 1 - sliceIndex : visibleRange.start + sliceIndex, sliceIndex }) )
|
|
1157
|
+
|
|
1158
|
+
return itemsWithOriginalIndex
|
|
1159
|
+
})() as currentItemWithIndex, i (currentItemWithIndex.originalIndex)}
|
|
731
1160
|
<!-- Only debug when visible range or average height changes -->
|
|
732
1161
|
{#if debug && i === 0 && shouldShowDebugInfo(prevVisibleRange, visibleItems(), prevHeight, calculatedItemHeight)}
|
|
733
1162
|
{@const debugInfo = createDebugInfo(
|
|
734
1163
|
visibleItems(),
|
|
735
1164
|
items.length,
|
|
736
|
-
|
|
737
|
-
calculatedItemHeight
|
|
1165
|
+
Object.keys(heightCache).length,
|
|
1166
|
+
calculatedItemHeight,
|
|
1167
|
+
scrollTop,
|
|
1168
|
+
height || 0,
|
|
1169
|
+
totalHeight()
|
|
738
1170
|
)}
|
|
739
1171
|
{debugFunction
|
|
740
1172
|
? debugFunction(debugInfo)
|
|
741
1173
|
: console.info('Virtual List Debug:', debugInfo)}
|
|
742
1174
|
{/if}
|
|
743
1175
|
<!-- Render each visible item -->
|
|
744
|
-
<div
|
|
1176
|
+
<div
|
|
1177
|
+
bind:this={itemElements[currentItemWithIndex.sliceIndex]}
|
|
1178
|
+
use:autoObserveItemResize
|
|
1179
|
+
data-original-index={currentItemWithIndex.originalIndex}
|
|
1180
|
+
>
|
|
745
1181
|
{@render renderItem(
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
? items.length - (visibleItems().start + i) - 1
|
|
749
|
-
: visibleItems().start + i
|
|
1182
|
+
currentItemWithIndex.item,
|
|
1183
|
+
currentItemWithIndex.originalIndex
|
|
750
1184
|
)}
|
|
751
1185
|
</div>
|
|
752
1186
|
{/each}
|
|
@@ -789,4 +1223,10 @@
|
|
|
789
1223
|
left: 0;
|
|
790
1224
|
top: 0;
|
|
791
1225
|
}
|
|
1226
|
+
|
|
1227
|
+
/* Item wrapper divs should size to their content */
|
|
1228
|
+
.virtual-list-items > div {
|
|
1229
|
+
width: 100%;
|
|
1230
|
+
display: block;
|
|
1231
|
+
}
|
|
792
1232
|
</style>
|