@pdanpdan/virtual-scroll 0.8.0 → 0.9.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.
@@ -17,19 +17,26 @@ import {
17
17
  DEFAULT_COLUMN_WIDTH,
18
18
  DEFAULT_ITEM_SIZE,
19
19
  } from '../types';
20
- import { FenwickTree } from '../utils/fenwick-tree';
21
- import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike, scrollTo } from '../utils/scroll';
20
+ import { getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike, scrollTo } from '../utils/scroll';
22
21
  import {
23
22
  calculateColumnRange,
23
+ calculateIndexAt,
24
24
  calculateItemPosition,
25
+ calculateOffsetAt,
25
26
  calculateRange,
27
+ calculateRangeSize,
28
+ calculateRenderedSize,
29
+ calculateScale,
26
30
  calculateScrollTarget,
31
+ calculateSSROffsets,
27
32
  calculateStickyItem,
28
33
  calculateTotalSize,
29
34
  displayToVirtual,
30
35
  findPrevStickyIndex,
36
+ resolveSnap,
31
37
  virtualToDisplay,
32
38
  } from '../utils/virtual-scroll-logic';
39
+ import { useVirtualScrollSizes } from './useVirtualScrollSizes';
33
40
 
34
41
  /**
35
42
  * Composable for virtual scrolling logic.
@@ -58,6 +65,14 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
58
65
  const isProgrammaticScroll = ref(false);
59
66
  const internalScrollX = ref(0);
60
67
  const internalScrollY = ref(0);
68
+ /** The last recorded virtual X position, used to detect scroll direction for snapping. */
69
+ let lastInternalX = 0;
70
+ /** The last recorded virtual Y position, used to detect scroll direction for snapping. */
71
+ let lastInternalY = 0;
72
+ /** The current horizontal scroll direction ('start' towards left/logical start, 'end' towards right/logical end). */
73
+ let scrollDirectionX: 'start' | 'end' | null = null;
74
+ /** The current vertical scroll direction ('start' towards top, 'end' towards bottom). */
75
+ let scrollDirectionY: 'start' | 'end' | null = null;
61
76
 
62
77
  let computedStyle: CSSStyleDeclaration | null = null;
63
78
 
@@ -71,9 +86,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
71
86
  const container = props.value.container || props.value.hostRef || window;
72
87
  const el = isElement(container) ? container : document.documentElement;
73
88
 
74
- if (!computedStyle || !('direction' in computedStyle)) {
75
- computedStyle = window.getComputedStyle(el);
76
- }
89
+ computedStyle = window.getComputedStyle(el);
77
90
 
78
91
  const newRtl = computedStyle.direction === 'rtl';
79
92
  if (isRtl.value !== newRtl) {
@@ -81,28 +94,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
81
94
  }
82
95
  };
83
96
 
84
- // --- Fenwick Trees for efficient size and offset management ---
85
- const itemSizesX = new FenwickTree(props.value.items?.length || 0);
86
- const itemSizesY = new FenwickTree(props.value.items?.length || 0);
87
- const columnSizes = new FenwickTree(props.value.columnCount || 0);
88
-
89
- const treeUpdateFlag = ref(0);
90
-
91
- let measuredColumns = new Uint8Array(0);
92
- let measuredItemsX = new Uint8Array(0);
93
- let measuredItemsY = new Uint8Array(0);
94
-
95
- // --- Scroll Queue / Correction ---
96
- const pendingScroll = ref<{
97
- rowIndex: number | null | undefined;
98
- colIndex: number | null | undefined;
99
- options: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions | undefined;
100
- } | null>(null);
101
-
102
- // Track if sizes are initialized
103
- const sizesInitialized = ref(false);
104
- let lastItems: T[] = [];
105
-
106
97
  // --- Computed Config ---
107
98
  const direction = computed(() => [ 'vertical', 'horizontal', 'both' ].includes(props.value.direction as string) ? props.value.direction as ScrollDirection : 'vertical' as ScrollDirection);
108
99
 
@@ -124,6 +115,34 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
124
115
 
125
116
  const defaultSize = computed(() => props.value.defaultItemSize || fixedItemSize.value || DEFAULT_ITEM_SIZE);
126
117
 
118
+ // --- Size Management ---
119
+ const {
120
+ itemSizesX,
121
+ itemSizesY,
122
+ columnSizes,
123
+ measuredColumns,
124
+ measuredItemsY,
125
+ treeUpdateFlag,
126
+ getSizeAt,
127
+ initializeSizes,
128
+ updateItemSizes: coreUpdateItemSizes,
129
+ refresh: coreRefresh,
130
+ } = useVirtualScrollSizes(computed(() => ({
131
+ props: props.value,
132
+ isDynamicItemSize: isDynamicItemSize.value,
133
+ isDynamicColumnWidth: isDynamicColumnWidth.value,
134
+ defaultSize: defaultSize.value,
135
+ fixedItemSize: fixedItemSize.value,
136
+ direction: direction.value,
137
+ })));
138
+
139
+ // --- Scroll Queue / Correction ---
140
+ const pendingScroll = ref<{
141
+ rowIndex: number | null | undefined;
142
+ colIndex: number | null | undefined;
143
+ options: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions | undefined;
144
+ } | null>(null);
145
+
127
146
  const sortedStickyIndices = computed(() =>
128
147
  [ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b),
129
148
  );
@@ -157,55 +176,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
157
176
  // eslint-disable-next-line ts/no-unused-expressions
158
177
  treeUpdateFlag.value;
159
178
 
160
- if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
161
- const { start = 0, end = 0, colStart = 0, colEnd = 0 } = props.value.ssrRange;
162
- const colCount = props.value.columnCount || 0;
163
- const gap = props.value.gap || 0;
164
- const columnGap = props.value.columnGap || 0;
165
-
166
- let width = 0;
167
- let height = 0;
168
-
169
- if (direction.value === 'both') {
170
- if (colCount > 0) {
171
- const effectiveColEnd = colEnd || colCount;
172
- const total = columnSizes.query(effectiveColEnd) - columnSizes.query(colStart);
173
- width = Math.max(0, total - (effectiveColEnd > colStart ? columnGap : 0));
174
- }
175
- if (fixedItemSize.value !== null) {
176
- const len = end - start;
177
- height = Math.max(0, len * (fixedItemSize.value + gap) - (len > 0 ? gap : 0));
178
- } else {
179
- const total = itemSizesY.query(end) - itemSizesY.query(start);
180
- height = Math.max(0, total - (end > start ? gap : 0));
181
- }
182
- } else if (direction.value === 'horizontal') {
183
- if (fixedItemSize.value !== null) {
184
- const len = end - start;
185
- width = Math.max(0, len * (fixedItemSize.value + columnGap) - (len > 0 ? columnGap : 0));
186
- } else {
187
- const total = itemSizesX.query(end) - itemSizesX.query(start);
188
- width = Math.max(0, total - (end > start ? columnGap : 0));
189
- }
190
- height = usableHeight.value;
191
- } else {
192
- // vertical
193
- width = usableWidth.value;
194
- if (fixedItemSize.value !== null) {
195
- const len = end - start;
196
- height = Math.max(0, len * (fixedItemSize.value + gap) - (len > 0 ? gap : 0));
197
- } else {
198
- const total = itemSizesY.query(end) - itemSizesY.query(start);
199
- height = Math.max(0, total - (end > start ? gap : 0));
200
- }
201
- }
202
-
203
- return {
204
- width: Math.max(width, usableWidth.value),
205
- height: Math.max(height, usableHeight.value),
206
- };
207
- }
208
-
209
179
  return calculateTotalSize({
210
180
  direction: direction.value,
211
181
  itemsLength: props.value.items.length,
@@ -236,29 +206,14 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
236
206
  y: computed(() => Math.max(0, hostOffset.y - (flowStartY.value + stickyStartY.value))),
237
207
  });
238
208
 
239
- const renderedWidth = computed(() => (isWindowContainer.value ? totalWidth.value : Math.min(totalWidth.value, BROWSER_MAX_SIZE)));
240
- const renderedHeight = computed(() => (isWindowContainer.value ? totalHeight.value : Math.min(totalHeight.value, BROWSER_MAX_SIZE)));
209
+ const renderedWidth = computed(() => calculateRenderedSize(isWindowContainer.value, totalWidth.value));
210
+ const renderedHeight = computed(() => calculateRenderedSize(isWindowContainer.value, totalHeight.value));
241
211
 
242
- const renderedVirtualWidth = computed(() => (isWindowContainer.value ? virtualWidth.value : Math.max(0, renderedWidth.value - (flowStartX.value + stickyStartX.value + stickyEndX.value + flowEndX.value))));
243
- const renderedVirtualHeight = computed(() => (isWindowContainer.value ? virtualHeight.value : Math.max(0, renderedHeight.value - (flowStartY.value + stickyStartY.value + stickyEndY.value + flowEndY.value))));
212
+ const renderedVirtualWidth = computed(() => calculateRenderedSize(isWindowContainer.value, virtualWidth.value));
213
+ const renderedVirtualHeight = computed(() => calculateRenderedSize(isWindowContainer.value, virtualHeight.value));
244
214
 
245
- const scaleX = computed(() => {
246
- if (isWindowContainer.value || totalWidth.value <= BROWSER_MAX_SIZE) {
247
- return 1;
248
- }
249
- const realRange = totalWidth.value - viewportWidth.value;
250
- const displayRange = renderedWidth.value - viewportWidth.value;
251
- return displayRange > 0 ? realRange / displayRange : 1;
252
- });
253
-
254
- const scaleY = computed(() => {
255
- if (isWindowContainer.value || totalHeight.value <= BROWSER_MAX_SIZE) {
256
- return 1;
257
- }
258
- const realRange = totalHeight.value - viewportHeight.value;
259
- const displayRange = renderedHeight.value - viewportHeight.value;
260
- return displayRange > 0 ? realRange / displayRange : 1;
261
- });
215
+ const scaleX = computed(() => calculateScale(isWindowContainer.value, totalWidth.value, viewportWidth.value));
216
+ const scaleY = computed(() => calculateScale(isWindowContainer.value, totalHeight.value, viewportHeight.value));
262
217
 
263
218
  const relativeScrollX = computed(() => {
264
219
  if (direction.value === 'vertical') {
@@ -283,23 +238,24 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
283
238
  * @returns The width in pixels (excluding gap).
284
239
  */
285
240
  const getColumnWidth = (index: number) => {
286
- // eslint-disable-next-line ts/no-unused-expressions
287
- treeUpdateFlag.value;
288
-
289
- const columnGap = props.value.columnGap || 0;
290
- const cw = props.value.columnWidth;
291
- if (typeof cw === 'number' && cw > 0) {
292
- return cw;
293
- }
294
- if (Array.isArray(cw) && cw.length > 0) {
295
- const val = cw[ index % cw.length ];
296
- return (val != null && val > 0) ? val : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
297
- }
298
- if (typeof cw === 'function') {
299
- return cw(index);
241
+ if (direction.value === 'both') {
242
+ return getSizeAt(
243
+ index,
244
+ props.value.columnWidth,
245
+ props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH,
246
+ props.value.columnGap || 0,
247
+ columnSizes,
248
+ true,
249
+ );
300
250
  }
301
- const val = columnSizes.get(index);
302
- return val > 0 ? val - columnGap : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
251
+ return getSizeAt(
252
+ index,
253
+ props.value.itemSize,
254
+ props.value.defaultItemSize || DEFAULT_ITEM_SIZE,
255
+ props.value.columnGap || 0,
256
+ itemSizesX,
257
+ true,
258
+ );
303
259
  };
304
260
 
305
261
  /**
@@ -309,39 +265,32 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
309
265
  * @returns The height in pixels (excluding gap).
310
266
  */
311
267
  const getRowHeight = (index: number) => {
312
- // eslint-disable-next-line ts/no-unused-expressions
313
- treeUpdateFlag.value;
314
-
315
268
  if (direction.value === 'horizontal') {
316
269
  return usableHeight.value;
317
270
  }
318
271
 
319
- const gap = props.value.gap || 0;
320
- const itemSize = props.value.itemSize;
321
- if (typeof itemSize === 'number' && itemSize > 0) {
322
- return itemSize;
323
- }
324
- if (typeof itemSize === 'function') {
325
- const item = props.value.items[ index ];
326
- return item !== undefined ? itemSize(item, index) : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
327
- }
328
-
329
- const val = itemSizesY.get(index);
330
- return val > 0 ? val - gap : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
272
+ return getSizeAt(
273
+ index,
274
+ props.value.itemSize,
275
+ props.value.defaultItemSize || DEFAULT_ITEM_SIZE,
276
+ props.value.gap || 0,
277
+ itemSizesY,
278
+ false,
279
+ );
331
280
  };
332
281
 
333
282
  // --- Public Scroll API ---
334
283
  /**
335
284
  * Scrolls to a specific row and column index.
336
285
  *
337
- * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
338
- * @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
286
+ * @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally. Optional.
287
+ * @param colIndex - The column index to scroll to. Pass null to only scroll vertically. Optional.
339
288
  * @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
340
289
  * Defaults to { align: 'auto', behavior: 'auto' }.
341
290
  */
342
291
  function scrollToIndex(
343
- rowIndex: number | null | undefined,
344
- colIndex: number | null | undefined,
292
+ rowIndex?: number | null,
293
+ colIndex?: number | null,
345
294
  options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions,
346
295
  ) {
347
296
  const isCorrection = typeof options === 'object' && options !== null && 'isCorrection' in options
@@ -440,11 +389,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
440
389
  const currentOptions = pendingScroll.value.options;
441
390
  if (isScrollToIndexOptions(currentOptions)) {
442
391
  currentOptions.behavior = 'auto';
443
- } else {
444
- pendingScroll.value.options = {
445
- align: currentOptions as ScrollAlignment | ScrollAlignmentOptions,
446
- behavior: 'auto',
447
- };
448
392
  }
449
393
  }
450
394
  }
@@ -511,188 +455,17 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
511
455
  };
512
456
 
513
457
  // --- Measurement & Initialization ---
514
- const resizeMeasurements = (len: number, colCount: number) => {
515
- itemSizesX.resize(len);
516
- itemSizesY.resize(len);
517
- columnSizes.resize(colCount);
518
-
519
- if (measuredItemsX.length !== len) {
520
- const newMeasuredX = new Uint8Array(len);
521
- newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len, measuredItemsX.length)));
522
- measuredItemsX = newMeasuredX;
523
- }
524
- if (measuredItemsY.length !== len) {
525
- const newMeasuredY = new Uint8Array(len);
526
- newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len, measuredItemsY.length)));
527
- measuredItemsY = newMeasuredY;
528
- }
529
- if (measuredColumns.length !== colCount) {
530
- const newMeasuredCols = new Uint8Array(colCount);
531
- newMeasuredCols.set(measuredColumns.subarray(0, Math.min(colCount, measuredColumns.length)));
532
- measuredColumns = newMeasuredCols;
533
- }
534
- };
535
-
536
- const initializeMeasurements = () => {
537
- const newItems = props.value.items;
538
- const len = newItems.length;
539
- const colCount = props.value.columnCount || 0;
540
- const gap = props.value.gap || 0;
541
- const columnGap = props.value.columnGap || 0;
542
- const cw = props.value.columnWidth;
543
-
544
- let colNeedsRebuild = false;
545
- let itemsNeedRebuild = false;
546
-
547
- // Initialize columns
548
- if (colCount > 0) {
549
- for (let i = 0; i < colCount; i++) {
550
- const currentW = columnSizes.get(i);
551
- const isMeasured = measuredColumns[ i ] === 1;
552
-
553
- if (!isDynamicColumnWidth.value || (!isMeasured && currentW === 0)) {
554
- let baseWidth = 0;
555
- if (typeof cw === 'number' && cw > 0) {
556
- baseWidth = cw;
557
- } else if (Array.isArray(cw) && cw.length > 0) {
558
- baseWidth = cw[ i % cw.length ] || props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
559
- } else if (typeof cw === 'function') {
560
- baseWidth = cw(i);
561
- } else {
562
- baseWidth = props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
563
- }
564
-
565
- const targetW = baseWidth + columnGap;
566
- if (Math.abs(currentW - targetW) > 0.5) {
567
- columnSizes.set(i, targetW);
568
- measuredColumns[ i ] = isDynamicColumnWidth.value ? 0 : 1;
569
- colNeedsRebuild = true;
570
- } else if (!isDynamicColumnWidth.value) {
571
- measuredColumns[ i ] = 1;
572
- }
573
- }
574
- }
575
- }
576
-
577
- // Initialize items
578
- for (let i = 0; i < len; i++) {
579
- const item = props.value.items[ i ];
580
- const currentX = itemSizesX.get(i);
581
- const currentY = itemSizesY.get(i);
582
- const isMeasuredX = measuredItemsX[ i ] === 1;
583
- const isMeasuredY = measuredItemsY[ i ] === 1;
584
-
585
- if (direction.value === 'horizontal') {
586
- if (!isDynamicItemSize.value || (!isMeasuredX && currentX === 0)) {
587
- const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
588
- const targetX = baseSize + columnGap;
589
- if (Math.abs(currentX - targetX) > 0.5) {
590
- itemSizesX.set(i, targetX);
591
- measuredItemsX[ i ] = isDynamicItemSize.value ? 0 : 1;
592
- itemsNeedRebuild = true;
593
- } else if (!isDynamicItemSize.value) {
594
- measuredItemsX[ i ] = 1;
595
- }
596
- }
597
- } else if (currentX !== 0) {
598
- itemSizesX.set(i, 0);
599
- measuredItemsX[ i ] = 0;
600
- itemsNeedRebuild = true;
601
- }
602
-
603
- if (direction.value !== 'horizontal') {
604
- if (!isDynamicItemSize.value || (!isMeasuredY && currentY === 0)) {
605
- const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
606
- const targetY = baseSize + gap;
607
- if (Math.abs(currentY - targetY) > 0.5) {
608
- itemSizesY.set(i, targetY);
609
- measuredItemsY[ i ] = isDynamicItemSize.value ? 0 : 1;
610
- itemsNeedRebuild = true;
611
- } else if (!isDynamicItemSize.value) {
612
- measuredItemsY[ i ] = 1;
613
- }
614
- }
615
- } else if (currentY !== 0) {
616
- itemSizesY.set(i, 0);
617
- measuredItemsY[ i ] = 0;
618
- itemsNeedRebuild = true;
619
- }
620
- }
621
-
622
- if (colNeedsRebuild) {
623
- columnSizes.rebuild();
624
- }
625
- if (itemsNeedRebuild) {
626
- itemSizesX.rebuild();
627
- itemSizesY.rebuild();
628
- }
458
+ const handleScrollCorrection = (addedX: number, addedY: number) => {
459
+ nextTick(() => {
460
+ scrollToOffset(
461
+ addedX > 0 ? relativeScrollX.value + addedX : null,
462
+ addedY > 0 ? relativeScrollY.value + addedY : null,
463
+ { behavior: 'auto', isCorrection: true } as ScrollToIndexOptions,
464
+ );
465
+ });
629
466
  };
630
467
 
631
- const initializeSizes = () => {
632
- const newItems = props.value.items;
633
- const len = newItems.length;
634
- const colCount = props.value.columnCount || 0;
635
-
636
- resizeMeasurements(len, colCount);
637
-
638
- let prependCount = 0;
639
- if (props.value.restoreScrollOnPrepend && lastItems.length > 0 && len > lastItems.length) {
640
- const oldFirstItem = lastItems[ 0 ];
641
- if (oldFirstItem !== undefined) {
642
- for (let i = 1; i <= len - lastItems.length; i++) {
643
- if (newItems[ i ] === oldFirstItem) {
644
- prependCount = i;
645
- break;
646
- }
647
- }
648
- }
649
- }
650
-
651
- if (prependCount > 0) {
652
- itemSizesX.shift(prependCount);
653
- itemSizesY.shift(prependCount);
654
-
655
- if (pendingScroll.value && pendingScroll.value.rowIndex !== null && pendingScroll.value.rowIndex !== undefined) {
656
- pendingScroll.value.rowIndex += prependCount;
657
- }
658
-
659
- const newMeasuredX = new Uint8Array(len);
660
- const newMeasuredY = new Uint8Array(len);
661
- newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len - prependCount, measuredItemsX.length)), prependCount);
662
- newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len - prependCount, measuredItemsY.length)), prependCount);
663
- measuredItemsX = newMeasuredX;
664
- measuredItemsY = newMeasuredY;
665
-
666
- // Calculate added size
667
- const gap = props.value.gap || 0;
668
- const columnGap = props.value.columnGap || 0;
669
- let addedX = 0;
670
- let addedY = 0;
671
-
672
- for (let i = 0; i < prependCount; i++) {
673
- const size = typeof props.value.itemSize === 'function' ? props.value.itemSize(newItems[ i ] as T, i) : defaultSize.value;
674
- if (direction.value === 'horizontal') {
675
- addedX += size + columnGap;
676
- } else { addedY += size + gap; }
677
- }
678
-
679
- if (addedX > 0 || addedY > 0) {
680
- nextTick(() => {
681
- scrollToOffset(
682
- addedX > 0 ? relativeScrollX.value + addedX : null,
683
- addedY > 0 ? relativeScrollY.value + addedY : null,
684
- { behavior: 'auto', isCorrection: true } as ScrollToIndexOptions,
685
- );
686
- });
687
- }
688
- }
689
-
690
- initializeMeasurements();
691
-
692
- lastItems = [ ...newItems ];
693
- sizesInitialized.value = true;
694
- treeUpdateFlag.value++;
695
- };
468
+ const initialize = () => initializeSizes(handleScrollCorrection);
696
469
 
697
470
  /**
698
471
  * Updates the host element's offset relative to the scroll container.
@@ -756,7 +529,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
756
529
  () => props.value.columnGap,
757
530
  () => props.value.defaultItemSize,
758
531
  () => props.value.defaultColumnWidth,
759
- ], initializeSizes, { immediate: true });
532
+ ], initialize, { immediate: true });
760
533
 
761
534
  watch(() => [ props.value.container, props.value.hostElement ], () => {
762
535
  updateHostOffset();
@@ -812,28 +585,36 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
812
585
  });
813
586
 
814
587
  // --- Range & Visible Items ---
588
+ /**
589
+ * Helper to get the row index (or item index in list mode) at a specific virtual offset.
590
+ *
591
+ * @param offset - The virtual pixel offset (VU).
592
+ * @returns The index at that position.
593
+ */
815
594
  const getRowIndexAt = (offset: number) => {
816
- const gap = props.value.gap || 0;
817
- const columnGap = props.value.columnGap || 0;
818
- const fixedSize = fixedItemSize.value;
819
-
820
- if (direction.value === 'horizontal') {
821
- const step = (fixedSize || 0) + columnGap;
822
- if (fixedSize !== null && step > 0) {
823
- return Math.floor(offset / step);
824
- }
825
- return itemSizesX.findLowerBound(offset);
826
- }
827
- const step = (fixedSize || 0) + gap;
828
- if (fixedSize !== null && step > 0) {
829
- return Math.floor(offset / step);
830
- }
831
- return itemSizesY.findLowerBound(offset);
595
+ const isHorizontal = direction.value === 'horizontal';
596
+ return calculateIndexAt(
597
+ offset,
598
+ fixedItemSize.value,
599
+ isHorizontal ? (props.value.columnGap || 0) : (props.value.gap || 0),
600
+ (off) => (isHorizontal ? itemSizesX.findLowerBound(off) : itemSizesY.findLowerBound(off)),
601
+ );
832
602
  };
833
603
 
604
+ /**
605
+ * Helper to get the column index at a specific virtual offset.
606
+ *
607
+ * @param offset - The virtual pixel offset (VU).
608
+ * @returns The column index at that position.
609
+ */
834
610
  const getColIndexAt = (offset: number) => {
835
611
  if (direction.value === 'both') {
836
- return columnSizes.findLowerBound(offset);
612
+ return calculateIndexAt(
613
+ offset,
614
+ fixedColumnWidth.value,
615
+ props.value.columnGap || 0,
616
+ (off) => columnSizes.findLowerBound(off),
617
+ );
837
618
  }
838
619
  if (direction.value === 'horizontal') {
839
620
  return getRowIndexAt(offset);
@@ -905,25 +686,17 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
905
686
  const safeStart = Math.max(0, colStart);
906
687
  const safeEnd = Math.min(totalCols, colEnd || totalCols);
907
688
 
908
- const columnGap = props.value.columnGap || 0;
909
- const padStart = fixedColumnWidth.value !== null
910
- ? safeStart * (fixedColumnWidth.value + columnGap)
911
- : columnSizes.query(safeStart);
912
-
913
- const totalColWidth = fixedColumnWidth.value !== null
914
- ? totalCols * (fixedColumnWidth.value + columnGap) - columnGap
915
- : Math.max(0, columnSizes.query(totalCols) - columnGap);
916
-
917
- const contentEnd = fixedColumnWidth.value !== null
918
- ? (safeEnd * (fixedColumnWidth.value + columnGap) - (safeEnd > 0 ? columnGap : 0))
919
- : (columnSizes.query(safeEnd) - (safeEnd > 0 ? columnGap : 0));
920
-
921
- return {
922
- start: safeStart,
923
- end: safeEnd,
924
- padStart,
925
- padEnd: Math.max(0, totalColWidth - contentEnd),
926
- };
689
+ return calculateColumnRange({
690
+ columnCount: totalCols,
691
+ relativeScrollX: calculateOffsetAt(safeStart, fixedColumnWidth.value, props.value.columnGap || 0, (idx) => columnSizes.query(idx)),
692
+ usableWidth: calculateRangeSize(safeStart, safeEnd, fixedColumnWidth.value, props.value.columnGap || 0, (idx) => columnSizes.query(idx)),
693
+ colBuffer: 0,
694
+ fixedWidth: fixedColumnWidth.value,
695
+ columnGap: props.value.columnGap || 0,
696
+ findLowerBound: (offset) => columnSizes.findLowerBound(offset),
697
+ query: (idx) => columnSizes.query(idx),
698
+ totalColsQuery: () => columnSizes.query(totalCols),
699
+ });
927
700
  }
928
701
 
929
702
  const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
@@ -953,9 +726,6 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
953
726
 
954
727
  const { start, end } = range.value;
955
728
  const items: RenderedItem<T>[] = [];
956
- const fixedSize = fixedItemSize.value;
957
- const gap = props.value.gap || 0;
958
- const columnGap = props.value.columnGap || 0;
959
729
  const stickyIndices = sortedStickyIndices.value;
960
730
  const stickySet = stickyIndicesSet.value;
961
731
 
@@ -974,24 +744,19 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
974
744
  sortedIndices.push(i);
975
745
  }
976
746
 
977
- const ssrStartRow = props.value.ssrRange?.start || 0;
978
-
979
- const ssrStartCol = props.value.ssrRange?.colStart || 0;
980
-
981
- let ssrOffsetX = 0;
982
- let ssrOffsetY = 0;
983
-
984
- if (!isHydrated.value && props.value.ssrRange) {
985
- ssrOffsetY = (direction.value !== 'horizontal')
986
- ? (fixedSize !== null ? ssrStartRow * (fixedSize + gap) : itemSizesY.query(ssrStartRow))
987
- : 0;
988
-
989
- if (direction.value === 'horizontal') {
990
- ssrOffsetX = fixedSize !== null ? ssrStartCol * (fixedSize + columnGap) : itemSizesX.query(ssrStartCol);
991
- } else if (direction.value === 'both') {
992
- ssrOffsetX = columnSizes.query(ssrStartCol);
993
- }
994
- }
747
+ const { x: ssrOffsetX, y: ssrOffsetY } = (!isHydrated.value && props.value.ssrRange)
748
+ ? calculateSSROffsets(
749
+ direction.value,
750
+ props.value.ssrRange,
751
+ fixedItemSize.value,
752
+ fixedColumnWidth.value,
753
+ props.value.gap || 0,
754
+ props.value.columnGap || 0,
755
+ (idx) => itemSizesY.query(idx),
756
+ (idx) => itemSizesX.query(idx),
757
+ (idx) => columnSizes.query(idx),
758
+ )
759
+ : { x: 0, y: 0 };
995
760
 
996
761
  const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
997
762
 
@@ -1201,144 +966,120 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1201
966
  }
1202
967
 
1203
968
  const scrollValueX = isRtl.value ? Math.abs(scrollX.value) : scrollX.value;
1204
- internalScrollX.value = displayToVirtual(scrollValueX, componentOffset.x, scaleX.value);
1205
- internalScrollY.value = displayToVirtual(scrollY.value, componentOffset.y, scaleY.value);
969
+ const virtualX = displayToVirtual(scrollValueX, componentOffset.x, scaleX.value);
970
+ const virtualY = displayToVirtual(scrollY.value, componentOffset.y, scaleY.value);
971
+
972
+ if (Math.abs(virtualX - lastInternalX) > 0.5) {
973
+ scrollDirectionX = virtualX > lastInternalX ? 'end' : 'start';
974
+ lastInternalX = virtualX;
975
+ }
976
+ if (Math.abs(virtualY - lastInternalY) > 0.5) {
977
+ scrollDirectionY = virtualY > lastInternalY ? 'end' : 'start';
978
+ lastInternalY = virtualY;
979
+ }
980
+
981
+ internalScrollX.value = virtualX;
982
+ internalScrollY.value = virtualY;
983
+
984
+ if (!isProgrammaticScroll.value) {
985
+ pendingScroll.value = null;
986
+ }
1206
987
 
1207
988
  if (!isScrolling.value) {
1208
- if (!isProgrammaticScroll.value) {
1209
- pendingScroll.value = null;
1210
- }
1211
989
  isScrolling.value = true;
1212
990
  }
1213
991
  clearTimeout(scrollTimeout);
1214
992
  scrollTimeout = setTimeout(() => {
993
+ const wasProgrammatic = isProgrammaticScroll.value;
1215
994
  isScrolling.value = false;
1216
995
  isProgrammaticScroll.value = false;
1217
- }, 250);
1218
- };
1219
996
 
1220
- /**
1221
- * Updates the size of multiple items in the Fenwick tree.
1222
- *
1223
- * @param updates - Array of updates
1224
- */
1225
- const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
1226
- let needUpdate = false;
1227
- let deltaX = 0;
1228
- let deltaY = 0;
1229
- const gap = props.value.gap || 0;
1230
- const columnGap = props.value.columnGap || 0;
1231
-
1232
- const currentRelX = relativeScrollX.value;
1233
- const currentRelY = relativeScrollY.value;
1234
-
1235
- const firstRowIndex = getRowIndexAt(direction.value === 'horizontal' ? currentRelX : currentRelY);
1236
- const firstColIndex = getColIndexAt(currentRelX);
1237
-
1238
- const isHorizontalMode = direction.value === 'horizontal';
1239
- const isBothMode = direction.value === 'both';
1240
-
1241
- const processedRows = new Set<number>();
1242
- const processedCols = new Set<number>();
1243
-
1244
- const tryUpdateColumn = (colIdx: number, width: number) => {
1245
- if (colIdx >= 0 && colIdx < (props.value.columnCount || 0) && !processedCols.has(colIdx)) {
1246
- processedCols.add(colIdx);
1247
- const oldW = columnSizes.get(colIdx);
1248
- const targetW = width + columnGap;
1249
-
1250
- if (!measuredColumns[ colIdx ] || Math.abs(oldW - targetW) > 0.1) {
1251
- const d = targetW - oldW;
1252
- if (Math.abs(d) > 0.1) {
1253
- columnSizes.update(colIdx, d);
1254
- needUpdate = true;
1255
- if (colIdx < firstColIndex) {
1256
- deltaX += d;
1257
- }
997
+ // Only perform snapping if enabled and the last scroll was user-initiated
998
+ if (props.value.snap && !wasProgrammatic) {
999
+ const snapProp = props.value.snap;
1000
+ const snapMode = snapProp === true ? 'auto' : snapProp;
1001
+ const details = scrollDetails.value;
1002
+ const itemsLen = props.value.items.length;
1003
+
1004
+ let targetRow: number | null = details.currentIndex;
1005
+ let targetCol: number | null = details.currentColIndex;
1006
+ let alignY: ScrollAlignment = 'start';
1007
+ let alignX: ScrollAlignment = 'start';
1008
+ let shouldSnap = false;
1009
+
1010
+ // Handle Y Axis (Vertical)
1011
+ if (direction.value !== 'horizontal') {
1012
+ const res = resolveSnap(
1013
+ snapMode,
1014
+ scrollDirectionY,
1015
+ details.currentIndex,
1016
+ details.currentEndIndex,
1017
+ relativeScrollY.value,
1018
+ viewportHeight.value,
1019
+ itemsLen,
1020
+ (i) => itemSizesY.get(i),
1021
+ (i) => itemSizesY.query(i),
1022
+ getRowIndexAt,
1023
+ );
1024
+ if (res) {
1025
+ targetRow = res.index;
1026
+ alignY = res.align;
1027
+ shouldSnap = true;
1258
1028
  }
1259
- measuredColumns[ colIdx ] = 1;
1260
1029
  }
1261
- }
1262
- };
1263
-
1264
- for (const { index, inlineSize, blockSize, element } of updates) {
1265
- // Ignore 0-size measurements as they usually indicate hidden/detached elements
1266
- if (inlineSize <= 0 && blockSize <= 0) {
1267
- continue;
1268
- }
1269
1030
 
1270
- const isMeasurable = isDynamicItemSize.value || typeof props.value.itemSize === 'function';
1271
- if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
1272
- processedRows.add(index);
1273
- if (isHorizontalMode && inlineSize > 0) {
1274
- const oldWidth = itemSizesX.get(index);
1275
- const targetWidth = inlineSize + columnGap;
1276
- if (!measuredItemsX[ index ] || Math.abs(targetWidth - oldWidth) > 0.1) {
1277
- const d = targetWidth - oldWidth;
1278
- itemSizesX.update(index, d);
1279
- measuredItemsX[ index ] = 1;
1280
- needUpdate = true;
1281
- if (index < firstRowIndex) {
1282
- deltaX += d;
1283
- }
1284
- }
1285
- }
1286
- if (!isHorizontalMode) {
1287
- const oldHeight = itemSizesY.get(index);
1288
- const targetHeight = blockSize + gap;
1289
-
1290
- if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
1291
- const d = targetHeight - oldHeight;
1292
- itemSizesY.update(index, d);
1293
- measuredItemsY[ index ] = 1;
1294
- needUpdate = true;
1295
- if (index < firstRowIndex) {
1296
- deltaY += d;
1297
- }
1031
+ // Handle X Axis (Horizontal)
1032
+ if (direction.value !== 'vertical') {
1033
+ const isGrid = direction.value === 'both';
1034
+ const colCount = isGrid ? (props.value.columnCount || 0) : itemsLen;
1035
+ const res = resolveSnap(
1036
+ snapMode,
1037
+ scrollDirectionX,
1038
+ details.currentColIndex,
1039
+ details.currentEndColIndex,
1040
+ relativeScrollX.value,
1041
+ viewportWidth.value,
1042
+ colCount,
1043
+ (i) => (isGrid ? columnSizes.get(i) : itemSizesX.get(i)),
1044
+ (i) => (isGrid ? columnSizes.query(i) : itemSizesX.query(i)),
1045
+ getColIndexAt,
1046
+ );
1047
+ if (res) {
1048
+ targetCol = res.index;
1049
+ alignX = res.align;
1050
+ shouldSnap = true;
1298
1051
  }
1299
1052
  }
1300
- }
1301
1053
 
1302
- // Dynamic column width measurement
1303
- const isColMeasurable = isDynamicColumnWidth.value || typeof props.value.columnWidth === 'function';
1304
- if (
1305
- isBothMode
1306
- && element
1307
- && props.value.columnCount
1308
- && isColMeasurable
1309
- && (inlineSize > 0 || element.dataset.colIndex === undefined)
1310
- ) {
1311
- const colIndexAttr = element.dataset.colIndex;
1312
- if (colIndexAttr != null) {
1313
- tryUpdateColumn(Number.parseInt(colIndexAttr, 10), inlineSize);
1314
- } else {
1315
- // If the element is a row, try to find cells with data-col-index
1316
- const cells = Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
1317
-
1318
- for (const child of cells) {
1319
- const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
1320
- tryUpdateColumn(colIndex, child.getBoundingClientRect().width);
1321
- }
1054
+ if (shouldSnap) {
1055
+ scrollToIndex(targetRow, targetCol, {
1056
+ align: { x: alignX, y: alignY },
1057
+ behavior: 'smooth',
1058
+ });
1322
1059
  }
1323
1060
  }
1324
- }
1325
-
1326
- if (needUpdate) {
1327
- treeUpdateFlag.value++;
1328
- // Only compensate if not in a programmatic scroll,
1329
- // as it would interrupt the browser animation or explicit alignment.
1330
- const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
1061
+ }, 250);
1062
+ };
1331
1063
 
1332
- if (!hasPendingScroll && (deltaX !== 0 || deltaY !== 0)) {
1333
- const contentStartLogicalX = flowStartX.value + stickyStartX.value + paddingStartX.value;
1334
- const contentStartLogicalY = flowStartY.value + stickyStartY.value + paddingStartY.value;
1335
- scrollToOffset(
1336
- deltaX !== 0 ? currentRelX + deltaX + contentStartLogicalX : null,
1337
- deltaY !== 0 ? currentRelY + deltaY + contentStartLogicalY : null,
1338
- { behavior: 'auto' },
1339
- );
1340
- }
1341
- }
1064
+ /**
1065
+ * Updates the size of multiple items in the Fenwick tree.
1066
+ *
1067
+ * @param updates - Array of updates
1068
+ */
1069
+ const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
1070
+ coreUpdateItemSizes(
1071
+ updates,
1072
+ getRowIndexAt,
1073
+ getColIndexAt,
1074
+ relativeScrollX.value,
1075
+ relativeScrollY.value,
1076
+ (dx, dy) => {
1077
+ const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
1078
+ if (!hasPendingScroll) {
1079
+ handleScrollCorrection(dx, dy);
1080
+ }
1081
+ },
1082
+ );
1342
1083
  };
1343
1084
 
1344
1085
  /**
@@ -1418,8 +1159,8 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1418
1159
  const reachedX = (colIndex === null || colIndex === undefined) || Math.abs(currentRelX - targetX) < toleranceX;
1419
1160
  const reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(currentRelY - targetY) < toleranceY;
1420
1161
 
1421
- const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns[ colIndex ] === 1;
1422
- const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY[ rowIndex ] === 1;
1162
+ const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns.value[ colIndex ] === 1;
1163
+ const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY.value[ rowIndex ] === 1;
1423
1164
 
1424
1165
  if (reachedX && reachedY) {
1425
1166
  if (isMeasuredX && isMeasuredY && !isScrolling.value && !isProgrammaticScroll.value) {
@@ -1562,13 +1303,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1562
1303
  * Useful if item source data has changed in a way that affects sizes without changing the items array reference.
1563
1304
  */
1564
1305
  const refresh = () => {
1565
- itemSizesX.resize(0);
1566
- itemSizesY.resize(0);
1567
- columnSizes.resize(0);
1568
- measuredColumns.fill(0);
1569
- measuredItemsX.fill(0);
1570
- measuredItemsY.fill(0);
1571
- initializeSizes();
1306
+ coreRefresh(handleScrollCorrection);
1572
1307
  };
1573
1308
 
1574
1309
  return {
@@ -1628,7 +1363,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1628
1363
  * @param index - The row index.
1629
1364
  * @returns The virtual offset in VU.
1630
1365
  */
1631
- getRowOffset: (index: number) => (flowStartY.value + stickyStartY.value + paddingStartY.value) + itemSizesY.query(index),
1366
+ getRowOffset: (index: number) => (flowStartY.value + stickyStartY.value + paddingStartY.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.gap || 0, (idx) => itemSizesY.query(idx)),
1632
1367
 
1633
1368
  /**
1634
1369
  * Helper to get the virtual offset of a specific column.
@@ -1636,7 +1371,13 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1636
1371
  * @param index - The column index.
1637
1372
  * @returns The virtual offset in VU.
1638
1373
  */
1639
- getColumnOffset: (index: number) => (flowStartX.value + stickyStartX.value + paddingStartX.value) + columnSizes.query(index),
1374
+ getColumnOffset: (index: number) => {
1375
+ const itemsStartVU_X = flowStartX.value + stickyStartX.value + paddingStartX.value;
1376
+ if (direction.value === 'both') {
1377
+ return itemsStartVU_X + calculateOffsetAt(index, fixedColumnWidth.value, props.value.columnGap || 0, (idx) => columnSizes.query(idx));
1378
+ }
1379
+ return itemsStartVU_X + calculateOffsetAt(index, fixedItemSize.value, props.value.columnGap || 0, (idx) => itemSizesX.query(idx));
1380
+ },
1640
1381
 
1641
1382
  /**
1642
1383
  * Helper to get the virtual offset of a specific item along the scroll axis.
@@ -1644,7 +1385,7 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1644
1385
  * @param index - The item index.
1645
1386
  * @returns The virtual offset in VU.
1646
1387
  */
1647
- getItemOffset: (index: number) => (direction.value === 'horizontal' ? (flowStartX.value + stickyStartX.value + paddingStartX.value) + itemSizesX.query(index) : (flowStartY.value + stickyStartY.value + paddingStartY.value) + itemSizesY.query(index)),
1388
+ getItemOffset: (index: number) => (direction.value === 'horizontal' ? (flowStartX.value + stickyStartX.value + paddingStartX.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.columnGap || 0, (idx) => itemSizesX.query(idx)) : (flowStartY.value + stickyStartY.value + paddingStartY.value) + calculateOffsetAt(index, fixedItemSize.value, props.value.gap || 0, (idx) => itemSizesY.query(idx))),
1648
1389
 
1649
1390
  /**
1650
1391
  * Helper to get the size of a specific item along the scroll axis.
@@ -1652,20 +1393,9 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1652
1393
  * @param index - The item index.
1653
1394
  * @returns The size in VU (excluding gap).
1654
1395
  */
1655
- getItemSize: (index: number) => {
1656
- if (direction.value === 'horizontal') {
1657
- return Math.max(0, itemSizesX.get(index) - (props.value.columnGap || 0));
1658
- }
1659
- const itemSize = props.value.itemSize;
1660
- if (typeof itemSize === 'number' && itemSize > 0) {
1661
- return itemSize;
1662
- }
1663
- if (typeof itemSize === 'function') {
1664
- const item = props.value.items[ index ];
1665
- return item !== undefined ? itemSize(item, index) : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
1666
- }
1667
- return Math.max(0, itemSizesY.get(index) - (props.value.gap || 0));
1668
- },
1396
+ getItemSize: (index: number) => (direction.value === 'horizontal'
1397
+ ? getColumnWidth(index)
1398
+ : getRowHeight(index)),
1669
1399
 
1670
1400
  /**
1671
1401
  * Programmatically scroll to a specific row and/or column.
@@ -1771,5 +1501,15 @@ export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<Virtu
1771
1501
  * Physical height of the items wrapper in the DOM (clamped to browser limits, in DU).
1772
1502
  */
1773
1503
  renderedVirtualHeight,
1504
+
1505
+ /**
1506
+ * Helper to get the row index at a specific virtual offset.
1507
+ */
1508
+ getRowIndexAt,
1509
+
1510
+ /**
1511
+ * Helper to get the column index at a specific virtual offset.
1512
+ */
1513
+ getColIndexAt,
1774
1514
  };
1775
1515
  }