@pdanpdan/virtual-scroll 0.1.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 +292 -0
- package/dist/index.css +1 -0
- package/dist/index.js +961 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/components/VirtualScroll.test.ts +912 -0
- package/src/components/VirtualScroll.vue +748 -0
- package/src/composables/useVirtualScroll.test.ts +1214 -0
- package/src/composables/useVirtualScroll.ts +1407 -0
- package/src/index.ts +4 -0
- package/src/utils/fenwick-tree.test.ts +119 -0
- package/src/utils/fenwick-tree.ts +155 -0
- package/src/utils/scroll.ts +59 -0
|
@@ -0,0 +1,1407 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
/* global ScrollToOptions */
|
|
4
|
+
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
|
5
|
+
|
|
6
|
+
import { FenwickTree } from '../utils/fenwick-tree.js';
|
|
7
|
+
import { getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions } from '../utils/scroll.js';
|
|
8
|
+
|
|
9
|
+
export const DEFAULT_ITEM_SIZE = 50;
|
|
10
|
+
export const DEFAULT_COLUMN_WIDTH = 150;
|
|
11
|
+
export const DEFAULT_BUFFER = 5;
|
|
12
|
+
|
|
13
|
+
export type ScrollDirection = 'vertical' | 'horizontal' | 'both';
|
|
14
|
+
export type ScrollAlignment = 'start' | 'center' | 'end' | 'auto';
|
|
15
|
+
|
|
16
|
+
export interface ScrollAlignmentOptions {
|
|
17
|
+
x?: ScrollAlignment;
|
|
18
|
+
y?: ScrollAlignment;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ScrollToIndexOptions {
|
|
22
|
+
align?: ScrollAlignment | ScrollAlignmentOptions;
|
|
23
|
+
behavior?: 'auto' | 'smooth';
|
|
24
|
+
isCorrection?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface VirtualScrollProps<T = unknown> {
|
|
28
|
+
/** Array of items to be virtualized */
|
|
29
|
+
items: T[];
|
|
30
|
+
/** Fixed size of each item or a function that returns the size of an item */
|
|
31
|
+
itemSize?: number | ((item: T, index: number) => number) | undefined;
|
|
32
|
+
/** Direction of the scroll: 'vertical', 'horizontal', or 'both' */
|
|
33
|
+
direction?: ScrollDirection | undefined;
|
|
34
|
+
/** Number of items to render before the visible viewport */
|
|
35
|
+
bufferBefore?: number | undefined;
|
|
36
|
+
/** Number of items to render after the visible viewport */
|
|
37
|
+
bufferAfter?: number | undefined;
|
|
38
|
+
/** The scrollable container element or window */
|
|
39
|
+
container?: HTMLElement | Window | null | undefined;
|
|
40
|
+
/** The host element that contains the items */
|
|
41
|
+
hostElement?: HTMLElement | null | undefined;
|
|
42
|
+
/** Range of items to render for SSR */
|
|
43
|
+
ssrRange?: {
|
|
44
|
+
start: number;
|
|
45
|
+
end: number;
|
|
46
|
+
colStart?: number;
|
|
47
|
+
colEnd?: number;
|
|
48
|
+
} | undefined;
|
|
49
|
+
/** Number of columns for bidirectional scroll */
|
|
50
|
+
columnCount?: number | undefined;
|
|
51
|
+
/** Fixed width of columns or an array of widths for alternating columns */
|
|
52
|
+
columnWidth?: number | number[] | ((index: number) => number) | undefined;
|
|
53
|
+
/** Padding at the start of the scroll container (e.g. for sticky headers) */
|
|
54
|
+
scrollPaddingStart?: number | { x?: number; y?: number; } | undefined;
|
|
55
|
+
/** Padding at the end of the scroll container */
|
|
56
|
+
scrollPaddingEnd?: number | { x?: number; y?: number; } | undefined;
|
|
57
|
+
/** Gap between items in pixels (vertical) */
|
|
58
|
+
gap?: number | undefined;
|
|
59
|
+
/** Gap between columns in pixels (horizontal/grid) */
|
|
60
|
+
columnGap?: number | undefined;
|
|
61
|
+
/** Indices of items that should stick to the top/start */
|
|
62
|
+
stickyIndices?: number[] | undefined;
|
|
63
|
+
/** Distance from the end of the scrollable area to trigger 'load' event */
|
|
64
|
+
loadDistance?: number | undefined;
|
|
65
|
+
/** Whether items are currently being loaded */
|
|
66
|
+
loading?: boolean | undefined;
|
|
67
|
+
/** Whether to restore scroll position when items are prepended */
|
|
68
|
+
restoreScrollOnPrepend?: boolean | undefined;
|
|
69
|
+
/** Initial scroll index to jump to on mount */
|
|
70
|
+
initialScrollIndex?: number | undefined;
|
|
71
|
+
/** Alignment for the initial scroll index */
|
|
72
|
+
initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions | undefined;
|
|
73
|
+
/** Default size for items before they are measured */
|
|
74
|
+
defaultItemSize?: number | undefined;
|
|
75
|
+
/** Default width for columns before they are measured */
|
|
76
|
+
defaultColumnWidth?: number | undefined;
|
|
77
|
+
/** Whether to enable debug mode (e.g. showing offsets) */
|
|
78
|
+
debug?: boolean | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RenderedItem<T = unknown> {
|
|
82
|
+
item: T;
|
|
83
|
+
index: number;
|
|
84
|
+
offset: { x: number; y: number; };
|
|
85
|
+
size: { width: number; height: number; };
|
|
86
|
+
originalX: number;
|
|
87
|
+
originalY: number;
|
|
88
|
+
isSticky?: boolean;
|
|
89
|
+
isStickyActive?: boolean;
|
|
90
|
+
stickyOffset: { x: number; y: number; };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ScrollDetails<T = unknown> {
|
|
94
|
+
items: RenderedItem<T>[];
|
|
95
|
+
currentIndex: number;
|
|
96
|
+
currentColIndex: number;
|
|
97
|
+
scrollOffset: { x: number; y: number; };
|
|
98
|
+
viewportSize: { width: number; height: number; };
|
|
99
|
+
totalSize: { width: number; height: number; };
|
|
100
|
+
isScrolling: boolean;
|
|
101
|
+
isProgrammaticScroll: boolean;
|
|
102
|
+
/** Range of items currently being rendered */
|
|
103
|
+
range: { start: number; end: number; };
|
|
104
|
+
/** Range of columns currently being rendered (for grid mode) */
|
|
105
|
+
columnRange: { start: number; end: number; padStart: number; padEnd: number; };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Composable for virtual scrolling logic.
|
|
110
|
+
* Handles calculation of visible items, scroll events, and dynamic item sizes.
|
|
111
|
+
*
|
|
112
|
+
* @param props - Reactive properties for virtual scroll configuration
|
|
113
|
+
*/
|
|
114
|
+
export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>) {
|
|
115
|
+
// --- State ---
|
|
116
|
+
const scrollX = ref(0);
|
|
117
|
+
const scrollY = ref(0);
|
|
118
|
+
const isScrolling = ref(false);
|
|
119
|
+
const isHydrated = ref(false);
|
|
120
|
+
const isHydrating = ref(false);
|
|
121
|
+
const isMounted = ref(false);
|
|
122
|
+
const viewportWidth = ref(0);
|
|
123
|
+
const viewportHeight = ref(0);
|
|
124
|
+
const hostOffset = reactive({ x: 0, y: 0 });
|
|
125
|
+
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
126
|
+
|
|
127
|
+
const isProgrammaticScroll = ref(false);
|
|
128
|
+
|
|
129
|
+
// --- Fenwick Trees for efficient size and offset management ---
|
|
130
|
+
const itemSizesX = new FenwickTree(props.value.items.length);
|
|
131
|
+
const itemSizesY = new FenwickTree(props.value.items.length);
|
|
132
|
+
const columnSizes = new FenwickTree(props.value.columnCount || 0);
|
|
133
|
+
|
|
134
|
+
const treeUpdateFlag = ref(0);
|
|
135
|
+
|
|
136
|
+
let measuredColumns = new Uint8Array(0);
|
|
137
|
+
let measuredItemsX = new Uint8Array(0);
|
|
138
|
+
let measuredItemsY = new Uint8Array(0);
|
|
139
|
+
|
|
140
|
+
// --- Scroll Queue / Correction ---
|
|
141
|
+
const pendingScroll = ref<{
|
|
142
|
+
rowIndex: number | null | undefined;
|
|
143
|
+
colIndex: number | null | undefined;
|
|
144
|
+
options: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions | undefined;
|
|
145
|
+
} | null>(null);
|
|
146
|
+
|
|
147
|
+
const maxWidth = ref(0);
|
|
148
|
+
const maxHeight = ref(0);
|
|
149
|
+
|
|
150
|
+
// Track if sizes are initialized
|
|
151
|
+
const sizesInitialized = ref(false);
|
|
152
|
+
let lastItems: T[] = [];
|
|
153
|
+
|
|
154
|
+
// --- Computed Config ---
|
|
155
|
+
const isDynamicItemSize = computed(() =>
|
|
156
|
+
props.value.itemSize === undefined || props.value.itemSize === null || props.value.itemSize === 0,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const isDynamicColumnWidth = computed(() =>
|
|
160
|
+
props.value.columnWidth === undefined || props.value.columnWidth === null || props.value.columnWidth === 0,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const fixedItemSize = computed(() =>
|
|
164
|
+
(typeof props.value.itemSize === 'number' && props.value.itemSize > 0) ? props.value.itemSize : null,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const defaultSize = computed(() => fixedItemSize.value || props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
|
|
168
|
+
|
|
169
|
+
const sortedStickyIndices = computed(() =>
|
|
170
|
+
[ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// --- Size Calculations ---
|
|
174
|
+
/**
|
|
175
|
+
* Total width of all items in the scrollable area.
|
|
176
|
+
*/
|
|
177
|
+
const totalWidth = computed(() => {
|
|
178
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
179
|
+
treeUpdateFlag.value;
|
|
180
|
+
|
|
181
|
+
if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
|
|
182
|
+
const { start = 0, end = 0, colStart = 0, colEnd = 0 } = props.value.ssrRange;
|
|
183
|
+
const colCount = props.value.columnCount || 0;
|
|
184
|
+
if (props.value.direction === 'both' && colCount > 0) {
|
|
185
|
+
return columnSizes.query(colEnd || colCount) - columnSizes.query(colStart);
|
|
186
|
+
}
|
|
187
|
+
if (props.value.direction === 'horizontal') {
|
|
188
|
+
if (fixedItemSize.value !== null) {
|
|
189
|
+
return (end - start) * (fixedItemSize.value + (props.value.columnGap || 0));
|
|
190
|
+
}
|
|
191
|
+
return itemSizesX.query(end) - itemSizesX.query(start);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (props.value.direction === 'both' && props.value.columnCount) {
|
|
196
|
+
return columnSizes.query(props.value.columnCount);
|
|
197
|
+
}
|
|
198
|
+
if (props.value.direction === 'vertical') {
|
|
199
|
+
return 0;
|
|
200
|
+
}
|
|
201
|
+
if (fixedItemSize.value !== null) {
|
|
202
|
+
return props.value.items.length * (fixedItemSize.value + (props.value.columnGap || 0));
|
|
203
|
+
}
|
|
204
|
+
return itemSizesX.query(props.value.items.length);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Total height of all items in the scrollable area.
|
|
209
|
+
*/
|
|
210
|
+
const totalHeight = computed(() => {
|
|
211
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
212
|
+
treeUpdateFlag.value;
|
|
213
|
+
|
|
214
|
+
if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
|
|
215
|
+
const { start, end } = props.value.ssrRange;
|
|
216
|
+
if (props.value.direction === 'vertical' || props.value.direction === 'both') {
|
|
217
|
+
if (fixedItemSize.value !== null) {
|
|
218
|
+
return (end - start) * (fixedItemSize.value + (props.value.gap || 0));
|
|
219
|
+
}
|
|
220
|
+
return itemSizesY.query(end) - itemSizesY.query(start);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (props.value.direction === 'horizontal') {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
if (fixedItemSize.value !== null) {
|
|
228
|
+
return props.value.items.length * (fixedItemSize.value + (props.value.gap || 0));
|
|
229
|
+
}
|
|
230
|
+
return itemSizesY.query(props.value.items.length);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const relativeScrollX = computed(() => {
|
|
234
|
+
const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
|
|
235
|
+
const padding = isHorizontal ? getPaddingX(props.value.scrollPaddingStart, props.value.direction) : 0;
|
|
236
|
+
return Math.max(0, scrollX.value + padding - hostOffset.x);
|
|
237
|
+
});
|
|
238
|
+
const relativeScrollY = computed(() => {
|
|
239
|
+
const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
|
|
240
|
+
const padding = isVertical ? getPaddingY(props.value.scrollPaddingStart, props.value.direction) : 0;
|
|
241
|
+
return Math.max(0, scrollY.value + padding - hostOffset.y);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// --- Scroll Helpers ---
|
|
245
|
+
const getColumnWidth = (index: number) => {
|
|
246
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
247
|
+
treeUpdateFlag.value;
|
|
248
|
+
|
|
249
|
+
const cw = props.value.columnWidth;
|
|
250
|
+
if (typeof cw === 'number' && cw > 0) {
|
|
251
|
+
return cw;
|
|
252
|
+
}
|
|
253
|
+
if (Array.isArray(cw) && cw.length > 0) {
|
|
254
|
+
return cw[ index % cw.length ] || DEFAULT_COLUMN_WIDTH;
|
|
255
|
+
}
|
|
256
|
+
if (typeof cw === 'function') {
|
|
257
|
+
return cw(index);
|
|
258
|
+
}
|
|
259
|
+
return columnSizes.get(index) || props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
// --- Public Scroll API ---
|
|
263
|
+
/**
|
|
264
|
+
* Scrolls to a specific row and column index.
|
|
265
|
+
*
|
|
266
|
+
* @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
|
|
267
|
+
* @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
|
|
268
|
+
* @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
|
|
269
|
+
*/
|
|
270
|
+
const scrollToIndex = (
|
|
271
|
+
rowIndex: number | null | undefined,
|
|
272
|
+
colIndex: number | null | undefined,
|
|
273
|
+
options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions,
|
|
274
|
+
) => {
|
|
275
|
+
const isCorrection = typeof options === 'object' && options !== null && 'isCorrection' in options
|
|
276
|
+
? options.isCorrection
|
|
277
|
+
: false;
|
|
278
|
+
|
|
279
|
+
if (!isCorrection) {
|
|
280
|
+
pendingScroll.value = { rowIndex, colIndex, options };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const container = props.value.container || window;
|
|
284
|
+
const fixedSize = fixedItemSize.value;
|
|
285
|
+
const gap = props.value.gap || 0;
|
|
286
|
+
const columnGap = props.value.columnGap || 0;
|
|
287
|
+
|
|
288
|
+
let align: ScrollAlignment | ScrollAlignmentOptions | undefined;
|
|
289
|
+
let behavior: 'auto' | 'smooth' | undefined;
|
|
290
|
+
|
|
291
|
+
if (isScrollToIndexOptions(options)) {
|
|
292
|
+
align = options.align;
|
|
293
|
+
behavior = options.behavior;
|
|
294
|
+
} else {
|
|
295
|
+
align = options as ScrollAlignment | ScrollAlignmentOptions;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const alignX = (typeof align === 'object' ? align.x : align) || 'auto';
|
|
299
|
+
const alignY = (typeof align === 'object' ? align.y : align) || 'auto';
|
|
300
|
+
|
|
301
|
+
const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
|
|
302
|
+
const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, props.value.direction);
|
|
303
|
+
const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
|
|
304
|
+
const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, props.value.direction);
|
|
305
|
+
|
|
306
|
+
const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
|
|
307
|
+
const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
|
|
308
|
+
|
|
309
|
+
const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
|
|
310
|
+
const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
|
|
311
|
+
|
|
312
|
+
let targetX = relativeScrollX.value;
|
|
313
|
+
let targetY = relativeScrollY.value;
|
|
314
|
+
let itemWidth = 0;
|
|
315
|
+
let itemHeight = 0;
|
|
316
|
+
|
|
317
|
+
// Y calculation
|
|
318
|
+
if (rowIndex !== null && rowIndex !== undefined) {
|
|
319
|
+
if (rowIndex >= props.value.items.length) {
|
|
320
|
+
targetY = totalHeight.value;
|
|
321
|
+
itemHeight = 0;
|
|
322
|
+
} else {
|
|
323
|
+
targetY = fixedSize !== null ? rowIndex * (fixedSize + gap) : itemSizesY.query(rowIndex);
|
|
324
|
+
itemHeight = fixedSize !== null ? fixedSize : itemSizesY.get(rowIndex) - gap;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Apply Y Alignment
|
|
328
|
+
if (alignY === 'start') {
|
|
329
|
+
// targetY is already at the start of the list
|
|
330
|
+
} else if (alignY === 'center') {
|
|
331
|
+
targetY -= (usableHeight - itemHeight) / 2;
|
|
332
|
+
} else if (alignY === 'end') {
|
|
333
|
+
targetY -= (usableHeight - itemHeight);
|
|
334
|
+
} else {
|
|
335
|
+
const isVisibleY = targetY >= relativeScrollY.value && (targetY + itemHeight) <= (relativeScrollY.value + usableHeight);
|
|
336
|
+
if (!isVisibleY) {
|
|
337
|
+
if (targetY < relativeScrollY.value) {
|
|
338
|
+
// keep targetY at start
|
|
339
|
+
} else {
|
|
340
|
+
targetY -= (usableHeight - itemHeight);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// X calculation
|
|
347
|
+
if (colIndex !== null && colIndex !== undefined) {
|
|
348
|
+
const totalCols = props.value.columnCount || 0;
|
|
349
|
+
if (colIndex >= totalCols && totalCols > 0) {
|
|
350
|
+
targetX = totalWidth.value;
|
|
351
|
+
itemWidth = 0;
|
|
352
|
+
} else if (props.value.direction === 'horizontal') {
|
|
353
|
+
targetX = fixedSize !== null ? colIndex * (fixedSize + columnGap) : itemSizesX.query(colIndex);
|
|
354
|
+
itemWidth = fixedSize !== null ? fixedSize : itemSizesX.get(colIndex) - columnGap;
|
|
355
|
+
} else {
|
|
356
|
+
targetX = columnSizes.query(colIndex);
|
|
357
|
+
itemWidth = columnSizes.get(colIndex);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Apply X Alignment
|
|
361
|
+
if (alignX === 'start') {
|
|
362
|
+
// targetX is already at the start of the list
|
|
363
|
+
} else if (alignX === 'center') {
|
|
364
|
+
targetX -= (usableWidth - itemWidth) / 2;
|
|
365
|
+
} else if (alignX === 'end') {
|
|
366
|
+
targetX -= (usableWidth - itemWidth);
|
|
367
|
+
} else {
|
|
368
|
+
const isVisibleX = targetX >= relativeScrollX.value && (targetX + itemWidth) <= (relativeScrollX.value + usableWidth);
|
|
369
|
+
if (!isVisibleX) {
|
|
370
|
+
if (targetX < relativeScrollX.value) {
|
|
371
|
+
// keep targetX at start
|
|
372
|
+
} else {
|
|
373
|
+
targetX -= (usableWidth - itemWidth);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Clamp to valid range
|
|
380
|
+
targetX = Math.max(0, Math.min(targetX, Math.max(0, totalWidth.value - usableWidth)));
|
|
381
|
+
targetY = Math.max(0, Math.min(targetY, Math.max(0, totalHeight.value - usableHeight)));
|
|
382
|
+
|
|
383
|
+
const finalX = targetX + hostOffset.x - (isHorizontal ? paddingStartX : 0);
|
|
384
|
+
const finalY = targetY + hostOffset.y - (isVertical ? paddingStartY : 0);
|
|
385
|
+
|
|
386
|
+
// Check if we reached the target
|
|
387
|
+
const tolerance = 1;
|
|
388
|
+
let reachedX = (colIndex === null || colIndex === undefined) || Math.abs(relativeScrollX.value - targetX) < tolerance;
|
|
389
|
+
let reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(relativeScrollY.value - targetY) < tolerance;
|
|
390
|
+
|
|
391
|
+
if (!reachedX || !reachedY) {
|
|
392
|
+
let curX = 0;
|
|
393
|
+
let curY = 0;
|
|
394
|
+
let maxW = 0;
|
|
395
|
+
let maxH = 0;
|
|
396
|
+
let viewW = 0;
|
|
397
|
+
let viewH = 0;
|
|
398
|
+
|
|
399
|
+
if (typeof window !== 'undefined') {
|
|
400
|
+
if (container === window) {
|
|
401
|
+
curX = window.scrollX;
|
|
402
|
+
curY = window.scrollY;
|
|
403
|
+
maxW = document.documentElement.scrollWidth;
|
|
404
|
+
maxH = document.documentElement.scrollHeight;
|
|
405
|
+
viewW = window.innerWidth;
|
|
406
|
+
viewH = window.innerHeight;
|
|
407
|
+
} else if (isElement(container)) {
|
|
408
|
+
curX = container.scrollLeft;
|
|
409
|
+
curY = container.scrollTop;
|
|
410
|
+
maxW = container.scrollWidth;
|
|
411
|
+
maxH = container.scrollHeight;
|
|
412
|
+
viewW = container.clientWidth;
|
|
413
|
+
viewH = container.clientHeight;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
if (!reachedX && colIndex !== null && colIndex !== undefined) {
|
|
417
|
+
const atLeft = curX <= tolerance && finalX <= tolerance;
|
|
418
|
+
const atRight = curX >= maxW - viewW - tolerance && finalX >= maxW - viewW - tolerance;
|
|
419
|
+
if (atLeft || atRight) {
|
|
420
|
+
reachedX = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!reachedY && rowIndex !== null && rowIndex !== undefined) {
|
|
425
|
+
const atTop = curY <= tolerance && finalY <= tolerance;
|
|
426
|
+
const atBottom = curY >= maxH - viewH - tolerance && finalY >= maxH - viewH - tolerance;
|
|
427
|
+
if (atTop || atBottom) {
|
|
428
|
+
reachedY = true;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
|
|
435
|
+
isProgrammaticScroll.value = true;
|
|
436
|
+
|
|
437
|
+
if (typeof window !== 'undefined' && container === window) {
|
|
438
|
+
window.scrollTo({
|
|
439
|
+
left: (colIndex === null || colIndex === undefined) ? undefined : Math.max(0, finalX),
|
|
440
|
+
top: (rowIndex === null || rowIndex === undefined) ? undefined : Math.max(0, finalY),
|
|
441
|
+
behavior: scrollBehavior,
|
|
442
|
+
} as ScrollToOptions);
|
|
443
|
+
} else if (isScrollableElement(container)) {
|
|
444
|
+
const scrollOptions: ScrollToOptions = {
|
|
445
|
+
behavior: scrollBehavior,
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
if (colIndex !== null && colIndex !== undefined) {
|
|
449
|
+
scrollOptions.left = Math.max(0, finalX);
|
|
450
|
+
}
|
|
451
|
+
if (rowIndex !== null && rowIndex !== undefined) {
|
|
452
|
+
scrollOptions.top = Math.max(0, finalY);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (typeof container.scrollTo === 'function') {
|
|
456
|
+
container.scrollTo(scrollOptions);
|
|
457
|
+
} else {
|
|
458
|
+
if (scrollOptions.left !== undefined) {
|
|
459
|
+
container.scrollLeft = scrollOptions.left;
|
|
460
|
+
}
|
|
461
|
+
if (scrollOptions.top !== undefined) {
|
|
462
|
+
container.scrollTop = scrollOptions.top;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
|
|
468
|
+
if (colIndex !== null && colIndex !== undefined) {
|
|
469
|
+
scrollX.value = Math.max(0, finalX);
|
|
470
|
+
}
|
|
471
|
+
if (rowIndex !== null && rowIndex !== undefined) {
|
|
472
|
+
scrollY.value = Math.max(0, finalY);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (reachedX && reachedY && !isScrolling.value) {
|
|
477
|
+
pendingScroll.value = null;
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Programmatically scroll to a specific pixel offset relative to the content start.
|
|
483
|
+
*
|
|
484
|
+
* @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
|
|
485
|
+
* @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
|
|
486
|
+
* @param options - Scroll options (behavior)
|
|
487
|
+
* @param options.behavior - The scroll behavior ('auto' | 'smooth')
|
|
488
|
+
*/
|
|
489
|
+
const scrollToOffset = (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => {
|
|
490
|
+
const container = props.value.container || window;
|
|
491
|
+
isProgrammaticScroll.value = true;
|
|
492
|
+
|
|
493
|
+
const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
|
|
494
|
+
const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
|
|
495
|
+
|
|
496
|
+
const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
|
|
497
|
+
const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
|
|
498
|
+
|
|
499
|
+
const currentX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
|
|
500
|
+
const currentY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
|
|
501
|
+
|
|
502
|
+
const targetX = (x !== null && x !== undefined) ? x + hostOffset.x - (isHorizontal ? paddingStartX : 0) : currentX;
|
|
503
|
+
const targetY = (y !== null && y !== undefined) ? y + hostOffset.y - (isVertical ? paddingStartY : 0) : currentY;
|
|
504
|
+
|
|
505
|
+
if (typeof window !== 'undefined' && container === window) {
|
|
506
|
+
window.scrollTo({
|
|
507
|
+
left: (x !== null && x !== undefined) ? targetX : undefined,
|
|
508
|
+
top: (y !== null && y !== undefined) ? targetY : undefined,
|
|
509
|
+
behavior: options?.behavior || 'auto',
|
|
510
|
+
} as ScrollToOptions);
|
|
511
|
+
} else if (isScrollableElement(container)) {
|
|
512
|
+
const scrollOptions: ScrollToOptions = {
|
|
513
|
+
behavior: options?.behavior || 'auto',
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
if (x !== null && x !== undefined) {
|
|
517
|
+
scrollOptions.left = targetX;
|
|
518
|
+
}
|
|
519
|
+
if (y !== null && y !== undefined) {
|
|
520
|
+
scrollOptions.top = targetY;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (typeof container.scrollTo === 'function') {
|
|
524
|
+
container.scrollTo(scrollOptions);
|
|
525
|
+
} else {
|
|
526
|
+
if (scrollOptions.left !== undefined) {
|
|
527
|
+
container.scrollLeft = scrollOptions.left;
|
|
528
|
+
}
|
|
529
|
+
if (scrollOptions.top !== undefined) {
|
|
530
|
+
container.scrollTop = scrollOptions.top;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
if (options?.behavior === 'auto' || options?.behavior === undefined) {
|
|
536
|
+
if (x !== null && x !== undefined) {
|
|
537
|
+
scrollX.value = targetX;
|
|
538
|
+
}
|
|
539
|
+
if (y !== null && y !== undefined) {
|
|
540
|
+
scrollY.value = targetY;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// --- Measurement & Initialization ---
|
|
546
|
+
const initializeSizes = () => {
|
|
547
|
+
const newItems = props.value.items;
|
|
548
|
+
const len = newItems.length;
|
|
549
|
+
const colCount = props.value.columnCount || 0;
|
|
550
|
+
|
|
551
|
+
itemSizesX.resize(len);
|
|
552
|
+
itemSizesY.resize(len);
|
|
553
|
+
columnSizes.resize(colCount);
|
|
554
|
+
|
|
555
|
+
if (measuredItemsX.length !== len) {
|
|
556
|
+
const newMeasuredX = new Uint8Array(len);
|
|
557
|
+
newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len, measuredItemsX.length)));
|
|
558
|
+
measuredItemsX = newMeasuredX;
|
|
559
|
+
}
|
|
560
|
+
if (measuredItemsY.length !== len) {
|
|
561
|
+
const newMeasuredY = new Uint8Array(len);
|
|
562
|
+
newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len, measuredItemsY.length)));
|
|
563
|
+
measuredItemsY = newMeasuredY;
|
|
564
|
+
}
|
|
565
|
+
if (measuredColumns.length !== colCount) {
|
|
566
|
+
const newMeasuredCols = new Uint8Array(colCount);
|
|
567
|
+
newMeasuredCols.set(measuredColumns.subarray(0, Math.min(colCount, measuredColumns.length)));
|
|
568
|
+
measuredColumns = newMeasuredCols;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
let prependCount = 0;
|
|
572
|
+
if (props.value.restoreScrollOnPrepend && lastItems.length > 0 && len > lastItems.length) {
|
|
573
|
+
const oldFirstItem = lastItems[ 0 ];
|
|
574
|
+
if (oldFirstItem !== undefined) {
|
|
575
|
+
for (let i = 1; i <= len - lastItems.length; i++) {
|
|
576
|
+
if (newItems[ i ] === oldFirstItem) {
|
|
577
|
+
prependCount = i;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (prependCount > 0) {
|
|
585
|
+
itemSizesX.shift(prependCount);
|
|
586
|
+
itemSizesY.shift(prependCount);
|
|
587
|
+
|
|
588
|
+
if (pendingScroll.value && pendingScroll.value.rowIndex !== null && pendingScroll.value.rowIndex !== undefined) {
|
|
589
|
+
pendingScroll.value.rowIndex += prependCount;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const newMeasuredX = new Uint8Array(len);
|
|
593
|
+
const newMeasuredY = new Uint8Array(len);
|
|
594
|
+
newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len - prependCount, measuredItemsX.length)), prependCount);
|
|
595
|
+
newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len - prependCount, measuredItemsY.length)), prependCount);
|
|
596
|
+
measuredItemsX = newMeasuredX;
|
|
597
|
+
measuredItemsY = newMeasuredY;
|
|
598
|
+
|
|
599
|
+
// Calculate added size
|
|
600
|
+
const gap = props.value.gap || 0;
|
|
601
|
+
const columnGap = props.value.columnGap || 0;
|
|
602
|
+
let addedX = 0;
|
|
603
|
+
let addedY = 0;
|
|
604
|
+
|
|
605
|
+
for (let i = 0; i < prependCount; i++) {
|
|
606
|
+
const size = typeof props.value.itemSize === 'function'
|
|
607
|
+
? props.value.itemSize(newItems[ i ] as T, i)
|
|
608
|
+
: defaultSize.value;
|
|
609
|
+
|
|
610
|
+
if (props.value.direction === 'horizontal') {
|
|
611
|
+
addedX += size + columnGap;
|
|
612
|
+
} else {
|
|
613
|
+
addedY += size + gap;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (addedX > 0 || addedY > 0) {
|
|
618
|
+
nextTick(() => {
|
|
619
|
+
scrollToOffset(
|
|
620
|
+
addedX > 0 ? relativeScrollX.value + addedX : null,
|
|
621
|
+
addedY > 0 ? relativeScrollY.value + addedY : null,
|
|
622
|
+
{ behavior: 'auto' },
|
|
623
|
+
);
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// Initialize columns if fixed width is provided
|
|
629
|
+
if (colCount > 0) {
|
|
630
|
+
const columnGap = props.value.columnGap || 0;
|
|
631
|
+
let colNeedsRebuild = false;
|
|
632
|
+
for (let i = 0; i < colCount; i++) {
|
|
633
|
+
const width = getColumnWidth(i);
|
|
634
|
+
const currentW = columnSizes.get(i);
|
|
635
|
+
|
|
636
|
+
// Only initialize from getColumnWidth if it's not dynamic,
|
|
637
|
+
// OR if it's dynamic but we don't have a measurement yet.
|
|
638
|
+
if (!isDynamicColumnWidth.value || currentW === 0) {
|
|
639
|
+
const targetW = width + columnGap;
|
|
640
|
+
if (Math.abs(currentW - targetW) > 0.5) {
|
|
641
|
+
columnSizes.set(i, targetW);
|
|
642
|
+
colNeedsRebuild = true;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (colNeedsRebuild) {
|
|
647
|
+
columnSizes.rebuild();
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const gap = props.value.gap || 0;
|
|
652
|
+
const columnGap = props.value.columnGap || 0;
|
|
653
|
+
let itemsNeedRebuild = false;
|
|
654
|
+
|
|
655
|
+
for (let i = 0; i < len; i++) {
|
|
656
|
+
const item = props.value.items[ i ];
|
|
657
|
+
const currentX = itemSizesX.get(i);
|
|
658
|
+
const currentY = itemSizesY.get(i);
|
|
659
|
+
|
|
660
|
+
// If it's dynamic and already has a measurement, keep it.
|
|
661
|
+
if (isDynamicItemSize.value && (currentX > 0 || currentY > 0)) {
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const size = typeof props.value.itemSize === 'function'
|
|
666
|
+
? props.value.itemSize(item as T, i)
|
|
667
|
+
: defaultSize.value;
|
|
668
|
+
|
|
669
|
+
const isVertical = props.value.direction === 'vertical';
|
|
670
|
+
const isHorizontal = props.value.direction === 'horizontal';
|
|
671
|
+
const isBoth = props.value.direction === 'both';
|
|
672
|
+
|
|
673
|
+
const targetX = isHorizontal ? size + columnGap : 0;
|
|
674
|
+
const targetY = (isVertical || isBoth) ? size + gap : 0;
|
|
675
|
+
|
|
676
|
+
if (Math.abs(currentX - targetX) > 0.5) {
|
|
677
|
+
itemSizesX.set(i, targetX);
|
|
678
|
+
itemsNeedRebuild = true;
|
|
679
|
+
}
|
|
680
|
+
if (Math.abs(currentY - targetY) > 0.5) {
|
|
681
|
+
itemSizesY.set(i, targetY);
|
|
682
|
+
itemsNeedRebuild = true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Max dimension tracking: determines scrollable area size
|
|
686
|
+
const w = isHorizontal ? size : (isBoth ? Math.max(size, viewportWidth.value) : 0);
|
|
687
|
+
const h = (isVertical || isBoth) ? size : 0;
|
|
688
|
+
|
|
689
|
+
if (w > maxWidth.value) {
|
|
690
|
+
maxWidth.value = w;
|
|
691
|
+
}
|
|
692
|
+
if (h > maxHeight.value) {
|
|
693
|
+
maxHeight.value = h;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (itemsNeedRebuild) {
|
|
698
|
+
itemSizesX.rebuild();
|
|
699
|
+
itemSizesY.rebuild();
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
lastItems = [ ...newItems ];
|
|
703
|
+
sizesInitialized.value = true;
|
|
704
|
+
treeUpdateFlag.value++;
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Updates the host element's offset relative to the scroll container.
|
|
709
|
+
*/
|
|
710
|
+
const updateHostOffset = () => {
|
|
711
|
+
if (props.value.hostElement && typeof window !== 'undefined') {
|
|
712
|
+
const rect = props.value.hostElement.getBoundingClientRect();
|
|
713
|
+
const container = props.value.container || window;
|
|
714
|
+
|
|
715
|
+
let newX = 0;
|
|
716
|
+
let newY = 0;
|
|
717
|
+
|
|
718
|
+
if (container === window) {
|
|
719
|
+
newX = rect.left + window.scrollX;
|
|
720
|
+
newY = rect.top + window.scrollY;
|
|
721
|
+
} else if (container === props.value.hostElement) {
|
|
722
|
+
newX = 0;
|
|
723
|
+
newY = 0;
|
|
724
|
+
} else if (isElement(container)) {
|
|
725
|
+
const containerRect = container.getBoundingClientRect();
|
|
726
|
+
newX = rect.left - containerRect.left + container.scrollLeft;
|
|
727
|
+
newY = rect.top - containerRect.top + container.scrollTop;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
if (Math.abs(hostOffset.x - newX) > 0.1 || Math.abs(hostOffset.y - newY) > 0.1) {
|
|
731
|
+
hostOffset.x = newX;
|
|
732
|
+
hostOffset.y = newY;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
watch([
|
|
738
|
+
() => props.value.items.length,
|
|
739
|
+
() => props.value.columnCount,
|
|
740
|
+
() => props.value.columnWidth,
|
|
741
|
+
() => props.value.itemSize,
|
|
742
|
+
() => props.value.gap,
|
|
743
|
+
() => props.value.columnGap,
|
|
744
|
+
], initializeSizes, { immediate: true });
|
|
745
|
+
|
|
746
|
+
watch(() => [ props.value.container, props.value.hostElement ], () => {
|
|
747
|
+
updateHostOffset();
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
// --- Range & Visible Items ---
|
|
751
|
+
/**
|
|
752
|
+
* Current range of items that should be rendered.
|
|
753
|
+
*/
|
|
754
|
+
const range = computed(() => {
|
|
755
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
756
|
+
treeUpdateFlag.value;
|
|
757
|
+
|
|
758
|
+
if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
|
|
759
|
+
return {
|
|
760
|
+
start: props.value.ssrRange.start,
|
|
761
|
+
end: props.value.ssrRange.end,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const direction = props.value.direction || 'vertical';
|
|
766
|
+
const bufferBefore = (props.value.ssrRange && !isScrolling.value) ? 0 : (props.value.bufferBefore ?? DEFAULT_BUFFER);
|
|
767
|
+
const bufferAfter = props.value.bufferAfter ?? DEFAULT_BUFFER;
|
|
768
|
+
const gap = props.value.gap || 0;
|
|
769
|
+
const columnGap = props.value.columnGap || 0;
|
|
770
|
+
const fixedSize = fixedItemSize.value;
|
|
771
|
+
const paddingStartX = getPaddingX(props.value.scrollPaddingStart, direction);
|
|
772
|
+
const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, direction);
|
|
773
|
+
const paddingStartY = getPaddingY(props.value.scrollPaddingStart, direction);
|
|
774
|
+
const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, direction);
|
|
775
|
+
|
|
776
|
+
const isVertical = direction === 'vertical' || direction === 'both';
|
|
777
|
+
const isHorizontal = direction === 'horizontal' || direction === 'both';
|
|
778
|
+
|
|
779
|
+
const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
|
|
780
|
+
const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
|
|
781
|
+
|
|
782
|
+
let start = 0;
|
|
783
|
+
let end = props.value.items.length;
|
|
784
|
+
|
|
785
|
+
if (isVertical) {
|
|
786
|
+
if (fixedSize !== null) {
|
|
787
|
+
start = Math.floor(relativeScrollY.value / (fixedSize + gap));
|
|
788
|
+
end = Math.ceil((relativeScrollY.value + usableHeight) / (fixedSize + gap));
|
|
789
|
+
} else {
|
|
790
|
+
start = itemSizesY.findLowerBound(relativeScrollY.value);
|
|
791
|
+
let currentY = itemSizesY.query(start);
|
|
792
|
+
let i = start;
|
|
793
|
+
while (i < props.value.items.length && currentY < relativeScrollY.value + usableHeight) {
|
|
794
|
+
currentY = itemSizesY.query(++i);
|
|
795
|
+
}
|
|
796
|
+
end = i;
|
|
797
|
+
}
|
|
798
|
+
} else {
|
|
799
|
+
if (fixedSize !== null) {
|
|
800
|
+
start = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
|
|
801
|
+
end = Math.ceil((relativeScrollX.value + usableWidth) / (fixedSize + columnGap));
|
|
802
|
+
} else {
|
|
803
|
+
start = itemSizesX.findLowerBound(relativeScrollX.value);
|
|
804
|
+
let currentX = itemSizesX.query(start);
|
|
805
|
+
let i = start;
|
|
806
|
+
while (i < props.value.items.length && currentX < relativeScrollX.value + usableWidth) {
|
|
807
|
+
currentX = itemSizesX.query(++i);
|
|
808
|
+
}
|
|
809
|
+
end = i;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return {
|
|
814
|
+
start: Math.max(0, start - bufferBefore),
|
|
815
|
+
end: Math.min(props.value.items.length, end + bufferAfter),
|
|
816
|
+
};
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Index of the first visible item in the viewport.
|
|
821
|
+
*/
|
|
822
|
+
const currentIndex = computed(() => {
|
|
823
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
824
|
+
treeUpdateFlag.value;
|
|
825
|
+
|
|
826
|
+
const fixedSize = fixedItemSize.value;
|
|
827
|
+
const gap = props.value.gap || 0;
|
|
828
|
+
const columnGap = props.value.columnGap || 0;
|
|
829
|
+
|
|
830
|
+
if (props.value.direction === 'horizontal') {
|
|
831
|
+
if (fixedSize !== null) {
|
|
832
|
+
return Math.floor(relativeScrollX.value / (fixedSize + columnGap));
|
|
833
|
+
}
|
|
834
|
+
return itemSizesX.findLowerBound(relativeScrollX.value);
|
|
835
|
+
}
|
|
836
|
+
if (fixedSize !== null) {
|
|
837
|
+
return Math.floor(relativeScrollY.value / (fixedSize + gap));
|
|
838
|
+
}
|
|
839
|
+
return itemSizesY.findLowerBound(relativeScrollY.value);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* List of items to be rendered with their calculated offsets and sizes.
|
|
844
|
+
*/
|
|
845
|
+
const renderedItems = computed<RenderedItem<T>[]>(() => {
|
|
846
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
847
|
+
treeUpdateFlag.value;
|
|
848
|
+
|
|
849
|
+
const { start, end } = range.value;
|
|
850
|
+
const items: RenderedItem<T>[] = [];
|
|
851
|
+
const fixedSize = fixedItemSize.value;
|
|
852
|
+
const gap = props.value.gap || 0;
|
|
853
|
+
const columnGap = props.value.columnGap || 0;
|
|
854
|
+
const stickyIndices = sortedStickyIndices.value;
|
|
855
|
+
|
|
856
|
+
// Always include relevant sticky items
|
|
857
|
+
const indicesToRender = new Set<number>();
|
|
858
|
+
for (let i = start; i < end; i++) {
|
|
859
|
+
indicesToRender.add(i);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (isHydrated.value || !props.value.ssrRange) {
|
|
863
|
+
const activeIdx = currentIndex.value;
|
|
864
|
+
// find the largest index in stickyIndices that is < activeIdx
|
|
865
|
+
let prevStickyIdx: number | undefined;
|
|
866
|
+
let low = 0;
|
|
867
|
+
let high = stickyIndices.length - 1;
|
|
868
|
+
while (low <= high) {
|
|
869
|
+
const mid = (low + high) >>> 1;
|
|
870
|
+
if (stickyIndices[ mid ]! < activeIdx) {
|
|
871
|
+
prevStickyIdx = stickyIndices[ mid ];
|
|
872
|
+
low = mid + 1;
|
|
873
|
+
} else {
|
|
874
|
+
high = mid - 1;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
if (prevStickyIdx !== undefined) {
|
|
879
|
+
indicesToRender.add(prevStickyIdx);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
for (const idx of stickyIndices) {
|
|
883
|
+
if (idx >= start && idx < end) {
|
|
884
|
+
indicesToRender.add(idx);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const sortedIndices = Array.from(indicesToRender).sort((a, b) => a - b);
|
|
890
|
+
|
|
891
|
+
const ssrStartRow = props.value.ssrRange?.start || 0;
|
|
892
|
+
const ssrStartCol = props.value.ssrRange?.colStart || 0;
|
|
893
|
+
|
|
894
|
+
let ssrOffsetX = 0;
|
|
895
|
+
let ssrOffsetY = 0;
|
|
896
|
+
|
|
897
|
+
if (!isHydrated.value && props.value.ssrRange) {
|
|
898
|
+
ssrOffsetY = (props.value.direction === 'vertical' || props.value.direction === 'both')
|
|
899
|
+
? (fixedSize !== null ? ssrStartRow * (fixedSize + gap) : itemSizesY.query(ssrStartRow))
|
|
900
|
+
: 0;
|
|
901
|
+
|
|
902
|
+
if (props.value.direction === 'horizontal') {
|
|
903
|
+
ssrOffsetX = fixedSize !== null ? ssrStartCol * (fixedSize + columnGap) : itemSizesX.query(ssrStartCol);
|
|
904
|
+
} else if (props.value.direction === 'both') {
|
|
905
|
+
ssrOffsetX = columnSizes.query(ssrStartCol);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
for (const i of sortedIndices) {
|
|
910
|
+
const item = props.value.items[ i ];
|
|
911
|
+
if (item === undefined) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
let x = 0;
|
|
916
|
+
let y = 0;
|
|
917
|
+
let width = 0;
|
|
918
|
+
let height = 0;
|
|
919
|
+
|
|
920
|
+
if (props.value.direction === 'horizontal') {
|
|
921
|
+
x = fixedSize !== null ? i * (fixedSize + columnGap) : itemSizesX.query(i);
|
|
922
|
+
width = fixedSize !== null ? fixedSize : itemSizesX.get(i) - columnGap;
|
|
923
|
+
height = viewportHeight.value;
|
|
924
|
+
} else {
|
|
925
|
+
// vertical or both
|
|
926
|
+
y = (props.value.direction === 'vertical' || props.value.direction === 'both') && fixedSize !== null ? i * (fixedSize + gap) : itemSizesY.query(i);
|
|
927
|
+
height = fixedSize !== null ? fixedSize : itemSizesY.get(i) - gap;
|
|
928
|
+
width = props.value.direction === 'both' ? totalWidth.value : viewportWidth.value;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const isSticky = stickyIndices.includes(i);
|
|
932
|
+
const originalX = x;
|
|
933
|
+
const originalY = y;
|
|
934
|
+
let isStickyActive = false;
|
|
935
|
+
const stickyOffset = { x: 0, y: 0 };
|
|
936
|
+
|
|
937
|
+
if (isSticky) {
|
|
938
|
+
if (props.value.direction === 'vertical' || props.value.direction === 'both') {
|
|
939
|
+
if (relativeScrollY.value > originalY) {
|
|
940
|
+
isStickyActive = true;
|
|
941
|
+
// Check if next sticky item pushes this one
|
|
942
|
+
let nextStickyIdx: number | undefined;
|
|
943
|
+
let low = 0;
|
|
944
|
+
let high = stickyIndices.length - 1;
|
|
945
|
+
while (low <= high) {
|
|
946
|
+
const mid = (low + high) >>> 1;
|
|
947
|
+
if (stickyIndices[ mid ]! > i) {
|
|
948
|
+
nextStickyIdx = stickyIndices[ mid ];
|
|
949
|
+
high = mid - 1;
|
|
950
|
+
} else {
|
|
951
|
+
low = mid + 1;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (nextStickyIdx !== undefined) {
|
|
956
|
+
const nextStickyY = fixedSize !== null ? nextStickyIdx * (fixedSize + gap) : itemSizesY.query(nextStickyIdx);
|
|
957
|
+
const distance = nextStickyY - relativeScrollY.value;
|
|
958
|
+
if (distance < height) {
|
|
959
|
+
stickyOffset.y = -(height - distance);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
} else if (props.value.direction === 'horizontal') {
|
|
964
|
+
if (relativeScrollX.value > originalX) {
|
|
965
|
+
isStickyActive = true;
|
|
966
|
+
// Check if next sticky item pushes this one
|
|
967
|
+
let nextStickyIdx: number | undefined;
|
|
968
|
+
let low = 0;
|
|
969
|
+
let high = stickyIndices.length - 1;
|
|
970
|
+
while (low <= high) {
|
|
971
|
+
const mid = (low + high) >>> 1;
|
|
972
|
+
if (stickyIndices[ mid ]! > i) {
|
|
973
|
+
nextStickyIdx = stickyIndices[ mid ];
|
|
974
|
+
high = mid - 1;
|
|
975
|
+
} else {
|
|
976
|
+
low = mid + 1;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (nextStickyIdx !== undefined) {
|
|
981
|
+
const nextStickyX = fixedSize !== null ? nextStickyIdx * (fixedSize + columnGap) : itemSizesX.query(nextStickyIdx);
|
|
982
|
+
const distance = nextStickyX - relativeScrollX.value;
|
|
983
|
+
if (distance < width) {
|
|
984
|
+
stickyOffset.x = -(width - distance);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
items.push({
|
|
992
|
+
item,
|
|
993
|
+
index: i,
|
|
994
|
+
offset: { x: originalX - ssrOffsetX, y: originalY - ssrOffsetY },
|
|
995
|
+
size: { width, height },
|
|
996
|
+
originalX,
|
|
997
|
+
originalY,
|
|
998
|
+
isSticky,
|
|
999
|
+
isStickyActive,
|
|
1000
|
+
stickyOffset,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
return items;
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
const columnRange = computed(() => {
|
|
1007
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
1008
|
+
treeUpdateFlag.value;
|
|
1009
|
+
|
|
1010
|
+
const totalCols = props.value.columnCount || 0;
|
|
1011
|
+
|
|
1012
|
+
if (!totalCols) {
|
|
1013
|
+
return { start: 0, end: 0, padStart: 0, padEnd: 0 };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
|
|
1017
|
+
const { colStart = 0, colEnd = 0 } = props.value.ssrRange;
|
|
1018
|
+
const safeStart = Math.max(0, colStart);
|
|
1019
|
+
const safeEnd = Math.min(totalCols, colEnd || totalCols);
|
|
1020
|
+
return {
|
|
1021
|
+
start: safeStart,
|
|
1022
|
+
end: safeEnd,
|
|
1023
|
+
padStart: 0,
|
|
1024
|
+
padEnd: 0,
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const start = columnSizes.findLowerBound(relativeScrollX.value);
|
|
1029
|
+
let currentX = columnSizes.query(start);
|
|
1030
|
+
let end = start;
|
|
1031
|
+
|
|
1032
|
+
while (end < totalCols && currentX < relativeScrollX.value + viewportWidth.value) {
|
|
1033
|
+
currentX = columnSizes.query(++end);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
|
|
1037
|
+
|
|
1038
|
+
// Add buffer of columns
|
|
1039
|
+
const safeStart = Math.max(0, start - colBuffer);
|
|
1040
|
+
const safeEnd = Math.min(totalCols, end + colBuffer);
|
|
1041
|
+
|
|
1042
|
+
return {
|
|
1043
|
+
start: safeStart,
|
|
1044
|
+
end: safeEnd,
|
|
1045
|
+
padStart: columnSizes.query(safeStart),
|
|
1046
|
+
padEnd: columnSizes.query(totalCols) - columnSizes.query(safeEnd),
|
|
1047
|
+
};
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Detailed information about the current scroll state.
|
|
1052
|
+
*/
|
|
1053
|
+
const scrollDetails = computed<ScrollDetails<T>>(() => {
|
|
1054
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
1055
|
+
treeUpdateFlag.value;
|
|
1056
|
+
|
|
1057
|
+
const fixedSize = fixedItemSize.value;
|
|
1058
|
+
const columnGap = props.value.columnGap || 0;
|
|
1059
|
+
|
|
1060
|
+
let currentColIndex = 0;
|
|
1061
|
+
if (props.value.direction === 'horizontal' || props.value.direction === 'both') {
|
|
1062
|
+
if (fixedSize !== null) {
|
|
1063
|
+
currentColIndex = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
|
|
1064
|
+
} else {
|
|
1065
|
+
currentColIndex = itemSizesX.findLowerBound(relativeScrollX.value);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
return {
|
|
1070
|
+
items: renderedItems.value,
|
|
1071
|
+
currentIndex: currentIndex.value,
|
|
1072
|
+
currentColIndex,
|
|
1073
|
+
scrollOffset: { x: relativeScrollX.value, y: relativeScrollY.value },
|
|
1074
|
+
viewportSize: { width: viewportWidth.value, height: viewportHeight.value },
|
|
1075
|
+
totalSize: { width: totalWidth.value, height: totalHeight.value },
|
|
1076
|
+
isScrolling: isScrolling.value,
|
|
1077
|
+
isProgrammaticScroll: isProgrammaticScroll.value,
|
|
1078
|
+
range: range.value,
|
|
1079
|
+
columnRange: columnRange.value,
|
|
1080
|
+
};
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
// --- Event Handlers & Lifecycle ---
|
|
1084
|
+
/**
|
|
1085
|
+
* Stops any currently active programmatic scroll and clears pending corrections.
|
|
1086
|
+
*/
|
|
1087
|
+
const stopProgrammaticScroll = () => {
|
|
1088
|
+
isProgrammaticScroll.value = false;
|
|
1089
|
+
pendingScroll.value = null;
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Event handler for scroll events.
|
|
1094
|
+
*/
|
|
1095
|
+
const handleScroll = (e: Event) => {
|
|
1096
|
+
const target = e.target;
|
|
1097
|
+
if (typeof window === 'undefined') {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (target === window || target === document) {
|
|
1102
|
+
scrollX.value = window.scrollX;
|
|
1103
|
+
scrollY.value = window.scrollY;
|
|
1104
|
+
} else if (isScrollableElement(target)) {
|
|
1105
|
+
scrollX.value = target.scrollLeft;
|
|
1106
|
+
scrollY.value = target.scrollTop;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (!isScrolling.value) {
|
|
1110
|
+
if (!isProgrammaticScroll.value) {
|
|
1111
|
+
pendingScroll.value = null;
|
|
1112
|
+
}
|
|
1113
|
+
isScrolling.value = true;
|
|
1114
|
+
}
|
|
1115
|
+
clearTimeout(scrollTimeout);
|
|
1116
|
+
scrollTimeout = setTimeout(() => {
|
|
1117
|
+
isScrolling.value = false;
|
|
1118
|
+
isProgrammaticScroll.value = false;
|
|
1119
|
+
}, 250);
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Updates the size of multiple items in the Fenwick tree.
|
|
1124
|
+
*
|
|
1125
|
+
* @param updates - Array of updates
|
|
1126
|
+
*/
|
|
1127
|
+
const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
|
|
1128
|
+
let needUpdate = false;
|
|
1129
|
+
const gap = props.value.gap || 0;
|
|
1130
|
+
const columnGap = props.value.columnGap || 0;
|
|
1131
|
+
|
|
1132
|
+
for (const { index, inlineSize, blockSize, element } of updates) {
|
|
1133
|
+
if (isDynamicItemSize.value) {
|
|
1134
|
+
if (inlineSize > maxWidth.value) {
|
|
1135
|
+
maxWidth.value = inlineSize;
|
|
1136
|
+
}
|
|
1137
|
+
if (blockSize > maxHeight.value) {
|
|
1138
|
+
maxHeight.value = blockSize;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (props.value.direction === 'horizontal') {
|
|
1142
|
+
const oldWidth = itemSizesX.get(index);
|
|
1143
|
+
const targetWidth = inlineSize + columnGap;
|
|
1144
|
+
if (Math.abs(oldWidth - targetWidth) > 0.5 && (targetWidth > oldWidth || !measuredItemsX[ index ])) {
|
|
1145
|
+
itemSizesX.update(index, targetWidth - oldWidth);
|
|
1146
|
+
measuredItemsX[ index ] = 1;
|
|
1147
|
+
needUpdate = true;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
if (props.value.direction === 'vertical' || props.value.direction === 'both') {
|
|
1151
|
+
const oldHeight = itemSizesY.get(index);
|
|
1152
|
+
const targetHeight = blockSize + gap;
|
|
1153
|
+
// For grid, keep max height encountered to avoid shrinking on horizontal scroll
|
|
1154
|
+
if (props.value.direction === 'both') {
|
|
1155
|
+
if (targetHeight > oldHeight || !measuredItemsY[ index ]) {
|
|
1156
|
+
itemSizesY.update(index, targetHeight - oldHeight);
|
|
1157
|
+
measuredItemsY[ index ] = 1;
|
|
1158
|
+
needUpdate = true;
|
|
1159
|
+
}
|
|
1160
|
+
} else if (Math.abs(oldHeight - targetHeight) > 0.5 && (targetHeight > oldHeight || !measuredItemsY[ index ])) {
|
|
1161
|
+
itemSizesY.update(index, targetHeight - oldHeight);
|
|
1162
|
+
measuredItemsY[ index ] = 1;
|
|
1163
|
+
needUpdate = true;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// Dynamic column width measurement
|
|
1169
|
+
if (
|
|
1170
|
+
props.value.direction === 'both'
|
|
1171
|
+
&& element
|
|
1172
|
+
&& props.value.columnCount
|
|
1173
|
+
&& isDynamicColumnWidth.value
|
|
1174
|
+
) {
|
|
1175
|
+
const cells = element.dataset.colIndex !== undefined
|
|
1176
|
+
? [ element ]
|
|
1177
|
+
: Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
|
|
1178
|
+
|
|
1179
|
+
for (const child of cells) {
|
|
1180
|
+
const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
|
|
1181
|
+
|
|
1182
|
+
if (colIndex >= 0 && colIndex < (props.value.columnCount || 0)) {
|
|
1183
|
+
const w = child.offsetWidth;
|
|
1184
|
+
const oldW = columnSizes.get(colIndex);
|
|
1185
|
+
const targetW = w + columnGap;
|
|
1186
|
+
if (targetW > oldW || !measuredColumns[ colIndex ]) {
|
|
1187
|
+
columnSizes.update(colIndex, targetW - oldW);
|
|
1188
|
+
measuredColumns[ colIndex ] = 1;
|
|
1189
|
+
needUpdate = true;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
if (needUpdate) {
|
|
1197
|
+
treeUpdateFlag.value++;
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* Updates the size of a specific item in the Fenwick tree.
|
|
1203
|
+
*
|
|
1204
|
+
* @param index - Index of the item
|
|
1205
|
+
* @param inlineSize - New inlineSize
|
|
1206
|
+
* @param blockSize - New blockSize
|
|
1207
|
+
* @param element - The element that was measured (optional)
|
|
1208
|
+
*/
|
|
1209
|
+
const updateItemSize = (index: number, inlineSize: number, blockSize: number, element?: HTMLElement) => {
|
|
1210
|
+
updateItemSizes([ { index, inlineSize, blockSize, element } ]);
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
// --- Scroll Queue / Correction Watchers ---
|
|
1214
|
+
const checkPendingScroll = () => {
|
|
1215
|
+
if (pendingScroll.value && !isHydrating.value) {
|
|
1216
|
+
const { rowIndex, colIndex, options } = pendingScroll.value;
|
|
1217
|
+
const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
|
|
1218
|
+
? { ...options, isCorrection: true }
|
|
1219
|
+
: { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
|
|
1220
|
+
scrollToIndex(rowIndex, colIndex, correctionOptions);
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
|
|
1224
|
+
watch(treeUpdateFlag, checkPendingScroll);
|
|
1225
|
+
|
|
1226
|
+
watch(isScrolling, (scrolling) => {
|
|
1227
|
+
if (!scrolling) {
|
|
1228
|
+
checkPendingScroll();
|
|
1229
|
+
}
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
1233
|
+
|
|
1234
|
+
const attachEvents = (container: HTMLElement | Window | null) => {
|
|
1235
|
+
if (!container || typeof window === 'undefined') {
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const scrollTarget = container === window ? document : container;
|
|
1239
|
+
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
|
|
1240
|
+
|
|
1241
|
+
if (container === window) {
|
|
1242
|
+
viewportWidth.value = window.innerWidth;
|
|
1243
|
+
viewportHeight.value = window.innerHeight;
|
|
1244
|
+
scrollX.value = window.scrollX;
|
|
1245
|
+
scrollY.value = window.scrollY;
|
|
1246
|
+
|
|
1247
|
+
const onResize = () => {
|
|
1248
|
+
viewportWidth.value = window.innerWidth;
|
|
1249
|
+
viewportHeight.value = window.innerHeight;
|
|
1250
|
+
updateHostOffset();
|
|
1251
|
+
};
|
|
1252
|
+
window.addEventListener('resize', onResize);
|
|
1253
|
+
return () => {
|
|
1254
|
+
scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1255
|
+
window.removeEventListener('resize', onResize);
|
|
1256
|
+
};
|
|
1257
|
+
} else {
|
|
1258
|
+
viewportWidth.value = (container as HTMLElement).clientWidth;
|
|
1259
|
+
viewportHeight.value = (container as HTMLElement).clientHeight;
|
|
1260
|
+
scrollX.value = (container as HTMLElement).scrollLeft;
|
|
1261
|
+
scrollY.value = (container as HTMLElement).scrollTop;
|
|
1262
|
+
|
|
1263
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
1264
|
+
for (const entry of entries) {
|
|
1265
|
+
if (entry.target === container) {
|
|
1266
|
+
viewportWidth.value = (container as HTMLElement).clientWidth;
|
|
1267
|
+
viewportHeight.value = (container as HTMLElement).clientHeight;
|
|
1268
|
+
updateHostOffset();
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
resizeObserver.observe(container as HTMLElement);
|
|
1273
|
+
return () => {
|
|
1274
|
+
scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1275
|
+
resizeObserver?.disconnect();
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
let cleanup: (() => void) | undefined;
|
|
1281
|
+
|
|
1282
|
+
if (getCurrentInstance()) {
|
|
1283
|
+
onMounted(() => {
|
|
1284
|
+
isMounted.value = true;
|
|
1285
|
+
|
|
1286
|
+
watch(() => props.value.container, (newContainer) => {
|
|
1287
|
+
cleanup?.();
|
|
1288
|
+
cleanup = attachEvents(newContainer || null);
|
|
1289
|
+
}, { immediate: true });
|
|
1290
|
+
|
|
1291
|
+
updateHostOffset();
|
|
1292
|
+
|
|
1293
|
+
if (props.value.ssrRange || props.value.initialScrollIndex !== undefined) {
|
|
1294
|
+
nextTick(() => {
|
|
1295
|
+
updateHostOffset();
|
|
1296
|
+
const initialIndex = props.value.initialScrollIndex !== undefined
|
|
1297
|
+
? props.value.initialScrollIndex
|
|
1298
|
+
: props.value.ssrRange?.start;
|
|
1299
|
+
const initialAlign = props.value.initialScrollAlign || 'start';
|
|
1300
|
+
|
|
1301
|
+
if (initialIndex !== undefined && initialIndex !== null) {
|
|
1302
|
+
scrollToIndex(initialIndex, props.value.ssrRange?.colStart, { align: initialAlign, behavior: 'auto' });
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
isHydrated.value = true;
|
|
1306
|
+
isHydrating.value = true;
|
|
1307
|
+
nextTick(() => {
|
|
1308
|
+
isHydrating.value = false;
|
|
1309
|
+
});
|
|
1310
|
+
});
|
|
1311
|
+
} else {
|
|
1312
|
+
isHydrated.value = true;
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
|
|
1316
|
+
onUnmounted(() => {
|
|
1317
|
+
cleanup?.();
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/**
|
|
1322
|
+
* The list of items currently rendered in the DOM.
|
|
1323
|
+
*/
|
|
1324
|
+
const refresh = () => {
|
|
1325
|
+
itemSizesX.resize(0);
|
|
1326
|
+
itemSizesY.resize(0);
|
|
1327
|
+
columnSizes.resize(0);
|
|
1328
|
+
measuredColumns.fill(0);
|
|
1329
|
+
measuredItemsX.fill(0);
|
|
1330
|
+
measuredItemsY.fill(0);
|
|
1331
|
+
maxWidth.value = 0;
|
|
1332
|
+
maxHeight.value = 0;
|
|
1333
|
+
initializeSizes();
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
return {
|
|
1337
|
+
/**
|
|
1338
|
+
* Array of items to be rendered with their calculated offsets and sizes.
|
|
1339
|
+
*/
|
|
1340
|
+
renderedItems,
|
|
1341
|
+
/**
|
|
1342
|
+
* Total calculated width of all items including gaps.
|
|
1343
|
+
*/
|
|
1344
|
+
totalWidth,
|
|
1345
|
+
/**
|
|
1346
|
+
* Total calculated height of all items including gaps.
|
|
1347
|
+
*/
|
|
1348
|
+
totalHeight,
|
|
1349
|
+
/**
|
|
1350
|
+
* Detailed information about the current scroll state.
|
|
1351
|
+
* Includes currentIndex, scrollOffset, viewportSize, totalSize, and isScrolling.
|
|
1352
|
+
*/
|
|
1353
|
+
scrollDetails,
|
|
1354
|
+
/**
|
|
1355
|
+
* Programmatically scroll to a specific row and/or column.
|
|
1356
|
+
* @param rowIndex - The row index to scroll to
|
|
1357
|
+
* @param colIndex - The column index to scroll to
|
|
1358
|
+
* @param options - Alignment and behavior options
|
|
1359
|
+
*/
|
|
1360
|
+
scrollToIndex,
|
|
1361
|
+
/**
|
|
1362
|
+
* Programmatically scroll to a specific pixel offset.
|
|
1363
|
+
* @param x - The pixel offset to scroll to on the X axis
|
|
1364
|
+
* @param y - The pixel offset to scroll to on the Y axis
|
|
1365
|
+
* @param options - Behavior options
|
|
1366
|
+
*/
|
|
1367
|
+
scrollToOffset,
|
|
1368
|
+
/**
|
|
1369
|
+
* Stops any currently active programmatic scroll and clears pending corrections.
|
|
1370
|
+
*/
|
|
1371
|
+
stopProgrammaticScroll,
|
|
1372
|
+
/**
|
|
1373
|
+
* Updates the stored size of an item. Should be called when an item is measured (e.g., via ResizeObserver).
|
|
1374
|
+
* @param index - The item index
|
|
1375
|
+
* @param width - The measured width
|
|
1376
|
+
* @param height - The measured height
|
|
1377
|
+
* @param element - The measured element (optional, used for grid column detection)
|
|
1378
|
+
*/
|
|
1379
|
+
updateItemSize,
|
|
1380
|
+
/**
|
|
1381
|
+
* Updates the stored size of multiple items. Should be called when items are measured (e.g., via ResizeObserver).
|
|
1382
|
+
* @param updates - Array of item updates
|
|
1383
|
+
*/
|
|
1384
|
+
updateItemSizes,
|
|
1385
|
+
/**
|
|
1386
|
+
* Recalculates the host element's offset relative to the scroll container.
|
|
1387
|
+
*/
|
|
1388
|
+
updateHostOffset,
|
|
1389
|
+
/**
|
|
1390
|
+
* Information about the current visible range of columns.
|
|
1391
|
+
*/
|
|
1392
|
+
columnRange,
|
|
1393
|
+
/**
|
|
1394
|
+
* Helper to get the width of a specific column based on current configuration.
|
|
1395
|
+
* @param index - The column index
|
|
1396
|
+
*/
|
|
1397
|
+
getColumnWidth,
|
|
1398
|
+
/**
|
|
1399
|
+
* Resets all dynamic measurements and re-initializes from props.
|
|
1400
|
+
*/
|
|
1401
|
+
refresh,
|
|
1402
|
+
/**
|
|
1403
|
+
* Whether the component has finished its first client-side mount and hydration.
|
|
1404
|
+
*/
|
|
1405
|
+
isHydrated,
|
|
1406
|
+
};
|
|
1407
|
+
}
|