vlist 1.9.1 → 2.0.0
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.github.md +104 -97
- package/README.md +46 -33
- package/dist/constants.d.ts +11 -6
- package/dist/constants.js +83 -0
- package/dist/core/create.d.ts +10 -0
- package/dist/core/create.js +740 -0
- package/dist/core/dom.d.ts +8 -0
- package/dist/core/dom.js +47 -0
- package/dist/core/hooks.d.ts +16 -0
- package/dist/core/hooks.js +67 -0
- package/dist/core/index.d.ts +17 -0
- package/dist/core/index.js +13 -0
- package/dist/core/pipeline.d.ts +51 -0
- package/dist/core/pipeline.js +307 -0
- package/dist/core/pool.d.ts +9 -0
- package/dist/core/pool.js +42 -0
- package/dist/core/scroll.d.ts +32 -0
- package/dist/core/scroll.js +137 -0
- package/dist/core/sizes.d.ts +8 -0
- package/dist/core/sizes.js +6 -0
- package/dist/core/state.d.ts +47 -0
- package/dist/core/state.js +56 -0
- package/dist/core/types.d.ts +187 -0
- package/dist/core/types.js +7 -0
- package/dist/{builder → core}/velocity.d.ts +1 -1
- package/dist/core/velocity.js +33 -0
- package/dist/events/emitter.js +60 -0
- package/dist/events/index.js +6 -0
- package/dist/index.d.ts +28 -19
- package/dist/index.js +28 -1
- package/dist/internals.d.ts +11 -7
- package/dist/internals.js +60 -1
- package/dist/plugins/a11y/index.d.ts +2 -0
- package/dist/plugins/a11y/index.js +1 -0
- package/dist/plugins/a11y/plugin.d.ts +13 -0
- package/dist/plugins/a11y/plugin.js +259 -0
- package/dist/{features → plugins}/async/index.d.ts +1 -1
- package/dist/plugins/async/index.js +12 -0
- package/dist/{features → plugins}/async/manager.d.ts +5 -1
- package/dist/plugins/async/manager.js +568 -0
- package/dist/plugins/async/placeholder.js +154 -0
- package/dist/plugins/async/plugin.d.ts +48 -0
- package/dist/plugins/async/plugin.js +311 -0
- package/dist/plugins/async/sparse.js +540 -0
- package/dist/plugins/autosize/index.d.ts +5 -0
- package/dist/plugins/autosize/index.js +4 -0
- package/dist/plugins/autosize/plugin.d.ts +19 -0
- package/dist/plugins/autosize/plugin.js +185 -0
- package/dist/plugins/grid/index.d.ts +7 -0
- package/dist/plugins/grid/index.js +5 -0
- package/dist/plugins/grid/layout.js +275 -0
- package/dist/plugins/grid/plugin.d.ts +23 -0
- package/dist/plugins/grid/plugin.js +347 -0
- package/dist/plugins/grid/renderer.js +525 -0
- package/dist/plugins/grid/types.js +11 -0
- package/dist/plugins/groups/async-bridge.js +246 -0
- package/dist/{features → plugins}/groups/index.d.ts +1 -1
- package/dist/plugins/groups/index.js +13 -0
- package/dist/plugins/groups/layout.js +294 -0
- package/dist/plugins/groups/plugin.d.ts +22 -0
- package/dist/plugins/groups/plugin.js +571 -0
- package/dist/plugins/groups/sticky.js +255 -0
- package/dist/plugins/groups/types.js +12 -0
- package/dist/plugins/masonry/index.d.ts +8 -0
- package/dist/plugins/masonry/index.js +6 -0
- package/dist/plugins/masonry/layout.js +261 -0
- package/dist/plugins/masonry/plugin.d.ts +32 -0
- package/dist/plugins/masonry/plugin.js +381 -0
- package/dist/plugins/masonry/renderer.js +354 -0
- package/dist/plugins/masonry/types.js +9 -0
- package/dist/plugins/page/index.d.ts +5 -0
- package/dist/plugins/page/index.js +5 -0
- package/dist/plugins/page/plugin.d.ts +21 -0
- package/dist/plugins/page/plugin.js +166 -0
- package/dist/plugins/scale/index.d.ts +5 -0
- package/dist/plugins/scale/index.js +4 -0
- package/dist/plugins/scale/plugin.d.ts +24 -0
- package/dist/plugins/scale/plugin.js +507 -0
- package/dist/plugins/scrollbar/controller.js +574 -0
- package/dist/plugins/scrollbar/index.d.ts +7 -0
- package/dist/plugins/scrollbar/index.js +6 -0
- package/dist/plugins/scrollbar/plugin.d.ts +20 -0
- package/dist/plugins/scrollbar/plugin.js +93 -0
- package/dist/plugins/scrollbar/scrollbar.js +556 -0
- package/dist/plugins/selection/index.d.ts +6 -0
- package/dist/plugins/selection/index.js +7 -0
- package/dist/plugins/selection/plugin.d.ts +16 -0
- package/dist/plugins/selection/plugin.js +601 -0
- package/dist/{features → plugins}/selection/state.d.ts +8 -0
- package/dist/plugins/selection/state.js +332 -0
- package/dist/plugins/snapshots/index.d.ts +5 -0
- package/dist/plugins/snapshots/index.js +5 -0
- package/dist/plugins/snapshots/plugin.d.ts +17 -0
- package/dist/plugins/snapshots/plugin.js +301 -0
- package/dist/plugins/sortable/index.d.ts +6 -0
- package/dist/plugins/sortable/index.js +6 -0
- package/dist/plugins/sortable/plugin.d.ts +34 -0
- package/dist/plugins/sortable/plugin.js +753 -0
- package/dist/plugins/table/header.js +501 -0
- package/dist/{features → plugins}/table/index.d.ts +1 -1
- package/dist/plugins/table/index.js +12 -0
- package/dist/plugins/table/layout.js +211 -0
- package/dist/plugins/table/plugin.d.ts +20 -0
- package/dist/plugins/table/plugin.js +391 -0
- package/dist/plugins/table/renderer.js +625 -0
- package/dist/plugins/table/types.js +12 -0
- package/dist/plugins/transition/index.d.ts +5 -0
- package/dist/plugins/transition/index.js +5 -0
- package/dist/plugins/transition/plugin.d.ts +22 -0
- package/dist/plugins/transition/plugin.js +405 -0
- package/dist/rendering/aria.js +23 -0
- package/dist/rendering/index.js +18 -0
- package/dist/rendering/measured.js +98 -0
- package/dist/rendering/renderer.js +586 -0
- package/dist/rendering/scale.js +267 -0
- package/dist/rendering/scroll.js +71 -0
- package/dist/rendering/sizes.js +193 -0
- package/dist/rendering/sort.js +65 -0
- package/dist/rendering/viewport.js +268 -0
- package/dist/size.json +1 -1
- package/dist/types.js +5 -0
- package/dist/utils/padding.d.ts +2 -4
- package/dist/utils/padding.js +49 -0
- package/dist/utils/stats.js +124 -0
- package/dist/vlist-grid.css +1 -1
- package/dist/vlist-masonry.css +1 -1
- package/dist/vlist-table.css +1 -1
- package/dist/vlist.css +1 -1
- package/package.json +9 -4
- package/dist/builder/a11y.d.ts +0 -16
- package/dist/builder/api.d.ts +0 -21
- package/dist/builder/context.d.ts +0 -36
- package/dist/builder/core.d.ts +0 -16
- package/dist/builder/data.d.ts +0 -71
- package/dist/builder/dom.d.ts +0 -15
- package/dist/builder/index.d.ts +0 -25
- package/dist/builder/materialize.d.ts +0 -166
- package/dist/builder/pool.d.ts +0 -10
- package/dist/builder/range.d.ts +0 -10
- package/dist/builder/scroll.d.ts +0 -24
- package/dist/builder/types.d.ts +0 -512
- package/dist/features/async/feature.d.ts +0 -72
- package/dist/features/autosize/feature.d.ts +0 -34
- package/dist/features/autosize/index.d.ts +0 -2
- package/dist/features/grid/feature.d.ts +0 -48
- package/dist/features/grid/index.d.ts +0 -9
- package/dist/features/groups/feature.d.ts +0 -75
- package/dist/features/masonry/feature.d.ts +0 -45
- package/dist/features/masonry/index.d.ts +0 -9
- package/dist/features/page/feature.d.ts +0 -109
- package/dist/features/page/index.d.ts +0 -9
- package/dist/features/scale/feature.d.ts +0 -42
- package/dist/features/scale/index.d.ts +0 -10
- package/dist/features/scrollbar/feature.d.ts +0 -81
- package/dist/features/scrollbar/index.d.ts +0 -8
- package/dist/features/selection/feature.d.ts +0 -91
- package/dist/features/selection/index.d.ts +0 -7
- package/dist/features/snapshots/feature.d.ts +0 -79
- package/dist/features/snapshots/index.d.ts +0 -9
- package/dist/features/sortable/feature.d.ts +0 -101
- package/dist/features/sortable/index.d.ts +0 -6
- package/dist/features/table/feature.d.ts +0 -67
- package/dist/features/transition/feature.d.ts +0 -30
- package/dist/features/transition/index.d.ts +0 -9
- /package/dist/{features → plugins}/async/placeholder.d.ts +0 -0
- /package/dist/{features → plugins}/async/sparse.d.ts +0 -0
- /package/dist/{features → plugins}/grid/layout.d.ts +0 -0
- /package/dist/{features → plugins}/grid/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/grid/types.d.ts +0 -0
- /package/dist/{features → plugins}/groups/async-bridge.d.ts +0 -0
- /package/dist/{features → plugins}/groups/layout.d.ts +0 -0
- /package/dist/{features → plugins}/groups/sticky.d.ts +0 -0
- /package/dist/{features → plugins}/groups/types.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/layout.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/masonry/types.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/controller.d.ts +0 -0
- /package/dist/{features → plugins}/scrollbar/scrollbar.d.ts +0 -0
- /package/dist/{features → plugins}/table/header.d.ts +0 -0
- /package/dist/{features → plugins}/table/layout.d.ts +0 -0
- /package/dist/{features → plugins}/table/renderer.d.ts +0 -0
- /package/dist/{features → plugins}/table/types.d.ts +0 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vlist - Compression Module
|
|
3
|
+
* Pure functions for handling large lists that exceed browser size limits
|
|
4
|
+
*
|
|
5
|
+
* When a list's total size (sum of all item sizes) exceeds the browser's
|
|
6
|
+
* maximum element size (~16.7M pixels), we "compress" the virtual scroll space.
|
|
7
|
+
*
|
|
8
|
+
* Key concepts:
|
|
9
|
+
* - actualSize: The true size if all items were rendered
|
|
10
|
+
* - virtualSize: The capped size used for the scroll container (≤ MAX_VIRTUAL_SIZE)
|
|
11
|
+
* - compressionRatio: virtualSize / actualSize (1 = no compression, <1 = compressed)
|
|
12
|
+
*
|
|
13
|
+
* When compressed:
|
|
14
|
+
* - Scroll position maps to item index via ratio, not pixel math
|
|
15
|
+
* - Item positions are calculated relative to a "virtual index" at current scroll
|
|
16
|
+
* - Near-bottom interpolation ensures the last items are reachable
|
|
17
|
+
*/
|
|
18
|
+
import { MAX_VIRTUAL_SIZE } from "../constants";
|
|
19
|
+
import { countVisibleItems, } from "./sizes";
|
|
20
|
+
// Re-export for convenience
|
|
21
|
+
export { MAX_VIRTUAL_SIZE };
|
|
22
|
+
/**
|
|
23
|
+
* Calculate compression state for a list
|
|
24
|
+
* Pure function - no side effects
|
|
25
|
+
*
|
|
26
|
+
* @param _totalItems - Total number of items
|
|
27
|
+
* @param sizeCache - Size cache for item sizes/offsets
|
|
28
|
+
* @param force - When true, enables compressed mode even if total size is under the limit.
|
|
29
|
+
* Useful for testing, consistent UX, or preemptively enabling compression
|
|
30
|
+
* before the list grows past the browser limit.
|
|
31
|
+
*/
|
|
32
|
+
export const getCompressionState = (_totalItems, sizeCache, force) => {
|
|
33
|
+
const actualSize = sizeCache.getTotalSize();
|
|
34
|
+
const isCompressed = force === true || actualSize > MAX_VIRTUAL_SIZE;
|
|
35
|
+
const virtualSize = actualSize > MAX_VIRTUAL_SIZE ? MAX_VIRTUAL_SIZE : actualSize;
|
|
36
|
+
const ratio = actualSize > 0 ? virtualSize / actualSize : 1;
|
|
37
|
+
return {
|
|
38
|
+
isCompressed,
|
|
39
|
+
actualSize,
|
|
40
|
+
virtualSize,
|
|
41
|
+
ratio,
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
// =============================================================================
|
|
45
|
+
// Range Calculations (Compressed)
|
|
46
|
+
// =============================================================================
|
|
47
|
+
/**
|
|
48
|
+
* Calculate visible range with compression support
|
|
49
|
+
* Pure function - no side effects
|
|
50
|
+
*
|
|
51
|
+
* @param scrollTop - Current scroll position
|
|
52
|
+
* @param containerHeight - Viewport container height
|
|
53
|
+
* @param sizeCache - Size cache for item sizes/offsets
|
|
54
|
+
* @param totalItems - Total number of items
|
|
55
|
+
* @param compression - Compression state
|
|
56
|
+
* @param out - Output range to mutate (avoids allocation on hot path)
|
|
57
|
+
*/
|
|
58
|
+
export const calculateCompressedVisibleRange = (scrollPosition, containerHeight, sizeCache, totalItems, compression, out) => {
|
|
59
|
+
if (totalItems === 0 || containerHeight === 0) {
|
|
60
|
+
out.start = 0;
|
|
61
|
+
out.end = -1;
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
if (!compression.isCompressed || compression.ratio === 1) {
|
|
65
|
+
// Normal calculation using size cache.
|
|
66
|
+
// Also used when ratio === 1 (force mode, no actual compression) —
|
|
67
|
+
// the scroll infrastructure is active but positions map 1:1,
|
|
68
|
+
// so offset-based math is exact and avoids interpolation drift.
|
|
69
|
+
const start = sizeCache.indexAtOffset(scrollPosition);
|
|
70
|
+
// Find the last item that is at least partially visible
|
|
71
|
+
// Add 1 to match the fixed-height ceil() behavior (safe overshoot)
|
|
72
|
+
let end = sizeCache.indexAtOffset(scrollPosition + containerHeight);
|
|
73
|
+
if (end < totalItems - 1)
|
|
74
|
+
end++;
|
|
75
|
+
out.start = Math.max(0, start);
|
|
76
|
+
out.end = Math.min(totalItems - 1, Math.max(0, end));
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
// Compressed calculation — map scrollTop to actual space, then use
|
|
80
|
+
// sizeCache prefix sums to find the correct index. This handles mixed
|
|
81
|
+
// item sizes (e.g. group headers shorter than data items) correctly,
|
|
82
|
+
// matching how calculateCompressedItemPosition maps positions.
|
|
83
|
+
const { virtualSize } = compression;
|
|
84
|
+
const scrollRatio = scrollPosition / virtualSize;
|
|
85
|
+
const actualSize = sizeCache.getTotalSize();
|
|
86
|
+
const actualScrollOffset = scrollRatio * actualSize;
|
|
87
|
+
const start = sizeCache.indexAtOffset(actualScrollOffset);
|
|
88
|
+
const visibleCount = countVisibleItems(sizeCache, Math.max(0, start), containerHeight, totalItems);
|
|
89
|
+
const end = start + visibleCount;
|
|
90
|
+
out.start = Math.max(0, start);
|
|
91
|
+
out.end = Math.min(totalItems - 1, Math.max(0, end));
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Calculate render range with compression support (adds overscan)
|
|
96
|
+
* Pure function - no side effects
|
|
97
|
+
*
|
|
98
|
+
* @param out - Output range to mutate (avoids allocation on hot path)
|
|
99
|
+
*/
|
|
100
|
+
export const calculateCompressedRenderRange = (visibleRange, overscan, totalItems, out) => {
|
|
101
|
+
if (totalItems === 0) {
|
|
102
|
+
out.start = 0;
|
|
103
|
+
out.end = -1;
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
out.start = Math.max(0, visibleRange.start - overscan);
|
|
107
|
+
out.end = Math.min(totalItems - 1, visibleRange.end + overscan);
|
|
108
|
+
return out;
|
|
109
|
+
};
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// Item Positioning (Compressed)
|
|
112
|
+
// =============================================================================
|
|
113
|
+
/**
|
|
114
|
+
* Calculate item position (translateY) with compression support
|
|
115
|
+
* Pure function - no side effects
|
|
116
|
+
*
|
|
117
|
+
* In compressed mode (manual wheel scrolling, overflow: hidden), items are
|
|
118
|
+
* positioned RELATIVE TO THE VIEWPORT. The scroll container doesn't actually
|
|
119
|
+
* scroll - we intercept wheel events and manually position items.
|
|
120
|
+
*
|
|
121
|
+
* Key insight:
|
|
122
|
+
* - Calculate a "virtual scroll index" from the scroll ratio
|
|
123
|
+
* - Items are positioned relative to this virtual index using actual heights
|
|
124
|
+
* - Each item keeps its full height for proper rendering
|
|
125
|
+
*
|
|
126
|
+
* @param index - Item index
|
|
127
|
+
* @param scrollTop - Current (virtual) scroll position
|
|
128
|
+
* @param sizeCache - Size cache for item sizes/offsets
|
|
129
|
+
* @param totalItems - Total number of items
|
|
130
|
+
* @param containerHeight - Viewport container height
|
|
131
|
+
* @param compression - Compression state
|
|
132
|
+
*/
|
|
133
|
+
export const calculateCompressedItemPosition = (index, scrollPosition, sizeCache, totalItems, _containerHeight, compression, _rangeStart) => {
|
|
134
|
+
if (!compression.isCompressed || totalItems === 0) {
|
|
135
|
+
// Normal: absolute position in content space (scroll handled by container)
|
|
136
|
+
return sizeCache.getOffset(index);
|
|
137
|
+
}
|
|
138
|
+
// When ratio === 1 (force mode, no actual compression), virtualSize === actualSize
|
|
139
|
+
// so scroll position maps 1:1 to pixel offset. Use simple subtraction to avoid
|
|
140
|
+
// near-bottom interpolation drift that causes a gap at the bottom.
|
|
141
|
+
if (compression.ratio === 1) {
|
|
142
|
+
return sizeCache.getOffset(index) - scrollPosition;
|
|
143
|
+
}
|
|
144
|
+
const { virtualSize } = compression;
|
|
145
|
+
// Normal compressed positioning
|
|
146
|
+
//
|
|
147
|
+
// Map scrollTop to an actual-space offset via the compression ratio,
|
|
148
|
+
// then position the item relative to that offset.
|
|
149
|
+
// With compression slack on the content div the linear formula is valid
|
|
150
|
+
// for ALL scroll positions — no near-bottom interpolation needed.
|
|
151
|
+
const scrollRatio = scrollPosition / virtualSize;
|
|
152
|
+
const actualSize = sizeCache.getTotalSize();
|
|
153
|
+
const virtualScrollOffset = scrollRatio * actualSize;
|
|
154
|
+
return sizeCache.getOffset(index) - virtualScrollOffset;
|
|
155
|
+
};
|
|
156
|
+
// =============================================================================
|
|
157
|
+
// Scroll Position Calculations (Compressed)
|
|
158
|
+
// =============================================================================
|
|
159
|
+
/**
|
|
160
|
+
* Calculate scroll position to bring an index into view (with compression)
|
|
161
|
+
* Pure function - no side effects
|
|
162
|
+
*
|
|
163
|
+
* @param index - Target item index
|
|
164
|
+
* @param sizeCache - Size cache for item sizes/offsets
|
|
165
|
+
* @param containerHeight - Viewport container height
|
|
166
|
+
* @param totalItems - Total number of items
|
|
167
|
+
* @param compression - Compression state
|
|
168
|
+
* @param align - Alignment within viewport
|
|
169
|
+
*/
|
|
170
|
+
export const calculateCompressedScrollToIndex = (index, sizeCache, containerHeight, totalItems, compression, align = "start") => {
|
|
171
|
+
if (totalItems === 0)
|
|
172
|
+
return 0;
|
|
173
|
+
let targetPosition;
|
|
174
|
+
if (compression.isCompressed && compression.ratio !== 1) {
|
|
175
|
+
// Map index to compressed scroll position using linear formula.
|
|
176
|
+
// With compression slack on the content div the linear mapping is valid
|
|
177
|
+
// for ALL indices — no special-case needed for the last item.
|
|
178
|
+
const indexRatio = index / totalItems;
|
|
179
|
+
targetPosition = indexRatio * compression.virtualSize;
|
|
180
|
+
// Alignment adjustment must be scaled by the compression ratio.
|
|
181
|
+
// The viewport shows containerHeight/itemSize items regardless of
|
|
182
|
+
// compression, but each scroll-pixel maps to 1/ratio actual pixels.
|
|
183
|
+
// Without scaling, the pixel offset overshoots by 1/ratio items.
|
|
184
|
+
const itemSize = sizeCache.getSize(index);
|
|
185
|
+
switch (align) {
|
|
186
|
+
case "center":
|
|
187
|
+
targetPosition -= (containerHeight - itemSize) / 2 * compression.ratio;
|
|
188
|
+
break;
|
|
189
|
+
case "end":
|
|
190
|
+
targetPosition -= (containerHeight - itemSize) * compression.ratio;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
// NOTE: no maxScroll clamp here — the caller (withScale) manages
|
|
194
|
+
// maxScroll with compression slack included. Clamping to
|
|
195
|
+
// virtualSize − containerHeight would prevent reaching the last items.
|
|
196
|
+
return Math.max(0, targetPosition);
|
|
197
|
+
}
|
|
198
|
+
// Direct calculation using actual offset.
|
|
199
|
+
// Also used when ratio === 1 (force mode) — positions map 1:1.
|
|
200
|
+
targetPosition = sizeCache.getOffset(index);
|
|
201
|
+
// Adjust for alignment using the specific item's size
|
|
202
|
+
const itemSize = sizeCache.getSize(index);
|
|
203
|
+
switch (align) {
|
|
204
|
+
case "center":
|
|
205
|
+
targetPosition -= (containerHeight - itemSize) / 2;
|
|
206
|
+
break;
|
|
207
|
+
case "end":
|
|
208
|
+
targetPosition -= containerHeight - itemSize;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
// Clamp to valid range
|
|
212
|
+
const maxScroll = compression.virtualSize - containerHeight;
|
|
213
|
+
return Math.max(0, Math.min(targetPosition, maxScroll));
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Calculate the approximate item index at a given scroll position
|
|
217
|
+
* Useful for debugging and scroll position restoration
|
|
218
|
+
* Pure function - no side effects
|
|
219
|
+
*/
|
|
220
|
+
export const calculateIndexFromScrollPosition = (scrollPosition, sizeCache, totalItems, compression) => {
|
|
221
|
+
if (totalItems === 0)
|
|
222
|
+
return 0;
|
|
223
|
+
if (compression.isCompressed && compression.ratio !== 1) {
|
|
224
|
+
const scrollRatio = scrollPosition / compression.virtualSize;
|
|
225
|
+
return Math.floor(scrollRatio * totalItems);
|
|
226
|
+
}
|
|
227
|
+
// Direct lookup — also used when ratio === 1 (force mode, no actual compression)
|
|
228
|
+
return sizeCache.indexAtOffset(scrollPosition);
|
|
229
|
+
};
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// Utility Functions
|
|
232
|
+
// =============================================================================
|
|
233
|
+
/**
|
|
234
|
+
* Check if compression is needed for a list configuration
|
|
235
|
+
* Pure function - no side effects
|
|
236
|
+
*
|
|
237
|
+
* Note: This overload accepts a HeightCache for variable heights.
|
|
238
|
+
* For simple fixed-height checks, use needsCompressionFixed().
|
|
239
|
+
*/
|
|
240
|
+
export const needsCompression = (totalItems, heightOrCache) => {
|
|
241
|
+
if (typeof heightOrCache === "number") {
|
|
242
|
+
return totalItems * heightOrCache > MAX_VIRTUAL_SIZE;
|
|
243
|
+
}
|
|
244
|
+
return heightOrCache.getTotalSize() > MAX_VIRTUAL_SIZE;
|
|
245
|
+
};
|
|
246
|
+
/**
|
|
247
|
+
* Calculate maximum items supported without compression
|
|
248
|
+
* Only meaningful for fixed-height items
|
|
249
|
+
* Pure function - no side effects
|
|
250
|
+
*/
|
|
251
|
+
export const getMaxItemsWithoutCompression = (itemSize) => {
|
|
252
|
+
if (itemSize <= 0)
|
|
253
|
+
return 0;
|
|
254
|
+
return Math.floor(MAX_VIRTUAL_SIZE / itemSize);
|
|
255
|
+
};
|
|
256
|
+
/**
|
|
257
|
+
* Get human-readable compression info for debugging
|
|
258
|
+
* Pure function - no side effects
|
|
259
|
+
*/
|
|
260
|
+
export const getCompressionInfo = (totalItems, sizeCache, force) => {
|
|
261
|
+
const compression = getCompressionState(totalItems, sizeCache, force);
|
|
262
|
+
if (!compression.isCompressed) {
|
|
263
|
+
return `No compression needed (${totalItems} items, ${(compression.actualSize / 1000000).toFixed(2)}M px)`;
|
|
264
|
+
}
|
|
265
|
+
const ratioPercent = (compression.ratio * 100).toFixed(1);
|
|
266
|
+
return `Compressed to ${ratioPercent}% (${totalItems} items, ${(compression.actualSize / 1000000).toFixed(1)}M px → ${(compression.virtualSize / 1000000).toFixed(1)}M px virtual)`;
|
|
267
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vlist - Smart Edge Scroll
|
|
3
|
+
* Shared scroll utility used by both core baseline and withSelection feature.
|
|
4
|
+
* Only scrolls when the target item is outside the viewport; aligns to nearest edge.
|
|
5
|
+
*
|
|
6
|
+
* Split into two functions for tree-shaking:
|
|
7
|
+
* - scrollToFocusSimple: normal mode only (used by base builder)
|
|
8
|
+
* - scrollToFocus: handles both normal and compressed modes (used by features)
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Simple scroll-to-focus: normal (non-compressed) mode only.
|
|
12
|
+
* Padding-aware: accounts for CSS padding on the content element.
|
|
13
|
+
*/
|
|
14
|
+
export const scrollToFocusSimple = (index, sizeCache, scrollPosition, containerSize, startPadding = 0, endPadding = 0) => {
|
|
15
|
+
const itemOffset = sizeCache.getOffset(index);
|
|
16
|
+
const itemSize = sizeCache.getSize(index);
|
|
17
|
+
const adjustedTop = itemOffset + startPadding;
|
|
18
|
+
const adjustedBottom = adjustedTop + itemSize;
|
|
19
|
+
const viewportBottom = scrollPosition + containerSize;
|
|
20
|
+
if (adjustedTop < scrollPosition)
|
|
21
|
+
return Math.max(0, itemOffset);
|
|
22
|
+
if (adjustedBottom > viewportBottom)
|
|
23
|
+
return adjustedBottom + endPadding - containerSize;
|
|
24
|
+
return scrollPosition;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Full scroll-to-focus: handles both normal and compressed (withScale) modes.
|
|
28
|
+
* Used by withSelection feature which must work with compression.
|
|
29
|
+
*/
|
|
30
|
+
export const scrollToFocus = (index, sizeCache, scrollPosition, containerSize, startPadding = 0, endPadding = 0, compression, totalItems, visibleRange) => {
|
|
31
|
+
const isCompressed = compression != null &&
|
|
32
|
+
compression.isCompressed &&
|
|
33
|
+
compression.ratio !== 1;
|
|
34
|
+
if (!isCompressed) {
|
|
35
|
+
return scrollToFocusSimple(index, sizeCache, scrollPosition, containerSize, startPadding, endPadding);
|
|
36
|
+
}
|
|
37
|
+
// ── Compressed: prefix-sum positioning ──
|
|
38
|
+
// Use actual offsets from the size cache instead of linear index math.
|
|
39
|
+
// Linear math assumes uniform item sizes, but group headers are shorter
|
|
40
|
+
// than data items, causing the focused item to land short of the edge.
|
|
41
|
+
const { virtualSize } = compression;
|
|
42
|
+
const itemSize = sizeCache.getSize(Math.max(0, index));
|
|
43
|
+
const effectiveSize = containerSize - startPadding - endPadding;
|
|
44
|
+
const fullyVisible = Math.max(1, Math.floor(effectiveSize / itemSize));
|
|
45
|
+
const totalActualSize = sizeCache.getTotalSize();
|
|
46
|
+
if (visibleRange) {
|
|
47
|
+
if (index >= visibleRange.start + fullyVisible) {
|
|
48
|
+
const itemBottom = sizeCache.getOffset(index) + itemSize;
|
|
49
|
+
const targetActualTop = itemBottom + endPadding - containerSize;
|
|
50
|
+
return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
|
|
51
|
+
}
|
|
52
|
+
if (index <= visibleRange.start) {
|
|
53
|
+
const targetActualTop = sizeCache.getOffset(index) - startPadding;
|
|
54
|
+
return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
|
|
55
|
+
}
|
|
56
|
+
return scrollPosition;
|
|
57
|
+
}
|
|
58
|
+
// No visible range — fallback
|
|
59
|
+
const currentIndex = (scrollPosition / virtualSize) * totalItems;
|
|
60
|
+
const currentEnd = currentIndex + fullyVisible;
|
|
61
|
+
if (index >= currentEnd) {
|
|
62
|
+
const itemBottom = sizeCache.getOffset(index) + itemSize;
|
|
63
|
+
const targetActualTop = itemBottom + endPadding - containerSize;
|
|
64
|
+
return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
|
|
65
|
+
}
|
|
66
|
+
if (index < currentIndex) {
|
|
67
|
+
const targetActualTop = sizeCache.getOffset(index) - startPadding;
|
|
68
|
+
return Math.max(0, (targetActualTop / totalActualSize) * virtualSize);
|
|
69
|
+
}
|
|
70
|
+
return scrollPosition;
|
|
71
|
+
};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vlist - Size Cache
|
|
3
|
+
* Efficient size management for fixed and variable item sizes
|
|
4
|
+
*
|
|
5
|
+
* Provides two implementations:
|
|
6
|
+
* - Fixed: O(1) operations using multiplication (zero overhead, matches existing behavior)
|
|
7
|
+
* - Variable: O(1) offset lookup via prefix sums, O(log n) binary search for index-at-offset
|
|
8
|
+
*
|
|
9
|
+
* The SizeCache abstraction allows all virtual scrolling and compression code
|
|
10
|
+
* to work identically with both fixed and variable sizes, for both vertical and horizontal scrolling.
|
|
11
|
+
*/
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Fixed Size Cache
|
|
14
|
+
// =============================================================================
|
|
15
|
+
/**
|
|
16
|
+
* Create a fixed-size cache
|
|
17
|
+
* All operations are O(1) using simple multiplication — zero overhead
|
|
18
|
+
*/
|
|
19
|
+
const createFixedSizeCache = (size, initialTotal) => {
|
|
20
|
+
let total = initialTotal;
|
|
21
|
+
return {
|
|
22
|
+
getOffset: (index) => index * size,
|
|
23
|
+
getSize: (_index) => size,
|
|
24
|
+
indexAtOffset: (offset) => {
|
|
25
|
+
if (total === 0 || size === 0)
|
|
26
|
+
return 0;
|
|
27
|
+
return Math.max(0, Math.min(Math.floor(offset / size), total - 1));
|
|
28
|
+
},
|
|
29
|
+
getTotalSize: () => total * size,
|
|
30
|
+
getTotal: () => total,
|
|
31
|
+
rebuild: (newTotal) => {
|
|
32
|
+
total = newTotal;
|
|
33
|
+
},
|
|
34
|
+
isVariable: () => false,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// Variable Size Cache
|
|
39
|
+
// =============================================================================
|
|
40
|
+
/**
|
|
41
|
+
* Create a variable-size cache using prefix sums
|
|
42
|
+
*
|
|
43
|
+
* Prefix sums array: prefixSums[i] = sum of sizes for items 0..i-1
|
|
44
|
+
* prefixSums[0] = 0
|
|
45
|
+
* prefixSums[1] = size(0)
|
|
46
|
+
* prefixSums[n] = total size of all n items
|
|
47
|
+
*
|
|
48
|
+
* This enables:
|
|
49
|
+
* getOffset(i) = prefixSums[i] — O(1)
|
|
50
|
+
* getTotalSize() = prefixSums[n] — O(1)
|
|
51
|
+
* indexAtOffset(y) = binary search — O(log n)
|
|
52
|
+
*/
|
|
53
|
+
const createVariableSizeCache = (sizeFn, initialTotal) => {
|
|
54
|
+
let total = initialTotal;
|
|
55
|
+
let prefixSums = new Float64Array(0);
|
|
56
|
+
/**
|
|
57
|
+
* Build prefix sums from the size function
|
|
58
|
+
* O(n) — only called on data changes, never on scroll
|
|
59
|
+
*/
|
|
60
|
+
const build = (n) => {
|
|
61
|
+
total = n;
|
|
62
|
+
prefixSums = new Float64Array(n + 1);
|
|
63
|
+
prefixSums[0] = 0;
|
|
64
|
+
for (let i = 0; i < n; i++) {
|
|
65
|
+
prefixSums[i + 1] = prefixSums[i] + sizeFn(i);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
// Initial build
|
|
69
|
+
build(initialTotal);
|
|
70
|
+
/**
|
|
71
|
+
* Binary search: find the largest index i where prefixSums[i] <= offset
|
|
72
|
+
* This gives the item that contains the given scroll offset
|
|
73
|
+
*/
|
|
74
|
+
const binarySearch = (offset) => {
|
|
75
|
+
if (total === 0)
|
|
76
|
+
return 0;
|
|
77
|
+
// Clamp to valid range
|
|
78
|
+
if (offset <= 0)
|
|
79
|
+
return 0;
|
|
80
|
+
if (offset >= prefixSums[total])
|
|
81
|
+
return total - 1;
|
|
82
|
+
let lo = 0;
|
|
83
|
+
let hi = total - 1;
|
|
84
|
+
while (lo < hi) {
|
|
85
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
86
|
+
if (prefixSums[mid] <= offset) {
|
|
87
|
+
lo = mid;
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
hi = mid - 1;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return lo;
|
|
94
|
+
};
|
|
95
|
+
return {
|
|
96
|
+
getOffset: (index) => {
|
|
97
|
+
if (index <= 0)
|
|
98
|
+
return 0;
|
|
99
|
+
if (index >= total)
|
|
100
|
+
return prefixSums[total];
|
|
101
|
+
return prefixSums[index];
|
|
102
|
+
},
|
|
103
|
+
getSize: (index) => sizeFn(index),
|
|
104
|
+
indexAtOffset: (offset) => binarySearch(offset),
|
|
105
|
+
getTotalSize: () => prefixSums[total] ?? 0,
|
|
106
|
+
getTotal: () => total,
|
|
107
|
+
rebuild: (newTotal) => build(newTotal),
|
|
108
|
+
isVariable: () => true,
|
|
109
|
+
};
|
|
110
|
+
};
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// Factory
|
|
113
|
+
// =============================================================================
|
|
114
|
+
/**
|
|
115
|
+
* Create a size cache — returns fixed or variable implementation
|
|
116
|
+
*
|
|
117
|
+
* When size is a number, returns a zero-overhead fixed implementation.
|
|
118
|
+
* When size is a function, builds a prefix-sum array for efficient lookups.
|
|
119
|
+
*/
|
|
120
|
+
export const createSizeCache = (size, initialTotal) => {
|
|
121
|
+
if (typeof size === "number") {
|
|
122
|
+
return createFixedSizeCache(size, initialTotal);
|
|
123
|
+
}
|
|
124
|
+
return createVariableSizeCache(size, initialTotal);
|
|
125
|
+
};
|
|
126
|
+
// =============================================================================
|
|
127
|
+
// Helpers
|
|
128
|
+
// =============================================================================
|
|
129
|
+
/**
|
|
130
|
+
* Count how many items fit in a given container size starting from startIndex
|
|
131
|
+
* Used for compressed mode visible range calculations
|
|
132
|
+
*
|
|
133
|
+
* For fixed sizes: O(1) via division
|
|
134
|
+
* For variable sizes: O(k) where k = visible item count (typically 10-50)
|
|
135
|
+
*/
|
|
136
|
+
export const countVisibleItems = (sizeCache, startIndex, containerSize, totalItems) => {
|
|
137
|
+
if (totalItems === 0)
|
|
138
|
+
return 0;
|
|
139
|
+
if (!sizeCache.isVariable()) {
|
|
140
|
+
return Math.ceil(containerSize / sizeCache.getSize(0));
|
|
141
|
+
}
|
|
142
|
+
let count = 0;
|
|
143
|
+
let accumulated = 0;
|
|
144
|
+
let idx = startIndex;
|
|
145
|
+
while (idx < totalItems && accumulated < containerSize) {
|
|
146
|
+
accumulated += sizeCache.getSize(idx);
|
|
147
|
+
count++;
|
|
148
|
+
idx++;
|
|
149
|
+
}
|
|
150
|
+
return Math.max(1, count);
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Count how many items fit starting from the bottom of the list
|
|
154
|
+
* Used for near-bottom interpolation in compressed mode
|
|
155
|
+
*
|
|
156
|
+
* For fixed sizes: O(1) via division
|
|
157
|
+
* For variable sizes: O(k) where k = items fitting (typically 10-50)
|
|
158
|
+
*/
|
|
159
|
+
export const countItemsFittingFromBottom = (sizeCache, containerSize, totalItems) => {
|
|
160
|
+
if (totalItems === 0)
|
|
161
|
+
return 0;
|
|
162
|
+
if (!sizeCache.isVariable()) {
|
|
163
|
+
return Math.floor(containerSize / sizeCache.getSize(0));
|
|
164
|
+
}
|
|
165
|
+
let count = 0;
|
|
166
|
+
let accumulated = 0;
|
|
167
|
+
for (let i = totalItems - 1; i >= 0; i--) {
|
|
168
|
+
const s = sizeCache.getSize(i);
|
|
169
|
+
if (accumulated + s > containerSize)
|
|
170
|
+
break;
|
|
171
|
+
accumulated += s;
|
|
172
|
+
count++;
|
|
173
|
+
}
|
|
174
|
+
return Math.max(count, 1);
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* Calculate the pixel offset for a fractional virtual scroll index
|
|
178
|
+
*
|
|
179
|
+
* In compressed mode, the scroll position maps to a fractional item index
|
|
180
|
+
* (e.g., 5.3 means 30% into item 5). This function calculates the actual
|
|
181
|
+
* pixel offset for such a fractional position using variable sizes.
|
|
182
|
+
*
|
|
183
|
+
* For fixed sizes this reduces to: virtualIndex * itemSize
|
|
184
|
+
* For variable sizes: offset(floor) + frac * size(floor)
|
|
185
|
+
*/
|
|
186
|
+
export const getOffsetForVirtualIndex = (sizeCache, virtualIndex, totalItems) => {
|
|
187
|
+
if (totalItems === 0)
|
|
188
|
+
return 0;
|
|
189
|
+
const intPart = Math.floor(virtualIndex);
|
|
190
|
+
const fracPart = virtualIndex - intPart;
|
|
191
|
+
const safeInt = Math.max(0, Math.min(intPart, totalItems - 1));
|
|
192
|
+
return sizeCache.getOffset(safeInt) + fracPart * sizeCache.getSize(safeInt);
|
|
193
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/rendering/sort.ts
|
|
2
|
+
/**
|
|
3
|
+
* Shared DOM sort utility for accessibility.
|
|
4
|
+
*
|
|
5
|
+
* Virtual list renderers append new elements at the end of the container
|
|
6
|
+
* for performance (batched DocumentFragment insertion). After scrolling,
|
|
7
|
+
* DOM order diverges from logical item order. Screen readers traverse
|
|
8
|
+
* DOM order, so items are encountered in a nonsensical sequence.
|
|
9
|
+
*
|
|
10
|
+
* This utility reorders DOM children to match logical index order.
|
|
11
|
+
* Called on scroll idle — zero cost during scroll, single lightweight
|
|
12
|
+
* reflow when idle (items are position:absolute, no geometry change).
|
|
13
|
+
*
|
|
14
|
+
* **Minimal-move approach**: walks sorted elements and current DOM children
|
|
15
|
+
* in parallel. Elements already at the correct position are never touched
|
|
16
|
+
* — preserving browser :hover state and avoiding CSS transition replays
|
|
17
|
+
* on elements under the cursor.
|
|
18
|
+
*
|
|
19
|
+
* Used by: core renderer, grid renderer, masonry renderer, and core.ts
|
|
20
|
+
* inlined render path.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Reorder DOM children so they follow logical data-index order.
|
|
24
|
+
*
|
|
25
|
+
* Only elements that are out of position are moved via `insertBefore`.
|
|
26
|
+
* Elements already in the correct spot are skipped entirely (no DOM
|
|
27
|
+
* mutation), which preserves :hover state and CSS transitions.
|
|
28
|
+
*
|
|
29
|
+
* @param container - The DOM element that holds rendered items
|
|
30
|
+
* @param keys - The rendered Map's keys (item indices)
|
|
31
|
+
* @param getElement - Lookup function: index → HTMLElement | undefined
|
|
32
|
+
*/
|
|
33
|
+
export const sortRenderedDOM = (container, keys, getElement) => {
|
|
34
|
+
// Collect and sort logical indices
|
|
35
|
+
const sorted = Array.from(keys).sort((a, b) => a - b);
|
|
36
|
+
if (sorted.length <= 1)
|
|
37
|
+
return;
|
|
38
|
+
// Resolve to elements in target (sorted) order, skip undefined
|
|
39
|
+
const elements = [];
|
|
40
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
41
|
+
const el = getElement(sorted[i]);
|
|
42
|
+
if (el)
|
|
43
|
+
elements.push(el);
|
|
44
|
+
}
|
|
45
|
+
if (elements.length <= 1)
|
|
46
|
+
return;
|
|
47
|
+
// Walk sorted elements against current DOM children in parallel.
|
|
48
|
+
// `cursor` tracks our position in the DOM child list.
|
|
49
|
+
// For each target element:
|
|
50
|
+
// - if it matches the cursor → already in place, advance cursor
|
|
51
|
+
// - if not → insertBefore(cursor) to put it in the right spot
|
|
52
|
+
let cursor = container.firstChild;
|
|
53
|
+
for (let i = 0; i < elements.length; i++) {
|
|
54
|
+
const el = elements[i];
|
|
55
|
+
if (el === cursor) {
|
|
56
|
+
// Already in the correct position — skip, no DOM mutation
|
|
57
|
+
cursor = cursor.nextSibling;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Out of place — move it before the current cursor position.
|
|
61
|
+
// insertBefore(el, null) is equivalent to appendChild.
|
|
62
|
+
container.insertBefore(el, cursor);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
};
|