@pdanpdan/virtual-scroll 0.9.0 → 0.10.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.
- package/README.md +90 -12
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +174 -154
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +863 -742
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +1 -1
- package/src/components/VirtualScroll.vue +30 -20
- package/src/composables/useVirtualScroll.ts +374 -800
- package/src/composables/useVirtualScrollSizes.ts +144 -142
- package/src/composables/useVirtualScrollbar.ts +16 -0
- package/src/extensions/all.ts +7 -0
- package/src/extensions/coordinate-scaling.ts +30 -0
- package/src/extensions/index.ts +88 -0
- package/src/extensions/infinite-loading.ts +47 -0
- package/src/extensions/prepend-restoration.ts +49 -0
- package/src/extensions/rtl.ts +42 -0
- package/src/extensions/snapping.ts +82 -0
- package/src/extensions/sticky.ts +43 -0
- package/src/types.ts +27 -7
- package/src/utils/scroll.ts +1 -1
- package/src/utils/virtual-scroll-logic.ts +44 -2
|
@@ -56,8 +56,12 @@ export function useVirtualScrollSizes<T>(
|
|
|
56
56
|
/** Cached list of previous items to detect prepending and shift measurements. */
|
|
57
57
|
let lastItems: T[] = [];
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Helper to get the base size of an item from props or default fallback.
|
|
61
|
+
* @param item - The data item.
|
|
62
|
+
* @param index - The item index.
|
|
63
|
+
*/
|
|
59
64
|
const getItemBaseSize = (item: T, index: number) => (typeof props.value.props.itemSize === 'function' ? (props.value.props.itemSize as (item: T, index: number) => number)(item, index) : props.value.defaultSize);
|
|
60
|
-
|
|
61
65
|
/**
|
|
62
66
|
* Internal helper to get the size of an item or column at a specific index.
|
|
63
67
|
*
|
|
@@ -71,7 +75,7 @@ export function useVirtualScrollSizes<T>(
|
|
|
71
75
|
*/
|
|
72
76
|
const getSizeAt = (
|
|
73
77
|
index: number,
|
|
74
|
-
sizeProp: number | number[] | ((...args: any[]) => number) | null | undefined,
|
|
78
|
+
sizeProp: number | (number | null | undefined)[] | ((...args: any[]) => number) | null | undefined,
|
|
75
79
|
defaultSize: number,
|
|
76
80
|
gap: number,
|
|
77
81
|
tree: FenwickTree,
|
|
@@ -83,7 +87,7 @@ export function useVirtualScrollSizes<T>(
|
|
|
83
87
|
if (typeof sizeProp === 'number' && sizeProp > 0) {
|
|
84
88
|
return sizeProp;
|
|
85
89
|
}
|
|
86
|
-
if (
|
|
90
|
+
if (Array.isArray(sizeProp) && sizeProp.length > 0) {
|
|
87
91
|
const val = sizeProp[ index % sizeProp.length ];
|
|
88
92
|
return (val != null && val > 0) ? val : defaultSize;
|
|
89
93
|
}
|
|
@@ -125,112 +129,152 @@ export function useVirtualScrollSizes<T>(
|
|
|
125
129
|
}
|
|
126
130
|
};
|
|
127
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Helper to initialize measurements for a single axis.
|
|
134
|
+
*/
|
|
135
|
+
const initializeAxis = (
|
|
136
|
+
count: number,
|
|
137
|
+
tree: FenwickTree,
|
|
138
|
+
measured: Uint8Array,
|
|
139
|
+
sizeProp: number | (number | null | undefined)[] | ((...args: any[]) => number) | null | undefined,
|
|
140
|
+
defaultSize: number,
|
|
141
|
+
gap: number,
|
|
142
|
+
isDynamic: boolean,
|
|
143
|
+
isX: boolean,
|
|
144
|
+
shouldReset: boolean,
|
|
145
|
+
) => {
|
|
146
|
+
let needsRebuild = false;
|
|
147
|
+
|
|
148
|
+
if (shouldReset) {
|
|
149
|
+
for (let i = 0; i < count; i++) {
|
|
150
|
+
if (tree.get(i) !== 0) {
|
|
151
|
+
tree.set(i, 0);
|
|
152
|
+
measured[ i ] = 0;
|
|
153
|
+
needsRebuild = true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return needsRebuild;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (let i = 0; i < count; i++) {
|
|
160
|
+
const current = tree.get(i);
|
|
161
|
+
const isMeasured = measured[ i ] === 1;
|
|
162
|
+
|
|
163
|
+
if (!isDynamic || (!isMeasured && current === 0)) {
|
|
164
|
+
const baseSize = getSizeAt(i, sizeProp, defaultSize, gap, tree, isX) + gap;
|
|
165
|
+
|
|
166
|
+
if (Math.abs(current - baseSize) > 0.5) {
|
|
167
|
+
tree.set(i, baseSize);
|
|
168
|
+
measured[ i ] = isDynamic ? 0 : 1;
|
|
169
|
+
needsRebuild = true;
|
|
170
|
+
} else if (!isDynamic) {
|
|
171
|
+
measured[ i ] = 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return needsRebuild;
|
|
176
|
+
};
|
|
177
|
+
|
|
128
178
|
/**
|
|
129
179
|
* Initializes prefix sum trees from props (fixed sizes, width arrays, or functions).
|
|
130
180
|
*/
|
|
131
181
|
const initializeMeasurements = () => {
|
|
132
182
|
const propsVal = props.value.props;
|
|
133
|
-
const
|
|
134
|
-
const len = newItems.length;
|
|
183
|
+
const len = propsVal.items.length;
|
|
135
184
|
const colCount = propsVal.columnCount || 0;
|
|
136
185
|
const gap = propsVal.gap || 0;
|
|
137
186
|
const columnGap = propsVal.columnGap || 0;
|
|
138
187
|
const cw = propsVal.columnWidth;
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
188
|
+
const itemSize = propsVal.itemSize;
|
|
189
|
+
const defaultColWidth = propsVal.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
|
|
190
|
+
const defaultItemSize = propsVal.defaultItemSize || props.value.defaultSize;
|
|
142
191
|
|
|
143
192
|
// Initialize columns
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (props.value.direction === 'horizontal') {
|
|
182
|
-
if (!props.value.isDynamicItemSize || (!isMeasuredX && currentX === 0)) {
|
|
183
|
-
const baseSize = getItemBaseSize(item as T, i);
|
|
184
|
-
const targetX = baseSize + columnGap;
|
|
185
|
-
if (Math.abs(currentX - targetX) > 0.5) {
|
|
186
|
-
itemSizesX.set(i, targetX);
|
|
187
|
-
measuredItemsX.value[ i ] = props.value.isDynamicItemSize ? 0 : 1;
|
|
188
|
-
itemsNeedRebuild = true;
|
|
189
|
-
} else if (!props.value.isDynamicItemSize) {
|
|
190
|
-
measuredItemsX.value[ i ] = 1;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
} else if (currentX !== 0) {
|
|
194
|
-
itemSizesX.set(i, 0);
|
|
195
|
-
measuredItemsX.value[ i ] = 0;
|
|
196
|
-
itemsNeedRebuild = true;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
if (props.value.direction !== 'horizontal') {
|
|
200
|
-
if (!props.value.isDynamicItemSize || (!isMeasuredY && currentY === 0)) {
|
|
201
|
-
const baseSize = getItemBaseSize(item as T, i);
|
|
202
|
-
const targetY = baseSize + gap;
|
|
203
|
-
if (Math.abs(currentY - targetY) > 0.5) {
|
|
204
|
-
itemSizesY.set(i, targetY);
|
|
205
|
-
measuredItemsY.value[ i ] = props.value.isDynamicItemSize ? 0 : 1;
|
|
206
|
-
itemsNeedRebuild = true;
|
|
207
|
-
} else if (!props.value.isDynamicItemSize) {
|
|
208
|
-
measuredItemsY.value[ i ] = 1;
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
} else if (currentY !== 0) {
|
|
212
|
-
itemSizesY.set(i, 0);
|
|
213
|
-
measuredItemsY.value[ i ] = 0;
|
|
214
|
-
itemsNeedRebuild = true;
|
|
215
|
-
}
|
|
216
|
-
}
|
|
193
|
+
const colNeedsRebuild = initializeAxis(
|
|
194
|
+
colCount,
|
|
195
|
+
columnSizes,
|
|
196
|
+
measuredColumns.value,
|
|
197
|
+
cw,
|
|
198
|
+
defaultColWidth,
|
|
199
|
+
columnGap,
|
|
200
|
+
props.value.isDynamicColumnWidth,
|
|
201
|
+
true,
|
|
202
|
+
false,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Initialize items X
|
|
206
|
+
const itemsXNeedsRebuild = initializeAxis(
|
|
207
|
+
len,
|
|
208
|
+
itemSizesX,
|
|
209
|
+
measuredItemsX.value,
|
|
210
|
+
itemSize,
|
|
211
|
+
defaultItemSize,
|
|
212
|
+
columnGap,
|
|
213
|
+
props.value.isDynamicItemSize,
|
|
214
|
+
true,
|
|
215
|
+
props.value.direction !== 'horizontal',
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
// Initialize items Y
|
|
219
|
+
const itemsYNeedsRebuild = initializeAxis(
|
|
220
|
+
len,
|
|
221
|
+
itemSizesY,
|
|
222
|
+
measuredItemsY.value,
|
|
223
|
+
itemSize,
|
|
224
|
+
defaultItemSize,
|
|
225
|
+
gap,
|
|
226
|
+
props.value.isDynamicItemSize,
|
|
227
|
+
false,
|
|
228
|
+
props.value.direction === 'horizontal',
|
|
229
|
+
);
|
|
217
230
|
|
|
218
231
|
if (colNeedsRebuild) {
|
|
219
232
|
columnSizes.rebuild();
|
|
220
233
|
}
|
|
221
|
-
if (
|
|
234
|
+
if (itemsXNeedsRebuild) {
|
|
222
235
|
itemSizesX.rebuild();
|
|
236
|
+
}
|
|
237
|
+
if (itemsYNeedsRebuild) {
|
|
223
238
|
itemSizesY.rebuild();
|
|
224
239
|
}
|
|
225
240
|
};
|
|
226
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Helper to update a single size in the tree.
|
|
244
|
+
*/
|
|
245
|
+
/**
|
|
246
|
+
* Helper to update a single size in the tree.
|
|
247
|
+
* @param index - Index to update.
|
|
248
|
+
* @param newSize - Measured size (without gap).
|
|
249
|
+
* @param tree - Target Fenwick tree.
|
|
250
|
+
* @param measured - Tracking array for measurements.
|
|
251
|
+
* @param gap - Gap size.
|
|
252
|
+
* @param firstIndex - Current first visible index (for delta calculation).
|
|
253
|
+
* @param accumulatedDelta - Object to collect scroll correction delta.
|
|
254
|
+
* @param accumulatedDelta.val - The current accumulated delta value.
|
|
255
|
+
*/
|
|
256
|
+
const updateAxis = (index: number, newSize: number, tree: FenwickTree, measured: Uint8Array, gap: number, firstIndex: number, accumulatedDelta: { val: number; }) => {
|
|
257
|
+
const oldSize = tree.get(index);
|
|
258
|
+
const targetSize = newSize + gap;
|
|
259
|
+
let updated = false;
|
|
260
|
+
|
|
261
|
+
if (!measured[ index ] || Math.abs(targetSize - oldSize) > 0.1) {
|
|
262
|
+
const d = targetSize - oldSize;
|
|
263
|
+
tree.update(index, d);
|
|
264
|
+
measured[ index ] = 1;
|
|
265
|
+
updated = true;
|
|
266
|
+
if (index < firstIndex && oldSize > 0) {
|
|
267
|
+
accumulatedDelta.val += d;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return updated;
|
|
271
|
+
};
|
|
272
|
+
|
|
227
273
|
/**
|
|
228
274
|
* Initializes or updates sizes based on current props and items.
|
|
229
275
|
* Handles prepending of items by shifting existing measurements.
|
|
230
|
-
*
|
|
231
|
-
* @param onScrollCorrection - Callback to adjust scroll position when items are prepended.
|
|
232
276
|
*/
|
|
233
|
-
const initializeSizes = (
|
|
277
|
+
const initializeSizes = () => {
|
|
234
278
|
const propsVal = props.value.props;
|
|
235
279
|
const newItems = propsVal.items;
|
|
236
280
|
const len = newItems.length;
|
|
@@ -252,23 +296,6 @@ export function useVirtualScrollSizes<T>(
|
|
|
252
296
|
newMeasuredY.set(measuredItemsY.value.subarray(0, Math.min(len - prependCount, measuredItemsY.value.length)), prependCount);
|
|
253
297
|
measuredItemsX.value = newMeasuredX;
|
|
254
298
|
measuredItemsY.value = newMeasuredY;
|
|
255
|
-
|
|
256
|
-
// Calculate added size
|
|
257
|
-
const gap = propsVal.gap || 0;
|
|
258
|
-
const columnGap = propsVal.columnGap || 0;
|
|
259
|
-
let addedX = 0;
|
|
260
|
-
let addedY = 0;
|
|
261
|
-
|
|
262
|
-
for (let i = 0; i < prependCount; i++) {
|
|
263
|
-
const size = getItemBaseSize(newItems[ i ] as T, i);
|
|
264
|
-
if (props.value.direction === 'horizontal') {
|
|
265
|
-
addedX += size + columnGap;
|
|
266
|
-
} else { addedY += size + gap; }
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if ((addedX > 0 || addedY > 0) && onScrollCorrection) {
|
|
270
|
-
onScrollCorrection(addedX, addedY);
|
|
271
|
-
}
|
|
272
299
|
}
|
|
273
300
|
|
|
274
301
|
initializeMeasurements();
|
|
@@ -297,8 +324,8 @@ export function useVirtualScrollSizes<T>(
|
|
|
297
324
|
onScrollCorrection: (deltaX: number, deltaY: number) => void,
|
|
298
325
|
) => {
|
|
299
326
|
let needUpdate = false;
|
|
300
|
-
|
|
301
|
-
|
|
327
|
+
const deltaX = { val: 0 };
|
|
328
|
+
const deltaY = { val: 0 };
|
|
302
329
|
const propsVal = props.value.props;
|
|
303
330
|
const gap = propsVal.gap || 0;
|
|
304
331
|
const columnGap = propsVal.columnGap || 0;
|
|
@@ -312,22 +339,16 @@ export function useVirtualScrollSizes<T>(
|
|
|
312
339
|
const processedRows = new Set<number>();
|
|
313
340
|
const processedCols = new Set<number>();
|
|
314
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Helper to try and update a column width from an element measurement.
|
|
344
|
+
* @param colIdx - Column index.
|
|
345
|
+
* @param width - Measured width.
|
|
346
|
+
*/
|
|
315
347
|
const tryUpdateColumn = (colIdx: number, width: number) => {
|
|
316
348
|
if (colIdx >= 0 && colIdx < (propsVal.columnCount || 0) && !processedCols.has(colIdx)) {
|
|
317
349
|
processedCols.add(colIdx);
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (!measuredColumns.value[ colIdx ] || Math.abs(oldW - targetW) > 0.1) {
|
|
322
|
-
const d = targetW - oldW;
|
|
323
|
-
if (Math.abs(d) > 0.1) {
|
|
324
|
-
columnSizes.update(colIdx, d);
|
|
325
|
-
needUpdate = true;
|
|
326
|
-
if (colIdx < firstColIndex && oldW > 0) {
|
|
327
|
-
deltaX += d;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
measuredColumns.value[ colIdx ] = 1;
|
|
350
|
+
if (updateAxis(colIdx, width, columnSizes, measuredColumns.value, columnGap, firstColIndex, deltaX)) {
|
|
351
|
+
needUpdate = true;
|
|
331
352
|
}
|
|
332
353
|
}
|
|
333
354
|
};
|
|
@@ -342,30 +363,13 @@ export function useVirtualScrollSizes<T>(
|
|
|
342
363
|
if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
|
|
343
364
|
processedRows.add(index);
|
|
344
365
|
if (isHorizontalMode && inlineSize > 0) {
|
|
345
|
-
|
|
346
|
-
const targetWidth = inlineSize + columnGap;
|
|
347
|
-
if (!measuredItemsX.value[ index ] || Math.abs(targetWidth - oldWidth) > 0.1) {
|
|
348
|
-
const d = targetWidth - oldWidth;
|
|
349
|
-
itemSizesX.update(index, d);
|
|
350
|
-
measuredItemsX.value[ index ] = 1;
|
|
366
|
+
if (updateAxis(index, inlineSize, itemSizesX, measuredItemsX.value, columnGap, firstRowIndex, deltaX)) {
|
|
351
367
|
needUpdate = true;
|
|
352
|
-
if (index < firstRowIndex && oldWidth > 0) {
|
|
353
|
-
deltaX += d;
|
|
354
|
-
}
|
|
355
368
|
}
|
|
356
369
|
}
|
|
357
370
|
if (!isHorizontalMode) {
|
|
358
|
-
|
|
359
|
-
const targetHeight = blockSize + gap;
|
|
360
|
-
|
|
361
|
-
if (!measuredItemsY.value[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
|
|
362
|
-
const d = targetHeight - oldHeight;
|
|
363
|
-
itemSizesY.update(index, d);
|
|
364
|
-
measuredItemsY.value[ index ] = 1;
|
|
371
|
+
if (updateAxis(index, blockSize, itemSizesY, measuredItemsY.value, gap, firstRowIndex, deltaY)) {
|
|
365
372
|
needUpdate = true;
|
|
366
|
-
if (index < firstRowIndex && oldHeight > 0) {
|
|
367
|
-
deltaY += d;
|
|
368
|
-
}
|
|
369
373
|
}
|
|
370
374
|
}
|
|
371
375
|
}
|
|
@@ -396,25 +400,23 @@ export function useVirtualScrollSizes<T>(
|
|
|
396
400
|
|
|
397
401
|
if (needUpdate) {
|
|
398
402
|
treeUpdateFlag.value++;
|
|
399
|
-
if (deltaX !== 0 || deltaY !== 0) {
|
|
400
|
-
onScrollCorrection(deltaX, deltaY);
|
|
403
|
+
if (deltaX.val !== 0 || deltaY.val !== 0) {
|
|
404
|
+
onScrollCorrection(deltaX.val, deltaY.val);
|
|
401
405
|
}
|
|
402
406
|
}
|
|
403
407
|
};
|
|
404
408
|
|
|
405
409
|
/**
|
|
406
410
|
* Resets all dynamic measurements and re-initializes from current props.
|
|
407
|
-
*
|
|
408
|
-
* @param onScrollCorrection - Callback to adjust scroll position.
|
|
409
411
|
*/
|
|
410
|
-
const refresh = (
|
|
412
|
+
const refresh = () => {
|
|
411
413
|
itemSizesX.resize(0);
|
|
412
414
|
itemSizesY.resize(0);
|
|
413
415
|
columnSizes.resize(0);
|
|
414
416
|
measuredColumns.value.fill(0);
|
|
415
417
|
measuredItemsX.value.fill(0);
|
|
416
418
|
measuredItemsY.value.fill(0);
|
|
417
|
-
initializeSizes(
|
|
419
|
+
initializeSizes();
|
|
418
420
|
};
|
|
419
421
|
|
|
420
422
|
return {
|
|
@@ -98,6 +98,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
|
|
|
98
98
|
let startPos = 0;
|
|
99
99
|
let startScrollPos = 0;
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Handles click events on the scrollbar track to jump to a scroll position.
|
|
103
|
+
* @param event - The mouse event.
|
|
104
|
+
*/
|
|
101
105
|
function handleTrackClick(event: MouseEvent) {
|
|
102
106
|
const track = event.currentTarget as HTMLElement;
|
|
103
107
|
if (event.target !== track) {
|
|
@@ -126,6 +130,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
|
|
|
126
130
|
props.value.scrollToOffset(Math.max(0, Math.min(scrollableRange, targetOffset)));
|
|
127
131
|
}
|
|
128
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Handles pointer down events on the scrollbar thumb to start dragging.
|
|
135
|
+
* @param event - The pointer event.
|
|
136
|
+
*/
|
|
129
137
|
function handleThumbPointerDown(event: PointerEvent) {
|
|
130
138
|
isDragging.value = true;
|
|
131
139
|
startPos = isHorizontal.value
|
|
@@ -139,6 +147,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
|
|
|
139
147
|
event.stopPropagation();
|
|
140
148
|
}
|
|
141
149
|
|
|
150
|
+
/**
|
|
151
|
+
* Handles pointer move events to update the scroll position while dragging the thumb.
|
|
152
|
+
* @param event - The pointer event.
|
|
153
|
+
*/
|
|
142
154
|
function handleThumbPointerMove(event: PointerEvent) {
|
|
143
155
|
if (!isDragging.value) {
|
|
144
156
|
return;
|
|
@@ -173,6 +185,10 @@ export function useVirtualScrollbar(propsInput: MaybeRefOrGetter<UseVirtualScrol
|
|
|
173
185
|
props.value.scrollToOffset(Math.max(0, Math.min(scrollableContentRange, targetOffset)));
|
|
174
186
|
}
|
|
175
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Handles pointer up events to stop dragging the thumb.
|
|
190
|
+
* @param event - The pointer event.
|
|
191
|
+
*/
|
|
176
192
|
function handleThumbPointerUp(event: PointerEvent) {
|
|
177
193
|
if (!isDragging.value) {
|
|
178
194
|
return;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionContext, VirtualScrollExtension } from './index';
|
|
2
|
+
|
|
3
|
+
import { watchEffect } from 'vue';
|
|
4
|
+
|
|
5
|
+
import { calculateScale } from '../utils/virtual-scroll-logic';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extension for Coordinate Scaling.
|
|
9
|
+
* Enables support for massive lists by scaling virtual coordinates when they exceed browser limits.
|
|
10
|
+
*/
|
|
11
|
+
export function useCoordinateScalingExtension<T = unknown>(): VirtualScrollExtension<T> {
|
|
12
|
+
return {
|
|
13
|
+
name: 'coordinate-scaling',
|
|
14
|
+
onInit(ctx: ExtensionContext<T>) {
|
|
15
|
+
watchEffect(() => {
|
|
16
|
+
const container = ctx.props.value.container;
|
|
17
|
+
const totalSize = ctx.totalSize.value;
|
|
18
|
+
const viewportWidth = ctx.internalState.viewportWidth.value;
|
|
19
|
+
const viewportHeight = ctx.internalState.viewportHeight.value;
|
|
20
|
+
|
|
21
|
+
const isWindow = (typeof window !== 'undefined' && container === window) || container === undefined;
|
|
22
|
+
|
|
23
|
+
if (totalSize && viewportWidth && viewportHeight) {
|
|
24
|
+
ctx.internalState.scaleX.value = calculateScale(isWindow, totalSize.width, viewportWidth);
|
|
25
|
+
ctx.internalState.scaleY.value = calculateScale(isWindow, totalSize.height, viewportHeight);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { RenderedItem, ScrollAlignment, ScrollAlignmentOptions, ScrollDetails, ScrollToIndexOptions, Size, VirtualScrollProps } from '../types';
|
|
2
|
+
import type { Ref } from 'vue';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook context provided to extensions.
|
|
6
|
+
*/
|
|
7
|
+
export interface ExtensionContext<T = unknown> {
|
|
8
|
+
/** Reactive reference to the component props. */
|
|
9
|
+
props: Ref<VirtualScrollProps<T>>;
|
|
10
|
+
/** Reactive reference to the current scroll details. */
|
|
11
|
+
scrollDetails: Ref<ScrollDetails<T>>;
|
|
12
|
+
/** Total calculated or estimated size of the scrollable area (DU). */
|
|
13
|
+
totalSize: Ref<Size>;
|
|
14
|
+
/** Reactive reference to the current rendered item range. */
|
|
15
|
+
range: Ref<{ start: number; end: number; }>;
|
|
16
|
+
/** Reactive reference to the first visible item index. */
|
|
17
|
+
currentIndex: Ref<number>;
|
|
18
|
+
/** Reactive references to internal component state variables. */
|
|
19
|
+
internalState: {
|
|
20
|
+
/** Horizontal display scroll position (DU). */
|
|
21
|
+
scrollX: Ref<number>;
|
|
22
|
+
/** Vertical display scroll position (DU). */
|
|
23
|
+
scrollY: Ref<number>;
|
|
24
|
+
/** Horizontal virtual scroll position (VU). */
|
|
25
|
+
internalScrollX: Ref<number>;
|
|
26
|
+
/** Vertical virtual scroll position (VU). */
|
|
27
|
+
internalScrollY: Ref<number>;
|
|
28
|
+
/** Right-to-Left text direction state. */
|
|
29
|
+
isRtl: Ref<boolean>;
|
|
30
|
+
/** Scrolling activity state. */
|
|
31
|
+
isScrolling: Ref<boolean>;
|
|
32
|
+
/** Programmatic scroll activity state. */
|
|
33
|
+
isProgrammaticScroll: Ref<boolean>;
|
|
34
|
+
/** Viewport width (DU). */
|
|
35
|
+
viewportWidth: Ref<number>;
|
|
36
|
+
/** Viewport height (DU). */
|
|
37
|
+
viewportHeight: Ref<number>;
|
|
38
|
+
/** Coordinate scale factor for X axis. */
|
|
39
|
+
scaleX: Ref<number>;
|
|
40
|
+
/** Coordinate scale factor for Y axis. */
|
|
41
|
+
scaleY: Ref<number>;
|
|
42
|
+
/** Horizontal scroll direction. */
|
|
43
|
+
scrollDirectionX: Ref<'start' | 'end' | null>;
|
|
44
|
+
/** Vertical scroll direction. */
|
|
45
|
+
scrollDirectionY: Ref<'start' | 'end' | null>;
|
|
46
|
+
/** Relative horizontal virtual scroll position (VU). */
|
|
47
|
+
relativeScrollX: Ref<number>;
|
|
48
|
+
/** Relative vertical virtual scroll position (VU). */
|
|
49
|
+
relativeScrollY: Ref<number>;
|
|
50
|
+
};
|
|
51
|
+
/** Direct access to core component methods. */
|
|
52
|
+
methods: {
|
|
53
|
+
/** Scroll to a specific row and/or column. */
|
|
54
|
+
scrollToIndex: (rowIndex?: number | null, colIndex?: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions) => void;
|
|
55
|
+
/** Scroll to a specific virtual pixel offset. */
|
|
56
|
+
scrollToOffset: (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => void;
|
|
57
|
+
/** Detect and update text direction. */
|
|
58
|
+
updateDirection: () => void;
|
|
59
|
+
/** Get row index at virtual offset. */
|
|
60
|
+
getRowIndexAt: (offset: number) => number;
|
|
61
|
+
/** Get column index at virtual offset. */
|
|
62
|
+
getColIndexAt: (offset: number) => number;
|
|
63
|
+
/** Get actual size of item (measured or estimated). */
|
|
64
|
+
getItemSize: (index: number) => number;
|
|
65
|
+
/** Get base configuration size of item. */
|
|
66
|
+
getItemBaseSize: (item: T, index: number) => number;
|
|
67
|
+
/** Get virtual offset of item. */
|
|
68
|
+
getItemOffset: (index: number) => number;
|
|
69
|
+
/** Adjust scroll position for measurement changes. */
|
|
70
|
+
handleScrollCorrection: (addedX: number, addedY: number) => void;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Base interface for Virtual Scroll extensions.
|
|
76
|
+
*/
|
|
77
|
+
export interface VirtualScrollExtension<T = unknown> {
|
|
78
|
+
/** Unique name of the extension. */
|
|
79
|
+
name: string;
|
|
80
|
+
/** Called when the component is initialized. */
|
|
81
|
+
onInit?: (ctx: ExtensionContext<T>) => void;
|
|
82
|
+
/** Called on every scroll event. */
|
|
83
|
+
onScroll?: (ctx: ExtensionContext<T>, event: Event) => void;
|
|
84
|
+
/** Called when scrolling activity stops. */
|
|
85
|
+
onScrollEnd?: (ctx: ExtensionContext<T>) => void;
|
|
86
|
+
/** Post-processor for the list of rendered items. */
|
|
87
|
+
transformRenderedItems?: (items: RenderedItem<T>[], ctx: ExtensionContext<T>) => RenderedItem<T>[];
|
|
88
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionContext, VirtualScrollExtension } from './index';
|
|
2
|
+
|
|
3
|
+
import { watch } from 'vue';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extension for Infinite Loading logic.
|
|
7
|
+
* Triggers an `onLoad` callback when the user scrolls near the end of the content.
|
|
8
|
+
*
|
|
9
|
+
* @param options - Extension options.
|
|
10
|
+
* @param options.onLoad - Callback triggered when more data should be loaded.
|
|
11
|
+
*/
|
|
12
|
+
export function useInfiniteLoadingExtension<T = unknown>(options: {
|
|
13
|
+
/**
|
|
14
|
+
* Callback triggered when the scroll position reaches the `loadDistance` threshold.
|
|
15
|
+
* @param axis - The axis that reached the threshold.
|
|
16
|
+
*/
|
|
17
|
+
onLoad: (axis: 'vertical' | 'horizontal') => void;
|
|
18
|
+
}): VirtualScrollExtension<T> {
|
|
19
|
+
return {
|
|
20
|
+
name: 'infinite-loading',
|
|
21
|
+
onInit(ctx: ExtensionContext<T>) {
|
|
22
|
+
watch(ctx.scrollDetails, (details) => {
|
|
23
|
+
if (ctx.props.value.loading || !details || !details.totalSize || (details.totalSize.width === 0 && details.totalSize.height === 0)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const direction = ctx.props.value.direction || 'vertical';
|
|
28
|
+
const loadDistance = ctx.props.value.loadDistance ?? 200;
|
|
29
|
+
|
|
30
|
+
// vertical or both
|
|
31
|
+
if (direction !== 'horizontal') {
|
|
32
|
+
const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
|
|
33
|
+
if (remaining <= loadDistance) {
|
|
34
|
+
options.onLoad('vertical');
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// horizontal or both
|
|
38
|
+
if (direction !== 'vertical') {
|
|
39
|
+
const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
|
|
40
|
+
if (remaining <= loadDistance) {
|
|
41
|
+
options.onLoad('horizontal');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ExtensionContext, VirtualScrollExtension } from './index';
|
|
2
|
+
|
|
3
|
+
import { watch } from 'vue';
|
|
4
|
+
|
|
5
|
+
import { calculatePrependCount } from '../utils/virtual-scroll-logic';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Extension for Prepend Restoration logic.
|
|
9
|
+
* Automatically maintains the current scroll position when items are prepended to the list.
|
|
10
|
+
*/
|
|
11
|
+
export function usePrependRestorationExtension<T = unknown>(): VirtualScrollExtension<T> {
|
|
12
|
+
let lastItems: T[] = [];
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
name: 'prepend-restoration',
|
|
16
|
+
onInit(ctx: ExtensionContext<T>) {
|
|
17
|
+
// Use a local copy to avoid mutation issues
|
|
18
|
+
lastItems = [ ...ctx.props.value.items ];
|
|
19
|
+
|
|
20
|
+
watch(() => ctx.props.value.items, (newItems) => {
|
|
21
|
+
if (!ctx.props.value.restoreScrollOnPrepend) {
|
|
22
|
+
lastItems = [ ...newItems ];
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const prependCount = calculatePrependCount(lastItems, newItems);
|
|
27
|
+
|
|
28
|
+
if (prependCount > 0) {
|
|
29
|
+
const direction = ctx.props.value.direction || 'vertical';
|
|
30
|
+
const gap = (direction === 'horizontal' ? ctx.props.value.columnGap : ctx.props.value.gap) || 0;
|
|
31
|
+
|
|
32
|
+
let addedSize = 0;
|
|
33
|
+
for (let i = 0; i < prependCount; i++) {
|
|
34
|
+
addedSize += ctx.methods.getItemBaseSize(newItems[ i ]!, i) + gap;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (addedSize > 0) {
|
|
38
|
+
ctx.methods.handleScrollCorrection(
|
|
39
|
+
direction === 'horizontal' ? addedSize : 0,
|
|
40
|
+
direction !== 'horizontal' ? addedSize : 0,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lastItems = [ ...newItems ];
|
|
46
|
+
}, { deep: false }); // Identity check is enough
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|