vlist 1.9.0 → 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,507 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vlist v2 — Scale Plugin
|
|
3
|
+
*
|
|
4
|
+
* Enables support for 1M+ item lists by compressing the scroll space
|
|
5
|
+
* when total content size exceeds the browser's ~16.7M pixel limit.
|
|
6
|
+
*
|
|
7
|
+
* Priority 20 — runs after layout plugins (10), before selection (50).
|
|
8
|
+
*
|
|
9
|
+
* When compressed:
|
|
10
|
+
* - Native scroll is disabled (overflow: hidden)
|
|
11
|
+
* - Custom wheel handler with lerp smooth scroll
|
|
12
|
+
* - Custom touch handler with momentum
|
|
13
|
+
* - Items positioned relative to viewport via onCalculate hook
|
|
14
|
+
* - Fallback scrollbar created if no scrollbar plugin present
|
|
15
|
+
*
|
|
16
|
+
* Uses v1's rendering/scale.ts pure math functions directly.
|
|
17
|
+
*/
|
|
18
|
+
import { getCompressionState, calculateCompressedVisibleRange, calculateCompressedItemPosition, calculateCompressedScrollToIndex, } from "../../rendering/scale";
|
|
19
|
+
import { createScrollbar } from "../scrollbar/scrollbar";
|
|
20
|
+
import { SCROLL_EASING, SCROLL_DURATION } from "../../constants";
|
|
21
|
+
// =============================================================================
|
|
22
|
+
// Constants
|
|
23
|
+
// =============================================================================
|
|
24
|
+
const LERP_FACTOR = 0.65;
|
|
25
|
+
const SNAP_THRESHOLD = 0.5;
|
|
26
|
+
const SCROLL_SPEED_MULTIPLIER = 1.7;
|
|
27
|
+
const TOUCH_DECELERATION = 0.95;
|
|
28
|
+
const TOUCH_MIN_VELOCITY = 0.1;
|
|
29
|
+
const TOUCH_VELOCITY_SAMPLES = 5;
|
|
30
|
+
const TOUCH_VELOCITY_WINDOW = 100;
|
|
31
|
+
// =============================================================================
|
|
32
|
+
// Factory
|
|
33
|
+
// =============================================================================
|
|
34
|
+
export function scale(config) {
|
|
35
|
+
const force = config?.force ?? false;
|
|
36
|
+
let engineState;
|
|
37
|
+
let sizeCache;
|
|
38
|
+
let viewport;
|
|
39
|
+
let horizontal;
|
|
40
|
+
let overscan;
|
|
41
|
+
let compression = {
|
|
42
|
+
isCompressed: false,
|
|
43
|
+
actualSize: 0,
|
|
44
|
+
virtualSize: 0,
|
|
45
|
+
ratio: 1,
|
|
46
|
+
};
|
|
47
|
+
let compressedActive = false;
|
|
48
|
+
let slack = 0;
|
|
49
|
+
let fallbackScrollbar = null;
|
|
50
|
+
let ownsScrollbar = false;
|
|
51
|
+
// Virtual scroll state
|
|
52
|
+
let virtualScrollPosition = 0;
|
|
53
|
+
let targetScrollPosition = 0;
|
|
54
|
+
let smoothScrollId = null;
|
|
55
|
+
let easedScrollId = null;
|
|
56
|
+
// Touch state
|
|
57
|
+
let touchStartPos = 0;
|
|
58
|
+
let touchScrollStart = 0;
|
|
59
|
+
let momentumId = null;
|
|
60
|
+
// Pre-allocated ring buffer for velocity sampling (zero-alloc hot path)
|
|
61
|
+
const sampleTimes = new Float64Array(TOUCH_VELOCITY_SAMPLES);
|
|
62
|
+
const samplePositions = new Float64Array(TOUCH_VELOCITY_SAMPLES);
|
|
63
|
+
let sampleCount = 0;
|
|
64
|
+
let sampleHead = 0;
|
|
65
|
+
// Reusable range object for compressed visible range calc
|
|
66
|
+
const compRange = { start: 0, end: -1 };
|
|
67
|
+
// Track item count so onCalculate can detect data mutations
|
|
68
|
+
let lastCheckedTotal = -1;
|
|
69
|
+
let storedCtx = null;
|
|
70
|
+
function getMaxScroll() {
|
|
71
|
+
return Math.max(0, compression.virtualSize + slack - engineState.containerSize);
|
|
72
|
+
}
|
|
73
|
+
function computeSlack() {
|
|
74
|
+
if (compression.virtualSize <= 0)
|
|
75
|
+
return 0;
|
|
76
|
+
return Math.max(0, engineState.containerSize * (1 - compression.ratio));
|
|
77
|
+
}
|
|
78
|
+
function updateCompression(ctx) {
|
|
79
|
+
const totalItems = engineState.totalItems;
|
|
80
|
+
compression = getCompressionState(totalItems, sizeCache, force);
|
|
81
|
+
if (compression.isCompressed && !compressedActive) {
|
|
82
|
+
activateCompression(ctx);
|
|
83
|
+
}
|
|
84
|
+
else if (!compression.isCompressed && compressedActive) {
|
|
85
|
+
deactivateCompression(ctx);
|
|
86
|
+
}
|
|
87
|
+
if (compression.isCompressed) {
|
|
88
|
+
slack = computeSlack();
|
|
89
|
+
engineState.isCompressed = true;
|
|
90
|
+
engineState.compressionRatio = compression.ratio;
|
|
91
|
+
ctx.updateContentSize(compression.virtualSize + slack);
|
|
92
|
+
if (fallbackScrollbar) {
|
|
93
|
+
fallbackScrollbar.updateBounds(compression.virtualSize + slack, engineState.containerSize);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Wheel/touch handlers — stored so we can remove them
|
|
98
|
+
let wheelHandler = null;
|
|
99
|
+
let touchStartHandler = null;
|
|
100
|
+
let touchMoveHandler = null;
|
|
101
|
+
let touchEndHandler = null;
|
|
102
|
+
let nativeScrollReset = null;
|
|
103
|
+
function applyVirtualScroll(pos) {
|
|
104
|
+
virtualScrollPosition = pos;
|
|
105
|
+
targetScrollPosition = pos;
|
|
106
|
+
engineState.prevScrollPosition = engineState.scrollPosition;
|
|
107
|
+
engineState.scrollPosition = pos;
|
|
108
|
+
engineState.scrollDirection = pos > engineState.prevScrollPosition ? 1 : -1;
|
|
109
|
+
storedCtx.forceRender();
|
|
110
|
+
}
|
|
111
|
+
function cancelAnimations() {
|
|
112
|
+
if (smoothScrollId !== null) {
|
|
113
|
+
cancelAnimationFrame(smoothScrollId);
|
|
114
|
+
smoothScrollId = null;
|
|
115
|
+
}
|
|
116
|
+
if (easedScrollId !== null) {
|
|
117
|
+
cancelAnimationFrame(easedScrollId);
|
|
118
|
+
easedScrollId = null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function setVirtualPosition(pos) {
|
|
122
|
+
cancelAnimations();
|
|
123
|
+
applyVirtualScroll(Math.max(0, Math.min(pos, getMaxScroll())));
|
|
124
|
+
}
|
|
125
|
+
function easedScrollTo(target, duration, easing = SCROLL_EASING) {
|
|
126
|
+
cancelAnimations();
|
|
127
|
+
const clampedTarget = Math.max(0, Math.min(target, getMaxScroll()));
|
|
128
|
+
const from = virtualScrollPosition;
|
|
129
|
+
if (Math.abs(clampedTarget - from) < SNAP_THRESHOLD) {
|
|
130
|
+
applyVirtualScroll(clampedTarget);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const start = performance.now();
|
|
134
|
+
const tick = (now) => {
|
|
135
|
+
const t = Math.min((now - start) / duration, 1);
|
|
136
|
+
applyVirtualScroll(from + (clampedTarget - from) * easing(t));
|
|
137
|
+
if (t < 1)
|
|
138
|
+
easedScrollId = requestAnimationFrame(tick);
|
|
139
|
+
else
|
|
140
|
+
easedScrollId = null;
|
|
141
|
+
};
|
|
142
|
+
easedScrollId = requestAnimationFrame(tick);
|
|
143
|
+
}
|
|
144
|
+
function activateCompression(ctx) {
|
|
145
|
+
compressedActive = true;
|
|
146
|
+
engineState.isCompressed = true;
|
|
147
|
+
const scrollProp = horizontal ? "scrollLeft" : "scrollTop";
|
|
148
|
+
const overflowProp = horizontal ? "overflowX" : "overflow";
|
|
149
|
+
// Capture native position before disabling
|
|
150
|
+
const nativePos = viewport[scrollProp];
|
|
151
|
+
viewport.style[overflowProp] = "hidden";
|
|
152
|
+
viewport[scrollProp] = 0;
|
|
153
|
+
if (nativePos > 0) {
|
|
154
|
+
virtualScrollPosition = nativePos;
|
|
155
|
+
targetScrollPosition = nativePos;
|
|
156
|
+
engineState.scrollPosition = nativePos;
|
|
157
|
+
}
|
|
158
|
+
slack = computeSlack();
|
|
159
|
+
// Lerp smooth scroll tick
|
|
160
|
+
const smoothScrollTick = () => {
|
|
161
|
+
const diff = targetScrollPosition - virtualScrollPosition;
|
|
162
|
+
const maxScroll = getMaxScroll();
|
|
163
|
+
if (Math.abs(diff) < SNAP_THRESHOLD) {
|
|
164
|
+
virtualScrollPosition = Math.max(0, Math.min(targetScrollPosition, maxScroll));
|
|
165
|
+
targetScrollPosition = virtualScrollPosition;
|
|
166
|
+
smoothScrollId = null;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
virtualScrollPosition += diff * LERP_FACTOR;
|
|
170
|
+
virtualScrollPosition = Math.max(0, Math.min(virtualScrollPosition, maxScroll));
|
|
171
|
+
smoothScrollId = requestAnimationFrame(smoothScrollTick);
|
|
172
|
+
}
|
|
173
|
+
engineState.prevScrollPosition = engineState.scrollPosition;
|
|
174
|
+
engineState.scrollPosition = virtualScrollPosition;
|
|
175
|
+
engineState.scrollDirection = virtualScrollPosition > engineState.prevScrollPosition ? 1 : -1;
|
|
176
|
+
ctx.forceRender();
|
|
177
|
+
};
|
|
178
|
+
// Wheel handler
|
|
179
|
+
wheelHandler = (e) => {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
const maxScroll = getMaxScroll();
|
|
182
|
+
targetScrollPosition = Math.max(0, Math.min(targetScrollPosition + e.deltaY * compression.ratio * SCROLL_SPEED_MULTIPLIER, maxScroll));
|
|
183
|
+
if (smoothScrollId === null) {
|
|
184
|
+
smoothScrollId = requestAnimationFrame(smoothScrollTick);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
viewport.addEventListener("wheel", wheelHandler, { passive: false });
|
|
188
|
+
// Touch handlers
|
|
189
|
+
const cancelMomentum = () => {
|
|
190
|
+
if (momentumId !== null) {
|
|
191
|
+
cancelAnimationFrame(momentumId);
|
|
192
|
+
momentumId = null;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
touchStartHandler = (e) => {
|
|
196
|
+
cancelMomentum();
|
|
197
|
+
if (smoothScrollId !== null) {
|
|
198
|
+
cancelAnimationFrame(smoothScrollId);
|
|
199
|
+
smoothScrollId = null;
|
|
200
|
+
}
|
|
201
|
+
const touch = e.touches[0];
|
|
202
|
+
if (!touch)
|
|
203
|
+
return;
|
|
204
|
+
const y = horizontal ? touch.clientX : touch.clientY;
|
|
205
|
+
touchStartPos = y;
|
|
206
|
+
touchScrollStart = virtualScrollPosition;
|
|
207
|
+
sampleCount = 1;
|
|
208
|
+
sampleHead = 0;
|
|
209
|
+
sampleTimes[0] = performance.now();
|
|
210
|
+
samplePositions[0] = y;
|
|
211
|
+
};
|
|
212
|
+
touchMoveHandler = (e) => {
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
const touch = e.touches[0];
|
|
215
|
+
if (!touch)
|
|
216
|
+
return;
|
|
217
|
+
const y = horizontal ? touch.clientX : touch.clientY;
|
|
218
|
+
const now = performance.now();
|
|
219
|
+
const slot = sampleCount < TOUCH_VELOCITY_SAMPLES ? sampleCount : sampleHead;
|
|
220
|
+
sampleTimes[slot] = now;
|
|
221
|
+
samplePositions[slot] = y;
|
|
222
|
+
if (sampleCount < TOUCH_VELOCITY_SAMPLES) {
|
|
223
|
+
sampleCount++;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
sampleHead = (sampleHead + 1) % TOUCH_VELOCITY_SAMPLES;
|
|
227
|
+
}
|
|
228
|
+
const delta = touchStartPos - y;
|
|
229
|
+
const maxScroll = getMaxScroll();
|
|
230
|
+
const newPos = Math.max(0, Math.min(touchScrollStart + delta * compression.ratio * SCROLL_SPEED_MULTIPLIER, maxScroll));
|
|
231
|
+
virtualScrollPosition = newPos;
|
|
232
|
+
targetScrollPosition = newPos;
|
|
233
|
+
engineState.prevScrollPosition = engineState.scrollPosition;
|
|
234
|
+
engineState.scrollPosition = newPos;
|
|
235
|
+
engineState.scrollDirection = newPos > engineState.prevScrollPosition ? 1 : -1;
|
|
236
|
+
ctx.forceRender();
|
|
237
|
+
};
|
|
238
|
+
touchEndHandler = () => {
|
|
239
|
+
const now = performance.now();
|
|
240
|
+
// Scan ring buffer for oldest/newest within velocity window (zero alloc)
|
|
241
|
+
let oldestTime = Infinity, oldestPos = 0;
|
|
242
|
+
let newestTime = -1, newestPos = 0;
|
|
243
|
+
let recentCount = 0;
|
|
244
|
+
for (let i = 0; i < sampleCount; i++) {
|
|
245
|
+
const idx = (sampleHead + i) % TOUCH_VELOCITY_SAMPLES;
|
|
246
|
+
const t = sampleTimes[idx];
|
|
247
|
+
if (now - t >= TOUCH_VELOCITY_WINDOW)
|
|
248
|
+
continue;
|
|
249
|
+
recentCount++;
|
|
250
|
+
const p = samplePositions[idx];
|
|
251
|
+
if (t < oldestTime) {
|
|
252
|
+
oldestTime = t;
|
|
253
|
+
oldestPos = p;
|
|
254
|
+
}
|
|
255
|
+
if (t > newestTime) {
|
|
256
|
+
newestTime = t;
|
|
257
|
+
newestPos = p;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let velocity = 0;
|
|
261
|
+
if (recentCount >= 2) {
|
|
262
|
+
const dt = newestTime - oldestTime;
|
|
263
|
+
if (dt > 0)
|
|
264
|
+
velocity = (oldestPos - newestPos) / dt;
|
|
265
|
+
}
|
|
266
|
+
sampleCount = 0;
|
|
267
|
+
sampleHead = 0;
|
|
268
|
+
if (Math.abs(velocity) < TOUCH_MIN_VELOCITY)
|
|
269
|
+
return;
|
|
270
|
+
let frameVelocity = velocity * 16;
|
|
271
|
+
const momentumTick = () => {
|
|
272
|
+
frameVelocity *= TOUCH_DECELERATION;
|
|
273
|
+
if (Math.abs(frameVelocity) < 0.5) {
|
|
274
|
+
momentumId = null;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const maxScroll = getMaxScroll();
|
|
278
|
+
let newPos = virtualScrollPosition + frameVelocity;
|
|
279
|
+
newPos = Math.max(0, Math.min(newPos, maxScroll));
|
|
280
|
+
if ((newPos <= 0 && frameVelocity < 0) || (newPos >= maxScroll && frameVelocity > 0)) {
|
|
281
|
+
virtualScrollPosition = newPos;
|
|
282
|
+
targetScrollPosition = newPos;
|
|
283
|
+
engineState.scrollPosition = newPos;
|
|
284
|
+
ctx.forceRender();
|
|
285
|
+
momentumId = null;
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
virtualScrollPosition = newPos;
|
|
289
|
+
targetScrollPosition = newPos;
|
|
290
|
+
engineState.prevScrollPosition = engineState.scrollPosition;
|
|
291
|
+
engineState.scrollPosition = newPos;
|
|
292
|
+
engineState.scrollDirection = newPos > engineState.prevScrollPosition ? 1 : -1;
|
|
293
|
+
ctx.forceRender();
|
|
294
|
+
momentumId = requestAnimationFrame(momentumTick);
|
|
295
|
+
};
|
|
296
|
+
momentumId = requestAnimationFrame(momentumTick);
|
|
297
|
+
};
|
|
298
|
+
viewport.addEventListener("touchstart", touchStartHandler, { passive: true });
|
|
299
|
+
viewport.addEventListener("touchmove", touchMoveHandler, { passive: false });
|
|
300
|
+
viewport.addEventListener("touchend", touchEndHandler, { passive: true });
|
|
301
|
+
viewport.addEventListener("touchcancel", touchEndHandler, { passive: true });
|
|
302
|
+
// Native scroll drift guard
|
|
303
|
+
nativeScrollReset = () => {
|
|
304
|
+
if (viewport[scrollProp] !== 0) {
|
|
305
|
+
viewport[scrollProp] = 0;
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
viewport.addEventListener("scroll", nativeScrollReset, { passive: true });
|
|
309
|
+
// Scroll callback used by both existing scrollbar plugin and fallback
|
|
310
|
+
const scrollbarCallback = (position) => {
|
|
311
|
+
virtualScrollPosition = position;
|
|
312
|
+
targetScrollPosition = position;
|
|
313
|
+
if (smoothScrollId !== null) {
|
|
314
|
+
cancelAnimationFrame(smoothScrollId);
|
|
315
|
+
smoothScrollId = null;
|
|
316
|
+
}
|
|
317
|
+
engineState.prevScrollPosition = engineState.scrollPosition;
|
|
318
|
+
engineState.scrollPosition = position;
|
|
319
|
+
engineState.scrollDirection = position > engineState.prevScrollPosition ? 1 : -1;
|
|
320
|
+
ctx.forceRender();
|
|
321
|
+
};
|
|
322
|
+
// If scrollbar plugin exists, redirect its callback and use its instance.
|
|
323
|
+
// Otherwise create a fallback scrollbar.
|
|
324
|
+
const setCallback = ctx.getMethod("_scrollbar:setCallback");
|
|
325
|
+
const getInstance = ctx.getMethod("_scrollbar:getInstance");
|
|
326
|
+
if (setCallback && getInstance) {
|
|
327
|
+
setCallback(scrollbarCallback);
|
|
328
|
+
const existing = getInstance();
|
|
329
|
+
if (existing) {
|
|
330
|
+
fallbackScrollbar = existing;
|
|
331
|
+
ownsScrollbar = false;
|
|
332
|
+
fallbackScrollbar.updateBounds(compression.virtualSize + slack, engineState.containerSize);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else if (!fallbackScrollbar) {
|
|
336
|
+
const classPrefix = ctx.config.classPrefix;
|
|
337
|
+
fallbackScrollbar = createScrollbar(viewport, scrollbarCallback, {}, classPrefix, horizontal, ctx.dom.root);
|
|
338
|
+
ownsScrollbar = true;
|
|
339
|
+
if (!viewport.classList.contains(`${classPrefix}-viewport--custom-scrollbar`)) {
|
|
340
|
+
viewport.classList.add(`${classPrefix}-viewport--custom-scrollbar`);
|
|
341
|
+
}
|
|
342
|
+
fallbackScrollbar.updateBounds(compression.virtualSize + slack, engineState.containerSize);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function deactivateCompression(ctx) {
|
|
346
|
+
compressedActive = false;
|
|
347
|
+
engineState.isCompressed = false;
|
|
348
|
+
engineState.compressionRatio = 1;
|
|
349
|
+
virtualScrollPosition = 0;
|
|
350
|
+
targetScrollPosition = 0;
|
|
351
|
+
sampleCount = 0;
|
|
352
|
+
sampleHead = 0;
|
|
353
|
+
cleanup();
|
|
354
|
+
const overflowProp = horizontal ? "overflowX" : "overflow";
|
|
355
|
+
viewport.style[overflowProp] = "auto";
|
|
356
|
+
slack = 0;
|
|
357
|
+
ctx.updateContentSize(sizeCache.getTotalSize());
|
|
358
|
+
}
|
|
359
|
+
function cleanup() {
|
|
360
|
+
if (smoothScrollId !== null) {
|
|
361
|
+
cancelAnimationFrame(smoothScrollId);
|
|
362
|
+
smoothScrollId = null;
|
|
363
|
+
}
|
|
364
|
+
if (easedScrollId !== null) {
|
|
365
|
+
cancelAnimationFrame(easedScrollId);
|
|
366
|
+
easedScrollId = null;
|
|
367
|
+
}
|
|
368
|
+
if (momentumId !== null) {
|
|
369
|
+
cancelAnimationFrame(momentumId);
|
|
370
|
+
momentumId = null;
|
|
371
|
+
}
|
|
372
|
+
if (wheelHandler) {
|
|
373
|
+
viewport.removeEventListener("wheel", wheelHandler);
|
|
374
|
+
wheelHandler = null;
|
|
375
|
+
}
|
|
376
|
+
if (touchStartHandler) {
|
|
377
|
+
viewport.removeEventListener("touchstart", touchStartHandler);
|
|
378
|
+
touchStartHandler = null;
|
|
379
|
+
}
|
|
380
|
+
if (touchMoveHandler) {
|
|
381
|
+
viewport.removeEventListener("touchmove", touchMoveHandler);
|
|
382
|
+
touchMoveHandler = null;
|
|
383
|
+
}
|
|
384
|
+
if (touchEndHandler) {
|
|
385
|
+
viewport.removeEventListener("touchend", touchEndHandler);
|
|
386
|
+
viewport.removeEventListener("touchcancel", touchEndHandler);
|
|
387
|
+
touchEndHandler = null;
|
|
388
|
+
}
|
|
389
|
+
if (nativeScrollReset) {
|
|
390
|
+
viewport.removeEventListener("scroll", nativeScrollReset);
|
|
391
|
+
nativeScrollReset = null;
|
|
392
|
+
}
|
|
393
|
+
if (fallbackScrollbar && ownsScrollbar) {
|
|
394
|
+
fallbackScrollbar.destroy();
|
|
395
|
+
}
|
|
396
|
+
fallbackScrollbar = null;
|
|
397
|
+
ownsScrollbar = false;
|
|
398
|
+
const scrollProp = horizontal ? "scrollLeft" : "scrollTop";
|
|
399
|
+
if (viewport && viewport[scrollProp] !== 0) {
|
|
400
|
+
viewport[scrollProp] = 0;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return {
|
|
404
|
+
name: "scale",
|
|
405
|
+
priority: 20,
|
|
406
|
+
setup(ctx) {
|
|
407
|
+
engineState = ctx.getState();
|
|
408
|
+
sizeCache = ctx.sizeCache;
|
|
409
|
+
viewport = ctx.dom.viewport;
|
|
410
|
+
horizontal = ctx.config.horizontal;
|
|
411
|
+
overscan = ctx.config.overscan;
|
|
412
|
+
storedCtx = ctx;
|
|
413
|
+
ctx.registerMethod("_updateCompressionMode", () => updateCompression(ctx));
|
|
414
|
+
// Register scroll get/set so scrollToIndex routes through us
|
|
415
|
+
ctx.setScrollFns(() => virtualScrollPosition, (actualOffset) => {
|
|
416
|
+
if (!compression.isCompressed || !compressedActive) {
|
|
417
|
+
const prop = horizontal ? "scrollLeft" : "scrollTop";
|
|
418
|
+
viewport[prop] = actualOffset;
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
cancelAnimations();
|
|
422
|
+
if (momentumId !== null) {
|
|
423
|
+
cancelAnimationFrame(momentumId);
|
|
424
|
+
momentumId = null;
|
|
425
|
+
}
|
|
426
|
+
const virtualPos = actualOffset * compression.ratio;
|
|
427
|
+
applyVirtualScroll(Math.max(0, Math.min(virtualPos, getMaxScroll())));
|
|
428
|
+
});
|
|
429
|
+
ctx.setScrollToIndexFn((index, align, behavior, duration, easing) => {
|
|
430
|
+
if (!compression.isCompressed || !compressedActive)
|
|
431
|
+
return false;
|
|
432
|
+
const virtualPos = calculateCompressedScrollToIndex(index, sizeCache, engineState.containerSize, engineState.totalItems, compression, align);
|
|
433
|
+
if (behavior === "smooth") {
|
|
434
|
+
easedScrollTo(virtualPos, duration ?? SCROLL_DURATION, easing);
|
|
435
|
+
}
|
|
436
|
+
else {
|
|
437
|
+
setVirtualPosition(virtualPos);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
// Initial compression check
|
|
441
|
+
updateCompression(ctx);
|
|
442
|
+
lastCheckedTotal = engineState.totalItems;
|
|
443
|
+
ctx.registerDestroyHandler(cleanup);
|
|
444
|
+
},
|
|
445
|
+
hooks: {
|
|
446
|
+
onCalculate(state) {
|
|
447
|
+
// Re-check compression when item count changes (data mutation)
|
|
448
|
+
if (state.totalItems !== lastCheckedTotal && storedCtx) {
|
|
449
|
+
lastCheckedTotal = state.totalItems;
|
|
450
|
+
updateCompression(storedCtx);
|
|
451
|
+
}
|
|
452
|
+
if (!compression.isCompressed || !compressedActive)
|
|
453
|
+
return;
|
|
454
|
+
// Override phase1's buffer contents with compressed positioning.
|
|
455
|
+
// Phase1 ran with state.scrollPosition = virtualScrollPosition and
|
|
456
|
+
// sizeCache in actual space, so its range calc may be off.
|
|
457
|
+
// We recalculate using the compression-aware formula.
|
|
458
|
+
const totalItems = state.totalItems;
|
|
459
|
+
if (totalItems === 0 || state.containerSize <= 0) {
|
|
460
|
+
state.visibleCount = 0;
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
// Calculate compressed visible range
|
|
464
|
+
calculateCompressedVisibleRange(state.scrollPosition, state.containerSize, sizeCache, totalItems, compression, compRange);
|
|
465
|
+
// Apply overscan
|
|
466
|
+
const renderStart = Math.max(0, compRange.start - overscan);
|
|
467
|
+
const renderEnd = Math.min(totalItems - 1, compRange.end + overscan);
|
|
468
|
+
// Fill buffers with compressed item positions
|
|
469
|
+
const count = Math.min(renderEnd - renderStart + 1, state.capacity);
|
|
470
|
+
state.visibleCount = count;
|
|
471
|
+
state.startIndex = renderStart;
|
|
472
|
+
for (let i = 0; i < count; i++) {
|
|
473
|
+
const idx = renderStart + i;
|
|
474
|
+
state.visibleIndices[i] = idx;
|
|
475
|
+
state.visibleOffsets[i] = calculateCompressedItemPosition(idx, state.scrollPosition, sizeCache, totalItems, state.containerSize, compression);
|
|
476
|
+
state.visibleSizes[i] = sizeCache.getSize(idx);
|
|
477
|
+
}
|
|
478
|
+
state.prevRangeStart = renderStart;
|
|
479
|
+
state.prevRangeEnd = renderEnd;
|
|
480
|
+
},
|
|
481
|
+
onAfterScroll(scrollPosition) {
|
|
482
|
+
if (fallbackScrollbar && compressedActive) {
|
|
483
|
+
fallbackScrollbar.updatePosition(scrollPosition);
|
|
484
|
+
fallbackScrollbar.show();
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
onResize() {
|
|
488
|
+
if (compressedActive) {
|
|
489
|
+
slack = computeSlack();
|
|
490
|
+
if (fallbackScrollbar) {
|
|
491
|
+
fallbackScrollbar.updateBounds(compression.virtualSize + slack, engineState.containerSize);
|
|
492
|
+
}
|
|
493
|
+
// Clamp virtual scroll to new bounds
|
|
494
|
+
const maxScroll = getMaxScroll();
|
|
495
|
+
if (virtualScrollPosition > maxScroll) {
|
|
496
|
+
virtualScrollPosition = maxScroll;
|
|
497
|
+
targetScrollPosition = maxScroll;
|
|
498
|
+
engineState.scrollPosition = maxScroll;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
destroy() {
|
|
504
|
+
cleanup();
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
}
|