@wyxos/vibe 1.6.17 → 1.6.18

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/src/Masonry.vue CHANGED
@@ -1,1334 +1,1439 @@
1
- <script setup lang="ts">
2
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
- import calculateLayout from "./calculateLayout";
4
- import { debounce } from 'lodash-es'
5
- import {
6
- getColumnCount,
7
- getBreakpointName,
8
- calculateContainerHeight,
9
- getItemAttributes,
10
- calculateColumnHeights
11
- } from './masonryUtils'
12
- import { useMasonryTransitions } from './useMasonryTransitions'
13
- import { useMasonryScroll } from './useMasonryScroll'
14
- import MasonryItem from './components/MasonryItem.vue'
15
-
16
- const props = defineProps({
17
- getNextPage: {
18
- type: Function,
19
- default: () => {}
20
- },
21
- loadAtPage: {
22
- type: [Number, String],
23
- default: null
24
- },
25
- items: {
26
- type: Array,
27
- default: () => []
28
- },
29
- layout: {
30
- type: Object
31
- },
32
- paginationType: {
33
- type: String,
34
- default: 'page', // or 'cursor'
35
- validator: (v: string) => ['page', 'cursor'].includes(v)
36
- },
37
- skipInitialLoad: {
38
- type: Boolean,
39
- default: false
40
- },
41
- pageSize: {
42
- type: Number,
43
- default: 40
44
- },
45
- // Backfill configuration
46
- backfillEnabled: {
47
- type: Boolean,
48
- default: true
49
- },
50
- backfillDelayMs: {
51
- type: Number,
52
- default: 2000
53
- },
54
- backfillMaxCalls: {
55
- type: Number,
56
- default: 10
57
- },
58
- // Retry configuration
59
- retryMaxAttempts: {
60
- type: Number,
61
- default: 3
62
- },
63
- retryInitialDelayMs: {
64
- type: Number,
65
- default: 2000
66
- },
67
- retryBackoffStepMs: {
68
- type: Number,
69
- default: 2000
70
- },
71
- transitionDurationMs: {
72
- type: Number,
73
- default: 450
74
- },
75
- // Shorter, snappier duration specifically for item removal (leave)
76
- leaveDurationMs: {
77
- type: Number,
78
- default: 160
79
- },
80
- transitionEasing: {
81
- type: String,
82
- default: 'cubic-bezier(.22,.61,.36,1)'
83
- },
84
- // Force motion even when user has reduced-motion enabled
85
- forceMotion: {
86
- type: Boolean,
87
- default: false
88
- },
89
- virtualBufferPx: {
90
- type: Number,
91
- default: 600
92
- },
93
- loadThresholdPx: {
94
- type: Number,
95
- default: 200
96
- },
97
- autoRefreshOnEmpty: {
98
- type: Boolean,
99
- default: false
100
- },
101
- // Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
102
- layoutMode: {
103
- type: String,
104
- default: 'auto',
105
- validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
106
- },
107
- // Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
108
- mobileBreakpoint: {
109
- type: [Number, String],
110
- default: 768 // 'md' breakpoint
111
- },
112
- })
113
-
114
- const defaultLayout = {
115
- sizes: {base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6},
116
- gutterX: 10,
117
- gutterY: 10,
118
- header: 0,
119
- footer: 0,
120
- paddingLeft: 0,
121
- paddingRight: 0,
122
- placement: 'masonry'
123
- }
124
-
125
- const layout = computed(() => ({
126
- ...defaultLayout,
127
- ...props.layout,
128
- sizes: {
129
- ...defaultLayout.sizes,
130
- ...(props.layout?.sizes || {})
131
- }
132
- }))
133
-
134
- const wrapper = ref<HTMLElement | null>(null)
135
- const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
136
- const containerHeight = ref<number>(typeof window !== 'undefined' ? window.innerHeight : 768)
137
- const fixedDimensions = ref<{ width?: number; height?: number } | null>(null)
138
- let resizeObserver: ResizeObserver | null = null
139
-
140
- // Get breakpoint value from Tailwind breakpoint name
141
- function getBreakpointValue(breakpoint: string): number {
142
- const breakpoints: Record<string, number> = {
143
- 'sm': 640,
144
- 'md': 768,
145
- 'lg': 1024,
146
- 'xl': 1280,
147
- '2xl': 1536
148
- }
149
- return breakpoints[breakpoint] || 768
150
- }
151
-
152
- // Determine if we should use swipe mode
153
- const useSwipeMode = computed(() => {
154
- if (props.layoutMode === 'masonry') return false
155
- if (props.layoutMode === 'swipe') return true
156
-
157
- // Auto mode: check container width
158
- const breakpoint = typeof props.mobileBreakpoint === 'string'
159
- ? getBreakpointValue(props.mobileBreakpoint)
160
- : props.mobileBreakpoint
161
-
162
- return containerWidth.value < breakpoint
163
- })
164
-
165
- // Get current item index for swipe mode
166
- const currentItem = computed(() => {
167
- if (!useSwipeMode.value || masonry.value.length === 0) return null
168
- const index = Math.max(0, Math.min(currentSwipeIndex.value, masonry.value.length - 1))
169
- return (masonry.value as any[])[index] || null
170
- })
171
-
172
- // Get next/previous items for preloading in swipe mode
173
- const nextItem = computed(() => {
174
- if (!useSwipeMode.value || !currentItem.value) return null
175
- const nextIndex = currentSwipeIndex.value + 1
176
- if (nextIndex >= masonry.value.length) return null
177
- return (masonry.value as any[])[nextIndex] || null
178
- })
179
-
180
- const previousItem = computed(() => {
181
- if (!useSwipeMode.value || !currentItem.value) return null
182
- const prevIndex = currentSwipeIndex.value - 1
183
- if (prevIndex < 0) return null
184
- return (masonry.value as any[])[prevIndex] || null
185
- })
186
-
187
- const emits = defineEmits([
188
- 'update:items',
189
- 'backfill:start',
190
- 'backfill:tick',
191
- 'backfill:stop',
192
- 'retry:start',
193
- 'retry:tick',
194
- 'retry:stop',
195
- 'remove-all:complete',
196
- // Re-emit item-level preload events from the default MasonryItem
197
- 'item:preload:success',
198
- 'item:preload:error',
199
- // Mouse events from MasonryItem content
200
- 'item:mouse-enter',
201
- 'item:mouse-leave'
202
- ])
203
-
204
- const masonry = computed<any>({
205
- get: () => props.items,
206
- set: (val) => emits('update:items', val)
207
- })
208
-
209
- const columns = ref<number>(7)
210
- const container = ref<HTMLElement | null>(null)
211
- const paginationHistory = ref<any[]>([])
212
- const currentPage = ref<any>(null) // Track the actual current page being displayed
213
- const isLoading = ref<boolean>(false)
214
- const masonryContentHeight = ref<number>(0)
215
-
216
- // Current breakpoint
217
- const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
218
-
219
- // Swipe mode state
220
- const currentSwipeIndex = ref<number>(0)
221
- const swipeOffset = ref<number>(0)
222
- const isDragging = ref<boolean>(false)
223
- const dragStartY = ref<number>(0)
224
- const dragStartOffset = ref<number>(0)
225
- const swipeContainer = ref<HTMLElement | null>(null)
226
-
227
- // Diagnostics: track items missing width/height to help developers
228
- const invalidDimensionIds = ref<Set<number | string>>(new Set())
229
- function isPositiveNumber(value: unknown): boolean {
230
- return typeof value === 'number' && Number.isFinite(value) && value > 0
231
- }
232
- function checkItemDimensions(items: any[], context: string) {
233
- try {
234
- if (!Array.isArray(items) || items.length === 0) return
235
- const missing = items.filter((item) => !isPositiveNumber(item?.width) || !isPositiveNumber(item?.height))
236
- if (missing.length === 0) return
237
-
238
- const newIds: Array<number | string> = []
239
- for (const item of missing) {
240
- const id = (item?.id as number | string | undefined) ?? `idx:${items.indexOf(item)}`
241
- if (!invalidDimensionIds.value.has(id)) {
242
- invalidDimensionIds.value.add(id)
243
- newIds.push(id)
244
- }
245
- }
246
- if (newIds.length > 0) {
247
- const sample = newIds.slice(0, 10)
248
- // eslint-disable-next-line no-console
249
- console.warn(
250
- '[Masonry] Items missing width/height detected:',
251
- {
252
- context,
253
- count: newIds.length,
254
- sampleIds: sample,
255
- hint: 'Ensure each item has positive width and height. Consider providing fallbacks (e.g., 512x512) at the data layer.'
256
- }
257
- )
258
- }
259
- } catch {
260
- // best-effort diagnostics only
261
- }
262
- }
263
-
264
- // Virtualization viewport state
265
- const viewportTop = ref(0)
266
- const viewportHeight = ref(0)
267
- const VIRTUAL_BUFFER_PX = props.virtualBufferPx
268
-
269
- // Gate transitions during virtualization-only DOM churn
270
- const virtualizing = ref(false)
271
-
272
- // Scroll progress tracking
273
- const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
274
- distanceToTrigger: 0,
275
- isNearTrigger: false
276
- })
277
-
278
- const updateScrollProgress = (precomputedHeights?: number[]) => {
279
- if (!container.value) return
280
-
281
- const {scrollTop, clientHeight} = container.value
282
- const visibleBottom = scrollTop + clientHeight
283
-
284
- const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
285
- const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
286
- const threshold = typeof props.loadThresholdPx === 'number' ? props.loadThresholdPx : 200
287
- const triggerPoint = threshold >= 0
288
- ? Math.max(0, tallest - threshold)
289
- : Math.max(0, tallest + threshold)
290
-
291
- const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
292
- const isNearTrigger = distanceToTrigger <= 100
293
-
294
- scrollProgress.value = {
295
- distanceToTrigger: Math.round(distanceToTrigger),
296
- isNearTrigger
297
- }
298
- }
299
-
300
- // Setup composables
301
- const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
302
-
303
- // Transition wrappers that skip animation during virtualization
304
- function enter(el: HTMLElement, done: () => void) {
305
- if (virtualizing.value) {
306
- const left = parseInt(el.dataset.left || '0', 10)
307
- const top = parseInt(el.dataset.top || '0', 10)
308
- el.style.transition = 'none'
309
- el.style.opacity = '1'
310
- el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
311
- el.style.removeProperty('--masonry-opacity-delay')
312
- requestAnimationFrame(() => {
313
- el.style.transition = ''
314
- done()
315
- })
316
- } else {
317
- onEnter(el, done)
318
- }
319
- }
320
- function beforeEnter(el: HTMLElement) {
321
- if (virtualizing.value) {
322
- const left = parseInt(el.dataset.left || '0', 10)
323
- const top = parseInt(el.dataset.top || '0', 10)
324
- el.style.transition = 'none'
325
- el.style.opacity = '1'
326
- el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
327
- el.style.removeProperty('--masonry-opacity-delay')
328
- } else {
329
- onBeforeEnter(el)
330
- }
331
- }
332
- function beforeLeave(el: HTMLElement) {
333
- if (virtualizing.value) {
334
- // no-op; removal will be immediate in leave
335
- } else {
336
- onBeforeLeave(el)
337
- }
338
- }
339
- function leave(el: HTMLElement, done: () => void) {
340
- if (virtualizing.value) {
341
- // Skip animation during virtualization
342
- done()
343
- } else {
344
- onLeave(el, done)
345
- }
346
- }
347
-
348
- // Visible window of items (virtualization)
349
- const visibleMasonry = computed(() => {
350
- const top = viewportTop.value - VIRTUAL_BUFFER_PX
351
- const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
352
- const items = masonry.value as any[]
353
- if (!items || items.length === 0) return [] as any[]
354
- return items.filter((it: any) => {
355
- const itemTop = it.top
356
- const itemBottom = it.top + it.columnHeight
357
- return itemBottom >= top && itemTop <= bottom
358
- })
359
- })
360
-
361
- const {handleScroll} = useMasonryScroll({
362
- container,
363
- masonry: masonry as any,
364
- columns,
365
- containerHeight: masonryContentHeight,
366
- isLoading,
367
- pageSize: props.pageSize,
368
- refreshLayout,
369
- setItemsRaw: (items: any[]) => {
370
- masonry.value = items
371
- },
372
- loadNext,
373
- loadThresholdPx: props.loadThresholdPx
374
- })
375
-
376
- function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
377
- fixedDimensions.value = dimensions
378
- if (dimensions) {
379
- if (dimensions.width !== undefined) containerWidth.value = dimensions.width
380
- if (dimensions.height !== undefined) containerHeight.value = dimensions.height
381
- // Force layout refresh when dimensions change
382
- if (!useSwipeMode.value && container.value && masonry.value.length > 0) {
383
- nextTick(() => {
384
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
385
- refreshLayout(masonry.value as any)
386
- updateScrollProgress()
387
- })
388
- }
389
- } else {
390
- // When clearing fixed dimensions, restore from wrapper
391
- if (wrapper.value) {
392
- containerWidth.value = wrapper.value.clientWidth
393
- containerHeight.value = wrapper.value.clientHeight
394
- }
395
- }
396
- }
397
-
398
- defineExpose({
399
- isLoading,
400
- refreshLayout,
401
- // Container dimensions (wrapper element)
402
- containerWidth,
403
- containerHeight,
404
- // Masonry content height (for backward compatibility, old containerHeight)
405
- contentHeight: masonryContentHeight,
406
- // Current page
407
- currentPage,
408
- // Set fixed dimensions (overrides ResizeObserver)
409
- setFixedDimensions,
410
- remove,
411
- removeMany,
412
- removeAll,
413
- loadNext,
414
- loadPage,
415
- refreshCurrentPage,
416
- reset,
417
- init,
418
- paginationHistory,
419
- cancelLoad,
420
- scrollToTop,
421
- totalItems: computed(() => (masonry.value as any[]).length),
422
- currentBreakpoint
423
- })
424
-
425
- function calculateHeight(content: any[]) {
426
- const newHeight = calculateContainerHeight(content as any)
427
- let floor = 0
428
- if (container.value) {
429
- const {scrollTop, clientHeight} = container.value
430
- floor = scrollTop + clientHeight + 100
431
- }
432
- masonryContentHeight.value = Math.max(newHeight, floor)
433
- }
434
-
435
- function refreshLayout(items: any[]) {
436
- if (useSwipeMode.value) {
437
- // In swipe mode, no layout calculation needed - items are stacked vertically
438
- masonry.value = items as any
439
- return
440
- }
441
-
442
- if (!container.value) return
443
- // Developer diagnostics: warn when dimensions are invalid
444
- checkItemDimensions(items as any[], 'refreshLayout')
445
- // Preserve original index before layout reordering
446
- const itemsWithIndex = items.map((item, index) => ({
447
- ...item,
448
- originalIndex: item.originalIndex ?? index
449
- }))
450
-
451
- // When fixed dimensions are set, ensure container uses the fixed width for layout
452
- // This prevents gaps when the container's actual width differs from the fixed width
453
- const containerEl = container.value as HTMLElement
454
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
455
- // Temporarily set width to match fixed dimensions for accurate layout calculation
456
- const originalWidth = containerEl.style.width
457
- const originalBoxSizing = containerEl.style.boxSizing
458
- containerEl.style.boxSizing = 'border-box'
459
- containerEl.style.width = `${fixedDimensions.value.width}px`
460
- // Force reflow
461
- containerEl.offsetWidth
462
-
463
- const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
464
-
465
- // Restore original width
466
- containerEl.style.width = originalWidth
467
- containerEl.style.boxSizing = originalBoxSizing
468
-
469
- calculateHeight(content as any)
470
- masonry.value = content
471
- } else {
472
- const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
473
- calculateHeight(content as any)
474
- masonry.value = content
475
- }
476
- }
477
-
478
- function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
479
- return new Promise<void>((resolve) => {
480
- const total = Math.max(0, totalMs | 0)
481
- const start = Date.now()
482
- onTick(total, total)
483
- const id = setInterval(() => {
484
- // Check for cancellation
485
- if (cancelRequested.value) {
486
- clearInterval(id)
487
- resolve()
488
- return
489
- }
490
- const elapsed = Date.now() - start
491
- const remaining = Math.max(0, total - elapsed)
492
- onTick(remaining, total)
493
- if (remaining <= 0) {
494
- clearInterval(id)
495
- resolve()
496
- }
497
- }, 100)
498
- })
499
- }
500
-
501
- async function getContent(page: number) {
502
- try {
503
- const response = await fetchWithRetry(() => props.getNextPage(page))
504
- refreshLayout([...(masonry.value as any[]), ...response.items])
505
- return response
506
- } catch (error) {
507
- console.error('Error in getContent:', error)
508
- throw error
509
- }
510
- }
511
-
512
- async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
513
- let attempt = 0
514
- const max = props.retryMaxAttempts
515
- let delay = props.retryInitialDelayMs
516
- // eslint-disable-next-line no-constant-condition
517
- while (true) {
518
- try {
519
- const res = await fn()
520
- if (attempt > 0) {
521
- emits('retry:stop', {attempt, success: true})
522
- }
523
- return res
524
- } catch (err) {
525
- attempt++
526
- if (attempt > max) {
527
- emits('retry:stop', {attempt: attempt - 1, success: false})
528
- throw err
529
- }
530
- emits('retry:start', {attempt, max, totalMs: delay})
531
- await waitWithProgress(delay, (remaining, total) => {
532
- emits('retry:tick', {attempt, remainingMs: remaining, totalMs: total})
533
- })
534
- delay += props.retryBackoffStepMs
535
- }
536
- }
537
- }
538
-
539
- async function loadPage(page: number) {
540
- if (isLoading.value) return
541
- // Starting a new load should clear any previous cancel request
542
- cancelRequested.value = false
543
- isLoading.value = true
544
- try {
545
- const baseline = (masonry.value as any[]).length
546
- if (cancelRequested.value) return
547
- const response = await getContent(page)
548
- if (cancelRequested.value) return
549
- currentPage.value = page // Track the current page
550
- paginationHistory.value.push(response.nextPage)
551
- await maybeBackfillToTarget(baseline)
552
- return response
553
- } catch (error) {
554
- console.error('Error loading page:', error)
555
- throw error
556
- } finally {
557
- isLoading.value = false
558
- }
559
- }
560
-
561
- async function loadNext() {
562
- if (isLoading.value) return
563
- // Starting a new load should clear any previous cancel request
564
- cancelRequested.value = false
565
- isLoading.value = true
566
- try {
567
- const baseline = (masonry.value as any[]).length
568
- if (cancelRequested.value) return
569
- const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
570
- const response = await getContent(nextPageToLoad)
571
- if (cancelRequested.value) return
572
- currentPage.value = nextPageToLoad // Track the current page
573
- paginationHistory.value.push(response.nextPage)
574
- await maybeBackfillToTarget(baseline)
575
- return response
576
- } catch (error) {
577
- console.error('Error loading next page:', error)
578
- throw error
579
- } finally {
580
- isLoading.value = false
581
- }
582
- }
583
-
584
- /**
585
- * Refresh the current page by clearing items and reloading from current page
586
- * Useful when items are removed and you want to stay on the same page
587
- */
588
- async function refreshCurrentPage() {
589
- if (isLoading.value) return
590
- cancelRequested.value = false
591
- isLoading.value = true
592
-
593
- try {
594
- // Use the tracked current page
595
- const pageToRefresh = currentPage.value
596
-
597
- if (pageToRefresh == null) {
598
- console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
599
- return
600
- }
601
-
602
- // Clear existing items
603
- masonry.value = []
604
- masonryContentHeight.value = 0
605
-
606
- // Reset pagination history to just the current page
607
- paginationHistory.value = [pageToRefresh]
608
-
609
- await nextTick()
610
-
611
- // Reload the current page
612
- const response = await getContent(pageToRefresh)
613
- if (cancelRequested.value) return
614
-
615
- // Update pagination state
616
- currentPage.value = pageToRefresh
617
- paginationHistory.value.push(response.nextPage)
618
-
619
- // Optionally backfill if needed
620
- const baseline = (masonry.value as any[]).length
621
- await maybeBackfillToTarget(baseline)
622
-
623
- return response
624
- } catch (error) {
625
- console.error('[Masonry] Error refreshing current page:', error)
626
- throw error
627
- } finally {
628
- isLoading.value = false
629
- }
630
- }
631
-
632
- async function remove(item: any) {
633
- const next = (masonry.value as any[]).filter(i => i.id !== item.id)
634
- masonry.value = next
635
- await nextTick()
636
-
637
- // If all items were removed, either refresh current page or load next based on prop
638
- if (next.length === 0 && paginationHistory.value.length > 0) {
639
- if (props.autoRefreshOnEmpty) {
640
- await refreshCurrentPage()
641
- } else {
642
- try {
643
- await loadNext()
644
- // Force backfill from 0 to ensure viewport is filled
645
- // Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
646
- await maybeBackfillToTarget(0, true)
647
- } catch {}
648
- }
649
- return
650
- }
651
-
652
- // Commit DOM updates without forcing sync reflow
653
- await new Promise<void>(r => requestAnimationFrame(() => r()))
654
- // Start FLIP on next frame
655
- requestAnimationFrame(() => {
656
- refreshLayout(next)
657
- })
658
- }
659
-
660
- async function removeMany(items: any[]) {
661
- if (!items || items.length === 0) return
662
- const ids = new Set(items.map(i => i.id))
663
- const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
664
- masonry.value = next
665
- await nextTick()
666
-
667
- // If all items were removed, either refresh current page or load next based on prop
668
- if (next.length === 0 && paginationHistory.value.length > 0) {
669
- if (props.autoRefreshOnEmpty) {
670
- await refreshCurrentPage()
671
- } else {
672
- try {
673
- await loadNext()
674
- // Force backfill from 0 to ensure viewport is filled
675
- await maybeBackfillToTarget(0, true)
676
- } catch {}
677
- }
678
- return
679
- }
680
-
681
- // Commit DOM updates without forcing sync reflow
682
- await new Promise<void>(r => requestAnimationFrame(() => r()))
683
- // Start FLIP on next frame
684
- requestAnimationFrame(() => {
685
- refreshLayout(next)
686
- })
687
- }
688
-
689
- function scrollToTop(options?: ScrollToOptions) {
690
- if (container.value) {
691
- container.value.scrollTo({
692
- top: 0,
693
- behavior: options?.behavior ?? 'smooth',
694
- ...options
695
- })
696
- }
697
- }
698
-
699
- async function removeAll() {
700
- // Scroll to top first for better UX
701
- scrollToTop({ behavior: 'smooth' })
702
-
703
- // Clear all items
704
- masonry.value = []
705
-
706
- // Recalculate height to 0
707
- containerHeight.value = 0
708
-
709
- await nextTick()
710
-
711
- // Emit completion event
712
- emits('remove-all:complete')
713
- }
714
-
715
- function onResize() {
716
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
717
- refreshLayout(masonry.value as any)
718
- if (container.value) {
719
- viewportTop.value = container.value.scrollTop
720
- viewportHeight.value = container.value.clientHeight
721
- }
722
- }
723
-
724
- let backfillActive = false
725
- const cancelRequested = ref(false)
726
-
727
- async function maybeBackfillToTarget(baselineCount: number, force = false) {
728
- if (!force && !props.backfillEnabled) return
729
- if (backfillActive) return
730
- if (cancelRequested.value) return
731
-
732
- const targetCount = (baselineCount || 0) + (props.pageSize || 0)
733
- if (!props.pageSize || props.pageSize <= 0) return
734
-
735
- const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
736
- if (lastNext == null) return
737
-
738
- if ((masonry.value as any[]).length >= targetCount) return
739
-
740
- backfillActive = true
741
- try {
742
- let calls = 0
743
- emits('backfill:start', {target: targetCount, fetched: (masonry.value as any[]).length, calls})
744
-
745
- while (
746
- (masonry.value as any[]).length < targetCount &&
747
- calls < props.backfillMaxCalls &&
748
- paginationHistory.value[paginationHistory.value.length - 1] != null &&
749
- !cancelRequested.value
750
- ) {
751
- await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
752
- emits('backfill:tick', {
753
- fetched: (masonry.value as any[]).length,
754
- target: targetCount,
755
- calls,
756
- remainingMs: remaining,
757
- totalMs: total
758
- })
759
- })
760
-
761
- if (cancelRequested.value) break
762
-
763
- const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
764
- try {
765
- isLoading.value = true
766
- const response = await getContent(currentPage)
767
- if (cancelRequested.value) break
768
- paginationHistory.value.push(response.nextPage)
769
- } finally {
770
- isLoading.value = false
771
- }
772
-
773
- calls++
774
- }
775
-
776
- emits('backfill:stop', {fetched: (masonry.value as any[]).length, calls})
777
- } finally {
778
- backfillActive = false
779
- }
780
- }
781
-
782
- function cancelLoad() {
783
- cancelRequested.value = true
784
- isLoading.value = false
785
- backfillActive = false
786
- }
787
-
788
- function reset() {
789
- // Cancel ongoing work, then immediately clear cancel so new loads can start
790
- cancelLoad()
791
- cancelRequested.value = false
792
- if (container.value) {
793
- container.value.scrollTo({
794
- top: 0,
795
- behavior: 'smooth'
796
- })
797
- }
798
-
799
- masonry.value = []
800
- containerHeight.value = 0
801
- currentPage.value = props.loadAtPage // Reset current page tracking
802
- paginationHistory.value = [props.loadAtPage]
803
-
804
- scrollProgress.value = {
805
- distanceToTrigger: 0,
806
- isNearTrigger: false
807
- }
808
- }
809
-
810
- const debouncedScrollHandler = debounce(async () => {
811
- if (useSwipeMode.value) return // Skip scroll handling in swipe mode
812
-
813
- if (container.value) {
814
- viewportTop.value = container.value.scrollTop
815
- viewportHeight.value = container.value.clientHeight
816
- }
817
- // Gate transitions for virtualization-only DOM changes
818
- virtualizing.value = true
819
- await nextTick()
820
- await new Promise<void>(r => requestAnimationFrame(() => r()))
821
- virtualizing.value = false
822
-
823
- const heights = calculateColumnHeights(masonry.value as any, columns.value)
824
- handleScroll(heights as any)
825
- updateScrollProgress(heights)
826
- }, 200)
827
-
828
- const debouncedResizeHandler = debounce(onResize, 200)
829
-
830
- // Swipe gesture handlers
831
- function handleTouchStart(e: TouchEvent) {
832
- if (!useSwipeMode.value) return
833
- isDragging.value = true
834
- dragStartY.value = e.touches[0].clientY
835
- dragStartOffset.value = swipeOffset.value
836
- e.preventDefault()
837
- }
838
-
839
- function handleTouchMove(e: TouchEvent) {
840
- if (!useSwipeMode.value || !isDragging.value) return
841
- const deltaY = e.touches[0].clientY - dragStartY.value
842
- swipeOffset.value = dragStartOffset.value + deltaY
843
- e.preventDefault()
844
- }
845
-
846
- function handleTouchEnd(e: TouchEvent) {
847
- if (!useSwipeMode.value || !isDragging.value) return
848
- isDragging.value = false
849
-
850
- const deltaY = swipeOffset.value - dragStartOffset.value
851
- const threshold = 100 // Minimum swipe distance to trigger navigation
852
-
853
- if (Math.abs(deltaY) > threshold) {
854
- if (deltaY > 0 && previousItem.value) {
855
- // Swipe down - go to previous
856
- goToPreviousItem()
857
- } else if (deltaY < 0 && nextItem.value) {
858
- // Swipe up - go to next
859
- goToNextItem()
860
- } else {
861
- // Snap back
862
- snapToCurrentItem()
863
- }
864
- } else {
865
- // Snap back if swipe wasn't far enough
866
- snapToCurrentItem()
867
- }
868
-
869
- e.preventDefault()
870
- }
871
-
872
- // Mouse drag handlers for desktop testing
873
- function handleMouseDown(e: MouseEvent) {
874
- if (!useSwipeMode.value) return
875
- isDragging.value = true
876
- dragStartY.value = e.clientY
877
- dragStartOffset.value = swipeOffset.value
878
- e.preventDefault()
879
- }
880
-
881
- function handleMouseMove(e: MouseEvent) {
882
- if (!useSwipeMode.value || !isDragging.value) return
883
- const deltaY = e.clientY - dragStartY.value
884
- swipeOffset.value = dragStartOffset.value + deltaY
885
- e.preventDefault()
886
- }
887
-
888
- function handleMouseUp(e: MouseEvent) {
889
- if (!useSwipeMode.value || !isDragging.value) return
890
- isDragging.value = false
891
-
892
- const deltaY = swipeOffset.value - dragStartOffset.value
893
- const threshold = 100
894
-
895
- if (Math.abs(deltaY) > threshold) {
896
- if (deltaY > 0 && previousItem.value) {
897
- goToPreviousItem()
898
- } else if (deltaY < 0 && nextItem.value) {
899
- goToNextItem()
900
- } else {
901
- snapToCurrentItem()
902
- }
903
- } else {
904
- snapToCurrentItem()
905
- }
906
-
907
- e.preventDefault()
908
- }
909
-
910
- function goToNextItem() {
911
- if (!nextItem.value) {
912
- // Try to load next page
913
- loadNext()
914
- return
915
- }
916
-
917
- currentSwipeIndex.value++
918
- snapToCurrentItem()
919
-
920
- // Preload next item if we're near the end
921
- if (currentSwipeIndex.value >= masonry.value.length - 5) {
922
- loadNext()
923
- }
924
- }
925
-
926
- function goToPreviousItem() {
927
- if (!previousItem.value) return
928
-
929
- currentSwipeIndex.value--
930
- snapToCurrentItem()
931
- }
932
-
933
- function snapToCurrentItem() {
934
- if (!swipeContainer.value) return
935
-
936
- // Use container height for swipe mode instead of window height
937
- const viewportHeight = swipeContainer.value.clientHeight
938
- swipeOffset.value = -currentSwipeIndex.value * viewportHeight
939
- }
940
-
941
- // Watch for container/window resize to update swipe mode
942
- // Note: containerWidth is updated by ResizeObserver, not here
943
- function handleWindowResize() {
944
- // If switching from swipe to masonry, reset swipe state
945
- if (!useSwipeMode.value && currentSwipeIndex.value > 0) {
946
- currentSwipeIndex.value = 0
947
- swipeOffset.value = 0
948
- }
949
-
950
- // If switching to swipe mode, ensure we have items loaded
951
- if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
952
- loadPage(paginationHistory.value[0] as any)
953
- }
954
-
955
- // Re-snap to current item on resize to adjust offset
956
- if (useSwipeMode.value) {
957
- snapToCurrentItem()
958
- }
959
- }
960
-
961
- function init(items: any[], page: any, next: any) {
962
- currentPage.value = page // Track the initial current page
963
- paginationHistory.value = [page]
964
- paginationHistory.value.push(next)
965
- // Diagnostics: check incoming initial items
966
- checkItemDimensions(items as any[], 'init')
967
-
968
- if (useSwipeMode.value) {
969
- // In swipe mode, just add items without layout calculation
970
- masonry.value = [...(masonry.value as any[]), ...items]
971
- // Reset swipe index if we're at the start
972
- if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
973
- swipeOffset.value = 0
974
- }
975
- } else {
976
- refreshLayout([...(masonry.value as any[]), ...items])
977
- updateScrollProgress()
978
- }
979
- }
980
-
981
- // Watch for layout changes and update columns + refresh layout dynamically
982
- watch(
983
- layout,
984
- () => {
985
- if (useSwipeMode.value) {
986
- // In swipe mode, no layout recalculation needed
987
- return
988
- }
989
- if (container.value) {
990
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
991
- refreshLayout(masonry.value as any)
992
- }
993
- },
994
- { deep: true }
995
- )
996
-
997
- // Watch for layout-mode prop changes to ensure proper mode switching
998
- watch(() => props.layoutMode, () => {
999
- // Force update containerWidth when layout-mode changes to ensure useSwipeMode computes correctly
1000
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
1001
- containerWidth.value = fixedDimensions.value.width
1002
- } else if (wrapper.value) {
1003
- containerWidth.value = wrapper.value.clientWidth
1004
- }
1005
- })
1006
-
1007
- // Watch container element to attach scroll listener when available
1008
- watch(container, (el) => {
1009
- if (el && !useSwipeMode.value) {
1010
- // Attach scroll listener for masonry mode
1011
- el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1012
- el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1013
- } else if (el) {
1014
- // Remove scroll listener if switching to swipe mode
1015
- el.removeEventListener('scroll', debouncedScrollHandler)
1016
- }
1017
- }, { immediate: true })
1018
-
1019
- // Watch for swipe mode changes to refresh layout and setup/teardown handlers
1020
- watch(useSwipeMode, (newValue, oldValue) => {
1021
- // Skip if this is the initial watch call and values are the same
1022
- if (oldValue === undefined && newValue === false) return
1023
-
1024
- nextTick(() => {
1025
- if (newValue) {
1026
- // Switching to Swipe Mode
1027
- document.addEventListener('mousemove', handleMouseMove)
1028
- document.addEventListener('mouseup', handleMouseUp)
1029
-
1030
- // Remove scroll listener
1031
- if (container.value) {
1032
- container.value.removeEventListener('scroll', debouncedScrollHandler)
1033
- }
1034
-
1035
- // Reset index if needed
1036
- currentSwipeIndex.value = 0
1037
- swipeOffset.value = 0
1038
- if (masonry.value.length > 0) {
1039
- snapToCurrentItem()
1040
- }
1041
- } else {
1042
- // Switching to Masonry Mode
1043
- document.removeEventListener('mousemove', handleMouseMove)
1044
- document.removeEventListener('mouseup', handleMouseUp)
1045
-
1046
- if (container.value && wrapper.value) {
1047
- // Ensure containerWidth is up to date - use fixed dimensions if set
1048
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
1049
- containerWidth.value = fixedDimensions.value.width
1050
- } else {
1051
- containerWidth.value = wrapper.value.clientWidth
1052
- }
1053
-
1054
- // Attach scroll listener (container watcher will handle this, but ensure it's attached)
1055
- container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1056
- container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1057
-
1058
- // Refresh layout with updated width
1059
- if (masonry.value.length > 0) {
1060
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
1061
- refreshLayout(masonry.value as any)
1062
-
1063
- // Update viewport state
1064
- viewportTop.value = container.value.scrollTop
1065
- viewportHeight.value = container.value.clientHeight
1066
- updateScrollProgress()
1067
- }
1068
- }
1069
- }
1070
- })
1071
- }, { immediate: true })
1072
-
1073
- // Watch for swipe container element to attach touch listeners
1074
- watch(swipeContainer, (el) => {
1075
- if (el) {
1076
- el.addEventListener('touchstart', handleTouchStart, { passive: false })
1077
- el.addEventListener('touchmove', handleTouchMove, { passive: false })
1078
- el.addEventListener('touchend', handleTouchEnd)
1079
- el.addEventListener('mousedown', handleMouseDown)
1080
- }
1081
- })
1082
-
1083
- // Watch for items changes in swipe mode to reset index if needed
1084
- watch(() => masonry.value.length, (newLength, oldLength) => {
1085
- if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
1086
- // First items loaded, ensure we're at index 0
1087
- currentSwipeIndex.value = 0
1088
- nextTick(() => snapToCurrentItem())
1089
- }
1090
- })
1091
-
1092
- // Watch wrapper element to setup ResizeObserver for container width
1093
- watch(wrapper, (el) => {
1094
- if (resizeObserver) {
1095
- resizeObserver.disconnect()
1096
- resizeObserver = null
1097
- }
1098
-
1099
- if (el && typeof ResizeObserver !== 'undefined') {
1100
- resizeObserver = new ResizeObserver((entries) => {
1101
- // Skip updates if fixed dimensions are set
1102
- if (fixedDimensions.value) return
1103
-
1104
- for (const entry of entries) {
1105
- const newWidth = entry.contentRect.width
1106
- const newHeight = entry.contentRect.height
1107
- if (containerWidth.value !== newWidth) {
1108
- containerWidth.value = newWidth
1109
- }
1110
- if (containerHeight.value !== newHeight) {
1111
- containerHeight.value = newHeight
1112
- }
1113
- }
1114
- })
1115
- resizeObserver.observe(el)
1116
- // Initial dimensions (only if not fixed)
1117
- if (!fixedDimensions.value) {
1118
- containerWidth.value = el.clientWidth
1119
- containerHeight.value = el.clientHeight
1120
- }
1121
- } else if (el) {
1122
- // Fallback if ResizeObserver not available
1123
- if (!fixedDimensions.value) {
1124
- containerWidth.value = el.clientWidth
1125
- containerHeight.value = el.clientHeight
1126
- }
1127
- }
1128
- }, { immediate: true })
1129
-
1130
- // Watch containerWidth changes to refresh layout in masonry mode
1131
- watch(containerWidth, (newWidth, oldWidth) => {
1132
- if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
1133
- // Use nextTick to ensure DOM has updated
1134
- nextTick(() => {
1135
- columns.value = getColumnCount(layout.value as any, newWidth)
1136
- refreshLayout(masonry.value as any)
1137
- updateScrollProgress()
1138
- })
1139
- }
1140
- })
1141
-
1142
- onMounted(async () => {
1143
- try {
1144
- // Wait for next tick to ensure wrapper is mounted
1145
- await nextTick()
1146
-
1147
- // Container dimensions are managed by ResizeObserver
1148
- // Only set initial values if ResizeObserver isn't available
1149
- if (wrapper.value && !resizeObserver) {
1150
- containerWidth.value = wrapper.value.clientWidth
1151
- containerHeight.value = wrapper.value.clientHeight
1152
- }
1153
-
1154
- if (!useSwipeMode.value) {
1155
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
1156
- if (container.value) {
1157
- viewportTop.value = container.value.scrollTop
1158
- viewportHeight.value = container.value.clientHeight
1159
- }
1160
- }
1161
-
1162
- const initialPage = props.loadAtPage as any
1163
- paginationHistory.value = [initialPage]
1164
-
1165
- if (!props.skipInitialLoad) {
1166
- await loadPage(paginationHistory.value[0] as any)
1167
- }
1168
-
1169
- if (!useSwipeMode.value) {
1170
- updateScrollProgress()
1171
- } else {
1172
- // In swipe mode, snap to first item
1173
- nextTick(() => snapToCurrentItem())
1174
- }
1175
-
1176
- } catch (error) {
1177
- console.error('Error during component initialization:', error)
1178
- isLoading.value = false
1179
- }
1180
-
1181
- // Scroll listener is handled by watcher now for consistency
1182
- window.addEventListener('resize', debouncedResizeHandler)
1183
- window.addEventListener('resize', handleWindowResize)
1184
- })
1185
-
1186
- onUnmounted(() => {
1187
- if (resizeObserver) {
1188
- resizeObserver.disconnect()
1189
- resizeObserver = null
1190
- }
1191
-
1192
- container.value?.removeEventListener('scroll', debouncedScrollHandler)
1193
- window.removeEventListener('resize', debouncedResizeHandler)
1194
- window.removeEventListener('resize', handleWindowResize)
1195
-
1196
- if (swipeContainer.value) {
1197
- swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
1198
- swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
1199
- swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
1200
- swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
1201
- }
1202
-
1203
- // Clean up mouse handlers
1204
- document.removeEventListener('mousemove', handleMouseMove)
1205
- document.removeEventListener('mouseup', handleMouseUp)
1206
- })
1207
- </script>
1208
-
1209
- <template>
1210
- <div ref="wrapper" class="w-full h-full flex flex-col relative">
1211
- <!-- Swipe Feed Mode (Mobile/Tablet) -->
1212
- <div v-if="useSwipeMode"
1213
- class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
1214
- :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
1215
- ref="swipeContainer"
1216
- style="height: 100%; max-height: 100%; position: relative;">
1217
- <div
1218
- class="relative w-full"
1219
- :style="{
1220
- transform: `translateY(${swipeOffset}px)`,
1221
- transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
1222
- height: `${masonry.length * 100}%`
1223
- }">
1224
- <div
1225
- v-for="(item, index) in masonry"
1226
- :key="`${item.page}-${item.id}`"
1227
- class="absolute top-0 left-0 w-full"
1228
- :style="{
1229
- top: `${index * (100 / masonry.length)}%`,
1230
- height: `${100 / masonry.length}%`
1231
- }">
1232
- <div class="w-full h-full flex items-center justify-center p-4">
1233
- <div class="w-full h-full max-w-full max-h-full relative">
1234
- <slot :item="item" :remove="remove">
1235
- <MasonryItem
1236
- :item="item"
1237
- :remove="remove"
1238
- :header-height="layout.header"
1239
- :footer-height="layout.footer"
1240
- :in-swipe-mode="true"
1241
- :is-active="index === currentSwipeIndex"
1242
- @preload:success="(p) => emits('item:preload:success', p)"
1243
- @preload:error="(p) => emits('item:preload:error', p)"
1244
- @mouse-enter="(p) => emits('item:mouse-enter', p)"
1245
- @mouse-leave="(p) => emits('item:mouse-leave', p)"
1246
- >
1247
- <!-- Pass through header and footer slots to MasonryItem -->
1248
- <template #header="slotProps">
1249
- <slot name="item-header" v-bind="slotProps" />
1250
- </template>
1251
- <template #footer="slotProps">
1252
- <slot name="item-footer" v-bind="slotProps" />
1253
- </template>
1254
- </MasonryItem>
1255
- </slot>
1256
- </div>
1257
- </div>
1258
- </div>
1259
- </div>
1260
-
1261
-
1262
-
1263
-
1264
- </div>
1265
-
1266
- <!-- Masonry Grid Mode (Desktop) -->
1267
- <div v-else
1268
- class="overflow-auto w-full flex-1 masonry-container"
1269
- :class="{ 'force-motion': props.forceMotion }"
1270
- ref="container">
1271
- <div class="relative"
1272
- :style="{height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
1273
- <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
1274
- @leave="leave"
1275
- @before-leave="beforeLeave">
1276
- <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
1277
- class="absolute masonry-item"
1278
- v-bind="getItemAttributes(item, i)">
1279
- <!-- Use default slot if provided, otherwise use MasonryItem -->
1280
- <slot :item="item" :remove="remove">
1281
- <MasonryItem
1282
- :item="item"
1283
- :remove="remove"
1284
- :header-height="layout.header"
1285
- :footer-height="layout.footer"
1286
- :in-swipe-mode="false"
1287
- :is-active="false"
1288
- @preload:success="(p) => emits('item:preload:success', p)"
1289
- @preload:error="(p) => emits('item:preload:error', p)"
1290
- @mouse-enter="(p) => emits('item:mouse-enter', p)"
1291
- @mouse-leave="(p) => emits('item:mouse-leave', p)"
1292
- >
1293
- <!-- Pass through header and footer slots to MasonryItem -->
1294
- <template #header="slotProps">
1295
- <slot name="item-header" v-bind="slotProps" />
1296
- </template>
1297
- <template #footer="slotProps">
1298
- <slot name="item-footer" v-bind="slotProps" />
1299
- </template>
1300
- </MasonryItem>
1301
- </slot>
1302
- </div>
1303
- </transition-group>
1304
-
1305
-
1306
- </div>
1307
- </div>
1308
- </div>
1309
- </template>
1310
-
1311
- <style scoped>
1312
- .masonry-container {
1313
- overflow-anchor: none;
1314
- }
1315
-
1316
- .masonry-item {
1317
- will-change: transform, opacity;
1318
- contain: layout paint;
1319
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
1320
- opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1321
- backface-visibility: hidden;
1322
- }
1323
-
1324
- .masonry-move {
1325
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
1326
- }
1327
-
1328
- @media (prefers-reduced-motion: reduce) {
1329
- .masonry-container:not(.force-motion) .masonry-item,
1330
- .masonry-container:not(.force-motion) .masonry-move {
1331
- transition-duration: 1ms !important;
1332
- }
1333
- }
1334
- </style>
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
+ import calculateLayout from "./calculateLayout";
4
+ import { debounce } from 'lodash-es'
5
+ import {
6
+ getColumnCount,
7
+ getBreakpointName,
8
+ calculateContainerHeight,
9
+ getItemAttributes,
10
+ calculateColumnHeights
11
+ } from './masonryUtils'
12
+ import { useMasonryTransitions } from './useMasonryTransitions'
13
+ import { useMasonryScroll } from './useMasonryScroll'
14
+ import MasonryItem from './components/MasonryItem.vue'
15
+
16
+ const props = defineProps({
17
+ getNextPage: {
18
+ type: Function,
19
+ default: () => { }
20
+ },
21
+ loadAtPage: {
22
+ type: [Number, String],
23
+ default: null
24
+ },
25
+ items: {
26
+ type: Array,
27
+ default: () => []
28
+ },
29
+ layout: {
30
+ type: Object
31
+ },
32
+ paginationType: {
33
+ type: String,
34
+ default: 'page', // or 'cursor'
35
+ validator: (v: string) => ['page', 'cursor'].includes(v)
36
+ },
37
+ skipInitialLoad: {
38
+ type: Boolean,
39
+ default: false
40
+ },
41
+ pageSize: {
42
+ type: Number,
43
+ default: 40
44
+ },
45
+ // Backfill configuration
46
+ backfillEnabled: {
47
+ type: Boolean,
48
+ default: true
49
+ },
50
+ backfillDelayMs: {
51
+ type: Number,
52
+ default: 2000
53
+ },
54
+ backfillMaxCalls: {
55
+ type: Number,
56
+ default: 10
57
+ },
58
+ // Retry configuration
59
+ retryMaxAttempts: {
60
+ type: Number,
61
+ default: 3
62
+ },
63
+ retryInitialDelayMs: {
64
+ type: Number,
65
+ default: 2000
66
+ },
67
+ retryBackoffStepMs: {
68
+ type: Number,
69
+ default: 2000
70
+ },
71
+ transitionDurationMs: {
72
+ type: Number,
73
+ default: 450
74
+ },
75
+ // Shorter, snappier duration specifically for item removal (leave)
76
+ leaveDurationMs: {
77
+ type: Number,
78
+ default: 160
79
+ },
80
+ transitionEasing: {
81
+ type: String,
82
+ default: 'cubic-bezier(.22,.61,.36,1)'
83
+ },
84
+ // Force motion even when user has reduced-motion enabled
85
+ forceMotion: {
86
+ type: Boolean,
87
+ default: false
88
+ },
89
+ virtualBufferPx: {
90
+ type: Number,
91
+ default: 600
92
+ },
93
+ loadThresholdPx: {
94
+ type: Number,
95
+ default: 200
96
+ },
97
+ autoRefreshOnEmpty: {
98
+ type: Boolean,
99
+ default: false
100
+ },
101
+ // Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
102
+ layoutMode: {
103
+ type: String,
104
+ default: 'auto',
105
+ validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
106
+ },
107
+ // Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
108
+ mobileBreakpoint: {
109
+ type: [Number, String],
110
+ default: 768 // 'md' breakpoint
111
+ },
112
+ })
113
+
114
+ const defaultLayout = {
115
+ sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
116
+ gutterX: 10,
117
+ gutterY: 10,
118
+ header: 0,
119
+ footer: 0,
120
+ paddingLeft: 0,
121
+ paddingRight: 0,
122
+ placement: 'masonry'
123
+ }
124
+
125
+ const layout = computed(() => ({
126
+ ...defaultLayout,
127
+ ...props.layout,
128
+ sizes: {
129
+ ...defaultLayout.sizes,
130
+ ...(props.layout?.sizes || {})
131
+ }
132
+ }))
133
+
134
+ const wrapper = ref<HTMLElement | null>(null)
135
+ const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
136
+ const containerHeight = ref<number>(typeof window !== 'undefined' ? window.innerHeight : 768)
137
+ const fixedDimensions = ref<{ width?: number; height?: number } | null>(null)
138
+ let resizeObserver: ResizeObserver | null = null
139
+
140
+ // Get breakpoint value from Tailwind breakpoint name
141
+ function getBreakpointValue(breakpoint: string): number {
142
+ const breakpoints: Record<string, number> = {
143
+ 'sm': 640,
144
+ 'md': 768,
145
+ 'lg': 1024,
146
+ 'xl': 1280,
147
+ '2xl': 1536
148
+ }
149
+ return breakpoints[breakpoint] || 768
150
+ }
151
+
152
+ // Determine if we should use swipe mode
153
+ const useSwipeMode = computed(() => {
154
+ if (props.layoutMode === 'masonry') return false
155
+ if (props.layoutMode === 'swipe') return true
156
+
157
+ // Auto mode: check container width
158
+ const breakpoint = typeof props.mobileBreakpoint === 'string'
159
+ ? getBreakpointValue(props.mobileBreakpoint)
160
+ : props.mobileBreakpoint
161
+
162
+ return containerWidth.value < breakpoint
163
+ })
164
+
165
+ // Get current item index for swipe mode
166
+ const currentItem = computed(() => {
167
+ if (!useSwipeMode.value || masonry.value.length === 0) return null
168
+ const index = Math.max(0, Math.min(currentSwipeIndex.value, masonry.value.length - 1))
169
+ return (masonry.value as any[])[index] || null
170
+ })
171
+
172
+ // Get next/previous items for preloading in swipe mode
173
+ const nextItem = computed(() => {
174
+ if (!useSwipeMode.value || !currentItem.value) return null
175
+ const nextIndex = currentSwipeIndex.value + 1
176
+ if (nextIndex >= masonry.value.length) return null
177
+ return (masonry.value as any[])[nextIndex] || null
178
+ })
179
+
180
+ const previousItem = computed(() => {
181
+ if (!useSwipeMode.value || !currentItem.value) return null
182
+ const prevIndex = currentSwipeIndex.value - 1
183
+ if (prevIndex < 0) return null
184
+ return (masonry.value as any[])[prevIndex] || null
185
+ })
186
+
187
+ const emits = defineEmits([
188
+ 'update:items',
189
+ 'backfill:start',
190
+ 'backfill:tick',
191
+ 'backfill:stop',
192
+ 'retry:start',
193
+ 'retry:tick',
194
+ 'retry:stop',
195
+ 'remove-all:complete',
196
+ // Re-emit item-level preload events from the default MasonryItem
197
+ 'item:preload:success',
198
+ 'item:preload:error',
199
+ // Mouse events from MasonryItem content
200
+ 'item:mouse-enter',
201
+ 'item:mouse-leave'
202
+ ])
203
+
204
+ const masonry = computed<any>({
205
+ get: () => props.items,
206
+ set: (val) => emits('update:items', val)
207
+ })
208
+
209
+ const columns = ref<number>(7)
210
+ const container = ref<HTMLElement | null>(null)
211
+ const paginationHistory = ref<any[]>([])
212
+ const currentPage = ref<any>(null) // Track the actual current page being displayed
213
+ const isLoading = ref<boolean>(false)
214
+ const masonryContentHeight = ref<number>(0)
215
+ const hasReachedEnd = ref<boolean>(false) // Track when we've reached the last page
216
+ const loadError = ref<Error | null>(null) // Track load errors
217
+
218
+ // Current breakpoint
219
+ const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
220
+
221
+ // Swipe mode state
222
+ const currentSwipeIndex = ref<number>(0)
223
+ const swipeOffset = ref<number>(0)
224
+ const isDragging = ref<boolean>(false)
225
+ const dragStartY = ref<number>(0)
226
+ const dragStartOffset = ref<number>(0)
227
+ const swipeContainer = ref<HTMLElement | null>(null)
228
+
229
+ // Diagnostics: track items missing width/height to help developers
230
+ const invalidDimensionIds = ref<Set<number | string>>(new Set())
231
+ function isPositiveNumber(value: unknown): boolean {
232
+ return typeof value === 'number' && Number.isFinite(value) && value > 0
233
+ }
234
+ function checkItemDimensions(items: any[], context: string) {
235
+ try {
236
+ if (!Array.isArray(items) || items.length === 0) return
237
+ const missing = items.filter((item) => !isPositiveNumber(item?.width) || !isPositiveNumber(item?.height))
238
+ if (missing.length === 0) return
239
+
240
+ const newIds: Array<number | string> = []
241
+ for (const item of missing) {
242
+ const id = (item?.id as number | string | undefined) ?? `idx:${items.indexOf(item)}`
243
+ if (!invalidDimensionIds.value.has(id)) {
244
+ invalidDimensionIds.value.add(id)
245
+ newIds.push(id)
246
+ }
247
+ }
248
+ if (newIds.length > 0) {
249
+ const sample = newIds.slice(0, 10)
250
+ // eslint-disable-next-line no-console
251
+ console.warn(
252
+ '[Masonry] Items missing width/height detected:',
253
+ {
254
+ context,
255
+ count: newIds.length,
256
+ sampleIds: sample,
257
+ hint: 'Ensure each item has positive width and height. Consider providing fallbacks (e.g., 512x512) at the data layer.'
258
+ }
259
+ )
260
+ }
261
+ } catch {
262
+ // best-effort diagnostics only
263
+ }
264
+ }
265
+
266
+ // Virtualization viewport state
267
+ const viewportTop = ref(0)
268
+ const viewportHeight = ref(0)
269
+ const VIRTUAL_BUFFER_PX = props.virtualBufferPx
270
+
271
+ // Gate transitions during virtualization-only DOM churn
272
+ const virtualizing = ref(false)
273
+
274
+ // Scroll progress tracking
275
+ const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
276
+ distanceToTrigger: 0,
277
+ isNearTrigger: false
278
+ })
279
+
280
+ const updateScrollProgress = (precomputedHeights?: number[]) => {
281
+ if (!container.value) return
282
+
283
+ const { scrollTop, clientHeight } = container.value
284
+ const visibleBottom = scrollTop + clientHeight
285
+
286
+ const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
287
+ const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
288
+ const threshold = typeof props.loadThresholdPx === 'number' ? props.loadThresholdPx : 200
289
+ const triggerPoint = threshold >= 0
290
+ ? Math.max(0, tallest - threshold)
291
+ : Math.max(0, tallest + threshold)
292
+
293
+ const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
294
+ const isNearTrigger = distanceToTrigger <= 100
295
+
296
+ scrollProgress.value = {
297
+ distanceToTrigger: Math.round(distanceToTrigger),
298
+ isNearTrigger
299
+ }
300
+ }
301
+
302
+ // Setup composables
303
+ const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
304
+
305
+ // Transition wrappers that skip animation during virtualization
306
+ function enter(el: HTMLElement, done: () => void) {
307
+ if (virtualizing.value) {
308
+ const left = parseInt(el.dataset.left || '0', 10)
309
+ const top = parseInt(el.dataset.top || '0', 10)
310
+ el.style.transition = 'none'
311
+ el.style.opacity = '1'
312
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
313
+ el.style.removeProperty('--masonry-opacity-delay')
314
+ requestAnimationFrame(() => {
315
+ el.style.transition = ''
316
+ done()
317
+ })
318
+ } else {
319
+ onEnter(el, done)
320
+ }
321
+ }
322
+ function beforeEnter(el: HTMLElement) {
323
+ if (virtualizing.value) {
324
+ const left = parseInt(el.dataset.left || '0', 10)
325
+ const top = parseInt(el.dataset.top || '0', 10)
326
+ el.style.transition = 'none'
327
+ el.style.opacity = '1'
328
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
329
+ el.style.removeProperty('--masonry-opacity-delay')
330
+ } else {
331
+ onBeforeEnter(el)
332
+ }
333
+ }
334
+ function beforeLeave(el: HTMLElement) {
335
+ if (virtualizing.value) {
336
+ // no-op; removal will be immediate in leave
337
+ } else {
338
+ onBeforeLeave(el)
339
+ }
340
+ }
341
+ function leave(el: HTMLElement, done: () => void) {
342
+ if (virtualizing.value) {
343
+ // Skip animation during virtualization
344
+ done()
345
+ } else {
346
+ onLeave(el, done)
347
+ }
348
+ }
349
+
350
+ // Visible window of items (virtualization)
351
+ const visibleMasonry = computed(() => {
352
+ const top = viewportTop.value - VIRTUAL_BUFFER_PX
353
+ const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
354
+ const items = masonry.value as any[]
355
+ if (!items || items.length === 0) return [] as any[]
356
+ return items.filter((it: any) => {
357
+ const itemTop = it.top
358
+ const itemBottom = it.top + it.columnHeight
359
+ return itemBottom >= top && itemTop <= bottom
360
+ })
361
+ })
362
+
363
+ const { handleScroll } = useMasonryScroll({
364
+ container,
365
+ masonry: masonry as any,
366
+ columns,
367
+ containerHeight: masonryContentHeight,
368
+ isLoading,
369
+ pageSize: props.pageSize,
370
+ refreshLayout,
371
+ setItemsRaw: (items: any[]) => {
372
+ masonry.value = items
373
+ },
374
+ loadNext,
375
+ loadThresholdPx: props.loadThresholdPx
376
+ })
377
+
378
+ function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
379
+ fixedDimensions.value = dimensions
380
+ if (dimensions) {
381
+ if (dimensions.width !== undefined) containerWidth.value = dimensions.width
382
+ if (dimensions.height !== undefined) containerHeight.value = dimensions.height
383
+ // Force layout refresh when dimensions change
384
+ if (!useSwipeMode.value && container.value && masonry.value.length > 0) {
385
+ nextTick(() => {
386
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
387
+ refreshLayout(masonry.value as any)
388
+ updateScrollProgress()
389
+ })
390
+ }
391
+ } else {
392
+ // When clearing fixed dimensions, restore from wrapper
393
+ if (wrapper.value) {
394
+ containerWidth.value = wrapper.value.clientWidth
395
+ containerHeight.value = wrapper.value.clientHeight
396
+ }
397
+ }
398
+ }
399
+
400
+ defineExpose({
401
+ isLoading,
402
+ refreshLayout,
403
+ // Container dimensions (wrapper element)
404
+ containerWidth,
405
+ containerHeight,
406
+ // Masonry content height (for backward compatibility, old containerHeight)
407
+ contentHeight: masonryContentHeight,
408
+ // Current page
409
+ currentPage,
410
+ // End of list tracking
411
+ hasReachedEnd,
412
+ // Load error tracking
413
+ loadError,
414
+ // Set fixed dimensions (overrides ResizeObserver)
415
+ setFixedDimensions,
416
+ remove,
417
+ removeMany,
418
+ removeAll,
419
+ loadNext,
420
+ loadPage,
421
+ refreshCurrentPage,
422
+ reset,
423
+ destroy,
424
+ init,
425
+ paginationHistory,
426
+ cancelLoad,
427
+ scrollToTop,
428
+ totalItems: computed(() => (masonry.value as any[]).length),
429
+ currentBreakpoint
430
+ })
431
+
432
+ function calculateHeight(content: any[]) {
433
+ const newHeight = calculateContainerHeight(content as any)
434
+ let floor = 0
435
+ if (container.value) {
436
+ const { scrollTop, clientHeight } = container.value
437
+ floor = scrollTop + clientHeight + 100
438
+ }
439
+ masonryContentHeight.value = Math.max(newHeight, floor)
440
+ }
441
+
442
+ function refreshLayout(items: any[]) {
443
+ if (useSwipeMode.value) {
444
+ // In swipe mode, no layout calculation needed - items are stacked vertically
445
+ masonry.value = items as any
446
+ return
447
+ }
448
+
449
+ if (!container.value) return
450
+ // Developer diagnostics: warn when dimensions are invalid
451
+ checkItemDimensions(items as any[], 'refreshLayout')
452
+ // Preserve original index before layout reordering
453
+ const itemsWithIndex = items.map((item, index) => ({
454
+ ...item,
455
+ originalIndex: item.originalIndex ?? index
456
+ }))
457
+
458
+ // When fixed dimensions are set, ensure container uses the fixed width for layout
459
+ // This prevents gaps when the container's actual width differs from the fixed width
460
+ const containerEl = container.value as HTMLElement
461
+ if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
462
+ // Temporarily set width to match fixed dimensions for accurate layout calculation
463
+ const originalWidth = containerEl.style.width
464
+ const originalBoxSizing = containerEl.style.boxSizing
465
+ containerEl.style.boxSizing = 'border-box'
466
+ containerEl.style.width = `${fixedDimensions.value.width}px`
467
+ // Force reflow
468
+ containerEl.offsetWidth
469
+
470
+ const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
471
+
472
+ // Restore original width
473
+ containerEl.style.width = originalWidth
474
+ containerEl.style.boxSizing = originalBoxSizing
475
+
476
+ calculateHeight(content as any)
477
+ masonry.value = content
478
+ } else {
479
+ const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
480
+ calculateHeight(content as any)
481
+ masonry.value = content
482
+ }
483
+ }
484
+
485
+ function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
486
+ return new Promise<void>((resolve) => {
487
+ const total = Math.max(0, totalMs | 0)
488
+ const start = Date.now()
489
+ onTick(total, total)
490
+ const id = setInterval(() => {
491
+ // Check for cancellation
492
+ if (cancelRequested.value) {
493
+ clearInterval(id)
494
+ resolve()
495
+ return
496
+ }
497
+ const elapsed = Date.now() - start
498
+ const remaining = Math.max(0, total - elapsed)
499
+ onTick(remaining, total)
500
+ if (remaining <= 0) {
501
+ clearInterval(id)
502
+ resolve()
503
+ }
504
+ }, 100)
505
+ })
506
+ }
507
+
508
+ async function getContent(page: number) {
509
+ try {
510
+ const response = await fetchWithRetry(() => props.getNextPage(page))
511
+ refreshLayout([...(masonry.value as any[]), ...response.items])
512
+ return response
513
+ } catch (error) {
514
+ console.error('Error in getContent:', error)
515
+ throw error
516
+ }
517
+ }
518
+
519
+ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
520
+ let attempt = 0
521
+ const max = props.retryMaxAttempts
522
+ let delay = props.retryInitialDelayMs
523
+ // eslint-disable-next-line no-constant-condition
524
+ while (true) {
525
+ try {
526
+ const res = await fn()
527
+ if (attempt > 0) {
528
+ emits('retry:stop', { attempt, success: true })
529
+ }
530
+ return res
531
+ } catch (err) {
532
+ attempt++
533
+ if (attempt > max) {
534
+ emits('retry:stop', { attempt: attempt - 1, success: false })
535
+ throw err
536
+ }
537
+ emits('retry:start', { attempt, max, totalMs: delay })
538
+ await waitWithProgress(delay, (remaining, total) => {
539
+ emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
540
+ })
541
+ delay += props.retryBackoffStepMs
542
+ }
543
+ }
544
+ }
545
+
546
+ async function loadPage(page: number) {
547
+ if (isLoading.value) return
548
+ // Starting a new load should clear any previous cancel request
549
+ cancelRequested.value = false
550
+ isLoading.value = true
551
+ // Reset hasReachedEnd and loadError when loading a new page
552
+ hasReachedEnd.value = false
553
+ loadError.value = null
554
+ try {
555
+ const baseline = (masonry.value as any[]).length
556
+ if (cancelRequested.value) return
557
+ const response = await getContent(page)
558
+ if (cancelRequested.value) return
559
+ // Clear error on successful load
560
+ loadError.value = null
561
+ currentPage.value = page // Track the current page
562
+ paginationHistory.value.push(response.nextPage)
563
+ // Update hasReachedEnd if nextPage is null
564
+ if (response.nextPage == null) {
565
+ hasReachedEnd.value = true
566
+ }
567
+ await maybeBackfillToTarget(baseline)
568
+ return response
569
+ } catch (error) {
570
+ console.error('Error loading page:', error)
571
+ // Set load error
572
+ loadError.value = error instanceof Error ? error : new Error(String(error))
573
+ throw error
574
+ } finally {
575
+ isLoading.value = false
576
+ }
577
+ }
578
+
579
+ async function loadNext() {
580
+ if (isLoading.value) return
581
+ // Don't load if we've already reached the end
582
+ if (hasReachedEnd.value) return
583
+ // Starting a new load should clear any previous cancel request
584
+ cancelRequested.value = false
585
+ isLoading.value = true
586
+ // Clear error when attempting to load
587
+ loadError.value = null
588
+ try {
589
+ const baseline = (masonry.value as any[]).length
590
+ if (cancelRequested.value) return
591
+ const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
592
+ // Don't load if nextPageToLoad is null
593
+ if (nextPageToLoad == null) {
594
+ hasReachedEnd.value = true
595
+ isLoading.value = false
596
+ return
597
+ }
598
+ const response = await getContent(nextPageToLoad)
599
+ if (cancelRequested.value) return
600
+ // Clear error on successful load
601
+ loadError.value = null
602
+ currentPage.value = nextPageToLoad // Track the current page
603
+ paginationHistory.value.push(response.nextPage)
604
+ // Update hasReachedEnd if nextPage is null
605
+ if (response.nextPage == null) {
606
+ hasReachedEnd.value = true
607
+ }
608
+ await maybeBackfillToTarget(baseline)
609
+ return response
610
+ } catch (error) {
611
+ console.error('Error loading next page:', error)
612
+ // Set load error
613
+ loadError.value = error instanceof Error ? error : new Error(String(error))
614
+ throw error
615
+ } finally {
616
+ isLoading.value = false
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Refresh the current page by clearing items and reloading from current page
622
+ * Useful when items are removed and you want to stay on the same page
623
+ */
624
+ async function refreshCurrentPage() {
625
+ if (isLoading.value) return
626
+ cancelRequested.value = false
627
+ isLoading.value = true
628
+
629
+ try {
630
+ // Use the tracked current page
631
+ const pageToRefresh = currentPage.value
632
+
633
+ if (pageToRefresh == null) {
634
+ console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
635
+ return
636
+ }
637
+
638
+ // Clear existing items
639
+ masonry.value = []
640
+ masonryContentHeight.value = 0
641
+ hasReachedEnd.value = false // Reset end flag when refreshing
642
+ loadError.value = null // Reset error flag when refreshing
643
+
644
+ // Reset pagination history to just the current page
645
+ paginationHistory.value = [pageToRefresh]
646
+
647
+ await nextTick()
648
+
649
+ // Reload the current page
650
+ const response = await getContent(pageToRefresh)
651
+ if (cancelRequested.value) return
652
+
653
+ // Clear error on successful load
654
+ loadError.value = null
655
+ // Update pagination state
656
+ currentPage.value = pageToRefresh
657
+ paginationHistory.value.push(response.nextPage)
658
+ // Update hasReachedEnd if nextPage is null
659
+ if (response.nextPage == null) {
660
+ hasReachedEnd.value = true
661
+ }
662
+
663
+ // Optionally backfill if needed
664
+ const baseline = (masonry.value as any[]).length
665
+ await maybeBackfillToTarget(baseline)
666
+
667
+ return response
668
+ } catch (error) {
669
+ console.error('[Masonry] Error refreshing current page:', error)
670
+ // Set load error
671
+ loadError.value = error instanceof Error ? error : new Error(String(error))
672
+ throw error
673
+ } finally {
674
+ isLoading.value = false
675
+ }
676
+ }
677
+
678
+ async function remove(item: any) {
679
+ const next = (masonry.value as any[]).filter(i => i.id !== item.id)
680
+ masonry.value = next
681
+ await nextTick()
682
+
683
+ // If all items were removed, either refresh current page or load next based on prop
684
+ if (next.length === 0 && paginationHistory.value.length > 0) {
685
+ if (props.autoRefreshOnEmpty) {
686
+ await refreshCurrentPage()
687
+ } else {
688
+ try {
689
+ await loadNext()
690
+ // Force backfill from 0 to ensure viewport is filled
691
+ // Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
692
+ await maybeBackfillToTarget(0, true)
693
+ } catch { }
694
+ }
695
+ return
696
+ }
697
+
698
+ // Commit DOM updates without forcing sync reflow
699
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
700
+ // Start FLIP on next frame
701
+ requestAnimationFrame(() => {
702
+ refreshLayout(next)
703
+ })
704
+ }
705
+
706
+ async function removeMany(items: any[]) {
707
+ if (!items || items.length === 0) return
708
+ const ids = new Set(items.map(i => i.id))
709
+ const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
710
+ masonry.value = next
711
+ await nextTick()
712
+
713
+ // If all items were removed, either refresh current page or load next based on prop
714
+ if (next.length === 0 && paginationHistory.value.length > 0) {
715
+ if (props.autoRefreshOnEmpty) {
716
+ await refreshCurrentPage()
717
+ } else {
718
+ try {
719
+ await loadNext()
720
+ // Force backfill from 0 to ensure viewport is filled
721
+ await maybeBackfillToTarget(0, true)
722
+ } catch { }
723
+ }
724
+ return
725
+ }
726
+
727
+ // Commit DOM updates without forcing sync reflow
728
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
729
+ // Start FLIP on next frame
730
+ requestAnimationFrame(() => {
731
+ refreshLayout(next)
732
+ })
733
+ }
734
+
735
+ function scrollToTop(options?: ScrollToOptions) {
736
+ if (container.value) {
737
+ container.value.scrollTo({
738
+ top: 0,
739
+ behavior: options?.behavior ?? 'smooth',
740
+ ...options
741
+ })
742
+ }
743
+ }
744
+
745
+ async function removeAll() {
746
+ // Scroll to top first for better UX
747
+ scrollToTop({ behavior: 'smooth' })
748
+
749
+ // Clear all items
750
+ masonry.value = []
751
+
752
+ // Recalculate height to 0
753
+ containerHeight.value = 0
754
+
755
+ await nextTick()
756
+
757
+ // Emit completion event
758
+ emits('remove-all:complete')
759
+ }
760
+
761
+ function onResize() {
762
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
763
+ refreshLayout(masonry.value as any)
764
+ if (container.value) {
765
+ viewportTop.value = container.value.scrollTop
766
+ viewportHeight.value = container.value.clientHeight
767
+ }
768
+ }
769
+
770
+ let backfillActive = false
771
+ const cancelRequested = ref(false)
772
+
773
+ async function maybeBackfillToTarget(baselineCount: number, force = false) {
774
+ if (!force && !props.backfillEnabled) return
775
+ if (backfillActive) return
776
+ if (cancelRequested.value) return
777
+ // Don't backfill if we've reached the end
778
+ if (hasReachedEnd.value) return
779
+
780
+ const targetCount = (baselineCount || 0) + (props.pageSize || 0)
781
+ if (!props.pageSize || props.pageSize <= 0) return
782
+
783
+ const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
784
+ if (lastNext == null) {
785
+ hasReachedEnd.value = true
786
+ return
787
+ }
788
+
789
+ if ((masonry.value as any[]).length >= targetCount) return
790
+
791
+ backfillActive = true
792
+ try {
793
+ let calls = 0
794
+ emits('backfill:start', { target: targetCount, fetched: (masonry.value as any[]).length, calls })
795
+
796
+ while (
797
+ (masonry.value as any[]).length < targetCount &&
798
+ calls < props.backfillMaxCalls &&
799
+ paginationHistory.value[paginationHistory.value.length - 1] != null &&
800
+ !cancelRequested.value &&
801
+ !hasReachedEnd.value
802
+ ) {
803
+ await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
804
+ emits('backfill:tick', {
805
+ fetched: (masonry.value as any[]).length,
806
+ target: targetCount,
807
+ calls,
808
+ remainingMs: remaining,
809
+ totalMs: total
810
+ })
811
+ })
812
+
813
+ if (cancelRequested.value) break
814
+
815
+ const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
816
+ if (currentPage == null) {
817
+ hasReachedEnd.value = true
818
+ break
819
+ }
820
+ try {
821
+ isLoading.value = true
822
+ const response = await getContent(currentPage)
823
+ if (cancelRequested.value) break
824
+ // Clear error on successful load
825
+ loadError.value = null
826
+ paginationHistory.value.push(response.nextPage)
827
+ // Update hasReachedEnd if nextPage is null
828
+ if (response.nextPage == null) {
829
+ hasReachedEnd.value = true
830
+ }
831
+ } catch (error) {
832
+ // Set load error but don't break the backfill loop
833
+ loadError.value = error instanceof Error ? error : new Error(String(error))
834
+ } finally {
835
+ isLoading.value = false
836
+ }
837
+
838
+ calls++
839
+ }
840
+
841
+ emits('backfill:stop', { fetched: (masonry.value as any[]).length, calls })
842
+ } finally {
843
+ backfillActive = false
844
+ }
845
+ }
846
+
847
+ function cancelLoad() {
848
+ cancelRequested.value = true
849
+ isLoading.value = false
850
+ backfillActive = false
851
+ }
852
+
853
+ function reset() {
854
+ // Cancel ongoing work, then immediately clear cancel so new loads can start
855
+ cancelLoad()
856
+ cancelRequested.value = false
857
+ if (container.value) {
858
+ container.value.scrollTo({
859
+ top: 0,
860
+ behavior: 'smooth'
861
+ })
862
+ }
863
+
864
+ masonry.value = []
865
+ containerHeight.value = 0
866
+ currentPage.value = props.loadAtPage // Reset current page tracking
867
+ paginationHistory.value = [props.loadAtPage]
868
+ hasReachedEnd.value = false // Reset end flag
869
+ loadError.value = null // Reset error flag
870
+
871
+ scrollProgress.value = {
872
+ distanceToTrigger: 0,
873
+ isNearTrigger: false
874
+ }
875
+ }
876
+
877
+ function destroy() {
878
+ // Cancel any ongoing loads
879
+ cancelLoad()
880
+
881
+ // Reset all state
882
+ masonry.value = []
883
+ masonryContentHeight.value = 0
884
+ currentPage.value = null
885
+ paginationHistory.value = []
886
+ hasReachedEnd.value = false
887
+ loadError.value = null
888
+ isLoading.value = false
889
+ backfillActive = false
890
+ cancelRequested.value = false
891
+
892
+ // Reset swipe mode state
893
+ currentSwipeIndex.value = 0
894
+ swipeOffset.value = 0
895
+ isDragging.value = false
896
+
897
+ // Reset viewport state
898
+ viewportTop.value = 0
899
+ viewportHeight.value = 0
900
+ virtualizing.value = false
901
+
902
+ // Reset scroll progress
903
+ scrollProgress.value = {
904
+ distanceToTrigger: 0,
905
+ isNearTrigger: false
906
+ }
907
+
908
+ // Reset invalid dimension tracking
909
+ invalidDimensionIds.value.clear()
910
+
911
+ // Scroll to top if container exists
912
+ if (container.value) {
913
+ container.value.scrollTo({
914
+ top: 0,
915
+ behavior: 'auto' // Instant scroll for destroy
916
+ })
917
+ }
918
+ }
919
+
920
+ const debouncedScrollHandler = debounce(async () => {
921
+ if (useSwipeMode.value) return // Skip scroll handling in swipe mode
922
+
923
+ if (container.value) {
924
+ viewportTop.value = container.value.scrollTop
925
+ viewportHeight.value = container.value.clientHeight
926
+ }
927
+ // Gate transitions for virtualization-only DOM changes
928
+ virtualizing.value = true
929
+ await nextTick()
930
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
931
+ virtualizing.value = false
932
+
933
+ const heights = calculateColumnHeights(masonry.value as any, columns.value)
934
+ handleScroll(heights as any)
935
+ updateScrollProgress(heights)
936
+ }, 200)
937
+
938
+ const debouncedResizeHandler = debounce(onResize, 200)
939
+
940
+ // Swipe gesture handlers
941
+ function handleTouchStart(e: TouchEvent) {
942
+ if (!useSwipeMode.value) return
943
+ isDragging.value = true
944
+ dragStartY.value = e.touches[0].clientY
945
+ dragStartOffset.value = swipeOffset.value
946
+ e.preventDefault()
947
+ }
948
+
949
+ function handleTouchMove(e: TouchEvent) {
950
+ if (!useSwipeMode.value || !isDragging.value) return
951
+ const deltaY = e.touches[0].clientY - dragStartY.value
952
+ swipeOffset.value = dragStartOffset.value + deltaY
953
+ e.preventDefault()
954
+ }
955
+
956
+ function handleTouchEnd(e: TouchEvent) {
957
+ if (!useSwipeMode.value || !isDragging.value) return
958
+ isDragging.value = false
959
+
960
+ const deltaY = swipeOffset.value - dragStartOffset.value
961
+ const threshold = 100 // Minimum swipe distance to trigger navigation
962
+
963
+ if (Math.abs(deltaY) > threshold) {
964
+ if (deltaY > 0 && previousItem.value) {
965
+ // Swipe down - go to previous
966
+ goToPreviousItem()
967
+ } else if (deltaY < 0 && nextItem.value) {
968
+ // Swipe up - go to next
969
+ goToNextItem()
970
+ } else {
971
+ // Snap back
972
+ snapToCurrentItem()
973
+ }
974
+ } else {
975
+ // Snap back if swipe wasn't far enough
976
+ snapToCurrentItem()
977
+ }
978
+
979
+ e.preventDefault()
980
+ }
981
+
982
+ // Mouse drag handlers for desktop testing
983
+ function handleMouseDown(e: MouseEvent) {
984
+ if (!useSwipeMode.value) return
985
+ isDragging.value = true
986
+ dragStartY.value = e.clientY
987
+ dragStartOffset.value = swipeOffset.value
988
+ e.preventDefault()
989
+ }
990
+
991
+ function handleMouseMove(e: MouseEvent) {
992
+ if (!useSwipeMode.value || !isDragging.value) return
993
+ const deltaY = e.clientY - dragStartY.value
994
+ swipeOffset.value = dragStartOffset.value + deltaY
995
+ e.preventDefault()
996
+ }
997
+
998
+ function handleMouseUp(e: MouseEvent) {
999
+ if (!useSwipeMode.value || !isDragging.value) return
1000
+ isDragging.value = false
1001
+
1002
+ const deltaY = swipeOffset.value - dragStartOffset.value
1003
+ const threshold = 100
1004
+
1005
+ if (Math.abs(deltaY) > threshold) {
1006
+ if (deltaY > 0 && previousItem.value) {
1007
+ goToPreviousItem()
1008
+ } else if (deltaY < 0 && nextItem.value) {
1009
+ goToNextItem()
1010
+ } else {
1011
+ snapToCurrentItem()
1012
+ }
1013
+ } else {
1014
+ snapToCurrentItem()
1015
+ }
1016
+
1017
+ e.preventDefault()
1018
+ }
1019
+
1020
+ function goToNextItem() {
1021
+ if (!nextItem.value) {
1022
+ // Try to load next page
1023
+ loadNext()
1024
+ return
1025
+ }
1026
+
1027
+ currentSwipeIndex.value++
1028
+ snapToCurrentItem()
1029
+
1030
+ // Preload next item if we're near the end
1031
+ if (currentSwipeIndex.value >= masonry.value.length - 5) {
1032
+ loadNext()
1033
+ }
1034
+ }
1035
+
1036
+ function goToPreviousItem() {
1037
+ if (!previousItem.value) return
1038
+
1039
+ currentSwipeIndex.value--
1040
+ snapToCurrentItem()
1041
+ }
1042
+
1043
+ function snapToCurrentItem() {
1044
+ if (!swipeContainer.value) return
1045
+
1046
+ // Use container height for swipe mode instead of window height
1047
+ const viewportHeight = swipeContainer.value.clientHeight
1048
+ swipeOffset.value = -currentSwipeIndex.value * viewportHeight
1049
+ }
1050
+
1051
+ // Watch for container/window resize to update swipe mode
1052
+ // Note: containerWidth is updated by ResizeObserver, not here
1053
+ function handleWindowResize() {
1054
+ // If switching from swipe to masonry, reset swipe state
1055
+ if (!useSwipeMode.value && currentSwipeIndex.value > 0) {
1056
+ currentSwipeIndex.value = 0
1057
+ swipeOffset.value = 0
1058
+ }
1059
+
1060
+ // If switching to swipe mode, ensure we have items loaded
1061
+ if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
1062
+ loadPage(paginationHistory.value[0] as any)
1063
+ }
1064
+
1065
+ // Re-snap to current item on resize to adjust offset
1066
+ if (useSwipeMode.value) {
1067
+ snapToCurrentItem()
1068
+ }
1069
+ }
1070
+
1071
+ function init(items: any[], page: any, next: any) {
1072
+ currentPage.value = page // Track the initial current page
1073
+ paginationHistory.value = [page]
1074
+ paginationHistory.value.push(next)
1075
+ // Update hasReachedEnd if next is null
1076
+ hasReachedEnd.value = next == null
1077
+ // Diagnostics: check incoming initial items
1078
+ checkItemDimensions(items as any[], 'init')
1079
+
1080
+ if (useSwipeMode.value) {
1081
+ // In swipe mode, just add items without layout calculation
1082
+ masonry.value = [...(masonry.value as any[]), ...items]
1083
+ // Reset swipe index if we're at the start
1084
+ if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
1085
+ swipeOffset.value = 0
1086
+ }
1087
+ } else {
1088
+ refreshLayout([...(masonry.value as any[]), ...items])
1089
+ updateScrollProgress()
1090
+ }
1091
+ }
1092
+
1093
+ // Watch for layout changes and update columns + refresh layout dynamically
1094
+ watch(
1095
+ layout,
1096
+ () => {
1097
+ if (useSwipeMode.value) {
1098
+ // In swipe mode, no layout recalculation needed
1099
+ return
1100
+ }
1101
+ if (container.value) {
1102
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
1103
+ refreshLayout(masonry.value as any)
1104
+ }
1105
+ },
1106
+ { deep: true }
1107
+ )
1108
+
1109
+ // Watch for layout-mode prop changes to ensure proper mode switching
1110
+ watch(() => props.layoutMode, () => {
1111
+ // Force update containerWidth when layout-mode changes to ensure useSwipeMode computes correctly
1112
+ if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
1113
+ containerWidth.value = fixedDimensions.value.width
1114
+ } else if (wrapper.value) {
1115
+ containerWidth.value = wrapper.value.clientWidth
1116
+ }
1117
+ })
1118
+
1119
+ // Watch container element to attach scroll listener when available
1120
+ watch(container, (el) => {
1121
+ if (el && !useSwipeMode.value) {
1122
+ // Attach scroll listener for masonry mode
1123
+ el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1124
+ el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1125
+ } else if (el) {
1126
+ // Remove scroll listener if switching to swipe mode
1127
+ el.removeEventListener('scroll', debouncedScrollHandler)
1128
+ }
1129
+ }, { immediate: true })
1130
+
1131
+ // Watch for swipe mode changes to refresh layout and setup/teardown handlers
1132
+ watch(useSwipeMode, (newValue, oldValue) => {
1133
+ // Skip if this is the initial watch call and values are the same
1134
+ if (oldValue === undefined && newValue === false) return
1135
+
1136
+ nextTick(() => {
1137
+ if (newValue) {
1138
+ // Switching to Swipe Mode
1139
+ document.addEventListener('mousemove', handleMouseMove)
1140
+ document.addEventListener('mouseup', handleMouseUp)
1141
+
1142
+ // Remove scroll listener
1143
+ if (container.value) {
1144
+ container.value.removeEventListener('scroll', debouncedScrollHandler)
1145
+ }
1146
+
1147
+ // Reset index if needed
1148
+ currentSwipeIndex.value = 0
1149
+ swipeOffset.value = 0
1150
+ if (masonry.value.length > 0) {
1151
+ snapToCurrentItem()
1152
+ }
1153
+ } else {
1154
+ // Switching to Masonry Mode
1155
+ document.removeEventListener('mousemove', handleMouseMove)
1156
+ document.removeEventListener('mouseup', handleMouseUp)
1157
+
1158
+ if (container.value && wrapper.value) {
1159
+ // Ensure containerWidth is up to date - use fixed dimensions if set
1160
+ if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
1161
+ containerWidth.value = fixedDimensions.value.width
1162
+ } else {
1163
+ containerWidth.value = wrapper.value.clientWidth
1164
+ }
1165
+
1166
+ // Attach scroll listener (container watcher will handle this, but ensure it's attached)
1167
+ container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1168
+ container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1169
+
1170
+ // Refresh layout with updated width
1171
+ if (masonry.value.length > 0) {
1172
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
1173
+ refreshLayout(masonry.value as any)
1174
+
1175
+ // Update viewport state
1176
+ viewportTop.value = container.value.scrollTop
1177
+ viewportHeight.value = container.value.clientHeight
1178
+ updateScrollProgress()
1179
+ }
1180
+ }
1181
+ }
1182
+ })
1183
+ }, { immediate: true })
1184
+
1185
+ // Watch for swipe container element to attach touch listeners
1186
+ watch(swipeContainer, (el) => {
1187
+ if (el) {
1188
+ el.addEventListener('touchstart', handleTouchStart, { passive: false })
1189
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
1190
+ el.addEventListener('touchend', handleTouchEnd)
1191
+ el.addEventListener('mousedown', handleMouseDown)
1192
+ }
1193
+ })
1194
+
1195
+ // Watch for items changes in swipe mode to reset index if needed
1196
+ watch(() => masonry.value.length, (newLength, oldLength) => {
1197
+ if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
1198
+ // First items loaded, ensure we're at index 0
1199
+ currentSwipeIndex.value = 0
1200
+ nextTick(() => snapToCurrentItem())
1201
+ }
1202
+ })
1203
+
1204
+ // Watch wrapper element to setup ResizeObserver for container width
1205
+ watch(wrapper, (el) => {
1206
+ if (resizeObserver) {
1207
+ resizeObserver.disconnect()
1208
+ resizeObserver = null
1209
+ }
1210
+
1211
+ if (el && typeof ResizeObserver !== 'undefined') {
1212
+ resizeObserver = new ResizeObserver((entries) => {
1213
+ // Skip updates if fixed dimensions are set
1214
+ if (fixedDimensions.value) return
1215
+
1216
+ for (const entry of entries) {
1217
+ const newWidth = entry.contentRect.width
1218
+ const newHeight = entry.contentRect.height
1219
+ if (containerWidth.value !== newWidth) {
1220
+ containerWidth.value = newWidth
1221
+ }
1222
+ if (containerHeight.value !== newHeight) {
1223
+ containerHeight.value = newHeight
1224
+ }
1225
+ }
1226
+ })
1227
+ resizeObserver.observe(el)
1228
+ // Initial dimensions (only if not fixed)
1229
+ if (!fixedDimensions.value) {
1230
+ containerWidth.value = el.clientWidth
1231
+ containerHeight.value = el.clientHeight
1232
+ }
1233
+ } else if (el) {
1234
+ // Fallback if ResizeObserver not available
1235
+ if (!fixedDimensions.value) {
1236
+ containerWidth.value = el.clientWidth
1237
+ containerHeight.value = el.clientHeight
1238
+ }
1239
+ }
1240
+ }, { immediate: true })
1241
+
1242
+ // Watch containerWidth changes to refresh layout in masonry mode
1243
+ watch(containerWidth, (newWidth, oldWidth) => {
1244
+ if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
1245
+ // Use nextTick to ensure DOM has updated
1246
+ nextTick(() => {
1247
+ columns.value = getColumnCount(layout.value as any, newWidth)
1248
+ refreshLayout(masonry.value as any)
1249
+ updateScrollProgress()
1250
+ })
1251
+ }
1252
+ })
1253
+
1254
+ onMounted(async () => {
1255
+ try {
1256
+ // Wait for next tick to ensure wrapper is mounted
1257
+ await nextTick()
1258
+
1259
+ // Container dimensions are managed by ResizeObserver
1260
+ // Only set initial values if ResizeObserver isn't available
1261
+ if (wrapper.value && !resizeObserver) {
1262
+ containerWidth.value = wrapper.value.clientWidth
1263
+ containerHeight.value = wrapper.value.clientHeight
1264
+ }
1265
+
1266
+ if (!useSwipeMode.value) {
1267
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
1268
+ if (container.value) {
1269
+ viewportTop.value = container.value.scrollTop
1270
+ viewportHeight.value = container.value.clientHeight
1271
+ }
1272
+ }
1273
+
1274
+ const initialPage = props.loadAtPage as any
1275
+ paginationHistory.value = [initialPage]
1276
+
1277
+ if (!props.skipInitialLoad) {
1278
+ await loadPage(paginationHistory.value[0] as any)
1279
+ }
1280
+
1281
+ if (!useSwipeMode.value) {
1282
+ updateScrollProgress()
1283
+ } else {
1284
+ // In swipe mode, snap to first item
1285
+ nextTick(() => snapToCurrentItem())
1286
+ }
1287
+
1288
+ } catch (error) {
1289
+ console.error('Error during component initialization:', error)
1290
+ isLoading.value = false
1291
+ }
1292
+
1293
+ // Scroll listener is handled by watcher now for consistency
1294
+ window.addEventListener('resize', debouncedResizeHandler)
1295
+ window.addEventListener('resize', handleWindowResize)
1296
+ })
1297
+
1298
+ onUnmounted(() => {
1299
+ if (resizeObserver) {
1300
+ resizeObserver.disconnect()
1301
+ resizeObserver = null
1302
+ }
1303
+
1304
+ container.value?.removeEventListener('scroll', debouncedScrollHandler)
1305
+ window.removeEventListener('resize', debouncedResizeHandler)
1306
+ window.removeEventListener('resize', handleWindowResize)
1307
+
1308
+ if (swipeContainer.value) {
1309
+ swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
1310
+ swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
1311
+ swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
1312
+ swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
1313
+ }
1314
+
1315
+ // Clean up mouse handlers
1316
+ document.removeEventListener('mousemove', handleMouseMove)
1317
+ document.removeEventListener('mouseup', handleMouseUp)
1318
+ })
1319
+ </script>
1320
+
1321
+ <template>
1322
+ <div ref="wrapper" class="w-full h-full flex flex-col relative">
1323
+ <!-- Swipe Feed Mode (Mobile/Tablet) -->
1324
+ <div v-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
1325
+ :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
1326
+ ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
1327
+ <div class="relative w-full" :style="{
1328
+ transform: `translateY(${swipeOffset}px)`,
1329
+ transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
1330
+ height: `${masonry.length * 100}%`
1331
+ }">
1332
+ <div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
1333
+ :style="{
1334
+ top: `${index * (100 / masonry.length)}%`,
1335
+ height: `${100 / masonry.length}%`
1336
+ }">
1337
+ <div class="w-full h-full flex items-center justify-center p-4">
1338
+ <div class="w-full h-full max-w-full max-h-full relative">
1339
+ <slot :item="item" :remove="remove">
1340
+ <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1341
+ :in-swipe-mode="true" :is-active="index === currentSwipeIndex"
1342
+ @preload:success="(p) => emits('item:preload:success', p)"
1343
+ @preload:error="(p) => emits('item:preload:error', p)"
1344
+ @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
1345
+ <!-- Pass through header and footer slots to MasonryItem -->
1346
+ <template #header="slotProps">
1347
+ <slot name="item-header" v-bind="slotProps" />
1348
+ </template>
1349
+ <template #footer="slotProps">
1350
+ <slot name="item-footer" v-bind="slotProps" />
1351
+ </template>
1352
+ </MasonryItem>
1353
+ </slot>
1354
+ </div>
1355
+ </div>
1356
+ </div>
1357
+ </div>
1358
+ <!-- End of list message for swipe mode -->
1359
+ <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
1360
+ <slot name="end-message">
1361
+ <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
1362
+ </slot>
1363
+ </div>
1364
+ <!-- Error message for swipe mode -->
1365
+ <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
1366
+ <slot name="error-message" :error="loadError">
1367
+ <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1368
+ </slot>
1369
+ </div>
1370
+ </div>
1371
+
1372
+ <!-- Masonry Grid Mode (Desktop) -->
1373
+ <div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
1374
+ ref="container">
1375
+ <div class="relative"
1376
+ :style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
1377
+ <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
1378
+ @before-leave="beforeLeave">
1379
+ <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
1380
+ v-bind="getItemAttributes(item, i)">
1381
+ <!-- Use default slot if provided, otherwise use MasonryItem -->
1382
+ <slot :item="item" :remove="remove">
1383
+ <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1384
+ :in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
1385
+ @preload:error="(p) => emits('item:preload:error', p)"
1386
+ @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
1387
+ <!-- Pass through header and footer slots to MasonryItem -->
1388
+ <template #header="slotProps">
1389
+ <slot name="item-header" v-bind="slotProps" />
1390
+ </template>
1391
+ <template #footer="slotProps">
1392
+ <slot name="item-footer" v-bind="slotProps" />
1393
+ </template>
1394
+ </MasonryItem>
1395
+ </slot>
1396
+ </div>
1397
+ </transition-group>
1398
+ </div>
1399
+ <!-- End of list message -->
1400
+ <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
1401
+ <slot name="end-message">
1402
+ <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
1403
+ </slot>
1404
+ </div>
1405
+ <!-- Error message -->
1406
+ <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
1407
+ <slot name="error-message" :error="loadError">
1408
+ <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1409
+ </slot>
1410
+ </div>
1411
+ </div>
1412
+ </div>
1413
+ </template>
1414
+
1415
+ <style scoped>
1416
+ .masonry-container {
1417
+ overflow-anchor: none;
1418
+ }
1419
+
1420
+ .masonry-item {
1421
+ will-change: transform, opacity;
1422
+ contain: layout paint;
1423
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
1424
+ opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1425
+ backface-visibility: hidden;
1426
+ }
1427
+
1428
+ .masonry-move {
1429
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
1430
+ }
1431
+
1432
+ @media (prefers-reduced-motion: reduce) {
1433
+
1434
+ .masonry-container:not(.force-motion) .masonry-item,
1435
+ .masonry-container:not(.force-motion) .masonry-move {
1436
+ transition-duration: 1ms !important;
1437
+ }
1438
+ }
1439
+ </style>