vlist 2.0.0 → 2.0.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/README.github.md +2 -2
- package/README.md +2 -2
- package/dist/core/dom.d.ts +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/pipeline.d.ts +2 -2
- package/dist/core/scroll.d.ts +1 -1
- package/dist/core/types.d.ts +7 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -28
- package/dist/internals.js +1 -60
- package/dist/plugins/scrollbar/controller.d.ts +3 -3
- package/dist/plugins/scrollbar/scrollbar.d.ts +2 -2
- package/dist/rendering/renderer.d.ts +2 -2
- package/dist/rendering/viewport.d.ts +1 -1
- package/dist/size.json +1 -1
- package/dist/types.d.ts +1 -1
- package/package.json +1 -1
- package/dist/constants.js +0 -83
- package/dist/core/create.js +0 -740
- package/dist/core/dom.js +0 -47
- package/dist/core/hooks.js +0 -67
- package/dist/core/index.js +0 -13
- package/dist/core/pipeline.js +0 -307
- package/dist/core/pool.js +0 -42
- package/dist/core/scroll.js +0 -137
- package/dist/core/sizes.js +0 -6
- package/dist/core/state.js +0 -56
- package/dist/core/types.js +0 -7
- package/dist/core/velocity.js +0 -33
- package/dist/events/emitter.js +0 -60
- package/dist/events/index.js +0 -6
- package/dist/plugins/a11y/index.js +0 -1
- package/dist/plugins/a11y/plugin.js +0 -259
- package/dist/plugins/async/index.js +0 -12
- package/dist/plugins/async/manager.js +0 -568
- package/dist/plugins/async/placeholder.js +0 -154
- package/dist/plugins/async/plugin.js +0 -311
- package/dist/plugins/async/sparse.js +0 -540
- package/dist/plugins/autosize/index.js +0 -4
- package/dist/plugins/autosize/plugin.js +0 -185
- package/dist/plugins/grid/index.js +0 -5
- package/dist/plugins/grid/layout.js +0 -275
- package/dist/plugins/grid/plugin.js +0 -347
- package/dist/plugins/grid/renderer.js +0 -525
- package/dist/plugins/grid/types.js +0 -11
- package/dist/plugins/groups/async-bridge.js +0 -246
- package/dist/plugins/groups/index.js +0 -13
- package/dist/plugins/groups/layout.js +0 -294
- package/dist/plugins/groups/plugin.js +0 -571
- package/dist/plugins/groups/sticky.js +0 -255
- package/dist/plugins/groups/types.js +0 -12
- package/dist/plugins/masonry/index.js +0 -6
- package/dist/plugins/masonry/layout.js +0 -261
- package/dist/plugins/masonry/plugin.js +0 -381
- package/dist/plugins/masonry/renderer.js +0 -354
- package/dist/plugins/masonry/types.js +0 -9
- package/dist/plugins/page/index.js +0 -5
- package/dist/plugins/page/plugin.js +0 -166
- package/dist/plugins/scale/index.js +0 -4
- package/dist/plugins/scale/plugin.js +0 -507
- package/dist/plugins/scrollbar/controller.js +0 -574
- package/dist/plugins/scrollbar/index.js +0 -6
- package/dist/plugins/scrollbar/plugin.js +0 -93
- package/dist/plugins/scrollbar/scrollbar.js +0 -556
- package/dist/plugins/selection/index.js +0 -7
- package/dist/plugins/selection/plugin.js +0 -601
- package/dist/plugins/selection/state.js +0 -332
- package/dist/plugins/snapshots/index.js +0 -5
- package/dist/plugins/snapshots/plugin.js +0 -301
- package/dist/plugins/sortable/index.js +0 -6
- package/dist/plugins/sortable/plugin.js +0 -753
- package/dist/plugins/table/header.js +0 -501
- package/dist/plugins/table/index.js +0 -12
- package/dist/plugins/table/layout.js +0 -211
- package/dist/plugins/table/plugin.js +0 -391
- package/dist/plugins/table/renderer.js +0 -625
- package/dist/plugins/table/types.js +0 -12
- package/dist/plugins/transition/index.js +0 -5
- package/dist/plugins/transition/plugin.js +0 -405
- package/dist/rendering/aria.js +0 -23
- package/dist/rendering/index.js +0 -18
- package/dist/rendering/measured.js +0 -98
- package/dist/rendering/renderer.js +0 -586
- package/dist/rendering/scale.js +0 -267
- package/dist/rendering/scroll.js +0 -71
- package/dist/rendering/sizes.js +0 -193
- package/dist/rendering/sort.js +0 -65
- package/dist/rendering/viewport.js +0 -268
- package/dist/types.js +0 -5
- package/dist/utils/padding.js +0 -49
- package/dist/utils/stats.js +0 -124
|
@@ -1,574 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vlist - Scroll Controller
|
|
3
|
-
* Handles both native scrolling and manual wheel-based scrolling for compressed lists
|
|
4
|
-
*
|
|
5
|
-
* When compression is active (large lists exceeding browser height limits),
|
|
6
|
-
* we switch from native scrolling to manual wheel event handling.
|
|
7
|
-
* This allows smooth scrolling through millions of items.
|
|
8
|
-
*/
|
|
9
|
-
// =============================================================================
|
|
10
|
-
// Velocity Tracker (Circular Buffer)
|
|
11
|
-
// =============================================================================
|
|
12
|
-
/** Number of samples in circular buffer (avoids array allocation on every update) */
|
|
13
|
-
const VELOCITY_SAMPLE_COUNT = 8;
|
|
14
|
-
/** Minimum samples needed before velocity readings are considered reliable */
|
|
15
|
-
const MIN_RELIABLE_SAMPLES = 3;
|
|
16
|
-
/**
|
|
17
|
-
* Maximum time gap (ms) between samples before the buffer is considered stale.
|
|
18
|
-
* After a pause longer than this, previous samples no longer represent the
|
|
19
|
-
* current scroll gesture — we reset and start measuring fresh.
|
|
20
|
-
* Set below the idle timeout (150ms) so stale detection triggers before idle.
|
|
21
|
-
*/
|
|
22
|
-
const STALE_GAP_MS = 100;
|
|
23
|
-
const createVelocityTracker = (initialPosition = 0) => {
|
|
24
|
-
// Pre-allocate sample array to avoid allocation during scrolling
|
|
25
|
-
const samples = new Array(VELOCITY_SAMPLE_COUNT);
|
|
26
|
-
for (let i = 0; i < VELOCITY_SAMPLE_COUNT; i++) {
|
|
27
|
-
samples[i] = { position: 0, time: 0 };
|
|
28
|
-
}
|
|
29
|
-
return {
|
|
30
|
-
velocity: 0,
|
|
31
|
-
lastPosition: initialPosition,
|
|
32
|
-
lastTime: performance.now(),
|
|
33
|
-
samples,
|
|
34
|
-
sampleIndex: 0,
|
|
35
|
-
sampleCount: 0,
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
const updateVelocityTracker = (tracker, newPosition) => {
|
|
39
|
-
const now = performance.now();
|
|
40
|
-
const timeDelta = now - tracker.lastTime;
|
|
41
|
-
if (timeDelta === 0)
|
|
42
|
-
return tracker;
|
|
43
|
-
// Stale gap detection: if too much time has passed since the last sample,
|
|
44
|
-
// the previous measurements belong to a different scroll gesture.
|
|
45
|
-
// Reset the buffer and record this position as the new baseline.
|
|
46
|
-
// Velocity stays at 0 (unreliable) until MIN_RELIABLE_SAMPLES accumulate.
|
|
47
|
-
if (timeDelta > STALE_GAP_MS) {
|
|
48
|
-
tracker.sampleCount = 0;
|
|
49
|
-
tracker.sampleIndex = 0;
|
|
50
|
-
tracker.velocity = 0;
|
|
51
|
-
// Record baseline — first real velocity will be computed on the next update
|
|
52
|
-
const baseline = tracker.samples[0];
|
|
53
|
-
baseline.position = newPosition;
|
|
54
|
-
baseline.time = now;
|
|
55
|
-
tracker.sampleIndex = 1;
|
|
56
|
-
tracker.sampleCount = 1;
|
|
57
|
-
tracker.lastPosition = newPosition;
|
|
58
|
-
tracker.lastTime = now;
|
|
59
|
-
return tracker;
|
|
60
|
-
}
|
|
61
|
-
// Write to current slot in circular buffer (no allocation)
|
|
62
|
-
const currentSample = tracker.samples[tracker.sampleIndex];
|
|
63
|
-
currentSample.position = newPosition;
|
|
64
|
-
currentSample.time = now;
|
|
65
|
-
// Advance index (wrap around)
|
|
66
|
-
tracker.sampleIndex = (tracker.sampleIndex + 1) % VELOCITY_SAMPLE_COUNT;
|
|
67
|
-
tracker.sampleCount = Math.min(tracker.sampleCount + 1, VELOCITY_SAMPLE_COUNT);
|
|
68
|
-
// Calculate average velocity from samples (only when we have enough data)
|
|
69
|
-
if (tracker.sampleCount >= 2) {
|
|
70
|
-
const oldestIndex = (tracker.sampleIndex - tracker.sampleCount + VELOCITY_SAMPLE_COUNT) %
|
|
71
|
-
VELOCITY_SAMPLE_COUNT;
|
|
72
|
-
const oldest = tracker.samples[oldestIndex];
|
|
73
|
-
const totalDistance = newPosition - oldest.position;
|
|
74
|
-
const totalTime = now - oldest.time;
|
|
75
|
-
tracker.velocity = totalTime > 0 ? totalDistance / totalTime : 0;
|
|
76
|
-
}
|
|
77
|
-
// sampleCount < 2: keep velocity at 0 (not enough data yet)
|
|
78
|
-
// Update position/time baseline (mutate in place to avoid allocation)
|
|
79
|
-
tracker.lastPosition = newPosition;
|
|
80
|
-
tracker.lastTime = now;
|
|
81
|
-
return tracker;
|
|
82
|
-
};
|
|
83
|
-
/** Check if the velocity tracker has accumulated enough samples for reliable readings */
|
|
84
|
-
const isVelocityTrackerReliable = (tracker) => tracker.sampleCount >= MIN_RELIABLE_SAMPLES;
|
|
85
|
-
// =============================================================================
|
|
86
|
-
// Scroll Controller Factory
|
|
87
|
-
// =============================================================================
|
|
88
|
-
/**
|
|
89
|
-
* Create a scroll controller for a viewport element
|
|
90
|
-
*
|
|
91
|
-
* Supports two modes:
|
|
92
|
-
* 1. Native scrolling (default) - uses browser's built-in scroll
|
|
93
|
-
* 2. Compressed scrolling - manual wheel handling for large lists
|
|
94
|
-
*/
|
|
95
|
-
export const createScrollController = (viewport, config = {}) => {
|
|
96
|
-
const { wheel = true, sensitivity = 1, smoothing = false, idleTimeout: idleMs = 150, onScroll, onIdle, scrollElement, horizontal = false, } = config;
|
|
97
|
-
const windowMode = !!scrollElement;
|
|
98
|
-
// State
|
|
99
|
-
let scrollPosition = 0;
|
|
100
|
-
let maxScroll = 0;
|
|
101
|
-
let containerHeight = windowMode
|
|
102
|
-
? horizontal
|
|
103
|
-
? window.innerWidth
|
|
104
|
-
: window.innerHeight
|
|
105
|
-
: horizontal
|
|
106
|
-
? viewport.clientWidth
|
|
107
|
-
: viewport.clientHeight;
|
|
108
|
-
let compressed = config.compressed ?? false;
|
|
109
|
-
let compression = config.compression;
|
|
110
|
-
let velocityTracker = createVelocityTracker();
|
|
111
|
-
let isScrolling = false;
|
|
112
|
-
let idleTimeout = null;
|
|
113
|
-
// =============================================================================
|
|
114
|
-
// Native Scroll Handling
|
|
115
|
-
// =============================================================================
|
|
116
|
-
const handleNativeScrollRaw = () => {
|
|
117
|
-
const newPosition = horizontal ? viewport.scrollLeft : viewport.scrollTop;
|
|
118
|
-
const direction = newPosition >= scrollPosition ? "down" : "up";
|
|
119
|
-
velocityTracker = updateVelocityTracker(velocityTracker, newPosition);
|
|
120
|
-
scrollPosition = newPosition;
|
|
121
|
-
if (onScroll) {
|
|
122
|
-
onScroll({
|
|
123
|
-
scrollTop: scrollPosition,
|
|
124
|
-
direction,
|
|
125
|
-
velocity: velocityTracker.velocity,
|
|
126
|
-
});
|
|
127
|
-
}
|
|
128
|
-
// Idle detection
|
|
129
|
-
scheduleIdleCheck();
|
|
130
|
-
};
|
|
131
|
-
// M1: RAF-throttle native scroll to guarantee at most one processing per frame
|
|
132
|
-
const handleNativeScroll = rafThrottle(handleNativeScrollRaw);
|
|
133
|
-
// =============================================================================
|
|
134
|
-
// Window Scroll Handling
|
|
135
|
-
// =============================================================================
|
|
136
|
-
const handleWindowScrollRaw = () => {
|
|
137
|
-
// Compute list-relative scroll position from the viewport's bounding rect.
|
|
138
|
-
// When the list's top edge is at the window's top, rect.top = 0, scrollTop = 0.
|
|
139
|
-
// When the list has scrolled 500px past, rect.top = -500, scrollTop = 500.
|
|
140
|
-
const rect = viewport.getBoundingClientRect();
|
|
141
|
-
const newPosition = horizontal
|
|
142
|
-
? Math.max(0, -rect.left)
|
|
143
|
-
: Math.max(0, -rect.top);
|
|
144
|
-
const direction = newPosition >= scrollPosition ? "down" : "up";
|
|
145
|
-
velocityTracker = updateVelocityTracker(velocityTracker, newPosition);
|
|
146
|
-
scrollPosition = newPosition;
|
|
147
|
-
if (!isScrolling) {
|
|
148
|
-
isScrolling = true;
|
|
149
|
-
}
|
|
150
|
-
if (onScroll) {
|
|
151
|
-
onScroll({
|
|
152
|
-
scrollTop: scrollPosition,
|
|
153
|
-
direction,
|
|
154
|
-
velocity: velocityTracker.velocity,
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
// Idle detection
|
|
158
|
-
scheduleIdleCheck();
|
|
159
|
-
};
|
|
160
|
-
const handleWindowScroll = rafThrottle(handleWindowScrollRaw);
|
|
161
|
-
// =============================================================================
|
|
162
|
-
// Compressed (Manual) Scroll Handling
|
|
163
|
-
// =============================================================================
|
|
164
|
-
/** Block wheel events (used in native mode when wheel is disabled) */
|
|
165
|
-
const blockWheel = (event) => {
|
|
166
|
-
event.preventDefault();
|
|
167
|
-
};
|
|
168
|
-
/**
|
|
169
|
-
* Translate vertical wheel (deltaY) into horizontal scroll in native mode.
|
|
170
|
-
* Trackpad deltaX already causes native horizontal scroll, so only remap
|
|
171
|
-
* deltaY when there is no deltaX. This keeps trackpad gestures natural
|
|
172
|
-
* while letting a regular mouse wheel drive horizontal scrolling.
|
|
173
|
-
*/
|
|
174
|
-
const handleHorizontalWheel = (event) => {
|
|
175
|
-
if (event.deltaX)
|
|
176
|
-
return; // native horizontal scroll handles it
|
|
177
|
-
event.preventDefault();
|
|
178
|
-
viewport.scrollLeft += event.deltaY;
|
|
179
|
-
};
|
|
180
|
-
const handleWheel = (event) => {
|
|
181
|
-
if (!compressed)
|
|
182
|
-
return;
|
|
183
|
-
event.preventDefault();
|
|
184
|
-
const delta = (horizontal ? event.deltaX || event.deltaY : event.deltaY) * sensitivity;
|
|
185
|
-
let newPosition = scrollPosition + delta;
|
|
186
|
-
// Apply smoothing if enabled
|
|
187
|
-
if (smoothing) {
|
|
188
|
-
newPosition = scrollPosition + delta * 0.3;
|
|
189
|
-
}
|
|
190
|
-
// Clamp to valid range
|
|
191
|
-
newPosition = Math.max(0, Math.min(newPosition, maxScroll));
|
|
192
|
-
if (newPosition !== scrollPosition) {
|
|
193
|
-
const previousPosition = scrollPosition;
|
|
194
|
-
const direction = newPosition >= previousPosition ? "down" : "up";
|
|
195
|
-
velocityTracker = updateVelocityTracker(velocityTracker, newPosition);
|
|
196
|
-
scrollPosition = newPosition;
|
|
197
|
-
if (!isScrolling) {
|
|
198
|
-
isScrolling = true;
|
|
199
|
-
}
|
|
200
|
-
if (onScroll) {
|
|
201
|
-
onScroll({
|
|
202
|
-
scrollTop: scrollPosition,
|
|
203
|
-
direction,
|
|
204
|
-
velocity: velocityTracker.velocity,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
// Idle detection
|
|
208
|
-
scheduleIdleCheck();
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
// =============================================================================
|
|
212
|
-
// Idle Detection
|
|
213
|
-
// =============================================================================
|
|
214
|
-
const scheduleIdleCheck = () => {
|
|
215
|
-
if (idleTimeout) {
|
|
216
|
-
clearTimeout(idleTimeout);
|
|
217
|
-
}
|
|
218
|
-
idleTimeout = setTimeout(() => {
|
|
219
|
-
isScrolling = false;
|
|
220
|
-
// Reset velocity tracker with current scroll position to avoid
|
|
221
|
-
// calculating huge velocity on next scroll event
|
|
222
|
-
velocityTracker = createVelocityTracker(scrollPosition);
|
|
223
|
-
if (onIdle) {
|
|
224
|
-
onIdle();
|
|
225
|
-
}
|
|
226
|
-
}, idleMs);
|
|
227
|
-
};
|
|
228
|
-
// =============================================================================
|
|
229
|
-
// Mode Switching
|
|
230
|
-
// =============================================================================
|
|
231
|
-
const enableCompression = (newCompression) => {
|
|
232
|
-
if (compressed)
|
|
233
|
-
return;
|
|
234
|
-
compressed = true;
|
|
235
|
-
compression = newCompression;
|
|
236
|
-
maxScroll = newCompression.virtualSize - containerHeight;
|
|
237
|
-
// In window mode, compression is purely mathematical — the content div
|
|
238
|
-
// height is set to the virtual height by vlist.ts, and the browser scrolls
|
|
239
|
-
// natively. No overflow changes or wheel interception needed.
|
|
240
|
-
if (windowMode)
|
|
241
|
-
return;
|
|
242
|
-
// Remove native scroll listener and cancel pending RAF
|
|
243
|
-
handleNativeScroll.cancel();
|
|
244
|
-
viewport.removeEventListener("scroll", handleNativeScroll);
|
|
245
|
-
// Remove native-mode wheel listeners
|
|
246
|
-
if (!wheel) {
|
|
247
|
-
viewport.removeEventListener("wheel", blockWheel);
|
|
248
|
-
}
|
|
249
|
-
else if (horizontal) {
|
|
250
|
-
viewport.removeEventListener("wheel", handleHorizontalWheel);
|
|
251
|
-
}
|
|
252
|
-
// Switch to overflow hidden
|
|
253
|
-
if (horizontal) {
|
|
254
|
-
viewport.style.overflowX = "hidden";
|
|
255
|
-
}
|
|
256
|
-
else {
|
|
257
|
-
viewport.style.overflow = "hidden";
|
|
258
|
-
}
|
|
259
|
-
// Add wheel listener (only if wheel is enabled)
|
|
260
|
-
if (wheel) {
|
|
261
|
-
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
|
262
|
-
}
|
|
263
|
-
// Convert current scroll position to compressed equivalent
|
|
264
|
-
const nativePos = horizontal ? viewport.scrollLeft : viewport.scrollTop;
|
|
265
|
-
if (nativePos > 0) {
|
|
266
|
-
const nativeMax = horizontal
|
|
267
|
-
? (compression?.actualSize ?? viewport.scrollWidth)
|
|
268
|
-
: (compression?.actualSize ?? viewport.scrollHeight);
|
|
269
|
-
const ratio = nativePos / nativeMax;
|
|
270
|
-
scrollPosition = ratio * maxScroll;
|
|
271
|
-
}
|
|
272
|
-
// Reset native scroll
|
|
273
|
-
if (horizontal) {
|
|
274
|
-
viewport.scrollLeft = 0;
|
|
275
|
-
}
|
|
276
|
-
else {
|
|
277
|
-
viewport.scrollTop = 0;
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
const disableCompression = () => {
|
|
281
|
-
if (!compressed)
|
|
282
|
-
return;
|
|
283
|
-
compressed = false;
|
|
284
|
-
// In window mode, nothing to revert — compression was purely mathematical
|
|
285
|
-
if (windowMode) {
|
|
286
|
-
compression = undefined;
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
// Remove wheel listener
|
|
290
|
-
viewport.removeEventListener("wheel", handleWheel);
|
|
291
|
-
// Restore native scrolling
|
|
292
|
-
if (horizontal) {
|
|
293
|
-
viewport.style.overflowX = "auto";
|
|
294
|
-
}
|
|
295
|
-
else {
|
|
296
|
-
viewport.style.overflow = "auto";
|
|
297
|
-
}
|
|
298
|
-
// Add native scroll listener
|
|
299
|
-
viewport.addEventListener("scroll", handleNativeScroll, { passive: true });
|
|
300
|
-
// Re-add native-mode wheel listeners
|
|
301
|
-
if (!wheel) {
|
|
302
|
-
viewport.addEventListener("wheel", blockWheel, { passive: false });
|
|
303
|
-
}
|
|
304
|
-
else if (horizontal) {
|
|
305
|
-
viewport.addEventListener("wheel", handleHorizontalWheel, {
|
|
306
|
-
passive: false,
|
|
307
|
-
});
|
|
308
|
-
}
|
|
309
|
-
// Restore scroll position
|
|
310
|
-
if (compression && scrollPosition > 0) {
|
|
311
|
-
const ratio = scrollPosition / maxScroll;
|
|
312
|
-
const restoredPos = ratio * (compression.actualSize - containerHeight);
|
|
313
|
-
if (horizontal) {
|
|
314
|
-
viewport.scrollLeft = restoredPos;
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
viewport.scrollTop = restoredPos;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
compression = undefined;
|
|
321
|
-
};
|
|
322
|
-
// =============================================================================
|
|
323
|
-
// Public API
|
|
324
|
-
// =============================================================================
|
|
325
|
-
const getScrollTop = () => {
|
|
326
|
-
// In window mode, scrollPosition is always the source of truth
|
|
327
|
-
// (viewport.scrollTop is 0 because overflow is visible).
|
|
328
|
-
// In compressed mode, scrollPosition is manually tracked.
|
|
329
|
-
// In native container mode, read from the DOM.
|
|
330
|
-
if (windowMode || compressed)
|
|
331
|
-
return scrollPosition;
|
|
332
|
-
return horizontal ? viewport.scrollLeft : viewport.scrollTop;
|
|
333
|
-
};
|
|
334
|
-
const scrollTo = (position, smooth = false) => {
|
|
335
|
-
const clampedPosition = Math.max(0, Math.min(position, maxScroll || Infinity));
|
|
336
|
-
if (windowMode) {
|
|
337
|
-
// Scroll the window so the desired list position is at the top of the viewport.
|
|
338
|
-
// listDocumentTop = the list's absolute position in the document.
|
|
339
|
-
const rect = viewport.getBoundingClientRect();
|
|
340
|
-
if (horizontal) {
|
|
341
|
-
const listDocumentLeft = rect.left + window.scrollX;
|
|
342
|
-
window.scrollTo({
|
|
343
|
-
left: listDocumentLeft + clampedPosition,
|
|
344
|
-
behavior: smooth ? "smooth" : "auto",
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
else {
|
|
348
|
-
const listDocumentTop = rect.top + window.scrollY;
|
|
349
|
-
window.scrollTo({
|
|
350
|
-
top: listDocumentTop + clampedPosition,
|
|
351
|
-
behavior: smooth ? "smooth" : "auto",
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
// The window scroll event will fire and update scrollPosition via handleWindowScroll
|
|
355
|
-
}
|
|
356
|
-
else if (compressed) {
|
|
357
|
-
if (clampedPosition === scrollPosition)
|
|
358
|
-
return;
|
|
359
|
-
const previousPosition = scrollPosition;
|
|
360
|
-
const direction = clampedPosition >= previousPosition ? "down" : "up";
|
|
361
|
-
velocityTracker = updateVelocityTracker(velocityTracker, clampedPosition);
|
|
362
|
-
scrollPosition = clampedPosition;
|
|
363
|
-
if (!isScrolling) {
|
|
364
|
-
isScrolling = true;
|
|
365
|
-
}
|
|
366
|
-
if (onScroll) {
|
|
367
|
-
onScroll({
|
|
368
|
-
scrollTop: scrollPosition,
|
|
369
|
-
direction,
|
|
370
|
-
velocity: velocityTracker.velocity,
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
scheduleIdleCheck();
|
|
374
|
-
}
|
|
375
|
-
else {
|
|
376
|
-
if (horizontal) {
|
|
377
|
-
viewport.scrollTo({
|
|
378
|
-
left: clampedPosition,
|
|
379
|
-
behavior: smooth ? "smooth" : "auto",
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
else {
|
|
383
|
-
viewport.scrollTo({
|
|
384
|
-
top: clampedPosition,
|
|
385
|
-
behavior: smooth ? "smooth" : "auto",
|
|
386
|
-
});
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
};
|
|
390
|
-
const scrollBy = (delta) => {
|
|
391
|
-
scrollTo(getScrollTop() + delta);
|
|
392
|
-
};
|
|
393
|
-
const isAtTop = () => {
|
|
394
|
-
return getScrollTop() <= 0;
|
|
395
|
-
};
|
|
396
|
-
const isAtBottom = (threshold = 0) => {
|
|
397
|
-
const scrollTop = getScrollTop();
|
|
398
|
-
// In window mode or compressed mode, use maxScroll (explicitly tracked).
|
|
399
|
-
// In native container mode, derive from the viewport's scroll geometry.
|
|
400
|
-
const max = windowMode || compressed
|
|
401
|
-
? maxScroll
|
|
402
|
-
: horizontal
|
|
403
|
-
? viewport.scrollWidth - viewport.clientWidth
|
|
404
|
-
: viewport.scrollHeight - viewport.clientHeight;
|
|
405
|
-
return scrollTop >= max - threshold;
|
|
406
|
-
};
|
|
407
|
-
const getScrollPercentage = () => {
|
|
408
|
-
const scrollTop = getScrollTop();
|
|
409
|
-
const max = windowMode || compressed
|
|
410
|
-
? maxScroll
|
|
411
|
-
: horizontal
|
|
412
|
-
? viewport.scrollWidth - viewport.clientWidth
|
|
413
|
-
: viewport.scrollHeight - viewport.clientHeight;
|
|
414
|
-
if (max <= 0)
|
|
415
|
-
return 0;
|
|
416
|
-
return Math.min(1, Math.max(0, scrollTop / max));
|
|
417
|
-
};
|
|
418
|
-
const updateConfig = (newConfig) => {
|
|
419
|
-
if (newConfig.compression) {
|
|
420
|
-
compression = newConfig.compression;
|
|
421
|
-
maxScroll = compression.virtualSize - containerHeight;
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
const isCompressedMode = () => compressed;
|
|
425
|
-
const getVelocityValue = () => Math.abs(velocityTracker.velocity);
|
|
426
|
-
const getIsTracking = () => isVelocityTrackerReliable(velocityTracker);
|
|
427
|
-
const getIsScrolling = () => isScrolling;
|
|
428
|
-
const getIsWindowMode = () => windowMode;
|
|
429
|
-
const updateContainerHeightFn = (height) => {
|
|
430
|
-
containerHeight = height;
|
|
431
|
-
// Recompute maxScroll if we have compression or are in window mode
|
|
432
|
-
if (compression) {
|
|
433
|
-
maxScroll = compression.virtualSize - containerHeight;
|
|
434
|
-
}
|
|
435
|
-
else if (windowMode) {
|
|
436
|
-
// In window mode without compression, maxScroll is derived from
|
|
437
|
-
// the content div height. vlist.ts calls updateConfig with compression
|
|
438
|
-
// when totalHeight changes, so this path handles the non-compressed case.
|
|
439
|
-
// We can't compute it here without knowing totalHeight, so leave it
|
|
440
|
-
// and let updateConfig handle it when compression state is updated.
|
|
441
|
-
}
|
|
442
|
-
};
|
|
443
|
-
const destroy = () => {
|
|
444
|
-
if (idleTimeout) {
|
|
445
|
-
clearTimeout(idleTimeout);
|
|
446
|
-
}
|
|
447
|
-
if (windowMode) {
|
|
448
|
-
handleWindowScroll.cancel();
|
|
449
|
-
window.removeEventListener("scroll", handleWindowScroll);
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
handleNativeScroll.cancel();
|
|
453
|
-
viewport.removeEventListener("scroll", handleNativeScroll);
|
|
454
|
-
viewport.removeEventListener("wheel", handleWheel);
|
|
455
|
-
viewport.removeEventListener("wheel", blockWheel);
|
|
456
|
-
viewport.removeEventListener("wheel", handleHorizontalWheel);
|
|
457
|
-
}
|
|
458
|
-
};
|
|
459
|
-
// =============================================================================
|
|
460
|
-
// Initialization
|
|
461
|
-
// =============================================================================
|
|
462
|
-
if (windowMode) {
|
|
463
|
-
// Window scroll mode — listen to window, don't manage viewport overflow
|
|
464
|
-
if (compressed && compression) {
|
|
465
|
-
maxScroll = compression.virtualSize - containerHeight;
|
|
466
|
-
}
|
|
467
|
-
window.addEventListener("scroll", handleWindowScroll, { passive: true });
|
|
468
|
-
}
|
|
469
|
-
else if (compressed && compression) {
|
|
470
|
-
// Start in compressed mode
|
|
471
|
-
maxScroll = compression.virtualSize - containerHeight;
|
|
472
|
-
if (horizontal) {
|
|
473
|
-
viewport.style.overflowX = "hidden";
|
|
474
|
-
}
|
|
475
|
-
else {
|
|
476
|
-
viewport.style.overflow = "hidden";
|
|
477
|
-
}
|
|
478
|
-
if (wheel) {
|
|
479
|
-
viewport.addEventListener("wheel", handleWheel, { passive: false });
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
else {
|
|
483
|
-
// Start in native scroll mode
|
|
484
|
-
if (horizontal) {
|
|
485
|
-
viewport.style.overflowX = "auto";
|
|
486
|
-
viewport.style.overflowY = "hidden";
|
|
487
|
-
}
|
|
488
|
-
else {
|
|
489
|
-
viewport.style.overflow = "auto";
|
|
490
|
-
}
|
|
491
|
-
viewport.addEventListener("scroll", handleNativeScroll, { passive: true });
|
|
492
|
-
if (!wheel) {
|
|
493
|
-
viewport.addEventListener("wheel", blockWheel, { passive: false });
|
|
494
|
-
}
|
|
495
|
-
else if (horizontal) {
|
|
496
|
-
viewport.addEventListener("wheel", handleHorizontalWheel, {
|
|
497
|
-
passive: false,
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
return {
|
|
502
|
-
getScrollTop,
|
|
503
|
-
scrollTo,
|
|
504
|
-
scrollBy,
|
|
505
|
-
isAtTop,
|
|
506
|
-
isAtBottom,
|
|
507
|
-
getScrollPercentage,
|
|
508
|
-
getVelocity: getVelocityValue,
|
|
509
|
-
isTracking: getIsTracking,
|
|
510
|
-
isScrolling: getIsScrolling,
|
|
511
|
-
updateConfig,
|
|
512
|
-
enableCompression,
|
|
513
|
-
disableCompression,
|
|
514
|
-
isCompressed: isCompressedMode,
|
|
515
|
-
isWindowMode: getIsWindowMode,
|
|
516
|
-
updateContainerHeight: updateContainerHeightFn,
|
|
517
|
-
destroy,
|
|
518
|
-
};
|
|
519
|
-
};
|
|
520
|
-
// =============================================================================
|
|
521
|
-
// Utility Functions
|
|
522
|
-
// =============================================================================
|
|
523
|
-
/**
|
|
524
|
-
* Throttle scroll handler using requestAnimationFrame
|
|
525
|
-
*/
|
|
526
|
-
export const rafThrottle = (fn) => {
|
|
527
|
-
let frameId = null;
|
|
528
|
-
let lastArgs = null;
|
|
529
|
-
const throttled = (...args) => {
|
|
530
|
-
lastArgs = args;
|
|
531
|
-
if (frameId === null) {
|
|
532
|
-
frameId = requestAnimationFrame(() => {
|
|
533
|
-
frameId = null;
|
|
534
|
-
if (lastArgs) {
|
|
535
|
-
fn(...lastArgs);
|
|
536
|
-
}
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
};
|
|
540
|
-
throttled.cancel = () => {
|
|
541
|
-
if (frameId !== null) {
|
|
542
|
-
cancelAnimationFrame(frameId);
|
|
543
|
-
frameId = null;
|
|
544
|
-
}
|
|
545
|
-
};
|
|
546
|
-
return throttled;
|
|
547
|
-
};
|
|
548
|
-
/**
|
|
549
|
-
* Check if scroll position is at bottom
|
|
550
|
-
*/
|
|
551
|
-
export const isAtBottom = (scrollTop, scrollHeight, clientHeight, threshold = 0) => {
|
|
552
|
-
return scrollTop + clientHeight >= scrollHeight - threshold;
|
|
553
|
-
};
|
|
554
|
-
/**
|
|
555
|
-
* Check if scroll position is at top
|
|
556
|
-
*/
|
|
557
|
-
export const isAtTop = (scrollTop, threshold = 0) => {
|
|
558
|
-
return scrollTop <= threshold;
|
|
559
|
-
};
|
|
560
|
-
/**
|
|
561
|
-
* Get scroll percentage (0-1)
|
|
562
|
-
*/
|
|
563
|
-
export const getScrollPercentage = (scrollTop, scrollHeight, clientHeight) => {
|
|
564
|
-
const maxScroll = scrollHeight - clientHeight;
|
|
565
|
-
if (maxScroll <= 0)
|
|
566
|
-
return 0;
|
|
567
|
-
return Math.min(1, Math.max(0, scrollTop / maxScroll));
|
|
568
|
-
};
|
|
569
|
-
/**
|
|
570
|
-
* Check if a range is visible in the scroll viewport
|
|
571
|
-
*/
|
|
572
|
-
export const isRangeVisible = (rangeStart, rangeEnd, visibleStart, visibleEnd) => {
|
|
573
|
-
return rangeStart <= visibleEnd && rangeEnd >= visibleStart;
|
|
574
|
-
};
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* vlist v2 — Scrollbar Plugin
|
|
3
|
-
*
|
|
4
|
-
* Replaces the native scrollbar with a custom, cross-browser consistent scrollbar.
|
|
5
|
-
* Priority 15 — runs after layout plugins (10) but before selection (50).
|
|
6
|
-
*
|
|
7
|
-
* Uses the v1 scrollbar UI component directly — it's standalone DOM/events code
|
|
8
|
-
* with no framework dependencies. This plugin wires it into the v2 lifecycle:
|
|
9
|
-
* - onAfterScroll hook → update thumb position
|
|
10
|
-
* - onResize hook → update thumb/track bounds
|
|
11
|
-
* - destroy → cleanup
|
|
12
|
-
*/
|
|
13
|
-
import { createScrollbar } from "./scrollbar";
|
|
14
|
-
// =============================================================================
|
|
15
|
-
// Factory
|
|
16
|
-
// =============================================================================
|
|
17
|
-
export function scrollbar(config) {
|
|
18
|
-
let sb = null;
|
|
19
|
-
let engineState;
|
|
20
|
-
let sizeCache;
|
|
21
|
-
let lastBoundsTotal = 0;
|
|
22
|
-
let lastBoundsContainer = 0;
|
|
23
|
-
return {
|
|
24
|
-
name: "scrollbar",
|
|
25
|
-
priority: 15,
|
|
26
|
-
setup(ctx) {
|
|
27
|
-
const { dom, config: resolvedConfig } = ctx;
|
|
28
|
-
const { classPrefix, horizontal } = resolvedConfig;
|
|
29
|
-
engineState = ctx.getState();
|
|
30
|
-
sizeCache = ctx.sizeCache;
|
|
31
|
-
// Indirect callback — scale plugin can redirect via registerMethod
|
|
32
|
-
let scrollCb = (position) => {
|
|
33
|
-
if (horizontal)
|
|
34
|
-
dom.viewport.scrollLeft = position;
|
|
35
|
-
else
|
|
36
|
-
dom.viewport.scrollTop = position;
|
|
37
|
-
};
|
|
38
|
-
ctx.registerMethod("_scrollbar:setCallback", (cb) => { scrollCb = cb; });
|
|
39
|
-
sb = createScrollbar(dom.viewport, (position) => { scrollCb(position); }, config, classPrefix, horizontal, dom.root);
|
|
40
|
-
dom.viewport.classList.add(`${classPrefix}-viewport--custom-scrollbar`);
|
|
41
|
-
if (config?.gutter) {
|
|
42
|
-
dom.viewport.classList.add(`${classPrefix}-viewport--gutter`);
|
|
43
|
-
}
|
|
44
|
-
// Defer initial bounds update — containerSize may be 0 during setup
|
|
45
|
-
// since the viewport hasn't been laid out yet. The resize observer
|
|
46
|
-
// will fire with the correct size after first paint.
|
|
47
|
-
// Skip if compressed — the scale plugin owns bounds in that case.
|
|
48
|
-
queueMicrotask(() => {
|
|
49
|
-
if (!engineState.isCompressed) {
|
|
50
|
-
sb?.updateBounds(sizeCache.getTotalSize(), engineState.containerSize);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
// Expose scrollbar instance for cross-plugin coordination (scale plugin)
|
|
54
|
-
ctx.registerMethod("_scrollbar:getInstance", () => sb);
|
|
55
|
-
ctx.registerDestroyHandler(() => {
|
|
56
|
-
sb?.destroy();
|
|
57
|
-
sb = null;
|
|
58
|
-
dom.viewport.classList.remove(`${classPrefix}-viewport--custom-scrollbar`);
|
|
59
|
-
if (config?.gutter) {
|
|
60
|
-
dom.viewport.classList.remove(`${classPrefix}-viewport--gutter`);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
},
|
|
64
|
-
hooks: {
|
|
65
|
-
onAfterScroll(scrollPosition) {
|
|
66
|
-
if (!engineState.isCompressed) {
|
|
67
|
-
const total = sizeCache.getTotalSize();
|
|
68
|
-
const container = engineState.containerSize;
|
|
69
|
-
if (total !== lastBoundsTotal || container !== lastBoundsContainer) {
|
|
70
|
-
lastBoundsTotal = total;
|
|
71
|
-
lastBoundsContainer = container;
|
|
72
|
-
sb?.updateBounds(total, container);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
sb?.updatePosition(scrollPosition);
|
|
76
|
-
sb?.show();
|
|
77
|
-
},
|
|
78
|
-
onResize() {
|
|
79
|
-
if (!engineState.isCompressed) {
|
|
80
|
-
const total = sizeCache.getTotalSize();
|
|
81
|
-
const container = engineState.containerSize;
|
|
82
|
-
lastBoundsTotal = total;
|
|
83
|
-
lastBoundsContainer = container;
|
|
84
|
-
sb?.updateBounds(total, container);
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
},
|
|
88
|
-
destroy() {
|
|
89
|
-
sb?.destroy();
|
|
90
|
-
sb = null;
|
|
91
|
-
},
|
|
92
|
-
};
|
|
93
|
-
}
|