mtrl-addons 0.1.1 → 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 -108
  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 -14
  114. /package/src/components/{list → vlist}/features.ts +0 -0
  115. /package/src/core/{compose → viewport}/features/performance.ts +0 -0
@@ -0,0 +1,882 @@
1
+ // src/core/viewport/features/collection.ts
2
+
3
+ /**
4
+ * Collection Feature - Data management and range loading
5
+ * Handles collection integration, pagination, and data fetching
6
+ */
7
+
8
+ import type { ViewportContext, ViewportComponent } from "../types";
9
+ import { VIEWPORT_CONSTANTS } from "../constants";
10
+
11
+ export interface CollectionConfig {
12
+ collection?: any; // Collection adapter
13
+ rangeSize?: number; // Default range size for loading
14
+ strategy?: "offset" | "page" | "cursor"; // Loading strategy
15
+ transform?: (item: any) => any; // Item transformation function
16
+ cancelLoadThreshold?: number; // Velocity threshold for cancelling loads
17
+ maxConcurrentRequests?: number;
18
+ enableRequestQueue?: boolean;
19
+ maxQueueSize?: number;
20
+ loadOnDragEnd?: boolean; // Enable loading when drag ends (safety measure)
21
+ }
22
+
23
+ export interface CollectionComponent {
24
+ collection: {
25
+ loadRange: (offset: number, limit: number) => Promise<any[]>;
26
+ loadMissingRanges: (
27
+ range: { start: number; end: number },
28
+ caller?: string
29
+ ) => Promise<void>;
30
+ getLoadedRanges: () => Set<number>;
31
+ getPendingRanges: () => Set<number>;
32
+ clearFailedRanges: () => void;
33
+ retryFailedRange: (rangeId: number) => Promise<any[]>;
34
+ setTotalItems: (total: number) => void;
35
+ getTotalItems: () => number;
36
+ // Cursor-specific methods
37
+ getCurrentCursor: () => string | null;
38
+ getCursorForPage: (page: number) => string | null;
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Adds collection functionality to viewport component
44
+ */
45
+ export function withCollection(config: CollectionConfig = {}) {
46
+ return <T extends ViewportContext & ViewportComponent>(
47
+ component: T
48
+ ): T & CollectionComponent => {
49
+ const {
50
+ collection,
51
+ rangeSize = VIEWPORT_CONSTANTS.LOADING.DEFAULT_RANGE_SIZE,
52
+ strategy = "offset",
53
+ transform,
54
+ cancelLoadThreshold = VIEWPORT_CONSTANTS.LOADING.CANCEL_THRESHOLD,
55
+ maxConcurrentRequests = VIEWPORT_CONSTANTS.LOADING
56
+ .MAX_CONCURRENT_REQUESTS,
57
+ enableRequestQueue = VIEWPORT_CONSTANTS.REQUEST_QUEUE.ENABLED,
58
+ maxQueueSize = VIEWPORT_CONSTANTS.REQUEST_QUEUE.MAX_QUEUE_SIZE,
59
+ loadOnDragEnd = true, // Default to true as safety measure
60
+ } = config;
61
+
62
+ // console.log("[Viewport Collection] Initialized with strategy:", strategy);
63
+
64
+ // Loading manager state
65
+ interface QueuedRequest {
66
+ range: { start: number; end: number };
67
+ priority: "high" | "normal" | "low";
68
+ timestamp: number;
69
+ resolve: () => void;
70
+ reject: (error: any) => void;
71
+ }
72
+
73
+ // State tracking
74
+ let currentVelocity = 0;
75
+ let activeLoadCount = 0;
76
+ let completedLoads = 0;
77
+ let failedLoads = 0;
78
+ let cancelledLoads = 0;
79
+ let isDragging = false; // Track drag state
80
+
81
+ const activeLoadRanges = new Set<string>();
82
+ let loadRequestQueue: QueuedRequest[] = [];
83
+
84
+ // State
85
+ let items: any[] = [];
86
+ let totalItems = 0;
87
+ let loadedRanges = new Set<number>();
88
+ let pendingRanges = new Set<number>();
89
+ let failedRanges = new Map<
90
+ number,
91
+ {
92
+ attempts: number;
93
+ lastError: Error;
94
+ timestamp: number;
95
+ }
96
+ >();
97
+ let activeRequests = new Map<number, Promise<any[]>>();
98
+
99
+ // Cursor pagination state
100
+ let currentCursor: string | null = null;
101
+ let cursorMap = new Map<number, string>(); // Map page number to cursor
102
+ let pageToOffsetMap = new Map<number, number>(); // Map page to actual offset
103
+ let highestLoadedPage = 0;
104
+ let discoveredTotal: number | null = null; // Track discovered total from API
105
+ let hasReachedEnd = false; // Track if we've reached the end of data
106
+
107
+ // Share items array with component
108
+ component.items = items;
109
+
110
+ /**
111
+ * Get a unique ID for a range based on offset and the base range size
112
+ */
113
+ const getRangeId = (offset: number, limit: number): number => {
114
+ // Always use the base rangeSize for consistent IDs
115
+ // This ensures merged ranges can be tracked properly
116
+ return Math.floor(offset / rangeSize);
117
+ };
118
+
119
+ // Loading manager helpers
120
+ const getRangeKey = (range: { start: number; end: number }): string => {
121
+ return `${range.start}-${range.end}`;
122
+ };
123
+
124
+ const canLoad = (): boolean => {
125
+ return currentVelocity <= cancelLoadThreshold;
126
+ };
127
+
128
+ const processQueue = () => {
129
+ if (!enableRequestQueue || loadRequestQueue.length === 0) return;
130
+
131
+ // Sort queue by priority and timestamp
132
+ loadRequestQueue.sort((a, b) => {
133
+ const priorityOrder = { high: 0, normal: 1, low: 2 };
134
+ const priorityDiff =
135
+ priorityOrder[a.priority] - priorityOrder[b.priority];
136
+ return priorityDiff !== 0 ? priorityDiff : a.timestamp - b.timestamp;
137
+ });
138
+
139
+ // Process requests up to capacity
140
+ while (
141
+ loadRequestQueue.length > 0 &&
142
+ activeLoadCount < maxConcurrentRequests
143
+ ) {
144
+ const request = loadRequestQueue.shift();
145
+ if (request) {
146
+ executeQueuedLoad(request);
147
+ }
148
+ }
149
+ };
150
+
151
+ const executeQueuedLoad = (request: QueuedRequest) => {
152
+ activeLoadCount++;
153
+ activeLoadRanges.add(getRangeKey(request.range));
154
+
155
+ // console.log(
156
+ // `[LoadingManager] Executing load for range ${request.range.start}-${
157
+ // request.range.end
158
+ // } (velocity: ${currentVelocity.toFixed(2)})`
159
+ // );
160
+
161
+ // Call the actual loadMissingRanges function
162
+ loadMissingRangesInternal(request.range)
163
+ .then(() => {
164
+ request.resolve();
165
+ completedLoads++;
166
+ })
167
+ .catch((error: Error) => {
168
+ request.reject(error);
169
+ failedLoads++;
170
+ })
171
+ .finally(() => {
172
+ activeLoadCount--;
173
+ activeLoadRanges.delete(getRangeKey(request.range));
174
+ processQueue();
175
+ });
176
+ };
177
+
178
+ /**
179
+ * Transform items if transform function provided
180
+ */
181
+ const transformItems = (rawItems: any[]): any[] => {
182
+ if (!transform) return rawItems;
183
+ return rawItems.map(transform);
184
+ };
185
+
186
+ /**
187
+ * Load a range of data
188
+ */
189
+ const loadRange = async (offset: number, limit: number): Promise<any[]> => {
190
+ // console.log(
191
+ // `[Collection] loadRange called: offset=${offset}, limit=${limit}`
192
+ // );
193
+
194
+ if (!collection) {
195
+ console.warn("[Collection] No collection adapter configured");
196
+ return [];
197
+ }
198
+
199
+ const rangeId = getRangeId(offset, limit);
200
+ //console.log(`[Collection] Range ID: ${rangeId}`);
201
+
202
+ // Check if already loaded
203
+ if (loadedRanges.has(rangeId)) {
204
+ // console.log(
205
+ // `[Collection] Range ${rangeId} already loaded, returning cached data`
206
+ // );
207
+ return items.slice(offset, offset + limit);
208
+ }
209
+
210
+ // Check if already pending
211
+ if (pendingRanges.has(rangeId)) {
212
+ // console.log(`[Collection] Range ${rangeId} already pending`);
213
+ const existingRequest = activeRequests.get(rangeId);
214
+ if (existingRequest) {
215
+ // console.log(
216
+ // `[Collection] Returning existing request for range ${rangeId}`
217
+ // );
218
+ return existingRequest;
219
+ }
220
+ }
221
+
222
+ // Mark as pending
223
+ // console.log(
224
+ // `[Collection] Marking range ${rangeId} as pending and loading...`
225
+ // );
226
+ pendingRanges.add(rangeId);
227
+
228
+ // Create request promise
229
+ const requestPromise = (async () => {
230
+ try {
231
+ // Call collection adapter with appropriate parameters
232
+ const page = Math.floor(offset / limit) + 1;
233
+ let params: any;
234
+
235
+ if (strategy === "cursor") {
236
+ // For cursor pagination
237
+ if (page === 1) {
238
+ // First page - no cursor
239
+ params = { limit };
240
+ } else {
241
+ // Check if we have cursor for previous page
242
+ const prevPageCursor = cursorMap.get(page - 1);
243
+ if (!prevPageCursor) {
244
+ // Can't load this page without previous cursor
245
+ console.warn(
246
+ `[Collection] Cannot load page ${page} without cursor for page ${
247
+ page - 1
248
+ }`
249
+ );
250
+ throw new Error(
251
+ `Sequential loading required - missing cursor for page ${
252
+ page - 1
253
+ }`
254
+ );
255
+ }
256
+ params = { cursor: prevPageCursor, limit };
257
+ }
258
+ } else if (strategy === "page") {
259
+ params = { page, limit };
260
+ } else {
261
+ // offset strategy
262
+ params = { offset, limit };
263
+ }
264
+
265
+ // console.log(
266
+ // `[Viewport Collection] Loading range offset=${offset}, limit=${limit}, strategy=${strategy}, calculated page=${page}, params:`,
267
+ // JSON.stringify(params)
268
+ // );
269
+
270
+ const response = await collection.read(params);
271
+
272
+ // Extract items and total
273
+ const rawItems = response.data || response.items || response;
274
+ const meta = response.meta || {};
275
+
276
+ // For cursor pagination, track the cursor (check both cursor and nextCursor)
277
+ const responseCursor = meta.cursor || meta.nextCursor;
278
+ if (strategy === "cursor" && responseCursor) {
279
+ currentCursor = responseCursor;
280
+ cursorMap.set(page, responseCursor);
281
+ pageToOffsetMap.set(page, offset);
282
+ highestLoadedPage = Math.max(highestLoadedPage, page);
283
+ console.log(
284
+ `[Collection] Stored cursor for page ${page}: ${responseCursor}`
285
+ );
286
+ }
287
+
288
+ // Check if we've reached the end
289
+ if (strategy === "cursor" && meta.hasNext === false) {
290
+ hasReachedEnd = true;
291
+ console.log(
292
+ `[Collection] Reached end of cursor pagination at page ${page}`
293
+ );
294
+ }
295
+
296
+ // Update discovered total if provided
297
+ if (meta.total !== undefined) {
298
+ discoveredTotal = meta.total;
299
+ }
300
+
301
+ // Transform items
302
+ const transformedItems = transformItems(rawItems);
303
+
304
+ // Add items to array
305
+ transformedItems.forEach((item, index) => {
306
+ items[offset + index] = item;
307
+ });
308
+
309
+ // For cursor strategy, calculate dynamic total based on loaded data
310
+ let newTotal = discoveredTotal || totalItems;
311
+
312
+ if (strategy === "cursor") {
313
+ // Calculate total based on loaded items + margin
314
+ const loadedItemsCount = items.filter(
315
+ (item) => item !== undefined
316
+ ).length;
317
+ const marginItems = hasReachedEnd
318
+ ? 0
319
+ : rangeSize *
320
+ VIEWPORT_CONSTANTS.PAGINATION.CURSOR_SCROLL_MARGIN_MULTIPLIER;
321
+ const minVirtualItems =
322
+ rangeSize *
323
+ VIEWPORT_CONSTANTS.PAGINATION.CURSOR_MIN_VIRTUAL_SIZE_MULTIPLIER;
324
+
325
+ // Dynamic total: loaded items + margin (unless we've reached the end)
326
+ newTotal = Math.max(
327
+ loadedItemsCount + marginItems,
328
+ minVirtualItems
329
+ );
330
+
331
+ console.log(
332
+ `[Collection] Cursor mode virtual size: loaded=${loadedItemsCount}, margin=${marginItems}, total=${newTotal}, hasReachedEnd=${hasReachedEnd}`
333
+ );
334
+
335
+ // Update total if it has grown
336
+ if (newTotal > totalItems) {
337
+ console.log(
338
+ `[Collection] Updating cursor virtual size from ${totalItems} to ${newTotal}`
339
+ );
340
+ totalItems = newTotal;
341
+ setTotalItems(newTotal);
342
+ }
343
+ } else {
344
+ // For other strategies, use discovered total or current total
345
+ newTotal = discoveredTotal || totalItems;
346
+ }
347
+
348
+ // Update state
349
+ if (newTotal !== totalItems) {
350
+ totalItems = newTotal;
351
+ setTotalItems(newTotal);
352
+ }
353
+
354
+ // Update component items reference
355
+ component.items = items;
356
+
357
+ // Emit items changed event
358
+ component.emit?.("viewport:items-changed", {
359
+ totalItems: newTotal,
360
+ loadedCount: items.filter((item) => item !== undefined).length,
361
+ });
362
+
363
+ // Update viewport state
364
+ const viewportState = (component.viewport as any).state;
365
+ if (viewportState) {
366
+ viewportState.totalItems = newTotal || items.length;
367
+ }
368
+
369
+ // Mark as loaded
370
+ loadedRanges.add(rangeId);
371
+ pendingRanges.delete(rangeId);
372
+ failedRanges.delete(rangeId);
373
+
374
+ // console.log(
375
+ // `[Collection] Range ${rangeId} loaded successfully with ${transformedItems.length} items`
376
+ // );
377
+
378
+ // Emit events
379
+ component.emit?.("viewport:range-loaded", {
380
+ offset,
381
+ limit,
382
+ items: transformedItems,
383
+ total: newTotal,
384
+ });
385
+
386
+ // Emit collection loaded event with more details
387
+ component.emit?.("collection:range-loaded", {
388
+ offset,
389
+ limit,
390
+ items: transformedItems,
391
+ total: newTotal,
392
+ rangeId,
393
+ itemsInMemory: items.filter((item) => item !== undefined).length,
394
+ cursor: strategy === "cursor" ? currentCursor : undefined,
395
+ });
396
+
397
+ // Trigger viewport update
398
+ component.viewport?.updateViewport?.();
399
+
400
+ return transformedItems;
401
+ } catch (error) {
402
+ // Handle error
403
+ pendingRanges.delete(rangeId);
404
+
405
+ const attempts = (failedRanges.get(rangeId)?.attempts || 0) + 1;
406
+ failedRanges.set(rangeId, {
407
+ attempts,
408
+ lastError: error as Error,
409
+ timestamp: Date.now(),
410
+ });
411
+
412
+ // Emit error event
413
+ component.emit?.("viewport:range-error", {
414
+ offset,
415
+ limit,
416
+ error,
417
+ attempts,
418
+ });
419
+
420
+ throw error;
421
+ } finally {
422
+ activeRequests.delete(rangeId);
423
+ }
424
+ })();
425
+
426
+ activeRequests.set(rangeId, requestPromise);
427
+ return requestPromise;
428
+ };
429
+
430
+ /**
431
+ * Load missing ranges from collection
432
+ */
433
+ const loadMissingRangesInternal = async (range: {
434
+ start: number;
435
+ end: number;
436
+ }): Promise<void> => {
437
+ if (!collection) return;
438
+
439
+ // For cursor pagination, we need to load sequentially
440
+ if (strategy === "cursor") {
441
+ const startPage = Math.floor(range.start / rangeSize) + 1;
442
+ const endPage = Math.floor(range.end / rangeSize) + 1;
443
+
444
+ // Limit how many pages we'll load at once
445
+ const maxPagesToLoad = 10;
446
+ const currentHighestPage = highestLoadedPage || 0;
447
+ const limitedEndPage = Math.min(
448
+ endPage,
449
+ currentHighestPage + maxPagesToLoad
450
+ );
451
+
452
+ console.log(
453
+ `[Collection] Cursor mode: need to load pages ${startPage} to ${endPage}, limited to ${limitedEndPage}`
454
+ );
455
+
456
+ // Check if we need to load pages sequentially
457
+ for (let page = startPage; page <= limitedEndPage; page++) {
458
+ const rangeId = page - 1; // Convert to 0-based rangeId
459
+ const offset = rangeId * rangeSize;
460
+
461
+ // Skip if already being loaded
462
+ if (pendingRanges.has(rangeId)) {
463
+ // Range already being loaded
464
+ return;
465
+ }
466
+
467
+ if (!loadedRanges.has(rangeId) && !pendingRanges.has(rangeId)) {
468
+ // For cursor pagination, we must load sequentially
469
+ // Check if we have all previous pages loaded
470
+ if (page > 1) {
471
+ let canLoad = true;
472
+ for (let prevPage = 1; prevPage < page; prevPage++) {
473
+ const prevRangeId = prevPage - 1;
474
+ if (!loadedRanges.has(prevRangeId)) {
475
+ console.log(
476
+ `[Collection] Cannot load page ${page} - need to load page ${prevPage} first`
477
+ );
478
+ canLoad = false;
479
+
480
+ // Try to load the missing page
481
+ if (!pendingRanges.has(prevRangeId)) {
482
+ try {
483
+ await loadRange(prevRangeId * rangeSize, rangeSize);
484
+ } catch (error) {
485
+ console.error(
486
+ `[Collection] Failed to load prerequisite page ${prevPage}:`,
487
+ error
488
+ );
489
+ return; // Stop trying to load further pages
490
+ }
491
+ }
492
+ break;
493
+ }
494
+ }
495
+
496
+ if (!canLoad) {
497
+ continue; // Skip this page for now
498
+ }
499
+ }
500
+
501
+ try {
502
+ await loadRange(offset, rangeSize);
503
+ } catch (error) {
504
+ console.error(`[Collection] Failed to load page ${page}:`, error);
505
+ break; // Stop sequential loading on error
506
+ }
507
+ }
508
+ }
509
+
510
+ if (endPage > limitedEndPage) {
511
+ console.log(
512
+ `[Collection] Stopped at page ${limitedEndPage} to prevent excessive loading (requested up to ${endPage})`
513
+ );
514
+ }
515
+
516
+ return;
517
+ }
518
+
519
+ // Original logic for offset/page strategies
520
+ // Calculate range boundaries
521
+ const startRange = Math.floor(range.start / rangeSize);
522
+ const endRange = Math.floor(range.end / rangeSize);
523
+
524
+ // Collect ranges that need loading
525
+ const rangesToLoad: number[] = [];
526
+ for (let rangeId = startRange; rangeId <= endRange; rangeId++) {
527
+ if (!loadedRanges.has(rangeId) && !pendingRanges.has(rangeId)) {
528
+ rangesToLoad.push(rangeId);
529
+ }
530
+ }
531
+
532
+ if (rangesToLoad.length === 0) {
533
+ // All ranges are already loaded or pending
534
+ // Check if there are queued requests we should process
535
+ if (
536
+ loadRequestQueue.length > 0 &&
537
+ activeLoadCount < maxConcurrentRequests
538
+ ) {
539
+ processQueue();
540
+ }
541
+ return;
542
+ }
543
+
544
+ // Load ranges individually - no merging to avoid loading old ranges
545
+ const promises = rangesToLoad.map((rangeId) =>
546
+ loadRange(rangeId * rangeSize, rangeSize)
547
+ );
548
+ await Promise.allSettled(promises);
549
+ };
550
+
551
+ /**
552
+ * Velocity-aware wrapper for loadMissingRanges
553
+ */
554
+ const loadMissingRanges = (
555
+ range: {
556
+ start: number;
557
+ end: number;
558
+ },
559
+ caller?: string
560
+ ): Promise<void> => {
561
+ return new Promise((resolve, reject) => {
562
+ const rangeKey = getRangeKey(range);
563
+ // Removed noisy log
564
+
565
+ // Check if already loading
566
+ if (activeLoadRanges.has(rangeKey)) {
567
+ // Range already being loaded
568
+ resolve();
569
+ return;
570
+ }
571
+
572
+ // Skip if dragging with low velocity (but not if we're idle)
573
+ if (isDragging && currentVelocity < 0.5 && currentVelocity > 0) {
574
+ // console.log(
575
+ // "[Collection] Load skipped - actively dragging with low velocity"
576
+ // );
577
+ cancelledLoads++;
578
+ resolve();
579
+ return;
580
+ }
581
+
582
+ // Check velocity - if too high, cancel the request entirely
583
+ if (!canLoad()) {
584
+ // console.log(
585
+ // `[Collection] Load cancelled - velocity ${currentVelocity.toFixed(
586
+ // 2
587
+ // )} exceeds threshold ${cancelLoadThreshold}`
588
+ // );
589
+ cancelledLoads++;
590
+ resolve();
591
+ return;
592
+ }
593
+
594
+ // Check capacity
595
+ if (activeLoadCount < maxConcurrentRequests) {
596
+ // Execute immediately
597
+ executeQueuedLoad({
598
+ range,
599
+ priority: "normal",
600
+ timestamp: Date.now(),
601
+ resolve,
602
+ reject,
603
+ });
604
+ } else if (
605
+ enableRequestQueue &&
606
+ loadRequestQueue.length < maxQueueSize
607
+ ) {
608
+ // Queue the request
609
+ loadRequestQueue.push({
610
+ range,
611
+ priority: "normal",
612
+ timestamp: Date.now(),
613
+ resolve,
614
+ reject,
615
+ });
616
+ // console.log(
617
+ // `[LoadingManager] Queued request (at capacity), queue size: ${loadRequestQueue.length}`
618
+ // );
619
+ } else {
620
+ // Queue overflow - resolve to avoid errors
621
+ if (loadRequestQueue.length >= maxQueueSize) {
622
+ const removed = loadRequestQueue.splice(
623
+ 0,
624
+ loadRequestQueue.length - maxQueueSize
625
+ );
626
+ removed.forEach((r) => {
627
+ cancelledLoads++;
628
+ r.resolve();
629
+ });
630
+ }
631
+ resolve();
632
+ }
633
+ });
634
+ };
635
+
636
+ /**
637
+ * Retry a failed range
638
+ */
639
+ const retryFailedRange = async (rangeId: number): Promise<any[]> => {
640
+ failedRanges.delete(rangeId);
641
+ const offset = rangeId * rangeSize;
642
+ return loadRange(offset, rangeSize);
643
+ };
644
+
645
+ /**
646
+ * Set total items count
647
+ */
648
+ const setTotalItems = (total: number): void => {
649
+ totalItems = total;
650
+
651
+ // Update viewport state's total items
652
+ const viewportState = (component.viewport as any).state;
653
+ if (viewportState) {
654
+ viewportState.totalItems = total;
655
+ }
656
+
657
+ component.emit?.("viewport:total-items-changed", { total });
658
+ };
659
+
660
+ // Hook into viewport initialization
661
+ const originalInitialize = component.viewport.initialize;
662
+ component.viewport.initialize = () => {
663
+ originalInitialize();
664
+
665
+ // Set initial total if provided
666
+ if (component.totalItems) {
667
+ totalItems = component.totalItems;
668
+ }
669
+
670
+ // Listen for drag events
671
+ component.on?.("viewport:drag-start", () => {
672
+ isDragging = true;
673
+ });
674
+
675
+ component.on?.("viewport:drag-end", () => {
676
+ isDragging = false;
677
+ // Process any queued requests after drag ends (safety measure)
678
+ // This ensures placeholders are replaced even if idle detection fails
679
+ if (loadOnDragEnd) {
680
+ processQueue();
681
+ }
682
+ });
683
+
684
+ // Listen for range changes
685
+ component.on?.("viewport:range-changed", async (data: any) => {
686
+ // Don't load during fast scrolling - loadMissingRanges will handle velocity check
687
+
688
+ // Skip initial load if we're dragging and velocity is low (drag just started)
689
+ // if (isDragging && currentVelocity < 0.5) {
690
+ // console.log(
691
+ // "[Collection] Skipping range-changed load during drag start"
692
+ // );
693
+ // return;
694
+ // }
695
+
696
+ // Extract range from event data - virtual feature emits { range: { start, end }, scrollPosition }
697
+ const range = data.range || data;
698
+ const { start, end } = range;
699
+
700
+ // Validate range before loading
701
+ if (typeof start !== "number" || typeof end !== "number") {
702
+ console.warn(
703
+ "[Collection] Invalid range in viewport:range-changed event:",
704
+ data
705
+ );
706
+ return;
707
+ }
708
+
709
+ // Load missing ranges if needed
710
+ await loadMissingRanges({ start, end }, "viewport:range-changed");
711
+
712
+ // For cursor mode, check if we need to update virtual size
713
+ if (strategy === "cursor" && !hasReachedEnd) {
714
+ const loadedItemsCount = items.filter(
715
+ (item) => item !== undefined
716
+ ).length;
717
+ const marginItems =
718
+ rangeSize *
719
+ VIEWPORT_CONSTANTS.PAGINATION.CURSOR_SCROLL_MARGIN_MULTIPLIER;
720
+ const minVirtualItems =
721
+ rangeSize *
722
+ VIEWPORT_CONSTANTS.PAGINATION.CURSOR_MIN_VIRTUAL_SIZE_MULTIPLIER;
723
+ const dynamicTotal = Math.max(
724
+ loadedItemsCount + marginItems,
725
+ minVirtualItems
726
+ );
727
+
728
+ if (dynamicTotal !== totalItems) {
729
+ console.log(
730
+ `[Collection] Updating cursor virtual size from ${totalItems} to ${dynamicTotal}`
731
+ );
732
+ setTotalItems(dynamicTotal);
733
+ }
734
+ }
735
+ });
736
+
737
+ // Listen for velocity changes
738
+ component.on?.("viewport:velocity-changed", (data: any) => {
739
+ const previousVelocity = currentVelocity;
740
+ currentVelocity = Math.abs(data.velocity || 0);
741
+
742
+ // When velocity drops below threshold, process queue
743
+ if (
744
+ previousVelocity > cancelLoadThreshold &&
745
+ currentVelocity <= cancelLoadThreshold
746
+ ) {
747
+ processQueue();
748
+ }
749
+ });
750
+
751
+ // Listen for idle state to process queue
752
+ component.on?.("viewport:idle", async (data: any) => {
753
+ //console.log("[Collection] Idle event received, velocity=0");
754
+ currentVelocity = 0;
755
+
756
+ // Reset dragging state on idle since user has stopped moving
757
+ if (isDragging) {
758
+ // console.log("[Collection] Resetting drag state on idle");
759
+ isDragging = false;
760
+ }
761
+
762
+ // Get current visible range from viewport
763
+ const viewportState = (component.viewport as any).state;
764
+ const visibleRange = viewportState?.visibleRange;
765
+
766
+ if (visibleRange) {
767
+ // console.log(
768
+ // `[Collection] Loading visible range on idle: ${visibleRange.start}-${visibleRange.end}`
769
+ // );
770
+
771
+ // Clear stale requests from queue that are far from current visible range
772
+ const buffer = rangeSize * 2; // Allow some buffer
773
+ loadRequestQueue = loadRequestQueue.filter((request) => {
774
+ const requestEnd = request.range.end;
775
+ const requestStart = request.range.start;
776
+ const isRelevant =
777
+ requestEnd >= visibleRange.start - buffer &&
778
+ requestStart <= visibleRange.end + buffer;
779
+
780
+ if (!isRelevant) {
781
+ console.log(
782
+ `[Collection] Removing stale queued request: ${requestStart}-${requestEnd}`
783
+ );
784
+ request.resolve(); // Resolve to avoid hanging promises
785
+ }
786
+ return isRelevant;
787
+ });
788
+
789
+ // Load the current visible range if needed
790
+ await loadMissingRanges(visibleRange, "viewport:idle");
791
+ }
792
+
793
+ // Also process any queued requests
794
+ processQueue();
795
+ });
796
+
797
+ // Load initial data if collection is available
798
+ if (collection) {
799
+ loadRange(0, rangeSize)
800
+ .then(() => {
801
+ // console.log("[Collection] Initial data loaded");
802
+ })
803
+ .catch((error) => {
804
+ console.error("[Collection] Failed to load initial data:", error);
805
+ });
806
+ }
807
+ };
808
+
809
+ // Add collection API to viewport
810
+ component.viewport.collection = {
811
+ loadRange: (offset: number, limit: number) => loadRange(offset, limit),
812
+ loadMissingRanges: (
813
+ range: { start: number; end: number },
814
+ caller?: string
815
+ ) => loadMissingRanges(range, caller || "viewport.collection"),
816
+ getLoadedRanges: () => loadedRanges,
817
+ getPendingRanges: () => pendingRanges,
818
+ clearFailedRanges: () => failedRanges.clear(),
819
+ retryFailedRange,
820
+ setTotalItems,
821
+ getTotalItems: () => totalItems,
822
+ // Cursor-specific methods
823
+ getCurrentCursor: () => currentCursor,
824
+ getCursorForPage: (page: number) => cursorMap.get(page) || null,
825
+ };
826
+
827
+ // Add collection data access with direct assignment
828
+ (component as any).collection = {
829
+ items,
830
+ getItems: () => items,
831
+ getItem: (index: number) => items[index],
832
+ loadRange,
833
+ loadMissingRanges: (
834
+ range: { start: number; end: number },
835
+ caller?: string
836
+ ) => loadMissingRanges(range, caller || "component.collection"),
837
+ getLoadingStats: () => ({
838
+ pendingRequests: activeLoadCount,
839
+ completedRequests: completedLoads,
840
+ failedRequests: failedLoads,
841
+ cancelledRequests: cancelledLoads,
842
+ currentVelocity,
843
+ canLoad: canLoad(),
844
+ queuedRequests: loadRequestQueue.length,
845
+ }),
846
+ // Cursor methods
847
+ getCurrentCursor: () => currentCursor,
848
+ getCursorForPage: (page: number) => cursorMap.get(page) || null,
849
+ };
850
+
851
+ // Also ensure component.items is updated
852
+ component.items = items;
853
+
854
+ // Cleanup function
855
+ const destroy = () => {
856
+ // Cancel pending requests
857
+ activeRequests.clear();
858
+ pendingRanges.clear();
859
+ };
860
+
861
+ // Return enhanced component
862
+ return {
863
+ ...component,
864
+ collection: {
865
+ loadRange,
866
+ loadMissingRanges: (
867
+ range: { start: number; end: number },
868
+ caller?: string
869
+ ) => loadMissingRanges(range, caller || "return.collection"),
870
+ getLoadedRanges: () => loadedRanges,
871
+ getPendingRanges: () => pendingRanges,
872
+ clearFailedRanges: () => failedRanges.clear(),
873
+ retryFailedRange,
874
+ setTotalItems,
875
+ getTotalItems: () => totalItems,
876
+ // Cursor-specific methods
877
+ getCurrentCursor: () => currentCursor,
878
+ getCursorForPage: (page: number) => cursorMap.get(page) || null,
879
+ },
880
+ };
881
+ };
882
+ }