@humanspeak/svelte-virtual-list 0.3.8 → 0.3.10
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/dist/SvelteVirtualList.svelte +174 -128
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +27 -0
- package/dist/reactive-list-manager/ReactiveListManager.svelte.js +72 -0
- package/dist/utils/perfMetrics.d.ts +114 -0
- package/dist/utils/perfMetrics.js +252 -0
- package/dist/utils/scrollCalculation.d.ts +35 -0
- package/dist/utils/scrollCalculation.js +81 -69
- package/dist/utils/virtualList.d.ts +36 -0
- package/dist/utils/virtualList.js +40 -13
- package/package.json +9 -9
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance metrics utility for profiling virtual list operations.
|
|
3
|
+
*
|
|
4
|
+
* This lightweight profiling wrapper measures execution times for critical operations
|
|
5
|
+
* in the virtual list component. Enable by setting the environment variable:
|
|
6
|
+
* PUBLIC_SVELTE_VIRTUAL_LIST_PERF=true
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { perfMetrics, measureSync, measureAsync, isPerfEnabled } from './perfMetrics.js'
|
|
11
|
+
*
|
|
12
|
+
* // Measure synchronous operation
|
|
13
|
+
* const result = measureSync('visibleRange', () => calculateVisibleRange(...))
|
|
14
|
+
*
|
|
15
|
+
* // Get aggregated stats
|
|
16
|
+
* const stats = perfMetrics.getStats()
|
|
17
|
+
* console.log(stats.visibleRange.avg, stats.visibleRange.max)
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export type MetricName = 'scrollHandler' | 'visibleRange' | 'transformY' | 'heightBatch' | 'frameTime' | 'displayItems' | 'resizeObserver' | 'initialRender';
|
|
21
|
+
export interface MetricEntry {
|
|
22
|
+
timestamp: number;
|
|
23
|
+
duration: number;
|
|
24
|
+
}
|
|
25
|
+
export interface MetricStats {
|
|
26
|
+
count: number;
|
|
27
|
+
total: number;
|
|
28
|
+
avg: number;
|
|
29
|
+
min: number;
|
|
30
|
+
max: number;
|
|
31
|
+
recent: number[];
|
|
32
|
+
}
|
|
33
|
+
export interface PerfMetrics {
|
|
34
|
+
scrollHandler: MetricEntry[];
|
|
35
|
+
visibleRange: MetricEntry[];
|
|
36
|
+
transformY: MetricEntry[];
|
|
37
|
+
heightBatch: MetricEntry[];
|
|
38
|
+
frameTime: MetricEntry[];
|
|
39
|
+
displayItems: MetricEntry[];
|
|
40
|
+
resizeObserver: MetricEntry[];
|
|
41
|
+
initialRender: MetricEntry[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Check if performance profiling is enabled via environment variable
|
|
45
|
+
*/
|
|
46
|
+
export declare const isPerfEnabled: () => boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Measure a synchronous function's execution time
|
|
49
|
+
*/
|
|
50
|
+
export declare const measureSync: <T>(name: MetricName, fn: () => T) => T;
|
|
51
|
+
/**
|
|
52
|
+
* Measure an async function's execution time
|
|
53
|
+
*/
|
|
54
|
+
export declare const measureAsync: <T>(name: MetricName, fn: () => Promise<T>) => Promise<T>;
|
|
55
|
+
/**
|
|
56
|
+
* Start a manual timing measurement (for operations that span callbacks)
|
|
57
|
+
*/
|
|
58
|
+
export declare const startMeasure: () => (() => number);
|
|
59
|
+
/**
|
|
60
|
+
* Record a pre-calculated duration
|
|
61
|
+
*/
|
|
62
|
+
export declare const recordDuration: (name: MetricName, duration: number) => void;
|
|
63
|
+
/**
|
|
64
|
+
* Start FPS tracking during scroll
|
|
65
|
+
*/
|
|
66
|
+
export declare const startFpsTracking: () => void;
|
|
67
|
+
/**
|
|
68
|
+
* Stop FPS tracking and return average FPS
|
|
69
|
+
*/
|
|
70
|
+
export declare const stopFpsTracking: () => number;
|
|
71
|
+
/**
|
|
72
|
+
* Get current FPS (call during active tracking)
|
|
73
|
+
*/
|
|
74
|
+
export declare const getCurrentFps: () => number;
|
|
75
|
+
/**
|
|
76
|
+
* Performance metrics interface with stats aggregation
|
|
77
|
+
*/
|
|
78
|
+
export declare const perfMetrics: {
|
|
79
|
+
/**
|
|
80
|
+
* Get aggregated stats for all metrics
|
|
81
|
+
*/
|
|
82
|
+
getStats: () => Record<MetricName, MetricStats>;
|
|
83
|
+
/**
|
|
84
|
+
* Get stats for a single metric
|
|
85
|
+
*/
|
|
86
|
+
getMetricStats: (name: MetricName) => MetricStats;
|
|
87
|
+
/**
|
|
88
|
+
* Get raw metric entries
|
|
89
|
+
*/
|
|
90
|
+
getRawMetrics: () => PerfMetrics;
|
|
91
|
+
/**
|
|
92
|
+
* Reset all metrics
|
|
93
|
+
*/
|
|
94
|
+
reset: () => void;
|
|
95
|
+
/**
|
|
96
|
+
* Get a summary report suitable for console output
|
|
97
|
+
*/
|
|
98
|
+
getSummary: () => string;
|
|
99
|
+
/**
|
|
100
|
+
* Log summary to console
|
|
101
|
+
*/
|
|
102
|
+
logSummary: () => void;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Memory tracking utilities
|
|
106
|
+
*/
|
|
107
|
+
export declare const getMemoryUsage: () => {
|
|
108
|
+
usedJSHeapSize: number;
|
|
109
|
+
totalJSHeapSize: number;
|
|
110
|
+
} | null;
|
|
111
|
+
/**
|
|
112
|
+
* Helper to format bytes to human-readable size
|
|
113
|
+
*/
|
|
114
|
+
export declare const formatBytes: (bytes: number) => string;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance metrics utility for profiling virtual list operations.
|
|
3
|
+
*
|
|
4
|
+
* This lightweight profiling wrapper measures execution times for critical operations
|
|
5
|
+
* in the virtual list component. Enable by setting the environment variable:
|
|
6
|
+
* PUBLIC_SVELTE_VIRTUAL_LIST_PERF=true
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* import { perfMetrics, measureSync, measureAsync, isPerfEnabled } from './perfMetrics.js'
|
|
11
|
+
*
|
|
12
|
+
* // Measure synchronous operation
|
|
13
|
+
* const result = measureSync('visibleRange', () => calculateVisibleRange(...))
|
|
14
|
+
*
|
|
15
|
+
* // Get aggregated stats
|
|
16
|
+
* const stats = perfMetrics.getStats()
|
|
17
|
+
* console.log(stats.visibleRange.avg, stats.visibleRange.max)
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
const MAX_SAMPLES = 1000; // Keep last N samples per metric
|
|
21
|
+
const RECENT_WINDOW = 100; // Number of recent samples for stats
|
|
22
|
+
/**
|
|
23
|
+
* Check if performance profiling is enabled via environment variable
|
|
24
|
+
*/
|
|
25
|
+
export const isPerfEnabled = () => {
|
|
26
|
+
if (typeof process === 'undefined')
|
|
27
|
+
return false;
|
|
28
|
+
return (process?.env?.PUBLIC_SVELTE_VIRTUAL_LIST_PERF === 'true' ||
|
|
29
|
+
process?.env?.SVELTE_VIRTUAL_LIST_PERF === 'true');
|
|
30
|
+
};
|
|
31
|
+
const createEmptyMetrics = () => ({
|
|
32
|
+
scrollHandler: [],
|
|
33
|
+
visibleRange: [],
|
|
34
|
+
transformY: [],
|
|
35
|
+
heightBatch: [],
|
|
36
|
+
frameTime: [],
|
|
37
|
+
displayItems: [],
|
|
38
|
+
resizeObserver: [],
|
|
39
|
+
initialRender: []
|
|
40
|
+
});
|
|
41
|
+
let metrics = createEmptyMetrics();
|
|
42
|
+
/**
|
|
43
|
+
* Record a metric measurement
|
|
44
|
+
*/
|
|
45
|
+
const record = (name, duration) => {
|
|
46
|
+
if (!isPerfEnabled())
|
|
47
|
+
return;
|
|
48
|
+
const entry = {
|
|
49
|
+
timestamp: performance.now(),
|
|
50
|
+
duration
|
|
51
|
+
};
|
|
52
|
+
metrics[name].push(entry);
|
|
53
|
+
// Keep array bounded
|
|
54
|
+
if (metrics[name].length > MAX_SAMPLES) {
|
|
55
|
+
metrics[name] = metrics[name].slice(-MAX_SAMPLES);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Measure a synchronous function's execution time
|
|
60
|
+
*/
|
|
61
|
+
export const measureSync = (name, fn) => {
|
|
62
|
+
if (!isPerfEnabled()) {
|
|
63
|
+
return fn();
|
|
64
|
+
}
|
|
65
|
+
const start = performance.now();
|
|
66
|
+
try {
|
|
67
|
+
return fn();
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
const duration = performance.now() - start;
|
|
71
|
+
record(name, duration);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Measure an async function's execution time
|
|
76
|
+
*/
|
|
77
|
+
export const measureAsync = async (name, fn) => {
|
|
78
|
+
if (!isPerfEnabled()) {
|
|
79
|
+
return fn();
|
|
80
|
+
}
|
|
81
|
+
const start = performance.now();
|
|
82
|
+
try {
|
|
83
|
+
return await fn();
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
const duration = performance.now() - start;
|
|
87
|
+
record(name, duration);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Start a manual timing measurement (for operations that span callbacks)
|
|
92
|
+
*/
|
|
93
|
+
export const startMeasure = () => {
|
|
94
|
+
const start = performance.now();
|
|
95
|
+
return () => performance.now() - start;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Record a pre-calculated duration
|
|
99
|
+
*/
|
|
100
|
+
export const recordDuration = (name, duration) => {
|
|
101
|
+
record(name, duration);
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Calculate stats for a single metric
|
|
105
|
+
*/
|
|
106
|
+
const calculateStats = (entries) => {
|
|
107
|
+
if (entries.length === 0) {
|
|
108
|
+
return { count: 0, total: 0, avg: 0, min: 0, max: 0, recent: [] };
|
|
109
|
+
}
|
|
110
|
+
const durations = entries.map((e) => e.duration);
|
|
111
|
+
const total = durations.reduce((a, b) => a + b, 0);
|
|
112
|
+
const recent = durations.slice(-RECENT_WINDOW);
|
|
113
|
+
return {
|
|
114
|
+
count: entries.length,
|
|
115
|
+
total,
|
|
116
|
+
avg: total / entries.length,
|
|
117
|
+
min: Math.min(...durations),
|
|
118
|
+
max: Math.max(...durations),
|
|
119
|
+
recent
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* Frame rate tracking for scroll performance
|
|
124
|
+
*/
|
|
125
|
+
let frameTimestamps = [];
|
|
126
|
+
let rafId = null;
|
|
127
|
+
/**
|
|
128
|
+
* Start FPS tracking during scroll
|
|
129
|
+
*/
|
|
130
|
+
export const startFpsTracking = () => {
|
|
131
|
+
if (!isPerfEnabled())
|
|
132
|
+
return;
|
|
133
|
+
if (rafId !== null)
|
|
134
|
+
return;
|
|
135
|
+
frameTimestamps = [];
|
|
136
|
+
const trackFrame = () => {
|
|
137
|
+
frameTimestamps.push(performance.now());
|
|
138
|
+
// Keep only last 2 seconds of frames
|
|
139
|
+
const cutoff = performance.now() - 2000;
|
|
140
|
+
frameTimestamps = frameTimestamps.filter((t) => t > cutoff);
|
|
141
|
+
rafId = requestAnimationFrame(trackFrame);
|
|
142
|
+
};
|
|
143
|
+
rafId = requestAnimationFrame(trackFrame);
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Stop FPS tracking and return average FPS
|
|
147
|
+
*/
|
|
148
|
+
export const stopFpsTracking = () => {
|
|
149
|
+
if (rafId !== null) {
|
|
150
|
+
cancelAnimationFrame(rafId);
|
|
151
|
+
rafId = null;
|
|
152
|
+
}
|
|
153
|
+
if (frameTimestamps.length < 2)
|
|
154
|
+
return 0;
|
|
155
|
+
const duration = frameTimestamps[frameTimestamps.length - 1] - frameTimestamps[0];
|
|
156
|
+
if (duration <= 0)
|
|
157
|
+
return 0;
|
|
158
|
+
return (frameTimestamps.length - 1) / (duration / 1000);
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Get current FPS (call during active tracking)
|
|
162
|
+
*/
|
|
163
|
+
export const getCurrentFps = () => {
|
|
164
|
+
if (frameTimestamps.length < 2)
|
|
165
|
+
return 0;
|
|
166
|
+
const now = performance.now();
|
|
167
|
+
const recentFrames = frameTimestamps.filter((t) => now - t < 1000);
|
|
168
|
+
if (recentFrames.length < 2)
|
|
169
|
+
return 0;
|
|
170
|
+
const duration = recentFrames[recentFrames.length - 1] - recentFrames[0];
|
|
171
|
+
if (duration <= 0)
|
|
172
|
+
return 0;
|
|
173
|
+
return (recentFrames.length - 1) / (duration / 1000);
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Performance metrics interface with stats aggregation
|
|
177
|
+
*/
|
|
178
|
+
export const perfMetrics = {
|
|
179
|
+
/**
|
|
180
|
+
* Get aggregated stats for all metrics
|
|
181
|
+
*/
|
|
182
|
+
getStats: () => ({
|
|
183
|
+
scrollHandler: calculateStats(metrics.scrollHandler),
|
|
184
|
+
visibleRange: calculateStats(metrics.visibleRange),
|
|
185
|
+
transformY: calculateStats(metrics.transformY),
|
|
186
|
+
heightBatch: calculateStats(metrics.heightBatch),
|
|
187
|
+
frameTime: calculateStats(metrics.frameTime),
|
|
188
|
+
displayItems: calculateStats(metrics.displayItems),
|
|
189
|
+
resizeObserver: calculateStats(metrics.resizeObserver),
|
|
190
|
+
initialRender: calculateStats(metrics.initialRender)
|
|
191
|
+
}),
|
|
192
|
+
/**
|
|
193
|
+
* Get stats for a single metric
|
|
194
|
+
*/
|
|
195
|
+
getMetricStats: (name) => calculateStats(metrics[name]),
|
|
196
|
+
/**
|
|
197
|
+
* Get raw metric entries
|
|
198
|
+
*/
|
|
199
|
+
getRawMetrics: () => ({ ...metrics }),
|
|
200
|
+
/**
|
|
201
|
+
* Reset all metrics
|
|
202
|
+
*/
|
|
203
|
+
reset: () => {
|
|
204
|
+
metrics = createEmptyMetrics();
|
|
205
|
+
},
|
|
206
|
+
/**
|
|
207
|
+
* Get a summary report suitable for console output
|
|
208
|
+
*/
|
|
209
|
+
getSummary: () => {
|
|
210
|
+
const stats = perfMetrics.getStats();
|
|
211
|
+
const lines = ['=== Virtual List Performance Summary ==='];
|
|
212
|
+
for (const [name, stat] of Object.entries(stats)) {
|
|
213
|
+
if (stat.count > 0) {
|
|
214
|
+
lines.push(`${name}: avg=${stat.avg.toFixed(2)}ms, max=${stat.max.toFixed(2)}ms, count=${stat.count}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return lines.join('\n');
|
|
218
|
+
},
|
|
219
|
+
/**
|
|
220
|
+
* Log summary to console
|
|
221
|
+
*/
|
|
222
|
+
logSummary: () => {
|
|
223
|
+
if (isPerfEnabled()) {
|
|
224
|
+
console.info(perfMetrics.getSummary());
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
/**
|
|
229
|
+
* Memory tracking utilities
|
|
230
|
+
*/
|
|
231
|
+
export const getMemoryUsage = () => {
|
|
232
|
+
if (typeof performance !== 'undefined' &&
|
|
233
|
+
'memory' in performance &&
|
|
234
|
+
performance.memory) {
|
|
235
|
+
const memory = performance.memory;
|
|
236
|
+
return {
|
|
237
|
+
usedJSHeapSize: memory.usedJSHeapSize,
|
|
238
|
+
totalJSHeapSize: memory.totalJSHeapSize
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
};
|
|
243
|
+
/**
|
|
244
|
+
* Helper to format bytes to human-readable size
|
|
245
|
+
*/
|
|
246
|
+
export const formatBytes = (bytes) => {
|
|
247
|
+
if (bytes < 1024)
|
|
248
|
+
return `${bytes} B`;
|
|
249
|
+
if (bytes < 1024 * 1024)
|
|
250
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
251
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
252
|
+
};
|
|
@@ -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
|
*
|
|
@@ -74,42 +136,25 @@ const calculateBottomToTopScrollTarget = (params) => {
|
|
|
74
136
|
const totalHeight = getScrollOffsetForIndex(heightCache, calculatedItemHeight, itemsLength);
|
|
75
137
|
const itemOffset = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
76
138
|
const itemHeight = calculatedItemHeight;
|
|
139
|
+
// Calculate item boundaries in bottomToTop coordinate space
|
|
140
|
+
const itemTop = totalHeight - (itemOffset + itemHeight);
|
|
141
|
+
const itemBottom = totalHeight - itemOffset;
|
|
77
142
|
if (align === 'auto') {
|
|
78
143
|
// If item is above the viewport, align to top
|
|
79
144
|
if (targetIndex < firstVisibleIndex) {
|
|
80
|
-
return
|
|
145
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
|
|
81
146
|
}
|
|
82
147
|
else if (targetIndex > lastVisibleIndex - 1) {
|
|
83
148
|
// In bottomToTop, "below" means higher indices that need HIGHER scrollTop
|
|
84
|
-
return
|
|
149
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
|
|
85
150
|
}
|
|
86
151
|
else {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
90
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
91
|
-
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);
|
|
92
154
|
}
|
|
93
155
|
}
|
|
94
|
-
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
else if (align === 'bottom') {
|
|
98
|
-
return Math.max(0, totalHeight - itemOffset - height);
|
|
99
|
-
}
|
|
100
|
-
else if (align === 'nearest') {
|
|
101
|
-
const itemTop = totalHeight - (itemOffset + itemHeight);
|
|
102
|
-
const itemBottom = totalHeight - itemOffset;
|
|
103
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
104
|
-
// Not visible, align to nearest edge
|
|
105
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
106
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
107
|
-
return distanceToTop < distanceToBottom ? itemTop : Math.max(0, itemBottom - height);
|
|
108
|
-
}
|
|
109
|
-
else {
|
|
110
|
-
// Already visible, do nothing
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
156
|
+
if (align === 'top' || align === 'bottom' || align === 'nearest') {
|
|
157
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
|
|
113
158
|
}
|
|
114
159
|
return null;
|
|
115
160
|
};
|
|
@@ -126,58 +171,25 @@ const calculateBottomToTopScrollTarget = (params) => {
|
|
|
126
171
|
*/
|
|
127
172
|
const calculateTopToBottomScrollTarget = (params) => {
|
|
128
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);
|
|
129
177
|
if (align === 'auto') {
|
|
130
178
|
// If item is above the viewport, align to top
|
|
131
179
|
if (targetIndex < firstVisibleIndex) {
|
|
132
|
-
|
|
133
|
-
return scrollTarget;
|
|
180
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'top');
|
|
134
181
|
}
|
|
135
182
|
// If item is below the viewport, align to bottom
|
|
136
183
|
else if (targetIndex > lastVisibleIndex - 1) {
|
|
137
|
-
|
|
138
|
-
const scrollTarget = Math.max(0, itemBottom - height);
|
|
139
|
-
return scrollTarget;
|
|
184
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, 'bottom');
|
|
140
185
|
}
|
|
141
186
|
else {
|
|
142
|
-
// Item is visible
|
|
143
|
-
|
|
144
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
145
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
146
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
147
|
-
if (distanceToTop < distanceToBottom) {
|
|
148
|
-
return itemTop;
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
return Math.max(0, itemBottom - height);
|
|
152
|
-
}
|
|
187
|
+
// Item is visible - align to nearest edge (always returns a value)
|
|
188
|
+
return alignVisibleToNearestEdge(itemTop, itemBottom, scrollTop, height);
|
|
153
189
|
}
|
|
154
190
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
return scrollTarget;
|
|
158
|
-
}
|
|
159
|
-
else if (align === 'bottom') {
|
|
160
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
161
|
-
return Math.max(0, itemBottom - height);
|
|
162
|
-
}
|
|
163
|
-
else if (align === 'nearest') {
|
|
164
|
-
const itemTop = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex);
|
|
165
|
-
const itemBottom = getScrollOffsetForIndex(heightCache, calculatedItemHeight, targetIndex + 1);
|
|
166
|
-
if (itemBottom <= scrollTop || itemTop >= scrollTop + height) {
|
|
167
|
-
// Not visible, align to nearest edge
|
|
168
|
-
const distanceToTop = Math.abs(scrollTop - itemTop);
|
|
169
|
-
const distanceToBottom = Math.abs(scrollTop + height - itemBottom);
|
|
170
|
-
if (distanceToTop < distanceToBottom) {
|
|
171
|
-
return itemTop;
|
|
172
|
-
}
|
|
173
|
-
else {
|
|
174
|
-
return Math.max(0, itemBottom - height);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
// Already visible, do nothing
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
191
|
+
if (align === 'top' || align === 'bottom' || align === 'nearest') {
|
|
192
|
+
return alignToEdge(itemTop, itemBottom, scrollTop, height, align);
|
|
181
193
|
}
|
|
182
194
|
return null;
|
|
183
195
|
};
|
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
import type { SvelteVirtualListMode, SvelteVirtualListPreviousVisibleRange } from '../types.js';
|
|
2
2
|
import type { VirtualListSetters, VirtualListState } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Validates a height value and returns it if valid, otherwise returns the fallback.
|
|
5
|
+
*
|
|
6
|
+
* A height is considered valid if it is a finite number greater than 0.
|
|
7
|
+
* This utility consolidates the repeated pattern of height validation
|
|
8
|
+
* found throughout the virtual list codebase.
|
|
9
|
+
*
|
|
10
|
+
* @param {unknown} height - The height value to validate
|
|
11
|
+
* @param {number} fallback - The fallback value to use if height is invalid
|
|
12
|
+
* @returns {number} The validated height or the fallback value
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* const height = getValidHeight(heightCache[i], calculatedItemHeight)
|
|
17
|
+
* // Returns heightCache[i] if valid, otherwise calculatedItemHeight
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare const getValidHeight: (height: unknown, fallback: number) => number;
|
|
21
|
+
/**
|
|
22
|
+
* Clamps a numeric value to be within a specified range.
|
|
23
|
+
*
|
|
24
|
+
* This utility consolidates the repeated `Math.max(min, Math.min(max, value))`
|
|
25
|
+
* pattern used throughout scroll calculations and positioning logic.
|
|
26
|
+
*
|
|
27
|
+
* @param {number} value - The value to clamp
|
|
28
|
+
* @param {number} min - The minimum allowed value
|
|
29
|
+
* @param {number} max - The maximum allowed value
|
|
30
|
+
* @returns {number} The clamped value
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const scrollTop = clampValue(targetScrollTop, 0, maxScrollTop)
|
|
35
|
+
* // Ensures scrollTop is between 0 and maxScrollTop
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare const clampValue: (value: number, min: number, max: number) => number;
|
|
3
39
|
/**
|
|
4
40
|
* Calculates the maximum scroll position for a virtual list.
|
|
5
41
|
*
|