@wyxos/vibe 1.4.1 → 1.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/lib/index.js +524 -433
- package/lib/vibe.css +1 -1
- package/package.json +3 -2
- package/src/App.vue +3 -1
- package/src/Masonry.vue +219 -32
- package/src/calculateLayout.ts +168 -151
- package/src/masonryUtils.ts +18 -5
- package/src/useMasonryScroll.ts +17 -105
- package/src/useMasonryTransitions.ts +14 -7
package/lib/vibe.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.masonry-container[data-v-
|
|
1
|
+
.masonry-container[data-v-b855ca99]{overflow-anchor:none}.masonry-item[data-v-b855ca99]{will-change:transform,opacity;contain:layout paint;transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),opacity var(--masonry-leave-duration, .16s) ease-out var(--masonry-opacity-delay, 0ms);backface-visibility:hidden}.masonry-move[data-v-b855ca99]{transition:transform var(--masonry-duration, .45s) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1))}@media (prefers-reduced-motion: reduce){.masonry-container:not(.force-motion) .masonry-item[data-v-b855ca99],.masonry-container:not(.force-motion) .masonry-move[data-v-b855ca99]{transition-duration:1ms!important}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyxos/vibe",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"main": "lib/index.js",
|
|
5
5
|
"module": "lib/index.js",
|
|
6
6
|
"types": "lib/index.d.ts",
|
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"@tailwindcss/vite": "^4.0.15",
|
|
45
|
+
"@types/lodash-es": "^4.17.12",
|
|
46
|
+
"@types/node": "^24.5.2",
|
|
45
47
|
"@vitejs/plugin-vue": "^5.2.1",
|
|
46
48
|
"@vue/test-utils": "^2.4.6",
|
|
47
|
-
"@types/node": "^24.5.2",
|
|
48
49
|
"chalk": "^5.3.0",
|
|
49
50
|
"inquirer": "^10.1.8",
|
|
50
51
|
"jsdom": "^26.0.0",
|
package/src/App.vue
CHANGED
|
@@ -8,6 +8,8 @@ const items = ref<MasonryItem[]>([]);
|
|
|
8
8
|
|
|
9
9
|
const masonry = ref<InstanceType<typeof Masonry> | null>(null);
|
|
10
10
|
|
|
11
|
+
const layout = { sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 10 }, header: 40, footer: 40 };
|
|
12
|
+
|
|
11
13
|
const getPage = async (page: number): Promise<GetPageResult> => {
|
|
12
14
|
return new Promise((resolve) => {
|
|
13
15
|
setTimeout(() => {
|
|
@@ -49,7 +51,7 @@ const getPage = async (page: number): Promise<GetPageResult> => {
|
|
|
49
51
|
<p>Showing: <span class="bg-blue-500 text-white p-2 rounded">{{ items.length }}</span></p>
|
|
50
52
|
</div>
|
|
51
53
|
</header>
|
|
52
|
-
<masonry class="bg-blue-500 " v-model:items="items" :get-next-page="getPage" :load-at-page="1" ref="masonry">
|
|
54
|
+
<masonry class="bg-blue-500 " v-model:items="items" :get-next-page="getPage" :load-at-page="1" :layout="layout" ref="masonry">
|
|
53
55
|
<template #item="{item, remove}">
|
|
54
56
|
<img :src="item.src" class="w-full"/>
|
|
55
57
|
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="remove(item)">
|
package/src/Masonry.vue
CHANGED
|
@@ -36,10 +36,6 @@ const props = defineProps({
|
|
|
36
36
|
type: Boolean,
|
|
37
37
|
default: false
|
|
38
38
|
},
|
|
39
|
-
maxItems: {
|
|
40
|
-
type: Number,
|
|
41
|
-
default: 100
|
|
42
|
-
},
|
|
43
39
|
pageSize: {
|
|
44
40
|
type: Number,
|
|
45
41
|
default: 40
|
|
@@ -87,6 +83,14 @@ const props = defineProps({
|
|
|
87
83
|
forceMotion: {
|
|
88
84
|
type: Boolean,
|
|
89
85
|
default: false
|
|
86
|
+
},
|
|
87
|
+
virtualBufferPx: {
|
|
88
|
+
type: Number,
|
|
89
|
+
default: 600
|
|
90
|
+
},
|
|
91
|
+
loadThresholdPx: {
|
|
92
|
+
type: Number,
|
|
93
|
+
default: 200
|
|
90
94
|
}
|
|
91
95
|
})
|
|
92
96
|
|
|
@@ -117,7 +121,8 @@ const emits = defineEmits([
|
|
|
117
121
|
'backfill:stop',
|
|
118
122
|
'retry:start',
|
|
119
123
|
'retry:tick',
|
|
120
|
-
'retry:stop'
|
|
124
|
+
'retry:stop',
|
|
125
|
+
'remove-all:complete'
|
|
121
126
|
])
|
|
122
127
|
|
|
123
128
|
const masonry = computed<any>({
|
|
@@ -128,25 +133,72 @@ const masonry = computed<any>({
|
|
|
128
133
|
const columns = ref<number>(7)
|
|
129
134
|
const container = ref<HTMLElement | null>(null)
|
|
130
135
|
const paginationHistory = ref<any[]>([])
|
|
131
|
-
const nextPage = ref<number | null>(null)
|
|
132
136
|
const isLoading = ref<boolean>(false)
|
|
133
137
|
const containerHeight = ref<number>(0)
|
|
134
138
|
|
|
139
|
+
// Diagnostics: track items missing width/height to help developers
|
|
140
|
+
const invalidDimensionIds = ref<Set<number | string>>(new Set())
|
|
141
|
+
function isPositiveNumber(value: unknown): boolean {
|
|
142
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
143
|
+
}
|
|
144
|
+
function checkItemDimensions(items: any[], context: string) {
|
|
145
|
+
try {
|
|
146
|
+
if (!Array.isArray(items) || items.length === 0) return
|
|
147
|
+
const missing = items.filter((item) => !isPositiveNumber(item?.width) || !isPositiveNumber(item?.height))
|
|
148
|
+
if (missing.length === 0) return
|
|
149
|
+
|
|
150
|
+
const newIds: Array<number | string> = []
|
|
151
|
+
for (const item of missing) {
|
|
152
|
+
const id = (item?.id as number | string | undefined) ?? `idx:${items.indexOf(item)}`
|
|
153
|
+
if (!invalidDimensionIds.value.has(id)) {
|
|
154
|
+
invalidDimensionIds.value.add(id)
|
|
155
|
+
newIds.push(id)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (newIds.length > 0) {
|
|
159
|
+
const sample = newIds.slice(0, 10)
|
|
160
|
+
// eslint-disable-next-line no-console
|
|
161
|
+
console.warn(
|
|
162
|
+
'[Masonry] Items missing width/height detected:',
|
|
163
|
+
{
|
|
164
|
+
context,
|
|
165
|
+
count: newIds.length,
|
|
166
|
+
sampleIds: sample,
|
|
167
|
+
hint: 'Ensure each item has positive width and height. Consider providing fallbacks (e.g., 512x512) at the data layer.'
|
|
168
|
+
}
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// best-effort diagnostics only
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Virtualization viewport state
|
|
177
|
+
const viewportTop = ref(0)
|
|
178
|
+
const viewportHeight = ref(0)
|
|
179
|
+
const VIRTUAL_BUFFER_PX = props.virtualBufferPx
|
|
180
|
+
|
|
181
|
+
// Gate transitions during virtualization-only DOM churn
|
|
182
|
+
const virtualizing = ref(false)
|
|
183
|
+
|
|
135
184
|
// Scroll progress tracking
|
|
136
185
|
const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
|
|
137
186
|
distanceToTrigger: 0,
|
|
138
187
|
isNearTrigger: false
|
|
139
188
|
})
|
|
140
189
|
|
|
141
|
-
const updateScrollProgress = () => {
|
|
190
|
+
const updateScrollProgress = (precomputedHeights?: number[]) => {
|
|
142
191
|
if (!container.value) return
|
|
143
192
|
|
|
144
193
|
const {scrollTop, clientHeight} = container.value
|
|
145
194
|
const visibleBottom = scrollTop + clientHeight
|
|
146
195
|
|
|
147
|
-
const columnHeights = calculateColumnHeights(masonry.value as any, columns.value)
|
|
148
|
-
const
|
|
149
|
-
const
|
|
196
|
+
const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
|
|
197
|
+
const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
|
|
198
|
+
const threshold = typeof props.loadThresholdPx === 'number' ? props.loadThresholdPx : 200
|
|
199
|
+
const triggerPoint = threshold >= 0
|
|
200
|
+
? Math.max(0, tallest - threshold)
|
|
201
|
+
: Math.max(0, tallest + threshold)
|
|
150
202
|
|
|
151
203
|
const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
|
|
152
204
|
const isNearTrigger = distanceToTrigger <= 100
|
|
@@ -158,7 +210,65 @@ const updateScrollProgress = () => {
|
|
|
158
210
|
}
|
|
159
211
|
|
|
160
212
|
// Setup composables
|
|
161
|
-
const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry)
|
|
213
|
+
const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
|
|
214
|
+
|
|
215
|
+
// Transition wrappers that skip animation during virtualization
|
|
216
|
+
function enter(el: HTMLElement, done: () => void) {
|
|
217
|
+
if (virtualizing.value) {
|
|
218
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
219
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
220
|
+
el.style.transition = 'none'
|
|
221
|
+
el.style.opacity = '1'
|
|
222
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
223
|
+
el.style.removeProperty('--masonry-opacity-delay')
|
|
224
|
+
requestAnimationFrame(() => {
|
|
225
|
+
el.style.transition = ''
|
|
226
|
+
done()
|
|
227
|
+
})
|
|
228
|
+
} else {
|
|
229
|
+
onEnter(el, done)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function beforeEnter(el: HTMLElement) {
|
|
233
|
+
if (virtualizing.value) {
|
|
234
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
235
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
236
|
+
el.style.transition = 'none'
|
|
237
|
+
el.style.opacity = '1'
|
|
238
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
239
|
+
el.style.removeProperty('--masonry-opacity-delay')
|
|
240
|
+
} else {
|
|
241
|
+
onBeforeEnter(el)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function beforeLeave(el: HTMLElement) {
|
|
245
|
+
if (virtualizing.value) {
|
|
246
|
+
// no-op; removal will be immediate in leave
|
|
247
|
+
} else {
|
|
248
|
+
onBeforeLeave(el)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function leave(el: HTMLElement, done: () => void) {
|
|
252
|
+
if (virtualizing.value) {
|
|
253
|
+
// Skip animation during virtualization
|
|
254
|
+
done()
|
|
255
|
+
} else {
|
|
256
|
+
onLeave(el, done)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Visible window of items (virtualization)
|
|
261
|
+
const visibleMasonry = computed(() => {
|
|
262
|
+
const top = viewportTop.value - VIRTUAL_BUFFER_PX
|
|
263
|
+
const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
|
|
264
|
+
const items = masonry.value as any[]
|
|
265
|
+
if (!items || items.length === 0) return [] as any[]
|
|
266
|
+
return items.filter((it: any) => {
|
|
267
|
+
const itemTop = it.top
|
|
268
|
+
const itemBottom = it.top + it.columnHeight
|
|
269
|
+
return itemBottom >= top && itemTop <= bottom
|
|
270
|
+
})
|
|
271
|
+
})
|
|
162
272
|
|
|
163
273
|
const {handleScroll} = useMasonryScroll({
|
|
164
274
|
container,
|
|
@@ -166,14 +276,13 @@ const {handleScroll} = useMasonryScroll({
|
|
|
166
276
|
columns,
|
|
167
277
|
containerHeight,
|
|
168
278
|
isLoading,
|
|
169
|
-
maxItems: props.maxItems,
|
|
170
279
|
pageSize: props.pageSize,
|
|
171
280
|
refreshLayout,
|
|
172
281
|
setItemsRaw: (items: any[]) => {
|
|
173
282
|
masonry.value = items
|
|
174
283
|
},
|
|
175
284
|
loadNext,
|
|
176
|
-
|
|
285
|
+
loadThresholdPx: props.loadThresholdPx
|
|
177
286
|
})
|
|
178
287
|
|
|
179
288
|
defineExpose({
|
|
@@ -182,11 +291,15 @@ defineExpose({
|
|
|
182
291
|
containerHeight,
|
|
183
292
|
remove,
|
|
184
293
|
removeMany,
|
|
294
|
+
removeAll,
|
|
185
295
|
loadNext,
|
|
186
296
|
loadPage,
|
|
187
297
|
reset,
|
|
188
298
|
init,
|
|
189
|
-
paginationHistory
|
|
299
|
+
paginationHistory,
|
|
300
|
+
cancelLoad,
|
|
301
|
+
scrollToTop,
|
|
302
|
+
totalItems: computed(() => (masonry.value as any[]).length)
|
|
190
303
|
})
|
|
191
304
|
|
|
192
305
|
function calculateHeight(content: any[]) {
|
|
@@ -201,6 +314,8 @@ function calculateHeight(content: any[]) {
|
|
|
201
314
|
|
|
202
315
|
function refreshLayout(items: any[]) {
|
|
203
316
|
if (!container.value) return
|
|
317
|
+
// Developer diagnostics: warn when dimensions are invalid
|
|
318
|
+
checkItemDimensions(items as any[], 'refreshLayout')
|
|
204
319
|
const content = calculateLayout(items as any, container.value as HTMLElement, columns.value, layout.value as any)
|
|
205
320
|
calculateHeight(content as any)
|
|
206
321
|
masonry.value = content
|
|
@@ -212,6 +327,12 @@ function waitWithProgress(totalMs: number, onTick: (remaining: number, total: nu
|
|
|
212
327
|
const start = Date.now()
|
|
213
328
|
onTick(total, total)
|
|
214
329
|
const id = setInterval(() => {
|
|
330
|
+
// Check for cancellation
|
|
331
|
+
if (cancelRequested.value) {
|
|
332
|
+
clearInterval(id)
|
|
333
|
+
resolve()
|
|
334
|
+
return
|
|
335
|
+
}
|
|
215
336
|
const elapsed = Date.now() - start
|
|
216
337
|
const remaining = Math.max(0, total - elapsed)
|
|
217
338
|
onTick(remaining, total)
|
|
@@ -262,11 +383,14 @@ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
|
|
|
262
383
|
}
|
|
263
384
|
|
|
264
385
|
async function loadPage(page: number) {
|
|
265
|
-
if (isLoading.value) return
|
|
386
|
+
if (isLoading.value || cancelRequested.value) return
|
|
266
387
|
isLoading.value = true
|
|
388
|
+
cancelRequested.value = false
|
|
267
389
|
try {
|
|
268
390
|
const baseline = (masonry.value as any[]).length
|
|
391
|
+
if (cancelRequested.value) return
|
|
269
392
|
const response = await getContent(page)
|
|
393
|
+
if (cancelRequested.value) return
|
|
270
394
|
paginationHistory.value.push(response.nextPage)
|
|
271
395
|
await maybeBackfillToTarget(baseline)
|
|
272
396
|
return response
|
|
@@ -279,12 +403,15 @@ async function loadPage(page: number) {
|
|
|
279
403
|
}
|
|
280
404
|
|
|
281
405
|
async function loadNext() {
|
|
282
|
-
if (isLoading.value) return
|
|
406
|
+
if (isLoading.value || cancelRequested.value) return
|
|
283
407
|
isLoading.value = true
|
|
408
|
+
cancelRequested.value = false
|
|
284
409
|
try {
|
|
285
410
|
const baseline = (masonry.value as any[]).length
|
|
411
|
+
if (cancelRequested.value) return
|
|
286
412
|
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
287
413
|
const response = await getContent(currentPage)
|
|
414
|
+
if (cancelRequested.value) return
|
|
288
415
|
paginationHistory.value.push(response.nextPage)
|
|
289
416
|
await maybeBackfillToTarget(baseline)
|
|
290
417
|
return response
|
|
@@ -300,9 +427,9 @@ async function remove(item: any) {
|
|
|
300
427
|
const next = (masonry.value as any[]).filter(i => i.id !== item.id)
|
|
301
428
|
masonry.value = next
|
|
302
429
|
await nextTick()
|
|
303
|
-
//
|
|
304
|
-
void
|
|
305
|
-
// Start FLIP on next frame
|
|
430
|
+
// Commit DOM updates without forcing sync reflow
|
|
431
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
432
|
+
// Start FLIP on next frame
|
|
306
433
|
requestAnimationFrame(() => {
|
|
307
434
|
refreshLayout(next)
|
|
308
435
|
})
|
|
@@ -314,24 +441,56 @@ async function removeMany(items: any[]) {
|
|
|
314
441
|
const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
|
|
315
442
|
masonry.value = next
|
|
316
443
|
await nextTick()
|
|
317
|
-
//
|
|
318
|
-
void
|
|
319
|
-
// Start FLIP on next frame
|
|
444
|
+
// Commit DOM updates without forcing sync reflow
|
|
445
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
446
|
+
// Start FLIP on next frame
|
|
320
447
|
requestAnimationFrame(() => {
|
|
321
448
|
refreshLayout(next)
|
|
322
449
|
})
|
|
323
450
|
}
|
|
324
451
|
|
|
452
|
+
function scrollToTop(options?: ScrollToOptions) {
|
|
453
|
+
if (container.value) {
|
|
454
|
+
container.value.scrollTo({
|
|
455
|
+
top: 0,
|
|
456
|
+
behavior: options?.behavior ?? 'smooth',
|
|
457
|
+
...options
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function removeAll() {
|
|
463
|
+
// Scroll to top first for better UX
|
|
464
|
+
scrollToTop({ behavior: 'smooth' })
|
|
465
|
+
|
|
466
|
+
// Clear all items
|
|
467
|
+
masonry.value = []
|
|
468
|
+
|
|
469
|
+
// Recalculate height to 0
|
|
470
|
+
containerHeight.value = 0
|
|
471
|
+
|
|
472
|
+
await nextTick()
|
|
473
|
+
|
|
474
|
+
// Emit completion event
|
|
475
|
+
emits('remove-all:complete')
|
|
476
|
+
}
|
|
477
|
+
|
|
325
478
|
function onResize() {
|
|
326
479
|
columns.value = getColumnCount(layout.value as any)
|
|
327
480
|
refreshLayout(masonry.value as any)
|
|
481
|
+
if (container.value) {
|
|
482
|
+
viewportTop.value = container.value.scrollTop
|
|
483
|
+
viewportHeight.value = container.value.clientHeight
|
|
484
|
+
}
|
|
328
485
|
}
|
|
329
486
|
|
|
330
487
|
let backfillActive = false
|
|
488
|
+
const cancelRequested = ref(false)
|
|
331
489
|
|
|
332
490
|
async function maybeBackfillToTarget(baselineCount: number) {
|
|
333
491
|
if (!props.backfillEnabled) return
|
|
334
492
|
if (backfillActive) return
|
|
493
|
+
if (cancelRequested.value) return
|
|
335
494
|
|
|
336
495
|
const targetCount = (baselineCount || 0) + (props.pageSize || 0)
|
|
337
496
|
if (!props.pageSize || props.pageSize <= 0) return
|
|
@@ -349,7 +508,8 @@ async function maybeBackfillToTarget(baselineCount: number) {
|
|
|
349
508
|
while (
|
|
350
509
|
(masonry.value as any[]).length < targetCount &&
|
|
351
510
|
calls < props.backfillMaxCalls &&
|
|
352
|
-
paginationHistory.value[paginationHistory.value.length - 1] != null
|
|
511
|
+
paginationHistory.value[paginationHistory.value.length - 1] != null &&
|
|
512
|
+
!cancelRequested.value
|
|
353
513
|
) {
|
|
354
514
|
await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
|
|
355
515
|
emits('backfill:tick', {
|
|
@@ -361,10 +521,13 @@ async function maybeBackfillToTarget(baselineCount: number) {
|
|
|
361
521
|
})
|
|
362
522
|
})
|
|
363
523
|
|
|
524
|
+
if (cancelRequested.value) break
|
|
525
|
+
|
|
364
526
|
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
365
527
|
try {
|
|
366
528
|
isLoading.value = true
|
|
367
529
|
const response = await getContent(currentPage)
|
|
530
|
+
if (cancelRequested.value) break
|
|
368
531
|
paginationHistory.value.push(response.nextPage)
|
|
369
532
|
} finally {
|
|
370
533
|
isLoading.value = false
|
|
@@ -379,7 +542,14 @@ async function maybeBackfillToTarget(baselineCount: number) {
|
|
|
379
542
|
}
|
|
380
543
|
}
|
|
381
544
|
|
|
545
|
+
function cancelLoad() {
|
|
546
|
+
cancelRequested.value = true
|
|
547
|
+
isLoading.value = false
|
|
548
|
+
backfillActive = false
|
|
549
|
+
}
|
|
550
|
+
|
|
382
551
|
function reset() {
|
|
552
|
+
cancelLoad()
|
|
383
553
|
if (container.value) {
|
|
384
554
|
container.value.scrollTo({
|
|
385
555
|
top: 0,
|
|
@@ -397,9 +567,20 @@ function reset() {
|
|
|
397
567
|
}
|
|
398
568
|
}
|
|
399
569
|
|
|
400
|
-
const debouncedScrollHandler = debounce(() => {
|
|
401
|
-
|
|
402
|
-
|
|
570
|
+
const debouncedScrollHandler = debounce(async () => {
|
|
571
|
+
if (container.value) {
|
|
572
|
+
viewportTop.value = container.value.scrollTop
|
|
573
|
+
viewportHeight.value = container.value.clientHeight
|
|
574
|
+
}
|
|
575
|
+
// Gate transitions for virtualization-only DOM changes
|
|
576
|
+
virtualizing.value = true
|
|
577
|
+
await nextTick()
|
|
578
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
579
|
+
virtualizing.value = false
|
|
580
|
+
|
|
581
|
+
const heights = calculateColumnHeights(masonry.value as any, columns.value)
|
|
582
|
+
handleScroll(heights as any)
|
|
583
|
+
updateScrollProgress(heights)
|
|
403
584
|
}, 200)
|
|
404
585
|
|
|
405
586
|
const debouncedResizeHandler = debounce(onResize, 200)
|
|
@@ -407,6 +588,8 @@ const debouncedResizeHandler = debounce(onResize, 200)
|
|
|
407
588
|
function init(items: any[], page: any, next: any) {
|
|
408
589
|
paginationHistory.value = [page]
|
|
409
590
|
paginationHistory.value.push(next)
|
|
591
|
+
// Diagnostics: check incoming initial items
|
|
592
|
+
checkItemDimensions(items as any[], 'init')
|
|
410
593
|
refreshLayout([...(masonry.value as any[]), ...items])
|
|
411
594
|
updateScrollProgress()
|
|
412
595
|
}
|
|
@@ -414,6 +597,10 @@ function init(items: any[], page: any, next: any) {
|
|
|
414
597
|
onMounted(async () => {
|
|
415
598
|
try {
|
|
416
599
|
columns.value = getColumnCount(layout.value as any)
|
|
600
|
+
if (container.value) {
|
|
601
|
+
viewportTop.value = container.value.scrollTop
|
|
602
|
+
viewportHeight.value = container.value.clientHeight
|
|
603
|
+
}
|
|
417
604
|
|
|
418
605
|
const initialPage = props.loadAtPage as any
|
|
419
606
|
paginationHistory.value = [initialPage]
|
|
@@ -429,7 +616,7 @@ onMounted(async () => {
|
|
|
429
616
|
isLoading.value = false
|
|
430
617
|
}
|
|
431
618
|
|
|
432
|
-
container.value?.addEventListener('scroll', debouncedScrollHandler)
|
|
619
|
+
container.value?.addEventListener('scroll', debouncedScrollHandler, { passive: true })
|
|
433
620
|
window.addEventListener('resize', debouncedResizeHandler)
|
|
434
621
|
})
|
|
435
622
|
|
|
@@ -443,14 +630,14 @@ onUnmounted(() => {
|
|
|
443
630
|
<div class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }" ref="container">
|
|
444
631
|
<div class="relative"
|
|
445
632
|
:style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
|
|
446
|
-
<transition-group name="masonry" :css="false" @enter="
|
|
447
|
-
@leave="
|
|
448
|
-
@before-leave="
|
|
449
|
-
<div v-for="(item, i) in
|
|
633
|
+
<transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
|
|
634
|
+
@leave="leave"
|
|
635
|
+
@before-leave="beforeLeave">
|
|
636
|
+
<div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
|
|
450
637
|
class="absolute masonry-item"
|
|
451
638
|
v-bind="getItemAttributes(item, i)">
|
|
452
639
|
<slot name="item" v-bind="{item, remove}">
|
|
453
|
-
<img :src="item.src" class="w-full"/>
|
|
640
|
+
<img :src="item.src" class="w-full" loading="lazy" decoding="async"/>
|
|
454
641
|
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
|
|
455
642
|
@click="remove(item)">
|
|
456
643
|
<i class="fas fa-trash"></i>
|