@pdanpdan/virtual-scroll 0.3.0 → 0.5.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 +268 -275
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1497 -192
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2219 -896
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -2
- package/package.json +5 -1
- package/src/components/VirtualScroll.test.ts +1979 -627
- package/src/components/VirtualScroll.vue +951 -349
- package/src/components/VirtualScrollbar.test.ts +174 -0
- package/src/components/VirtualScrollbar.vue +102 -0
- package/src/composables/useVirtualScroll.test.ts +1160 -1521
- package/src/composables/useVirtualScroll.ts +1135 -791
- package/src/composables/useVirtualScrollbar.test.ts +526 -0
- package/src/composables/useVirtualScrollbar.ts +239 -0
- package/src/index.ts +4 -0
- package/src/types.ts +816 -0
- package/src/utils/fenwick-tree.test.ts +39 -39
- package/src/utils/fenwick-tree.ts +38 -18
- package/src/utils/scroll.test.ts +174 -0
- package/src/utils/scroll.ts +50 -13
- package/src/utils/virtual-scroll-logic.test.ts +2850 -0
- package/src/utils/virtual-scroll-logic.ts +901 -0
|
@@ -1,144 +1,55 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
RenderedItem,
|
|
3
|
+
ScrollAlignment,
|
|
4
|
+
ScrollAlignmentOptions,
|
|
5
|
+
ScrollDetails,
|
|
6
|
+
ScrollDirection,
|
|
7
|
+
ScrollToIndexOptions,
|
|
8
|
+
VirtualScrollProps,
|
|
9
|
+
} from '../types';
|
|
10
|
+
import type { MaybeRefOrGetter } from 'vue';
|
|
2
11
|
|
|
3
12
|
/* global ScrollToOptions */
|
|
4
|
-
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
|
13
|
+
import { computed, getCurrentInstance, nextTick, onMounted, onUnmounted, reactive, ref, toValue, watch } from 'vue';
|
|
5
14
|
|
|
6
15
|
import { FenwickTree } from '../utils/fenwick-tree';
|
|
7
|
-
import { getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions } from '../utils/scroll';
|
|
16
|
+
import { BROWSER_MAX_SIZE, getPaddingX, getPaddingY, isElement, isScrollableElement, isScrollToIndexOptions, isWindowLike } from '../utils/scroll';
|
|
17
|
+
import {
|
|
18
|
+
calculateColumnRange,
|
|
19
|
+
calculateItemPosition,
|
|
20
|
+
calculateRange,
|
|
21
|
+
calculateScrollTarget,
|
|
22
|
+
calculateStickyItem,
|
|
23
|
+
calculateTotalSize,
|
|
24
|
+
displayToVirtual,
|
|
25
|
+
findPrevStickyIndex,
|
|
26
|
+
virtualToDisplay,
|
|
27
|
+
} from '../utils/virtual-scroll-logic';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
type RenderedItem,
|
|
31
|
+
type ScrollAlignment,
|
|
32
|
+
type ScrollAlignmentOptions,
|
|
33
|
+
type ScrollDetails,
|
|
34
|
+
type ScrollDirection,
|
|
35
|
+
type ScrollToIndexOptions,
|
|
36
|
+
type VirtualScrollProps,
|
|
37
|
+
};
|
|
8
38
|
|
|
9
39
|
export const DEFAULT_ITEM_SIZE = 40;
|
|
10
40
|
export const DEFAULT_COLUMN_WIDTH = 100;
|
|
11
41
|
export const DEFAULT_BUFFER = 5;
|
|
12
42
|
|
|
13
|
-
export type ScrollDirection = 'vertical' | 'horizontal' | 'both';
|
|
14
|
-
export type ScrollAlignment = 'start' | 'center' | 'end' | 'auto';
|
|
15
|
-
|
|
16
|
-
/** Options for scroll alignment in a single axis or both axes. */
|
|
17
|
-
export interface ScrollAlignmentOptions {
|
|
18
|
-
/** Alignment on the X axis. */
|
|
19
|
-
x?: ScrollAlignment;
|
|
20
|
-
/** Alignment on the Y axis. */
|
|
21
|
-
y?: ScrollAlignment;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/** Options for the scrollToIndex method. */
|
|
25
|
-
export interface ScrollToIndexOptions {
|
|
26
|
-
/** Where to align the item in the viewport. */
|
|
27
|
-
align?: ScrollAlignment | ScrollAlignmentOptions;
|
|
28
|
-
/** Scroll behavior. */
|
|
29
|
-
behavior?: 'auto' | 'smooth';
|
|
30
|
-
/** Internal flag for recursive correction calls. */
|
|
31
|
-
isCorrection?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Configuration properties for the useVirtualScroll composable. */
|
|
35
|
-
export interface VirtualScrollProps<T = unknown> {
|
|
36
|
-
/** Array of items to be virtualized. */
|
|
37
|
-
items: T[];
|
|
38
|
-
/** Fixed size of each item or a function that returns the size of an item. */
|
|
39
|
-
itemSize?: number | ((item: T, index: number) => number) | undefined;
|
|
40
|
-
/** Direction of the scroll: 'vertical', 'horizontal', or 'both'. */
|
|
41
|
-
direction?: ScrollDirection | undefined;
|
|
42
|
-
/** Number of items to render before the visible viewport. */
|
|
43
|
-
bufferBefore?: number | undefined;
|
|
44
|
-
/** Number of items to render after the visible viewport. */
|
|
45
|
-
bufferAfter?: number | undefined;
|
|
46
|
-
/** The scrollable container element or window. */
|
|
47
|
-
container?: HTMLElement | Window | null | undefined;
|
|
48
|
-
/** The host element that contains the items. */
|
|
49
|
-
hostElement?: HTMLElement | null | undefined;
|
|
50
|
-
/** Range of items to render for SSR. */
|
|
51
|
-
ssrRange?: {
|
|
52
|
-
start: number;
|
|
53
|
-
end: number;
|
|
54
|
-
colStart?: number;
|
|
55
|
-
colEnd?: number;
|
|
56
|
-
} | undefined;
|
|
57
|
-
/** Number of columns for bidirectional scroll. */
|
|
58
|
-
columnCount?: number | undefined;
|
|
59
|
-
/** Fixed width of columns or an array/function for column widths. */
|
|
60
|
-
columnWidth?: number | number[] | ((index: number) => number) | undefined;
|
|
61
|
-
/** Padding at the start of the scroll container. */
|
|
62
|
-
scrollPaddingStart?: number | { x?: number; y?: number; } | undefined;
|
|
63
|
-
/** Padding at the end of the scroll container. */
|
|
64
|
-
scrollPaddingEnd?: number | { x?: number; y?: number; } | undefined;
|
|
65
|
-
/** Gap between items in pixels (vertical). */
|
|
66
|
-
gap?: number | undefined;
|
|
67
|
-
/** Gap between columns in pixels (horizontal/grid). */
|
|
68
|
-
columnGap?: number | undefined;
|
|
69
|
-
/** Indices of items that should stick to the top/start. */
|
|
70
|
-
stickyIndices?: number[] | undefined;
|
|
71
|
-
/** Distance from the end of the scrollable area to trigger 'load' event. */
|
|
72
|
-
loadDistance?: number | undefined;
|
|
73
|
-
/** Whether items are currently being loaded. */
|
|
74
|
-
loading?: boolean | undefined;
|
|
75
|
-
/** Whether to restore scroll position when items are prepended. */
|
|
76
|
-
restoreScrollOnPrepend?: boolean | undefined;
|
|
77
|
-
/** Initial scroll index to jump to on mount. */
|
|
78
|
-
initialScrollIndex?: number | undefined;
|
|
79
|
-
/** Alignment for the initial scroll index. */
|
|
80
|
-
initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions | undefined;
|
|
81
|
-
/** Default size for items before they are measured. */
|
|
82
|
-
defaultItemSize?: number | undefined;
|
|
83
|
-
/** Default width for columns before they are measured. */
|
|
84
|
-
defaultColumnWidth?: number | undefined;
|
|
85
|
-
/** Whether to enable debug mode. */
|
|
86
|
-
debug?: boolean | undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Represents an item currently rendered in the virtual scroll area. */
|
|
90
|
-
export interface RenderedItem<T = unknown> {
|
|
91
|
-
/** The original data item. */
|
|
92
|
-
item: T;
|
|
93
|
-
/** The index of the item in the original array. */
|
|
94
|
-
index: number;
|
|
95
|
-
/** The calculated offset relative to the host element. */
|
|
96
|
-
offset: { x: number; y: number; };
|
|
97
|
-
/** The current measured or estimated size. */
|
|
98
|
-
size: { width: number; height: number; };
|
|
99
|
-
/** The original X offset before sticky adjustments. */
|
|
100
|
-
originalX: number;
|
|
101
|
-
/** The original Y offset before sticky adjustments. */
|
|
102
|
-
originalY: number;
|
|
103
|
-
/** Whether this item is configured to be sticky. */
|
|
104
|
-
isSticky?: boolean;
|
|
105
|
-
/** Whether this item is currently stuck at the threshold. */
|
|
106
|
-
isStickyActive?: boolean;
|
|
107
|
-
/** The offset applied for the sticky pushing effect. */
|
|
108
|
-
stickyOffset: { x: number; y: number; };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Comprehensive state of the virtual scroll system. */
|
|
112
|
-
export interface ScrollDetails<T = unknown> {
|
|
113
|
-
/** List of items currently rendered. */
|
|
114
|
-
items: RenderedItem<T>[];
|
|
115
|
-
/** Index of the first item partially or fully visible in the viewport. */
|
|
116
|
-
currentIndex: number;
|
|
117
|
-
/** Index of the first column partially or fully visible. */
|
|
118
|
-
currentColIndex: number;
|
|
119
|
-
/** Current scroll position relative to content start. */
|
|
120
|
-
scrollOffset: { x: number; y: number; };
|
|
121
|
-
/** Dimensions of the visible viewport. */
|
|
122
|
-
viewportSize: { width: number; height: number; };
|
|
123
|
-
/** Total calculated size of all items and gaps. */
|
|
124
|
-
totalSize: { width: number; height: number; };
|
|
125
|
-
/** Whether the container is currently being scrolled. */
|
|
126
|
-
isScrolling: boolean;
|
|
127
|
-
/** Whether the current scroll was initiated by a method call. */
|
|
128
|
-
isProgrammaticScroll: boolean;
|
|
129
|
-
/** Range of items currently being rendered. */
|
|
130
|
-
range: { start: number; end: number; };
|
|
131
|
-
/** Range of columns currently being rendered (for grid mode). */
|
|
132
|
-
columnRange: { start: number; end: number; padStart: number; padEnd: number; };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
43
|
/**
|
|
136
44
|
* Composable for virtual scrolling logic.
|
|
137
|
-
* Handles calculation of visible items, scroll events,
|
|
45
|
+
* Handles calculation of visible items, scroll events, dynamic item sizes, and programmatic scrolling.
|
|
138
46
|
*
|
|
139
|
-
* @param
|
|
47
|
+
* @param propsInput - The configuration properties. Can be a plain object, a Ref, or a getter function.
|
|
48
|
+
* @see VirtualScrollProps
|
|
140
49
|
*/
|
|
141
|
-
export function useVirtualScroll<T = unknown>(
|
|
50
|
+
export function useVirtualScroll<T = unknown>(propsInput: MaybeRefOrGetter<VirtualScrollProps<T>>) {
|
|
51
|
+
const props = computed(() => toValue(propsInput));
|
|
52
|
+
|
|
142
53
|
// --- State ---
|
|
143
54
|
const scrollX = ref(0);
|
|
144
55
|
const scrollY = ref(0);
|
|
@@ -146,16 +57,42 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
146
57
|
const isHydrated = ref(false);
|
|
147
58
|
const isHydrating = ref(false);
|
|
148
59
|
const isMounted = ref(false);
|
|
60
|
+
const isRtl = ref(false);
|
|
149
61
|
const viewportWidth = ref(0);
|
|
150
62
|
const viewportHeight = ref(0);
|
|
151
63
|
const hostOffset = reactive({ x: 0, y: 0 });
|
|
64
|
+
const hostRefOffset = reactive({ x: 0, y: 0 });
|
|
152
65
|
let scrollTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
153
66
|
|
|
154
67
|
const isProgrammaticScroll = ref(false);
|
|
68
|
+
const internalScrollX = ref(0);
|
|
69
|
+
const internalScrollY = ref(0);
|
|
70
|
+
|
|
71
|
+
let computedStyle: CSSStyleDeclaration | null = null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Detects the current direction (LTR/RTL) of the scroll container.
|
|
75
|
+
*/
|
|
76
|
+
const updateDirection = () => {
|
|
77
|
+
if (typeof window === 'undefined') {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const container = props.value.container || props.value.hostRef || window;
|
|
81
|
+
const el = isElement(container) ? container : document.documentElement;
|
|
82
|
+
|
|
83
|
+
if (!computedStyle || !('direction' in computedStyle)) {
|
|
84
|
+
computedStyle = window.getComputedStyle(el);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const newRtl = computedStyle.direction === 'rtl';
|
|
88
|
+
if (isRtl.value !== newRtl) {
|
|
89
|
+
isRtl.value = newRtl;
|
|
90
|
+
}
|
|
91
|
+
};
|
|
155
92
|
|
|
156
93
|
// --- Fenwick Trees for efficient size and offset management ---
|
|
157
|
-
const itemSizesX = new FenwickTree(props.value.items
|
|
158
|
-
const itemSizesY = new FenwickTree(props.value.items
|
|
94
|
+
const itemSizesX = new FenwickTree(props.value.items?.length || 0);
|
|
95
|
+
const itemSizesY = new FenwickTree(props.value.items?.length || 0);
|
|
159
96
|
const columnSizes = new FenwickTree(props.value.columnCount || 0);
|
|
160
97
|
|
|
161
98
|
const treeUpdateFlag = ref(0);
|
|
@@ -176,6 +113,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
176
113
|
let lastItems: T[] = [];
|
|
177
114
|
|
|
178
115
|
// --- Computed Config ---
|
|
116
|
+
const direction = computed(() => [ 'vertical', 'horizontal', 'both' ].includes(props.value.direction as string) ? props.value.direction as ScrollDirection : 'vertical' as ScrollDirection);
|
|
117
|
+
|
|
179
118
|
const isDynamicItemSize = computed(() =>
|
|
180
119
|
props.value.itemSize === undefined || props.value.itemSize === null || props.value.itemSize === 0,
|
|
181
120
|
);
|
|
@@ -188,108 +127,175 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
188
127
|
(typeof props.value.itemSize === 'number' && props.value.itemSize > 0) ? props.value.itemSize : null,
|
|
189
128
|
);
|
|
190
129
|
|
|
130
|
+
const fixedColumnWidth = computed(() =>
|
|
131
|
+
(typeof props.value.columnWidth === 'number' && props.value.columnWidth > 0) ? props.value.columnWidth : null,
|
|
132
|
+
);
|
|
133
|
+
|
|
191
134
|
const defaultSize = computed(() => props.value.defaultItemSize || fixedItemSize.value || DEFAULT_ITEM_SIZE);
|
|
192
135
|
|
|
193
136
|
const sortedStickyIndices = computed(() =>
|
|
194
137
|
[ ...(props.value.stickyIndices || []) ].sort((a, b) => a - b),
|
|
195
138
|
);
|
|
196
139
|
|
|
140
|
+
const stickyIndicesSet = computed(() => new Set(sortedStickyIndices.value));
|
|
141
|
+
|
|
142
|
+
const paddingStartX = computed(() => getPaddingX(props.value.scrollPaddingStart, props.value.direction));
|
|
143
|
+
const paddingEndX = computed(() => getPaddingX(props.value.scrollPaddingEnd, props.value.direction));
|
|
144
|
+
const paddingStartY = computed(() => getPaddingY(props.value.scrollPaddingStart, props.value.direction));
|
|
145
|
+
const paddingEndY = computed(() => getPaddingY(props.value.scrollPaddingEnd, props.value.direction));
|
|
146
|
+
|
|
147
|
+
const stickyStartX = computed(() => getPaddingX(props.value.stickyStart, props.value.direction));
|
|
148
|
+
const stickyEndX = computed(() => getPaddingX(props.value.stickyEnd, props.value.direction));
|
|
149
|
+
const stickyStartY = computed(() => getPaddingY(props.value.stickyStart, props.value.direction));
|
|
150
|
+
const stickyEndY = computed(() => getPaddingY(props.value.stickyEnd, props.value.direction));
|
|
151
|
+
|
|
152
|
+
const flowStartX = computed(() => getPaddingX(props.value.flowPaddingStart, props.value.direction));
|
|
153
|
+
const flowEndX = computed(() => getPaddingX(props.value.flowPaddingEnd, props.value.direction));
|
|
154
|
+
const flowStartY = computed(() => getPaddingY(props.value.flowPaddingStart, props.value.direction));
|
|
155
|
+
const flowEndY = computed(() => getPaddingY(props.value.flowPaddingEnd, props.value.direction));
|
|
156
|
+
|
|
157
|
+
const usableWidth = computed(() => viewportWidth.value - (direction.value !== 'vertical' ? (stickyStartX.value + stickyEndX.value) : 0));
|
|
158
|
+
|
|
159
|
+
const usableHeight = computed(() => viewportHeight.value - (direction.value !== 'horizontal' ? (stickyStartY.value + stickyEndY.value) : 0));
|
|
160
|
+
|
|
197
161
|
// --- Size Calculations ---
|
|
198
162
|
/**
|
|
199
|
-
* Total width of all items in the scrollable area.
|
|
163
|
+
* Total size (width and height) of all items in the scrollable area.
|
|
200
164
|
*/
|
|
201
|
-
const
|
|
165
|
+
const totalSize = computed(() => {
|
|
202
166
|
// eslint-disable-next-line ts/no-unused-expressions
|
|
203
167
|
treeUpdateFlag.value;
|
|
204
168
|
|
|
205
169
|
if (!isHydrated.value && props.value.ssrRange && !isMounted.value) {
|
|
206
170
|
const { start = 0, end = 0, colStart = 0, colEnd = 0 } = props.value.ssrRange;
|
|
207
171
|
const colCount = props.value.columnCount || 0;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
172
|
+
const gap = props.value.gap || 0;
|
|
173
|
+
const columnGap = props.value.columnGap || 0;
|
|
174
|
+
|
|
175
|
+
let width = 0;
|
|
176
|
+
let height = 0;
|
|
177
|
+
|
|
178
|
+
if (direction.value === 'both') {
|
|
179
|
+
if (colCount > 0) {
|
|
180
|
+
const effectiveColEnd = colEnd || colCount;
|
|
181
|
+
const total = columnSizes.query(effectiveColEnd) - columnSizes.query(colStart);
|
|
182
|
+
width = Math.max(0, total - (effectiveColEnd > colStart ? columnGap : 0));
|
|
211
183
|
}
|
|
212
|
-
const effectiveColEnd = colEnd || colCount;
|
|
213
|
-
const total = columnSizes.query(effectiveColEnd) - columnSizes.query(colStart);
|
|
214
|
-
return Math.max(0, total - (effectiveColEnd > colStart ? (props.value.columnGap || 0) : 0));
|
|
215
|
-
}
|
|
216
|
-
/* v8 ignore else -- @preserve */
|
|
217
|
-
if (props.value.direction === 'horizontal') {
|
|
218
184
|
if (fixedItemSize.value !== null) {
|
|
219
185
|
const len = end - start;
|
|
220
|
-
|
|
186
|
+
height = Math.max(0, len * (fixedItemSize.value + gap) - (len > 0 ? gap : 0));
|
|
187
|
+
} else {
|
|
188
|
+
const total = itemSizesY.query(end) - itemSizesY.query(start);
|
|
189
|
+
height = Math.max(0, total - (end > start ? gap : 0));
|
|
190
|
+
}
|
|
191
|
+
} else if (direction.value === 'horizontal') {
|
|
192
|
+
if (fixedItemSize.value !== null) {
|
|
193
|
+
const len = end - start;
|
|
194
|
+
width = Math.max(0, len * (fixedItemSize.value + columnGap) - (len > 0 ? columnGap : 0));
|
|
195
|
+
} else {
|
|
196
|
+
const total = itemSizesX.query(end) - itemSizesX.query(start);
|
|
197
|
+
width = Math.max(0, total - (end > start ? columnGap : 0));
|
|
198
|
+
}
|
|
199
|
+
height = usableHeight.value;
|
|
200
|
+
} else {
|
|
201
|
+
// vertical
|
|
202
|
+
width = usableWidth.value;
|
|
203
|
+
if (fixedItemSize.value !== null) {
|
|
204
|
+
const len = end - start;
|
|
205
|
+
height = Math.max(0, len * (fixedItemSize.value + gap) - (len > 0 ? gap : 0));
|
|
206
|
+
} else {
|
|
207
|
+
const total = itemSizesY.query(end) - itemSizesY.query(start);
|
|
208
|
+
height = Math.max(0, total - (end > start ? gap : 0));
|
|
221
209
|
}
|
|
222
|
-
const total = itemSizesX.query(end) - itemSizesX.query(start);
|
|
223
|
-
return Math.max(0, total - (end > start ? (props.value.columnGap || 0) : 0));
|
|
224
210
|
}
|
|
225
|
-
}
|
|
226
211
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
}
|
|
232
|
-
const total = columnSizes.query(colCount);
|
|
233
|
-
return Math.max(0, total - (props.value.columnGap || 0));
|
|
234
|
-
}
|
|
235
|
-
if (props.value.direction === 'vertical') {
|
|
236
|
-
return 0;
|
|
237
|
-
}
|
|
238
|
-
if (fixedItemSize.value !== null) {
|
|
239
|
-
const len = props.value.items.length;
|
|
240
|
-
return Math.max(0, len * (fixedItemSize.value + (props.value.columnGap || 0)) - (len > 0 ? (props.value.columnGap || 0) : 0));
|
|
212
|
+
return {
|
|
213
|
+
width: Math.max(width, usableWidth.value),
|
|
214
|
+
height: Math.max(height, usableHeight.value),
|
|
215
|
+
};
|
|
241
216
|
}
|
|
242
|
-
|
|
243
|
-
return
|
|
217
|
+
|
|
218
|
+
return calculateTotalSize({
|
|
219
|
+
direction: direction.value,
|
|
220
|
+
itemsLength: props.value.items.length,
|
|
221
|
+
columnCount: props.value.columnCount || 0,
|
|
222
|
+
fixedSize: fixedItemSize.value,
|
|
223
|
+
fixedWidth: fixedColumnWidth.value,
|
|
224
|
+
gap: props.value.gap || 0,
|
|
225
|
+
columnGap: props.value.columnGap || 0,
|
|
226
|
+
usableWidth: usableWidth.value,
|
|
227
|
+
usableHeight: usableHeight.value,
|
|
228
|
+
queryY: (idx) => itemSizesY.query(idx),
|
|
229
|
+
queryX: (idx) => itemSizesX.query(idx),
|
|
230
|
+
queryColumn: (idx) => columnSizes.query(idx),
|
|
231
|
+
});
|
|
244
232
|
});
|
|
245
233
|
|
|
246
|
-
|
|
247
|
-
* Total height of all items in the scrollable area.
|
|
248
|
-
*/
|
|
249
|
-
const totalHeight = computed(() => {
|
|
250
|
-
// eslint-disable-next-line ts/no-unused-expressions
|
|
251
|
-
treeUpdateFlag.value;
|
|
234
|
+
const isWindowContainer = computed(() => isWindowLike(props.value.container));
|
|
252
235
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
/* v8 ignore else -- @preserve */
|
|
256
|
-
if (props.value.direction === 'vertical' || props.value.direction === 'both') {
|
|
257
|
-
if (fixedItemSize.value !== null) {
|
|
258
|
-
const len = end - start;
|
|
259
|
-
return Math.max(0, len * (fixedItemSize.value + (props.value.gap || 0)) - (len > 0 ? (props.value.gap || 0) : 0));
|
|
260
|
-
}
|
|
261
|
-
const total = itemSizesY.query(end) - itemSizesY.query(start);
|
|
262
|
-
return Math.max(0, total - (end > start ? (props.value.gap || 0) : 0));
|
|
263
|
-
}
|
|
264
|
-
}
|
|
236
|
+
const virtualWidth = computed(() => totalSize.value.width + paddingStartX.value + paddingEndX.value);
|
|
237
|
+
const virtualHeight = computed(() => totalSize.value.height + paddingStartY.value + paddingEndY.value);
|
|
265
238
|
|
|
266
|
-
|
|
267
|
-
|
|
239
|
+
const totalWidth = computed(() => (flowStartX.value + stickyStartX.value + stickyEndX.value + flowEndX.value + virtualWidth.value));
|
|
240
|
+
|
|
241
|
+
const totalHeight = computed(() => (flowStartY.value + stickyStartY.value + stickyEndY.value + flowEndY.value + virtualHeight.value));
|
|
242
|
+
|
|
243
|
+
const componentOffset = reactive({
|
|
244
|
+
x: computed(() => Math.max(0, hostOffset.x - (flowStartX.value + stickyStartX.value))),
|
|
245
|
+
y: computed(() => Math.max(0, hostOffset.y - (flowStartY.value + stickyStartY.value))),
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const renderedWidth = computed(() => (isWindowContainer.value ? totalWidth.value : Math.min(totalWidth.value, BROWSER_MAX_SIZE)));
|
|
249
|
+
const renderedHeight = computed(() => (isWindowContainer.value ? totalHeight.value : Math.min(totalHeight.value, BROWSER_MAX_SIZE)));
|
|
250
|
+
|
|
251
|
+
const renderedVirtualWidth = computed(() => (isWindowContainer.value ? virtualWidth.value : Math.max(0, renderedWidth.value - (flowStartX.value + stickyStartX.value + stickyEndX.value + flowEndX.value))));
|
|
252
|
+
const renderedVirtualHeight = computed(() => (isWindowContainer.value ? virtualHeight.value : Math.max(0, renderedHeight.value - (flowStartY.value + stickyStartY.value + stickyEndY.value + flowEndY.value))));
|
|
253
|
+
|
|
254
|
+
const scaleX = computed(() => {
|
|
255
|
+
if (isWindowContainer.value || totalWidth.value <= BROWSER_MAX_SIZE) {
|
|
256
|
+
return 1;
|
|
268
257
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
258
|
+
const realRange = totalWidth.value - viewportWidth.value;
|
|
259
|
+
const displayRange = renderedWidth.value - viewportWidth.value;
|
|
260
|
+
return displayRange > 0 ? realRange / displayRange : 1;
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const scaleY = computed(() => {
|
|
264
|
+
if (isWindowContainer.value || totalHeight.value <= BROWSER_MAX_SIZE) {
|
|
265
|
+
return 1;
|
|
272
266
|
}
|
|
273
|
-
const
|
|
274
|
-
|
|
267
|
+
const realRange = totalHeight.value - viewportHeight.value;
|
|
268
|
+
const displayRange = renderedHeight.value - viewportHeight.value;
|
|
269
|
+
return displayRange > 0 ? realRange / displayRange : 1;
|
|
275
270
|
});
|
|
276
271
|
|
|
277
272
|
const relativeScrollX = computed(() => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
273
|
+
if (direction.value === 'vertical') {
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
const flowPaddingX = flowStartX.value + stickyStartX.value + paddingStartX.value;
|
|
277
|
+
return internalScrollX.value - flowPaddingX;
|
|
281
278
|
});
|
|
279
|
+
|
|
282
280
|
const relativeScrollY = computed(() => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
281
|
+
if (direction.value === 'horizontal') {
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
const flowPaddingY = flowStartY.value + stickyStartY.value + paddingStartY.value;
|
|
285
|
+
return internalScrollY.value - flowPaddingY;
|
|
286
286
|
});
|
|
287
287
|
|
|
288
|
-
|
|
288
|
+
/**
|
|
289
|
+
* Returns the currently calculated width for a specific column index, taking measurements and gaps into account.
|
|
290
|
+
*
|
|
291
|
+
* @param index - The column index.
|
|
292
|
+
* @returns The width in pixels (excluding gap).
|
|
293
|
+
*/
|
|
289
294
|
const getColumnWidth = (index: number) => {
|
|
290
295
|
// eslint-disable-next-line ts/no-unused-expressions
|
|
291
296
|
treeUpdateFlag.value;
|
|
292
297
|
|
|
298
|
+
const columnGap = props.value.columnGap || 0;
|
|
293
299
|
const cw = props.value.columnWidth;
|
|
294
300
|
if (typeof cw === 'number' && cw > 0) {
|
|
295
301
|
return cw;
|
|
@@ -298,11 +304,39 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
298
304
|
const val = cw[ index % cw.length ];
|
|
299
305
|
return (val != null && val > 0) ? val : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
|
|
300
306
|
}
|
|
301
|
-
/* v8 ignore else -- @preserve */
|
|
302
307
|
if (typeof cw === 'function') {
|
|
303
308
|
return cw(index);
|
|
304
309
|
}
|
|
305
|
-
|
|
310
|
+
const val = columnSizes.get(index);
|
|
311
|
+
return val > 0 ? val - columnGap : (props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Returns the currently calculated height for a specific row index, taking measurements and gaps into account.
|
|
316
|
+
*
|
|
317
|
+
* @param index - The row index.
|
|
318
|
+
* @returns The height in pixels (excluding gap).
|
|
319
|
+
*/
|
|
320
|
+
const getRowHeight = (index: number) => {
|
|
321
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
322
|
+
treeUpdateFlag.value;
|
|
323
|
+
|
|
324
|
+
if (direction.value === 'horizontal') {
|
|
325
|
+
return usableHeight.value;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const gap = props.value.gap || 0;
|
|
329
|
+
const itemSize = props.value.itemSize;
|
|
330
|
+
if (typeof itemSize === 'number' && itemSize > 0) {
|
|
331
|
+
return itemSize;
|
|
332
|
+
}
|
|
333
|
+
if (typeof itemSize === 'function') {
|
|
334
|
+
const item = props.value.items[ index ];
|
|
335
|
+
return item !== undefined ? itemSize(item, index) : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const val = itemSizesY.get(index);
|
|
339
|
+
return val > 0 ? val - gap : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
|
|
306
340
|
};
|
|
307
341
|
|
|
308
342
|
// --- Public Scroll API ---
|
|
@@ -312,172 +346,80 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
312
346
|
* @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
|
|
313
347
|
* @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
|
|
314
348
|
* @param options - Scroll options including alignment ('start', 'center', 'end', 'auto') and behavior ('auto', 'smooth').
|
|
349
|
+
* Defaults to { align: 'auto', behavior: 'auto' }.
|
|
315
350
|
*/
|
|
316
|
-
|
|
351
|
+
function scrollToIndex(
|
|
317
352
|
rowIndex: number | null | undefined,
|
|
318
353
|
colIndex: number | null | undefined,
|
|
319
354
|
options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions,
|
|
320
|
-
)
|
|
355
|
+
) {
|
|
321
356
|
const isCorrection = typeof options === 'object' && options !== null && 'isCorrection' in options
|
|
322
357
|
? options.isCorrection
|
|
323
358
|
: false;
|
|
324
359
|
|
|
325
|
-
if (!isCorrection) {
|
|
326
|
-
pendingScroll.value = { rowIndex, colIndex, options };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
360
|
const container = props.value.container || window;
|
|
330
|
-
const fixedSize = fixedItemSize.value;
|
|
331
|
-
const gap = props.value.gap || 0;
|
|
332
|
-
const columnGap = props.value.columnGap || 0;
|
|
333
|
-
|
|
334
|
-
let align: ScrollAlignment | ScrollAlignmentOptions | undefined;
|
|
335
|
-
let behavior: 'auto' | 'smooth' | undefined;
|
|
336
|
-
|
|
337
|
-
if (isScrollToIndexOptions(options)) {
|
|
338
|
-
align = options.align;
|
|
339
|
-
behavior = options.behavior;
|
|
340
|
-
} else {
|
|
341
|
-
align = options as ScrollAlignment | ScrollAlignmentOptions;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
const alignX = (typeof align === 'object' ? align.x : align) || 'auto';
|
|
345
|
-
const alignY = (typeof align === 'object' ? align.y : align) || 'auto';
|
|
346
|
-
|
|
347
|
-
const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
|
|
348
|
-
const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, props.value.direction);
|
|
349
|
-
const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
|
|
350
|
-
const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, props.value.direction);
|
|
351
|
-
|
|
352
|
-
const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
|
|
353
|
-
const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
|
|
354
|
-
|
|
355
|
-
const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
|
|
356
|
-
const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
|
|
357
|
-
|
|
358
|
-
let targetX = relativeScrollX.value;
|
|
359
|
-
let targetY = relativeScrollY.value;
|
|
360
|
-
let itemWidth = 0;
|
|
361
|
-
let itemHeight = 0;
|
|
362
|
-
|
|
363
|
-
// Y calculation
|
|
364
|
-
if (rowIndex !== null && rowIndex !== undefined) {
|
|
365
|
-
if (rowIndex >= props.value.items.length) {
|
|
366
|
-
targetY = totalHeight.value;
|
|
367
|
-
itemHeight = 0;
|
|
368
|
-
} else {
|
|
369
|
-
targetY = fixedSize !== null ? rowIndex * (fixedSize + gap) : itemSizesY.query(rowIndex);
|
|
370
|
-
itemHeight = fixedSize !== null ? fixedSize : itemSizesY.get(rowIndex) - gap;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Apply Y Alignment
|
|
374
|
-
if (alignY === 'start') {
|
|
375
|
-
// targetY is already at the start of the list
|
|
376
|
-
} else if (alignY === 'center') {
|
|
377
|
-
targetY -= (usableHeight - itemHeight) / 2;
|
|
378
|
-
} else if (alignY === 'end') {
|
|
379
|
-
targetY -= (usableHeight - itemHeight);
|
|
380
|
-
} else {
|
|
381
|
-
const isVisibleY = targetY >= relativeScrollY.value && (targetY + itemHeight) <= (relativeScrollY.value + usableHeight);
|
|
382
|
-
if (!isVisibleY) {
|
|
383
|
-
if (targetY < relativeScrollY.value) {
|
|
384
|
-
// keep targetY at start
|
|
385
|
-
} else {
|
|
386
|
-
targetY -= (usableHeight - itemHeight);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
361
|
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
362
|
+
const { targetX, targetY, effectiveAlignX, effectiveAlignY } = calculateScrollTarget({
|
|
363
|
+
rowIndex,
|
|
364
|
+
colIndex,
|
|
365
|
+
options,
|
|
366
|
+
direction: direction.value,
|
|
367
|
+
viewportWidth: viewportWidth.value,
|
|
368
|
+
viewportHeight: viewportHeight.value,
|
|
369
|
+
totalWidth: totalWidth.value,
|
|
370
|
+
totalHeight: totalHeight.value,
|
|
371
|
+
gap: props.value.gap || 0,
|
|
372
|
+
columnGap: props.value.columnGap || 0,
|
|
373
|
+
fixedSize: fixedItemSize.value,
|
|
374
|
+
fixedWidth: fixedColumnWidth.value,
|
|
375
|
+
relativeScrollX: relativeScrollX.value,
|
|
376
|
+
relativeScrollY: relativeScrollY.value,
|
|
377
|
+
getItemSizeY: (idx) => itemSizesY.get(idx),
|
|
378
|
+
getItemSizeX: (idx) => itemSizesX.get(idx),
|
|
379
|
+
getItemQueryY: (idx) => itemSizesY.query(idx),
|
|
380
|
+
getItemQueryX: (idx) => itemSizesX.query(idx),
|
|
381
|
+
getColumnSize: (idx) => columnSizes.get(idx),
|
|
382
|
+
getColumnQuery: (idx) => columnSizes.query(idx),
|
|
383
|
+
scaleX: scaleX.value,
|
|
384
|
+
scaleY: scaleY.value,
|
|
385
|
+
hostOffsetX: componentOffset.x,
|
|
386
|
+
hostOffsetY: componentOffset.y,
|
|
387
|
+
stickyIndices: sortedStickyIndices.value,
|
|
388
|
+
stickyStartX: stickyStartX.value,
|
|
389
|
+
stickyStartY: stickyStartY.value,
|
|
390
|
+
stickyEndX: stickyEndX.value,
|
|
391
|
+
stickyEndY: stickyEndY.value,
|
|
392
|
+
flowPaddingStartX: flowStartX.value,
|
|
393
|
+
flowPaddingStartY: flowStartY.value,
|
|
394
|
+
flowPaddingEndX: flowEndX.value,
|
|
395
|
+
flowPaddingEndY: flowEndY.value,
|
|
396
|
+
paddingStartX: paddingStartX.value,
|
|
397
|
+
paddingStartY: paddingStartY.value,
|
|
398
|
+
paddingEndX: paddingEndX.value,
|
|
399
|
+
paddingEndY: paddingEndY.value,
|
|
400
|
+
});
|
|
405
401
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
/* v8 ignore if -- @preserve */
|
|
417
|
-
if (targetX < relativeScrollX.value) {
|
|
418
|
-
// keep targetX at start
|
|
419
|
-
} else {
|
|
420
|
-
targetX -= (usableWidth - itemWidth);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
}
|
|
402
|
+
if (!isCorrection) {
|
|
403
|
+
const behavior = isScrollToIndexOptions(options) ? options.behavior : undefined;
|
|
404
|
+
pendingScroll.value = {
|
|
405
|
+
rowIndex,
|
|
406
|
+
colIndex,
|
|
407
|
+
options: {
|
|
408
|
+
align: { x: effectiveAlignX, y: effectiveAlignY },
|
|
409
|
+
...(behavior != null ? { behavior } : {}),
|
|
410
|
+
},
|
|
411
|
+
};
|
|
424
412
|
}
|
|
425
413
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
targetY = Math.max(0, Math.min(targetY, Math.max(0, totalHeight.value - usableHeight)));
|
|
429
|
-
|
|
430
|
-
const finalX = targetX + hostOffset.x - (isHorizontal ? paddingStartX : 0);
|
|
431
|
-
const finalY = targetY + hostOffset.y - (isVertical ? paddingStartY : 0);
|
|
432
|
-
|
|
433
|
-
// Check if we reached the target
|
|
434
|
-
const tolerance = 1;
|
|
435
|
-
let reachedX = (colIndex === null || colIndex === undefined) || Math.abs(relativeScrollX.value - targetX) < tolerance;
|
|
436
|
-
let reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(relativeScrollY.value - targetY) < tolerance;
|
|
437
|
-
|
|
438
|
-
if (!reachedX || !reachedY) {
|
|
439
|
-
let curX = 0;
|
|
440
|
-
let curY = 0;
|
|
441
|
-
let maxW = 0;
|
|
442
|
-
let maxH = 0;
|
|
443
|
-
let viewW = 0;
|
|
444
|
-
let viewH = 0;
|
|
445
|
-
|
|
446
|
-
/* v8 ignore else -- @preserve */
|
|
447
|
-
if (typeof window !== 'undefined') {
|
|
448
|
-
if (container === window) {
|
|
449
|
-
curX = window.scrollX;
|
|
450
|
-
curY = window.scrollY;
|
|
451
|
-
maxW = document.documentElement.scrollWidth;
|
|
452
|
-
maxH = document.documentElement.scrollHeight;
|
|
453
|
-
viewW = window.innerWidth;
|
|
454
|
-
viewH = window.innerHeight;
|
|
455
|
-
} else if (isElement(container)) {
|
|
456
|
-
curX = container.scrollLeft;
|
|
457
|
-
curY = container.scrollTop;
|
|
458
|
-
maxW = container.scrollWidth;
|
|
459
|
-
maxH = container.scrollHeight;
|
|
460
|
-
viewW = container.clientWidth;
|
|
461
|
-
viewH = container.clientHeight;
|
|
462
|
-
}
|
|
414
|
+
const displayTargetX = virtualToDisplay(targetX, componentOffset.x, scaleX.value);
|
|
415
|
+
const displayTargetY = virtualToDisplay(targetY, componentOffset.y, scaleY.value);
|
|
463
416
|
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const atRight = curX >= maxW - viewW - tolerance && finalX >= maxW - viewW - tolerance;
|
|
467
|
-
/* v8 ignore else -- @preserve */
|
|
468
|
-
if (atLeft || atRight) {
|
|
469
|
-
reachedX = true;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
417
|
+
const finalX = isRtl.value ? -displayTargetX : displayTargetX;
|
|
418
|
+
const finalY = displayTargetY;
|
|
472
419
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
if (atTop || atBottom) {
|
|
477
|
-
reachedY = true;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
}
|
|
420
|
+
let behavior: 'auto' | 'smooth' | undefined;
|
|
421
|
+
if (isScrollToIndexOptions(options)) {
|
|
422
|
+
behavior = options.behavior;
|
|
481
423
|
}
|
|
482
424
|
|
|
483
425
|
const scrollBehavior = isCorrection ? 'auto' : (behavior || 'smooth');
|
|
@@ -485,7 +427,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
485
427
|
|
|
486
428
|
if (typeof window !== 'undefined' && container === window) {
|
|
487
429
|
window.scrollTo({
|
|
488
|
-
left: (colIndex === null || colIndex === undefined) ? undefined : Math.max(0, finalX),
|
|
430
|
+
left: (colIndex === null || colIndex === undefined) ? undefined : (isRtl.value ? finalX : Math.max(0, finalX)),
|
|
489
431
|
top: (rowIndex === null || rowIndex === undefined) ? undefined : Math.max(0, finalY),
|
|
490
432
|
behavior: scrollBehavior,
|
|
491
433
|
} as ScrollToOptions);
|
|
@@ -495,7 +437,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
495
437
|
};
|
|
496
438
|
|
|
497
439
|
if (colIndex !== null && colIndex !== undefined) {
|
|
498
|
-
scrollOptions.left = Math.max(0, finalX);
|
|
440
|
+
scrollOptions.left = (isRtl.value ? finalX : Math.max(0, finalX));
|
|
499
441
|
}
|
|
500
442
|
if (rowIndex !== null && rowIndex !== undefined) {
|
|
501
443
|
scrollOptions.top = Math.max(0, finalY);
|
|
@@ -515,53 +457,65 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
515
457
|
|
|
516
458
|
if (scrollBehavior === 'auto' || scrollBehavior === undefined) {
|
|
517
459
|
if (colIndex !== null && colIndex !== undefined) {
|
|
518
|
-
scrollX.value = Math.max(0, finalX);
|
|
460
|
+
scrollX.value = (isRtl.value ? finalX : Math.max(0, finalX));
|
|
461
|
+
internalScrollX.value = targetX;
|
|
519
462
|
}
|
|
520
463
|
if (rowIndex !== null && rowIndex !== undefined) {
|
|
521
464
|
scrollY.value = Math.max(0, finalY);
|
|
465
|
+
internalScrollY.value = targetY;
|
|
522
466
|
}
|
|
523
|
-
}
|
|
524
467
|
|
|
525
|
-
|
|
526
|
-
|
|
468
|
+
if (pendingScroll.value) {
|
|
469
|
+
const currentOptions = pendingScroll.value.options;
|
|
470
|
+
if (isScrollToIndexOptions(currentOptions)) {
|
|
471
|
+
currentOptions.behavior = 'auto';
|
|
472
|
+
} else {
|
|
473
|
+
pendingScroll.value.options = {
|
|
474
|
+
align: currentOptions as ScrollAlignment | ScrollAlignmentOptions,
|
|
475
|
+
behavior: 'auto',
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
}
|
|
527
479
|
}
|
|
528
|
-
}
|
|
480
|
+
}
|
|
529
481
|
|
|
530
482
|
/**
|
|
531
483
|
* Programmatically scroll to a specific pixel offset relative to the content start.
|
|
532
484
|
*
|
|
533
485
|
* @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
|
|
534
486
|
* @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
|
|
535
|
-
* @param options - Scroll options (behavior)
|
|
536
|
-
* @param options.behavior - The scroll behavior ('auto' | 'smooth')
|
|
487
|
+
* @param options - Scroll options (behavior).
|
|
488
|
+
* @param options.behavior - The scroll behavior ('auto' | 'smooth'). Defaults to 'auto'.
|
|
537
489
|
*/
|
|
538
490
|
const scrollToOffset = (x?: number | null, y?: number | null, options?: { behavior?: 'auto' | 'smooth'; }) => {
|
|
539
491
|
const container = props.value.container || window;
|
|
540
492
|
isProgrammaticScroll.value = true;
|
|
541
|
-
|
|
542
|
-
const isVertical = props.value.direction === 'vertical' || props.value.direction === 'both';
|
|
543
|
-
const isHorizontal = props.value.direction === 'horizontal' || props.value.direction === 'both';
|
|
544
|
-
|
|
545
|
-
const paddingStartX = getPaddingX(props.value.scrollPaddingStart, props.value.direction);
|
|
546
|
-
const paddingStartY = getPaddingY(props.value.scrollPaddingStart, props.value.direction);
|
|
547
|
-
const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, props.value.direction);
|
|
548
|
-
const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, props.value.direction);
|
|
549
|
-
|
|
550
|
-
const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
|
|
551
|
-
const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
|
|
493
|
+
pendingScroll.value = null;
|
|
552
494
|
|
|
553
495
|
const clampedX = (x !== null && x !== undefined)
|
|
554
|
-
?
|
|
496
|
+
? Math.max(0, Math.min(x, totalWidth.value - viewportWidth.value))
|
|
555
497
|
: null;
|
|
556
498
|
const clampedY = (y !== null && y !== undefined)
|
|
557
|
-
?
|
|
499
|
+
? Math.max(0, Math.min(y, totalHeight.value - viewportHeight.value))
|
|
558
500
|
: null;
|
|
559
501
|
|
|
502
|
+
if (clampedX !== null) {
|
|
503
|
+
internalScrollX.value = clampedX;
|
|
504
|
+
}
|
|
505
|
+
if (clampedY !== null) {
|
|
506
|
+
internalScrollY.value = clampedY;
|
|
507
|
+
}
|
|
508
|
+
|
|
560
509
|
const currentX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
|
|
561
510
|
const currentY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
|
|
562
511
|
|
|
563
|
-
const
|
|
564
|
-
const
|
|
512
|
+
const displayTargetX = (clampedX !== null) ? virtualToDisplay(clampedX, componentOffset.x, scaleX.value) : null;
|
|
513
|
+
const displayTargetY = (clampedY !== null) ? virtualToDisplay(clampedY, componentOffset.y, scaleY.value) : null;
|
|
514
|
+
|
|
515
|
+
const targetX = (displayTargetX !== null)
|
|
516
|
+
? (isRtl.value ? -displayTargetX : displayTargetX)
|
|
517
|
+
: currentX;
|
|
518
|
+
const targetY = (displayTargetY !== null) ? displayTargetY : currentY;
|
|
565
519
|
|
|
566
520
|
if (typeof window !== 'undefined' && container === window) {
|
|
567
521
|
window.scrollTo({
|
|
@@ -604,11 +558,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
604
558
|
};
|
|
605
559
|
|
|
606
560
|
// --- Measurement & Initialization ---
|
|
607
|
-
const
|
|
608
|
-
const newItems = props.value.items;
|
|
609
|
-
const len = newItems.length;
|
|
610
|
-
const colCount = props.value.columnCount || 0;
|
|
611
|
-
|
|
561
|
+
const resizeMeasurements = (len: number, colCount: number) => {
|
|
612
562
|
itemSizesX.resize(len);
|
|
613
563
|
itemSizesY.resize(len);
|
|
614
564
|
columnSizes.resize(colCount);
|
|
@@ -628,78 +578,38 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
628
578
|
newMeasuredCols.set(measuredColumns.subarray(0, Math.min(colCount, measuredColumns.length)));
|
|
629
579
|
measuredColumns = newMeasuredCols;
|
|
630
580
|
}
|
|
581
|
+
};
|
|
631
582
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
prependCount = i;
|
|
640
|
-
break;
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
if (prependCount > 0) {
|
|
647
|
-
itemSizesX.shift(prependCount);
|
|
648
|
-
itemSizesY.shift(prependCount);
|
|
649
|
-
|
|
650
|
-
if (pendingScroll.value && pendingScroll.value.rowIndex !== null && pendingScroll.value.rowIndex !== undefined) {
|
|
651
|
-
pendingScroll.value.rowIndex += prependCount;
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const newMeasuredX = new Uint8Array(len);
|
|
655
|
-
const newMeasuredY = new Uint8Array(len);
|
|
656
|
-
newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len - prependCount, measuredItemsX.length)), prependCount);
|
|
657
|
-
newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len - prependCount, measuredItemsY.length)), prependCount);
|
|
658
|
-
measuredItemsX = newMeasuredX;
|
|
659
|
-
measuredItemsY = newMeasuredY;
|
|
660
|
-
|
|
661
|
-
// Calculate added size
|
|
662
|
-
const gap = props.value.gap || 0;
|
|
663
|
-
const columnGap = props.value.columnGap || 0;
|
|
664
|
-
let addedX = 0;
|
|
665
|
-
let addedY = 0;
|
|
666
|
-
|
|
667
|
-
for (let i = 0; i < prependCount; i++) {
|
|
668
|
-
const size = typeof props.value.itemSize === 'function'
|
|
669
|
-
? props.value.itemSize(newItems[ i ] as T, i)
|
|
670
|
-
: defaultSize.value;
|
|
671
|
-
|
|
672
|
-
if (props.value.direction === 'horizontal') {
|
|
673
|
-
addedX += size + columnGap;
|
|
674
|
-
} else {
|
|
675
|
-
addedY += size + gap;
|
|
676
|
-
}
|
|
677
|
-
}
|
|
583
|
+
const initializeMeasurements = () => {
|
|
584
|
+
const newItems = props.value.items;
|
|
585
|
+
const len = newItems.length;
|
|
586
|
+
const colCount = props.value.columnCount || 0;
|
|
587
|
+
const gap = props.value.gap || 0;
|
|
588
|
+
const columnGap = props.value.columnGap || 0;
|
|
589
|
+
const cw = props.value.columnWidth;
|
|
678
590
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
nextTick(() => {
|
|
682
|
-
scrollToOffset(
|
|
683
|
-
addedX > 0 ? relativeScrollX.value + addedX : null,
|
|
684
|
-
addedY > 0 ? relativeScrollY.value + addedY : null,
|
|
685
|
-
{ behavior: 'auto' },
|
|
686
|
-
);
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
}
|
|
591
|
+
let colNeedsRebuild = false;
|
|
592
|
+
let itemsNeedRebuild = false;
|
|
690
593
|
|
|
691
|
-
// Initialize columns
|
|
594
|
+
// Initialize columns
|
|
692
595
|
if (colCount > 0) {
|
|
693
|
-
const columnGap = props.value.columnGap || 0;
|
|
694
|
-
let colNeedsRebuild = false;
|
|
695
596
|
for (let i = 0; i < colCount; i++) {
|
|
696
|
-
const width = getColumnWidth(i);
|
|
697
597
|
const currentW = columnSizes.get(i);
|
|
698
598
|
const isMeasured = measuredColumns[ i ] === 1;
|
|
699
599
|
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
600
|
+
if (!isDynamicColumnWidth.value || (!isMeasured && currentW === 0)) {
|
|
601
|
+
let baseWidth = 0;
|
|
602
|
+
if (typeof cw === 'number' && cw > 0) {
|
|
603
|
+
baseWidth = cw;
|
|
604
|
+
} else if (Array.isArray(cw) && cw.length > 0) {
|
|
605
|
+
baseWidth = cw[ i % cw.length ] || props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
|
|
606
|
+
} else if (typeof cw === 'function') {
|
|
607
|
+
baseWidth = cw(i);
|
|
608
|
+
} else {
|
|
609
|
+
baseWidth = props.value.defaultColumnWidth || DEFAULT_COLUMN_WIDTH;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const targetW = baseWidth + columnGap;
|
|
703
613
|
if (Math.abs(currentW - targetW) > 0.5) {
|
|
704
614
|
columnSizes.set(i, targetW);
|
|
705
615
|
measuredColumns[ i ] = isDynamicColumnWidth.value ? 0 : 1;
|
|
@@ -709,38 +619,20 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
709
619
|
}
|
|
710
620
|
}
|
|
711
621
|
}
|
|
712
|
-
if (colNeedsRebuild) {
|
|
713
|
-
columnSizes.rebuild();
|
|
714
|
-
}
|
|
715
622
|
}
|
|
716
623
|
|
|
717
|
-
|
|
718
|
-
const columnGap = props.value.columnGap || 0;
|
|
719
|
-
let itemsNeedRebuild = false;
|
|
720
|
-
|
|
624
|
+
// Initialize items
|
|
721
625
|
for (let i = 0; i < len; i++) {
|
|
722
626
|
const item = props.value.items[ i ];
|
|
723
627
|
const currentX = itemSizesX.get(i);
|
|
724
628
|
const currentY = itemSizesY.get(i);
|
|
725
|
-
|
|
726
|
-
const size = typeof props.value.itemSize === 'function'
|
|
727
|
-
? props.value.itemSize(item as T, i)
|
|
728
|
-
: defaultSize.value;
|
|
729
|
-
|
|
730
|
-
const isVertical = props.value.direction === 'vertical';
|
|
731
|
-
const isHorizontal = props.value.direction === 'horizontal';
|
|
732
|
-
const isBoth = props.value.direction === 'both';
|
|
733
|
-
|
|
734
|
-
const targetX = isHorizontal ? size + columnGap : 0;
|
|
735
|
-
const targetY = (isVertical || isBoth) ? size + gap : 0;
|
|
736
|
-
|
|
737
629
|
const isMeasuredX = measuredItemsX[ i ] === 1;
|
|
738
630
|
const isMeasuredY = measuredItemsY[ i ] === 1;
|
|
739
631
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
632
|
+
if (direction.value === 'horizontal') {
|
|
633
|
+
if (!isDynamicItemSize.value || (!isMeasuredX && currentX === 0)) {
|
|
634
|
+
const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
|
|
635
|
+
const targetX = baseSize + columnGap;
|
|
744
636
|
if (Math.abs(currentX - targetX) > 0.5) {
|
|
745
637
|
itemSizesX.set(i, targetX);
|
|
746
638
|
measuredItemsX[ i ] = isDynamicItemSize.value ? 0 : 1;
|
|
@@ -755,9 +647,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
755
647
|
itemsNeedRebuild = true;
|
|
756
648
|
}
|
|
757
649
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
650
|
+
if (direction.value !== 'horizontal') {
|
|
651
|
+
if (!isDynamicItemSize.value || (!isMeasuredY && currentY === 0)) {
|
|
652
|
+
const baseSize = typeof props.value.itemSize === 'function' ? props.value.itemSize(item as T, i) : defaultSize.value;
|
|
653
|
+
const targetY = baseSize + gap;
|
|
761
654
|
if (Math.abs(currentY - targetY) > 0.5) {
|
|
762
655
|
itemSizesY.set(i, targetY);
|
|
763
656
|
measuredItemsY[ i ] = isDynamicItemSize.value ? 0 : 1;
|
|
@@ -773,10 +666,75 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
773
666
|
}
|
|
774
667
|
}
|
|
775
668
|
|
|
669
|
+
if (colNeedsRebuild) {
|
|
670
|
+
columnSizes.rebuild();
|
|
671
|
+
}
|
|
776
672
|
if (itemsNeedRebuild) {
|
|
777
673
|
itemSizesX.rebuild();
|
|
778
674
|
itemSizesY.rebuild();
|
|
779
675
|
}
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const initializeSizes = () => {
|
|
679
|
+
const newItems = props.value.items;
|
|
680
|
+
const len = newItems.length;
|
|
681
|
+
const colCount = props.value.columnCount || 0;
|
|
682
|
+
|
|
683
|
+
resizeMeasurements(len, colCount);
|
|
684
|
+
|
|
685
|
+
let prependCount = 0;
|
|
686
|
+
if (props.value.restoreScrollOnPrepend && lastItems.length > 0 && len > lastItems.length) {
|
|
687
|
+
const oldFirstItem = lastItems[ 0 ];
|
|
688
|
+
if (oldFirstItem !== undefined) {
|
|
689
|
+
for (let i = 1; i <= len - lastItems.length; i++) {
|
|
690
|
+
if (newItems[ i ] === oldFirstItem) {
|
|
691
|
+
prependCount = i;
|
|
692
|
+
break;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (prependCount > 0) {
|
|
699
|
+
itemSizesX.shift(prependCount);
|
|
700
|
+
itemSizesY.shift(prependCount);
|
|
701
|
+
|
|
702
|
+
if (pendingScroll.value && pendingScroll.value.rowIndex !== null && pendingScroll.value.rowIndex !== undefined) {
|
|
703
|
+
pendingScroll.value.rowIndex += prependCount;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const newMeasuredX = new Uint8Array(len);
|
|
707
|
+
const newMeasuredY = new Uint8Array(len);
|
|
708
|
+
newMeasuredX.set(measuredItemsX.subarray(0, Math.min(len - prependCount, measuredItemsX.length)), prependCount);
|
|
709
|
+
newMeasuredY.set(measuredItemsY.subarray(0, Math.min(len - prependCount, measuredItemsY.length)), prependCount);
|
|
710
|
+
measuredItemsX = newMeasuredX;
|
|
711
|
+
measuredItemsY = newMeasuredY;
|
|
712
|
+
|
|
713
|
+
// Calculate added size
|
|
714
|
+
const gap = props.value.gap || 0;
|
|
715
|
+
const columnGap = props.value.columnGap || 0;
|
|
716
|
+
let addedX = 0;
|
|
717
|
+
let addedY = 0;
|
|
718
|
+
|
|
719
|
+
for (let i = 0; i < prependCount; i++) {
|
|
720
|
+
const size = typeof props.value.itemSize === 'function' ? props.value.itemSize(newItems[ i ] as T, i) : defaultSize.value;
|
|
721
|
+
if (direction.value === 'horizontal') {
|
|
722
|
+
addedX += size + columnGap;
|
|
723
|
+
} else { addedY += size + gap; }
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (addedX > 0 || addedY > 0) {
|
|
727
|
+
nextTick(() => {
|
|
728
|
+
scrollToOffset(
|
|
729
|
+
addedX > 0 ? relativeScrollX.value + addedX : null,
|
|
730
|
+
addedY > 0 ? relativeScrollY.value + addedY : null,
|
|
731
|
+
{ behavior: 'auto', isCorrection: true } as ScrollToIndexOptions,
|
|
732
|
+
);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
initializeMeasurements();
|
|
780
738
|
|
|
781
739
|
lastItems = [ ...newItems ];
|
|
782
740
|
sizesInitialized.value = true;
|
|
@@ -787,34 +745,56 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
787
745
|
* Updates the host element's offset relative to the scroll container.
|
|
788
746
|
*/
|
|
789
747
|
const updateHostOffset = () => {
|
|
790
|
-
if (
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
let newX = 0;
|
|
795
|
-
let newY = 0;
|
|
748
|
+
if (typeof window === 'undefined') {
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const container = props.value.container || window;
|
|
796
752
|
|
|
753
|
+
const calculateOffset = (el: HTMLElement) => {
|
|
754
|
+
const rect = el.getBoundingClientRect();
|
|
797
755
|
if (container === window) {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
756
|
+
return {
|
|
757
|
+
x: isRtl.value
|
|
758
|
+
? document.documentElement.clientWidth - rect.right - window.scrollX
|
|
759
|
+
: rect.left + window.scrollX,
|
|
760
|
+
y: rect.top + window.scrollY,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
if (container === el) {
|
|
764
|
+
return { x: 0, y: 0 };
|
|
765
|
+
}
|
|
766
|
+
if (isElement(container)) {
|
|
804
767
|
const containerRect = container.getBoundingClientRect();
|
|
805
|
-
|
|
806
|
-
|
|
768
|
+
return {
|
|
769
|
+
x: isRtl.value
|
|
770
|
+
? containerRect.right - rect.right - container.scrollLeft
|
|
771
|
+
: rect.left - containerRect.left + container.scrollLeft,
|
|
772
|
+
y: rect.top - containerRect.top + container.scrollTop,
|
|
773
|
+
};
|
|
807
774
|
}
|
|
775
|
+
return { x: 0, y: 0 };
|
|
776
|
+
};
|
|
808
777
|
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
778
|
+
if (props.value.hostElement) {
|
|
779
|
+
const newOffset = calculateOffset(props.value.hostElement);
|
|
780
|
+
if (Math.abs(hostOffset.x - newOffset.x) > 0.1 || Math.abs(hostOffset.y - newOffset.y) > 0.1) {
|
|
781
|
+
hostOffset.x = newOffset.x;
|
|
782
|
+
hostOffset.y = newOffset.y;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
if (props.value.hostRef) {
|
|
787
|
+
const newOffset = calculateOffset(props.value.hostRef);
|
|
788
|
+
if (Math.abs(hostRefOffset.x - newOffset.x) > 0.1 || Math.abs(hostRefOffset.y - newOffset.y) > 0.1) {
|
|
789
|
+
hostRefOffset.x = newOffset.x;
|
|
790
|
+
hostRefOffset.y = newOffset.y;
|
|
812
791
|
}
|
|
813
792
|
}
|
|
814
793
|
};
|
|
815
794
|
|
|
816
795
|
watch([
|
|
817
796
|
() => props.value.items,
|
|
797
|
+
() => props.value.items.length,
|
|
818
798
|
() => props.value.direction,
|
|
819
799
|
() => props.value.columnCount,
|
|
820
800
|
() => props.value.columnWidth,
|
|
@@ -829,7 +809,85 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
829
809
|
updateHostOffset();
|
|
830
810
|
});
|
|
831
811
|
|
|
832
|
-
|
|
812
|
+
watch(isRtl, (newRtl, oldRtl) => {
|
|
813
|
+
if (oldRtl === undefined || newRtl === oldRtl || !isMounted.value) {
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Use the oldRtl to correctly interpret the current scrollX
|
|
818
|
+
if (direction.value === 'vertical') {
|
|
819
|
+
updateHostOffset();
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const scrollValue = oldRtl ? Math.abs(scrollX.value) : scrollX.value;
|
|
824
|
+
const oldRelativeScrollX = displayToVirtual(scrollValue, hostOffset.x, scaleX.value);
|
|
825
|
+
|
|
826
|
+
// Update host offset for the new direction
|
|
827
|
+
updateHostOffset();
|
|
828
|
+
|
|
829
|
+
// Maintain logical horizontal position when direction changes
|
|
830
|
+
scrollToOffset(oldRelativeScrollX, null, { behavior: 'auto' });
|
|
831
|
+
}, { flush: 'sync' });
|
|
832
|
+
|
|
833
|
+
watch([ scaleX, scaleY ], () => {
|
|
834
|
+
if (!isMounted.value || isScrolling.value || isProgrammaticScroll.value) {
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// Sync display scroll to maintain logical position
|
|
838
|
+
scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
watch([ () => props.value.items.length, () => props.value.columnCount ], ([ newLen, newColCount ], [ oldLen, oldColCount ]) => {
|
|
842
|
+
nextTick(() => {
|
|
843
|
+
const maxRelX = Math.max(0, totalWidth.value - viewportWidth.value);
|
|
844
|
+
const maxRelY = Math.max(0, totalHeight.value - viewportHeight.value);
|
|
845
|
+
|
|
846
|
+
if (internalScrollX.value > maxRelX || internalScrollY.value > maxRelY) {
|
|
847
|
+
scrollToOffset(
|
|
848
|
+
Math.min(internalScrollX.value, maxRelX),
|
|
849
|
+
Math.min(internalScrollY.value, maxRelY),
|
|
850
|
+
{ behavior: 'auto' },
|
|
851
|
+
);
|
|
852
|
+
} else if ((newLen !== oldLen && scaleY.value !== 1) || (newColCount !== oldColCount && scaleX.value !== 1)) {
|
|
853
|
+
// Even if within bounds, we must sync the display scroll position
|
|
854
|
+
// because the coordinate scaling factor changed.
|
|
855
|
+
scrollToOffset(internalScrollX.value, internalScrollY.value, { behavior: 'auto' });
|
|
856
|
+
}
|
|
857
|
+
updateHostOffset();
|
|
858
|
+
});
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
// --- Range & Visible Items ---
|
|
862
|
+
const getRowIndexAt = (offset: number) => {
|
|
863
|
+
const gap = props.value.gap || 0;
|
|
864
|
+
const columnGap = props.value.columnGap || 0;
|
|
865
|
+
const fixedSize = fixedItemSize.value;
|
|
866
|
+
|
|
867
|
+
if (direction.value === 'horizontal') {
|
|
868
|
+
const step = (fixedSize || 0) + columnGap;
|
|
869
|
+
if (fixedSize !== null && step > 0) {
|
|
870
|
+
return Math.floor(offset / step);
|
|
871
|
+
}
|
|
872
|
+
return itemSizesX.findLowerBound(offset);
|
|
873
|
+
}
|
|
874
|
+
const step = (fixedSize || 0) + gap;
|
|
875
|
+
if (fixedSize !== null && step > 0) {
|
|
876
|
+
return Math.floor(offset / step);
|
|
877
|
+
}
|
|
878
|
+
return itemSizesY.findLowerBound(offset);
|
|
879
|
+
};
|
|
880
|
+
|
|
881
|
+
const getColIndexAt = (offset: number) => {
|
|
882
|
+
if (direction.value === 'both') {
|
|
883
|
+
return columnSizes.findLowerBound(offset);
|
|
884
|
+
}
|
|
885
|
+
if (direction.value === 'horizontal') {
|
|
886
|
+
return getRowIndexAt(offset);
|
|
887
|
+
}
|
|
888
|
+
return 0;
|
|
889
|
+
};
|
|
890
|
+
|
|
833
891
|
/**
|
|
834
892
|
* Current range of items that should be rendered.
|
|
835
893
|
*/
|
|
@@ -844,58 +902,26 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
844
902
|
};
|
|
845
903
|
}
|
|
846
904
|
|
|
847
|
-
const direction = props.value.direction || 'vertical';
|
|
848
905
|
const bufferBefore = (props.value.ssrRange && !isScrolling.value) ? 0 : (props.value.bufferBefore ?? DEFAULT_BUFFER);
|
|
849
906
|
const bufferAfter = props.value.bufferAfter ?? DEFAULT_BUFFER;
|
|
850
|
-
const gap = props.value.gap || 0;
|
|
851
|
-
const columnGap = props.value.columnGap || 0;
|
|
852
|
-
const fixedSize = fixedItemSize.value;
|
|
853
|
-
const paddingStartX = getPaddingX(props.value.scrollPaddingStart, direction);
|
|
854
|
-
const paddingEndX = getPaddingX(props.value.scrollPaddingEnd, direction);
|
|
855
|
-
const paddingStartY = getPaddingY(props.value.scrollPaddingStart, direction);
|
|
856
|
-
const paddingEndY = getPaddingY(props.value.scrollPaddingEnd, direction);
|
|
857
|
-
|
|
858
|
-
const isVertical = direction === 'vertical' || direction === 'both';
|
|
859
|
-
const isHorizontal = direction === 'horizontal' || direction === 'both';
|
|
860
|
-
|
|
861
|
-
const usableWidth = viewportWidth.value - (isHorizontal ? (paddingStartX + paddingEndX) : 0);
|
|
862
|
-
const usableHeight = viewportHeight.value - (isVertical ? (paddingStartY + paddingEndY) : 0);
|
|
863
|
-
|
|
864
|
-
let start = 0;
|
|
865
|
-
let end = props.value.items.length;
|
|
866
|
-
|
|
867
|
-
if (isVertical) {
|
|
868
|
-
if (fixedSize !== null) {
|
|
869
|
-
start = Math.floor(relativeScrollY.value / (fixedSize + gap));
|
|
870
|
-
end = Math.ceil((relativeScrollY.value + usableHeight) / (fixedSize + gap));
|
|
871
|
-
} else {
|
|
872
|
-
start = itemSizesY.findLowerBound(relativeScrollY.value);
|
|
873
|
-
let currentY = itemSizesY.query(start);
|
|
874
|
-
let i = start;
|
|
875
|
-
while (i < props.value.items.length && currentY < relativeScrollY.value + usableHeight) {
|
|
876
|
-
currentY = itemSizesY.query(++i);
|
|
877
|
-
}
|
|
878
|
-
end = i;
|
|
879
|
-
}
|
|
880
|
-
} else {
|
|
881
|
-
if (fixedSize !== null) {
|
|
882
|
-
start = Math.floor(relativeScrollX.value / (fixedSize + columnGap));
|
|
883
|
-
end = Math.ceil((relativeScrollX.value + usableWidth) / (fixedSize + columnGap));
|
|
884
|
-
} else {
|
|
885
|
-
start = itemSizesX.findLowerBound(relativeScrollX.value);
|
|
886
|
-
let currentX = itemSizesX.query(start);
|
|
887
|
-
let i = start;
|
|
888
|
-
while (i < props.value.items.length && currentX < relativeScrollX.value + usableWidth) {
|
|
889
|
-
currentX = itemSizesX.query(++i);
|
|
890
|
-
}
|
|
891
|
-
end = i;
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
907
|
|
|
895
|
-
return {
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
908
|
+
return calculateRange({
|
|
909
|
+
direction: direction.value,
|
|
910
|
+
relativeScrollX: relativeScrollX.value,
|
|
911
|
+
relativeScrollY: relativeScrollY.value,
|
|
912
|
+
usableWidth: usableWidth.value,
|
|
913
|
+
usableHeight: usableHeight.value,
|
|
914
|
+
itemsLength: props.value.items.length,
|
|
915
|
+
bufferBefore,
|
|
916
|
+
bufferAfter,
|
|
917
|
+
gap: props.value.gap || 0,
|
|
918
|
+
columnGap: props.value.columnGap || 0,
|
|
919
|
+
fixedSize: fixedItemSize.value,
|
|
920
|
+
findLowerBoundY: (offset) => itemSizesY.findLowerBound(offset),
|
|
921
|
+
findLowerBoundX: (offset) => itemSizesX.findLowerBound(offset),
|
|
922
|
+
queryY: (idx) => itemSizesY.query(idx),
|
|
923
|
+
queryX: (idx) => itemSizesX.query(idx),
|
|
924
|
+
});
|
|
899
925
|
});
|
|
900
926
|
|
|
901
927
|
/**
|
|
@@ -905,25 +931,69 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
905
931
|
// eslint-disable-next-line ts/no-unused-expressions
|
|
906
932
|
treeUpdateFlag.value;
|
|
907
933
|
|
|
908
|
-
const
|
|
909
|
-
const
|
|
910
|
-
const
|
|
934
|
+
const offsetX = relativeScrollX.value + stickyStartX.value;
|
|
935
|
+
const offsetY = relativeScrollY.value + stickyStartY.value;
|
|
936
|
+
const offset = direction.value === 'horizontal' ? offsetX : offsetY;
|
|
937
|
+
return getRowIndexAt(offset);
|
|
938
|
+
});
|
|
911
939
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
940
|
+
const columnRange = computed(() => {
|
|
941
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
942
|
+
treeUpdateFlag.value;
|
|
943
|
+
|
|
944
|
+
const totalCols = props.value.columnCount || 0;
|
|
945
|
+
|
|
946
|
+
if (!totalCols) {
|
|
947
|
+
return { start: 0, end: 0, padStart: 0, padEnd: 0 };
|
|
917
948
|
}
|
|
918
|
-
|
|
919
|
-
|
|
949
|
+
|
|
950
|
+
if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
|
|
951
|
+
const { colStart = 0, colEnd = 0 } = props.value.ssrRange;
|
|
952
|
+
const safeStart = Math.max(0, colStart);
|
|
953
|
+
const safeEnd = Math.min(totalCols, colEnd || totalCols);
|
|
954
|
+
|
|
955
|
+
const columnGap = props.value.columnGap || 0;
|
|
956
|
+
const padStart = fixedColumnWidth.value !== null
|
|
957
|
+
? safeStart * (fixedColumnWidth.value + columnGap)
|
|
958
|
+
: columnSizes.query(safeStart);
|
|
959
|
+
|
|
960
|
+
const totalColWidth = fixedColumnWidth.value !== null
|
|
961
|
+
? totalCols * (fixedColumnWidth.value + columnGap) - columnGap
|
|
962
|
+
: Math.max(0, columnSizes.query(totalCols) - columnGap);
|
|
963
|
+
|
|
964
|
+
const contentEnd = fixedColumnWidth.value !== null
|
|
965
|
+
? (safeEnd * (fixedColumnWidth.value + columnGap) - (safeEnd > 0 ? columnGap : 0))
|
|
966
|
+
: (columnSizes.query(safeEnd) - (safeEnd > 0 ? columnGap : 0));
|
|
967
|
+
|
|
968
|
+
return {
|
|
969
|
+
start: safeStart,
|
|
970
|
+
end: safeEnd,
|
|
971
|
+
padStart,
|
|
972
|
+
padEnd: Math.max(0, totalColWidth - contentEnd),
|
|
973
|
+
};
|
|
920
974
|
}
|
|
921
|
-
|
|
975
|
+
|
|
976
|
+
const colBuffer = (props.value.ssrRange && !isScrolling.value) ? 0 : 2;
|
|
977
|
+
|
|
978
|
+
return calculateColumnRange({
|
|
979
|
+
columnCount: totalCols,
|
|
980
|
+
relativeScrollX: relativeScrollX.value,
|
|
981
|
+
usableWidth: usableWidth.value,
|
|
982
|
+
colBuffer,
|
|
983
|
+
fixedWidth: fixedColumnWidth.value,
|
|
984
|
+
columnGap: props.value.columnGap || 0,
|
|
985
|
+
findLowerBound: (offset) => columnSizes.findLowerBound(offset),
|
|
986
|
+
query: (idx) => columnSizes.query(idx),
|
|
987
|
+
totalColsQuery: () => columnSizes.query(totalCols),
|
|
988
|
+
});
|
|
922
989
|
});
|
|
923
990
|
|
|
924
991
|
/**
|
|
925
992
|
* List of items to be rendered with their calculated offsets and sizes.
|
|
926
993
|
*/
|
|
994
|
+
|
|
995
|
+
let lastRenderedItems: RenderedItem<T>[] = [];
|
|
996
|
+
|
|
927
997
|
const renderedItems = computed<RenderedItem<T>[]>(() => {
|
|
928
998
|
// eslint-disable-next-line ts/no-unused-expressions
|
|
929
999
|
treeUpdateFlag.value;
|
|
@@ -934,231 +1004,209 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
934
1004
|
const gap = props.value.gap || 0;
|
|
935
1005
|
const columnGap = props.value.columnGap || 0;
|
|
936
1006
|
const stickyIndices = sortedStickyIndices.value;
|
|
1007
|
+
const stickySet = stickyIndicesSet.value;
|
|
937
1008
|
|
|
938
|
-
|
|
939
|
-
const indicesToRender = new Set<number>();
|
|
940
|
-
for (let i = start; i < end; i++) {
|
|
941
|
-
indicesToRender.add(i);
|
|
942
|
-
}
|
|
1009
|
+
const sortedIndices: number[] = [];
|
|
943
1010
|
|
|
944
1011
|
if (isHydrated.value || !props.value.ssrRange) {
|
|
945
1012
|
const activeIdx = currentIndex.value;
|
|
946
|
-
|
|
947
|
-
let prevStickyIdx: number | undefined;
|
|
948
|
-
let low = 0;
|
|
949
|
-
let high = stickyIndices.length - 1;
|
|
950
|
-
while (low <= high) {
|
|
951
|
-
const mid = (low + high) >>> 1;
|
|
952
|
-
if (stickyIndices[ mid ]! < activeIdx) {
|
|
953
|
-
prevStickyIdx = stickyIndices[ mid ];
|
|
954
|
-
low = mid + 1;
|
|
955
|
-
} else {
|
|
956
|
-
high = mid - 1;
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
if (prevStickyIdx !== undefined) {
|
|
961
|
-
indicesToRender.add(prevStickyIdx);
|
|
962
|
-
}
|
|
1013
|
+
const prevStickyIdx = findPrevStickyIndex(stickyIndices, activeIdx);
|
|
963
1014
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
indicesToRender.add(idx);
|
|
967
|
-
}
|
|
1015
|
+
if (prevStickyIdx !== undefined && prevStickyIdx < start) {
|
|
1016
|
+
sortedIndices.push(prevStickyIdx);
|
|
968
1017
|
}
|
|
969
1018
|
}
|
|
970
1019
|
|
|
971
|
-
|
|
1020
|
+
for (let i = start; i < end; i++) {
|
|
1021
|
+
sortedIndices.push(i);
|
|
1022
|
+
}
|
|
972
1023
|
|
|
973
1024
|
const ssrStartRow = props.value.ssrRange?.start || 0;
|
|
1025
|
+
|
|
974
1026
|
const ssrStartCol = props.value.ssrRange?.colStart || 0;
|
|
975
1027
|
|
|
976
1028
|
let ssrOffsetX = 0;
|
|
977
1029
|
let ssrOffsetY = 0;
|
|
978
1030
|
|
|
979
1031
|
if (!isHydrated.value && props.value.ssrRange) {
|
|
980
|
-
ssrOffsetY = (
|
|
1032
|
+
ssrOffsetY = (direction.value !== 'horizontal')
|
|
981
1033
|
? (fixedSize !== null ? ssrStartRow * (fixedSize + gap) : itemSizesY.query(ssrStartRow))
|
|
982
1034
|
: 0;
|
|
983
1035
|
|
|
984
|
-
if (
|
|
1036
|
+
if (direction.value === 'horizontal') {
|
|
985
1037
|
ssrOffsetX = fixedSize !== null ? ssrStartCol * (fixedSize + columnGap) : itemSizesX.query(ssrStartCol);
|
|
986
|
-
} else if (
|
|
1038
|
+
} else if (direction.value === 'both') {
|
|
987
1039
|
ssrOffsetX = columnSizes.query(ssrStartCol);
|
|
988
1040
|
}
|
|
989
1041
|
}
|
|
990
1042
|
|
|
1043
|
+
const lastItemsMap = new Map(lastRenderedItems.map((it) => [ it.index, it ]));
|
|
1044
|
+
|
|
1045
|
+
// Optimization: Cache sequential queries to avoid O(log N) tree traversal for every item
|
|
1046
|
+
let lastIndexX = -1;
|
|
1047
|
+
let lastOffsetX = 0;
|
|
1048
|
+
let lastIndexY = -1;
|
|
1049
|
+
let lastOffsetY = 0;
|
|
1050
|
+
|
|
1051
|
+
const queryXCached = (idx: number) => {
|
|
1052
|
+
if (idx === lastIndexX + 1) {
|
|
1053
|
+
lastOffsetX += itemSizesX.get(lastIndexX);
|
|
1054
|
+
lastIndexX = idx;
|
|
1055
|
+
return lastOffsetX;
|
|
1056
|
+
}
|
|
1057
|
+
lastOffsetX = itemSizesX.query(idx);
|
|
1058
|
+
lastIndexX = idx;
|
|
1059
|
+
return lastOffsetX;
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
const queryYCached = (idx: number) => {
|
|
1063
|
+
if (idx === lastIndexY + 1) {
|
|
1064
|
+
lastOffsetY += itemSizesY.get(lastIndexY);
|
|
1065
|
+
lastIndexY = idx;
|
|
1066
|
+
return lastOffsetY;
|
|
1067
|
+
}
|
|
1068
|
+
lastOffsetY = itemSizesY.query(idx);
|
|
1069
|
+
lastIndexY = idx;
|
|
1070
|
+
return lastOffsetY;
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
const itemsStartVU_X = flowStartX.value + stickyStartX.value + paddingStartX.value;
|
|
1074
|
+
const itemsStartVU_Y = flowStartY.value + stickyStartY.value + paddingStartY.value;
|
|
1075
|
+
const wrapperStartDU_X = flowStartX.value + stickyStartX.value;
|
|
1076
|
+
const wrapperStartDU_Y = flowStartY.value + stickyStartY.value;
|
|
1077
|
+
|
|
1078
|
+
const colRange = columnRange.value;
|
|
1079
|
+
|
|
991
1080
|
for (const i of sortedIndices) {
|
|
992
1081
|
const item = props.value.items[ i ];
|
|
993
1082
|
if (item === undefined) {
|
|
994
1083
|
continue;
|
|
995
1084
|
}
|
|
996
1085
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
}
|
|
1086
|
+
const { x, y, width, height } = calculateItemPosition({
|
|
1087
|
+
index: i,
|
|
1088
|
+
direction: direction.value,
|
|
1089
|
+
fixedSize: fixedItemSize.value,
|
|
1090
|
+
gap: props.value.gap || 0,
|
|
1091
|
+
columnGap: props.value.columnGap || 0,
|
|
1092
|
+
usableWidth: usableWidth.value,
|
|
1093
|
+
usableHeight: usableHeight.value,
|
|
1094
|
+
totalWidth: totalSize.value.width,
|
|
1095
|
+
queryY: queryYCached,
|
|
1096
|
+
queryX: queryXCached,
|
|
1097
|
+
getSizeY: (idx) => itemSizesY.get(idx),
|
|
1098
|
+
getSizeX: (idx) => itemSizesX.get(idx),
|
|
1099
|
+
columnRange: colRange,
|
|
1100
|
+
});
|
|
1012
1101
|
|
|
1013
|
-
const isSticky =
|
|
1102
|
+
const isSticky = stickySet.has(i);
|
|
1014
1103
|
const originalX = x;
|
|
1015
1104
|
const originalY = y;
|
|
1016
|
-
let isStickyActive = false;
|
|
1017
|
-
const stickyOffset = { x: 0, y: 0 };
|
|
1018
|
-
|
|
1019
|
-
if (isSticky) {
|
|
1020
|
-
if (props.value.direction === 'vertical' || props.value.direction === 'both') {
|
|
1021
|
-
if (relativeScrollY.value > originalY) {
|
|
1022
|
-
isStickyActive = true;
|
|
1023
|
-
// Check if next sticky item pushes this one
|
|
1024
|
-
let nextStickyIdx: number | undefined;
|
|
1025
|
-
let low = 0;
|
|
1026
|
-
let high = stickyIndices.length - 1;
|
|
1027
|
-
while (low <= high) {
|
|
1028
|
-
const mid = (low + high) >>> 1;
|
|
1029
|
-
if (stickyIndices[ mid ]! > i) {
|
|
1030
|
-
nextStickyIdx = stickyIndices[ mid ];
|
|
1031
|
-
high = mid - 1;
|
|
1032
|
-
} else {
|
|
1033
|
-
low = mid + 1;
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
|
|
1037
|
-
if (nextStickyIdx !== undefined) {
|
|
1038
|
-
const nextStickyY = fixedSize !== null ? nextStickyIdx * (fixedSize + gap) : itemSizesY.query(nextStickyIdx);
|
|
1039
|
-
const distance = nextStickyY - relativeScrollY.value;
|
|
1040
|
-
/* v8 ignore else -- @preserve */
|
|
1041
|
-
if (distance < height) {
|
|
1042
|
-
stickyOffset.y = -(height - distance);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
} else if (props.value.direction === 'horizontal') {
|
|
1047
|
-
if (relativeScrollX.value > originalX) {
|
|
1048
|
-
isStickyActive = true;
|
|
1049
|
-
// Check if next sticky item pushes this one
|
|
1050
|
-
let nextStickyIdx: number | undefined;
|
|
1051
|
-
let low = 0;
|
|
1052
|
-
let high = stickyIndices.length - 1;
|
|
1053
|
-
while (low <= high) {
|
|
1054
|
-
const mid = (low + high) >>> 1;
|
|
1055
|
-
if (stickyIndices[ mid ]! > i) {
|
|
1056
|
-
nextStickyIdx = stickyIndices[ mid ];
|
|
1057
|
-
high = mid - 1;
|
|
1058
|
-
} else {
|
|
1059
|
-
low = mid + 1;
|
|
1060
|
-
}
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
if (nextStickyIdx !== undefined) {
|
|
1064
|
-
const nextStickyX = fixedSize !== null ? nextStickyIdx * (fixedSize + columnGap) : itemSizesX.query(nextStickyIdx);
|
|
1065
|
-
const distance = nextStickyX - relativeScrollX.value;
|
|
1066
|
-
/* v8 ignore else -- @preserve */
|
|
1067
|
-
if (distance < width) {
|
|
1068
|
-
stickyOffset.x = -(width - distance);
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
1105
|
|
|
1075
|
-
|
|
1076
|
-
item,
|
|
1106
|
+
const { isStickyActive, stickyOffset } = calculateStickyItem({
|
|
1077
1107
|
index: i,
|
|
1078
|
-
|
|
1079
|
-
|
|
1108
|
+
isSticky,
|
|
1109
|
+
direction: direction.value,
|
|
1110
|
+
relativeScrollX: relativeScrollX.value,
|
|
1111
|
+
relativeScrollY: relativeScrollY.value,
|
|
1080
1112
|
originalX,
|
|
1081
1113
|
originalY,
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1114
|
+
width,
|
|
1115
|
+
height,
|
|
1116
|
+
stickyIndices,
|
|
1117
|
+
fixedSize: fixedItemSize.value,
|
|
1118
|
+
fixedWidth: fixedColumnWidth.value,
|
|
1119
|
+
gap: props.value.gap || 0,
|
|
1120
|
+
columnGap: props.value.columnGap || 0,
|
|
1121
|
+
getItemQueryY: (idx) => itemSizesY.query(idx),
|
|
1122
|
+
getItemQueryX: (idx) => itemSizesX.query(idx),
|
|
1085
1123
|
});
|
|
1086
|
-
}
|
|
1087
|
-
return items;
|
|
1088
|
-
});
|
|
1089
|
-
|
|
1090
|
-
const columnRange = computed(() => {
|
|
1091
|
-
// eslint-disable-next-line ts/no-unused-expressions
|
|
1092
|
-
treeUpdateFlag.value;
|
|
1093
|
-
|
|
1094
|
-
const totalCols = props.value.columnCount || 0;
|
|
1095
|
-
|
|
1096
|
-
if (!totalCols) {
|
|
1097
|
-
return { start: 0, end: 0, padStart: 0, padEnd: 0 };
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
if ((!isHydrated.value || isHydrating.value) && props.value.ssrRange) {
|
|
1101
|
-
const { colStart = 0, colEnd = 0 } = props.value.ssrRange;
|
|
1102
|
-
const safeStart = Math.max(0, colStart);
|
|
1103
|
-
const safeEnd = Math.min(totalCols, colEnd || totalCols);
|
|
1104
|
-
return {
|
|
1105
|
-
start: safeStart,
|
|
1106
|
-
end: safeEnd,
|
|
1107
|
-
padStart: 0,
|
|
1108
|
-
padEnd: 0,
|
|
1109
|
-
};
|
|
1110
|
-
}
|
|
1111
1124
|
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1125
|
+
const offsetX = isHydrated.value
|
|
1126
|
+
? (internalScrollX.value / scaleX.value + (originalX + itemsStartVU_X - internalScrollX.value)) - wrapperStartDU_X
|
|
1127
|
+
: (originalX - ssrOffsetX);
|
|
1128
|
+
const offsetY = isHydrated.value
|
|
1129
|
+
? (internalScrollY.value / scaleY.value + (originalY + itemsStartVU_Y - internalScrollY.value)) - wrapperStartDU_Y
|
|
1130
|
+
: (originalY - ssrOffsetY);
|
|
1131
|
+
const last = lastItemsMap.get(i);
|
|
1115
1132
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1133
|
+
if (
|
|
1134
|
+
last
|
|
1135
|
+
&& last.item === item
|
|
1136
|
+
&& last.offset.x === offsetX
|
|
1137
|
+
&& last.offset.y === offsetY
|
|
1138
|
+
&& last.size.width === width
|
|
1139
|
+
&& last.size.height === height
|
|
1140
|
+
&& last.isSticky === isSticky
|
|
1141
|
+
&& last.isStickyActive === isStickyActive
|
|
1142
|
+
&& last.stickyOffset.x === stickyOffset.x
|
|
1143
|
+
&& last.stickyOffset.y === stickyOffset.y
|
|
1144
|
+
) {
|
|
1145
|
+
items.push(last);
|
|
1146
|
+
} else {
|
|
1147
|
+
items.push({
|
|
1148
|
+
item,
|
|
1149
|
+
index: i,
|
|
1150
|
+
offset: { x: offsetX, y: offsetY },
|
|
1151
|
+
size: { width, height },
|
|
1152
|
+
originalX,
|
|
1153
|
+
originalY,
|
|
1154
|
+
isSticky,
|
|
1155
|
+
isStickyActive,
|
|
1156
|
+
stickyOffset: {
|
|
1157
|
+
x: stickyOffset.x,
|
|
1158
|
+
y: stickyOffset.y,
|
|
1159
|
+
},
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1118
1162
|
}
|
|
1119
1163
|
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
// Add buffer of columns
|
|
1123
|
-
const safeStart = Math.max(0, start - colBuffer);
|
|
1124
|
-
const safeEnd = Math.min(totalCols, end + colBuffer);
|
|
1164
|
+
lastRenderedItems = items;
|
|
1125
1165
|
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
return {
|
|
1129
|
-
start: safeStart,
|
|
1130
|
-
end: safeEnd,
|
|
1131
|
-
padStart,
|
|
1132
|
-
padEnd: columnSizes.query(totalCols) - columnSizes.query(safeEnd),
|
|
1133
|
-
};
|
|
1166
|
+
return items;
|
|
1134
1167
|
});
|
|
1135
1168
|
|
|
1136
|
-
/**
|
|
1137
|
-
* Detailed information about the current scroll state.
|
|
1138
|
-
*/
|
|
1139
1169
|
const scrollDetails = computed<ScrollDetails<T>>(() => {
|
|
1140
1170
|
// eslint-disable-next-line ts/no-unused-expressions
|
|
1141
1171
|
treeUpdateFlag.value;
|
|
1142
1172
|
|
|
1143
|
-
const
|
|
1144
|
-
const
|
|
1173
|
+
const currentScrollX = relativeScrollX.value + stickyStartX.value;
|
|
1174
|
+
const currentScrollY = relativeScrollY.value + stickyStartY.value;
|
|
1145
1175
|
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
}
|
|
1176
|
+
const currentEndScrollX = relativeScrollX.value + (viewportWidth.value - stickyEndX.value) - 1;
|
|
1177
|
+
const currentEndScrollY = relativeScrollY.value + (viewportHeight.value - stickyEndY.value) - 1;
|
|
1178
|
+
|
|
1179
|
+
const currentColIndex = getColIndexAt(currentScrollX);
|
|
1180
|
+
const currentRowIndex = getRowIndexAt(currentScrollY);
|
|
1181
|
+
const currentEndIndex = getRowIndexAt(direction.value === 'horizontal' ? currentEndScrollX : currentEndScrollY);
|
|
1182
|
+
const currentEndColIndex = getColIndexAt(currentEndScrollX);
|
|
1154
1183
|
|
|
1155
1184
|
return {
|
|
1156
1185
|
items: renderedItems.value,
|
|
1157
|
-
currentIndex:
|
|
1186
|
+
currentIndex: currentRowIndex,
|
|
1158
1187
|
currentColIndex,
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1188
|
+
currentEndIndex,
|
|
1189
|
+
currentEndColIndex,
|
|
1190
|
+
scrollOffset: {
|
|
1191
|
+
x: internalScrollX.value,
|
|
1192
|
+
y: internalScrollY.value,
|
|
1193
|
+
},
|
|
1194
|
+
displayScrollOffset: {
|
|
1195
|
+
x: isRtl.value ? Math.abs(scrollX.value + hostRefOffset.x) : Math.max(0, scrollX.value - hostRefOffset.x),
|
|
1196
|
+
y: Math.max(0, scrollY.value - hostRefOffset.y),
|
|
1197
|
+
},
|
|
1198
|
+
viewportSize: {
|
|
1199
|
+
width: viewportWidth.value,
|
|
1200
|
+
height: viewportHeight.value,
|
|
1201
|
+
},
|
|
1202
|
+
displayViewportSize: {
|
|
1203
|
+
width: viewportWidth.value,
|
|
1204
|
+
height: viewportHeight.value,
|
|
1205
|
+
},
|
|
1206
|
+
totalSize: {
|
|
1207
|
+
width: totalWidth.value,
|
|
1208
|
+
height: totalHeight.value,
|
|
1209
|
+
},
|
|
1162
1210
|
isScrolling: isScrolling.value,
|
|
1163
1211
|
isProgrammaticScroll: isProgrammaticScroll.value,
|
|
1164
1212
|
range: range.value,
|
|
@@ -1184,14 +1232,24 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1184
1232
|
return;
|
|
1185
1233
|
}
|
|
1186
1234
|
|
|
1235
|
+
updateDirection();
|
|
1236
|
+
|
|
1187
1237
|
if (target === window || target === document) {
|
|
1188
1238
|
scrollX.value = window.scrollX;
|
|
1189
1239
|
scrollY.value = window.scrollY;
|
|
1240
|
+
viewportWidth.value = document.documentElement.clientWidth;
|
|
1241
|
+
viewportHeight.value = document.documentElement.clientHeight;
|
|
1190
1242
|
} else if (isScrollableElement(target)) {
|
|
1191
1243
|
scrollX.value = target.scrollLeft;
|
|
1192
1244
|
scrollY.value = target.scrollTop;
|
|
1245
|
+
viewportWidth.value = target.clientWidth;
|
|
1246
|
+
viewportHeight.value = target.clientHeight;
|
|
1193
1247
|
}
|
|
1194
1248
|
|
|
1249
|
+
const scrollValueX = isRtl.value ? Math.abs(scrollX.value) : scrollX.value;
|
|
1250
|
+
internalScrollX.value = displayToVirtual(scrollValueX, componentOffset.x, scaleX.value);
|
|
1251
|
+
internalScrollY.value = displayToVirtual(scrollY.value, componentOffset.y, scaleY.value);
|
|
1252
|
+
|
|
1195
1253
|
if (!isScrolling.value) {
|
|
1196
1254
|
if (!isProgrammaticScroll.value) {
|
|
1197
1255
|
pendingScroll.value = null;
|
|
@@ -1212,41 +1270,57 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1212
1270
|
*/
|
|
1213
1271
|
const updateItemSizes = (updates: Array<{ index: number; inlineSize: number; blockSize: number; element?: HTMLElement | undefined; }>) => {
|
|
1214
1272
|
let needUpdate = false;
|
|
1273
|
+
let deltaX = 0;
|
|
1274
|
+
let deltaY = 0;
|
|
1215
1275
|
const gap = props.value.gap || 0;
|
|
1216
1276
|
const columnGap = props.value.columnGap || 0;
|
|
1217
1277
|
|
|
1278
|
+
const currentRelX = relativeScrollX.value;
|
|
1279
|
+
const currentRelY = relativeScrollY.value;
|
|
1280
|
+
|
|
1281
|
+
const firstRowIndex = getRowIndexAt(direction.value === 'horizontal' ? currentRelX : currentRelY);
|
|
1282
|
+
const firstColIndex = getColIndexAt(currentRelX);
|
|
1283
|
+
|
|
1284
|
+
const isHorizontalMode = direction.value === 'horizontal';
|
|
1285
|
+
const isBothMode = direction.value === 'both';
|
|
1286
|
+
|
|
1287
|
+
const processedRows = new Set<number>();
|
|
1288
|
+
const processedCols = new Set<number>();
|
|
1289
|
+
|
|
1218
1290
|
for (const { index, inlineSize, blockSize, element } of updates) {
|
|
1291
|
+
// Ignore 0-size measurements as they usually indicate hidden/detached elements
|
|
1292
|
+
if (inlineSize <= 0 && blockSize <= 0) {
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1219
1296
|
const isMeasurable = isDynamicItemSize.value || typeof props.value.itemSize === 'function';
|
|
1220
|
-
if (
|
|
1221
|
-
|
|
1297
|
+
if (index >= 0 && !processedRows.has(index) && isMeasurable && blockSize > 0) {
|
|
1298
|
+
processedRows.add(index);
|
|
1299
|
+
if (isHorizontalMode && inlineSize > 0) {
|
|
1222
1300
|
const oldWidth = itemSizesX.get(index);
|
|
1223
1301
|
const targetWidth = inlineSize + columnGap;
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
/* v8 ignore else -- @preserve */
|
|
1228
|
-
if (!measuredItemsX[ index ] || Math.abs(targetWidth - oldWidth) > 0.5) {
|
|
1229
|
-
itemSizesX.update(index, targetWidth - oldWidth);
|
|
1302
|
+
if (!measuredItemsX[ index ] || Math.abs(targetWidth - oldWidth) > 0.1) {
|
|
1303
|
+
const d = targetWidth - oldWidth;
|
|
1304
|
+
itemSizesX.update(index, d);
|
|
1230
1305
|
measuredItemsX[ index ] = 1;
|
|
1231
1306
|
needUpdate = true;
|
|
1307
|
+
if (index < firstRowIndex) {
|
|
1308
|
+
deltaX += d;
|
|
1309
|
+
}
|
|
1232
1310
|
}
|
|
1233
1311
|
}
|
|
1234
|
-
if (
|
|
1312
|
+
if (!isHorizontalMode) {
|
|
1235
1313
|
const oldHeight = itemSizesY.get(index);
|
|
1236
1314
|
const targetHeight = blockSize + gap;
|
|
1237
1315
|
|
|
1238
|
-
if (
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.5) {
|
|
1242
|
-
itemSizesY.update(index, targetHeight - oldHeight);
|
|
1243
|
-
measuredItemsY[ index ] = 1;
|
|
1244
|
-
needUpdate = true;
|
|
1245
|
-
}
|
|
1246
|
-
} else if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.5) {
|
|
1247
|
-
itemSizesY.update(index, targetHeight - oldHeight);
|
|
1316
|
+
if (!measuredItemsY[ index ] || Math.abs(targetHeight - oldHeight) > 0.1) {
|
|
1317
|
+
const d = targetHeight - oldHeight;
|
|
1318
|
+
itemSizesY.update(index, d);
|
|
1248
1319
|
measuredItemsY[ index ] = 1;
|
|
1249
1320
|
needUpdate = true;
|
|
1321
|
+
if (index < firstRowIndex) {
|
|
1322
|
+
deltaY += d;
|
|
1323
|
+
}
|
|
1250
1324
|
}
|
|
1251
1325
|
}
|
|
1252
1326
|
}
|
|
@@ -1254,27 +1328,58 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1254
1328
|
// Dynamic column width measurement
|
|
1255
1329
|
const isColMeasurable = isDynamicColumnWidth.value || typeof props.value.columnWidth === 'function';
|
|
1256
1330
|
if (
|
|
1257
|
-
|
|
1331
|
+
isBothMode
|
|
1258
1332
|
&& element
|
|
1259
1333
|
&& props.value.columnCount
|
|
1260
1334
|
&& isColMeasurable
|
|
1335
|
+
&& (inlineSize > 0 || element.dataset.colIndex === undefined)
|
|
1261
1336
|
) {
|
|
1262
|
-
const
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
|
|
1268
|
-
|
|
1269
|
-
/* v8 ignore else -- @preserve */
|
|
1270
|
-
if (colIndex >= 0 && colIndex < (props.value.columnCount || 0)) {
|
|
1271
|
-
const w = child.offsetWidth;
|
|
1337
|
+
const colIndexAttr = element.dataset.colIndex;
|
|
1338
|
+
if (colIndexAttr != null) {
|
|
1339
|
+
const colIndex = Number.parseInt(colIndexAttr, 10);
|
|
1340
|
+
if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
|
|
1341
|
+
processedCols.add(colIndex);
|
|
1272
1342
|
const oldW = columnSizes.get(colIndex);
|
|
1273
|
-
const targetW =
|
|
1274
|
-
|
|
1275
|
-
|
|
1343
|
+
const targetW = inlineSize + columnGap;
|
|
1344
|
+
|
|
1345
|
+
if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
|
|
1346
|
+
const d = targetW - oldW;
|
|
1347
|
+
if (Math.abs(d) > 0.1) {
|
|
1348
|
+
columnSizes.update(colIndex, d);
|
|
1349
|
+
needUpdate = true;
|
|
1350
|
+
if (colIndex < firstColIndex) {
|
|
1351
|
+
deltaX += d;
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1276
1354
|
measuredColumns[ colIndex ] = 1;
|
|
1277
|
-
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
} else {
|
|
1358
|
+
// If the element is a row, try to find cells with data-col-index
|
|
1359
|
+
const cells = element.dataset.colIndex !== undefined
|
|
1360
|
+
? [ element ]
|
|
1361
|
+
: Array.from(element.querySelectorAll('[data-col-index]')) as HTMLElement[];
|
|
1362
|
+
|
|
1363
|
+
for (const child of cells) {
|
|
1364
|
+
const colIndex = Number.parseInt(child.dataset.colIndex!, 10);
|
|
1365
|
+
|
|
1366
|
+
if (colIndex >= 0 && colIndex < (props.value.columnCount || 0) && !processedCols.has(colIndex)) {
|
|
1367
|
+
processedCols.add(colIndex);
|
|
1368
|
+
const rect = child.getBoundingClientRect();
|
|
1369
|
+
const w = rect.width;
|
|
1370
|
+
const oldW = columnSizes.get(colIndex);
|
|
1371
|
+
const targetW = w + columnGap;
|
|
1372
|
+
if (!measuredColumns[ colIndex ] || Math.abs(oldW - targetW) > 0.1) {
|
|
1373
|
+
const d = targetW - oldW;
|
|
1374
|
+
if (Math.abs(d) > 0.1) {
|
|
1375
|
+
columnSizes.update(colIndex, d);
|
|
1376
|
+
needUpdate = true;
|
|
1377
|
+
if (colIndex < firstColIndex) {
|
|
1378
|
+
deltaX += d;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
measuredColumns[ colIndex ] = 1;
|
|
1382
|
+
}
|
|
1278
1383
|
}
|
|
1279
1384
|
}
|
|
1280
1385
|
}
|
|
@@ -1283,6 +1388,19 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1283
1388
|
|
|
1284
1389
|
if (needUpdate) {
|
|
1285
1390
|
treeUpdateFlag.value++;
|
|
1391
|
+
// Only compensate if not in a programmatic scroll,
|
|
1392
|
+
// as it would interrupt the browser animation or explicit alignment.
|
|
1393
|
+
const hasPendingScroll = pendingScroll.value !== null || isProgrammaticScroll.value;
|
|
1394
|
+
|
|
1395
|
+
if (!hasPendingScroll && (deltaX !== 0 || deltaY !== 0)) {
|
|
1396
|
+
const contentStartLogicalX = flowStartX.value + stickyStartX.value + paddingStartX.value;
|
|
1397
|
+
const contentStartLogicalY = flowStartY.value + stickyStartY.value + paddingStartY.value;
|
|
1398
|
+
scrollToOffset(
|
|
1399
|
+
deltaX !== 0 ? currentRelX + deltaX + contentStartLogicalX : null,
|
|
1400
|
+
deltaY !== 0 ? currentRelY + deltaY + contentStartLogicalY : null,
|
|
1401
|
+
{ behavior: 'auto' },
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1286
1404
|
}
|
|
1287
1405
|
};
|
|
1288
1406
|
|
|
@@ -1299,17 +1417,89 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1299
1417
|
};
|
|
1300
1418
|
|
|
1301
1419
|
// --- Scroll Queue / Correction Watchers ---
|
|
1302
|
-
|
|
1420
|
+
function checkPendingScroll() {
|
|
1303
1421
|
if (pendingScroll.value && !isHydrating.value) {
|
|
1304
1422
|
const { rowIndex, colIndex, options } = pendingScroll.value;
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1423
|
+
|
|
1424
|
+
const isSmooth = isScrollToIndexOptions(options) && options.behavior === 'smooth';
|
|
1425
|
+
|
|
1426
|
+
// If it's a smooth scroll, we wait until it's finished before correcting.
|
|
1427
|
+
if (isSmooth && isScrolling.value) {
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const container = props.value.container || window;
|
|
1432
|
+
const actualScrollX = (typeof window !== 'undefined' && container === window ? window.scrollX : (container as HTMLElement).scrollLeft);
|
|
1433
|
+
const actualScrollY = (typeof window !== 'undefined' && container === window ? window.scrollY : (container as HTMLElement).scrollTop);
|
|
1434
|
+
|
|
1435
|
+
const scrollValueX = isRtl.value ? Math.abs(actualScrollX) : actualScrollX;
|
|
1436
|
+
const scrollValueY = actualScrollY;
|
|
1437
|
+
|
|
1438
|
+
const currentRelX = displayToVirtual(scrollValueX, 0, scaleX.value);
|
|
1439
|
+
const currentRelY = displayToVirtual(scrollValueY, 0, scaleY.value);
|
|
1440
|
+
|
|
1441
|
+
const { targetX, targetY } = calculateScrollTarget({
|
|
1442
|
+
rowIndex,
|
|
1443
|
+
colIndex,
|
|
1444
|
+
options,
|
|
1445
|
+
direction: direction.value,
|
|
1446
|
+
viewportWidth: viewportWidth.value,
|
|
1447
|
+
viewportHeight: viewportHeight.value,
|
|
1448
|
+
totalWidth: virtualWidth.value,
|
|
1449
|
+
totalHeight: virtualHeight.value,
|
|
1450
|
+
gap: props.value.gap || 0,
|
|
1451
|
+
columnGap: props.value.columnGap || 0,
|
|
1452
|
+
fixedSize: fixedItemSize.value,
|
|
1453
|
+
fixedWidth: fixedColumnWidth.value,
|
|
1454
|
+
relativeScrollX: currentRelX,
|
|
1455
|
+
relativeScrollY: currentRelY,
|
|
1456
|
+
getItemSizeY: (idx) => itemSizesY.get(idx),
|
|
1457
|
+
getItemSizeX: (idx) => itemSizesX.get(idx),
|
|
1458
|
+
getItemQueryY: (idx) => itemSizesY.query(idx),
|
|
1459
|
+
getItemQueryX: (idx) => itemSizesX.query(idx),
|
|
1460
|
+
getColumnSize: (idx) => columnSizes.get(idx),
|
|
1461
|
+
getColumnQuery: (idx) => columnSizes.query(idx),
|
|
1462
|
+
scaleX: scaleX.value,
|
|
1463
|
+
scaleY: scaleY.value,
|
|
1464
|
+
hostOffsetX: componentOffset.x,
|
|
1465
|
+
hostOffsetY: componentOffset.y,
|
|
1466
|
+
stickyIndices: sortedStickyIndices.value,
|
|
1467
|
+
stickyStartX: stickyStartX.value,
|
|
1468
|
+
stickyStartY: stickyStartY.value,
|
|
1469
|
+
stickyEndX: stickyEndX.value,
|
|
1470
|
+
stickyEndY: stickyEndY.value,
|
|
1471
|
+
flowPaddingStartX: flowStartX.value,
|
|
1472
|
+
flowPaddingStartY: flowStartY.value,
|
|
1473
|
+
flowPaddingEndX: flowEndX.value,
|
|
1474
|
+
flowPaddingEndY: flowEndY.value,
|
|
1475
|
+
paddingStartX: paddingStartX.value,
|
|
1476
|
+
paddingStartY: paddingStartY.value,
|
|
1477
|
+
paddingEndX: paddingEndX.value,
|
|
1478
|
+
paddingEndY: paddingEndY.value,
|
|
1479
|
+
});
|
|
1480
|
+
|
|
1481
|
+
const toleranceX = 2;
|
|
1482
|
+
const toleranceY = 2;
|
|
1483
|
+
const reachedX = (colIndex === null || colIndex === undefined) || Math.abs(currentRelX - targetX) < toleranceX;
|
|
1484
|
+
const reachedY = (rowIndex === null || rowIndex === undefined) || Math.abs(currentRelY - targetY) < toleranceY;
|
|
1485
|
+
|
|
1486
|
+
const isMeasuredX = colIndex == null || colIndex === undefined || measuredColumns[ colIndex ] === 1;
|
|
1487
|
+
const isMeasuredY = rowIndex == null || rowIndex === undefined || measuredItemsY[ rowIndex ] === 1;
|
|
1488
|
+
|
|
1489
|
+
if (reachedX && reachedY) {
|
|
1490
|
+
if (isMeasuredX && isMeasuredY && !isScrolling.value && !isProgrammaticScroll.value) {
|
|
1491
|
+
pendingScroll.value = null;
|
|
1492
|
+
}
|
|
1493
|
+
} else {
|
|
1494
|
+
const correctionOptions: ScrollToIndexOptions = isScrollToIndexOptions(options)
|
|
1495
|
+
? { ...options, isCorrection: true }
|
|
1496
|
+
: { align: options as ScrollAlignment | ScrollAlignmentOptions, isCorrection: true };
|
|
1497
|
+
scrollToIndex(rowIndex, colIndex, correctionOptions);
|
|
1498
|
+
}
|
|
1309
1499
|
}
|
|
1310
|
-
}
|
|
1500
|
+
}
|
|
1311
1501
|
|
|
1312
|
-
watch(treeUpdateFlag, checkPendingScroll);
|
|
1502
|
+
watch([ treeUpdateFlag, viewportWidth, viewportHeight ], checkPendingScroll);
|
|
1313
1503
|
|
|
1314
1504
|
watch(isScrolling, (scrolling) => {
|
|
1315
1505
|
if (!scrolling) {
|
|
@@ -1318,6 +1508,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1318
1508
|
});
|
|
1319
1509
|
|
|
1320
1510
|
let resizeObserver: ResizeObserver | null = null;
|
|
1511
|
+
let directionObserver: MutationObserver | null = null;
|
|
1512
|
+
let directionInterval: ReturnType<typeof setInterval> | undefined;
|
|
1321
1513
|
|
|
1322
1514
|
const attachEvents = (container: HTMLElement | Window | null) => {
|
|
1323
1515
|
if (!container || typeof window === 'undefined') {
|
|
@@ -1326,21 +1518,34 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1326
1518
|
const scrollTarget = container === window ? document : container;
|
|
1327
1519
|
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
|
|
1328
1520
|
|
|
1521
|
+
computedStyle = null;
|
|
1522
|
+
updateDirection();
|
|
1523
|
+
|
|
1524
|
+
if (isElement(container)) {
|
|
1525
|
+
directionObserver = new MutationObserver(() => updateDirection());
|
|
1526
|
+
directionObserver.observe(container, { attributes: true, attributeFilter: [ 'dir', 'style' ] });
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
directionInterval = setInterval(updateDirection, 1000);
|
|
1530
|
+
|
|
1329
1531
|
if (container === window) {
|
|
1330
|
-
viewportWidth.value =
|
|
1331
|
-
viewportHeight.value =
|
|
1532
|
+
viewportWidth.value = document.documentElement.clientWidth;
|
|
1533
|
+
viewportHeight.value = document.documentElement.clientHeight;
|
|
1332
1534
|
scrollX.value = window.scrollX;
|
|
1333
1535
|
scrollY.value = window.scrollY;
|
|
1334
1536
|
|
|
1335
1537
|
const onResize = () => {
|
|
1336
|
-
|
|
1337
|
-
|
|
1538
|
+
updateDirection();
|
|
1539
|
+
viewportWidth.value = document.documentElement.clientWidth;
|
|
1540
|
+
viewportHeight.value = document.documentElement.clientHeight;
|
|
1338
1541
|
updateHostOffset();
|
|
1339
1542
|
};
|
|
1340
1543
|
window.addEventListener('resize', onResize);
|
|
1341
1544
|
return () => {
|
|
1342
1545
|
scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1343
1546
|
window.removeEventListener('resize', onResize);
|
|
1547
|
+
clearInterval(directionInterval);
|
|
1548
|
+
computedStyle = null;
|
|
1344
1549
|
};
|
|
1345
1550
|
} else {
|
|
1346
1551
|
viewportWidth.value = (container as HTMLElement).clientWidth;
|
|
@@ -1349,8 +1554,8 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1349
1554
|
scrollY.value = (container as HTMLElement).scrollTop;
|
|
1350
1555
|
|
|
1351
1556
|
resizeObserver = new ResizeObserver((entries) => {
|
|
1557
|
+
updateDirection();
|
|
1352
1558
|
for (const entry of entries) {
|
|
1353
|
-
/* v8 ignore else -- @preserve */
|
|
1354
1559
|
if (entry.target === container) {
|
|
1355
1560
|
viewportWidth.value = (container as HTMLElement).clientWidth;
|
|
1356
1561
|
viewportHeight.value = (container as HTMLElement).clientHeight;
|
|
@@ -1362,6 +1567,9 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1362
1567
|
return () => {
|
|
1363
1568
|
scrollTarget.removeEventListener('scroll', handleScroll);
|
|
1364
1569
|
resizeObserver?.disconnect();
|
|
1570
|
+
directionObserver?.disconnect();
|
|
1571
|
+
clearInterval(directionInterval);
|
|
1572
|
+
computedStyle = null;
|
|
1365
1573
|
};
|
|
1366
1574
|
}
|
|
1367
1575
|
};
|
|
@@ -1371,6 +1579,7 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1371
1579
|
if (getCurrentInstance()) {
|
|
1372
1580
|
onMounted(() => {
|
|
1373
1581
|
isMounted.value = true;
|
|
1582
|
+
updateDirection();
|
|
1374
1583
|
|
|
1375
1584
|
watch(() => props.value.container, (newContainer) => {
|
|
1376
1585
|
cleanup?.();
|
|
@@ -1379,15 +1588,16 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1379
1588
|
|
|
1380
1589
|
updateHostOffset();
|
|
1381
1590
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1591
|
+
// Ensure we have a layout cycle before considering it hydrated
|
|
1592
|
+
// and starting virtualization. This avoids issues with 0-size viewports.
|
|
1593
|
+
nextTick(() => {
|
|
1594
|
+
updateHostOffset();
|
|
1595
|
+
if (props.value.ssrRange || props.value.initialScrollIndex !== undefined) {
|
|
1385
1596
|
const initialIndex = props.value.initialScrollIndex !== undefined
|
|
1386
1597
|
? props.value.initialScrollIndex
|
|
1387
1598
|
: props.value.ssrRange?.start;
|
|
1388
1599
|
const initialAlign = props.value.initialScrollAlign || 'start';
|
|
1389
1600
|
|
|
1390
|
-
/* v8 ignore else -- @preserve */
|
|
1391
1601
|
if (initialIndex !== undefined && initialIndex !== null) {
|
|
1392
1602
|
scrollToIndex(initialIndex, props.value.ssrRange?.colStart, { align: initialAlign, behavior: 'auto' });
|
|
1393
1603
|
}
|
|
@@ -1397,10 +1607,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1397
1607
|
nextTick(() => {
|
|
1398
1608
|
isHydrating.value = false;
|
|
1399
1609
|
});
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
}
|
|
1610
|
+
} else {
|
|
1611
|
+
isHydrated.value = true;
|
|
1612
|
+
}
|
|
1613
|
+
});
|
|
1404
1614
|
});
|
|
1405
1615
|
|
|
1406
1616
|
onUnmounted(() => {
|
|
@@ -1411,6 +1621,10 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1411
1621
|
/**
|
|
1412
1622
|
* The list of items currently rendered in the DOM.
|
|
1413
1623
|
*/
|
|
1624
|
+
/**
|
|
1625
|
+
* Resets all dynamic measurements and re-initializes from current props.
|
|
1626
|
+
* Useful if item source data has changed in a way that affects sizes without changing the items array reference.
|
|
1627
|
+
*/
|
|
1414
1628
|
const refresh = () => {
|
|
1415
1629
|
itemSizesX.resize(0);
|
|
1416
1630
|
itemSizesY.resize(0);
|
|
@@ -1423,73 +1637,203 @@ export function useVirtualScroll<T = unknown>(props: Ref<VirtualScrollProps<T>>)
|
|
|
1423
1637
|
|
|
1424
1638
|
return {
|
|
1425
1639
|
/**
|
|
1426
|
-
* Array of items
|
|
1640
|
+
* Array of items currently rendered in the DOM with their calculated offsets and sizes.
|
|
1641
|
+
* Offsets are in Display Units (DU), sizes are in Virtual Units (VU).
|
|
1642
|
+
* @see RenderedItem
|
|
1427
1643
|
*/
|
|
1428
1644
|
renderedItems,
|
|
1645
|
+
|
|
1429
1646
|
/**
|
|
1430
|
-
* Total calculated width of all items including gaps.
|
|
1647
|
+
* Total calculated width of all items including gaps (in VU).
|
|
1431
1648
|
*/
|
|
1432
1649
|
totalWidth,
|
|
1650
|
+
|
|
1433
1651
|
/**
|
|
1434
|
-
* Total calculated height of all items including gaps.
|
|
1652
|
+
* Total calculated height of all items including gaps (in VU).
|
|
1435
1653
|
*/
|
|
1436
1654
|
totalHeight,
|
|
1655
|
+
|
|
1656
|
+
/**
|
|
1657
|
+
* Total width to be rendered in the DOM (clamped to browser limits, in DU).
|
|
1658
|
+
*/
|
|
1659
|
+
renderedWidth,
|
|
1660
|
+
|
|
1661
|
+
/**
|
|
1662
|
+
* Total height to be rendered in the DOM (clamped to browser limits, in DU).
|
|
1663
|
+
*/
|
|
1664
|
+
renderedHeight,
|
|
1665
|
+
|
|
1437
1666
|
/**
|
|
1438
1667
|
* Detailed information about the current scroll state.
|
|
1439
|
-
* Includes currentIndex, scrollOffset, viewportSize, totalSize, and
|
|
1668
|
+
* Includes currentIndex, scrollOffset (VU), displayScrollOffset (DU), viewportSize (DU), totalSize (VU), and scrolling status.
|
|
1669
|
+
* @see ScrollDetails
|
|
1440
1670
|
*/
|
|
1441
1671
|
scrollDetails,
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* Helper to get the height of a specific row based on current configuration and measurements.
|
|
1675
|
+
*
|
|
1676
|
+
* @param index - The row index.
|
|
1677
|
+
* @returns The height in VU (excluding gap).
|
|
1678
|
+
*/
|
|
1679
|
+
getRowHeight,
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Helper to get the width of a specific column based on current configuration and measurements.
|
|
1683
|
+
*
|
|
1684
|
+
* @param index - The column index.
|
|
1685
|
+
* @returns The width in VU (excluding gap).
|
|
1686
|
+
*/
|
|
1687
|
+
getColumnWidth,
|
|
1688
|
+
|
|
1689
|
+
/**
|
|
1690
|
+
* Helper to get the virtual offset of a specific row.
|
|
1691
|
+
*
|
|
1692
|
+
* @param index - The row index.
|
|
1693
|
+
* @returns The virtual offset in VU.
|
|
1694
|
+
*/
|
|
1695
|
+
getRowOffset: (index: number) => (flowStartY.value + stickyStartY.value + paddingStartY.value) + itemSizesY.query(index),
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Helper to get the virtual offset of a specific column.
|
|
1699
|
+
*
|
|
1700
|
+
* @param index - The column index.
|
|
1701
|
+
* @returns The virtual offset in VU.
|
|
1702
|
+
*/
|
|
1703
|
+
getColumnOffset: (index: number) => (flowStartX.value + stickyStartX.value + paddingStartX.value) + columnSizes.query(index),
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* Helper to get the virtual offset of a specific item along the scroll axis.
|
|
1707
|
+
*
|
|
1708
|
+
* @param index - The item index.
|
|
1709
|
+
* @returns The virtual offset in VU.
|
|
1710
|
+
*/
|
|
1711
|
+
getItemOffset: (index: number) => (direction.value === 'horizontal' ? (flowStartX.value + stickyStartX.value + paddingStartX.value) + itemSizesX.query(index) : (flowStartY.value + stickyStartY.value + paddingStartY.value) + itemSizesY.query(index)),
|
|
1712
|
+
|
|
1713
|
+
/**
|
|
1714
|
+
* Helper to get the size of a specific item along the scroll axis.
|
|
1715
|
+
*
|
|
1716
|
+
* @param index - The item index.
|
|
1717
|
+
* @returns The size in VU (excluding gap).
|
|
1718
|
+
*/
|
|
1719
|
+
getItemSize: (index: number) => {
|
|
1720
|
+
if (direction.value === 'horizontal') {
|
|
1721
|
+
return Math.max(0, itemSizesX.get(index) - (props.value.columnGap || 0));
|
|
1722
|
+
}
|
|
1723
|
+
const itemSize = props.value.itemSize;
|
|
1724
|
+
if (typeof itemSize === 'number' && itemSize > 0) {
|
|
1725
|
+
return itemSize;
|
|
1726
|
+
}
|
|
1727
|
+
if (typeof itemSize === 'function') {
|
|
1728
|
+
const item = props.value.items[ index ];
|
|
1729
|
+
return item !== undefined ? itemSize(item, index) : (props.value.defaultItemSize || DEFAULT_ITEM_SIZE);
|
|
1730
|
+
}
|
|
1731
|
+
return Math.max(0, itemSizesY.get(index) - (props.value.gap || 0));
|
|
1732
|
+
},
|
|
1733
|
+
|
|
1442
1734
|
/**
|
|
1443
1735
|
* Programmatically scroll to a specific row and/or column.
|
|
1444
|
-
*
|
|
1445
|
-
* @param
|
|
1446
|
-
* @param
|
|
1736
|
+
*
|
|
1737
|
+
* @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
|
|
1738
|
+
* @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
|
|
1739
|
+
* @param options - Alignment and behavior options.
|
|
1740
|
+
* @see ScrollAlignment
|
|
1741
|
+
* @see ScrollToIndexOptions
|
|
1447
1742
|
*/
|
|
1448
1743
|
scrollToIndex,
|
|
1744
|
+
|
|
1449
1745
|
/**
|
|
1450
|
-
* Programmatically scroll to a specific pixel offset.
|
|
1451
|
-
*
|
|
1452
|
-
* @param
|
|
1453
|
-
* @param
|
|
1746
|
+
* Programmatically scroll to a specific pixel offset relative to the content start.
|
|
1747
|
+
*
|
|
1748
|
+
* @param x - The pixel offset to scroll to on the X axis (VU). Pass null to keep current position.
|
|
1749
|
+
* @param y - The pixel offset to scroll to on the Y axis (VU). Pass null to keep current position.
|
|
1750
|
+
* @param options - Scroll options (behavior).
|
|
1454
1751
|
*/
|
|
1455
1752
|
scrollToOffset,
|
|
1753
|
+
|
|
1456
1754
|
/**
|
|
1457
|
-
* Stops any currently active
|
|
1755
|
+
* Stops any currently active smooth scroll animation and clears pending corrections.
|
|
1458
1756
|
*/
|
|
1459
1757
|
stopProgrammaticScroll,
|
|
1758
|
+
|
|
1460
1759
|
/**
|
|
1461
1760
|
* Updates the stored size of an item. Should be called when an item is measured (e.g., via ResizeObserver).
|
|
1462
|
-
*
|
|
1463
|
-
* @param
|
|
1464
|
-
* @param
|
|
1465
|
-
* @param
|
|
1761
|
+
*
|
|
1762
|
+
* @param index - The item index.
|
|
1763
|
+
* @param width - The measured inlineSize (width in DU).
|
|
1764
|
+
* @param height - The measured blockSize (height in DU).
|
|
1765
|
+
* @param element - The measured element (optional, used for robust grid column detection).
|
|
1466
1766
|
*/
|
|
1467
1767
|
updateItemSize,
|
|
1768
|
+
|
|
1468
1769
|
/**
|
|
1469
|
-
* Updates the stored size of multiple items
|
|
1470
|
-
*
|
|
1770
|
+
* Updates the stored size of multiple items simultaneously.
|
|
1771
|
+
*
|
|
1772
|
+
* @param updates - Array of measurement updates (sizes in DU).
|
|
1471
1773
|
*/
|
|
1472
1774
|
updateItemSizes,
|
|
1775
|
+
|
|
1473
1776
|
/**
|
|
1474
1777
|
* Recalculates the host element's offset relative to the scroll container.
|
|
1778
|
+
* Useful if the container or host moves without a resize event.
|
|
1475
1779
|
*/
|
|
1476
1780
|
updateHostOffset,
|
|
1781
|
+
|
|
1477
1782
|
/**
|
|
1478
|
-
*
|
|
1783
|
+
* Detects the current direction (LTR/RTL) of the scroll container.
|
|
1479
1784
|
*/
|
|
1480
|
-
|
|
1785
|
+
updateDirection,
|
|
1786
|
+
|
|
1481
1787
|
/**
|
|
1482
|
-
*
|
|
1483
|
-
* @
|
|
1788
|
+
* Information about the current visible range of columns and their paddings.
|
|
1789
|
+
* @see ColumnRange
|
|
1484
1790
|
*/
|
|
1485
|
-
|
|
1791
|
+
columnRange,
|
|
1792
|
+
|
|
1486
1793
|
/**
|
|
1487
1794
|
* Resets all dynamic measurements and re-initializes from props.
|
|
1795
|
+
* Useful if item sizes have changed externally.
|
|
1488
1796
|
*/
|
|
1489
1797
|
refresh,
|
|
1798
|
+
|
|
1490
1799
|
/**
|
|
1491
1800
|
* Whether the component has finished its first client-side mount and hydration.
|
|
1492
1801
|
*/
|
|
1493
1802
|
isHydrated,
|
|
1803
|
+
|
|
1804
|
+
/**
|
|
1805
|
+
* Whether the container is the window or body.
|
|
1806
|
+
*/
|
|
1807
|
+
isWindowContainer,
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Whether the scroll container is in Right-to-Left (RTL) mode.
|
|
1811
|
+
*/
|
|
1812
|
+
isRtl,
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* Coordinate scaling factor for X axis (VU/DU).
|
|
1816
|
+
*/
|
|
1817
|
+
scaleX,
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* Coordinate scaling factor for Y axis (VU/DU).
|
|
1821
|
+
*/
|
|
1822
|
+
scaleY,
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Absolute offset of the component within its container (DU).
|
|
1826
|
+
*/
|
|
1827
|
+
componentOffset,
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Physical width of the items wrapper in the DOM (clamped to browser limits, in DU).
|
|
1831
|
+
*/
|
|
1832
|
+
renderedVirtualWidth,
|
|
1833
|
+
|
|
1834
|
+
/**
|
|
1835
|
+
* Physical height of the items wrapper in the DOM (clamped to browser limits, in DU).
|
|
1836
|
+
*/
|
|
1837
|
+
renderedVirtualHeight,
|
|
1494
1838
|
};
|
|
1495
1839
|
}
|