@wyxos/vibe 1.6.21 → 1.6.22

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