@humanspeak/svelte-virtual-list 0.3.6 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -0
- package/dist/SvelteVirtualList.svelte +307 -118
- package/dist/SvelteVirtualList.svelte.d.ts +40 -0
- package/dist/reactive-list-manager/RecomputeScheduler.d.ts +78 -0
- package/dist/reactive-list-manager/RecomputeScheduler.js +78 -9
- package/dist/types.d.ts +15 -0
- package/dist/utils/heightChangeDetection.d.ts +32 -7
- package/dist/utils/heightChangeDetection.js +32 -7
- package/dist/utils/scrollCalculation.d.ts +35 -0
- package/dist/utils/scrollCalculation.js +99 -71
- package/dist/utils/virtualList.d.ts +64 -0
- package/dist/utils/virtualList.js +83 -12
- package/package.json +30 -30
|
@@ -92,6 +92,26 @@ import { type SvelteVirtualListProps, type SvelteVirtualListScrollOptions } from
|
|
|
92
92
|
declare function $$render<TItem = unknown>(): {
|
|
93
93
|
props: SvelteVirtualListProps<TItem>;
|
|
94
94
|
exports: {
|
|
95
|
+
/**
|
|
96
|
+
* Runs a batch of updates with scroll corrections coalesced until the batch completes.
|
|
97
|
+
*
|
|
98
|
+
* Use this method when making multiple changes to the items array to prevent
|
|
99
|
+
* intermediate scroll corrections. The scroll position reconciliation is deferred
|
|
100
|
+
* until the batch exits, ensuring smooth visual updates.
|
|
101
|
+
*
|
|
102
|
+
* @param {() => void} fn - The function containing batch updates to execute.
|
|
103
|
+
* @returns {void}
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* // Add multiple items without intermediate scroll corrections
|
|
108
|
+
* list.runInBatch(() => {
|
|
109
|
+
* items.push(newItem1);
|
|
110
|
+
* items.push(newItem2);
|
|
111
|
+
* items.push(newItem3);
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/ runInBatch: (fn: () => void) => void;
|
|
95
115
|
/**
|
|
96
116
|
* Scrolls the virtual list to the item at the given index.
|
|
97
117
|
*
|
|
@@ -159,6 +179,26 @@ declare class __sveltets_Render<TItem = unknown> {
|
|
|
159
179
|
slots(): ReturnType<typeof $$render<TItem>>['slots'];
|
|
160
180
|
bindings(): "";
|
|
161
181
|
exports(): {
|
|
182
|
+
/**
|
|
183
|
+
* Runs a batch of updates with scroll corrections coalesced until the batch completes.
|
|
184
|
+
*
|
|
185
|
+
* Use this method when making multiple changes to the items array to prevent
|
|
186
|
+
* intermediate scroll corrections. The scroll position reconciliation is deferred
|
|
187
|
+
* until the batch exits, ensuring smooth visual updates.
|
|
188
|
+
*
|
|
189
|
+
* @param {() => void} fn - The function containing batch updates to execute.
|
|
190
|
+
* @returns {void}
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* ```typescript
|
|
194
|
+
* // Add multiple items without intermediate scroll corrections
|
|
195
|
+
* list.runInBatch(() => {
|
|
196
|
+
* items.push(newItem1);
|
|
197
|
+
* items.push(newItem2);
|
|
198
|
+
* items.push(newItem3);
|
|
199
|
+
* });
|
|
200
|
+
* ```
|
|
201
|
+
*/ runInBatch: (fn: () => void) => void;
|
|
162
202
|
/**
|
|
163
203
|
* Scrolls the virtual list to the item at the given index.
|
|
164
204
|
*
|
|
@@ -1,13 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler that coalesces recompute requests to the next animation frame.
|
|
3
|
+
*
|
|
4
|
+
* This class provides efficient batching of recompute operations by scheduling
|
|
5
|
+
* them to run on the next animation frame in browser environments. In non-browser
|
|
6
|
+
* or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Coalesces multiple schedule() calls into a single recompute
|
|
10
|
+
* - Supports temporary blocking during critical sections
|
|
11
|
+
* - Handles nested block/unblock calls with depth tracking
|
|
12
|
+
* - Environment-aware: uses RAF in browsers, setTimeout in tests
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const scheduler = new RecomputeScheduler(() => {
|
|
17
|
+
* console.log('Recomputing derived state');
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Multiple calls within the same frame are coalesced
|
|
21
|
+
* scheduler.schedule();
|
|
22
|
+
* scheduler.schedule();
|
|
23
|
+
* scheduler.schedule(); // Only one recompute will run
|
|
24
|
+
*
|
|
25
|
+
* // Block during critical sections
|
|
26
|
+
* scheduler.block();
|
|
27
|
+
* scheduler.schedule(); // Marked as pending, won't run yet
|
|
28
|
+
* scheduler.unblock(); // Runs immediately if pending
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @class
|
|
32
|
+
*/
|
|
1
33
|
export declare class RecomputeScheduler {
|
|
34
|
+
/** Callback function to execute on recompute. */
|
|
2
35
|
private onRecompute;
|
|
36
|
+
/** Whether a recompute is currently scheduled. */
|
|
3
37
|
private isScheduled;
|
|
38
|
+
/** Whether a recompute is pending due to blocking. */
|
|
4
39
|
private isPending;
|
|
40
|
+
/** Current nesting depth of block() calls. */
|
|
5
41
|
private blockDepth;
|
|
42
|
+
/** ID of the pending setTimeout (non-browser fallback). */
|
|
6
43
|
private timeoutId;
|
|
44
|
+
/** ID of the pending requestAnimationFrame. */
|
|
7
45
|
private rafId;
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new RecomputeScheduler instance.
|
|
48
|
+
*
|
|
49
|
+
* @param {() => void} onRecompute - Callback function to execute when recompute runs.
|
|
50
|
+
*/
|
|
8
51
|
constructor(onRecompute: () => void);
|
|
52
|
+
/**
|
|
53
|
+
* Schedules a recompute for the next animation frame.
|
|
54
|
+
*
|
|
55
|
+
* If the scheduler is blocked, the request is marked as pending and will
|
|
56
|
+
* execute when unblocked. Multiple calls while a recompute is already
|
|
57
|
+
* scheduled are coalesced into a single execution.
|
|
58
|
+
*
|
|
59
|
+
* @returns {void}
|
|
60
|
+
*/
|
|
9
61
|
schedule: () => void;
|
|
62
|
+
/**
|
|
63
|
+
* Temporarily blocks recompute execution.
|
|
64
|
+
*
|
|
65
|
+
* Cancels any in-flight timers and marks any pending recompute request.
|
|
66
|
+
* Block calls can be nested; the scheduler remains blocked until all
|
|
67
|
+
* corresponding unblock() calls are made.
|
|
68
|
+
*
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
10
71
|
block: () => void;
|
|
72
|
+
/**
|
|
73
|
+
* Unblocks the scheduler and runs pending recompute if any.
|
|
74
|
+
*
|
|
75
|
+
* Decrements the block depth counter. When the depth reaches zero and
|
|
76
|
+
* a recompute was pending, it executes immediately (synchronously).
|
|
77
|
+
* Guards against underflow if unblock is called without matching block.
|
|
78
|
+
*
|
|
79
|
+
* @returns {void}
|
|
80
|
+
*/
|
|
11
81
|
unblock: () => void;
|
|
82
|
+
/**
|
|
83
|
+
* Cancels any scheduled or pending recompute.
|
|
84
|
+
*
|
|
85
|
+
* Clears all timers (setTimeout and RAF) and resets the scheduled
|
|
86
|
+
* and pending flags. Does not affect the block depth.
|
|
87
|
+
*
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
12
90
|
cancel: () => void;
|
|
13
91
|
}
|
|
@@ -1,19 +1,65 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler that coalesces recompute requests to the next animation frame.
|
|
3
|
+
*
|
|
4
|
+
* This class provides efficient batching of recompute operations by scheduling
|
|
5
|
+
* them to run on the next animation frame in browser environments. In non-browser
|
|
6
|
+
* or jsdom environments, it falls back to setTimeout(0) for deterministic testing.
|
|
7
|
+
*
|
|
8
|
+
* Key features:
|
|
9
|
+
* - Coalesces multiple schedule() calls into a single recompute
|
|
10
|
+
* - Supports temporary blocking during critical sections
|
|
11
|
+
* - Handles nested block/unblock calls with depth tracking
|
|
12
|
+
* - Environment-aware: uses RAF in browsers, setTimeout in tests
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const scheduler = new RecomputeScheduler(() => {
|
|
17
|
+
* console.log('Recomputing derived state');
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Multiple calls within the same frame are coalesced
|
|
21
|
+
* scheduler.schedule();
|
|
22
|
+
* scheduler.schedule();
|
|
23
|
+
* scheduler.schedule(); // Only one recompute will run
|
|
24
|
+
*
|
|
25
|
+
* // Block during critical sections
|
|
26
|
+
* scheduler.block();
|
|
27
|
+
* scheduler.schedule(); // Marked as pending, won't run yet
|
|
28
|
+
* scheduler.unblock(); // Runs immediately if pending
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* @class
|
|
32
|
+
*/
|
|
6
33
|
export class RecomputeScheduler {
|
|
34
|
+
/** Callback function to execute on recompute. */
|
|
7
35
|
onRecompute;
|
|
36
|
+
/** Whether a recompute is currently scheduled. */
|
|
8
37
|
isScheduled = false;
|
|
38
|
+
/** Whether a recompute is pending due to blocking. */
|
|
9
39
|
isPending = false;
|
|
40
|
+
/** Current nesting depth of block() calls. */
|
|
10
41
|
blockDepth = 0;
|
|
42
|
+
/** ID of the pending setTimeout (non-browser fallback). */
|
|
11
43
|
timeoutId = null;
|
|
44
|
+
/** ID of the pending requestAnimationFrame. */
|
|
12
45
|
rafId = null;
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new RecomputeScheduler instance.
|
|
48
|
+
*
|
|
49
|
+
* @param {() => void} onRecompute - Callback function to execute when recompute runs.
|
|
50
|
+
*/
|
|
13
51
|
constructor(onRecompute) {
|
|
14
52
|
this.onRecompute = onRecompute;
|
|
15
53
|
}
|
|
16
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Schedules a recompute for the next animation frame.
|
|
56
|
+
*
|
|
57
|
+
* If the scheduler is blocked, the request is marked as pending and will
|
|
58
|
+
* execute when unblocked. Multiple calls while a recompute is already
|
|
59
|
+
* scheduled are coalesced into a single execution.
|
|
60
|
+
*
|
|
61
|
+
* @returns {void}
|
|
62
|
+
*/
|
|
17
63
|
schedule = () => {
|
|
18
64
|
if (this.blockDepth > 0) {
|
|
19
65
|
this.isPending = true;
|
|
@@ -45,7 +91,15 @@ export class RecomputeScheduler {
|
|
|
45
91
|
this.onRecompute();
|
|
46
92
|
});
|
|
47
93
|
};
|
|
48
|
-
|
|
94
|
+
/**
|
|
95
|
+
* Temporarily blocks recompute execution.
|
|
96
|
+
*
|
|
97
|
+
* Cancels any in-flight timers and marks any pending recompute request.
|
|
98
|
+
* Block calls can be nested; the scheduler remains blocked until all
|
|
99
|
+
* corresponding unblock() calls are made.
|
|
100
|
+
*
|
|
101
|
+
* @returns {void}
|
|
102
|
+
*/
|
|
49
103
|
block = () => {
|
|
50
104
|
this.blockDepth += 1;
|
|
51
105
|
if (this.timeoutId) {
|
|
@@ -61,7 +115,15 @@ export class RecomputeScheduler {
|
|
|
61
115
|
this.isPending = true;
|
|
62
116
|
}
|
|
63
117
|
};
|
|
64
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Unblocks the scheduler and runs pending recompute if any.
|
|
120
|
+
*
|
|
121
|
+
* Decrements the block depth counter. When the depth reaches zero and
|
|
122
|
+
* a recompute was pending, it executes immediately (synchronously).
|
|
123
|
+
* Guards against underflow if unblock is called without matching block.
|
|
124
|
+
*
|
|
125
|
+
* @returns {void}
|
|
126
|
+
*/
|
|
65
127
|
unblock = () => {
|
|
66
128
|
if (this.blockDepth === 0)
|
|
67
129
|
return;
|
|
@@ -71,7 +133,14 @@ export class RecomputeScheduler {
|
|
|
71
133
|
this.onRecompute();
|
|
72
134
|
}
|
|
73
135
|
};
|
|
74
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Cancels any scheduled or pending recompute.
|
|
138
|
+
*
|
|
139
|
+
* Clears all timers (setTimeout and RAF) and resets the scheduled
|
|
140
|
+
* and pending flags. Does not affect the block depth.
|
|
141
|
+
*
|
|
142
|
+
* @returns {void}
|
|
143
|
+
*/
|
|
75
144
|
cancel = () => {
|
|
76
145
|
if (this.timeoutId) {
|
|
77
146
|
clearTimeout(this.timeoutId);
|
package/dist/types.d.ts
CHANGED
|
@@ -63,6 +63,21 @@ export type SvelteVirtualListProps<TItem = any> = {
|
|
|
63
63
|
* CSS class to apply to the scrollable viewport element.
|
|
64
64
|
*/
|
|
65
65
|
viewportClass?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Callback when more data is needed. Supports sync and async functions.
|
|
68
|
+
* Called when the user scrolls near the end of the list (based on loadMoreThreshold).
|
|
69
|
+
*/
|
|
70
|
+
onLoadMore?: () => void | Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Number of items from the end to trigger onLoadMore.
|
|
73
|
+
* @default 20
|
|
74
|
+
*/
|
|
75
|
+
loadMoreThreshold?: number;
|
|
76
|
+
/**
|
|
77
|
+
* Set to false when all data has been loaded to stop triggering onLoadMore.
|
|
78
|
+
* @default true
|
|
79
|
+
*/
|
|
80
|
+
hasMore?: boolean;
|
|
66
81
|
};
|
|
67
82
|
/**
|
|
68
83
|
* Debug information provided by the virtual list during rendering.
|
|
@@ -1,12 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Utility functions for detecting significant height changes in virtual list items
|
|
2
|
+
* Utility functions for detecting significant height changes in virtual list items.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Provides height change detection utilities for virtual list optimization.
|
|
5
|
+
* These functions help determine when item height changes are significant enough to
|
|
6
|
+
* trigger recalculations, preventing unnecessary updates for sub-pixel variations.
|
|
3
7
|
*/
|
|
4
8
|
/**
|
|
5
|
-
* Checks if a height change is significant enough to warrant marking an item as dirty
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Checks if a height change is significant enough to warrant marking an item as dirty.
|
|
10
|
+
*
|
|
11
|
+
* This function compares the new measured height against the cached height for an item
|
|
12
|
+
* and determines if the difference exceeds the specified margin of error. Items with
|
|
13
|
+
* no previous measurement are always considered significant.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} itemIndex - The index of the item in the virtual list.
|
|
16
|
+
* @param {number} newHeight - The new measured height in pixels.
|
|
17
|
+
* @param {Record<number, number>} heightCache - Cache of previously measured item heights.
|
|
18
|
+
* @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
|
|
19
|
+
* smaller than this value are considered insignificant.
|
|
20
|
+
* @returns {boolean} Returns true if the height change exceeds the margin of error
|
|
21
|
+
* or if this is the first measurement for the item.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const heightCache = { 0: 40, 1: 50 };
|
|
26
|
+
*
|
|
27
|
+
* // First-time measurement (no cache entry)
|
|
28
|
+
* isSignificantHeightChange(2, 45, heightCache); // true
|
|
29
|
+
*
|
|
30
|
+
* // Significant change (exceeds 1px threshold)
|
|
31
|
+
* isSignificantHeightChange(0, 45, heightCache); // true
|
|
32
|
+
*
|
|
33
|
+
* // Insignificant change (within 1px threshold)
|
|
34
|
+
* isSignificantHeightChange(0, 40.5, heightCache); // false
|
|
35
|
+
* ```
|
|
11
36
|
*/
|
|
12
37
|
export declare const isSignificantHeightChange: (itemIndex: number, newHeight: number, heightCache: Record<number, number>, marginOfError?: number) => boolean;
|
|
@@ -1,13 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Utility functions for detecting significant height changes in virtual list items
|
|
2
|
+
* Utility functions for detecting significant height changes in virtual list items.
|
|
3
|
+
*
|
|
4
|
+
* @fileoverview Provides height change detection utilities for virtual list optimization.
|
|
5
|
+
* These functions help determine when item height changes are significant enough to
|
|
6
|
+
* trigger recalculations, preventing unnecessary updates for sub-pixel variations.
|
|
3
7
|
*/
|
|
4
8
|
/**
|
|
5
|
-
* Checks if a height change is significant enough to warrant marking an item as dirty
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Checks if a height change is significant enough to warrant marking an item as dirty.
|
|
10
|
+
*
|
|
11
|
+
* This function compares the new measured height against the cached height for an item
|
|
12
|
+
* and determines if the difference exceeds the specified margin of error. Items with
|
|
13
|
+
* no previous measurement are always considered significant.
|
|
14
|
+
*
|
|
15
|
+
* @param {number} itemIndex - The index of the item in the virtual list.
|
|
16
|
+
* @param {number} newHeight - The new measured height in pixels.
|
|
17
|
+
* @param {Record<number, number>} heightCache - Cache of previously measured item heights.
|
|
18
|
+
* @param {number} [marginOfError=1] - Height difference threshold in pixels. Changes
|
|
19
|
+
* smaller than this value are considered insignificant.
|
|
20
|
+
* @returns {boolean} Returns true if the height change exceeds the margin of error
|
|
21
|
+
* or if this is the first measurement for the item.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```typescript
|
|
25
|
+
* const heightCache = { 0: 40, 1: 50 };
|
|
26
|
+
*
|
|
27
|
+
* // First-time measurement (no cache entry)
|
|
28
|
+
* isSignificantHeightChange(2, 45, heightCache); // true
|
|
29
|
+
*
|
|
30
|
+
* // Significant change (exceeds 1px threshold)
|
|
31
|
+
* isSignificantHeightChange(0, 45, heightCache); // true
|
|
32
|
+
*
|
|
33
|
+
* // Insignificant change (within 1px threshold)
|
|
34
|
+
* isSignificantHeightChange(0, 40.5, heightCache); // false
|
|
35
|
+
* ```
|
|
11
36
|
*/
|
|
12
37
|
export const isSignificantHeightChange = (itemIndex, newHeight, heightCache, marginOfError = 1) => {
|
|
13
38
|
const previousHeight = heightCache[itemIndex];
|
|
@@ -1,4 +1,39 @@
|
|
|
1
1
|
import type { SvelteVirtualListMode, SvelteVirtualListScrollAlign } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calculates the scroll target for aligning an item to a specific edge.
|
|
4
|
+
*
|
|
5
|
+
* This helper consolidates the shared alignment logic between bottomToTop
|
|
6
|
+
* and topToBottom scroll calculations, reducing code duplication.
|
|
7
|
+
*
|
|
8
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
9
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
10
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
11
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
12
|
+
* @param {'top' | 'bottom' | 'nearest'} align - The alignment mode
|
|
13
|
+
* @returns {number | null} The scroll target position, or null if item is already visible (for 'nearest')
|
|
14
|
+
*/
|
|
15
|
+
export declare const alignToEdge: (itemTop: number, itemBottom: number, scrollTop: number, viewportHeight: number, align: "top" | "bottom" | "nearest") => number | null;
|
|
16
|
+
/**
|
|
17
|
+
* Calculates the scroll target for aligning a visible item to its nearest edge.
|
|
18
|
+
*
|
|
19
|
+
* Unlike alignToEdge with 'nearest', this always returns a scroll position
|
|
20
|
+
* even when the item is visible. Used for 'auto' alignment mode when item
|
|
21
|
+
* is within the visible range.
|
|
22
|
+
*
|
|
23
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
24
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
25
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
26
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
27
|
+
* @returns {number} The scroll target position aligned to nearest edge
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* // For a visible item, align to whichever edge is closer
|
|
32
|
+
* const scrollTarget = alignVisibleToNearestEdge(400, 450, 200, 400)
|
|
33
|
+
* viewportElement.scrollTo({ top: scrollTarget })
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export declare const alignVisibleToNearestEdge: (itemTop: number, itemBottom: number, scrollTop: number, viewportHeight: number) => number;
|
|
2
37
|
/**
|
|
3
38
|
* Parameters for calculating scroll target position
|
|
4
39
|
*/
|
|
@@ -1,4 +1,66 @@
|
|
|
1
|
-
import { getScrollOffsetForIndex } from './virtualList.js';
|
|
1
|
+
import { clampValue, getScrollOffsetForIndex } from './virtualList.js';
|
|
2
|
+
/**
|
|
3
|
+
* Calculates the scroll target for aligning an item to a specific edge.
|
|
4
|
+
*
|
|
5
|
+
* This helper consolidates the shared alignment logic between bottomToTop
|
|
6
|
+
* and topToBottom scroll calculations, reducing code duplication.
|
|
7
|
+
*
|
|
8
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
9
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
10
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
11
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
12
|
+
* @param {'top' | 'bottom' | 'nearest'} align - The alignment mode
|
|
13
|
+
* @returns {number | null} The scroll target position, or null if item is already visible (for 'nearest')
|
|
14
|
+
*/
|
|
15
|
+
export const alignToEdge = (itemTop, itemBottom, scrollTop, viewportHeight, align) => {
|
|
16
|
+
if (align === 'top') {
|
|
17
|
+
return itemTop;
|
|
18
|
+
}
|
|
19
|
+
if (align === 'bottom') {
|
|
20
|
+
return clampValue(itemBottom - viewportHeight, 0, Infinity);
|
|
21
|
+
}
|
|
22
|
+
// 'nearest' alignment
|
|
23
|
+
const viewportBottom = scrollTop + viewportHeight;
|
|
24
|
+
const isVisible = itemTop < viewportBottom && itemBottom > scrollTop;
|
|
25
|
+
if (isVisible) {
|
|
26
|
+
// Already visible, no scroll needed
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
// Not visible - align to nearest edge
|
|
30
|
+
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
31
|
+
const distanceToBottom = Math.abs(viewportBottom - itemBottom);
|
|
32
|
+
return distanceToTop < distanceToBottom
|
|
33
|
+
? itemTop
|
|
34
|
+
: clampValue(itemBottom - viewportHeight, 0, Infinity);
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Calculates the scroll target for aligning a visible item to its nearest edge.
|
|
38
|
+
*
|
|
39
|
+
* Unlike alignToEdge with 'nearest', this always returns a scroll position
|
|
40
|
+
* even when the item is visible. Used for 'auto' alignment mode when item
|
|
41
|
+
* is within the visible range.
|
|
42
|
+
*
|
|
43
|
+
* @param {number} itemTop - The top position of the item in pixels
|
|
44
|
+
* @param {number} itemBottom - The bottom position of the item in pixels
|
|
45
|
+
* @param {number} scrollTop - Current scroll position in pixels
|
|
46
|
+
* @param {number} viewportHeight - Height of the viewport in pixels
|
|
47
|
+
* @returns {number} The scroll target position aligned to nearest edge
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```typescript
|
|
51
|
+
* // For a visible item, align to whichever edge is closer
|
|
52
|
+
* const scrollTarget = alignVisibleToNearestEdge(400, 450, 200, 400)
|
|
53
|
+
* viewportElement.scrollTo({ top: scrollTarget })
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export const alignVisibleToNearestEdge = (itemTop, itemBottom, scrollTop, viewportHeight) => {
|
|
57
|
+
const viewportBottom = scrollTop + viewportHeight;
|
|
58
|
+
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
59
|
+
const distanceToBottom = Math.abs(viewportBottom - itemBottom);
|
|
60
|
+
return distanceToTop < distanceToBottom
|
|
61
|
+
? itemTop
|
|
62
|
+
: clampValue(itemBottom - viewportHeight, 0, Infinity);
|
|
63
|
+
};
|
|
2
64
|
/**
|
|
3
65
|
* Calculates the target scroll position for scrolling to a specific item index.
|
|
4
66
|
*
|
|
@@ -58,7 +120,15 @@ export const calculateScrollTarget = (params) => {
|
|
|
58
120
|
}
|
|
59
121
|
};
|
|
60
122
|
/**
|
|
61
|
-
* Calculates
|
|
123
|
+
* Calculates the target scroll position for bottom-to-top mode.
|
|
124
|
+
*
|
|
125
|
+
* In bottom-to-top mode, items are rendered from the bottom of the viewport upward,
|
|
126
|
+
* which requires different scroll calculations than the standard top-to-bottom mode.
|
|
127
|
+
* This function handles the coordinate system translation and alignment logic.
|
|
128
|
+
*
|
|
129
|
+
* @param {BottomToTopScrollParams} params - Parameters for scroll calculation.
|
|
130
|
+
* @returns {number | null} The target scroll position in pixels, or null if no
|
|
131
|
+
* scroll is needed (item already visible with 'nearest' alignment).
|
|
62
132
|
*/
|
|
63
133
|
const calculateBottomToTopScrollTarget = (params) => {
|
|
64
134
|
const { align, targetIndex, itemsLength, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
|
|
@@ -66,102 +136,60 @@ const calculateBottomToTopScrollTarget = (params) => {
|
|
|
66
136
|
const totalHeight = getScrollOffsetForIndex(heightCache, calculatedItemHeight, itemsLength);
|
|
67
137
|
const itemOffset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
68
138
|
const itemHeight = calculatedItemHeight;
|
|
139
|
+
// Calculate item boundaries in bottomToTop coordinate space
|
|
140
|
+
const itemTop = totalHeight - (itemOffset + itemHeight);
|
|
141
|
+
const itemBottom = totalHeight - itemOffset;
|
|
69
142
|
if (align === 'auto') {
|
|
70
143
|
// If item is above the viewport, align to top
|
|
71
144
|
if (targetIndex < firstVisibleIndex) {
|
|
72
|
-
return
|
|
145
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
|
|
73
146
|
}
|
|
74
147
|
else if (targetIndex > lastVisibleIndex - 1) {
|
|
75
148
|
// In bottomToTop, "below" means higher indices that need HIGHER scrollTop
|
|
76
|
-
return
|
|
149
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
|
|
77
150
|
}
|
|
78
151
|
else {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
82
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
83
|
-
return distanceToTop < distanceToBottom ? itemTop : Math.max(0, itemBottom - height);
|
|
152
|
+
// Item is visible - align to nearest edge (always returns a value)
|
|
153
|
+
return alignVisibleToNearestEdge(itemTop, itemBottom, scrollTop, height);
|
|
84
154
|
}
|
|
85
155
|
}
|
|
86
|
-
|
|
87
|
-
return
|
|
88
|
-
}
|
|
89
|
-
else if (align === 'bottom') {
|
|
90
|
-
return Math.max(0, totalHeight - itemOffset - height);
|
|
91
|
-
}
|
|
92
|
-
else if (align === 'nearest') {
|
|
93
|
-
const itemTop = totalHeight - (itemOffset + itemHeight);
|
|
94
|
-
const itemBottom = totalHeight - itemOffset;
|
|
95
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
96
|
-
// Not visible, align to nearest edge
|
|
97
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
98
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
99
|
-
return distanceToTop < distanceToBottom ? itemTop : Math.max(0, itemBottom - height);
|
|
100
|
-
}
|
|
101
|
-
else {
|
|
102
|
-
// Already visible, do nothing
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
156
|
+
if (align === 'top' || align === 'bottom' || align === 'nearest') {
|
|
157
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
|
|
105
158
|
}
|
|
106
159
|
return null;
|
|
107
160
|
};
|
|
108
161
|
/**
|
|
109
|
-
* Calculates
|
|
162
|
+
* Calculates the target scroll position for top-to-bottom mode.
|
|
163
|
+
*
|
|
164
|
+
* This is the standard scroll mode where items are rendered from the top of the
|
|
165
|
+
* viewport downward. The function calculates the optimal scroll position based
|
|
166
|
+
* on the alignment option and current viewport state.
|
|
167
|
+
*
|
|
168
|
+
* @param {TopToBottomScrollParams} params - Parameters for scroll calculation.
|
|
169
|
+
* @returns {number | null} The target scroll position in pixels, or null if no
|
|
170
|
+
* scroll is needed (item already visible with 'nearest' alignment).
|
|
110
171
|
*/
|
|
111
172
|
const calculateTopToBottomScrollTarget = (params) => {
|
|
112
173
|
const { align, targetIndex, calculatedItemHeight, height, scrollTop, firstVisibleIndex, lastVisibleIndex, heightCache } = params;
|
|
174
|
+
// Calculate item boundaries
|
|
175
|
+
const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
176
|
+
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
113
177
|
if (align === 'auto') {
|
|
114
178
|
// If item is above the viewport, align to top
|
|
115
179
|
if (targetIndex < firstVisibleIndex) {
|
|
116
|
-
|
|
117
|
-
return scrollTarget;
|
|
180
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
|
|
118
181
|
}
|
|
119
182
|
// If item is below the viewport, align to bottom
|
|
120
183
|
else if (targetIndex > lastVisibleIndex - 1) {
|
|
121
|
-
|
|
122
|
-
const scrollTarget = Math.max(0, itemBottom - height);
|
|
123
|
-
return scrollTarget;
|
|
184
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
|
|
124
185
|
}
|
|
125
186
|
else {
|
|
126
|
-
// Item is visible
|
|
127
|
-
|
|
128
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
129
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
130
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
131
|
-
if (distanceToTop < distanceToBottom) {
|
|
132
|
-
return itemTop;
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
return Math.max(0, itemBottom - height);
|
|
136
|
-
}
|
|
187
|
+
// Item is visible - align to nearest edge (always returns a value)
|
|
188
|
+
return alignVisibleToNearestEdge(itemTop, itemBottom, scrollTop, height);
|
|
137
189
|
}
|
|
138
190
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
return scrollTarget;
|
|
142
|
-
}
|
|
143
|
-
else if (align === 'bottom') {
|
|
144
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
145
|
-
return Math.max(0, itemBottom - height);
|
|
146
|
-
}
|
|
147
|
-
else if (align === 'nearest') {
|
|
148
|
-
const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
149
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
150
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
151
|
-
// Not visible, align to nearest edge
|
|
152
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
153
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
154
|
-
if (distanceToTop < distanceToBottom) {
|
|
155
|
-
return itemTop;
|
|
156
|
-
}
|
|
157
|
-
else {
|
|
158
|
-
return Math.max(0, itemBottom - height);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
else {
|
|
162
|
-
// Already visible, do nothing
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
191
|
+
if (align === 'top' || align === 'bottom' || align === 'nearest') {
|
|
192
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
|
|
165
193
|
}
|
|
166
194
|
return null;
|
|
167
195
|
};
|