@wyxos/vibe 1.6.13 → 1.6.14

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