@wyxos/vibe 1.6.16 → 1.6.17

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,1317 +1,1334 @@
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 for swipe mode changes to refresh layout and setup/teardown handlers
1008
- watch(useSwipeMode, (newValue, oldValue) => {
1009
- // Skip if this is the initial watch call and values are the same
1010
- if (oldValue === undefined && newValue === false) return
1011
-
1012
- nextTick(() => {
1013
- if (newValue) {
1014
- // Switching to Swipe Mode
1015
- document.addEventListener('mousemove', handleMouseMove)
1016
- document.addEventListener('mouseup', handleMouseUp)
1017
-
1018
- // Reset index if needed
1019
- currentSwipeIndex.value = 0
1020
- swipeOffset.value = 0
1021
- if (masonry.value.length > 0) {
1022
- snapToCurrentItem()
1023
- }
1024
- } else {
1025
- // Switching to Masonry Mode
1026
- document.removeEventListener('mousemove', handleMouseMove)
1027
- document.removeEventListener('mouseup', handleMouseUp)
1028
-
1029
- if (container.value && wrapper.value) {
1030
- // Ensure containerWidth is up to date - use fixed dimensions if set
1031
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
1032
- containerWidth.value = fixedDimensions.value.width
1033
- } else {
1034
- containerWidth.value = wrapper.value.clientWidth
1035
- }
1036
-
1037
- // Re-attach scroll listener since container was re-created
1038
- container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1039
- container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1040
-
1041
- // Refresh layout with updated width
1042
- if (masonry.value.length > 0) {
1043
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
1044
- refreshLayout(masonry.value as any)
1045
-
1046
- // Update viewport state
1047
- viewportTop.value = container.value.scrollTop
1048
- viewportHeight.value = container.value.clientHeight
1049
- updateScrollProgress()
1050
- }
1051
- }
1052
- }
1053
- })
1054
- }, { immediate: true })
1055
-
1056
- // Watch for swipe container element to attach touch listeners
1057
- watch(swipeContainer, (el) => {
1058
- if (el) {
1059
- el.addEventListener('touchstart', handleTouchStart, { passive: false })
1060
- el.addEventListener('touchmove', handleTouchMove, { passive: false })
1061
- el.addEventListener('touchend', handleTouchEnd)
1062
- el.addEventListener('mousedown', handleMouseDown)
1063
- }
1064
- })
1065
-
1066
- // Watch for items changes in swipe mode to reset index if needed
1067
- watch(() => masonry.value.length, (newLength, oldLength) => {
1068
- if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
1069
- // First items loaded, ensure we're at index 0
1070
- currentSwipeIndex.value = 0
1071
- nextTick(() => snapToCurrentItem())
1072
- }
1073
- })
1074
-
1075
- // Watch wrapper element to setup ResizeObserver for container width
1076
- watch(wrapper, (el) => {
1077
- if (resizeObserver) {
1078
- resizeObserver.disconnect()
1079
- resizeObserver = null
1080
- }
1081
-
1082
- if (el && typeof ResizeObserver !== 'undefined') {
1083
- resizeObserver = new ResizeObserver((entries) => {
1084
- // Skip updates if fixed dimensions are set
1085
- if (fixedDimensions.value) return
1086
-
1087
- for (const entry of entries) {
1088
- const newWidth = entry.contentRect.width
1089
- const newHeight = entry.contentRect.height
1090
- if (containerWidth.value !== newWidth) {
1091
- containerWidth.value = newWidth
1092
- }
1093
- if (containerHeight.value !== newHeight) {
1094
- containerHeight.value = newHeight
1095
- }
1096
- }
1097
- })
1098
- resizeObserver.observe(el)
1099
- // Initial dimensions (only if not fixed)
1100
- if (!fixedDimensions.value) {
1101
- containerWidth.value = el.clientWidth
1102
- containerHeight.value = el.clientHeight
1103
- }
1104
- } else if (el) {
1105
- // Fallback if ResizeObserver not available
1106
- if (!fixedDimensions.value) {
1107
- containerWidth.value = el.clientWidth
1108
- containerHeight.value = el.clientHeight
1109
- }
1110
- }
1111
- }, { immediate: true })
1112
-
1113
- // Watch containerWidth changes to refresh layout in masonry mode
1114
- watch(containerWidth, (newWidth, oldWidth) => {
1115
- if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
1116
- // Use nextTick to ensure DOM has updated
1117
- nextTick(() => {
1118
- columns.value = getColumnCount(layout.value as any, newWidth)
1119
- refreshLayout(masonry.value as any)
1120
- updateScrollProgress()
1121
- })
1122
- }
1123
- })
1124
-
1125
- onMounted(async () => {
1126
- try {
1127
- // Wait for next tick to ensure wrapper is mounted
1128
- await nextTick()
1129
-
1130
- // Container dimensions are managed by ResizeObserver
1131
- // Only set initial values if ResizeObserver isn't available
1132
- if (wrapper.value && !resizeObserver) {
1133
- containerWidth.value = wrapper.value.clientWidth
1134
- containerHeight.value = wrapper.value.clientHeight
1135
- }
1136
-
1137
- if (!useSwipeMode.value) {
1138
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
1139
- if (container.value) {
1140
- viewportTop.value = container.value.scrollTop
1141
- viewportHeight.value = container.value.clientHeight
1142
- }
1143
- }
1144
-
1145
- const initialPage = props.loadAtPage as any
1146
- paginationHistory.value = [initialPage]
1147
-
1148
- if (!props.skipInitialLoad) {
1149
- await loadPage(paginationHistory.value[0] as any)
1150
- }
1151
-
1152
- if (!useSwipeMode.value) {
1153
- updateScrollProgress()
1154
- } else {
1155
- // In swipe mode, snap to first item
1156
- nextTick(() => snapToCurrentItem())
1157
- }
1158
-
1159
- } catch (error) {
1160
- console.error('Error during component initialization:', error)
1161
- isLoading.value = false
1162
- }
1163
-
1164
- // Scroll listener is handled by watcher now for consistency
1165
- window.addEventListener('resize', debouncedResizeHandler)
1166
- window.addEventListener('resize', handleWindowResize)
1167
- })
1168
-
1169
- onUnmounted(() => {
1170
- if (resizeObserver) {
1171
- resizeObserver.disconnect()
1172
- resizeObserver = null
1173
- }
1174
-
1175
- container.value?.removeEventListener('scroll', debouncedScrollHandler)
1176
- window.removeEventListener('resize', debouncedResizeHandler)
1177
- window.removeEventListener('resize', handleWindowResize)
1178
-
1179
- if (swipeContainer.value) {
1180
- swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
1181
- swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
1182
- swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
1183
- swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
1184
- }
1185
-
1186
- // Clean up mouse handlers
1187
- document.removeEventListener('mousemove', handleMouseMove)
1188
- document.removeEventListener('mouseup', handleMouseUp)
1189
- })
1190
- </script>
1191
-
1192
- <template>
1193
- <div ref="wrapper" class="w-full h-full flex flex-col relative">
1194
- <!-- Swipe Feed Mode (Mobile/Tablet) -->
1195
- <div v-if="useSwipeMode"
1196
- class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
1197
- :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
1198
- ref="swipeContainer"
1199
- style="height: 100%; max-height: 100%; position: relative;">
1200
- <div
1201
- class="relative w-full"
1202
- :style="{
1203
- transform: `translateY(${swipeOffset}px)`,
1204
- transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
1205
- height: `${masonry.length * 100}%`
1206
- }">
1207
- <div
1208
- v-for="(item, index) in masonry"
1209
- :key="`${item.page}-${item.id}`"
1210
- class="absolute top-0 left-0 w-full"
1211
- :style="{
1212
- top: `${index * (100 / masonry.length)}%`,
1213
- height: `${100 / masonry.length}%`
1214
- }">
1215
- <div class="w-full h-full flex items-center justify-center p-4">
1216
- <div class="w-full h-full max-w-full max-h-full relative">
1217
- <slot :item="item" :remove="remove">
1218
- <MasonryItem
1219
- :item="item"
1220
- :remove="remove"
1221
- :header-height="layout.header"
1222
- :footer-height="layout.footer"
1223
- :in-swipe-mode="true"
1224
- :is-active="index === currentSwipeIndex"
1225
- @preload:success="(p) => emits('item:preload:success', p)"
1226
- @preload:error="(p) => emits('item:preload:error', p)"
1227
- @mouse-enter="(p) => emits('item:mouse-enter', p)"
1228
- @mouse-leave="(p) => emits('item:mouse-leave', p)"
1229
- >
1230
- <!-- Pass through header and footer slots to MasonryItem -->
1231
- <template #header="slotProps">
1232
- <slot name="item-header" v-bind="slotProps" />
1233
- </template>
1234
- <template #footer="slotProps">
1235
- <slot name="item-footer" v-bind="slotProps" />
1236
- </template>
1237
- </MasonryItem>
1238
- </slot>
1239
- </div>
1240
- </div>
1241
- </div>
1242
- </div>
1243
-
1244
-
1245
-
1246
-
1247
- </div>
1248
-
1249
- <!-- Masonry Grid Mode (Desktop) -->
1250
- <div v-else
1251
- class="overflow-auto w-full flex-1 masonry-container"
1252
- :class="{ 'force-motion': props.forceMotion }"
1253
- ref="container">
1254
- <div class="relative"
1255
- :style="{height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
1256
- <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
1257
- @leave="leave"
1258
- @before-leave="beforeLeave">
1259
- <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`"
1260
- class="absolute masonry-item"
1261
- v-bind="getItemAttributes(item, i)">
1262
- <!-- Use default slot if provided, otherwise use MasonryItem -->
1263
- <slot :item="item" :remove="remove">
1264
- <MasonryItem
1265
- :item="item"
1266
- :remove="remove"
1267
- :header-height="layout.header"
1268
- :footer-height="layout.footer"
1269
- :in-swipe-mode="false"
1270
- :is-active="false"
1271
- @preload:success="(p) => emits('item:preload:success', p)"
1272
- @preload:error="(p) => emits('item:preload:error', p)"
1273
- @mouse-enter="(p) => emits('item:mouse-enter', p)"
1274
- @mouse-leave="(p) => emits('item:mouse-leave', p)"
1275
- >
1276
- <!-- Pass through header and footer slots to MasonryItem -->
1277
- <template #header="slotProps">
1278
- <slot name="item-header" v-bind="slotProps" />
1279
- </template>
1280
- <template #footer="slotProps">
1281
- <slot name="item-footer" v-bind="slotProps" />
1282
- </template>
1283
- </MasonryItem>
1284
- </slot>
1285
- </div>
1286
- </transition-group>
1287
-
1288
-
1289
- </div>
1290
- </div>
1291
- </div>
1292
- </template>
1293
-
1294
- <style scoped>
1295
- .masonry-container {
1296
- overflow-anchor: none;
1297
- }
1298
-
1299
- .masonry-item {
1300
- will-change: transform, opacity;
1301
- contain: layout paint;
1302
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
1303
- opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1304
- backface-visibility: hidden;
1305
- }
1306
-
1307
- .masonry-move {
1308
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
1309
- }
1310
-
1311
- @media (prefers-reduced-motion: reduce) {
1312
- .masonry-container:not(.force-motion) .masonry-item,
1313
- .masonry-container:not(.force-motion) .masonry-move {
1314
- transition-duration: 1ms !important;
1315
- }
1316
- }
1317
- </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
+
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>