@wyxos/vibe 1.6.24 → 1.6.25

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,1014 +1,1037 @@
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
- // Initial pagination state when skipInitialLoad is true and items are provided
49
- initialPage: {
50
- type: [Number, String],
51
- default: null
52
- },
53
- initialNextPage: {
54
- type: [Number, String],
55
- default: null
56
- },
57
- pageSize: {
58
- type: Number,
59
- default: 40
60
- },
61
- // Backfill configuration
62
- backfillEnabled: {
63
- type: Boolean,
64
- default: true
65
- },
66
- backfillDelayMs: {
67
- type: Number,
68
- default: 2000
69
- },
70
- backfillMaxCalls: {
71
- type: Number,
72
- default: 10
73
- },
74
- // Retry configuration
75
- retryMaxAttempts: {
76
- type: Number,
77
- default: 3
78
- },
79
- retryInitialDelayMs: {
80
- type: Number,
81
- default: 2000
82
- },
83
- retryBackoffStepMs: {
84
- type: Number,
85
- default: 2000
86
- },
87
- transitionDurationMs: {
88
- type: Number,
89
- default: 450
90
- },
91
- // Shorter, snappier duration specifically for item removal (leave)
92
- leaveDurationMs: {
93
- type: Number,
94
- default: 160
95
- },
96
- transitionEasing: {
97
- type: String,
98
- default: 'cubic-bezier(.22,.61,.36,1)'
99
- },
100
- // Force motion even when user has reduced-motion enabled
101
- forceMotion: {
102
- type: Boolean,
103
- default: false
104
- },
105
- virtualBufferPx: {
106
- type: Number,
107
- default: 600
108
- },
109
- loadThresholdPx: {
110
- type: Number,
111
- default: 200
112
- },
113
- autoRefreshOnEmpty: {
114
- type: Boolean,
115
- default: false
116
- },
117
- // Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
118
- layoutMode: {
119
- type: String,
120
- default: 'auto',
121
- validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
122
- },
123
- // Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
124
- mobileBreakpoint: {
125
- type: [Number, String],
126
- default: 768 // 'md' breakpoint
127
- },
128
- })
129
-
130
- const defaultLayout = {
131
- sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
132
- gutterX: 10,
133
- gutterY: 10,
134
- header: 0,
135
- footer: 0,
136
- paddingLeft: 0,
137
- paddingRight: 0,
138
- placement: 'masonry'
139
- }
140
-
141
- const layout = computed(() => ({
142
- ...defaultLayout,
143
- ...props.layout,
144
- sizes: {
145
- ...defaultLayout.sizes,
146
- ...(props.layout?.sizes || {})
147
- }
148
- }))
149
-
150
- const wrapper = ref<HTMLElement | null>(null)
151
- const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
152
- const containerHeight = ref<number>(typeof window !== 'undefined' ? window.innerHeight : 768)
153
- const fixedDimensions = ref<{ width?: number; height?: number } | null>(null)
154
- let resizeObserver: ResizeObserver | null = null
155
-
156
- // Get breakpoint value from Tailwind breakpoint name
157
- function getBreakpointValue(breakpoint: string): number {
158
- const breakpoints: Record<string, number> = {
159
- 'sm': 640,
160
- 'md': 768,
161
- 'lg': 1024,
162
- 'xl': 1280,
163
- '2xl': 1536
164
- }
165
- return breakpoints[breakpoint] || 768
166
- }
167
-
168
- // Determine if we should use swipe mode
169
- const useSwipeMode = computed(() => {
170
- if (props.layoutMode === 'masonry') return false
171
- if (props.layoutMode === 'swipe') return true
172
-
173
- // Auto mode: check container width
174
- const breakpoint = typeof props.mobileBreakpoint === 'string'
175
- ? getBreakpointValue(props.mobileBreakpoint)
176
- : props.mobileBreakpoint
177
-
178
- return containerWidth.value < breakpoint
179
- })
180
-
181
-
182
- const emits = defineEmits([
183
- 'update:items',
184
- 'backfill:start',
185
- 'backfill:tick',
186
- 'backfill:stop',
187
- 'retry:start',
188
- 'retry:tick',
189
- 'retry:stop',
190
- 'remove-all:complete',
191
- // Re-emit item-level preload events from the default MasonryItem
192
- 'item:preload:success',
193
- 'item:preload:error',
194
- // Mouse events from MasonryItem content
195
- 'item:mouse-enter',
196
- 'item:mouse-leave'
197
- ])
198
-
199
- const masonry = computed<any>({
200
- get: () => props.items,
201
- set: (val) => emits('update:items', val)
202
- })
203
-
204
- const columns = ref<number>(7)
205
- const container = ref<HTMLElement | null>(null)
206
- const paginationHistory = ref<any[]>([])
207
- const currentPage = ref<any>(null) // Track the actual current page being displayed
208
- const isLoading = ref<boolean>(false)
209
- const masonryContentHeight = ref<number>(0)
210
- const hasReachedEnd = ref<boolean>(false) // Track when we've reached the last page
211
- const loadError = ref<Error | null>(null) // Track load errors
212
-
213
- // Current breakpoint
214
- const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
215
-
216
-
217
- // Initialize dimensions composable first (needed by layout composable)
218
- const dimensions = useMasonryDimensions({
219
- masonry: masonry as any
220
- })
221
-
222
- // Extract dimension checking function
223
- const { checkItemDimensions, invalidDimensionIds, reset: resetDimensions } = dimensions
224
-
225
- // Initialize layout composable (needs checkItemDimensions from dimensions composable)
226
- const layoutComposable = useMasonryLayout({
227
- masonry: masonry as any,
228
- useSwipeMode,
229
- container,
230
- columns,
231
- containerWidth,
232
- masonryContentHeight,
233
- layout,
234
- fixedDimensions,
235
- checkItemDimensions
236
- })
237
-
238
- // Extract layout functions
239
- const { refreshLayout, setFixedDimensions: setFixedDimensionsLayout, onResize: onResizeLayout } = layoutComposable
240
-
241
- // Initialize virtualization composable
242
- const virtualization = useMasonryVirtualization({
243
- masonry: masonry as any,
244
- container,
245
- columns,
246
- virtualBufferPx: props.virtualBufferPx,
247
- loadThresholdPx: props.loadThresholdPx,
248
- handleScroll: () => { } // Will be set after pagination is initialized
249
- })
250
-
251
- // Extract virtualization state and functions
252
- const { viewportTop, viewportHeight, virtualizing, scrollProgress, visibleMasonry, updateScrollProgress, updateViewport: updateViewportVirtualization, reset: resetVirtualization } = virtualization
253
-
254
- // Initialize transitions composable with virtualization support
255
- const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(
256
- { container, masonry: masonry as any },
257
- { leaveDurationMs: props.leaveDurationMs, virtualizing }
258
- )
259
-
260
- // Transition functions for template (wrapped to match expected signature)
261
- const enter = onEnter
262
- const beforeEnter = onBeforeEnter
263
- const beforeLeave = onBeforeLeave
264
- const leave = onLeave
265
-
266
- // Initialize pagination composable
267
- const pagination = useMasonryPagination({
268
- getNextPage: props.getNextPage as (page: any) => Promise<{ items: any[]; nextPage: any }>,
269
- masonry: masonry as any,
270
- isLoading,
271
- hasReachedEnd,
272
- loadError,
273
- currentPage,
274
- paginationHistory,
275
- refreshLayout,
276
- retryMaxAttempts: props.retryMaxAttempts,
277
- retryInitialDelayMs: props.retryInitialDelayMs,
278
- retryBackoffStepMs: props.retryBackoffStepMs,
279
- backfillEnabled: props.backfillEnabled,
280
- backfillDelayMs: props.backfillDelayMs,
281
- backfillMaxCalls: props.backfillMaxCalls,
282
- pageSize: props.pageSize,
283
- autoRefreshOnEmpty: props.autoRefreshOnEmpty,
284
- emits
285
- })
286
-
287
- // Extract pagination functions
288
- const { loadPage, loadNext, refreshCurrentPage, cancelLoad, maybeBackfillToTarget } = pagination
289
-
290
- // Initialize swipe mode composable (needs loadNext and loadPage from pagination)
291
- const swipeMode = useSwipeModeComposable({
292
- useSwipeMode,
293
- masonry: masonry as any,
294
- isLoading,
295
- loadNext,
296
- loadPage,
297
- paginationHistory
298
- })
299
-
300
- // Initialize scroll handler (needs loadNext from pagination)
301
- const { handleScroll } = useMasonryScroll({
302
- container,
303
- masonry: masonry as any,
304
- columns,
305
- containerHeight: masonryContentHeight,
306
- isLoading,
307
- pageSize: props.pageSize,
308
- refreshLayout,
309
- setItemsRaw: (items: any[]) => {
310
- masonry.value = items
311
- },
312
- loadNext,
313
- loadThresholdPx: props.loadThresholdPx
314
- })
315
-
316
- // Update virtualization handleScroll to use the scroll handler
317
- virtualization.handleScroll.value = handleScroll
318
-
319
- // Initialize items composable
320
- const items = useMasonryItems({
321
- masonry: masonry as any,
322
- useSwipeMode,
323
- refreshLayout,
324
- refreshCurrentPage,
325
- loadNext,
326
- maybeBackfillToTarget,
327
- autoRefreshOnEmpty: props.autoRefreshOnEmpty,
328
- paginationHistory
329
- })
330
-
331
- // Extract item management functions
332
- const { remove, removeMany, restore, restoreMany, removeAll: removeAllItems } = items
333
-
334
- // setFixedDimensions is now in useMasonryLayout composable
335
- // Wrapper function to maintain API compatibility and handle wrapper restoration
336
- function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
337
- setFixedDimensionsLayout(dimensions, updateScrollProgress)
338
- if (!dimensions && wrapper.value) {
339
- // When clearing fixed dimensions, restore from wrapper
340
- containerWidth.value = wrapper.value.clientWidth
341
- containerHeight.value = wrapper.value.clientHeight
342
- }
343
- }
344
-
345
- defineExpose({
346
- isLoading,
347
- refreshLayout,
348
- // Container dimensions (wrapper element)
349
- containerWidth,
350
- containerHeight,
351
- // Masonry content height (for backward compatibility, old containerHeight)
352
- contentHeight: masonryContentHeight,
353
- // Current page
354
- currentPage,
355
- // End of list tracking
356
- hasReachedEnd,
357
- // Load error tracking
358
- loadError,
359
- // Set fixed dimensions (overrides ResizeObserver)
360
- setFixedDimensions,
361
- remove,
362
- removeMany,
363
- removeAll: removeAllItems,
364
- restore,
365
- restoreMany,
366
- loadNext,
367
- loadPage,
368
- refreshCurrentPage,
369
- reset,
370
- destroy,
371
- init,
372
- restoreItems,
373
- paginationHistory,
374
- cancelLoad,
375
- scrollToTop,
376
- scrollTo,
377
- totalItems: computed(() => (masonry.value as any[]).length),
378
- currentBreakpoint
379
- })
380
-
381
- // Layout functions are now in useMasonryLayout composable
382
- // Removed: calculateHeight, refreshLayout - now from layoutComposable
383
-
384
- // Expose swipe mode computed values and state for template
385
- const currentItem = swipeMode.currentItem
386
- const nextItem = swipeMode.nextItem
387
- const previousItem = swipeMode.previousItem
388
- const currentSwipeIndex = swipeMode.currentSwipeIndex
389
- const swipeOffset = swipeMode.swipeOffset
390
- const isDragging = swipeMode.isDragging
391
- const swipeContainer = swipeMode.swipeContainer
392
-
393
- // Swipe gesture handlers (delegated to composable)
394
- const handleTouchStart = swipeMode.handleTouchStart
395
- const handleTouchMove = swipeMode.handleTouchMove
396
- const handleTouchEnd = swipeMode.handleTouchEnd
397
- const handleMouseDown = swipeMode.handleMouseDown
398
- const handleMouseMove = swipeMode.handleMouseMove
399
- const handleMouseUp = swipeMode.handleMouseUp
400
- const goToNextItem = swipeMode.goToNextItem
401
- const goToPreviousItem = swipeMode.goToPreviousItem
402
- const snapToCurrentItem = swipeMode.snapToCurrentItem
403
-
404
- // refreshCurrentPage is now in useMasonryPagination composable
405
-
406
- // Item management functions (remove, removeMany, restore, restoreMany, removeAll) are now in useMasonryItems composable
407
-
408
- function scrollToTop(options?: ScrollToOptions) {
409
- if (container.value) {
410
- container.value.scrollTo({
411
- top: 0,
412
- behavior: options?.behavior ?? 'smooth',
413
- ...options
414
- })
415
- }
416
- }
417
-
418
- function scrollTo(options: { top?: number; left?: number; behavior?: ScrollBehavior }) {
419
- if (container.value) {
420
- container.value.scrollTo({
421
- top: options.top ?? container.value.scrollTop,
422
- left: options.left ?? container.value.scrollLeft,
423
- behavior: options.behavior ?? 'auto',
424
- })
425
- // Update viewport state immediately after scrolling
426
- if (container.value) {
427
- viewportTop.value = container.value.scrollTop
428
- viewportHeight.value = container.value.clientHeight || window.innerHeight
429
- }
430
- }
431
- }
432
-
433
- // removeAll is now in useMasonryItems composable (removeAllItems)
434
-
435
- // onResize is now in useMasonryLayout composable (onResizeLayout)
436
- function onResize() {
437
- onResizeLayout()
438
- if (container.value) {
439
- viewportTop.value = container.value.scrollTop
440
- viewportHeight.value = container.value.clientHeight
441
- }
442
- }
443
-
444
- // maybeBackfillToTarget, cancelLoad are now in useMasonryPagination composable
445
- // Removed: backfillActive, cancelRequested - now internal to pagination composable
446
-
447
- function reset() {
448
- // Cancel ongoing work
449
- cancelLoad()
450
-
451
- if (container.value) {
452
- container.value.scrollTo({
453
- top: 0,
454
- behavior: 'smooth'
455
- })
456
- }
457
-
458
- masonry.value = []
459
- containerHeight.value = 0
460
- currentPage.value = props.loadAtPage // Reset current page tracking
461
- paginationHistory.value = [props.loadAtPage]
462
- hasReachedEnd.value = false // Reset end flag
463
- loadError.value = null // Reset error flag
464
-
465
- // Reset virtualization state
466
- resetVirtualization()
467
-
468
- // Reset auto-initialization flag so watcher can work again if needed
469
- hasInitializedWithItems = false
470
- }
471
-
472
- function destroy() {
473
- // Cancel any ongoing loads
474
- cancelLoad()
475
-
476
- // Reset all state
477
- masonry.value = []
478
- masonryContentHeight.value = 0
479
- currentPage.value = null
480
- paginationHistory.value = []
481
- hasReachedEnd.value = false
482
- loadError.value = null
483
- isLoading.value = false
484
-
485
- // Reset swipe mode state
486
- currentSwipeIndex.value = 0
487
- swipeOffset.value = 0
488
- isDragging.value = false
489
-
490
- // Reset virtualization state
491
- resetVirtualization()
492
-
493
- // Reset invalid dimension tracking
494
- resetDimensions()
495
-
496
- // Scroll to top if container exists
497
- if (container.value) {
498
- container.value.scrollTo({
499
- top: 0,
500
- behavior: 'auto' // Instant scroll for destroy
501
- })
502
- }
503
- }
504
-
505
- // Scroll handler is now handled by virtualization composable's updateViewport
506
- const debouncedScrollHandler = debounce(async () => {
507
- if (useSwipeMode.value) return // Skip scroll handling in swipe mode
508
- await updateViewportVirtualization()
509
- }, 200)
510
-
511
- const debouncedResizeHandler = debounce(onResize, 200)
512
-
513
- // Window resize handler (combines swipe and general resize logic)
514
- function handleWindowResize() {
515
- // Delegate swipe-specific resize handling
516
- swipeMode.handleWindowResize()
517
-
518
- // General resize handling (if needed)
519
- // Note: containerWidth is updated by ResizeObserver
520
- }
521
-
522
- function init(items: any[], page: any, next: any) {
523
- currentPage.value = page // Track the initial current page
524
- paginationHistory.value = [page]
525
- paginationHistory.value.push(next)
526
- // Update hasReachedEnd if next is null
527
- hasReachedEnd.value = next == null
528
- // Diagnostics: check incoming initial items
529
- checkItemDimensions(items as any[], 'init')
530
-
531
- if (useSwipeMode.value) {
532
- // In swipe mode, just add items without layout calculation
533
- masonry.value = [...(masonry.value as any[]), ...items]
534
- // Reset swipe index if we're at the start
535
- if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
536
- swipeOffset.value = 0
537
- }
538
- } else {
539
- refreshLayout([...(masonry.value as any[]), ...items])
540
-
541
- // Update viewport state from container's scroll position
542
- // Critical after refresh when browser may restore scroll position
543
- if (container.value) {
544
- viewportTop.value = container.value.scrollTop
545
- viewportHeight.value = container.value.clientHeight || window.innerHeight
546
- }
547
-
548
- // Update again after DOM updates to catch browser scroll restoration
549
- // The debounced scroll handler will also catch any scroll changes
550
- nextTick(() => {
551
- if (container.value) {
552
- viewportTop.value = container.value.scrollTop
553
- viewportHeight.value = container.value.clientHeight || window.innerHeight
554
- updateScrollProgress()
555
- }
556
- })
557
- }
558
- }
559
-
560
- /**
561
- * Restore items when skipInitialLoad is true.
562
- * This method should be called instead of directly assigning to v-model:items
563
- * when restoring items from saved state.
564
- * @param items - Items to restore
565
- * @param page - Current page number/cursor
566
- * @param next - Next page cursor (or null if at end)
567
- */
568
- async function restoreItems(items: any[], page: any, next: any) {
569
- // If skipInitialLoad is false, fall back to init behavior
570
- if (!props.skipInitialLoad) {
571
- init(items, page, next)
572
- return
573
- }
574
-
575
- // When skipInitialLoad is true, we need to restore items without triggering initial load
576
- currentPage.value = page
577
- paginationHistory.value = [page]
578
- if (next !== null && next !== undefined) {
579
- paginationHistory.value.push(next)
580
- }
581
- // Only set hasReachedEnd to true if next is explicitly null (end of list)
582
- // undefined means "unknown" - don't assume end of list
583
- hasReachedEnd.value = next === null
584
- loadError.value = null
585
-
586
- // Diagnostics: check incoming items
587
- checkItemDimensions(items as any[], 'restoreItems')
588
-
589
- // Set items directly (v-model will sync) and refresh layout
590
- // Follow the same pattern as init() and getContent()
591
- if (useSwipeMode.value) {
592
- // In swipe mode, just set items without layout calculation
593
- masonry.value = items
594
- // Reset swipe index if we're at the start
595
- if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
596
- swipeOffset.value = 0
597
- }
598
- } else {
599
- // In masonry mode, refresh layout with the restored items
600
- refreshLayout(items)
601
-
602
- // Update viewport state from container's scroll position
603
- if (container.value) {
604
- viewportTop.value = container.value.scrollTop
605
- viewportHeight.value = container.value.clientHeight || window.innerHeight
606
- }
607
-
608
- // Update again after DOM updates to catch browser scroll restoration
609
- await nextTick()
610
- if (container.value) {
611
- viewportTop.value = container.value.scrollTop
612
- viewportHeight.value = container.value.clientHeight || window.innerHeight
613
- updateScrollProgress()
614
- }
615
- }
616
- }
617
-
618
- // Watch for layout changes and update columns + refresh layout dynamically
619
- watch(
620
- layout,
621
- () => {
622
- if (useSwipeMode.value) {
623
- // In swipe mode, no layout recalculation needed
624
- return
625
- }
626
- if (container.value) {
627
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
628
- refreshLayout(masonry.value as any)
629
- }
630
- },
631
- { deep: true }
632
- )
633
-
634
- // Watch for layout-mode prop changes to ensure proper mode switching
635
- watch(() => props.layoutMode, () => {
636
- // Force update containerWidth when layout-mode changes to ensure useSwipeMode computes correctly
637
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
638
- containerWidth.value = fixedDimensions.value.width
639
- } else if (wrapper.value) {
640
- containerWidth.value = wrapper.value.clientWidth
641
- }
642
- })
643
-
644
- // Watch container element to attach scroll listener when available
645
- watch(container, (el) => {
646
- if (el && !useSwipeMode.value) {
647
- // Attach scroll listener for masonry mode
648
- el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
649
- el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
650
- } else if (el) {
651
- // Remove scroll listener if switching to swipe mode
652
- el.removeEventListener('scroll', debouncedScrollHandler)
653
- }
654
- }, { immediate: true })
655
-
656
- // Watch for items when skipInitialLoad is true to auto-initialize pagination state
657
- // This handles cases where items are provided after mount or updated externally
658
- let hasInitializedWithItems = false
659
- watch(
660
- () => [props.items, props.skipInitialLoad, props.initialPage, props.initialNextPage] as const,
661
- ([items, skipInitialLoad, initialPage, initialNextPage]) => {
662
- // Only auto-initialize if:
663
- // 1. skipInitialLoad is true
664
- // 2. Items exist
665
- // 3. We haven't already initialized with items (to avoid re-initializing on every update)
666
- if (
667
- skipInitialLoad &&
668
- items &&
669
- items.length > 0 &&
670
- !hasInitializedWithItems
671
- ) {
672
- hasInitializedWithItems = true
673
- const page = initialPage !== null && initialPage !== undefined
674
- ? initialPage
675
- : (props.loadAtPage as any)
676
- const next = initialNextPage !== undefined
677
- ? initialNextPage
678
- : undefined // undefined means "unknown", null means "end of list"
679
-
680
- restoreItems(items as any[], page, next)
681
- }
682
- },
683
- { immediate: false }
684
- )
685
-
686
- // Watch for swipe mode changes to refresh layout and setup/teardown handlers
687
- watch(useSwipeMode, (newValue, oldValue) => {
688
- // Skip if this is the initial watch call and values are the same
689
- if (oldValue === undefined && newValue === false) return
690
-
691
- nextTick(() => {
692
- if (newValue) {
693
- // Switching to Swipe Mode
694
- document.addEventListener('mousemove', handleMouseMove)
695
- document.addEventListener('mouseup', handleMouseUp)
696
-
697
- // Remove scroll listener
698
- if (container.value) {
699
- container.value.removeEventListener('scroll', debouncedScrollHandler)
700
- }
701
-
702
- // Reset index if needed
703
- currentSwipeIndex.value = 0
704
- swipeOffset.value = 0
705
- if (masonry.value.length > 0) {
706
- snapToCurrentItem()
707
- }
708
- } else {
709
- // Switching to Masonry Mode
710
- document.removeEventListener('mousemove', handleMouseMove)
711
- document.removeEventListener('mouseup', handleMouseUp)
712
-
713
- if (container.value && wrapper.value) {
714
- // Ensure containerWidth is up to date - use fixed dimensions if set
715
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
716
- containerWidth.value = fixedDimensions.value.width
717
- } else {
718
- containerWidth.value = wrapper.value.clientWidth
719
- }
720
-
721
- // Attach scroll listener (container watcher will handle this, but ensure it's attached)
722
- container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
723
- container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
724
-
725
- // Refresh layout with updated width
726
- if (masonry.value.length > 0) {
727
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
728
- refreshLayout(masonry.value as any)
729
-
730
- // Update viewport state
731
- viewportTop.value = container.value.scrollTop
732
- viewportHeight.value = container.value.clientHeight
733
- updateScrollProgress()
734
- }
735
- }
736
- }
737
- })
738
- }, { immediate: true })
739
-
740
- // Watch for swipe container element to attach touch listeners
741
- watch(swipeContainer, (el) => {
742
- if (el) {
743
- el.addEventListener('touchstart', handleTouchStart, { passive: false })
744
- el.addEventListener('touchmove', handleTouchMove, { passive: false })
745
- el.addEventListener('touchend', handleTouchEnd)
746
- el.addEventListener('mousedown', handleMouseDown)
747
- }
748
- })
749
-
750
- // Watch for items changes in swipe mode to reset index if needed
751
- watch(() => masonry.value.length, (newLength, oldLength) => {
752
- if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
753
- // First items loaded, ensure we're at index 0
754
- currentSwipeIndex.value = 0
755
- nextTick(() => snapToCurrentItem())
756
- }
757
- })
758
-
759
- // Watch wrapper element to setup ResizeObserver for container width
760
- watch(wrapper, (el) => {
761
- if (resizeObserver) {
762
- resizeObserver.disconnect()
763
- resizeObserver = null
764
- }
765
-
766
- if (el && typeof ResizeObserver !== 'undefined') {
767
- resizeObserver = new ResizeObserver((entries) => {
768
- // Skip updates if fixed dimensions are set
769
- if (fixedDimensions.value) return
770
-
771
- for (const entry of entries) {
772
- const newWidth = entry.contentRect.width
773
- const newHeight = entry.contentRect.height
774
- if (containerWidth.value !== newWidth) {
775
- containerWidth.value = newWidth
776
- }
777
- if (containerHeight.value !== newHeight) {
778
- containerHeight.value = newHeight
779
- }
780
- }
781
- })
782
- resizeObserver.observe(el)
783
- // Initial dimensions (only if not fixed)
784
- if (!fixedDimensions.value) {
785
- containerWidth.value = el.clientWidth
786
- containerHeight.value = el.clientHeight
787
- }
788
- } else if (el) {
789
- // Fallback if ResizeObserver not available
790
- if (!fixedDimensions.value) {
791
- containerWidth.value = el.clientWidth
792
- containerHeight.value = el.clientHeight
793
- }
794
- }
795
- }, { immediate: true })
796
-
797
- // Watch containerWidth changes to refresh layout in masonry mode
798
- watch(containerWidth, (newWidth, oldWidth) => {
799
- if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
800
- // Use nextTick to ensure DOM has updated
801
- nextTick(() => {
802
- columns.value = getColumnCount(layout.value as any, newWidth)
803
- refreshLayout(masonry.value as any)
804
- updateScrollProgress()
805
- })
806
- }
807
- })
808
-
809
- onMounted(async () => {
810
- try {
811
- // Wait for next tick to ensure wrapper is mounted
812
- await nextTick()
813
-
814
- // Container dimensions are managed by ResizeObserver
815
- // Only set initial values if ResizeObserver isn't available
816
- if (wrapper.value && !resizeObserver) {
817
- containerWidth.value = wrapper.value.clientWidth
818
- containerHeight.value = wrapper.value.clientHeight
819
- }
820
-
821
- if (!useSwipeMode.value) {
822
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
823
- if (container.value) {
824
- viewportTop.value = container.value.scrollTop
825
- viewportHeight.value = container.value.clientHeight
826
- }
827
- }
828
-
829
- const initialPage = props.loadAtPage as any
830
- paginationHistory.value = [initialPage]
831
-
832
- if (!props.skipInitialLoad) {
833
- await loadPage(paginationHistory.value[0] as any)
834
- } else if (props.items && props.items.length > 0) {
835
- // When skipInitialLoad is true and items are provided, initialize pagination state
836
- // Use initialPage/initialNextPage props if provided, otherwise use loadAtPage
837
- // Only set next to null if initialNextPage is explicitly null (not undefined)
838
- const page = props.initialPage !== null && props.initialPage !== undefined
839
- ? props.initialPage
840
- : (props.loadAtPage as any)
841
- const next = props.initialNextPage !== undefined
842
- ? props.initialNextPage
843
- : undefined // undefined means "unknown", null means "end of list"
844
-
845
- await restoreItems(props.items as any[], page, next)
846
- // Mark as initialized to prevent watcher from running again
847
- hasInitializedWithItems = true
848
- }
849
-
850
- if (!useSwipeMode.value) {
851
- updateScrollProgress()
852
- } else {
853
- // In swipe mode, snap to first item
854
- nextTick(() => snapToCurrentItem())
855
- }
856
-
857
- } catch (error) {
858
- // If error is from loadPage, it's already handled via loadError
859
- // Only log truly unexpected initialization errors
860
- if (!loadError.value) {
861
- console.error('Error during component initialization:', error)
862
- // Set loadError for unexpected errors too
863
- loadError.value = normalizeError(error)
864
- }
865
- isLoading.value = false
866
- }
867
-
868
- // Scroll listener is handled by watcher now for consistency
869
- window.addEventListener('resize', debouncedResizeHandler)
870
- window.addEventListener('resize', handleWindowResize)
871
- })
872
-
873
- onUnmounted(() => {
874
- if (resizeObserver) {
875
- resizeObserver.disconnect()
876
- resizeObserver = null
877
- }
878
-
879
- container.value?.removeEventListener('scroll', debouncedScrollHandler)
880
- window.removeEventListener('resize', debouncedResizeHandler)
881
- window.removeEventListener('resize', handleWindowResize)
882
-
883
- if (swipeContainer.value) {
884
- swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
885
- swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
886
- swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
887
- swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
888
- }
889
-
890
- // Clean up mouse handlers
891
- document.removeEventListener('mousemove', handleMouseMove)
892
- document.removeEventListener('mouseup', handleMouseUp)
893
- })
894
- </script>
895
-
896
- <template>
897
- <div ref="wrapper" class="w-full h-full flex flex-col relative">
898
- <!-- Swipe Feed Mode (Mobile/Tablet) -->
899
- <div v-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
900
- :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
901
- ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
902
- <div class="relative w-full" :style="{
903
- transform: `translateY(${swipeOffset}px)`,
904
- transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
905
- height: `${masonry.length * 100}%`
906
- }">
907
- <div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
908
- :style="{
909
- top: `${index * (100 / masonry.length)}%`,
910
- height: `${100 / masonry.length}%`
911
- }">
912
- <div class="w-full h-full flex items-center justify-center p-4">
913
- <div class="w-full h-full max-w-full max-h-full relative">
914
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
915
- <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
916
- :in-swipe-mode="true" :is-active="index === currentSwipeIndex"
917
- @preload:success="(p) => emits('item:preload:success', p)"
918
- @preload:error="(p) => emits('item:preload:error', p)"
919
- @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
920
- <!-- Pass through header and footer slots to MasonryItem -->
921
- <template #header="slotProps">
922
- <slot name="item-header" v-bind="slotProps" />
923
- </template>
924
- <template #footer="slotProps">
925
- <slot name="item-footer" v-bind="slotProps" />
926
- </template>
927
- </MasonryItem>
928
- </slot>
929
- </div>
930
- </div>
931
- </div>
932
- </div>
933
- <!-- End of list message for swipe mode -->
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 for swipe mode -->
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
-
947
- <!-- Masonry Grid Mode (Desktop) -->
948
- <div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
949
- ref="container">
950
- <div class="relative"
951
- :style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
952
- <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
953
- @before-leave="beforeLeave">
954
- <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
955
- v-bind="getItemAttributes(item, i)">
956
- <!-- Use default slot if provided, otherwise use MasonryItem -->
957
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
958
- <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
959
- :in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
960
- @preload:error="(p) => emits('item:preload:error', p)"
961
- @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
962
- <!-- Pass through header and footer slots to MasonryItem -->
963
- <template #header="slotProps">
964
- <slot name="item-header" v-bind="slotProps" />
965
- </template>
966
- <template #footer="slotProps">
967
- <slot name="item-footer" v-bind="slotProps" />
968
- </template>
969
- </MasonryItem>
970
- </slot>
971
- </div>
972
- </transition-group>
973
- </div>
974
- <!-- End of list message -->
975
- <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
976
- <slot name="end-message">
977
- <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
978
- </slot>
979
- </div>
980
- <!-- Error message -->
981
- <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
982
- <slot name="error-message" :error="loadError">
983
- <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
984
- </slot>
985
- </div>
986
- </div>
987
- </div>
988
- </template>
989
-
990
- <style scoped>
991
- .masonry-container {
992
- overflow-anchor: none;
993
- }
994
-
995
- .masonry-item {
996
- will-change: transform, opacity;
997
- contain: layout paint;
998
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
999
- opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1000
- backface-visibility: hidden;
1001
- }
1002
-
1003
- .masonry-move {
1004
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
1005
- }
1006
-
1007
- @media (prefers-reduced-motion: reduce) {
1008
-
1009
- .masonry-container:not(.force-motion) .masonry-item,
1010
- .masonry-container:not(.force-motion) .masonry-move {
1011
- transition-duration: 1ms !important;
1012
- }
1013
- }
1014
- </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
+ // Initial pagination state when skipInitialLoad is true and items are provided
49
+ initialPage: {
50
+ type: [Number, String],
51
+ default: null
52
+ },
53
+ initialNextPage: {
54
+ type: [Number, String],
55
+ default: null
56
+ },
57
+ pageSize: {
58
+ type: Number,
59
+ default: 40
60
+ },
61
+ // Backfill configuration
62
+ backfillEnabled: {
63
+ type: Boolean,
64
+ default: true
65
+ },
66
+ backfillDelayMs: {
67
+ type: Number,
68
+ default: 2000
69
+ },
70
+ backfillMaxCalls: {
71
+ type: Number,
72
+ default: 10
73
+ },
74
+ // Retry configuration
75
+ retryMaxAttempts: {
76
+ type: Number,
77
+ default: 3
78
+ },
79
+ retryInitialDelayMs: {
80
+ type: Number,
81
+ default: 2000
82
+ },
83
+ retryBackoffStepMs: {
84
+ type: Number,
85
+ default: 2000
86
+ },
87
+ transitionDurationMs: {
88
+ type: Number,
89
+ default: 450
90
+ },
91
+ // Shorter, snappier duration specifically for item removal (leave)
92
+ leaveDurationMs: {
93
+ type: Number,
94
+ default: 160
95
+ },
96
+ transitionEasing: {
97
+ type: String,
98
+ default: 'cubic-bezier(.22,.61,.36,1)'
99
+ },
100
+ // Force motion even when user has reduced-motion enabled
101
+ forceMotion: {
102
+ type: Boolean,
103
+ default: false
104
+ },
105
+ virtualBufferPx: {
106
+ type: Number,
107
+ default: 600
108
+ },
109
+ loadThresholdPx: {
110
+ type: Number,
111
+ default: 200
112
+ },
113
+ autoRefreshOnEmpty: {
114
+ type: Boolean,
115
+ default: false
116
+ },
117
+ // Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
118
+ layoutMode: {
119
+ type: String,
120
+ default: 'auto',
121
+ validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
122
+ },
123
+ // Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
124
+ mobileBreakpoint: {
125
+ type: [Number, String],
126
+ default: 768 // 'md' breakpoint
127
+ },
128
+ })
129
+
130
+ const defaultLayout = {
131
+ sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
132
+ gutterX: 10,
133
+ gutterY: 10,
134
+ header: 0,
135
+ footer: 0,
136
+ paddingLeft: 0,
137
+ paddingRight: 0,
138
+ placement: 'masonry'
139
+ }
140
+
141
+ const layout = computed(() => ({
142
+ ...defaultLayout,
143
+ ...props.layout,
144
+ sizes: {
145
+ ...defaultLayout.sizes,
146
+ ...(props.layout?.sizes || {})
147
+ }
148
+ }))
149
+
150
+ const wrapper = ref<HTMLElement | null>(null)
151
+ const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
152
+ const containerHeight = ref<number>(typeof window !== 'undefined' ? window.innerHeight : 768)
153
+ const fixedDimensions = ref<{ width?: number; height?: number } | null>(null)
154
+ let resizeObserver: ResizeObserver | null = null
155
+
156
+ // Get breakpoint value from Tailwind breakpoint name
157
+ function getBreakpointValue(breakpoint: string): number {
158
+ const breakpoints: Record<string, number> = {
159
+ 'sm': 640,
160
+ 'md': 768,
161
+ 'lg': 1024,
162
+ 'xl': 1280,
163
+ '2xl': 1536
164
+ }
165
+ return breakpoints[breakpoint] || 768
166
+ }
167
+
168
+ // Determine if we should use swipe mode
169
+ const useSwipeMode = computed(() => {
170
+ if (props.layoutMode === 'masonry') return false
171
+ if (props.layoutMode === 'swipe') return true
172
+
173
+ // Auto mode: check container width
174
+ const breakpoint = typeof props.mobileBreakpoint === 'string'
175
+ ? getBreakpointValue(props.mobileBreakpoint)
176
+ : props.mobileBreakpoint
177
+
178
+ return containerWidth.value < breakpoint
179
+ })
180
+
181
+
182
+ const emits = defineEmits([
183
+ 'update:items',
184
+ 'backfill:start',
185
+ 'backfill:tick',
186
+ 'backfill:stop',
187
+ 'retry:start',
188
+ 'retry:tick',
189
+ 'retry:stop',
190
+ 'remove-all:complete',
191
+ // Re-emit item-level preload events from the default MasonryItem
192
+ 'item:preload:success',
193
+ 'item:preload:error',
194
+ // Mouse events from MasonryItem content
195
+ 'item:mouse-enter',
196
+ 'item:mouse-leave'
197
+ ])
198
+
199
+ const masonry = computed<any>({
200
+ get: () => props.items,
201
+ set: (val) => emits('update:items', val)
202
+ })
203
+
204
+ const columns = ref<number>(7)
205
+ const container = ref<HTMLElement | null>(null)
206
+ const paginationHistory = ref<any[]>([])
207
+ const currentPage = ref<any>(null) // Track the actual current page being displayed
208
+ const isLoading = ref<boolean>(false)
209
+ const masonryContentHeight = ref<number>(0)
210
+ const hasReachedEnd = ref<boolean>(false) // Track when we've reached the last page
211
+ const loadError = ref<Error | null>(null) // Track load errors
212
+
213
+ // Current breakpoint
214
+ const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
215
+
216
+
217
+ // Initialize dimensions composable first (needed by layout composable)
218
+ const dimensions = useMasonryDimensions({
219
+ masonry: masonry as any
220
+ })
221
+
222
+ // Extract dimension checking function
223
+ const { checkItemDimensions, invalidDimensionIds, reset: resetDimensions } = dimensions
224
+
225
+ // Initialize layout composable (needs checkItemDimensions from dimensions composable)
226
+ const layoutComposable = useMasonryLayout({
227
+ masonry: masonry as any,
228
+ useSwipeMode,
229
+ container,
230
+ columns,
231
+ containerWidth,
232
+ masonryContentHeight,
233
+ layout,
234
+ fixedDimensions,
235
+ checkItemDimensions
236
+ })
237
+
238
+ // Extract layout functions
239
+ const { refreshLayout, setFixedDimensions: setFixedDimensionsLayout, onResize: onResizeLayout } = layoutComposable
240
+
241
+ // Initialize virtualization composable
242
+ const virtualization = useMasonryVirtualization({
243
+ masonry: masonry as any,
244
+ container,
245
+ columns,
246
+ virtualBufferPx: props.virtualBufferPx,
247
+ loadThresholdPx: props.loadThresholdPx,
248
+ handleScroll: () => { } // Will be set after pagination is initialized
249
+ })
250
+
251
+ // Extract virtualization state and functions
252
+ const { viewportTop, viewportHeight, virtualizing, scrollProgress, visibleMasonry, updateScrollProgress, updateViewport: updateViewportVirtualization, reset: resetVirtualization } = virtualization
253
+
254
+ // Initialize transitions composable with virtualization support
255
+ const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(
256
+ { container, masonry: masonry as any },
257
+ { leaveDurationMs: props.leaveDurationMs, virtualizing }
258
+ )
259
+
260
+ // Transition functions for template (wrapped to match expected signature)
261
+ const enter = onEnter
262
+ const beforeEnter = onBeforeEnter
263
+ const beforeLeave = onBeforeLeave
264
+ const leave = onLeave
265
+
266
+ // Initialize pagination composable
267
+ const pagination = useMasonryPagination({
268
+ getNextPage: props.getNextPage as (page: any) => Promise<{ items: any[]; nextPage: any }>,
269
+ masonry: masonry as any,
270
+ isLoading,
271
+ hasReachedEnd,
272
+ loadError,
273
+ currentPage,
274
+ paginationHistory,
275
+ refreshLayout,
276
+ retryMaxAttempts: props.retryMaxAttempts,
277
+ retryInitialDelayMs: props.retryInitialDelayMs,
278
+ retryBackoffStepMs: props.retryBackoffStepMs,
279
+ backfillEnabled: props.backfillEnabled,
280
+ backfillDelayMs: props.backfillDelayMs,
281
+ backfillMaxCalls: props.backfillMaxCalls,
282
+ pageSize: props.pageSize,
283
+ autoRefreshOnEmpty: props.autoRefreshOnEmpty,
284
+ emits
285
+ })
286
+
287
+ // Extract pagination functions
288
+ const { loadPage, loadNext, refreshCurrentPage, cancelLoad, maybeBackfillToTarget } = pagination
289
+
290
+ // Initialize swipe mode composable (needs loadNext and loadPage from pagination)
291
+ const swipeMode = useSwipeModeComposable({
292
+ useSwipeMode,
293
+ masonry: masonry as any,
294
+ isLoading,
295
+ loadNext,
296
+ loadPage,
297
+ paginationHistory
298
+ })
299
+
300
+ // Initialize scroll handler (needs loadNext from pagination)
301
+ const { handleScroll } = useMasonryScroll({
302
+ container,
303
+ masonry: masonry as any,
304
+ columns,
305
+ containerHeight: masonryContentHeight,
306
+ isLoading,
307
+ pageSize: props.pageSize,
308
+ refreshLayout,
309
+ setItemsRaw: (items: any[]) => {
310
+ masonry.value = items
311
+ },
312
+ loadNext,
313
+ loadThresholdPx: props.loadThresholdPx
314
+ })
315
+
316
+ // Update virtualization handleScroll to use the scroll handler
317
+ virtualization.handleScroll.value = handleScroll
318
+
319
+ // Initialize items composable
320
+ const items = useMasonryItems({
321
+ masonry: masonry as any,
322
+ useSwipeMode,
323
+ refreshLayout,
324
+ refreshCurrentPage,
325
+ loadNext,
326
+ maybeBackfillToTarget,
327
+ autoRefreshOnEmpty: props.autoRefreshOnEmpty,
328
+ paginationHistory
329
+ })
330
+
331
+ // Extract item management functions
332
+ const { remove, removeMany, restore, restoreMany, removeAll: removeAllItems } = items
333
+
334
+ // setFixedDimensions is now in useMasonryLayout composable
335
+ // Wrapper function to maintain API compatibility and handle wrapper restoration
336
+ function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
337
+ setFixedDimensionsLayout(dimensions, updateScrollProgress)
338
+ if (!dimensions && wrapper.value) {
339
+ // When clearing fixed dimensions, restore from wrapper
340
+ containerWidth.value = wrapper.value.clientWidth
341
+ containerHeight.value = wrapper.value.clientHeight
342
+ }
343
+ }
344
+
345
+ defineExpose({
346
+ isLoading,
347
+ refreshLayout,
348
+ // Container dimensions (wrapper element)
349
+ containerWidth,
350
+ containerHeight,
351
+ // Masonry content height (for backward compatibility, old containerHeight)
352
+ contentHeight: masonryContentHeight,
353
+ // Current page
354
+ currentPage,
355
+ // End of list tracking
356
+ hasReachedEnd,
357
+ // Load error tracking
358
+ loadError,
359
+ // Set fixed dimensions (overrides ResizeObserver)
360
+ setFixedDimensions,
361
+ remove,
362
+ removeMany,
363
+ removeAll: removeAllItems,
364
+ restore,
365
+ restoreMany,
366
+ loadNext,
367
+ loadPage,
368
+ refreshCurrentPage,
369
+ reset,
370
+ destroy,
371
+ init,
372
+ restoreItems,
373
+ paginationHistory,
374
+ cancelLoad,
375
+ scrollToTop,
376
+ scrollTo,
377
+ totalItems: computed(() => (masonry.value as any[]).length),
378
+ currentBreakpoint
379
+ })
380
+
381
+ // Layout functions are now in useMasonryLayout composable
382
+ // Removed: calculateHeight, refreshLayout - now from layoutComposable
383
+
384
+ // Expose swipe mode computed values and state for template
385
+ const currentItem = swipeMode.currentItem
386
+ const nextItem = swipeMode.nextItem
387
+ const previousItem = swipeMode.previousItem
388
+ const currentSwipeIndex = swipeMode.currentSwipeIndex
389
+ const swipeOffset = swipeMode.swipeOffset
390
+ const isDragging = swipeMode.isDragging
391
+ const swipeContainer = swipeMode.swipeContainer
392
+
393
+ // Swipe gesture handlers (delegated to composable)
394
+ const handleTouchStart = swipeMode.handleTouchStart
395
+ const handleTouchMove = swipeMode.handleTouchMove
396
+ const handleTouchEnd = swipeMode.handleTouchEnd
397
+ const handleMouseDown = swipeMode.handleMouseDown
398
+ const handleMouseMove = swipeMode.handleMouseMove
399
+ const handleMouseUp = swipeMode.handleMouseUp
400
+ const goToNextItem = swipeMode.goToNextItem
401
+ const goToPreviousItem = swipeMode.goToPreviousItem
402
+ const snapToCurrentItem = swipeMode.snapToCurrentItem
403
+
404
+ // refreshCurrentPage is now in useMasonryPagination composable
405
+
406
+ // Item management functions (remove, removeMany, restore, restoreMany, removeAll) are now in useMasonryItems composable
407
+
408
+ function scrollToTop(options?: ScrollToOptions) {
409
+ if (container.value) {
410
+ container.value.scrollTo({
411
+ top: 0,
412
+ behavior: options?.behavior ?? 'smooth',
413
+ ...options
414
+ })
415
+ }
416
+ }
417
+
418
+ function scrollTo(options: { top?: number; left?: number; behavior?: ScrollBehavior }) {
419
+ if (container.value) {
420
+ container.value.scrollTo({
421
+ top: options.top ?? container.value.scrollTop,
422
+ left: options.left ?? container.value.scrollLeft,
423
+ behavior: options.behavior ?? 'auto',
424
+ })
425
+ // Update viewport state immediately after scrolling
426
+ if (container.value) {
427
+ viewportTop.value = container.value.scrollTop
428
+ viewportHeight.value = container.value.clientHeight || window.innerHeight
429
+ }
430
+ }
431
+ }
432
+
433
+ // removeAll is now in useMasonryItems composable (removeAllItems)
434
+
435
+ // onResize is now in useMasonryLayout composable (onResizeLayout)
436
+ function onResize() {
437
+ onResizeLayout()
438
+ if (container.value) {
439
+ viewportTop.value = container.value.scrollTop
440
+ viewportHeight.value = container.value.clientHeight
441
+ }
442
+ }
443
+
444
+ // maybeBackfillToTarget, cancelLoad are now in useMasonryPagination composable
445
+ // Removed: backfillActive, cancelRequested - now internal to pagination composable
446
+
447
+ function reset() {
448
+ // Cancel ongoing work
449
+ cancelLoad()
450
+
451
+ if (container.value) {
452
+ container.value.scrollTo({
453
+ top: 0,
454
+ behavior: 'smooth'
455
+ })
456
+ }
457
+
458
+ masonry.value = []
459
+ containerHeight.value = 0
460
+ currentPage.value = props.loadAtPage // Reset current page tracking
461
+ paginationHistory.value = [props.loadAtPage]
462
+ hasReachedEnd.value = false // Reset end flag
463
+ loadError.value = null // Reset error flag
464
+
465
+ // Reset virtualization state
466
+ resetVirtualization()
467
+
468
+ // Reset auto-initialization flag so watcher can work again if needed
469
+ hasInitializedWithItems = false
470
+ }
471
+
472
+ function destroy() {
473
+ // Cancel any ongoing loads
474
+ cancelLoad()
475
+
476
+ // Reset all state
477
+ masonry.value = []
478
+ masonryContentHeight.value = 0
479
+ currentPage.value = null
480
+ paginationHistory.value = []
481
+ hasReachedEnd.value = false
482
+ loadError.value = null
483
+ isLoading.value = false
484
+
485
+ // Reset swipe mode state
486
+ currentSwipeIndex.value = 0
487
+ swipeOffset.value = 0
488
+ isDragging.value = false
489
+
490
+ // Reset virtualization state
491
+ resetVirtualization()
492
+
493
+ // Reset invalid dimension tracking
494
+ resetDimensions()
495
+
496
+ // Scroll to top if container exists
497
+ if (container.value) {
498
+ container.value.scrollTo({
499
+ top: 0,
500
+ behavior: 'auto' // Instant scroll for destroy
501
+ })
502
+ }
503
+ }
504
+
505
+ // Scroll handler is now handled by virtualization composable's updateViewport
506
+ const debouncedScrollHandler = debounce(async () => {
507
+ if (useSwipeMode.value) return // Skip scroll handling in swipe mode
508
+ await updateViewportVirtualization()
509
+ }, 200)
510
+
511
+ const debouncedResizeHandler = debounce(onResize, 200)
512
+
513
+ // Window resize handler (combines swipe and general resize logic)
514
+ function handleWindowResize() {
515
+ // Delegate swipe-specific resize handling
516
+ swipeMode.handleWindowResize()
517
+
518
+ // General resize handling (if needed)
519
+ // Note: containerWidth is updated by ResizeObserver
520
+ }
521
+
522
+ function init(items: any[], page: any, next: any) {
523
+ currentPage.value = page // Track the initial current page
524
+ paginationHistory.value = [page]
525
+ paginationHistory.value.push(next)
526
+ // Update hasReachedEnd if next is null
527
+ hasReachedEnd.value = next == null
528
+ // Diagnostics: check incoming initial items
529
+ checkItemDimensions(items as any[], 'init')
530
+
531
+ if (useSwipeMode.value) {
532
+ // In swipe mode, just add items without layout calculation
533
+ masonry.value = [...(masonry.value as any[]), ...items]
534
+ // Reset swipe index if we're at the start
535
+ if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
536
+ swipeOffset.value = 0
537
+ }
538
+ } else {
539
+ refreshLayout([...(masonry.value as any[]), ...items])
540
+
541
+ // Update viewport state from container's scroll position
542
+ // Critical after refresh when browser may restore scroll position
543
+ if (container.value) {
544
+ viewportTop.value = container.value.scrollTop
545
+ viewportHeight.value = container.value.clientHeight || window.innerHeight
546
+ }
547
+
548
+ // Update again after DOM updates to catch browser scroll restoration
549
+ // The debounced scroll handler will also catch any scroll changes
550
+ nextTick(() => {
551
+ if (container.value) {
552
+ viewportTop.value = container.value.scrollTop
553
+ viewportHeight.value = container.value.clientHeight || window.innerHeight
554
+ updateScrollProgress()
555
+ }
556
+ })
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Restore items when skipInitialLoad is true.
562
+ * This method should be called instead of directly assigning to v-model:items
563
+ * when restoring items from saved state.
564
+ * @param items - Items to restore
565
+ * @param page - Current page number/cursor
566
+ * @param next - Next page cursor (or null if at end)
567
+ */
568
+ async function restoreItems(items: any[], page: any, next: any) {
569
+ // If skipInitialLoad is false, fall back to init behavior
570
+ if (!props.skipInitialLoad) {
571
+ init(items, page, next)
572
+ return
573
+ }
574
+
575
+ // When skipInitialLoad is true, we need to restore items without triggering initial load
576
+ currentPage.value = page
577
+ paginationHistory.value = [page]
578
+ if (next !== null && next !== undefined) {
579
+ paginationHistory.value.push(next)
580
+ }
581
+ // Only set hasReachedEnd to true if next is explicitly null (end of list)
582
+ // undefined means "unknown" - don't assume end of list
583
+ hasReachedEnd.value = next === null
584
+ loadError.value = null
585
+
586
+ // Diagnostics: check incoming items
587
+ checkItemDimensions(items as any[], 'restoreItems')
588
+
589
+ // Set items directly (v-model will sync) and refresh layout
590
+ // Follow the same pattern as init() and getContent()
591
+ if (useSwipeMode.value) {
592
+ // In swipe mode, just set items without layout calculation
593
+ masonry.value = items
594
+ // Reset swipe index if we're at the start
595
+ if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
596
+ swipeOffset.value = 0
597
+ }
598
+ } else {
599
+ // In masonry mode, refresh layout with the restored items
600
+ refreshLayout(items)
601
+
602
+ // Update viewport state from container's scroll position
603
+ if (container.value) {
604
+ viewportTop.value = container.value.scrollTop
605
+ viewportHeight.value = container.value.clientHeight || window.innerHeight
606
+ }
607
+
608
+ // Update again after DOM updates to catch browser scroll restoration
609
+ await nextTick()
610
+ if (container.value) {
611
+ viewportTop.value = container.value.scrollTop
612
+ viewportHeight.value = container.value.clientHeight || window.innerHeight
613
+ updateScrollProgress()
614
+
615
+ // Check if user is already at the bottom after restoration
616
+ // If so, trigger loading to restore scroll-to-bottom functionality
617
+ // Wait for layout to be fully calculated before checking
618
+ await nextTick()
619
+ const columnHeights = calculateColumnHeights(masonry.value as any, columns.value)
620
+ const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
621
+ const scrollerBottom = container.value.scrollTop + container.value.clientHeight
622
+ const threshold = typeof props.loadThresholdPx === 'number' ? props.loadThresholdPx : 200
623
+ const triggerPoint = threshold >= 0
624
+ ? Math.max(0, tallest - threshold)
625
+ : Math.max(0, tallest + threshold)
626
+ const nearBottom = scrollerBottom >= triggerPoint
627
+
628
+ // If user is at bottom and there's a next page, trigger loading
629
+ // This restores scroll-to-bottom functionality after tab restoration
630
+ if (nearBottom && !hasReachedEnd.value && !isLoading.value && paginationHistory.value.length > 0) {
631
+ const nextPage = paginationHistory.value[paginationHistory.value.length - 1]
632
+ if (nextPage != null) {
633
+ // Use handleScroll with forceCheck=true to bypass isScrollingDown check
634
+ await handleScroll(columnHeights, true)
635
+ }
636
+ }
637
+ }
638
+ }
639
+ }
640
+
641
+ // Watch for layout changes and update columns + refresh layout dynamically
642
+ watch(
643
+ layout,
644
+ () => {
645
+ if (useSwipeMode.value) {
646
+ // In swipe mode, no layout recalculation needed
647
+ return
648
+ }
649
+ if (container.value) {
650
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
651
+ refreshLayout(masonry.value as any)
652
+ }
653
+ },
654
+ { deep: true }
655
+ )
656
+
657
+ // Watch for layout-mode prop changes to ensure proper mode switching
658
+ watch(() => props.layoutMode, () => {
659
+ // Force update containerWidth when layout-mode changes to ensure useSwipeMode computes correctly
660
+ if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
661
+ containerWidth.value = fixedDimensions.value.width
662
+ } else if (wrapper.value) {
663
+ containerWidth.value = wrapper.value.clientWidth
664
+ }
665
+ })
666
+
667
+ // Watch container element to attach scroll listener when available
668
+ watch(container, (el) => {
669
+ if (el && !useSwipeMode.value) {
670
+ // Attach scroll listener for masonry mode
671
+ el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
672
+ el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
673
+ } else if (el) {
674
+ // Remove scroll listener if switching to swipe mode
675
+ el.removeEventListener('scroll', debouncedScrollHandler)
676
+ }
677
+ }, { immediate: true })
678
+
679
+ // Watch for items when skipInitialLoad is true to auto-initialize pagination state
680
+ // This handles cases where items are provided after mount or updated externally
681
+ let hasInitializedWithItems = false
682
+ watch(
683
+ () => [props.items, props.skipInitialLoad, props.initialPage, props.initialNextPage] as const,
684
+ ([items, skipInitialLoad, initialPage, initialNextPage]) => {
685
+ // Only auto-initialize if:
686
+ // 1. skipInitialLoad is true
687
+ // 2. Items exist
688
+ // 3. We haven't already initialized with items (to avoid re-initializing on every update)
689
+ if (
690
+ skipInitialLoad &&
691
+ items &&
692
+ items.length > 0 &&
693
+ !hasInitializedWithItems
694
+ ) {
695
+ hasInitializedWithItems = true
696
+ const page = initialPage !== null && initialPage !== undefined
697
+ ? initialPage
698
+ : (props.loadAtPage as any)
699
+ const next = initialNextPage !== undefined
700
+ ? initialNextPage
701
+ : undefined // undefined means "unknown", null means "end of list"
702
+
703
+ restoreItems(items as any[], page, next)
704
+ }
705
+ },
706
+ { immediate: false }
707
+ )
708
+
709
+ // Watch for swipe mode changes to refresh layout and setup/teardown handlers
710
+ watch(useSwipeMode, (newValue, oldValue) => {
711
+ // Skip if this is the initial watch call and values are the same
712
+ if (oldValue === undefined && newValue === false) return
713
+
714
+ nextTick(() => {
715
+ if (newValue) {
716
+ // Switching to Swipe Mode
717
+ document.addEventListener('mousemove', handleMouseMove)
718
+ document.addEventListener('mouseup', handleMouseUp)
719
+
720
+ // Remove scroll listener
721
+ if (container.value) {
722
+ container.value.removeEventListener('scroll', debouncedScrollHandler)
723
+ }
724
+
725
+ // Reset index if needed
726
+ currentSwipeIndex.value = 0
727
+ swipeOffset.value = 0
728
+ if (masonry.value.length > 0) {
729
+ snapToCurrentItem()
730
+ }
731
+ } else {
732
+ // Switching to Masonry Mode
733
+ document.removeEventListener('mousemove', handleMouseMove)
734
+ document.removeEventListener('mouseup', handleMouseUp)
735
+
736
+ if (container.value && wrapper.value) {
737
+ // Ensure containerWidth is up to date - use fixed dimensions if set
738
+ if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
739
+ containerWidth.value = fixedDimensions.value.width
740
+ } else {
741
+ containerWidth.value = wrapper.value.clientWidth
742
+ }
743
+
744
+ // Attach scroll listener (container watcher will handle this, but ensure it's attached)
745
+ container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
746
+ container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
747
+
748
+ // Refresh layout with updated width
749
+ if (masonry.value.length > 0) {
750
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
751
+ refreshLayout(masonry.value as any)
752
+
753
+ // Update viewport state
754
+ viewportTop.value = container.value.scrollTop
755
+ viewportHeight.value = container.value.clientHeight
756
+ updateScrollProgress()
757
+ }
758
+ }
759
+ }
760
+ })
761
+ }, { immediate: true })
762
+
763
+ // Watch for swipe container element to attach touch listeners
764
+ watch(swipeContainer, (el) => {
765
+ if (el) {
766
+ el.addEventListener('touchstart', handleTouchStart, { passive: false })
767
+ el.addEventListener('touchmove', handleTouchMove, { passive: false })
768
+ el.addEventListener('touchend', handleTouchEnd)
769
+ el.addEventListener('mousedown', handleMouseDown)
770
+ }
771
+ })
772
+
773
+ // Watch for items changes in swipe mode to reset index if needed
774
+ watch(() => masonry.value.length, (newLength, oldLength) => {
775
+ if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
776
+ // First items loaded, ensure we're at index 0
777
+ currentSwipeIndex.value = 0
778
+ nextTick(() => snapToCurrentItem())
779
+ }
780
+ })
781
+
782
+ // Watch wrapper element to setup ResizeObserver for container width
783
+ watch(wrapper, (el) => {
784
+ if (resizeObserver) {
785
+ resizeObserver.disconnect()
786
+ resizeObserver = null
787
+ }
788
+
789
+ if (el && typeof ResizeObserver !== 'undefined') {
790
+ resizeObserver = new ResizeObserver((entries) => {
791
+ // Skip updates if fixed dimensions are set
792
+ if (fixedDimensions.value) return
793
+
794
+ for (const entry of entries) {
795
+ const newWidth = entry.contentRect.width
796
+ const newHeight = entry.contentRect.height
797
+ if (containerWidth.value !== newWidth) {
798
+ containerWidth.value = newWidth
799
+ }
800
+ if (containerHeight.value !== newHeight) {
801
+ containerHeight.value = newHeight
802
+ }
803
+ }
804
+ })
805
+ resizeObserver.observe(el)
806
+ // Initial dimensions (only if not fixed)
807
+ if (!fixedDimensions.value) {
808
+ containerWidth.value = el.clientWidth
809
+ containerHeight.value = el.clientHeight
810
+ }
811
+ } else if (el) {
812
+ // Fallback if ResizeObserver not available
813
+ if (!fixedDimensions.value) {
814
+ containerWidth.value = el.clientWidth
815
+ containerHeight.value = el.clientHeight
816
+ }
817
+ }
818
+ }, { immediate: true })
819
+
820
+ // Watch containerWidth changes to refresh layout in masonry mode
821
+ watch(containerWidth, (newWidth, oldWidth) => {
822
+ if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
823
+ // Use nextTick to ensure DOM has updated
824
+ nextTick(() => {
825
+ columns.value = getColumnCount(layout.value as any, newWidth)
826
+ refreshLayout(masonry.value as any)
827
+ updateScrollProgress()
828
+ })
829
+ }
830
+ })
831
+
832
+ onMounted(async () => {
833
+ try {
834
+ // Wait for next tick to ensure wrapper is mounted
835
+ await nextTick()
836
+
837
+ // Container dimensions are managed by ResizeObserver
838
+ // Only set initial values if ResizeObserver isn't available
839
+ if (wrapper.value && !resizeObserver) {
840
+ containerWidth.value = wrapper.value.clientWidth
841
+ containerHeight.value = wrapper.value.clientHeight
842
+ }
843
+
844
+ if (!useSwipeMode.value) {
845
+ columns.value = getColumnCount(layout.value as any, containerWidth.value)
846
+ if (container.value) {
847
+ viewportTop.value = container.value.scrollTop
848
+ viewportHeight.value = container.value.clientHeight
849
+ }
850
+ }
851
+
852
+ const initialPage = props.loadAtPage as any
853
+ paginationHistory.value = [initialPage]
854
+
855
+ if (!props.skipInitialLoad) {
856
+ await loadPage(paginationHistory.value[0] as any)
857
+ } else if (props.items && props.items.length > 0) {
858
+ // When skipInitialLoad is true and items are provided, initialize pagination state
859
+ // Use initialPage/initialNextPage props if provided, otherwise use loadAtPage
860
+ // Only set next to null if initialNextPage is explicitly null (not undefined)
861
+ const page = props.initialPage !== null && props.initialPage !== undefined
862
+ ? props.initialPage
863
+ : (props.loadAtPage as any)
864
+ const next = props.initialNextPage !== undefined
865
+ ? props.initialNextPage
866
+ : undefined // undefined means "unknown", null means "end of list"
867
+
868
+ await restoreItems(props.items as any[], page, next)
869
+ // Mark as initialized to prevent watcher from running again
870
+ hasInitializedWithItems = true
871
+ }
872
+
873
+ if (!useSwipeMode.value) {
874
+ updateScrollProgress()
875
+ } else {
876
+ // In swipe mode, snap to first item
877
+ nextTick(() => snapToCurrentItem())
878
+ }
879
+
880
+ } catch (error) {
881
+ // If error is from loadPage, it's already handled via loadError
882
+ // Only log truly unexpected initialization errors
883
+ if (!loadError.value) {
884
+ console.error('Error during component initialization:', error)
885
+ // Set loadError for unexpected errors too
886
+ loadError.value = normalizeError(error)
887
+ }
888
+ isLoading.value = false
889
+ }
890
+
891
+ // Scroll listener is handled by watcher now for consistency
892
+ window.addEventListener('resize', debouncedResizeHandler)
893
+ window.addEventListener('resize', handleWindowResize)
894
+ })
895
+
896
+ onUnmounted(() => {
897
+ if (resizeObserver) {
898
+ resizeObserver.disconnect()
899
+ resizeObserver = null
900
+ }
901
+
902
+ container.value?.removeEventListener('scroll', debouncedScrollHandler)
903
+ window.removeEventListener('resize', debouncedResizeHandler)
904
+ window.removeEventListener('resize', handleWindowResize)
905
+
906
+ if (swipeContainer.value) {
907
+ swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
908
+ swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
909
+ swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
910
+ swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
911
+ }
912
+
913
+ // Clean up mouse handlers
914
+ document.removeEventListener('mousemove', handleMouseMove)
915
+ document.removeEventListener('mouseup', handleMouseUp)
916
+ })
917
+ </script>
918
+
919
+ <template>
920
+ <div ref="wrapper" class="w-full h-full flex flex-col relative">
921
+ <!-- Swipe Feed Mode (Mobile/Tablet) -->
922
+ <div v-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
923
+ :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
924
+ ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
925
+ <div class="relative w-full" :style="{
926
+ transform: `translateY(${swipeOffset}px)`,
927
+ transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
928
+ height: `${masonry.length * 100}%`
929
+ }">
930
+ <div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
931
+ :style="{
932
+ top: `${index * (100 / masonry.length)}%`,
933
+ height: `${100 / masonry.length}%`
934
+ }">
935
+ <div class="w-full h-full flex items-center justify-center p-4">
936
+ <div class="w-full h-full max-w-full max-h-full relative">
937
+ <slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
938
+ <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
939
+ :in-swipe-mode="true" :is-active="index === currentSwipeIndex"
940
+ @preload:success="(p) => emits('item:preload:success', p)"
941
+ @preload:error="(p) => emits('item:preload:error', p)"
942
+ @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
943
+ <!-- Pass through header and footer slots to MasonryItem -->
944
+ <template #header="slotProps">
945
+ <slot name="item-header" v-bind="slotProps" />
946
+ </template>
947
+ <template #footer="slotProps">
948
+ <slot name="item-footer" v-bind="slotProps" />
949
+ </template>
950
+ </MasonryItem>
951
+ </slot>
952
+ </div>
953
+ </div>
954
+ </div>
955
+ </div>
956
+ <!-- End of list message for swipe mode -->
957
+ <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
958
+ <slot name="end-message">
959
+ <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
960
+ </slot>
961
+ </div>
962
+ <!-- Error message for swipe mode -->
963
+ <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
964
+ <slot name="error-message" :error="loadError">
965
+ <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
966
+ </slot>
967
+ </div>
968
+ </div>
969
+
970
+ <!-- Masonry Grid Mode (Desktop) -->
971
+ <div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
972
+ ref="container">
973
+ <div class="relative"
974
+ :style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
975
+ <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
976
+ @before-leave="beforeLeave">
977
+ <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
978
+ v-bind="getItemAttributes(item, i)">
979
+ <!-- Use default slot if provided, otherwise use MasonryItem -->
980
+ <slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
981
+ <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
982
+ :in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
983
+ @preload:error="(p) => emits('item:preload:error', p)"
984
+ @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
985
+ <!-- Pass through header and footer slots to MasonryItem -->
986
+ <template #header="slotProps">
987
+ <slot name="item-header" v-bind="slotProps" />
988
+ </template>
989
+ <template #footer="slotProps">
990
+ <slot name="item-footer" v-bind="slotProps" />
991
+ </template>
992
+ </MasonryItem>
993
+ </slot>
994
+ </div>
995
+ </transition-group>
996
+ </div>
997
+ <!-- End of list message -->
998
+ <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
999
+ <slot name="end-message">
1000
+ <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
1001
+ </slot>
1002
+ </div>
1003
+ <!-- Error message -->
1004
+ <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
1005
+ <slot name="error-message" :error="loadError">
1006
+ <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1007
+ </slot>
1008
+ </div>
1009
+ </div>
1010
+ </div>
1011
+ </template>
1012
+
1013
+ <style scoped>
1014
+ .masonry-container {
1015
+ overflow-anchor: none;
1016
+ }
1017
+
1018
+ .masonry-item {
1019
+ will-change: transform, opacity;
1020
+ contain: layout paint;
1021
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
1022
+ opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1023
+ backface-visibility: hidden;
1024
+ }
1025
+
1026
+ .masonry-move {
1027
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
1028
+ }
1029
+
1030
+ @media (prefers-reduced-motion: reduce) {
1031
+
1032
+ .masonry-container:not(.force-motion) .masonry-item,
1033
+ .masonry-container:not(.force-motion) .masonry-move {
1034
+ transition-duration: 1ms !important;
1035
+ }
1036
+ }
1037
+ </style>