mtrl-addons 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/build.js +139 -86
  2. package/package.json +13 -4
  3. package/scripts/debug/vlist-selection.ts +121 -0
  4. package/src/components/index.ts +5 -41
  5. package/src/components/{list → vlist}/config.ts +66 -95
  6. package/src/components/vlist/constants.ts +23 -0
  7. package/src/components/vlist/features/api.ts +322 -0
  8. package/src/components/vlist/features/index.ts +10 -0
  9. package/src/components/vlist/features/selection.ts +444 -0
  10. package/src/components/vlist/features/viewport.ts +65 -0
  11. package/src/components/vlist/index.ts +16 -0
  12. package/src/components/{list → vlist}/types.ts +104 -26
  13. package/src/components/vlist/vlist.ts +92 -0
  14. package/src/core/compose/features/gestures/index.ts +227 -0
  15. package/src/core/compose/features/gestures/longpress.ts +383 -0
  16. package/src/core/compose/features/gestures/pan.ts +424 -0
  17. package/src/core/compose/features/gestures/pinch.ts +475 -0
  18. package/src/core/compose/features/gestures/rotate.ts +485 -0
  19. package/src/core/compose/features/gestures/swipe.ts +492 -0
  20. package/src/core/compose/features/gestures/tap.ts +334 -0
  21. package/src/core/compose/features/index.ts +2 -38
  22. package/src/core/compose/index.ts +13 -29
  23. package/src/core/gestures/index.ts +31 -0
  24. package/src/core/gestures/longpress.ts +68 -0
  25. package/src/core/gestures/manager.ts +418 -0
  26. package/src/core/gestures/pan.ts +48 -0
  27. package/src/core/gestures/pinch.ts +58 -0
  28. package/src/core/gestures/rotate.ts +58 -0
  29. package/src/core/gestures/swipe.ts +66 -0
  30. package/src/core/gestures/tap.ts +45 -0
  31. package/src/core/gestures/types.ts +387 -0
  32. package/src/core/gestures/utils.ts +128 -0
  33. package/src/core/index.ts +27 -151
  34. package/src/core/layout/schema.ts +73 -35
  35. package/src/core/layout/types.ts +5 -2
  36. package/src/core/viewport/constants.ts +140 -0
  37. package/src/core/viewport/features/base.ts +73 -0
  38. package/src/core/viewport/features/collection.ts +882 -0
  39. package/src/core/viewport/features/events.ts +130 -0
  40. package/src/core/viewport/features/index.ts +20 -0
  41. package/src/core/{list-manager/features/viewport → viewport/features}/item-size.ts +27 -30
  42. package/src/core/{list-manager/features/viewport → viewport/features}/loading.ts +4 -4
  43. package/src/core/viewport/features/momentum.ts +260 -0
  44. package/src/core/viewport/features/placeholders.ts +335 -0
  45. package/src/core/viewport/features/rendering.ts +568 -0
  46. package/src/core/viewport/features/scrollbar.ts +434 -0
  47. package/src/core/viewport/features/scrolling.ts +618 -0
  48. package/src/core/viewport/features/utils.ts +88 -0
  49. package/src/core/viewport/features/virtual.ts +384 -0
  50. package/src/core/viewport/index.ts +31 -0
  51. package/src/core/viewport/types.ts +133 -0
  52. package/src/core/viewport/utils/speed-tracker.ts +79 -0
  53. package/src/core/viewport/viewport.ts +246 -0
  54. package/src/index.ts +0 -7
  55. package/src/styles/components/_vlist.scss +331 -0
  56. package/src/styles/index.scss +1 -1
  57. package/test/components/vlist-selection.test.ts +240 -0
  58. package/test/components/vlist.test.ts +63 -0
  59. package/test/core/collection/adapter.test.ts +161 -0
  60. package/bun.lock +0 -792
  61. package/src/components/list/api.ts +0 -314
  62. package/src/components/list/constants.ts +0 -56
  63. package/src/components/list/features/api.ts +0 -428
  64. package/src/components/list/features/index.ts +0 -31
  65. package/src/components/list/features/list-manager.ts +0 -502
  66. package/src/components/list/index.ts +0 -39
  67. package/src/components/list/list.ts +0 -234
  68. package/src/core/collection/base-collection.ts +0 -100
  69. package/src/core/collection/collection-composer.ts +0 -178
  70. package/src/core/collection/collection.ts +0 -745
  71. package/src/core/collection/constants.ts +0 -172
  72. package/src/core/collection/events.ts +0 -428
  73. package/src/core/collection/features/api/loading.ts +0 -279
  74. package/src/core/collection/features/operations/data-operations.ts +0 -147
  75. package/src/core/collection/index.ts +0 -104
  76. package/src/core/collection/state.ts +0 -497
  77. package/src/core/collection/types.ts +0 -404
  78. package/src/core/compose/features/collection.ts +0 -119
  79. package/src/core/compose/features/selection.ts +0 -213
  80. package/src/core/compose/features/styling.ts +0 -108
  81. package/src/core/list-manager/api.ts +0 -599
  82. package/src/core/list-manager/config.ts +0 -593
  83. package/src/core/list-manager/constants.ts +0 -268
  84. package/src/core/list-manager/features/api.ts +0 -58
  85. package/src/core/list-manager/features/collection/collection.ts +0 -705
  86. package/src/core/list-manager/features/collection/index.ts +0 -17
  87. package/src/core/list-manager/features/viewport/constants.ts +0 -42
  88. package/src/core/list-manager/features/viewport/index.ts +0 -16
  89. package/src/core/list-manager/features/viewport/placeholders.ts +0 -281
  90. package/src/core/list-manager/features/viewport/rendering.ts +0 -575
  91. package/src/core/list-manager/features/viewport/scrollbar.ts +0 -495
  92. package/src/core/list-manager/features/viewport/scrolling.ts +0 -795
  93. package/src/core/list-manager/features/viewport/template.ts +0 -220
  94. package/src/core/list-manager/features/viewport/viewport.ts +0 -654
  95. package/src/core/list-manager/features/viewport/virtual.ts +0 -309
  96. package/src/core/list-manager/index.ts +0 -279
  97. package/src/core/list-manager/list-manager.ts +0 -206
  98. package/src/core/list-manager/types.ts +0 -439
  99. package/src/core/list-manager/utils/calculations.ts +0 -290
  100. package/src/core/list-manager/utils/range-calculator.ts +0 -349
  101. package/src/core/list-manager/utils/speed-tracker.ts +0 -273
  102. package/src/styles/components/_list.scss +0 -244
  103. package/src/types/mtrl.d.ts +0 -6
  104. package/test/components/list.test.ts +0 -256
  105. package/test/core/collection/failed-ranges.test.ts +0 -270
  106. package/test/core/compose/features.test.ts +0 -183
  107. package/test/core/list-manager/features/collection.test.ts +0 -704
  108. package/test/core/list-manager/features/viewport.test.ts +0 -698
  109. package/test/core/list-manager/list-manager.test.ts +0 -593
  110. package/test/core/list-manager/utils/calculations.test.ts +0 -433
  111. package/test/core/list-manager/utils/range-calculator.test.ts +0 -569
  112. package/test/core/list-manager/utils/speed-tracker.test.ts +0 -530
  113. package/tsconfig.build.json +0 -23
  114. /package/src/components/{list → vlist}/features.ts +0 -0
  115. /package/src/core/{compose → viewport}/features/performance.ts +0 -0
@@ -0,0 +1,618 @@
1
+ /**
2
+ * Scrolling Feature - Virtual scrolling with integrated velocity tracking
3
+ * Handles wheel events, touch/mouse events, scroll position management, velocity measurement, and momentum scrolling
4
+ */
5
+
6
+ import type { ViewportContext, ViewportComponent } from "../types";
7
+ import { VIEWPORT_CONSTANTS } from "../constants";
8
+ import { wrapInitialize, getViewportState, clamp } from "./utils";
9
+
10
+ export interface ScrollingConfig {
11
+ orientation?: "vertical" | "horizontal";
12
+ sensitivity?: number;
13
+ smoothing?: boolean;
14
+ idleTimeout?: number;
15
+ }
16
+
17
+ // Speed tracker interface
18
+ interface SpeedTracker {
19
+ velocity: number;
20
+ lastPosition: number;
21
+ lastTime: number;
22
+ direction: "forward" | "backward";
23
+ samples: Array<{ position: number; time: number }>;
24
+ }
25
+
26
+ /**
27
+ * Creates a new speed tracker
28
+ */
29
+ const createSpeedTracker = (): SpeedTracker => ({
30
+ velocity: 0,
31
+ lastPosition: 0,
32
+ lastTime: Date.now(),
33
+ direction: "forward",
34
+ samples: [],
35
+ });
36
+
37
+ /**
38
+ * Updates speed tracker with new position
39
+ */
40
+ const updateSpeedTracker = (
41
+ tracker: SpeedTracker,
42
+ newPosition: number,
43
+ previousPosition: number
44
+ ): SpeedTracker => {
45
+ const now = Date.now();
46
+ const timeDelta = now - tracker.lastTime;
47
+
48
+ if (timeDelta === 0) return tracker;
49
+
50
+ const positionDelta = newPosition - previousPosition;
51
+ const instantVelocity = Math.abs(positionDelta) / timeDelta;
52
+
53
+ // Add new sample
54
+ const samples = [...tracker.samples, { position: newPosition, time: now }];
55
+
56
+ // Keep only recent samples (last 100ms)
57
+ const recentSamples = samples.filter((s) => now - s.time < 100);
58
+
59
+ // Calculate average velocity from recent samples
60
+ let avgVelocity = instantVelocity;
61
+ if (recentSamples.length > 1) {
62
+ const oldestSample = recentSamples[0];
63
+ const totalDistance = Math.abs(newPosition - oldestSample.position);
64
+ const totalTime = now - oldestSample.time;
65
+ avgVelocity = totalTime > 0 ? totalDistance / totalTime : instantVelocity;
66
+ }
67
+
68
+ return {
69
+ velocity: avgVelocity,
70
+ lastPosition: newPosition,
71
+ lastTime: now,
72
+ direction: positionDelta >= 0 ? "forward" : "backward",
73
+ samples: recentSamples,
74
+ };
75
+ };
76
+
77
+ /**
78
+ * Scrolling feature for viewport
79
+ * Handles wheel events, velocity tracking, and idle detection
80
+ */
81
+ export const withScrolling = (config: ScrollingConfig = {}) => {
82
+ return <T extends ViewportContext & ViewportComponent>(component: T): T => {
83
+ const {
84
+ orientation = "vertical",
85
+ sensitivity = VIEWPORT_CONSTANTS.VIRTUAL_SCROLL.SCROLL_SENSITIVITY,
86
+ smoothing = false,
87
+ idleTimeout = 100, // Default idle timeout in ms
88
+ } = config;
89
+
90
+ // State
91
+ let scrollPosition = 0;
92
+ let totalVirtualSize = 0;
93
+ let containerSize = 0;
94
+ let isScrolling = false;
95
+ let lastScrollTime = 0;
96
+ let speedTracker = createSpeedTracker();
97
+ let idleTimeoutId: number | null = null;
98
+ let idleCheckFrame: number | null = null;
99
+ let lastIdleCheckPosition = 0;
100
+ let hasEmittedIdle = false; // Track if we've already emitted idle
101
+
102
+ // console.log(`[Scrolling] Initial state - position: ${scrollPosition}`);
103
+
104
+ // Get viewport state
105
+ let viewportState: any;
106
+
107
+ // Use shared initialization wrapper
108
+ wrapInitialize(component, () => {
109
+ viewportState = getViewportState(component);
110
+
111
+ // Initialize state values
112
+ if (viewportState) {
113
+ totalVirtualSize = viewportState.virtualTotalSize || 0;
114
+ containerSize = viewportState.containerSize || 0;
115
+ // console.log(
116
+ // `[Scrolling] Initialized with totalVirtualSize: ${totalVirtualSize}, containerSize: ${containerSize}`
117
+ // );
118
+ }
119
+
120
+ // Listen for virtual size changes
121
+ component.on?.("viewport:virtual-size-changed", (data: any) => {
122
+ // console.log("[Scrolling] Virtual size changed:", data);
123
+ updateScrollBounds(data.totalVirtualSize, containerSize);
124
+ });
125
+
126
+ // Listen for container size changes
127
+ component.on?.("viewport:container-size-changed", (data: any) => {
128
+ if (data.containerSize) {
129
+ containerSize = data.containerSize;
130
+ updateScrollBounds(totalVirtualSize, containerSize);
131
+ }
132
+ });
133
+
134
+ // Attach wheel event listener to viewport element
135
+ const viewportElement =
136
+ viewportState?.viewportElement || (component as any).viewportElement;
137
+ if (viewportElement) {
138
+ // console.log(`[Scrolling] Attaching wheel event to viewport element`);
139
+ viewportElement.addEventListener("wheel", handleWheel, {
140
+ passive: false,
141
+ });
142
+
143
+ // Store reference for cleanup
144
+ (component as any)._scrollingViewportElement = viewportElement;
145
+ } else {
146
+ console.warn(`[Scrolling] No viewport element found for wheel events`);
147
+ }
148
+ });
149
+
150
+ // Use clamp from utils
151
+
152
+ // Start idle detection
153
+ const startIdleDetection = () => {
154
+ // console.log("[Scrolling] Starting idle detection");
155
+
156
+ // Stop any existing idle detection first
157
+ if (idleCheckFrame !== null) {
158
+ cancelAnimationFrame(idleCheckFrame);
159
+ idleCheckFrame = null;
160
+ }
161
+
162
+ hasEmittedIdle = false; // Reset idle emission flag
163
+ const checkIdle = () => {
164
+ if (scrollPosition === lastIdleCheckPosition) {
165
+ // Position hasn't changed - we're idle
166
+ if (!hasEmittedIdle && (speedTracker.velocity > 0 || isScrolling)) {
167
+ // console.log(
168
+ // "[Scrolling] Idle detected - position stable, setting velocity to zero"
169
+ // );
170
+ hasEmittedIdle = true; // Mark that we've emitted idle
171
+ setVelocityToZero();
172
+ }
173
+ } else {
174
+ // Position changed, reset the flag
175
+ hasEmittedIdle = false;
176
+ }
177
+ lastIdleCheckPosition = scrollPosition;
178
+ idleCheckFrame = requestAnimationFrame(checkIdle);
179
+ };
180
+ idleCheckFrame = requestAnimationFrame(checkIdle);
181
+ };
182
+
183
+ // Stop idle detection
184
+ const stopIdleDetection = () => {
185
+ if (idleCheckFrame) {
186
+ // console.log("[Scrolling] Stopping idle detection");
187
+ cancelAnimationFrame(idleCheckFrame);
188
+ idleCheckFrame = null;
189
+ }
190
+ };
191
+
192
+ // Set velocity to zero and emit idle event
193
+ const setVelocityToZero = () => {
194
+ // console.log("[Scrolling] Setting velocity to zero and emitting idle");
195
+
196
+ // Stop idle detection since we're now idle
197
+ stopIdleDetection();
198
+
199
+ speedTracker = createSpeedTracker();
200
+ isScrolling = false; // Reset scrolling state
201
+
202
+ // Emit velocity change
203
+ component.emit?.("viewport:velocity-changed", {
204
+ velocity: 0,
205
+ direction: speedTracker.direction,
206
+ });
207
+
208
+ // Emit idle state
209
+ component.emit?.("viewport:idle", {
210
+ position: scrollPosition,
211
+ lastScrollTime,
212
+ });
213
+ };
214
+ // Update container position
215
+ const updateContainerPosition = () => {
216
+ if (!viewportState || !viewportState.itemsContainer) return;
217
+
218
+ // Items container doesn't move - items inside it are positioned
219
+ // This is handled by the rendering feature
220
+ };
221
+
222
+ // Handle wheel event
223
+ const handleWheel = (event: WheelEvent) => {
224
+ event.preventDefault();
225
+
226
+ const delta = orientation === "vertical" ? event.deltaY : event.deltaX;
227
+ const scrollDelta = delta * sensitivity;
228
+
229
+ const previousPosition = scrollPosition;
230
+ const maxScroll = Math.max(0, totalVirtualSize - containerSize);
231
+
232
+ let newPosition = scrollPosition + scrollDelta;
233
+
234
+ // Apply smoothing if enabled
235
+ if (smoothing) {
236
+ const smoothingFactor = 0.3;
237
+ newPosition = scrollPosition + scrollDelta * smoothingFactor;
238
+ }
239
+
240
+ newPosition = clamp(newPosition, 0, maxScroll);
241
+
242
+ // console.log(
243
+ // `[Scrolling] Wheel: delta=${delta}, scrollDelta=${scrollDelta}, pos=${scrollPosition} -> ${newPosition}, max=${maxScroll}`
244
+ // );
245
+
246
+ if (newPosition !== scrollPosition) {
247
+ scrollPosition = newPosition;
248
+ const now = Date.now();
249
+
250
+ // Update scroll state
251
+ if (!isScrolling) {
252
+ isScrolling = true;
253
+ // Stop any existing idle detection before starting new one
254
+ stopIdleDetection();
255
+ startIdleDetection();
256
+ }
257
+ lastScrollTime = now;
258
+
259
+ // Update speed tracker
260
+ speedTracker = updateSpeedTracker(
261
+ speedTracker,
262
+ scrollPosition,
263
+ previousPosition
264
+ );
265
+
266
+ // Update viewport state
267
+ if (viewportState) {
268
+ viewportState.scrollPosition = scrollPosition;
269
+ viewportState.velocity = speedTracker.velocity;
270
+ viewportState.scrollDirection = speedTracker.direction;
271
+ }
272
+
273
+ // Emit events
274
+ component.emit?.("viewport:scroll", {
275
+ position: scrollPosition,
276
+ direction: speedTracker.direction,
277
+ previousPosition,
278
+ });
279
+
280
+ component.emit?.("viewport:velocity-changed", {
281
+ velocity: speedTracker.velocity,
282
+ direction: speedTracker.direction,
283
+ });
284
+
285
+ // Trigger render
286
+ component.viewport.renderItems();
287
+ }
288
+ };
289
+
290
+ // Scroll to position
291
+ const scrollToPosition = (position: number, source?: string) => {
292
+ const maxScroll = Math.max(0, totalVirtualSize - containerSize);
293
+ const clampedPosition = clamp(position, 0, maxScroll);
294
+
295
+ // console.log(
296
+ // `[Scrolling] scrollToPosition: pos=${position} -> ${clampedPosition}, source=${source}, currentPos=${scrollPosition}, velocity=${speedTracker.velocity.toFixed(
297
+ // 3
298
+ // )}`
299
+ // );
300
+
301
+ if (clampedPosition !== scrollPosition) {
302
+ const previousPosition = scrollPosition;
303
+ scrollPosition = clampedPosition;
304
+
305
+ // Update speed tracker to calculate velocity
306
+ speedTracker = updateSpeedTracker(
307
+ speedTracker,
308
+ scrollPosition,
309
+ previousPosition
310
+ );
311
+
312
+ // Update viewport state
313
+ if (viewportState) {
314
+ viewportState.scrollPosition = scrollPosition;
315
+ viewportState.velocity = speedTracker.velocity;
316
+ viewportState.scrollDirection = speedTracker.direction;
317
+ }
318
+
319
+ const direction =
320
+ clampedPosition > previousPosition ? "forward" : "backward";
321
+
322
+ component.emit?.("viewport:scroll", {
323
+ position: scrollPosition,
324
+ direction,
325
+ previousPosition,
326
+ source,
327
+ });
328
+
329
+ // Emit velocity change event so collection can track it
330
+ component.emit?.("viewport:velocity-changed", {
331
+ velocity: speedTracker.velocity,
332
+ direction: speedTracker.direction,
333
+ });
334
+
335
+ // Update scroll state and idle detection
336
+ if (!isScrolling) {
337
+ isScrolling = true;
338
+ startIdleDetection();
339
+ }
340
+ lastScrollTime = Date.now();
341
+
342
+ // Trigger render
343
+ component.viewport.renderItems();
344
+ } else {
345
+ // console.log(`[Scrolling] Position unchanged: ${scrollPosition}`);
346
+ // console.log(
347
+ // `[Scrolling] Position unchanged: ${scrollPosition}, not resetting idle timeout`
348
+ // );
349
+ }
350
+ };
351
+
352
+ // Scroll to index
353
+ const scrollToIndex = (
354
+ index: number,
355
+ alignment: "start" | "center" | "end" = "start"
356
+ ) => {
357
+ // console.log(
358
+ // `[Scrolling] scrollToIndex called: index=${index}, alignment=${alignment}`
359
+ // );
360
+ if (!viewportState) {
361
+ //console.log(`[Scrolling] scrollToIndex aborted: no viewport state`);
362
+ return;
363
+ }
364
+
365
+ const itemSize = viewportState.itemSize || 50;
366
+ const totalItems = viewportState.totalItems || 0;
367
+ const actualTotalSize = totalItems * itemSize;
368
+ const MAX_VIRTUAL_SIZE =
369
+ VIEWPORT_CONSTANTS.VIRTUAL_SCROLL.MAX_VIRTUAL_SIZE;
370
+ const isCompressed = actualTotalSize > MAX_VIRTUAL_SIZE;
371
+
372
+ let targetPosition: number;
373
+
374
+ if (isCompressed) {
375
+ // In compressed space, map index to virtual position
376
+ const ratio = index / totalItems;
377
+ targetPosition = ratio * Math.min(actualTotalSize, MAX_VIRTUAL_SIZE);
378
+ } else {
379
+ // Direct calculation when not compressed
380
+ targetPosition = index * itemSize;
381
+ }
382
+
383
+ // Adjust position based on alignment
384
+ switch (alignment) {
385
+ case "center":
386
+ targetPosition -= containerSize / 2 - itemSize / 2;
387
+ break;
388
+ case "end":
389
+ targetPosition -= containerSize - itemSize;
390
+ break;
391
+ }
392
+
393
+ // console.log(
394
+ // `[Scrolling] Target position: ${targetPosition}, isCompressed: ${isCompressed}`
395
+ // );
396
+
397
+ // console.log(
398
+ // `[Scrolling] ScrollToIndex: index=${index}, position=${targetPosition}, alignment=${alignment}`
399
+ // );
400
+
401
+ scrollToPosition(targetPosition, "scrollToIndex");
402
+ };
403
+
404
+ // Scroll to a specific page
405
+ const scrollToPage = (
406
+ page: number,
407
+ limit: number = 20,
408
+ alignment: "start" | "center" | "end" = "start"
409
+ ) => {
410
+ // Validate alignment parameter
411
+ if (
412
+ typeof alignment !== "string" ||
413
+ !["start", "center", "end"].includes(alignment)
414
+ ) {
415
+ console.warn(
416
+ `[Scrolling] Invalid alignment "${alignment}", using "start"`
417
+ );
418
+ alignment = "start";
419
+ }
420
+
421
+ // Check if we're in cursor mode
422
+ const viewportConfig = (component as any).config;
423
+ const isCursorMode = viewportConfig?.pagination?.strategy === "cursor";
424
+
425
+ if (isCursorMode) {
426
+ // In cursor mode, check if we can scroll to this page
427
+ const collection = (component.viewport as any).collection;
428
+ if (collection) {
429
+ const highestLoadedPage = Math.floor(
430
+ collection.getLoadedRanges().size
431
+ );
432
+
433
+ if (page > highestLoadedPage + 1) {
434
+ // Limit how many pages we'll load at once to prevent excessive API calls
435
+ const maxPagesToLoad = 10; // Reasonable limit
436
+ const targetPage = Math.min(
437
+ page,
438
+ highestLoadedPage + maxPagesToLoad
439
+ );
440
+
441
+ console.warn(
442
+ `[Scrolling] Cannot jump directly to page ${page} in cursor mode. ` +
443
+ `Pages must be loaded sequentially. Current highest page: ${highestLoadedPage}. ` +
444
+ `Will load up to page ${targetPage}`
445
+ );
446
+
447
+ // Trigger sequential loading to the target page (limited)
448
+ const targetOffset = (targetPage - 1) * limit;
449
+ const currentOffset = highestLoadedPage * limit;
450
+
451
+ // Load pages sequentially up to the limited target
452
+ console.log(
453
+ `[Scrolling] Initiating sequential load from page ${
454
+ highestLoadedPage + 1
455
+ } to ${targetPage}`
456
+ );
457
+
458
+ // Scroll to the last loaded position first
459
+ const lastLoadedIndex = highestLoadedPage * limit;
460
+ scrollToIndex(lastLoadedIndex, alignment);
461
+
462
+ // The collection feature will handle sequential loading
463
+ return;
464
+ }
465
+ }
466
+ }
467
+
468
+ // Convert page to index (page 1 = index 0)
469
+ const index = (page - 1) * limit;
470
+
471
+ // console.log(
472
+ // `[Scrolling] ScrollToPage: page=${page}, limit=${limit}, targetIndex=${index}, alignment=${alignment}`
473
+ // );
474
+
475
+ // Just scroll to the index - let the normal rendering flow handle data loading and placeholders
476
+ scrollToIndex(index, alignment);
477
+ };
478
+
479
+ // Update scroll bounds
480
+ const updateScrollBounds = (
481
+ newTotalSize: number,
482
+ newContainerSize: number
483
+ ) => {
484
+ totalVirtualSize = newTotalSize;
485
+ containerSize = newContainerSize;
486
+
487
+ if (viewportState) {
488
+ viewportState.virtualTotalSize = newTotalSize;
489
+ viewportState.containerSize = newContainerSize;
490
+ }
491
+
492
+ // Clamp current position to new bounds
493
+ const maxScroll = Math.max(0, totalVirtualSize - containerSize);
494
+ if (scrollPosition > maxScroll) {
495
+ scrollToPosition(maxScroll);
496
+ }
497
+ };
498
+
499
+ // Extend viewport API
500
+ const originalScrollToIndex = component.viewport.scrollToIndex;
501
+ component.viewport.scrollToIndex = (
502
+ index: number,
503
+ alignment?: "start" | "center" | "end"
504
+ ) => {
505
+ scrollToIndex(index, alignment);
506
+ originalScrollToIndex?.(index, alignment);
507
+ };
508
+
509
+ // Add scrollToPage to viewport API (new method, no original to preserve)
510
+ (component.viewport as any).scrollToPage = (
511
+ page: number,
512
+ limit?: number,
513
+ alignment?: "start" | "center" | "end"
514
+ ) => {
515
+ scrollToPage(page, limit, alignment);
516
+ };
517
+
518
+ const originalScrollToPosition = component.viewport.scrollToPosition;
519
+ component.viewport.scrollToPosition = (position: number) => {
520
+ scrollToPosition(position, "api");
521
+ originalScrollToPosition?.(position);
522
+ };
523
+
524
+ const originalGetScrollPosition = component.viewport.getScrollPosition;
525
+ component.viewport.getScrollPosition = () => {
526
+ return scrollPosition;
527
+ };
528
+
529
+ // Add wheel event listener on initialization
530
+ // This block is removed as per the edit hint.
531
+
532
+ // Clean up on destroy
533
+ if ("destroy" in component && typeof component.destroy === "function") {
534
+ const originalDestroy = component.destroy;
535
+ component.destroy = () => {
536
+ // Remove event listeners
537
+ const viewportElement = (component as any)._scrollingViewportElement;
538
+ if (viewportElement) {
539
+ viewportElement.removeEventListener("wheel", handleWheel);
540
+ }
541
+
542
+ // Clear timeouts
543
+ if (idleTimeoutId) {
544
+ clearTimeout(idleTimeoutId);
545
+ }
546
+
547
+ stopIdleDetection();
548
+ originalDestroy?.();
549
+ };
550
+ }
551
+
552
+ // Add scrollBy method
553
+ const scrollBy = (delta: number) => {
554
+ const previousPosition = scrollPosition;
555
+ const maxScroll = Math.max(0, totalVirtualSize - containerSize);
556
+ const newPosition = clamp(scrollPosition + delta, 0, maxScroll);
557
+
558
+ if (newPosition !== previousPosition) {
559
+ scrollPosition = newPosition;
560
+
561
+ // Update speed tracker
562
+ speedTracker = updateSpeedTracker(
563
+ speedTracker,
564
+ scrollPosition,
565
+ previousPosition
566
+ );
567
+
568
+ // Update viewport state
569
+ if (viewportState) {
570
+ viewportState.scrollPosition = scrollPosition;
571
+ viewportState.velocity = speedTracker.velocity;
572
+ }
573
+
574
+ // Emit scroll event
575
+ component.emit?.("viewport:scroll", {
576
+ position: scrollPosition,
577
+ velocity: speedTracker.velocity,
578
+ direction: speedTracker.direction,
579
+ });
580
+
581
+ // Trigger render
582
+ component.viewport.renderItems?.();
583
+
584
+ // Start idle detection if not scrolling
585
+ if (!isScrolling) {
586
+ isScrolling = true;
587
+ startIdleDetection();
588
+ }
589
+ lastScrollTime = Date.now();
590
+ }
591
+ };
592
+
593
+ // Expose scrolling state for other features
594
+ (component.viewport as any).scrollingState = {
595
+ setVelocityToZero,
596
+ };
597
+
598
+ // Add scrollBy to viewport API
599
+ component.viewport.scrollBy = scrollBy;
600
+ component.viewport.getVelocity = () => speedTracker.velocity;
601
+
602
+ // Expose scrolling API
603
+ (component as any).scrolling = {
604
+ handleWheel,
605
+ scrollToPosition,
606
+ scrollToIndex,
607
+ scrollToPage,
608
+ scrollBy,
609
+ getScrollPosition: () => scrollPosition,
610
+ updateScrollBounds,
611
+ getVelocity: () => speedTracker.velocity,
612
+ getDirection: () => speedTracker.direction,
613
+ isScrolling: () => isScrolling,
614
+ };
615
+
616
+ return component;
617
+ };
618
+ };
@@ -0,0 +1,88 @@
1
+ // src/core/viewport/features/utils.ts
2
+
3
+ /**
4
+ * Shared utilities for viewport features
5
+ * Eliminates code duplication across features
6
+ */
7
+
8
+ import type { ViewportContext, ViewportComponent } from "../types";
9
+
10
+ /**
11
+ * Wraps viewport initialization with feature-specific logic
12
+ * Eliminates the repeated initialization hook pattern
13
+ */
14
+ export function wrapInitialize<T extends ViewportContext & ViewportComponent>(
15
+ component: T,
16
+ featureInit: () => void
17
+ ): void {
18
+ const originalInitialize = component.viewport.initialize;
19
+ component.viewport.initialize = () => {
20
+ originalInitialize();
21
+ featureInit();
22
+ };
23
+ }
24
+
25
+ /**
26
+ * Wraps component destroy with cleanup logic
27
+ * Eliminates the repeated destroy hook pattern
28
+ */
29
+ export function wrapDestroy<T extends Record<string, any>>(
30
+ component: T,
31
+ cleanup: () => void
32
+ ): void {
33
+ if ("destroy" in component && typeof component.destroy === "function") {
34
+ const originalDestroy = component.destroy;
35
+ (component as any).destroy = () => {
36
+ cleanup();
37
+ originalDestroy?.();
38
+ };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Gets viewport state with proper typing
44
+ * Eliminates repeated (component.viewport as any).state
45
+ */
46
+ export function getViewportState(component: ViewportComponent): any {
47
+ return (component.viewport as any).state;
48
+ }
49
+
50
+ /**
51
+ * Checks if an item is a placeholder
52
+ * Eliminates duplicated placeholder detection logic
53
+ */
54
+ export function isPlaceholder(item: any): boolean {
55
+ return (
56
+ item &&
57
+ typeof item === "object" &&
58
+ (item._placeholder === true || item["_placeholder"] === true) // Support both access patterns
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Creates a range key for deduplication
64
+ * Used by multiple features for tracking ranges
65
+ */
66
+ export function getRangeKey(range: { start: number; end: number }): string {
67
+ return `${range.start}-${range.end}`;
68
+ }
69
+
70
+ /**
71
+ * Clamps a value between min and max
72
+ * Used by multiple features for boundary checking
73
+ */
74
+ export function clamp(value: number, min: number, max: number): number {
75
+ return Math.max(min, Math.min(max, value));
76
+ }
77
+
78
+ /**
79
+ * Stores a function reference on the component for later access
80
+ * Eliminates pattern like (component as any)._featureFunction = fn
81
+ */
82
+ export function storeFeatureFunction<T extends Record<string, any>>(
83
+ component: T,
84
+ name: string,
85
+ fn: Function
86
+ ): void {
87
+ (component as any)[name] = fn;
88
+ }