@pdanpdan/virtual-scroll 0.3.0 → 0.4.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.
@@ -0,0 +1,605 @@
1
+ import type {
2
+ ColumnRangeParams,
3
+ ItemPositionParams,
4
+ ItemStyleParams,
5
+ RangeParams,
6
+ ScrollAlignment,
7
+ ScrollAlignmentOptions,
8
+ ScrollTargetParams,
9
+ ScrollTargetResult,
10
+ ScrollToIndexOptions,
11
+ StickyParams,
12
+ TotalSizeParams,
13
+ } from '../types';
14
+
15
+ import { isScrollToIndexOptions } from './scroll';
16
+
17
+ /**
18
+ * Calculates the target scroll position (relative to content) for a given row/column index and alignment.
19
+ *
20
+ * @param params - The parameters for calculation.
21
+ * @returns The target X and Y positions and item dimensions.
22
+ * @see ScrollTargetParams
23
+ * @see ScrollTargetResult
24
+ */
25
+ export function calculateScrollTarget(params: ScrollTargetParams): ScrollTargetResult {
26
+ const {
27
+ rowIndex,
28
+ colIndex,
29
+ options,
30
+ itemsLength,
31
+ columnCount,
32
+ direction,
33
+ usableWidth,
34
+ usableHeight,
35
+ totalWidth,
36
+ totalHeight,
37
+ gap,
38
+ columnGap,
39
+ fixedSize,
40
+ fixedWidth,
41
+ relativeScrollX,
42
+ relativeScrollY,
43
+ getItemSizeY,
44
+ getItemSizeX,
45
+ getItemQueryY,
46
+ getItemQueryX,
47
+ getColumnSize,
48
+ getColumnQuery,
49
+ stickyIndices,
50
+ } = params;
51
+
52
+ let align: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions | undefined;
53
+
54
+ if (isScrollToIndexOptions(options)) {
55
+ align = options.align;
56
+ } else {
57
+ align = options as ScrollAlignment | ScrollAlignmentOptions;
58
+ }
59
+
60
+ const alignX = (typeof align === 'object' ? align.x : align) || 'auto';
61
+ const alignY = (typeof align === 'object' ? align.y : align) || 'auto';
62
+
63
+ const isVertical = direction === 'vertical' || direction === 'both';
64
+ const isHorizontal = direction === 'horizontal' || direction === 'both';
65
+
66
+ let targetX = relativeScrollX;
67
+ let targetY = relativeScrollY;
68
+ let itemWidth = 0;
69
+ let itemHeight = 0;
70
+ let effectiveAlignX: ScrollAlignment = alignX === 'auto' ? 'auto' : alignX;
71
+ let effectiveAlignY: ScrollAlignment = alignY === 'auto' ? 'auto' : alignY;
72
+
73
+ // Y calculation
74
+ if (rowIndex != null) {
75
+ let stickyOffsetY = 0;
76
+ if (isVertical && stickyIndices && stickyIndices.length > 0) {
77
+ let activeStickyIdx: number | undefined;
78
+ let low = 0;
79
+ let high = stickyIndices.length - 1;
80
+ while (low <= high) {
81
+ const mid = (low + high) >>> 1;
82
+ if (stickyIndices[ mid ]! < rowIndex) {
83
+ activeStickyIdx = stickyIndices[ mid ];
84
+ low = mid + 1;
85
+ } else {
86
+ high = mid - 1;
87
+ }
88
+ }
89
+
90
+ if (activeStickyIdx !== undefined) {
91
+ stickyOffsetY = fixedSize !== null ? fixedSize : getItemSizeY(activeStickyIdx) - gap;
92
+ }
93
+ }
94
+
95
+ let itemY = 0;
96
+ if (rowIndex >= itemsLength) {
97
+ itemY = totalHeight;
98
+ itemHeight = 0;
99
+ } else {
100
+ itemY = fixedSize !== null ? rowIndex * (fixedSize + gap) : getItemQueryY(rowIndex);
101
+ itemHeight = fixedSize !== null ? fixedSize : getItemSizeY(rowIndex) - gap;
102
+ }
103
+
104
+ // Apply Y Alignment
105
+ if (alignY === 'start') {
106
+ targetY = itemY - stickyOffsetY;
107
+ } else if (alignY === 'center') {
108
+ targetY = itemY - (usableHeight - itemHeight) / 2;
109
+ } else if (alignY === 'end') {
110
+ targetY = itemY - (usableHeight - itemHeight);
111
+ } else {
112
+ // Auto alignment: stay if visible, otherwise align to nearest edge (minimal movement)
113
+ const isVisibleY = itemHeight <= (usableHeight - stickyOffsetY)
114
+ ? (itemY >= relativeScrollY + stickyOffsetY - 0.5 && (itemY + itemHeight) <= (relativeScrollY + usableHeight + 0.5))
115
+ : (itemY <= relativeScrollY + stickyOffsetY + 0.5 && (itemY + itemHeight) >= (relativeScrollY + usableHeight - 0.5));
116
+
117
+ if (!isVisibleY) {
118
+ const targetStart = itemY - stickyOffsetY;
119
+ const targetEnd = itemY - (usableHeight - itemHeight);
120
+
121
+ if (itemHeight <= usableHeight - stickyOffsetY) {
122
+ if (itemY < relativeScrollY + stickyOffsetY) {
123
+ targetY = targetStart;
124
+ effectiveAlignY = 'start';
125
+ } else {
126
+ targetY = targetEnd;
127
+ effectiveAlignY = 'end';
128
+ }
129
+ } else {
130
+ // Large item: minimal movement
131
+ if (Math.abs(targetStart - relativeScrollY) < Math.abs(targetEnd - relativeScrollY)) {
132
+ targetY = targetStart;
133
+ effectiveAlignY = 'start';
134
+ } else {
135
+ targetY = targetEnd;
136
+ effectiveAlignY = 'end';
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ // X calculation
144
+ if (colIndex != null) {
145
+ let stickyOffsetX = 0;
146
+ if (isHorizontal && stickyIndices && stickyIndices.length > 0 && (direction === 'horizontal' || direction === 'both')) {
147
+ let activeStickyIdx: number | undefined;
148
+ let low = 0;
149
+ let high = stickyIndices.length - 1;
150
+ while (low <= high) {
151
+ const mid = (low + high) >>> 1;
152
+ if (stickyIndices[ mid ]! < colIndex) {
153
+ activeStickyIdx = stickyIndices[ mid ];
154
+ low = mid + 1;
155
+ } else {
156
+ high = mid - 1;
157
+ }
158
+ }
159
+
160
+ if (activeStickyIdx !== undefined) {
161
+ stickyOffsetX = direction === 'horizontal'
162
+ ? (fixedSize !== null ? fixedSize : getItemSizeX(activeStickyIdx) - columnGap)
163
+ : (fixedWidth !== null ? fixedWidth : getColumnSize(activeStickyIdx) - columnGap);
164
+ }
165
+ }
166
+
167
+ let itemX = 0;
168
+ if (colIndex >= columnCount && columnCount > 0) {
169
+ itemX = totalWidth;
170
+ itemWidth = 0;
171
+ } else if (direction === 'horizontal') {
172
+ itemX = fixedSize !== null ? colIndex * (fixedSize + columnGap) : getItemQueryX(colIndex);
173
+ itemWidth = fixedSize !== null ? fixedSize : getItemSizeX(colIndex) - columnGap;
174
+ } else {
175
+ itemX = getColumnQuery(colIndex);
176
+ itemWidth = getColumnSize(colIndex) - columnGap;
177
+ }
178
+
179
+ // Apply X Alignment
180
+ if (alignX === 'start') {
181
+ targetX = itemX - stickyOffsetX;
182
+ } else if (alignX === 'center') {
183
+ targetX = itemX - (usableWidth - itemWidth) / 2;
184
+ } else if (alignX === 'end') {
185
+ targetX = itemX - (usableWidth - itemWidth);
186
+ } else {
187
+ // Auto alignment: stay if visible, otherwise align to nearest edge (minimal movement)
188
+ const isVisibleX = itemWidth <= (usableWidth - stickyOffsetX)
189
+ ? (itemX >= relativeScrollX + stickyOffsetX - 0.5 && (itemX + itemWidth) <= (relativeScrollX + usableWidth + 0.5))
190
+ : (itemX <= relativeScrollX + stickyOffsetX + 0.5 && (itemX + itemWidth) >= (relativeScrollX + usableWidth - 0.5));
191
+
192
+ if (!isVisibleX) {
193
+ const targetStart = itemX - stickyOffsetX;
194
+ const targetEnd = itemX - (usableWidth - itemWidth);
195
+
196
+ if (itemWidth <= usableWidth - stickyOffsetX) {
197
+ if (itemX < relativeScrollX + stickyOffsetX) {
198
+ targetX = targetStart;
199
+ effectiveAlignX = 'start';
200
+ } else {
201
+ targetX = targetEnd;
202
+ effectiveAlignX = 'end';
203
+ }
204
+ } else {
205
+ // Large item: minimal movement
206
+ if (Math.abs(targetStart - relativeScrollX) < Math.abs(targetEnd - relativeScrollX)) {
207
+ targetX = targetStart;
208
+ effectiveAlignX = 'start';
209
+ } else {
210
+ targetX = targetEnd;
211
+ effectiveAlignX = 'end';
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ // Clamp to valid range
219
+ targetX = Math.max(0, Math.min(targetX, Math.max(0, totalWidth - usableWidth)));
220
+ targetY = Math.max(0, Math.min(targetY, Math.max(0, totalHeight - usableHeight)));
221
+
222
+ return { targetX, targetY, itemWidth, itemHeight, effectiveAlignX, effectiveAlignY };
223
+ }
224
+
225
+ /**
226
+ * Calculates the range of items to render based on scroll position and viewport size.
227
+ *
228
+ * @param params - The parameters for calculation.
229
+ * @returns The start and end indices of the items to render.
230
+ * @see RangeParams
231
+ */
232
+ export function calculateRange(params: RangeParams) {
233
+ const {
234
+ direction,
235
+ relativeScrollX,
236
+ relativeScrollY,
237
+ usableWidth,
238
+ usableHeight,
239
+ itemsLength,
240
+ bufferBefore,
241
+ bufferAfter,
242
+ gap,
243
+ columnGap,
244
+ fixedSize,
245
+ findLowerBoundY,
246
+ findLowerBoundX,
247
+ queryY,
248
+ queryX,
249
+ } = params;
250
+
251
+ const isVertical = direction === 'vertical' || direction === 'both';
252
+
253
+ let start = 0;
254
+ let end = itemsLength;
255
+
256
+ if (isVertical) {
257
+ if (fixedSize !== null) {
258
+ start = Math.floor(relativeScrollY / (fixedSize + gap));
259
+ end = Math.ceil((relativeScrollY + usableHeight) / (fixedSize + gap));
260
+ } else {
261
+ start = findLowerBoundY(relativeScrollY);
262
+ const targetY = relativeScrollY + usableHeight;
263
+ end = findLowerBoundY(targetY);
264
+ if (end < itemsLength && queryY(end) < targetY) {
265
+ end++;
266
+ }
267
+ }
268
+ } else {
269
+ if (fixedSize !== null) {
270
+ start = Math.floor(relativeScrollX / (fixedSize + columnGap));
271
+ end = Math.ceil((relativeScrollX + usableWidth) / (fixedSize + columnGap));
272
+ } else {
273
+ start = findLowerBoundX(relativeScrollX);
274
+ const targetX = relativeScrollX + usableWidth;
275
+ end = findLowerBoundX(targetX);
276
+ if (end < itemsLength && queryX(end) < targetX) {
277
+ end++;
278
+ }
279
+ }
280
+ }
281
+
282
+ return {
283
+ start: Math.max(0, start - bufferBefore),
284
+ end: Math.min(itemsLength, end + bufferAfter),
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Calculates the range of columns to render for bidirectional scroll.
290
+ *
291
+ * @param params - The parameters for calculation.
292
+ * @returns The start and end indices and paddings for columns.
293
+ * @see ColumnRangeParams
294
+ * @see ColumnRange
295
+ */
296
+ export function calculateColumnRange(params: ColumnRangeParams) {
297
+ const {
298
+ columnCount,
299
+ relativeScrollX,
300
+ usableWidth,
301
+ colBuffer,
302
+ fixedWidth,
303
+ columnGap,
304
+ findLowerBound,
305
+ query,
306
+ totalColsQuery,
307
+ } = params;
308
+
309
+ if (!columnCount) {
310
+ return { start: 0, end: 0, padStart: 0, padEnd: 0 };
311
+ }
312
+
313
+ let start = 0;
314
+ let end = columnCount;
315
+
316
+ if (fixedWidth !== null) {
317
+ start = Math.floor(relativeScrollX / (fixedWidth + columnGap));
318
+ end = Math.ceil((relativeScrollX + usableWidth) / (fixedWidth + columnGap));
319
+ } else {
320
+ start = findLowerBound(relativeScrollX);
321
+ let currentX = query(start);
322
+ let i = start;
323
+ while (i < columnCount && currentX < relativeScrollX + usableWidth) {
324
+ currentX = query(++i);
325
+ }
326
+ end = i;
327
+ }
328
+
329
+ // Add buffer of columns
330
+ const safeStart = Math.max(0, start - colBuffer);
331
+ const safeEnd = Math.min(columnCount, end + colBuffer);
332
+
333
+ const padStart = fixedWidth !== null ? safeStart * (fixedWidth + columnGap) : query(safeStart);
334
+ const totalWidth = fixedWidth !== null ? columnCount * (fixedWidth + columnGap) - columnGap : Math.max(0, totalColsQuery() - columnGap);
335
+
336
+ const renderedEnd = fixedWidth !== null
337
+ ? (safeEnd * (fixedWidth + columnGap) - (safeEnd >= columnCount ? columnGap : 0))
338
+ : (query(safeEnd) - (safeEnd >= columnCount ? columnGap : 0));
339
+
340
+ return {
341
+ start: safeStart,
342
+ end: safeEnd,
343
+ padStart,
344
+ padEnd: Math.max(0, totalWidth - renderedEnd),
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Calculates the sticky state and offset for a single item.
350
+ *
351
+ * @param params - The parameters for calculation.
352
+ * @returns Sticky state and offset.
353
+ * @see StickyParams
354
+ */
355
+ export function calculateStickyItem(params: StickyParams) {
356
+ const {
357
+ index,
358
+ isSticky,
359
+ direction,
360
+ relativeScrollX,
361
+ relativeScrollY,
362
+ originalX,
363
+ originalY,
364
+ width,
365
+ height,
366
+ stickyIndices,
367
+ fixedSize,
368
+ fixedWidth,
369
+ gap,
370
+ columnGap,
371
+ getItemQueryY,
372
+ getItemQueryX,
373
+ } = params;
374
+
375
+ let isStickyActive = false;
376
+ const stickyOffset = { x: 0, y: 0 };
377
+
378
+ if (!isSticky) {
379
+ return { isStickyActive, stickyOffset };
380
+ }
381
+
382
+ if (direction === 'vertical' || direction === 'both') {
383
+ if (relativeScrollY > originalY) {
384
+ // Check if next sticky item pushes this one
385
+ let nextStickyIdx: number | undefined;
386
+ let low = 0;
387
+ let high = stickyIndices.length - 1;
388
+ while (low <= high) {
389
+ const mid = (low + high) >>> 1;
390
+ if (stickyIndices[ mid ]! > index) {
391
+ nextStickyIdx = stickyIndices[ mid ];
392
+ high = mid - 1;
393
+ } else {
394
+ low = mid + 1;
395
+ }
396
+ }
397
+
398
+ if (nextStickyIdx !== undefined) {
399
+ const nextStickyY = fixedSize !== null ? nextStickyIdx * (fixedSize + gap) : getItemQueryY(nextStickyIdx);
400
+ if (relativeScrollY >= nextStickyY) {
401
+ isStickyActive = false;
402
+ } else {
403
+ isStickyActive = true;
404
+ stickyOffset.y = Math.max(0, Math.min(height, nextStickyY - relativeScrollY)) - height;
405
+ }
406
+ } else {
407
+ isStickyActive = true;
408
+ }
409
+ }
410
+ }
411
+
412
+ if (direction === 'horizontal' || (direction === 'both' && !isStickyActive)) {
413
+ if (relativeScrollX > originalX) {
414
+ let nextStickyIdx: number | undefined;
415
+ let low = 0;
416
+ let high = stickyIndices.length - 1;
417
+ while (low <= high) {
418
+ const mid = (low + high) >>> 1;
419
+ if (stickyIndices[ mid ]! > index) {
420
+ nextStickyIdx = stickyIndices[ mid ];
421
+ high = mid - 1;
422
+ } else {
423
+ low = mid + 1;
424
+ }
425
+ }
426
+
427
+ if (nextStickyIdx !== undefined) {
428
+ const nextStickyX = direction === 'horizontal'
429
+ ? (fixedSize !== null ? nextStickyIdx * (fixedSize + columnGap) : getItemQueryX(nextStickyIdx))
430
+ : (fixedWidth !== null ? nextStickyIdx * (fixedWidth + columnGap) : getItemQueryX(nextStickyIdx));
431
+
432
+ if (relativeScrollX >= nextStickyX) {
433
+ isStickyActive = false;
434
+ } else {
435
+ isStickyActive = true;
436
+ stickyOffset.x = Math.max(0, Math.min(width, nextStickyX - relativeScrollX)) - width;
437
+ }
438
+ } else {
439
+ isStickyActive = true;
440
+ }
441
+ }
442
+ }
443
+
444
+ return { isStickyActive, stickyOffset };
445
+ }
446
+
447
+ /**
448
+ * Calculates the position and size of a single item.
449
+ *
450
+ * @param params - The parameters for calculation.
451
+ * @returns Item position and size.
452
+ * @see ItemPositionParams
453
+ */
454
+ export function calculateItemPosition(params: ItemPositionParams) {
455
+ const {
456
+ index,
457
+ direction,
458
+ fixedSize,
459
+ gap,
460
+ columnGap,
461
+ usableWidth,
462
+ usableHeight,
463
+ totalWidth,
464
+ queryY,
465
+ queryX,
466
+ getSizeY,
467
+ getSizeX,
468
+ } = params;
469
+
470
+ let x = 0;
471
+ let y = 0;
472
+ let width = 0;
473
+ let height = 0;
474
+
475
+ if (direction === 'horizontal') {
476
+ x = fixedSize !== null ? index * (fixedSize + columnGap) : queryX(index);
477
+ width = fixedSize !== null ? fixedSize : getSizeX(index) - columnGap;
478
+ height = usableHeight;
479
+ } else {
480
+ // vertical or both
481
+ y = (direction === 'vertical' || direction === 'both') && fixedSize !== null ? index * (fixedSize + gap) : queryY(index);
482
+ height = fixedSize !== null ? fixedSize : getSizeY(index) - gap;
483
+ width = direction === 'both' ? totalWidth : usableWidth;
484
+ }
485
+
486
+ return { height, width, x, y };
487
+ }
488
+
489
+ /**
490
+ * Calculates the style object for a rendered item.
491
+ *
492
+ * @param params - The parameters for calculation.
493
+ * @returns Style object.
494
+ * @see ItemStyleParams
495
+ */
496
+ export function calculateItemStyle<T = unknown>(params: ItemStyleParams<T>) {
497
+ const {
498
+ item,
499
+ direction,
500
+ itemSize,
501
+ containerTag,
502
+ paddingStartX,
503
+ paddingStartY,
504
+ isHydrated,
505
+ } = params;
506
+
507
+ const isVertical = direction === 'vertical';
508
+ const isHorizontal = direction === 'horizontal';
509
+ const isBoth = direction === 'both';
510
+ const isDynamic = itemSize === undefined || itemSize === null || itemSize === 0;
511
+
512
+ const style: Record<string, string | number | undefined> = {
513
+ blockSize: isHorizontal ? '100%' : (!isDynamic ? `${ item.size.height }px` : 'auto'),
514
+ };
515
+
516
+ if (isVertical && containerTag === 'table') {
517
+ style.minInlineSize = '100%';
518
+ } else {
519
+ style.inlineSize = isVertical ? '100%' : (!isDynamic ? `${ item.size.width }px` : 'auto');
520
+ }
521
+
522
+ if (isDynamic) {
523
+ if (!isVertical) {
524
+ style.minInlineSize = '1px';
525
+ }
526
+ if (!isHorizontal) {
527
+ style.minBlockSize = '1px';
528
+ }
529
+ }
530
+
531
+ if (isHydrated) {
532
+ if (item.isStickyActive) {
533
+ if (isVertical || isBoth) {
534
+ style.insetBlockStart = `${ paddingStartY }px`;
535
+ }
536
+
537
+ if (isHorizontal || isBoth) {
538
+ style.insetInlineStart = `${ paddingStartX }px`;
539
+ }
540
+
541
+ style.transform = `translate(${ item.stickyOffset.x }px, ${ item.stickyOffset.y }px)`;
542
+ } else {
543
+ style.transform = `translate(${ item.offset.x }px, ${ item.offset.y }px)`;
544
+ }
545
+ }
546
+
547
+ return style;
548
+ }
549
+
550
+ /**
551
+ * Calculates the total width and height of the virtualized content.
552
+ *
553
+ * @param params - The parameters for calculation.
554
+ * @returns Total width and height.
555
+ * @see TotalSizeParams
556
+ */
557
+ export function calculateTotalSize(params: TotalSizeParams) {
558
+ const {
559
+ direction,
560
+ itemsLength,
561
+ columnCount,
562
+ fixedSize,
563
+ fixedWidth,
564
+ gap,
565
+ columnGap,
566
+ usableWidth,
567
+ usableHeight,
568
+ queryY,
569
+ queryX,
570
+ queryColumn,
571
+ } = params;
572
+
573
+ let width = 0;
574
+ let height = 0;
575
+
576
+ if (direction === 'both') {
577
+ if (columnCount > 0) {
578
+ width = fixedWidth !== null ? columnCount * (fixedWidth + columnGap) - columnGap : Math.max(0, queryColumn(columnCount) - columnGap);
579
+ }
580
+ if (fixedSize !== null) {
581
+ height = Math.max(0, itemsLength * (fixedSize + gap) - (itemsLength > 0 ? gap : 0));
582
+ } else {
583
+ height = Math.max(0, queryY(itemsLength) - (itemsLength > 0 ? gap : 0));
584
+ }
585
+ width = Math.max(width, usableWidth);
586
+ height = Math.max(height, usableHeight);
587
+ } else if (direction === 'horizontal') {
588
+ if (fixedSize !== null) {
589
+ width = Math.max(0, itemsLength * (fixedSize + columnGap) - (itemsLength > 0 ? columnGap : 0));
590
+ } else {
591
+ width = Math.max(0, queryX(itemsLength) - (itemsLength > 0 ? columnGap : 0));
592
+ }
593
+ height = usableHeight;
594
+ } else {
595
+ // vertical
596
+ width = usableWidth;
597
+ if (fixedSize !== null) {
598
+ height = Math.max(0, itemsLength * (fixedSize + gap) - (itemsLength > 0 ? gap : 0));
599
+ } else {
600
+ height = Math.max(0, queryY(itemsLength) - (itemsLength > 0 ? gap : 0));
601
+ }
602
+ }
603
+
604
+ return { width, height };
605
+ }