@wyxos/vibe 1.6.21 → 1.6.23

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