@wyxos/vibe 1.6.20 → 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,1566 +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
303
- const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
304
-
305
- // Transition wrappers that skip animation during virtualization
306
- function enter(el: HTMLElement, done: () => void) {
307
- if (virtualizing.value) {
308
- const left = parseInt(el.dataset.left || '0', 10)
309
- const top = parseInt(el.dataset.top || '0', 10)
310
- el.style.transition = 'none'
311
- el.style.opacity = '1'
312
- el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
313
- el.style.removeProperty('--masonry-opacity-delay')
314
- requestAnimationFrame(() => {
315
- el.style.transition = ''
316
- done()
317
- })
318
- } else {
319
- onEnter(el, done)
320
- }
321
- }
322
- function beforeEnter(el: HTMLElement) {
323
- if (virtualizing.value) {
324
- const left = parseInt(el.dataset.left || '0', 10)
325
- const top = parseInt(el.dataset.top || '0', 10)
326
- el.style.transition = 'none'
327
- el.style.opacity = '1'
328
- el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
329
- el.style.removeProperty('--masonry-opacity-delay')
330
- } else {
331
- onBeforeEnter(el)
332
- }
333
- }
334
- function beforeLeave(el: HTMLElement) {
335
- if (virtualizing.value) {
336
- // no-op; removal will be immediate in leave
337
- } else {
338
- onBeforeLeave(el)
339
- }
340
- }
341
- function leave(el: HTMLElement, done: () => void) {
342
- if (virtualizing.value) {
343
- // Skip animation during virtualization
344
- done()
345
- } else {
346
- onLeave(el, done)
347
- }
348
- }
349
-
350
- // Visible window of items (virtualization)
351
- const visibleMasonry = computed(() => {
352
- const top = viewportTop.value - VIRTUAL_BUFFER_PX
353
- const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
354
- const items = masonry.value as any[]
355
- if (!items || items.length === 0) return [] as any[]
356
- return items.filter((it: any) => {
357
- const itemTop = it.top
358
- const itemBottom = it.top + it.columnHeight
359
- return itemBottom >= top && itemTop <= bottom
360
- })
361
- })
362
-
363
- const { handleScroll } = useMasonryScroll({
364
- container,
365
- masonry: masonry as any,
366
- columns,
367
- containerHeight: masonryContentHeight,
368
- isLoading,
369
- pageSize: props.pageSize,
370
- refreshLayout,
371
- setItemsRaw: (items: any[]) => {
372
- masonry.value = items
373
- },
374
- loadNext,
375
- loadThresholdPx: props.loadThresholdPx
376
- })
377
-
378
- function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
379
- fixedDimensions.value = dimensions
380
- if (dimensions) {
381
- if (dimensions.width !== undefined) containerWidth.value = dimensions.width
382
- if (dimensions.height !== undefined) containerHeight.value = dimensions.height
383
- // Force layout refresh when dimensions change
384
- if (!useSwipeMode.value && container.value && masonry.value.length > 0) {
385
- nextTick(() => {
386
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
387
- refreshLayout(masonry.value as any)
388
- updateScrollProgress()
389
- })
390
- }
391
- } else {
392
- // When clearing fixed dimensions, restore from wrapper
393
- if (wrapper.value) {
394
- containerWidth.value = wrapper.value.clientWidth
395
- containerHeight.value = wrapper.value.clientHeight
396
- }
397
- }
398
- }
399
-
400
- defineExpose({
401
- isLoading,
402
- refreshLayout,
403
- // Container dimensions (wrapper element)
404
- containerWidth,
405
- containerHeight,
406
- // Masonry content height (for backward compatibility, old containerHeight)
407
- contentHeight: masonryContentHeight,
408
- // Current page
409
- currentPage,
410
- // End of list tracking
411
- hasReachedEnd,
412
- // Load error tracking
413
- loadError,
414
- // Set fixed dimensions (overrides ResizeObserver)
415
- setFixedDimensions,
416
- remove,
417
- removeMany,
418
- removeAll,
419
- restore,
420
- restoreMany,
421
- loadNext,
422
- loadPage,
423
- refreshCurrentPage,
424
- reset,
425
- destroy,
426
- init,
427
- paginationHistory,
428
- cancelLoad,
429
- scrollToTop,
430
- totalItems: computed(() => (masonry.value as any[]).length),
431
- currentBreakpoint
432
- })
433
-
434
- function calculateHeight(content: any[]) {
435
- const newHeight = calculateContainerHeight(content as any)
436
- let floor = 0
437
- if (container.value) {
438
- const { scrollTop, clientHeight } = container.value
439
- floor = scrollTop + clientHeight + 100
440
- }
441
- masonryContentHeight.value = Math.max(newHeight, floor)
442
- }
443
-
444
- function refreshLayout(items: any[]) {
445
- if (useSwipeMode.value) {
446
- // In swipe mode, no layout calculation needed - items are stacked vertically
447
- masonry.value = items as any
448
- return
449
- }
450
-
451
- if (!container.value) return
452
- // Developer diagnostics: warn when dimensions are invalid
453
- checkItemDimensions(items as any[], 'refreshLayout')
454
- // Update original index to reflect current position in array
455
- // This ensures indices are correct after items are removed
456
- const itemsWithIndex = items.map((item, index) => ({
457
- ...item,
458
- originalIndex: index
459
- }))
460
-
461
- // When fixed dimensions are set, ensure container uses the fixed width for layout
462
- // This prevents gaps when the container's actual width differs from the fixed width
463
- const containerEl = container.value as HTMLElement
464
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
465
- // Temporarily set width to match fixed dimensions for accurate layout calculation
466
- const originalWidth = containerEl.style.width
467
- const originalBoxSizing = containerEl.style.boxSizing
468
- containerEl.style.boxSizing = 'border-box'
469
- containerEl.style.width = `${fixedDimensions.value.width}px`
470
- // Force reflow
471
- containerEl.offsetWidth
472
-
473
- const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
474
-
475
- // Restore original width
476
- containerEl.style.width = originalWidth
477
- containerEl.style.boxSizing = originalBoxSizing
478
-
479
- calculateHeight(content as any)
480
- masonry.value = content
481
- } else {
482
- const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
483
- calculateHeight(content as any)
484
- masonry.value = content
485
- }
486
- }
487
-
488
- function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
489
- return new Promise<void>((resolve) => {
490
- const total = Math.max(0, totalMs | 0)
491
- const start = Date.now()
492
- onTick(total, total)
493
- const id = setInterval(() => {
494
- // Check for cancellation
495
- if (cancelRequested.value) {
496
- clearInterval(id)
497
- resolve()
498
- return
499
- }
500
- const elapsed = Date.now() - start
501
- const remaining = Math.max(0, total - elapsed)
502
- onTick(remaining, total)
503
- if (remaining <= 0) {
504
- clearInterval(id)
505
- resolve()
506
- }
507
- }, 100)
508
- })
509
- }
510
-
511
- async function getContent(page: number) {
512
- try {
513
- const response = await fetchWithRetry(() => props.getNextPage(page))
514
- refreshLayout([...(masonry.value as any[]), ...response.items])
515
- return response
516
- } catch (error) {
517
- console.error('Error in getContent:', error)
518
- throw error
519
- }
520
- }
521
-
522
- async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
523
- let attempt = 0
524
- const max = props.retryMaxAttempts
525
- let delay = props.retryInitialDelayMs
526
- // eslint-disable-next-line no-constant-condition
527
- while (true) {
528
- try {
529
- const res = await fn()
530
- if (attempt > 0) {
531
- emits('retry:stop', { attempt, success: true })
532
- }
533
- return res
534
- } catch (err) {
535
- attempt++
536
- if (attempt > max) {
537
- emits('retry:stop', { attempt: attempt - 1, success: false })
538
- throw err
539
- }
540
- emits('retry:start', { attempt, max, totalMs: delay })
541
- await waitWithProgress(delay, (remaining, total) => {
542
- emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
543
- })
544
- delay += props.retryBackoffStepMs
545
- }
546
- }
547
- }
548
-
549
- async function loadPage(page: number) {
550
- if (isLoading.value) return
551
- // Starting a new load should clear any previous cancel request
552
- cancelRequested.value = false
553
- isLoading.value = true
554
- // Reset hasReachedEnd and loadError when loading a new page
555
- hasReachedEnd.value = false
556
- loadError.value = null
557
- try {
558
- const baseline = (masonry.value as any[]).length
559
- if (cancelRequested.value) return
560
- const response = await getContent(page)
561
- if (cancelRequested.value) return
562
- // Clear error on successful load
563
- loadError.value = null
564
- currentPage.value = page // Track the current page
565
- paginationHistory.value.push(response.nextPage)
566
- // Update hasReachedEnd if nextPage is null
567
- if (response.nextPage == null) {
568
- hasReachedEnd.value = true
569
- }
570
- await maybeBackfillToTarget(baseline)
571
- return response
572
- } catch (error) {
573
- console.error('Error loading page:', error)
574
- // Set load error
575
- loadError.value = error instanceof Error ? error : new Error(String(error))
576
- throw error
577
- } finally {
578
- isLoading.value = false
579
- }
580
- }
581
-
582
- async function loadNext() {
583
- if (isLoading.value) return
584
- // Don't load if we've already reached the end
585
- if (hasReachedEnd.value) return
586
- // Starting a new load should clear any previous cancel request
587
- cancelRequested.value = false
588
- isLoading.value = true
589
- // Clear error when attempting to load
590
- loadError.value = null
591
- try {
592
- const baseline = (masonry.value as any[]).length
593
- if (cancelRequested.value) return
594
- const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
595
- // Don't load if nextPageToLoad is null
596
- if (nextPageToLoad == null) {
597
- hasReachedEnd.value = true
598
- isLoading.value = false
599
- return
600
- }
601
- const response = await getContent(nextPageToLoad)
602
- if (cancelRequested.value) return
603
- // Clear error on successful load
604
- loadError.value = null
605
- currentPage.value = nextPageToLoad // Track the current page
606
- paginationHistory.value.push(response.nextPage)
607
- // Update hasReachedEnd if nextPage is null
608
- if (response.nextPage == null) {
609
- hasReachedEnd.value = true
610
- }
611
- await maybeBackfillToTarget(baseline)
612
- return response
613
- } catch (error) {
614
- console.error('Error loading next page:', error)
615
- // Set load error
616
- loadError.value = error instanceof Error ? error : new Error(String(error))
617
- throw error
618
- } finally {
619
- isLoading.value = false
620
- }
621
- }
622
-
623
- /**
624
- * Refresh the current page by clearing items and reloading from current page
625
- * Useful when items are removed and you want to stay on the same page
626
- */
627
- async function refreshCurrentPage() {
628
- if (isLoading.value) return
629
- cancelRequested.value = false
630
- isLoading.value = true
631
-
632
- try {
633
- // Use the tracked current page
634
- const pageToRefresh = currentPage.value
635
-
636
- if (pageToRefresh == null) {
637
- console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
638
- return
639
- }
640
-
641
- // Clear existing items
642
- masonry.value = []
643
- masonryContentHeight.value = 0
644
- hasReachedEnd.value = false // Reset end flag when refreshing
645
- loadError.value = null // Reset error flag when refreshing
646
-
647
- // Reset pagination history to just the current page
648
- paginationHistory.value = [pageToRefresh]
649
-
650
- await nextTick()
651
-
652
- // Reload the current page
653
- const response = await getContent(pageToRefresh)
654
- if (cancelRequested.value) return
655
-
656
- // Clear error on successful load
657
- loadError.value = null
658
- // Update pagination state
659
- currentPage.value = pageToRefresh
660
- paginationHistory.value.push(response.nextPage)
661
- // Update hasReachedEnd if nextPage is null
662
- if (response.nextPage == null) {
663
- hasReachedEnd.value = true
664
- }
665
-
666
- // Optionally backfill if needed
667
- const baseline = (masonry.value as any[]).length
668
- await maybeBackfillToTarget(baseline)
669
-
670
- return response
671
- } catch (error) {
672
- console.error('[Masonry] Error refreshing current page:', error)
673
- // Set load error
674
- loadError.value = error instanceof Error ? error : new Error(String(error))
675
- throw error
676
- } finally {
677
- isLoading.value = false
678
- }
679
- }
680
-
681
- async function remove(item: any) {
682
- const next = (masonry.value as any[]).filter(i => i.id !== item.id)
683
- masonry.value = next
684
- await nextTick()
685
-
686
- // If all items were removed, either refresh current page or load next based on prop
687
- if (next.length === 0 && paginationHistory.value.length > 0) {
688
- if (props.autoRefreshOnEmpty) {
689
- await refreshCurrentPage()
690
- } else {
691
- try {
692
- await loadNext()
693
- // Force backfill from 0 to ensure viewport is filled
694
- // Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
695
- await maybeBackfillToTarget(0, true)
696
- } catch { }
697
- }
698
- return
699
- }
700
-
701
- // Commit DOM updates without forcing sync reflow
702
- await new Promise<void>(r => requestAnimationFrame(() => r()))
703
- // Start FLIP on next frame
704
- requestAnimationFrame(() => {
705
- refreshLayout(next)
706
- })
707
- }
708
-
709
- async function removeMany(items: any[]) {
710
- if (!items || items.length === 0) return
711
- const ids = new Set(items.map(i => i.id))
712
- const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
713
- masonry.value = next
714
- await nextTick()
715
-
716
- // If all items were removed, either refresh current page or load next based on prop
717
- if (next.length === 0 && paginationHistory.value.length > 0) {
718
- if (props.autoRefreshOnEmpty) {
719
- await refreshCurrentPage()
720
- } else {
721
- try {
722
- await loadNext()
723
- // Force backfill from 0 to ensure viewport is filled
724
- await maybeBackfillToTarget(0, true)
725
- } catch { }
726
- }
727
- return
728
- }
729
-
730
- // Commit DOM updates without forcing sync reflow
731
- await new Promise<void>(r => requestAnimationFrame(() => r()))
732
- // Start FLIP on next frame
733
- requestAnimationFrame(() => {
734
- refreshLayout(next)
735
- })
736
- }
737
-
738
- /**
739
- * Restore a single item at its original index.
740
- * This is useful for undo operations where an item needs to be restored to its exact position.
741
- * Handles all index calculation and layout recalculation internally.
742
- * @param item - Item to restore
743
- * @param index - Original index of the item
744
- */
745
- async function restore(item: any, index: number) {
746
- if (!item) return
747
-
748
- const current = masonry.value as any[]
749
- const existingIndex = current.findIndex(i => i.id === item.id)
750
- if (existingIndex !== -1) return // Item already exists
751
-
752
- // Insert at the original index (clamped to valid range)
753
- const newItems = [...current]
754
- const targetIndex = Math.min(index, newItems.length)
755
- newItems.splice(targetIndex, 0, item)
756
-
757
- // Update the masonry array
758
- masonry.value = newItems
759
- await nextTick()
760
-
761
- // Trigger layout recalculation (same pattern as remove)
762
- if (!useSwipeMode.value) {
763
- // Commit DOM updates without forcing sync reflow
764
- await new Promise<void>(r => requestAnimationFrame(() => r()))
765
- // Start FLIP on next frame
766
- requestAnimationFrame(() => {
767
- refreshLayout(newItems)
768
- })
769
- }
770
- }
771
-
772
- /**
773
- * Restore multiple items at their original indices.
774
- * This is useful for undo operations where items need to be restored to their exact positions.
775
- * Handles all index calculation and layout recalculation internally.
776
- * @param items - Array of items to restore
777
- * @param indices - Array of original indices for each item (must match items array length)
778
- */
779
- async function restoreMany(items: any[], indices: number[]) {
780
- if (!items || items.length === 0) return
781
- if (!indices || indices.length !== items.length) {
782
- console.warn('[Masonry] restoreMany: items and indices arrays must have the same length')
783
- return
784
- }
785
-
786
- const current = masonry.value as any[]
787
- const existingIds = new Set(current.map(i => i.id))
788
-
789
- // Filter out items that already exist and pair with their indices
790
- const itemsToRestore: Array<{ item: any; index: number }> = []
791
- for (let i = 0; i < items.length; i++) {
792
- if (!existingIds.has(items[i]?.id)) {
793
- itemsToRestore.push({ item: items[i], index: indices[i] })
794
- }
795
- }
796
-
797
- if (itemsToRestore.length === 0) return
798
-
799
- // Build the final array by merging current items and restored items
800
- // Strategy: Build position by position - for each position, decide if it should be
801
- // a restored item (at its original index) or a current item (accounting for shifts)
802
-
803
- // Create a map of restored items by their original index for O(1) lookup
804
- const restoredByIndex = new Map<number, any>()
805
- for (const { item, index } of itemsToRestore) {
806
- restoredByIndex.set(index, item)
807
- }
808
-
809
- // Find the maximum position we need to consider
810
- const maxRestoredIndex = itemsToRestore.length > 0
811
- ? Math.max(...itemsToRestore.map(({ index }) => index))
812
- : -1
813
- const maxPosition = Math.max(current.length - 1, maxRestoredIndex)
814
-
815
- // Build the final array position by position
816
- // Key insight: Current array items are in "shifted" positions (missing the removed items).
817
- // When we restore items at their original positions, current items naturally shift back.
818
- // We can build the final array by iterating positions and using items sequentially.
819
- const newItems: any[] = []
820
- let currentArrayIndex = 0 // Track which current item we should use next
821
-
822
- // Iterate through all positions up to the maximum we need
823
- for (let position = 0; position <= maxPosition; position++) {
824
- // If there's a restored item that belongs at this position, use it
825
- if (restoredByIndex.has(position)) {
826
- newItems.push(restoredByIndex.get(position)!)
827
- } else {
828
- // Otherwise, this position should be filled by the next current item
829
- // Since current array is missing restored items, items are shifted left.
830
- // By using them sequentially, they naturally end up in the correct positions.
831
- if (currentArrayIndex < current.length) {
832
- newItems.push(current[currentArrayIndex])
833
- currentArrayIndex++
834
- }
835
- }
836
- }
837
-
838
- // Add any remaining current items that come after the last restored position
839
- // (These are items that were originally after maxRestoredIndex)
840
- while (currentArrayIndex < current.length) {
841
- newItems.push(current[currentArrayIndex])
842
- currentArrayIndex++
843
- }
844
-
845
- // Update the masonry array
846
- masonry.value = newItems
847
- await nextTick()
848
-
849
- // Trigger layout recalculation (same pattern as removeMany)
850
- if (!useSwipeMode.value) {
851
- // Commit DOM updates without forcing sync reflow
852
- await new Promise<void>(r => requestAnimationFrame(() => r()))
853
- // Start FLIP on next frame
854
- requestAnimationFrame(() => {
855
- refreshLayout(newItems)
856
- })
857
- }
858
- }
859
-
860
- function scrollToTop(options?: ScrollToOptions) {
861
- if (container.value) {
862
- container.value.scrollTo({
863
- top: 0,
864
- behavior: options?.behavior ?? 'smooth',
865
- ...options
866
- })
867
- }
868
- }
869
-
870
- async function removeAll() {
871
- // Scroll to top first for better UX
872
- scrollToTop({ behavior: 'smooth' })
873
-
874
- // Clear all items
875
- masonry.value = []
876
-
877
- // Recalculate height to 0
878
- containerHeight.value = 0
879
-
880
- await nextTick()
881
-
882
- // Emit completion event
883
- emits('remove-all:complete')
884
- }
885
-
886
- function onResize() {
887
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
888
- refreshLayout(masonry.value as any)
889
- if (container.value) {
890
- viewportTop.value = container.value.scrollTop
891
- viewportHeight.value = container.value.clientHeight
892
- }
893
- }
894
-
895
- let backfillActive = false
896
- const cancelRequested = ref(false)
897
-
898
- async function maybeBackfillToTarget(baselineCount: number, force = false) {
899
- if (!force && !props.backfillEnabled) return
900
- if (backfillActive) return
901
- if (cancelRequested.value) return
902
- // Don't backfill if we've reached the end
903
- if (hasReachedEnd.value) return
904
-
905
- const targetCount = (baselineCount || 0) + (props.pageSize || 0)
906
- if (!props.pageSize || props.pageSize <= 0) return
907
-
908
- const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
909
- if (lastNext == null) {
910
- hasReachedEnd.value = true
911
- return
912
- }
913
-
914
- if ((masonry.value as any[]).length >= targetCount) return
915
-
916
- backfillActive = true
917
- // Set loading to true at the start of backfill and keep it true throughout
918
- isLoading.value = true
919
- try {
920
- let calls = 0
921
- emits('backfill:start', { target: targetCount, fetched: (masonry.value as any[]).length, calls })
922
-
923
- while (
924
- (masonry.value as any[]).length < targetCount &&
925
- calls < props.backfillMaxCalls &&
926
- paginationHistory.value[paginationHistory.value.length - 1] != null &&
927
- !cancelRequested.value &&
928
- !hasReachedEnd.value
929
- ) {
930
- await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
931
- emits('backfill:tick', {
932
- fetched: (masonry.value as any[]).length,
933
- target: targetCount,
934
- calls,
935
- remainingMs: remaining,
936
- totalMs: total
937
- })
938
- })
939
-
940
- if (cancelRequested.value) break
941
-
942
- const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
943
- if (currentPage == null) {
944
- hasReachedEnd.value = true
945
- break
946
- }
947
- try {
948
- // Don't toggle isLoading here - keep it true throughout backfill
949
- const response = await getContent(currentPage)
950
- if (cancelRequested.value) break
951
- // Clear error on successful load
952
- loadError.value = null
953
- paginationHistory.value.push(response.nextPage)
954
- // Update hasReachedEnd if nextPage is null
955
- if (response.nextPage == null) {
956
- hasReachedEnd.value = true
957
- }
958
- } catch (error) {
959
- // Set load error but don't break the backfill loop
960
- loadError.value = error instanceof Error ? error : new Error(String(error))
961
- }
962
-
963
- calls++
964
- }
965
-
966
- emits('backfill:stop', { fetched: (masonry.value as any[]).length, calls })
967
- } finally {
968
- backfillActive = false
969
- // Only set loading to false when backfill completes
970
- isLoading.value = false
971
- }
972
- }
973
-
974
- function cancelLoad() {
975
- cancelRequested.value = true
976
- isLoading.value = false
977
- backfillActive = false
978
- }
979
-
980
- function reset() {
981
- // Cancel ongoing work, then immediately clear cancel so new loads can start
982
- cancelLoad()
983
- cancelRequested.value = false
984
- if (container.value) {
985
- container.value.scrollTo({
986
- top: 0,
987
- behavior: 'smooth'
988
- })
989
- }
990
-
991
- masonry.value = []
992
- containerHeight.value = 0
993
- currentPage.value = props.loadAtPage // Reset current page tracking
994
- paginationHistory.value = [props.loadAtPage]
995
- hasReachedEnd.value = false // Reset end flag
996
- loadError.value = null // Reset error flag
997
-
998
- scrollProgress.value = {
999
- distanceToTrigger: 0,
1000
- isNearTrigger: false
1001
- }
1002
- }
1003
-
1004
- function destroy() {
1005
- // Cancel any ongoing loads
1006
- cancelLoad()
1007
-
1008
- // Reset all state
1009
- masonry.value = []
1010
- masonryContentHeight.value = 0
1011
- currentPage.value = null
1012
- paginationHistory.value = []
1013
- hasReachedEnd.value = false
1014
- loadError.value = null
1015
- isLoading.value = false
1016
- backfillActive = false
1017
- cancelRequested.value = false
1018
-
1019
- // Reset swipe mode state
1020
- currentSwipeIndex.value = 0
1021
- swipeOffset.value = 0
1022
- isDragging.value = false
1023
-
1024
- // Reset viewport state
1025
- viewportTop.value = 0
1026
- viewportHeight.value = 0
1027
- virtualizing.value = false
1028
-
1029
- // Reset scroll progress
1030
- scrollProgress.value = {
1031
- distanceToTrigger: 0,
1032
- isNearTrigger: false
1033
- }
1034
-
1035
- // Reset invalid dimension tracking
1036
- invalidDimensionIds.value.clear()
1037
-
1038
- // Scroll to top if container exists
1039
- if (container.value) {
1040
- container.value.scrollTo({
1041
- top: 0,
1042
- behavior: 'auto' // Instant scroll for destroy
1043
- })
1044
- }
1045
- }
1046
-
1047
- const debouncedScrollHandler = debounce(async () => {
1048
- if (useSwipeMode.value) return // Skip scroll handling in swipe mode
1049
-
1050
- if (container.value) {
1051
- viewportTop.value = container.value.scrollTop
1052
- viewportHeight.value = container.value.clientHeight
1053
- }
1054
- // Gate transitions for virtualization-only DOM changes
1055
- virtualizing.value = true
1056
- await nextTick()
1057
- await new Promise<void>(r => requestAnimationFrame(() => r()))
1058
- virtualizing.value = false
1059
-
1060
- const heights = calculateColumnHeights(masonry.value as any, columns.value)
1061
- handleScroll(heights as any)
1062
- updateScrollProgress(heights)
1063
- }, 200)
1064
-
1065
- const debouncedResizeHandler = debounce(onResize, 200)
1066
-
1067
- // Swipe gesture handlers
1068
- function handleTouchStart(e: TouchEvent) {
1069
- if (!useSwipeMode.value) return
1070
- isDragging.value = true
1071
- dragStartY.value = e.touches[0].clientY
1072
- dragStartOffset.value = swipeOffset.value
1073
- e.preventDefault()
1074
- }
1075
-
1076
- function handleTouchMove(e: TouchEvent) {
1077
- if (!useSwipeMode.value || !isDragging.value) return
1078
- const deltaY = e.touches[0].clientY - dragStartY.value
1079
- swipeOffset.value = dragStartOffset.value + deltaY
1080
- e.preventDefault()
1081
- }
1082
-
1083
- function handleTouchEnd(e: TouchEvent) {
1084
- if (!useSwipeMode.value || !isDragging.value) return
1085
- isDragging.value = false
1086
-
1087
- const deltaY = swipeOffset.value - dragStartOffset.value
1088
- const threshold = 100 // Minimum swipe distance to trigger navigation
1089
-
1090
- if (Math.abs(deltaY) > threshold) {
1091
- if (deltaY > 0 && previousItem.value) {
1092
- // Swipe down - go to previous
1093
- goToPreviousItem()
1094
- } else if (deltaY < 0 && nextItem.value) {
1095
- // Swipe up - go to next
1096
- goToNextItem()
1097
- } else {
1098
- // Snap back
1099
- snapToCurrentItem()
1100
- }
1101
- } else {
1102
- // Snap back if swipe wasn't far enough
1103
- snapToCurrentItem()
1104
- }
1105
-
1106
- e.preventDefault()
1107
- }
1108
-
1109
- // Mouse drag handlers for desktop testing
1110
- function handleMouseDown(e: MouseEvent) {
1111
- if (!useSwipeMode.value) return
1112
- isDragging.value = true
1113
- dragStartY.value = e.clientY
1114
- dragStartOffset.value = swipeOffset.value
1115
- e.preventDefault()
1116
- }
1117
-
1118
- function handleMouseMove(e: MouseEvent) {
1119
- if (!useSwipeMode.value || !isDragging.value) return
1120
- const deltaY = e.clientY - dragStartY.value
1121
- swipeOffset.value = dragStartOffset.value + deltaY
1122
- e.preventDefault()
1123
- }
1124
-
1125
- function handleMouseUp(e: MouseEvent) {
1126
- if (!useSwipeMode.value || !isDragging.value) return
1127
- isDragging.value = false
1128
-
1129
- const deltaY = swipeOffset.value - dragStartOffset.value
1130
- const threshold = 100
1131
-
1132
- if (Math.abs(deltaY) > threshold) {
1133
- if (deltaY > 0 && previousItem.value) {
1134
- goToPreviousItem()
1135
- } else if (deltaY < 0 && nextItem.value) {
1136
- goToNextItem()
1137
- } else {
1138
- snapToCurrentItem()
1139
- }
1140
- } else {
1141
- snapToCurrentItem()
1142
- }
1143
-
1144
- e.preventDefault()
1145
- }
1146
-
1147
- function goToNextItem() {
1148
- if (!nextItem.value) {
1149
- // Try to load next page
1150
- loadNext()
1151
- return
1152
- }
1153
-
1154
- currentSwipeIndex.value++
1155
- snapToCurrentItem()
1156
-
1157
- // Preload next item if we're near the end
1158
- if (currentSwipeIndex.value >= masonry.value.length - 5) {
1159
- loadNext()
1160
- }
1161
- }
1162
-
1163
- function goToPreviousItem() {
1164
- if (!previousItem.value) return
1165
-
1166
- currentSwipeIndex.value--
1167
- snapToCurrentItem()
1168
- }
1169
-
1170
- function snapToCurrentItem() {
1171
- if (!swipeContainer.value) return
1172
-
1173
- // Use container height for swipe mode instead of window height
1174
- const viewportHeight = swipeContainer.value.clientHeight
1175
- swipeOffset.value = -currentSwipeIndex.value * viewportHeight
1176
- }
1177
-
1178
- // Watch for container/window resize to update swipe mode
1179
- // Note: containerWidth is updated by ResizeObserver, not here
1180
- function handleWindowResize() {
1181
- // If switching from swipe to masonry, reset swipe state
1182
- if (!useSwipeMode.value && currentSwipeIndex.value > 0) {
1183
- currentSwipeIndex.value = 0
1184
- swipeOffset.value = 0
1185
- }
1186
-
1187
- // If switching to swipe mode, ensure we have items loaded
1188
- if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
1189
- loadPage(paginationHistory.value[0] as any)
1190
- }
1191
-
1192
- // Re-snap to current item on resize to adjust offset
1193
- if (useSwipeMode.value) {
1194
- snapToCurrentItem()
1195
- }
1196
- }
1197
-
1198
- function init(items: any[], page: any, next: any) {
1199
- currentPage.value = page // Track the initial current page
1200
- paginationHistory.value = [page]
1201
- paginationHistory.value.push(next)
1202
- // Update hasReachedEnd if next is null
1203
- hasReachedEnd.value = next == null
1204
- // Diagnostics: check incoming initial items
1205
- checkItemDimensions(items as any[], 'init')
1206
-
1207
- if (useSwipeMode.value) {
1208
- // In swipe mode, just add items without layout calculation
1209
- masonry.value = [...(masonry.value as any[]), ...items]
1210
- // Reset swipe index if we're at the start
1211
- if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
1212
- swipeOffset.value = 0
1213
- }
1214
- } else {
1215
- refreshLayout([...(masonry.value as any[]), ...items])
1216
- updateScrollProgress()
1217
- }
1218
- }
1219
-
1220
- // Watch for layout changes and update columns + refresh layout dynamically
1221
- watch(
1222
- layout,
1223
- () => {
1224
- if (useSwipeMode.value) {
1225
- // In swipe mode, no layout recalculation needed
1226
- return
1227
- }
1228
- if (container.value) {
1229
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
1230
- refreshLayout(masonry.value as any)
1231
- }
1232
- },
1233
- { deep: true }
1234
- )
1235
-
1236
- // Watch for layout-mode prop changes to ensure proper mode switching
1237
- watch(() => props.layoutMode, () => {
1238
- // Force update containerWidth when layout-mode changes to ensure useSwipeMode computes correctly
1239
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
1240
- containerWidth.value = fixedDimensions.value.width
1241
- } else if (wrapper.value) {
1242
- containerWidth.value = wrapper.value.clientWidth
1243
- }
1244
- })
1245
-
1246
- // Watch container element to attach scroll listener when available
1247
- watch(container, (el) => {
1248
- if (el && !useSwipeMode.value) {
1249
- // Attach scroll listener for masonry mode
1250
- el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1251
- el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1252
- } else if (el) {
1253
- // Remove scroll listener if switching to swipe mode
1254
- el.removeEventListener('scroll', debouncedScrollHandler)
1255
- }
1256
- }, { immediate: true })
1257
-
1258
- // Watch for swipe mode changes to refresh layout and setup/teardown handlers
1259
- watch(useSwipeMode, (newValue, oldValue) => {
1260
- // Skip if this is the initial watch call and values are the same
1261
- if (oldValue === undefined && newValue === false) return
1262
-
1263
- nextTick(() => {
1264
- if (newValue) {
1265
- // Switching to Swipe Mode
1266
- document.addEventListener('mousemove', handleMouseMove)
1267
- document.addEventListener('mouseup', handleMouseUp)
1268
-
1269
- // Remove scroll listener
1270
- if (container.value) {
1271
- container.value.removeEventListener('scroll', debouncedScrollHandler)
1272
- }
1273
-
1274
- // Reset index if needed
1275
- currentSwipeIndex.value = 0
1276
- swipeOffset.value = 0
1277
- if (masonry.value.length > 0) {
1278
- snapToCurrentItem()
1279
- }
1280
- } else {
1281
- // Switching to Masonry Mode
1282
- document.removeEventListener('mousemove', handleMouseMove)
1283
- document.removeEventListener('mouseup', handleMouseUp)
1284
-
1285
- if (container.value && wrapper.value) {
1286
- // Ensure containerWidth is up to date - use fixed dimensions if set
1287
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
1288
- containerWidth.value = fixedDimensions.value.width
1289
- } else {
1290
- containerWidth.value = wrapper.value.clientWidth
1291
- }
1292
-
1293
- // Attach scroll listener (container watcher will handle this, but ensure it's attached)
1294
- container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
1295
- container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
1296
-
1297
- // Refresh layout with updated width
1298
- if (masonry.value.length > 0) {
1299
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
1300
- refreshLayout(masonry.value as any)
1301
-
1302
- // Update viewport state
1303
- viewportTop.value = container.value.scrollTop
1304
- viewportHeight.value = container.value.clientHeight
1305
- updateScrollProgress()
1306
- }
1307
- }
1308
- }
1309
- })
1310
- }, { immediate: true })
1311
-
1312
- // Watch for swipe container element to attach touch listeners
1313
- watch(swipeContainer, (el) => {
1314
- if (el) {
1315
- el.addEventListener('touchstart', handleTouchStart, { passive: false })
1316
- el.addEventListener('touchmove', handleTouchMove, { passive: false })
1317
- el.addEventListener('touchend', handleTouchEnd)
1318
- el.addEventListener('mousedown', handleMouseDown)
1319
- }
1320
- })
1321
-
1322
- // Watch for items changes in swipe mode to reset index if needed
1323
- watch(() => masonry.value.length, (newLength, oldLength) => {
1324
- if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
1325
- // First items loaded, ensure we're at index 0
1326
- currentSwipeIndex.value = 0
1327
- nextTick(() => snapToCurrentItem())
1328
- }
1329
- })
1330
-
1331
- // Watch wrapper element to setup ResizeObserver for container width
1332
- watch(wrapper, (el) => {
1333
- if (resizeObserver) {
1334
- resizeObserver.disconnect()
1335
- resizeObserver = null
1336
- }
1337
-
1338
- if (el && typeof ResizeObserver !== 'undefined') {
1339
- resizeObserver = new ResizeObserver((entries) => {
1340
- // Skip updates if fixed dimensions are set
1341
- if (fixedDimensions.value) return
1342
-
1343
- for (const entry of entries) {
1344
- const newWidth = entry.contentRect.width
1345
- const newHeight = entry.contentRect.height
1346
- if (containerWidth.value !== newWidth) {
1347
- containerWidth.value = newWidth
1348
- }
1349
- if (containerHeight.value !== newHeight) {
1350
- containerHeight.value = newHeight
1351
- }
1352
- }
1353
- })
1354
- resizeObserver.observe(el)
1355
- // Initial dimensions (only if not fixed)
1356
- if (!fixedDimensions.value) {
1357
- containerWidth.value = el.clientWidth
1358
- containerHeight.value = el.clientHeight
1359
- }
1360
- } else if (el) {
1361
- // Fallback if ResizeObserver not available
1362
- if (!fixedDimensions.value) {
1363
- containerWidth.value = el.clientWidth
1364
- containerHeight.value = el.clientHeight
1365
- }
1366
- }
1367
- }, { immediate: true })
1368
-
1369
- // Watch containerWidth changes to refresh layout in masonry mode
1370
- watch(containerWidth, (newWidth, oldWidth) => {
1371
- if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
1372
- // Use nextTick to ensure DOM has updated
1373
- nextTick(() => {
1374
- columns.value = getColumnCount(layout.value as any, newWidth)
1375
- refreshLayout(masonry.value as any)
1376
- updateScrollProgress()
1377
- })
1378
- }
1379
- })
1380
-
1381
- onMounted(async () => {
1382
- try {
1383
- // Wait for next tick to ensure wrapper is mounted
1384
- await nextTick()
1385
-
1386
- // Container dimensions are managed by ResizeObserver
1387
- // Only set initial values if ResizeObserver isn't available
1388
- if (wrapper.value && !resizeObserver) {
1389
- containerWidth.value = wrapper.value.clientWidth
1390
- containerHeight.value = wrapper.value.clientHeight
1391
- }
1392
-
1393
- if (!useSwipeMode.value) {
1394
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
1395
- if (container.value) {
1396
- viewportTop.value = container.value.scrollTop
1397
- viewportHeight.value = container.value.clientHeight
1398
- }
1399
- }
1400
-
1401
- const initialPage = props.loadAtPage as any
1402
- paginationHistory.value = [initialPage]
1403
-
1404
- if (!props.skipInitialLoad) {
1405
- await loadPage(paginationHistory.value[0] as any)
1406
- }
1407
-
1408
- if (!useSwipeMode.value) {
1409
- updateScrollProgress()
1410
- } else {
1411
- // In swipe mode, snap to first item
1412
- nextTick(() => snapToCurrentItem())
1413
- }
1414
-
1415
- } catch (error) {
1416
- console.error('Error during component initialization:', error)
1417
- isLoading.value = false
1418
- }
1419
-
1420
- // Scroll listener is handled by watcher now for consistency
1421
- window.addEventListener('resize', debouncedResizeHandler)
1422
- window.addEventListener('resize', handleWindowResize)
1423
- })
1424
-
1425
- onUnmounted(() => {
1426
- if (resizeObserver) {
1427
- resizeObserver.disconnect()
1428
- resizeObserver = null
1429
- }
1430
-
1431
- container.value?.removeEventListener('scroll', debouncedScrollHandler)
1432
- window.removeEventListener('resize', debouncedResizeHandler)
1433
- window.removeEventListener('resize', handleWindowResize)
1434
-
1435
- if (swipeContainer.value) {
1436
- swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
1437
- swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
1438
- swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
1439
- swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
1440
- }
1441
-
1442
- // Clean up mouse handlers
1443
- document.removeEventListener('mousemove', handleMouseMove)
1444
- document.removeEventListener('mouseup', handleMouseUp)
1445
- })
1446
- </script>
1447
-
1448
- <template>
1449
- <div ref="wrapper" class="w-full h-full flex flex-col relative">
1450
- <!-- Swipe Feed Mode (Mobile/Tablet) -->
1451
- <div v-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
1452
- :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
1453
- ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
1454
- <div class="relative w-full" :style="{
1455
- transform: `translateY(${swipeOffset}px)`,
1456
- transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
1457
- height: `${masonry.length * 100}%`
1458
- }">
1459
- <div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
1460
- :style="{
1461
- top: `${index * (100 / masonry.length)}%`,
1462
- height: `${100 / masonry.length}%`
1463
- }">
1464
- <div class="w-full h-full flex items-center justify-center p-4">
1465
- <div class="w-full h-full max-w-full max-h-full relative">
1466
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? props.items.indexOf(item)">
1467
- <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1468
- :in-swipe-mode="true" :is-active="index === currentSwipeIndex"
1469
- @preload:success="(p) => emits('item:preload:success', p)"
1470
- @preload:error="(p) => emits('item:preload:error', p)"
1471
- @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
1472
- <!-- Pass through header and footer slots to MasonryItem -->
1473
- <template #header="slotProps">
1474
- <slot name="item-header" v-bind="slotProps" />
1475
- </template>
1476
- <template #footer="slotProps">
1477
- <slot name="item-footer" v-bind="slotProps" />
1478
- </template>
1479
- </MasonryItem>
1480
- </slot>
1481
- </div>
1482
- </div>
1483
- </div>
1484
- </div>
1485
- <!-- End of list message for swipe mode -->
1486
- <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
1487
- <slot name="end-message">
1488
- <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
1489
- </slot>
1490
- </div>
1491
- <!-- Error message for swipe mode -->
1492
- <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
1493
- <slot name="error-message" :error="loadError">
1494
- <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1495
- </slot>
1496
- </div>
1497
- </div>
1498
-
1499
- <!-- Masonry Grid Mode (Desktop) -->
1500
- <div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
1501
- ref="container">
1502
- <div class="relative"
1503
- :style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
1504
- <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
1505
- @before-leave="beforeLeave">
1506
- <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
1507
- v-bind="getItemAttributes(item, i)">
1508
- <!-- Use default slot if provided, otherwise use MasonryItem -->
1509
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? items.indexOf(item)">
1510
- <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1511
- :in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
1512
- @preload:error="(p) => emits('item:preload:error', p)"
1513
- @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
1514
- <!-- Pass through header and footer slots to MasonryItem -->
1515
- <template #header="slotProps">
1516
- <slot name="item-header" v-bind="slotProps" />
1517
- </template>
1518
- <template #footer="slotProps">
1519
- <slot name="item-footer" v-bind="slotProps" />
1520
- </template>
1521
- </MasonryItem>
1522
- </slot>
1523
- </div>
1524
- </transition-group>
1525
- </div>
1526
- <!-- End of list message -->
1527
- <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
1528
- <slot name="end-message">
1529
- <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
1530
- </slot>
1531
- </div>
1532
- <!-- Error message -->
1533
- <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
1534
- <slot name="error-message" :error="loadError">
1535
- <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1536
- </slot>
1537
- </div>
1538
- </div>
1539
- </div>
1540
- </template>
1541
-
1542
- <style scoped>
1543
- .masonry-container {
1544
- overflow-anchor: none;
1545
- }
1546
-
1547
- .masonry-item {
1548
- will-change: transform, opacity;
1549
- contain: layout paint;
1550
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
1551
- opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1552
- backface-visibility: hidden;
1553
- }
1554
-
1555
- .masonry-move {
1556
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
1557
- }
1558
-
1559
- @media (prefers-reduced-motion: reduce) {
1560
-
1561
- .masonry-container:not(.force-motion) .masonry-item,
1562
- .masonry-container:not(.force-motion) .masonry-move {
1563
- transition-duration: 1ms !important;
1564
- }
1565
- }
1566
- </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>