@wyxos/vibe 1.6.14 → 1.6.16

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,1233 +1,1317 @@
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>
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>