@v1nt1248/3nclient-lib 0.3.35 → 0.3.37
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 +1 -0
- package/dist/src/components/index.d.ts +4 -2
- package/dist/src/components/ui3n-scrollbar/types.d.ts +61 -0
- package/dist/src/components/ui3n-scrollbar/ui3n-scrollbar.d.ts +53 -0
- package/dist/src/components/ui3n-scrollbar-vertical/ui3n-scrollbar-vertical.d.ts +1 -1
- package/dist/src/components/ui3n-table/composables/useTable.d.ts +6 -1
- package/dist/src/components/ui3n-table/types.d.ts +52 -0
- package/dist/src/components/ui3n-virtual-scroll/types.d.ts +38 -0
- package/dist/src/components/ui3n-virtual-scroll/ui3n-virtual-scroll.d.ts +9 -5
- package/dist/src/index.d.ts +2 -0
- package/dist/src/ui3n-components.d.ts +2 -0
- package/dist/style.css +1 -1
- package/dist/ui3n-components.js +1819 -1181
- package/package.json +2 -14
- package/src/components/index.ts +26 -1
- package/src/components/ui3n-scrollbar/types.ts +63 -0
- package/src/components/ui3n-scrollbar/ui3n-scrollbar.vue +697 -0
- package/src/components/ui3n-table/composables/useTable.ts +169 -16
- package/src/components/ui3n-table/types.ts +52 -0
- package/src/components/ui3n-table/ui3n-table.vue +376 -6
- package/src/components/ui3n-virtual-scroll/types.ts +40 -0
- package/src/components/ui3n-virtual-scroll/ui3n-virtual-scroll.vue +368 -59
|
@@ -1,18 +1,33 @@
|
|
|
1
|
-
<script lang="ts" setup generic="T extends { id
|
|
1
|
+
<script lang="ts" setup generic="T extends { id: string }">
|
|
2
2
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
-
import { computed, ref, shallowRef, onMounted, onBeforeUnmount, watch } from 'vue';
|
|
3
|
+
import { computed, ref, shallowRef, onMounted, onBeforeUnmount, watch, nextTick } from 'vue';
|
|
4
4
|
import Ui3nScrollbarVertical from '../ui3n-scrollbar-vertical/ui3n-scrollbar-vertical.vue';
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
Ui3nVirtualScrollEmits,
|
|
7
|
+
Ui3nVirtualScrollExpose,
|
|
8
|
+
Ui3nVirtualScrollProps,
|
|
9
|
+
Ui3nVirtualScrollSlots,
|
|
10
|
+
} from './types';
|
|
6
11
|
|
|
7
12
|
interface MeasuredSize {
|
|
8
13
|
height: number;
|
|
9
14
|
top: number;
|
|
10
15
|
}
|
|
11
16
|
|
|
17
|
+
interface ScrollAnchor {
|
|
18
|
+
id: string;
|
|
19
|
+
/** How far scrollTop is below the anchor item top */
|
|
20
|
+
offsetWithinItem: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
12
23
|
const props = withDefaults(defineProps<Ui3nVirtualScrollProps<T>>(), {
|
|
13
24
|
renderAhead: 20,
|
|
14
25
|
minChildHeight: 16,
|
|
26
|
+
stickToBottom: false,
|
|
27
|
+
stickToBottomThreshold: 32,
|
|
28
|
+
reachEdgeThreshold: 64,
|
|
15
29
|
});
|
|
30
|
+
const emits = defineEmits<Ui3nVirtualScrollEmits>();
|
|
16
31
|
defineSlots<Ui3nVirtualScrollSlots<T>>();
|
|
17
32
|
|
|
18
33
|
const scrollTop = ref(0);
|
|
@@ -22,14 +37,28 @@
|
|
|
22
37
|
const sizeCache = shallowRef<MeasuredSize[]>([]);
|
|
23
38
|
const cacheVersion = ref(0);
|
|
24
39
|
|
|
40
|
+
/** Long-lived measured heights keyed by item id */
|
|
41
|
+
const heightById = new Map<string, number>();
|
|
42
|
+
/** id -> index in the current props.items */
|
|
43
|
+
const idToIndex = shallowRef(new Map<string, number>());
|
|
44
|
+
|
|
25
45
|
let isAdjustingScroll = false;
|
|
26
46
|
let listResizeObserver: ResizeObserver | null = null;
|
|
27
47
|
let containerResizeObserver: ResizeObserver | null = null;
|
|
48
|
+
/** Previous items snapshot for anchor capture */
|
|
49
|
+
let prevItemsRef: T[] = [];
|
|
50
|
+
/**
|
|
51
|
+
* Which edge has already been announced. Without it a consumer that loads
|
|
52
|
+
* a page on 'reached-top' would get an event on every scroll tick while
|
|
53
|
+
* sitting at the edge, and fire a request per tick.
|
|
54
|
+
*/
|
|
55
|
+
let announcedEdge: 'top' | 'bottom' | null = null;
|
|
28
56
|
|
|
29
|
-
const itemRefs = new Map<string
|
|
57
|
+
const itemRefs = new Map<string, HTMLElement>();
|
|
30
58
|
|
|
31
59
|
const totalHeight = computed(() => {
|
|
32
|
-
|
|
60
|
+
// Touched to make this computed depend on measurements of the size cache
|
|
61
|
+
void cacheVersion.value;
|
|
33
62
|
if (!sizeCache.value.length) {
|
|
34
63
|
return 0;
|
|
35
64
|
}
|
|
@@ -47,21 +76,18 @@
|
|
|
47
76
|
return el ? el.clientHeight : 0;
|
|
48
77
|
});
|
|
49
78
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const targetTop = scrollTop.value;
|
|
53
|
-
|
|
54
|
-
if (!sizeCache.value.length) {
|
|
79
|
+
function findIndexAtOffset(cache: MeasuredSize[], targetTop: number): number {
|
|
80
|
+
if (!cache.length) {
|
|
55
81
|
return 0;
|
|
56
82
|
}
|
|
57
83
|
|
|
58
84
|
let low = 0;
|
|
59
|
-
let high =
|
|
85
|
+
let high = cache.length - 1;
|
|
60
86
|
let foundIndex = -1;
|
|
61
87
|
|
|
62
88
|
while (low <= high) {
|
|
63
89
|
const mid = Math.floor((low + high) / 2);
|
|
64
|
-
const item =
|
|
90
|
+
const item = cache[mid];
|
|
65
91
|
|
|
66
92
|
if (item.top <= targetTop && item.top + item.height > targetTop) {
|
|
67
93
|
foundIndex = mid;
|
|
@@ -77,21 +103,45 @@
|
|
|
77
103
|
foundIndex = Math.max(0, high);
|
|
78
104
|
}
|
|
79
105
|
|
|
106
|
+
return foundIndex;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const startNode = computed(() => {
|
|
110
|
+
// Touched to make this computed depend on measurements of the size cache
|
|
111
|
+
void cacheVersion.value;
|
|
112
|
+
if (!sizeCache.value.length) {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const foundIndex = findIndexAtOffset(sizeCache.value, scrollTop.value);
|
|
80
117
|
return Math.max(0, foundIndex - props.renderAhead);
|
|
81
118
|
});
|
|
82
119
|
|
|
120
|
+
/**
|
|
121
|
+
* How many items to keep mounted. Measured heights are used when they are
|
|
122
|
+
* available: deriving the count from minChildHeight alone inflates the window
|
|
123
|
+
* several times over whenever items are taller than that minimum.
|
|
124
|
+
*/
|
|
83
125
|
const visibleNodeCount = computed(() => {
|
|
126
|
+
// Touched to make this computed depend on measurements of the size cache
|
|
127
|
+
void cacheVersion.value;
|
|
84
128
|
const height = wrapElementHeight.value;
|
|
85
129
|
if (!height) {
|
|
86
130
|
return props.renderAhead * 2;
|
|
87
131
|
}
|
|
88
132
|
|
|
89
|
-
|
|
90
|
-
|
|
133
|
+
if (!sizeCache.value.length) {
|
|
134
|
+
return Math.ceil(height / props.minChildHeight) + 2 * props.renderAhead;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const firstVisible = findIndexAtOffset(sizeCache.value, scrollTop.value);
|
|
138
|
+
const lastVisible = findIndexAtOffset(sizeCache.value, scrollTop.value + height);
|
|
139
|
+
return lastVisible - firstVisible + 1 + 2 * props.renderAhead;
|
|
91
140
|
});
|
|
92
141
|
|
|
93
142
|
const offsetY = computed(() => {
|
|
94
|
-
|
|
143
|
+
// Touched to make this computed depend on measurements of the size cache
|
|
144
|
+
void cacheVersion.value;
|
|
95
145
|
if (!sizeCache.value.length) {
|
|
96
146
|
return 0;
|
|
97
147
|
}
|
|
@@ -110,42 +160,283 @@
|
|
|
110
160
|
return props.items.slice(start, start + count);
|
|
111
161
|
});
|
|
112
162
|
|
|
163
|
+
function getContainerEl(): HTMLElement | null {
|
|
164
|
+
return scrollbarComponentRef.value?.getContainer() ?? null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function getTotalHeightFromCache(cache: MeasuredSize[]): number {
|
|
168
|
+
if (!cache.length) {
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const last = cache[cache.length - 1];
|
|
173
|
+
return last.top + last.height;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isNearBottom(
|
|
177
|
+
containerEl: HTMLElement | null,
|
|
178
|
+
cache: MeasuredSize[],
|
|
179
|
+
threshold = props.stickToBottomThreshold,
|
|
180
|
+
): boolean {
|
|
181
|
+
if (!containerEl || !cache.length) {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const total = getTotalHeightFromCache(cache);
|
|
186
|
+
const distance = total - (containerEl.scrollTop + containerEl.clientHeight);
|
|
187
|
+
return distance <= threshold;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function applyScrollTop(containerEl: HTMLElement | null, nextScrollTop: number) {
|
|
191
|
+
if (!containerEl) {
|
|
192
|
+
scrollTop.value = Math.max(0, nextScrollTop);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
isAdjustingScroll = true;
|
|
197
|
+
containerEl.scrollTop = Math.max(0, nextScrollTop);
|
|
198
|
+
scrollTop.value = containerEl.scrollTop;
|
|
199
|
+
isAdjustingScroll = false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function scrollToBottomInternal(containerEl: HTMLElement | null = getContainerEl(), cache: MeasuredSize[] = sizeCache.value) {
|
|
203
|
+
if (!cache.length) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const total = getTotalHeightFromCache(cache);
|
|
208
|
+
const viewport = containerEl?.clientHeight ?? wrapElementHeight.value;
|
|
209
|
+
applyScrollTop(containerEl, total - viewport);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function captureScrollAnchor(
|
|
213
|
+
cache: MeasuredSize[],
|
|
214
|
+
items: T[],
|
|
215
|
+
scrollTopValue: number,
|
|
216
|
+
): ScrollAnchor | null {
|
|
217
|
+
if (!cache.length || !items.length) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const index = findIndexAtOffset(cache, scrollTopValue);
|
|
222
|
+
const row = cache[index];
|
|
223
|
+
const id = items[index]?.id;
|
|
224
|
+
|
|
225
|
+
if (!row || !id) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
id,
|
|
231
|
+
offsetWithinItem: scrollTopValue - row.top,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function restoreScrollAnchor(
|
|
236
|
+
anchor: ScrollAnchor,
|
|
237
|
+
cache: MeasuredSize[],
|
|
238
|
+
indexMap: Map<string, number>,
|
|
239
|
+
containerEl: HTMLElement | null,
|
|
240
|
+
): boolean {
|
|
241
|
+
const index = indexMap.get(anchor.id);
|
|
242
|
+
if (index === undefined || !cache[index]) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
applyScrollTop(containerEl, cache[index].top + anchor.offsetWithinItem);
|
|
247
|
+
return true;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function rebuildSizeCacheFromItems(): MeasuredSize[] {
|
|
251
|
+
const nextIdToIndex = new Map<string, number>();
|
|
252
|
+
const nextCache: MeasuredSize[] = new Array(props.items.length);
|
|
253
|
+
const retainedHeights = new Map<string, number>();
|
|
254
|
+
|
|
255
|
+
let currentTop = 0;
|
|
256
|
+
|
|
257
|
+
for (let i = 0; i < props.items.length; i++) {
|
|
258
|
+
const id = props.items[i].id;
|
|
259
|
+
nextIdToIndex.set(id, i);
|
|
260
|
+
|
|
261
|
+
const height = heightById.get(id) ?? props.minChildHeight;
|
|
262
|
+
retainedHeights.set(id, height);
|
|
263
|
+
|
|
264
|
+
nextCache[i] = { height, top: currentTop };
|
|
265
|
+
currentTop += height;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Drop heights for removed ids so the map does not grow forever
|
|
269
|
+
heightById.clear();
|
|
270
|
+
for (const [id, height] of retainedHeights) {
|
|
271
|
+
heightById.set(id, height);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (import.meta.env.DEV && nextIdToIndex.size !== props.items.length) {
|
|
275
|
+
// Duplicates would otherwise fail silently: the map keeps one index per
|
|
276
|
+
// id, so measurements of one element land on another element's row
|
|
277
|
+
console.warn('[Ui3nVirtualScroll] items contain duplicate ids, item heights and scroll anchoring will be wrong');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
idToIndex.value = nextIdToIndex;
|
|
281
|
+
return nextCache;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function syncItemsCache() {
|
|
285
|
+
const containerEl = getContainerEl();
|
|
286
|
+
const prevCache = sizeCache.value;
|
|
287
|
+
const prevItems = prevItemsRef;
|
|
288
|
+
|
|
289
|
+
// An empty previous cache means this is the first fill of the list, and
|
|
290
|
+
// a chat has to open at its newest item rather than at the oldest one
|
|
291
|
+
const shouldStick = props.stickToBottom && (!prevCache.length || isNearBottom(containerEl, prevCache));
|
|
292
|
+
const anchor = shouldStick ? null : captureScrollAnchor(prevCache, prevItems, scrollTop.value);
|
|
293
|
+
|
|
294
|
+
const nextCache = rebuildSizeCacheFromItems();
|
|
295
|
+
sizeCache.value = nextCache;
|
|
296
|
+
cacheVersion.value++;
|
|
297
|
+
|
|
298
|
+
prevItemsRef = props.items.slice();
|
|
299
|
+
// The consumer has responded with new items, so the edge may be announced again
|
|
300
|
+
announcedEdge = null;
|
|
301
|
+
|
|
302
|
+
const finalizeScroll = () => {
|
|
303
|
+
const el = getContainerEl();
|
|
304
|
+
|
|
305
|
+
if (shouldStick) {
|
|
306
|
+
scrollToBottomInternal(el, sizeCache.value);
|
|
307
|
+
} else if (anchor) {
|
|
308
|
+
const restored = restoreScrollAnchor(anchor, sizeCache.value, idToIndex.value, el);
|
|
309
|
+
if (!restored && el) {
|
|
310
|
+
// Anchor item removed — keep scroll within bounds
|
|
311
|
+
const maxScroll = Math.max(0, getTotalHeightFromCache(sizeCache.value) - el.clientHeight);
|
|
312
|
+
if (scrollTop.value > maxScroll) {
|
|
313
|
+
applyScrollTop(el, maxScroll);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
scrollbarComponentRef.value?.updateMetrics();
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// Wait for DOM/layout so container metrics and totalHeight are up to date
|
|
322
|
+
nextTick(() => {
|
|
323
|
+
requestAnimationFrame(finalizeScroll);
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
113
327
|
function onScroll(event: Event) {
|
|
114
328
|
if (isAdjustingScroll) {
|
|
115
329
|
return;
|
|
116
330
|
}
|
|
117
331
|
|
|
118
|
-
|
|
332
|
+
const containerEl = event.target as HTMLDivElement;
|
|
333
|
+
scrollTop.value = containerEl.scrollTop;
|
|
334
|
+
emits('scroll', event);
|
|
335
|
+
|
|
336
|
+
const atTop = scrollTop.value <= props.reachEdgeThreshold;
|
|
337
|
+
const atBottom = isNearBottom(containerEl, sizeCache.value, props.reachEdgeThreshold);
|
|
338
|
+
|
|
339
|
+
if (atTop) {
|
|
340
|
+
if (announcedEdge !== 'top') {
|
|
341
|
+
announcedEdge = 'top';
|
|
342
|
+
emits('reached-top');
|
|
343
|
+
}
|
|
344
|
+
} else if (atBottom) {
|
|
345
|
+
if (announcedEdge !== 'bottom') {
|
|
346
|
+
announcedEdge = 'bottom';
|
|
347
|
+
emits('reached-bottom');
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
announcedEdge = null;
|
|
351
|
+
}
|
|
119
352
|
}
|
|
120
353
|
|
|
121
|
-
function setItemRef(el: any, id: string
|
|
354
|
+
function setItemRef(el: any, id: string) {
|
|
122
355
|
if (el) {
|
|
123
|
-
const element = el.$el || el;
|
|
356
|
+
const element = (el.$el || el) as HTMLElement;
|
|
357
|
+
const prev = itemRefs.get(id);
|
|
358
|
+
if (prev && prev !== element) {
|
|
359
|
+
listResizeObserver?.unobserve(prev);
|
|
360
|
+
}
|
|
124
361
|
itemRefs.set(id, element);
|
|
125
362
|
listResizeObserver?.observe(element);
|
|
126
363
|
} else {
|
|
364
|
+
const prev = itemRefs.get(id);
|
|
365
|
+
if (prev) {
|
|
366
|
+
listResizeObserver?.unobserve(prev);
|
|
367
|
+
}
|
|
127
368
|
itemRefs.delete(id);
|
|
128
369
|
}
|
|
129
370
|
}
|
|
130
371
|
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
372
|
+
function scrollToBottom() {
|
|
373
|
+
scrollToBottomInternal();
|
|
374
|
+
scrollbarComponentRef.value?.updateMetrics();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function topForAlign(row: MeasuredSize, align: 'start' | 'end' | 'center', viewport: number): number {
|
|
378
|
+
if (align === 'end') {
|
|
379
|
+
return row.top + row.height - viewport;
|
|
380
|
+
}
|
|
381
|
+
if (align === 'center') {
|
|
382
|
+
return row.top + row.height / 2 - viewport / 2;
|
|
383
|
+
}
|
|
384
|
+
return row.top;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Items that have never been rendered are only estimated at minChildHeight,
|
|
389
|
+
* so the target's top is off by however much its predecessors are mistaken -
|
|
390
|
+
* the further the target, the bigger the miss. Scrolling there brings those
|
|
391
|
+
* items into view, they get measured, and the target moves. So aim again
|
|
392
|
+
* until it stops moving.
|
|
393
|
+
*/
|
|
394
|
+
function scrollToItem(id: string, align: 'start' | 'end' | 'center' = 'start', attemptsLeft = 5) {
|
|
395
|
+
const index = idToIndex.value.get(id);
|
|
396
|
+
if (index === undefined) {
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const row = sizeCache.value[index];
|
|
401
|
+
if (!row) {
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const containerEl = getContainerEl();
|
|
406
|
+
const viewport = containerEl?.clientHeight ?? wrapElementHeight.value;
|
|
407
|
+
|
|
408
|
+
applyScrollTop(containerEl, topForAlign(row, align, viewport));
|
|
409
|
+
scrollbarComponentRef.value?.updateMetrics();
|
|
410
|
+
|
|
411
|
+
if (attemptsLeft <= 0) {
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const topBefore = row.top;
|
|
416
|
+
requestAnimationFrame(() => {
|
|
417
|
+
const nextIndex = idToIndex.value.get(id);
|
|
418
|
+
const nextRow = nextIndex === undefined ? undefined : sizeCache.value[nextIndex];
|
|
419
|
+
if (nextRow && nextRow.top !== topBefore) {
|
|
420
|
+
scrollToItem(id, align, attemptsLeft - 1);
|
|
421
|
+
}
|
|
137
422
|
});
|
|
138
|
-
cacheVersion.value++;
|
|
139
423
|
}
|
|
140
424
|
|
|
425
|
+
defineExpose<Ui3nVirtualScrollExpose>({
|
|
426
|
+
scrollToBottom,
|
|
427
|
+
scrollToItem: (id, align) => scrollToItem(id, align),
|
|
428
|
+
getScrollTop: () => scrollTop.value,
|
|
429
|
+
isStuckToBottom: () => isNearBottom(getContainerEl(), sizeCache.value),
|
|
430
|
+
});
|
|
431
|
+
|
|
141
432
|
onMounted(() => {
|
|
142
|
-
const containerEl =
|
|
433
|
+
const containerEl = getContainerEl();
|
|
143
434
|
|
|
144
435
|
if (containerEl) {
|
|
145
436
|
viewportHeight.value = containerEl.clientHeight;
|
|
146
437
|
containerResizeObserver = new ResizeObserver(entries => {
|
|
147
438
|
for (const entry of entries) {
|
|
148
|
-
viewportHeight.value = entry.contentRect.height || entry.target.clientHeight;
|
|
439
|
+
viewportHeight.value = entry.contentRect.height || (entry.target as HTMLElement).clientHeight;
|
|
149
440
|
}
|
|
150
441
|
});
|
|
151
442
|
containerResizeObserver.observe(containerEl);
|
|
@@ -156,8 +447,11 @@
|
|
|
156
447
|
let lowestChangedIndex = Infinity;
|
|
157
448
|
let totalDeltaAboveKey = 0;
|
|
158
449
|
|
|
159
|
-
const updatedCache =
|
|
450
|
+
const updatedCache = sizeCache.value.slice();
|
|
451
|
+
const indexMap = idToIndex.value;
|
|
160
452
|
const currentScrollTop = scrollTop.value;
|
|
453
|
+
const el = getContainerEl();
|
|
454
|
+
const stickAfterMeasure = props.stickToBottom && isNearBottom(el, sizeCache.value);
|
|
161
455
|
|
|
162
456
|
for (const entry of entries) {
|
|
163
457
|
const sid = entry.target.getAttribute('data-sid');
|
|
@@ -165,49 +459,64 @@
|
|
|
165
459
|
continue;
|
|
166
460
|
}
|
|
167
461
|
|
|
168
|
-
const index =
|
|
169
|
-
if (index ===
|
|
462
|
+
const index = indexMap.get(sid);
|
|
463
|
+
if (index === undefined) {
|
|
170
464
|
continue;
|
|
171
465
|
}
|
|
172
466
|
|
|
173
467
|
const realHeight = entry.contentRect.height || entry.target.getBoundingClientRect().height;
|
|
174
468
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
469
|
+
const prev = updatedCache[index];
|
|
470
|
+
if (!prev || prev.height === realHeight) {
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
178
473
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
474
|
+
const oldHeight = prev.height;
|
|
475
|
+
updatedCache[index] = { ...prev, height: realHeight };
|
|
476
|
+
heightById.set(sid, realHeight);
|
|
477
|
+
|
|
478
|
+
// Only items lying entirely above the viewport push the content down
|
|
479
|
+
// by their full delta; one straddling the top edge contributes just
|
|
480
|
+
// the part of it that is out of sight, so compensating in full there
|
|
481
|
+
// would shove the visible content instead of holding it still
|
|
482
|
+
if (prev.top + prev.height <= currentScrollTop) {
|
|
483
|
+
totalDeltaAboveKey += realHeight - oldHeight;
|
|
484
|
+
} else if (prev.top < currentScrollTop) {
|
|
485
|
+
totalDeltaAboveKey += Math.min(realHeight, currentScrollTop - prev.top) - Math.min(oldHeight, currentScrollTop - prev.top);
|
|
486
|
+
}
|
|
182
487
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
488
|
+
if (index < lowestChangedIndex) {
|
|
489
|
+
lowestChangedIndex = index;
|
|
186
490
|
}
|
|
187
491
|
}
|
|
188
492
|
|
|
189
|
-
if (lowestChangedIndex
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
for (let i = lowestChangedIndex; i < updatedCache.length; i++) {
|
|
193
|
-
updatedCache[i].top = currentTop;
|
|
194
|
-
currentTop += updatedCache[i].height;
|
|
195
|
-
}
|
|
493
|
+
if (lowestChangedIndex === Infinity) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
196
496
|
|
|
197
|
-
|
|
198
|
-
|
|
497
|
+
let currentTop = updatedCache[lowestChangedIndex].top;
|
|
498
|
+
for (let i = lowestChangedIndex; i < updatedCache.length; i++) {
|
|
499
|
+
updatedCache[i] = { ...updatedCache[i], top: currentTop };
|
|
500
|
+
currentTop += updatedCache[i].height;
|
|
501
|
+
}
|
|
199
502
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
containerEl.scrollTop = currentScrollTop + totalDeltaAboveKey;
|
|
203
|
-
scrollTop.value = containerEl.scrollTop;
|
|
204
|
-
isAdjustingScroll = false;
|
|
205
|
-
}
|
|
503
|
+
sizeCache.value = updatedCache;
|
|
504
|
+
cacheVersion.value++;
|
|
206
505
|
|
|
207
|
-
|
|
506
|
+
if (stickAfterMeasure) {
|
|
507
|
+
scrollToBottomInternal(el, updatedCache);
|
|
508
|
+
} else if (el && totalDeltaAboveKey !== 0) {
|
|
509
|
+
applyScrollTop(el, currentScrollTop + totalDeltaAboveKey);
|
|
208
510
|
}
|
|
511
|
+
|
|
512
|
+
scrollbarComponentRef.value?.updateMetrics();
|
|
209
513
|
});
|
|
210
514
|
});
|
|
515
|
+
|
|
516
|
+
// Observe items that may already be mounted before observer creation
|
|
517
|
+
for (const element of itemRefs.values()) {
|
|
518
|
+
listResizeObserver.observe(element);
|
|
519
|
+
}
|
|
211
520
|
});
|
|
212
521
|
|
|
213
522
|
onBeforeUnmount(() => {
|
|
@@ -215,7 +524,7 @@
|
|
|
215
524
|
containerResizeObserver?.disconnect();
|
|
216
525
|
});
|
|
217
526
|
|
|
218
|
-
watch(() => props.items,
|
|
527
|
+
watch(() => props.items, syncItemsCache, { immediate: true });
|
|
219
528
|
</script>
|
|
220
529
|
|
|
221
530
|
<template>
|
|
@@ -235,9 +544,9 @@
|
|
|
235
544
|
>
|
|
236
545
|
<div
|
|
237
546
|
v-for="(item, index) in visibleChildren"
|
|
238
|
-
:key="item.id
|
|
239
|
-
:ref="el => setItemRef(el, item.id
|
|
240
|
-
v-bind="{ 'data-sid': item.id
|
|
547
|
+
:key="item.id"
|
|
548
|
+
:ref="el => setItemRef(el, item.id)"
|
|
549
|
+
v-bind="{ 'data-sid': item.id }"
|
|
241
550
|
:class="$style.item"
|
|
242
551
|
:style="{ minHeight: `${props.minChildHeight}px` }"
|
|
243
552
|
>
|