mtrl-addons 0.2.1 → 0.2.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/AI.md +28 -230
- package/CLAUDE.md +882 -0
- package/build.js +286 -110
- package/package.json +2 -1
- package/src/components/vlist/features/api.ts +316 -12
- package/src/components/vlist/features/selection.ts +248 -256
- package/src/components/vlist/features/viewport.ts +1 -7
- package/src/components/vlist/index.ts +1 -0
- package/src/components/vlist/types.ts +140 -8
- package/src/core/layout/schema.ts +81 -38
- package/src/core/viewport/constants.ts +7 -2
- package/src/core/viewport/features/collection.ts +376 -76
- package/src/core/viewport/features/item-size.ts +4 -4
- package/src/core/viewport/features/momentum.ts +11 -2
- package/src/core/viewport/features/rendering.ts +424 -30
- package/src/core/viewport/features/scrolling.ts +41 -25
- package/src/core/viewport/features/utils.ts +11 -5
- package/src/core/viewport/features/virtual.ts +169 -28
- package/src/core/viewport/types.ts +2 -2
- package/src/core/viewport/viewport.ts +29 -10
- package/src/styles/components/_vlist.scss +234 -213
|
@@ -40,7 +40,7 @@ const createSpeedTracker = (): SpeedTracker => ({
|
|
|
40
40
|
const updateSpeedTracker = (
|
|
41
41
|
tracker: SpeedTracker,
|
|
42
42
|
newPosition: number,
|
|
43
|
-
previousPosition: number
|
|
43
|
+
previousPosition: number,
|
|
44
44
|
): SpeedTracker => {
|
|
45
45
|
const now = Date.now();
|
|
46
46
|
const timeDelta = now - tracker.lastTime;
|
|
@@ -112,9 +112,10 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
112
112
|
if (viewportState) {
|
|
113
113
|
totalVirtualSize = viewportState.virtualTotalSize || 0;
|
|
114
114
|
containerSize = viewportState.containerSize || 0;
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
115
|
+
// Sync scrollPosition from viewportState (important for initialScrollIndex)
|
|
116
|
+
if (viewportState.scrollPosition > 0) {
|
|
117
|
+
scrollPosition = viewportState.scrollPosition;
|
|
118
|
+
}
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
// Listen for virtual size changes
|
|
@@ -123,6 +124,17 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
123
124
|
updateScrollBounds(data.totalVirtualSize, containerSize);
|
|
124
125
|
});
|
|
125
126
|
|
|
127
|
+
// Listen for scroll position changes from other features (e.g., virtual.ts after item size detection)
|
|
128
|
+
component.on?.("viewport:scroll-position-sync", (data: any) => {
|
|
129
|
+
if (data.position !== undefined && data.position !== scrollPosition) {
|
|
130
|
+
scrollPosition = data.position;
|
|
131
|
+
// Also update local tracking vars
|
|
132
|
+
totalVirtualSize =
|
|
133
|
+
viewportState?.virtualTotalSize || totalVirtualSize;
|
|
134
|
+
containerSize = viewportState?.containerSize || containerSize;
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
126
138
|
// Listen for container size changes
|
|
127
139
|
component.on?.("viewport:container-size-changed", (data: any) => {
|
|
128
140
|
if (data.containerSize) {
|
|
@@ -239,10 +251,6 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
239
251
|
|
|
240
252
|
newPosition = clamp(newPosition, 0, maxScroll);
|
|
241
253
|
|
|
242
|
-
// console.log(
|
|
243
|
-
// `[Scrolling] Wheel: delta=${delta}, scrollDelta=${scrollDelta}, pos=${scrollPosition} -> ${newPosition}, max=${maxScroll}`
|
|
244
|
-
// );
|
|
245
|
-
|
|
246
254
|
if (newPosition !== scrollPosition) {
|
|
247
255
|
scrollPosition = newPosition;
|
|
248
256
|
const now = Date.now();
|
|
@@ -260,7 +268,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
260
268
|
speedTracker = updateSpeedTracker(
|
|
261
269
|
speedTracker,
|
|
262
270
|
scrollPosition,
|
|
263
|
-
previousPosition
|
|
271
|
+
previousPosition,
|
|
264
272
|
);
|
|
265
273
|
|
|
266
274
|
// Update viewport state
|
|
@@ -306,7 +314,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
306
314
|
speedTracker = updateSpeedTracker(
|
|
307
315
|
speedTracker,
|
|
308
316
|
scrollPosition,
|
|
309
|
-
previousPosition
|
|
317
|
+
previousPosition,
|
|
310
318
|
);
|
|
311
319
|
|
|
312
320
|
// Update viewport state
|
|
@@ -352,7 +360,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
352
360
|
// Scroll to index
|
|
353
361
|
const scrollToIndex = (
|
|
354
362
|
index: number,
|
|
355
|
-
alignment: "start" | "center" | "end" = "start"
|
|
363
|
+
alignment: "start" | "center" | "end" = "start",
|
|
356
364
|
) => {
|
|
357
365
|
// console.log(
|
|
358
366
|
// `[Scrolling] scrollToIndex called: index=${index}, alignment=${alignment}`
|
|
@@ -405,7 +413,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
405
413
|
const scrollToPage = (
|
|
406
414
|
page: number,
|
|
407
415
|
limit: number = 20,
|
|
408
|
-
alignment: "start" | "center" | "end" = "start"
|
|
416
|
+
alignment: "start" | "center" | "end" = "start",
|
|
409
417
|
) => {
|
|
410
418
|
// Validate alignment parameter
|
|
411
419
|
if (
|
|
@@ -413,7 +421,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
413
421
|
!["start", "center", "end"].includes(alignment)
|
|
414
422
|
) {
|
|
415
423
|
console.warn(
|
|
416
|
-
`[Scrolling] Invalid alignment "${alignment}", using "start"
|
|
424
|
+
`[Scrolling] Invalid alignment "${alignment}", using "start"`,
|
|
417
425
|
);
|
|
418
426
|
alignment = "start";
|
|
419
427
|
}
|
|
@@ -427,7 +435,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
427
435
|
const collection = (component.viewport as any).collection;
|
|
428
436
|
if (collection) {
|
|
429
437
|
const highestLoadedPage = Math.floor(
|
|
430
|
-
collection.getLoadedRanges().size
|
|
438
|
+
collection.getLoadedRanges().size,
|
|
431
439
|
);
|
|
432
440
|
|
|
433
441
|
if (page > highestLoadedPage + 1) {
|
|
@@ -435,13 +443,13 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
435
443
|
const maxPagesToLoad = 10; // Reasonable limit
|
|
436
444
|
const targetPage = Math.min(
|
|
437
445
|
page,
|
|
438
|
-
highestLoadedPage + maxPagesToLoad
|
|
446
|
+
highestLoadedPage + maxPagesToLoad,
|
|
439
447
|
);
|
|
440
448
|
|
|
441
449
|
console.warn(
|
|
442
450
|
`[Scrolling] Cannot jump directly to page ${page} in cursor mode. ` +
|
|
443
451
|
`Pages must be loaded sequentially. Current highest page: ${highestLoadedPage}. ` +
|
|
444
|
-
`Will load up to page ${targetPage}
|
|
452
|
+
`Will load up to page ${targetPage}`,
|
|
445
453
|
);
|
|
446
454
|
|
|
447
455
|
// Trigger sequential loading to the target page (limited)
|
|
@@ -449,11 +457,11 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
449
457
|
const currentOffset = highestLoadedPage * limit;
|
|
450
458
|
|
|
451
459
|
// Load pages sequentially up to the limited target
|
|
452
|
-
console.log(
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
);
|
|
460
|
+
// console.log(
|
|
461
|
+
// `[Scrolling] Initiating sequential load from page ${
|
|
462
|
+
// highestLoadedPage + 1
|
|
463
|
+
// } to ${targetPage}`,
|
|
464
|
+
// );
|
|
457
465
|
|
|
458
466
|
// Scroll to the last loaded position first
|
|
459
467
|
const lastLoadedIndex = highestLoadedPage * limit;
|
|
@@ -479,7 +487,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
479
487
|
// Update scroll bounds
|
|
480
488
|
const updateScrollBounds = (
|
|
481
489
|
newTotalSize: number,
|
|
482
|
-
newContainerSize: number
|
|
490
|
+
newContainerSize: number,
|
|
483
491
|
) => {
|
|
484
492
|
totalVirtualSize = newTotalSize;
|
|
485
493
|
containerSize = newContainerSize;
|
|
@@ -489,6 +497,14 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
489
497
|
viewportState.containerSize = newContainerSize;
|
|
490
498
|
}
|
|
491
499
|
|
|
500
|
+
// Don't clamp scroll position until we have real data loaded
|
|
501
|
+
// This prevents resetting initialScrollIndex position before data arrives
|
|
502
|
+
// Check totalItems instead of totalVirtualSize since virtualSize can be non-zero from padding
|
|
503
|
+
const totalItems = viewportState?.totalItems || 0;
|
|
504
|
+
if (totalItems <= 0) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
|
|
492
508
|
// Clamp current position to new bounds
|
|
493
509
|
const maxScroll = Math.max(0, totalVirtualSize - containerSize);
|
|
494
510
|
if (scrollPosition > maxScroll) {
|
|
@@ -500,7 +516,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
500
516
|
const originalScrollToIndex = component.viewport.scrollToIndex;
|
|
501
517
|
component.viewport.scrollToIndex = (
|
|
502
518
|
index: number,
|
|
503
|
-
alignment?: "start" | "center" | "end"
|
|
519
|
+
alignment?: "start" | "center" | "end",
|
|
504
520
|
) => {
|
|
505
521
|
scrollToIndex(index, alignment);
|
|
506
522
|
originalScrollToIndex?.(index, alignment);
|
|
@@ -510,7 +526,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
510
526
|
(component.viewport as any).scrollToPage = (
|
|
511
527
|
page: number,
|
|
512
528
|
limit?: number,
|
|
513
|
-
alignment?: "start" | "center" | "end"
|
|
529
|
+
alignment?: "start" | "center" | "end",
|
|
514
530
|
) => {
|
|
515
531
|
scrollToPage(page, limit, alignment);
|
|
516
532
|
};
|
|
@@ -562,7 +578,7 @@ export const withScrolling = (config: ScrollingConfig = {}) => {
|
|
|
562
578
|
speedTracker = updateSpeedTracker(
|
|
563
579
|
speedTracker,
|
|
564
580
|
scrollPosition,
|
|
565
|
-
previousPosition
|
|
581
|
+
previousPosition,
|
|
566
582
|
);
|
|
567
583
|
|
|
568
584
|
// Update viewport state
|
|
@@ -13,12 +13,18 @@ import type { ViewportContext, ViewportComponent } from "../types";
|
|
|
13
13
|
*/
|
|
14
14
|
export function wrapInitialize<T extends ViewportContext & ViewportComponent>(
|
|
15
15
|
component: T,
|
|
16
|
-
featureInit: () => void
|
|
16
|
+
featureInit: () => void,
|
|
17
17
|
): void {
|
|
18
18
|
const originalInitialize = component.viewport.initialize;
|
|
19
19
|
component.viewport.initialize = () => {
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// Check if already initialized (returns false if already done)
|
|
21
|
+
const result = originalInitialize();
|
|
22
|
+
// Only run feature init if base init actually ran (didn't return false)
|
|
23
|
+
if (result !== false) {
|
|
24
|
+
featureInit();
|
|
25
|
+
}
|
|
26
|
+
// Propagate the result through the chain so outer wrappers know to skip
|
|
27
|
+
return result;
|
|
22
28
|
};
|
|
23
29
|
}
|
|
24
30
|
|
|
@@ -28,7 +34,7 @@ export function wrapInitialize<T extends ViewportContext & ViewportComponent>(
|
|
|
28
34
|
*/
|
|
29
35
|
export function wrapDestroy<T extends Record<string, any>>(
|
|
30
36
|
component: T,
|
|
31
|
-
cleanup: () => void
|
|
37
|
+
cleanup: () => void,
|
|
32
38
|
): void {
|
|
33
39
|
if ("destroy" in component && typeof component.destroy === "function") {
|
|
34
40
|
const originalDestroy = component.destroy;
|
|
@@ -82,7 +88,7 @@ export function clamp(value: number, min: number, max: number): number {
|
|
|
82
88
|
export function storeFeatureFunction<T extends Record<string, any>>(
|
|
83
89
|
component: T,
|
|
84
90
|
name: string,
|
|
85
|
-
fn: Function
|
|
91
|
+
fn: Function,
|
|
86
92
|
): void {
|
|
87
93
|
(component as any)[name] = fn;
|
|
88
94
|
}
|
|
@@ -13,6 +13,7 @@ export interface VirtualConfig {
|
|
|
13
13
|
orientation?: "vertical" | "horizontal";
|
|
14
14
|
autoDetectItemSize?: boolean;
|
|
15
15
|
debug?: boolean;
|
|
16
|
+
initialScrollIndex?: number;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -29,6 +30,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
29
30
|
? VIEWPORT_CONSTANTS.VIRTUAL_SCROLL.AUTO_DETECT_ITEM_SIZE
|
|
30
31
|
: false,
|
|
31
32
|
debug = false,
|
|
33
|
+
initialScrollIndex = 0,
|
|
32
34
|
} = config;
|
|
33
35
|
|
|
34
36
|
// Use provided itemSize or default, but mark if we should auto-detect
|
|
@@ -38,6 +40,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
38
40
|
const MAX_VIRTUAL_SIZE = VIEWPORT_CONSTANTS.VIRTUAL_SCROLL.MAX_VIRTUAL_SIZE;
|
|
39
41
|
let viewportState: any;
|
|
40
42
|
let hasCalculatedItemSize = false;
|
|
43
|
+
let hasRecalculatedScrollForCompression = false; // Track if we've recalculated scroll position for compression
|
|
41
44
|
|
|
42
45
|
// Initialize using shared wrapper
|
|
43
46
|
wrapInitialize(component, () => {
|
|
@@ -54,7 +57,32 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
54
57
|
});
|
|
55
58
|
|
|
56
59
|
updateTotalVirtualSize(viewportState.totalItems);
|
|
57
|
-
|
|
60
|
+
|
|
61
|
+
// If we have an initial scroll index, calculate initial scroll position
|
|
62
|
+
// Note: We store the initialScrollIndex to use directly in range calculations
|
|
63
|
+
// because scroll position may be compressed for large lists
|
|
64
|
+
let initialScrollPosition = viewportState.scrollPosition || 0;
|
|
65
|
+
|
|
66
|
+
if (initialScrollIndex > 0) {
|
|
67
|
+
// Store the target index for use in range calculations
|
|
68
|
+
(viewportState as any).targetScrollIndex = initialScrollIndex;
|
|
69
|
+
|
|
70
|
+
// Calculate scroll position (may be compressed for large lists)
|
|
71
|
+
initialScrollPosition =
|
|
72
|
+
initialScrollIndex * (viewportState.itemSize || initialItemSize);
|
|
73
|
+
viewportState.scrollPosition = initialScrollPosition;
|
|
74
|
+
|
|
75
|
+
// Notify scrolling feature to sync its local scroll position
|
|
76
|
+
// Use setTimeout to ensure scrolling feature has initialized
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
component.emit?.("viewport:scroll-position-sync", {
|
|
79
|
+
position: initialScrollPosition,
|
|
80
|
+
source: "initial-scroll-index",
|
|
81
|
+
});
|
|
82
|
+
}, 0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
updateVisibleRange(initialScrollPosition);
|
|
58
86
|
|
|
59
87
|
// Ensure container size is measured after DOM is ready
|
|
60
88
|
requestAnimationFrame(() => {
|
|
@@ -75,7 +103,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
75
103
|
|
|
76
104
|
// Calculate visible range
|
|
77
105
|
const calculateVisibleRange = (
|
|
78
|
-
scrollPosition: number
|
|
106
|
+
scrollPosition: number,
|
|
79
107
|
): { start: number; end: number } => {
|
|
80
108
|
if (!viewportState) {
|
|
81
109
|
console.warn("[Virtual] No viewport state, returning empty range");
|
|
@@ -91,6 +119,17 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
91
119
|
!totalItems ||
|
|
92
120
|
totalItems <= 0
|
|
93
121
|
) {
|
|
122
|
+
// If we have an initialScrollIndex, use it to calculate the range
|
|
123
|
+
// even when totalItems is 0 (not yet loaded from API)
|
|
124
|
+
if (initialScrollIndex > 0) {
|
|
125
|
+
const visibleCount = Math.ceil(
|
|
126
|
+
(containerSize || 600) /
|
|
127
|
+
(viewportState.itemSize || initialItemSize),
|
|
128
|
+
);
|
|
129
|
+
const start = Math.max(0, initialScrollIndex - overscan);
|
|
130
|
+
const end = initialScrollIndex + visibleCount + overscan;
|
|
131
|
+
return { start, end };
|
|
132
|
+
}
|
|
94
133
|
log(`Invalid state: container=${containerSize}, items=${totalItems}`);
|
|
95
134
|
return { start: 0, end: 0 };
|
|
96
135
|
}
|
|
@@ -102,12 +141,27 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
102
141
|
|
|
103
142
|
let start: number, end: number;
|
|
104
143
|
|
|
144
|
+
// Check if we have a target scroll index (for initialScrollIndex with compression)
|
|
145
|
+
const targetScrollIndex = (viewportState as any).targetScrollIndex;
|
|
146
|
+
|
|
105
147
|
if (compressionRatio < 1) {
|
|
106
148
|
// Compressed space calculation
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
149
|
+
// If we have a targetScrollIndex, use it directly instead of calculating from scroll position
|
|
150
|
+
// This ensures we show the correct items even when virtual space is compressed
|
|
151
|
+
if (targetScrollIndex !== undefined && targetScrollIndex > 0) {
|
|
152
|
+
start = Math.max(0, targetScrollIndex - overscan);
|
|
153
|
+
end = Math.min(
|
|
154
|
+
totalItems - 1,
|
|
155
|
+
targetScrollIndex + visibleCount + overscan,
|
|
156
|
+
);
|
|
157
|
+
// Clear targetScrollIndex after first use so normal scrolling works
|
|
158
|
+
delete (viewportState as any).targetScrollIndex;
|
|
159
|
+
} else {
|
|
160
|
+
const scrollRatio = scrollPosition / virtualSize;
|
|
161
|
+
const exactIndex = scrollRatio * totalItems;
|
|
162
|
+
start = Math.floor(exactIndex);
|
|
163
|
+
end = Math.ceil(exactIndex) + visibleCount;
|
|
164
|
+
}
|
|
111
165
|
|
|
112
166
|
// Near-bottom handling
|
|
113
167
|
const maxScroll = virtualSize - containerSize;
|
|
@@ -115,16 +169,16 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
115
169
|
|
|
116
170
|
if (distanceFromBottom <= containerSize && distanceFromBottom >= -1) {
|
|
117
171
|
const itemsAtBottom = Math.floor(
|
|
118
|
-
containerSize / viewportState.itemSize
|
|
172
|
+
containerSize / viewportState.itemSize,
|
|
119
173
|
);
|
|
120
174
|
const firstVisibleAtBottom = Math.max(0, totalItems - itemsAtBottom);
|
|
121
175
|
const interpolation = Math.max(
|
|
122
176
|
0,
|
|
123
|
-
Math.min(1, 1 - distanceFromBottom / containerSize)
|
|
177
|
+
Math.min(1, 1 - distanceFromBottom / containerSize),
|
|
124
178
|
);
|
|
125
179
|
|
|
126
180
|
start = Math.floor(
|
|
127
|
-
start + (firstVisibleAtBottom - start) * interpolation
|
|
181
|
+
start + (firstVisibleAtBottom - start) * interpolation,
|
|
128
182
|
);
|
|
129
183
|
end =
|
|
130
184
|
distanceFromBottom <= 1
|
|
@@ -148,7 +202,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
148
202
|
// Direct calculation
|
|
149
203
|
start = Math.max(
|
|
150
204
|
0,
|
|
151
|
-
Math.floor(scrollPosition / viewportState.itemSize) - overscan
|
|
205
|
+
Math.floor(scrollPosition / viewportState.itemSize) - overscan,
|
|
152
206
|
);
|
|
153
207
|
end = Math.min(totalItems - 1, start + visibleCount + overscan * 2);
|
|
154
208
|
}
|
|
@@ -179,12 +233,54 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
179
233
|
return { start, end };
|
|
180
234
|
};
|
|
181
235
|
|
|
236
|
+
// Calculate actual visible range (without overscan buffer)
|
|
237
|
+
const calculateActualVisibleRange = (scrollPosition: number) => {
|
|
238
|
+
if (!viewportState) return { start: 0, end: 0 };
|
|
239
|
+
|
|
240
|
+
const { containerSize, totalItems } = viewportState;
|
|
241
|
+
if (!containerSize || !totalItems) return { start: 0, end: 0 };
|
|
242
|
+
|
|
243
|
+
const itemSize = viewportState.itemSize;
|
|
244
|
+
const visibleCount = Math.ceil(containerSize / itemSize);
|
|
245
|
+
const compressionRatio = viewportState.virtualTotalSize
|
|
246
|
+
? (totalItems * itemSize) / viewportState.virtualTotalSize
|
|
247
|
+
: 1;
|
|
248
|
+
|
|
249
|
+
let start: number, end: number;
|
|
250
|
+
|
|
251
|
+
if (compressionRatio < 1) {
|
|
252
|
+
// Compressed space - calculate based on scroll ratio
|
|
253
|
+
const virtualSize =
|
|
254
|
+
viewportState.virtualTotalSize || totalItems * itemSize;
|
|
255
|
+
const scrollRatio = scrollPosition / virtualSize;
|
|
256
|
+
start = Math.floor(scrollRatio * totalItems);
|
|
257
|
+
end = Math.min(totalItems - 1, start + visibleCount - 1);
|
|
258
|
+
} else {
|
|
259
|
+
// Direct calculation
|
|
260
|
+
start = Math.floor(scrollPosition / itemSize);
|
|
261
|
+
end = Math.min(totalItems - 1, start + visibleCount - 1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Ensure valid range
|
|
265
|
+
start = Math.max(0, start);
|
|
266
|
+
end = Math.max(start, end);
|
|
267
|
+
|
|
268
|
+
return { start, end };
|
|
269
|
+
};
|
|
270
|
+
|
|
182
271
|
// Update functions
|
|
183
272
|
const updateVisibleRange = (scrollPosition: number) => {
|
|
184
273
|
if (!viewportState) return;
|
|
185
274
|
viewportState.visibleRange = calculateVisibleRange(scrollPosition);
|
|
275
|
+
|
|
276
|
+
// DEBUG: Log who calls updateVisibleRange with 0 when it should be non-zero
|
|
277
|
+
|
|
278
|
+
// Calculate actual visible range (without overscan) for UI display
|
|
279
|
+
const actualVisibleRange = calculateActualVisibleRange(scrollPosition);
|
|
280
|
+
|
|
186
281
|
component.emit?.("viewport:range-changed", {
|
|
187
282
|
range: viewportState.visibleRange,
|
|
283
|
+
visibleRange: actualVisibleRange,
|
|
188
284
|
scrollPosition,
|
|
189
285
|
});
|
|
190
286
|
};
|
|
@@ -201,7 +297,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
201
297
|
let totalPadding = 0;
|
|
202
298
|
if (viewportState.itemsContainer) {
|
|
203
299
|
const computedStyle = window.getComputedStyle(
|
|
204
|
-
viewportState.itemsContainer
|
|
300
|
+
viewportState.itemsContainer,
|
|
205
301
|
);
|
|
206
302
|
const paddingTop = parseFloat(computedStyle.paddingTop) || 0;
|
|
207
303
|
const paddingBottom = parseFloat(computedStyle.paddingBottom) || 0;
|
|
@@ -211,7 +307,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
211
307
|
// Include padding in the virtual size
|
|
212
308
|
viewportState.virtualTotalSize = Math.min(
|
|
213
309
|
actualSize + totalPadding,
|
|
214
|
-
MAX_VIRTUAL_SIZE
|
|
310
|
+
MAX_VIRTUAL_SIZE,
|
|
215
311
|
);
|
|
216
312
|
|
|
217
313
|
// Strategic log for debugging gap issue
|
|
@@ -259,7 +355,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
259
355
|
|
|
260
356
|
// Event listeners
|
|
261
357
|
component.on?.("viewport:scroll", (data: any) =>
|
|
262
|
-
updateVisibleRange(data.position)
|
|
358
|
+
updateVisibleRange(data.position),
|
|
263
359
|
);
|
|
264
360
|
|
|
265
361
|
component.on?.("viewport:items-changed", (data: any) => {
|
|
@@ -270,20 +366,55 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
270
366
|
});
|
|
271
367
|
|
|
272
368
|
// Listen for total items changes (important for cursor pagination)
|
|
369
|
+
// Note: setTotalItems() updates viewportState.totalItems BEFORE emitting this event,
|
|
370
|
+
// so we can't rely on viewportState.totalItems to detect the first total.
|
|
371
|
+
// Instead we use the hasRecalculatedScrollForCompression flag.
|
|
273
372
|
component.on?.("viewport:total-items-changed", (data: any) => {
|
|
373
|
+
if (data.total === undefined) return;
|
|
374
|
+
|
|
375
|
+
// FIX: When we first receive totalItems with initialScrollIndex and compression,
|
|
376
|
+
// recalculate scroll position to account for compression.
|
|
377
|
+
// Without this, initialScrollIndex calculates position as index * itemSize,
|
|
378
|
+
// but rendering uses compressed space, causing items to appear off-screen.
|
|
379
|
+
//
|
|
380
|
+
// We check this BEFORE updateTotalVirtualSize because we need to recalculate
|
|
381
|
+
// scroll position based on the new total, and the flag ensures we only do this once.
|
|
274
382
|
if (
|
|
275
|
-
|
|
276
|
-
|
|
383
|
+
initialScrollIndex > 0 &&
|
|
384
|
+
!hasRecalculatedScrollForCompression &&
|
|
385
|
+
data.total > 0
|
|
277
386
|
) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
387
|
+
const actualTotalSize = data.total * viewportState.itemSize;
|
|
388
|
+
const isCompressed = actualTotalSize > MAX_VIRTUAL_SIZE;
|
|
389
|
+
|
|
390
|
+
if (isCompressed) {
|
|
391
|
+
// Recalculate scroll position using compression-aware formula
|
|
392
|
+
// Same formula used in scrollToIndex
|
|
393
|
+
const ratio = initialScrollIndex / data.total;
|
|
394
|
+
const compressedPosition = ratio * MAX_VIRTUAL_SIZE;
|
|
395
|
+
|
|
396
|
+
viewportState.scrollPosition = compressedPosition;
|
|
283
397
|
|
|
284
|
-
|
|
285
|
-
|
|
398
|
+
// Notify scrolling feature to sync its local scroll position
|
|
399
|
+
component.emit?.("viewport:scroll-position-sync", {
|
|
400
|
+
position: compressedPosition,
|
|
401
|
+
source: "compression-recalculation",
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Clear targetScrollIndex since we've now correctly calculated the scroll position
|
|
405
|
+
// The normal compressed scrolling formula will now work correctly
|
|
406
|
+
delete (viewportState as any).targetScrollIndex;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Mark as done even if not compressed - we only want to do this check once
|
|
410
|
+
hasRecalculatedScrollForCompression = true;
|
|
286
411
|
}
|
|
412
|
+
|
|
413
|
+
updateTotalVirtualSize(data.total);
|
|
414
|
+
updateVisibleRange(viewportState?.scrollPosition || 0);
|
|
415
|
+
|
|
416
|
+
// Trigger a render to update the view
|
|
417
|
+
component.viewport?.renderItems?.();
|
|
287
418
|
});
|
|
288
419
|
|
|
289
420
|
// Listen for container size changes to recalculate virtual size
|
|
@@ -329,14 +460,24 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
329
460
|
|
|
330
461
|
if (sizes.length > 0) {
|
|
331
462
|
const avgSize = Math.round(
|
|
332
|
-
sizes.reduce((sum, size) => sum + size, 0) / sizes.length
|
|
463
|
+
sizes.reduce((sum, size) => sum + size, 0) / sizes.length,
|
|
333
464
|
);
|
|
334
|
-
|
|
335
|
-
// console.log(
|
|
336
|
-
// `[Virtual] Auto-detected item size: ${avgSize}px (was ${viewportState.itemSize}px), based on ${sizes.length} items`
|
|
337
|
-
// );
|
|
465
|
+
const previousItemSize = viewportState.itemSize;
|
|
338
466
|
viewportState.itemSize = avgSize;
|
|
339
467
|
|
|
468
|
+
// If we have an initialScrollIndex, recalculate scroll position
|
|
469
|
+
// based on the newly detected item size
|
|
470
|
+
if (initialScrollIndex > 0 && avgSize !== previousItemSize) {
|
|
471
|
+
const newScrollPosition = initialScrollIndex * avgSize;
|
|
472
|
+
viewportState.scrollPosition = newScrollPosition;
|
|
473
|
+
|
|
474
|
+
// Notify scrolling feature to sync its local scroll position
|
|
475
|
+
component.emit?.("viewport:scroll-position-sync", {
|
|
476
|
+
position: newScrollPosition,
|
|
477
|
+
source: "item-size-detected",
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
340
481
|
// Recalculate everything with new size
|
|
341
482
|
updateTotalVirtualSize(viewportState.totalItems);
|
|
342
483
|
updateVisibleRange(viewportState.scrollPosition || 0);
|
|
@@ -373,7 +514,7 @@ export const withVirtual = (config: VirtualConfig = {}) => {
|
|
|
373
514
|
calculateIndexFromPosition: (position: number) =>
|
|
374
515
|
Math.floor(
|
|
375
516
|
position /
|
|
376
|
-
((viewportState?.itemSize || initialItemSize) * compressionRatio)
|
|
517
|
+
((viewportState?.itemSize || initialItemSize) * compressionRatio),
|
|
377
518
|
),
|
|
378
519
|
calculatePositionForIndex: (index: number) =>
|
|
379
520
|
index * (viewportState?.itemSize || initialItemSize) * compressionRatio,
|
|
@@ -49,7 +49,7 @@ export interface ViewportConfig {
|
|
|
49
49
|
// Template for rendering items
|
|
50
50
|
template?: (
|
|
51
51
|
item: any,
|
|
52
|
-
index: number
|
|
52
|
+
index: number,
|
|
53
53
|
) => string | HTMLElement | any[] | Record<string, any>;
|
|
54
54
|
|
|
55
55
|
// Collection/data source configuration
|
|
@@ -109,7 +109,7 @@ export interface ViewportConfig {
|
|
|
109
109
|
export interface ViewportComponent extends ViewportContext {
|
|
110
110
|
viewport: {
|
|
111
111
|
// Core API
|
|
112
|
-
initialize(): void;
|
|
112
|
+
initialize(): boolean | void;
|
|
113
113
|
destroy(): void;
|
|
114
114
|
updateViewport(): void;
|
|
115
115
|
|