@wyxos/vibe 1.6.16 → 1.6.18
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/lib/index.js +770 -705
- package/lib/vibe.css +1 -1
- package/package.json +2 -2
- package/src/Masonry.vue +247 -125
- package/src/useMasonryScroll.ts +60 -60
package/src/Masonry.vue
CHANGED
|
@@ -16,7 +16,7 @@ import MasonryItem from './components/MasonryItem.vue'
|
|
|
16
16
|
const props = defineProps({
|
|
17
17
|
getNextPage: {
|
|
18
18
|
type: Function,
|
|
19
|
-
default: () => {}
|
|
19
|
+
default: () => { }
|
|
20
20
|
},
|
|
21
21
|
loadAtPage: {
|
|
22
22
|
type: [Number, String],
|
|
@@ -112,7 +112,7 @@ const props = defineProps({
|
|
|
112
112
|
})
|
|
113
113
|
|
|
114
114
|
const defaultLayout = {
|
|
115
|
-
sizes: {base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6},
|
|
115
|
+
sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
|
|
116
116
|
gutterX: 10,
|
|
117
117
|
gutterY: 10,
|
|
118
118
|
header: 0,
|
|
@@ -153,12 +153,12 @@ function getBreakpointValue(breakpoint: string): number {
|
|
|
153
153
|
const useSwipeMode = computed(() => {
|
|
154
154
|
if (props.layoutMode === 'masonry') return false
|
|
155
155
|
if (props.layoutMode === 'swipe') return true
|
|
156
|
-
|
|
156
|
+
|
|
157
157
|
// Auto mode: check container width
|
|
158
|
-
const breakpoint = typeof props.mobileBreakpoint === 'string'
|
|
158
|
+
const breakpoint = typeof props.mobileBreakpoint === 'string'
|
|
159
159
|
? getBreakpointValue(props.mobileBreakpoint)
|
|
160
160
|
: props.mobileBreakpoint
|
|
161
|
-
|
|
161
|
+
|
|
162
162
|
return containerWidth.value < breakpoint
|
|
163
163
|
})
|
|
164
164
|
|
|
@@ -212,6 +212,8 @@ const paginationHistory = ref<any[]>([])
|
|
|
212
212
|
const currentPage = ref<any>(null) // Track the actual current page being displayed
|
|
213
213
|
const isLoading = ref<boolean>(false)
|
|
214
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
|
|
215
217
|
|
|
216
218
|
// Current breakpoint
|
|
217
219
|
const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
|
|
@@ -278,7 +280,7 @@ const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }
|
|
|
278
280
|
const updateScrollProgress = (precomputedHeights?: number[]) => {
|
|
279
281
|
if (!container.value) return
|
|
280
282
|
|
|
281
|
-
const {scrollTop, clientHeight} = container.value
|
|
283
|
+
const { scrollTop, clientHeight } = container.value
|
|
282
284
|
const visibleBottom = scrollTop + clientHeight
|
|
283
285
|
|
|
284
286
|
const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
|
|
@@ -298,7 +300,7 @@ const updateScrollProgress = (precomputedHeights?: number[]) => {
|
|
|
298
300
|
}
|
|
299
301
|
|
|
300
302
|
// Setup composables
|
|
301
|
-
const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
|
|
303
|
+
const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
|
|
302
304
|
|
|
303
305
|
// Transition wrappers that skip animation during virtualization
|
|
304
306
|
function enter(el: HTMLElement, done: () => void) {
|
|
@@ -358,7 +360,7 @@ const visibleMasonry = computed(() => {
|
|
|
358
360
|
})
|
|
359
361
|
})
|
|
360
362
|
|
|
361
|
-
const {handleScroll} = useMasonryScroll({
|
|
363
|
+
const { handleScroll } = useMasonryScroll({
|
|
362
364
|
container,
|
|
363
365
|
masonry: masonry as any,
|
|
364
366
|
columns,
|
|
@@ -405,6 +407,10 @@ defineExpose({
|
|
|
405
407
|
contentHeight: masonryContentHeight,
|
|
406
408
|
// Current page
|
|
407
409
|
currentPage,
|
|
410
|
+
// End of list tracking
|
|
411
|
+
hasReachedEnd,
|
|
412
|
+
// Load error tracking
|
|
413
|
+
loadError,
|
|
408
414
|
// Set fixed dimensions (overrides ResizeObserver)
|
|
409
415
|
setFixedDimensions,
|
|
410
416
|
remove,
|
|
@@ -414,6 +420,7 @@ defineExpose({
|
|
|
414
420
|
loadPage,
|
|
415
421
|
refreshCurrentPage,
|
|
416
422
|
reset,
|
|
423
|
+
destroy,
|
|
417
424
|
init,
|
|
418
425
|
paginationHistory,
|
|
419
426
|
cancelLoad,
|
|
@@ -426,7 +433,7 @@ function calculateHeight(content: any[]) {
|
|
|
426
433
|
const newHeight = calculateContainerHeight(content as any)
|
|
427
434
|
let floor = 0
|
|
428
435
|
if (container.value) {
|
|
429
|
-
const {scrollTop, clientHeight} = container.value
|
|
436
|
+
const { scrollTop, clientHeight } = container.value
|
|
430
437
|
floor = scrollTop + clientHeight + 100
|
|
431
438
|
}
|
|
432
439
|
masonryContentHeight.value = Math.max(newHeight, floor)
|
|
@@ -438,7 +445,7 @@ function refreshLayout(items: any[]) {
|
|
|
438
445
|
masonry.value = items as any
|
|
439
446
|
return
|
|
440
447
|
}
|
|
441
|
-
|
|
448
|
+
|
|
442
449
|
if (!container.value) return
|
|
443
450
|
// Developer diagnostics: warn when dimensions are invalid
|
|
444
451
|
checkItemDimensions(items as any[], 'refreshLayout')
|
|
@@ -447,7 +454,7 @@ function refreshLayout(items: any[]) {
|
|
|
447
454
|
...item,
|
|
448
455
|
originalIndex: item.originalIndex ?? index
|
|
449
456
|
}))
|
|
450
|
-
|
|
457
|
+
|
|
451
458
|
// When fixed dimensions are set, ensure container uses the fixed width for layout
|
|
452
459
|
// This prevents gaps when the container's actual width differs from the fixed width
|
|
453
460
|
const containerEl = container.value as HTMLElement
|
|
@@ -459,13 +466,13 @@ function refreshLayout(items: any[]) {
|
|
|
459
466
|
containerEl.style.width = `${fixedDimensions.value.width}px`
|
|
460
467
|
// Force reflow
|
|
461
468
|
containerEl.offsetWidth
|
|
462
|
-
|
|
469
|
+
|
|
463
470
|
const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
|
|
464
|
-
|
|
471
|
+
|
|
465
472
|
// Restore original width
|
|
466
473
|
containerEl.style.width = originalWidth
|
|
467
474
|
containerEl.style.boxSizing = originalBoxSizing
|
|
468
|
-
|
|
475
|
+
|
|
469
476
|
calculateHeight(content as any)
|
|
470
477
|
masonry.value = content
|
|
471
478
|
} else {
|
|
@@ -518,18 +525,18 @@ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
|
|
|
518
525
|
try {
|
|
519
526
|
const res = await fn()
|
|
520
527
|
if (attempt > 0) {
|
|
521
|
-
emits('retry:stop', {attempt, success: true})
|
|
528
|
+
emits('retry:stop', { attempt, success: true })
|
|
522
529
|
}
|
|
523
530
|
return res
|
|
524
531
|
} catch (err) {
|
|
525
532
|
attempt++
|
|
526
533
|
if (attempt > max) {
|
|
527
|
-
emits('retry:stop', {attempt: attempt - 1, success: false})
|
|
534
|
+
emits('retry:stop', { attempt: attempt - 1, success: false })
|
|
528
535
|
throw err
|
|
529
536
|
}
|
|
530
|
-
emits('retry:start', {attempt, max, totalMs: delay})
|
|
537
|
+
emits('retry:start', { attempt, max, totalMs: delay })
|
|
531
538
|
await waitWithProgress(delay, (remaining, total) => {
|
|
532
|
-
emits('retry:tick', {attempt, remainingMs: remaining, totalMs: total})
|
|
539
|
+
emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
|
|
533
540
|
})
|
|
534
541
|
delay += props.retryBackoffStepMs
|
|
535
542
|
}
|
|
@@ -541,17 +548,28 @@ async function loadPage(page: number) {
|
|
|
541
548
|
// Starting a new load should clear any previous cancel request
|
|
542
549
|
cancelRequested.value = false
|
|
543
550
|
isLoading.value = true
|
|
551
|
+
// Reset hasReachedEnd and loadError when loading a new page
|
|
552
|
+
hasReachedEnd.value = false
|
|
553
|
+
loadError.value = null
|
|
544
554
|
try {
|
|
545
555
|
const baseline = (masonry.value as any[]).length
|
|
546
556
|
if (cancelRequested.value) return
|
|
547
557
|
const response = await getContent(page)
|
|
548
558
|
if (cancelRequested.value) return
|
|
559
|
+
// Clear error on successful load
|
|
560
|
+
loadError.value = null
|
|
549
561
|
currentPage.value = page // Track the current page
|
|
550
562
|
paginationHistory.value.push(response.nextPage)
|
|
563
|
+
// Update hasReachedEnd if nextPage is null
|
|
564
|
+
if (response.nextPage == null) {
|
|
565
|
+
hasReachedEnd.value = true
|
|
566
|
+
}
|
|
551
567
|
await maybeBackfillToTarget(baseline)
|
|
552
568
|
return response
|
|
553
569
|
} catch (error) {
|
|
554
570
|
console.error('Error loading page:', error)
|
|
571
|
+
// Set load error
|
|
572
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
555
573
|
throw error
|
|
556
574
|
} finally {
|
|
557
575
|
isLoading.value = false
|
|
@@ -560,21 +578,39 @@ async function loadPage(page: number) {
|
|
|
560
578
|
|
|
561
579
|
async function loadNext() {
|
|
562
580
|
if (isLoading.value) return
|
|
581
|
+
// Don't load if we've already reached the end
|
|
582
|
+
if (hasReachedEnd.value) return
|
|
563
583
|
// Starting a new load should clear any previous cancel request
|
|
564
584
|
cancelRequested.value = false
|
|
565
585
|
isLoading.value = true
|
|
586
|
+
// Clear error when attempting to load
|
|
587
|
+
loadError.value = null
|
|
566
588
|
try {
|
|
567
589
|
const baseline = (masonry.value as any[]).length
|
|
568
590
|
if (cancelRequested.value) return
|
|
569
591
|
const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
|
|
592
|
+
// Don't load if nextPageToLoad is null
|
|
593
|
+
if (nextPageToLoad == null) {
|
|
594
|
+
hasReachedEnd.value = true
|
|
595
|
+
isLoading.value = false
|
|
596
|
+
return
|
|
597
|
+
}
|
|
570
598
|
const response = await getContent(nextPageToLoad)
|
|
571
599
|
if (cancelRequested.value) return
|
|
600
|
+
// Clear error on successful load
|
|
601
|
+
loadError.value = null
|
|
572
602
|
currentPage.value = nextPageToLoad // Track the current page
|
|
573
603
|
paginationHistory.value.push(response.nextPage)
|
|
604
|
+
// Update hasReachedEnd if nextPage is null
|
|
605
|
+
if (response.nextPage == null) {
|
|
606
|
+
hasReachedEnd.value = true
|
|
607
|
+
}
|
|
574
608
|
await maybeBackfillToTarget(baseline)
|
|
575
609
|
return response
|
|
576
610
|
} catch (error) {
|
|
577
611
|
console.error('Error loading next page:', error)
|
|
612
|
+
// Set load error
|
|
613
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
578
614
|
throw error
|
|
579
615
|
} finally {
|
|
580
616
|
isLoading.value = false
|
|
@@ -589,40 +625,50 @@ async function refreshCurrentPage() {
|
|
|
589
625
|
if (isLoading.value) return
|
|
590
626
|
cancelRequested.value = false
|
|
591
627
|
isLoading.value = true
|
|
592
|
-
|
|
628
|
+
|
|
593
629
|
try {
|
|
594
630
|
// Use the tracked current page
|
|
595
631
|
const pageToRefresh = currentPage.value
|
|
596
|
-
|
|
632
|
+
|
|
597
633
|
if (pageToRefresh == null) {
|
|
598
634
|
console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
|
|
599
635
|
return
|
|
600
636
|
}
|
|
601
|
-
|
|
637
|
+
|
|
602
638
|
// Clear existing items
|
|
603
639
|
masonry.value = []
|
|
604
640
|
masonryContentHeight.value = 0
|
|
605
|
-
|
|
641
|
+
hasReachedEnd.value = false // Reset end flag when refreshing
|
|
642
|
+
loadError.value = null // Reset error flag when refreshing
|
|
643
|
+
|
|
606
644
|
// Reset pagination history to just the current page
|
|
607
645
|
paginationHistory.value = [pageToRefresh]
|
|
608
|
-
|
|
646
|
+
|
|
609
647
|
await nextTick()
|
|
610
|
-
|
|
648
|
+
|
|
611
649
|
// Reload the current page
|
|
612
650
|
const response = await getContent(pageToRefresh)
|
|
613
651
|
if (cancelRequested.value) return
|
|
614
|
-
|
|
652
|
+
|
|
653
|
+
// Clear error on successful load
|
|
654
|
+
loadError.value = null
|
|
615
655
|
// Update pagination state
|
|
616
656
|
currentPage.value = pageToRefresh
|
|
617
657
|
paginationHistory.value.push(response.nextPage)
|
|
618
|
-
|
|
658
|
+
// Update hasReachedEnd if nextPage is null
|
|
659
|
+
if (response.nextPage == null) {
|
|
660
|
+
hasReachedEnd.value = true
|
|
661
|
+
}
|
|
662
|
+
|
|
619
663
|
// Optionally backfill if needed
|
|
620
664
|
const baseline = (masonry.value as any[]).length
|
|
621
665
|
await maybeBackfillToTarget(baseline)
|
|
622
|
-
|
|
666
|
+
|
|
623
667
|
return response
|
|
624
668
|
} catch (error) {
|
|
625
669
|
console.error('[Masonry] Error refreshing current page:', error)
|
|
670
|
+
// Set load error
|
|
671
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
626
672
|
throw error
|
|
627
673
|
} finally {
|
|
628
674
|
isLoading.value = false
|
|
@@ -633,7 +679,7 @@ async function remove(item: any) {
|
|
|
633
679
|
const next = (masonry.value as any[]).filter(i => i.id !== item.id)
|
|
634
680
|
masonry.value = next
|
|
635
681
|
await nextTick()
|
|
636
|
-
|
|
682
|
+
|
|
637
683
|
// If all items were removed, either refresh current page or load next based on prop
|
|
638
684
|
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
639
685
|
if (props.autoRefreshOnEmpty) {
|
|
@@ -644,11 +690,11 @@ async function remove(item: any) {
|
|
|
644
690
|
// Force backfill from 0 to ensure viewport is filled
|
|
645
691
|
// Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
|
|
646
692
|
await maybeBackfillToTarget(0, true)
|
|
647
|
-
} catch {}
|
|
693
|
+
} catch { }
|
|
648
694
|
}
|
|
649
695
|
return
|
|
650
696
|
}
|
|
651
|
-
|
|
697
|
+
|
|
652
698
|
// Commit DOM updates without forcing sync reflow
|
|
653
699
|
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
654
700
|
// Start FLIP on next frame
|
|
@@ -663,7 +709,7 @@ async function removeMany(items: any[]) {
|
|
|
663
709
|
const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
|
|
664
710
|
masonry.value = next
|
|
665
711
|
await nextTick()
|
|
666
|
-
|
|
712
|
+
|
|
667
713
|
// If all items were removed, either refresh current page or load next based on prop
|
|
668
714
|
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
669
715
|
if (props.autoRefreshOnEmpty) {
|
|
@@ -673,11 +719,11 @@ async function removeMany(items: any[]) {
|
|
|
673
719
|
await loadNext()
|
|
674
720
|
// Force backfill from 0 to ensure viewport is filled
|
|
675
721
|
await maybeBackfillToTarget(0, true)
|
|
676
|
-
} catch {}
|
|
722
|
+
} catch { }
|
|
677
723
|
}
|
|
678
724
|
return
|
|
679
725
|
}
|
|
680
|
-
|
|
726
|
+
|
|
681
727
|
// Commit DOM updates without forcing sync reflow
|
|
682
728
|
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
683
729
|
// Start FLIP on next frame
|
|
@@ -699,15 +745,15 @@ function scrollToTop(options?: ScrollToOptions) {
|
|
|
699
745
|
async function removeAll() {
|
|
700
746
|
// Scroll to top first for better UX
|
|
701
747
|
scrollToTop({ behavior: 'smooth' })
|
|
702
|
-
|
|
748
|
+
|
|
703
749
|
// Clear all items
|
|
704
750
|
masonry.value = []
|
|
705
|
-
|
|
751
|
+
|
|
706
752
|
// Recalculate height to 0
|
|
707
753
|
containerHeight.value = 0
|
|
708
|
-
|
|
754
|
+
|
|
709
755
|
await nextTick()
|
|
710
|
-
|
|
756
|
+
|
|
711
757
|
// Emit completion event
|
|
712
758
|
emits('remove-all:complete')
|
|
713
759
|
}
|
|
@@ -728,25 +774,31 @@ async function maybeBackfillToTarget(baselineCount: number, force = false) {
|
|
|
728
774
|
if (!force && !props.backfillEnabled) return
|
|
729
775
|
if (backfillActive) return
|
|
730
776
|
if (cancelRequested.value) return
|
|
777
|
+
// Don't backfill if we've reached the end
|
|
778
|
+
if (hasReachedEnd.value) return
|
|
731
779
|
|
|
732
780
|
const targetCount = (baselineCount || 0) + (props.pageSize || 0)
|
|
733
781
|
if (!props.pageSize || props.pageSize <= 0) return
|
|
734
782
|
|
|
735
783
|
const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
|
|
736
|
-
if (lastNext == null)
|
|
784
|
+
if (lastNext == null) {
|
|
785
|
+
hasReachedEnd.value = true
|
|
786
|
+
return
|
|
787
|
+
}
|
|
737
788
|
|
|
738
789
|
if ((masonry.value as any[]).length >= targetCount) return
|
|
739
790
|
|
|
740
791
|
backfillActive = true
|
|
741
792
|
try {
|
|
742
793
|
let calls = 0
|
|
743
|
-
emits('backfill:start', {target: targetCount, fetched: (masonry.value as any[]).length, calls})
|
|
794
|
+
emits('backfill:start', { target: targetCount, fetched: (masonry.value as any[]).length, calls })
|
|
744
795
|
|
|
745
796
|
while (
|
|
746
797
|
(masonry.value as any[]).length < targetCount &&
|
|
747
798
|
calls < props.backfillMaxCalls &&
|
|
748
799
|
paginationHistory.value[paginationHistory.value.length - 1] != null &&
|
|
749
|
-
!cancelRequested.value
|
|
800
|
+
!cancelRequested.value &&
|
|
801
|
+
!hasReachedEnd.value
|
|
750
802
|
) {
|
|
751
803
|
await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
|
|
752
804
|
emits('backfill:tick', {
|
|
@@ -761,11 +813,24 @@ async function maybeBackfillToTarget(baselineCount: number, force = false) {
|
|
|
761
813
|
if (cancelRequested.value) break
|
|
762
814
|
|
|
763
815
|
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
816
|
+
if (currentPage == null) {
|
|
817
|
+
hasReachedEnd.value = true
|
|
818
|
+
break
|
|
819
|
+
}
|
|
764
820
|
try {
|
|
765
821
|
isLoading.value = true
|
|
766
822
|
const response = await getContent(currentPage)
|
|
767
823
|
if (cancelRequested.value) break
|
|
824
|
+
// Clear error on successful load
|
|
825
|
+
loadError.value = null
|
|
768
826
|
paginationHistory.value.push(response.nextPage)
|
|
827
|
+
// Update hasReachedEnd if nextPage is null
|
|
828
|
+
if (response.nextPage == null) {
|
|
829
|
+
hasReachedEnd.value = true
|
|
830
|
+
}
|
|
831
|
+
} catch (error) {
|
|
832
|
+
// Set load error but don't break the backfill loop
|
|
833
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
769
834
|
} finally {
|
|
770
835
|
isLoading.value = false
|
|
771
836
|
}
|
|
@@ -773,7 +838,7 @@ async function maybeBackfillToTarget(baselineCount: number, force = false) {
|
|
|
773
838
|
calls++
|
|
774
839
|
}
|
|
775
840
|
|
|
776
|
-
emits('backfill:stop', {fetched: (masonry.value as any[]).length, calls})
|
|
841
|
+
emits('backfill:stop', { fetched: (masonry.value as any[]).length, calls })
|
|
777
842
|
} finally {
|
|
778
843
|
backfillActive = false
|
|
779
844
|
}
|
|
@@ -800,6 +865,8 @@ function reset() {
|
|
|
800
865
|
containerHeight.value = 0
|
|
801
866
|
currentPage.value = props.loadAtPage // Reset current page tracking
|
|
802
867
|
paginationHistory.value = [props.loadAtPage]
|
|
868
|
+
hasReachedEnd.value = false // Reset end flag
|
|
869
|
+
loadError.value = null // Reset error flag
|
|
803
870
|
|
|
804
871
|
scrollProgress.value = {
|
|
805
872
|
distanceToTrigger: 0,
|
|
@@ -807,9 +874,52 @@ function reset() {
|
|
|
807
874
|
}
|
|
808
875
|
}
|
|
809
876
|
|
|
877
|
+
function destroy() {
|
|
878
|
+
// Cancel any ongoing loads
|
|
879
|
+
cancelLoad()
|
|
880
|
+
|
|
881
|
+
// Reset all state
|
|
882
|
+
masonry.value = []
|
|
883
|
+
masonryContentHeight.value = 0
|
|
884
|
+
currentPage.value = null
|
|
885
|
+
paginationHistory.value = []
|
|
886
|
+
hasReachedEnd.value = false
|
|
887
|
+
loadError.value = null
|
|
888
|
+
isLoading.value = false
|
|
889
|
+
backfillActive = false
|
|
890
|
+
cancelRequested.value = false
|
|
891
|
+
|
|
892
|
+
// Reset swipe mode state
|
|
893
|
+
currentSwipeIndex.value = 0
|
|
894
|
+
swipeOffset.value = 0
|
|
895
|
+
isDragging.value = false
|
|
896
|
+
|
|
897
|
+
// Reset viewport state
|
|
898
|
+
viewportTop.value = 0
|
|
899
|
+
viewportHeight.value = 0
|
|
900
|
+
virtualizing.value = false
|
|
901
|
+
|
|
902
|
+
// Reset scroll progress
|
|
903
|
+
scrollProgress.value = {
|
|
904
|
+
distanceToTrigger: 0,
|
|
905
|
+
isNearTrigger: false
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Reset invalid dimension tracking
|
|
909
|
+
invalidDimensionIds.value.clear()
|
|
910
|
+
|
|
911
|
+
// Scroll to top if container exists
|
|
912
|
+
if (container.value) {
|
|
913
|
+
container.value.scrollTo({
|
|
914
|
+
top: 0,
|
|
915
|
+
behavior: 'auto' // Instant scroll for destroy
|
|
916
|
+
})
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
810
920
|
const debouncedScrollHandler = debounce(async () => {
|
|
811
921
|
if (useSwipeMode.value) return // Skip scroll handling in swipe mode
|
|
812
|
-
|
|
922
|
+
|
|
813
923
|
if (container.value) {
|
|
814
924
|
viewportTop.value = container.value.scrollTop
|
|
815
925
|
viewportHeight.value = container.value.clientHeight
|
|
@@ -846,10 +956,10 @@ function handleTouchMove(e: TouchEvent) {
|
|
|
846
956
|
function handleTouchEnd(e: TouchEvent) {
|
|
847
957
|
if (!useSwipeMode.value || !isDragging.value) return
|
|
848
958
|
isDragging.value = false
|
|
849
|
-
|
|
959
|
+
|
|
850
960
|
const deltaY = swipeOffset.value - dragStartOffset.value
|
|
851
961
|
const threshold = 100 // Minimum swipe distance to trigger navigation
|
|
852
|
-
|
|
962
|
+
|
|
853
963
|
if (Math.abs(deltaY) > threshold) {
|
|
854
964
|
if (deltaY > 0 && previousItem.value) {
|
|
855
965
|
// Swipe down - go to previous
|
|
@@ -865,7 +975,7 @@ function handleTouchEnd(e: TouchEvent) {
|
|
|
865
975
|
// Snap back if swipe wasn't far enough
|
|
866
976
|
snapToCurrentItem()
|
|
867
977
|
}
|
|
868
|
-
|
|
978
|
+
|
|
869
979
|
e.preventDefault()
|
|
870
980
|
}
|
|
871
981
|
|
|
@@ -888,10 +998,10 @@ function handleMouseMove(e: MouseEvent) {
|
|
|
888
998
|
function handleMouseUp(e: MouseEvent) {
|
|
889
999
|
if (!useSwipeMode.value || !isDragging.value) return
|
|
890
1000
|
isDragging.value = false
|
|
891
|
-
|
|
1001
|
+
|
|
892
1002
|
const deltaY = swipeOffset.value - dragStartOffset.value
|
|
893
1003
|
const threshold = 100
|
|
894
|
-
|
|
1004
|
+
|
|
895
1005
|
if (Math.abs(deltaY) > threshold) {
|
|
896
1006
|
if (deltaY > 0 && previousItem.value) {
|
|
897
1007
|
goToPreviousItem()
|
|
@@ -903,7 +1013,7 @@ function handleMouseUp(e: MouseEvent) {
|
|
|
903
1013
|
} else {
|
|
904
1014
|
snapToCurrentItem()
|
|
905
1015
|
}
|
|
906
|
-
|
|
1016
|
+
|
|
907
1017
|
e.preventDefault()
|
|
908
1018
|
}
|
|
909
1019
|
|
|
@@ -913,10 +1023,10 @@ function goToNextItem() {
|
|
|
913
1023
|
loadNext()
|
|
914
1024
|
return
|
|
915
1025
|
}
|
|
916
|
-
|
|
1026
|
+
|
|
917
1027
|
currentSwipeIndex.value++
|
|
918
1028
|
snapToCurrentItem()
|
|
919
|
-
|
|
1029
|
+
|
|
920
1030
|
// Preload next item if we're near the end
|
|
921
1031
|
if (currentSwipeIndex.value >= masonry.value.length - 5) {
|
|
922
1032
|
loadNext()
|
|
@@ -925,14 +1035,14 @@ function goToNextItem() {
|
|
|
925
1035
|
|
|
926
1036
|
function goToPreviousItem() {
|
|
927
1037
|
if (!previousItem.value) return
|
|
928
|
-
|
|
1038
|
+
|
|
929
1039
|
currentSwipeIndex.value--
|
|
930
1040
|
snapToCurrentItem()
|
|
931
1041
|
}
|
|
932
1042
|
|
|
933
1043
|
function snapToCurrentItem() {
|
|
934
1044
|
if (!swipeContainer.value) return
|
|
935
|
-
|
|
1045
|
+
|
|
936
1046
|
// Use container height for swipe mode instead of window height
|
|
937
1047
|
const viewportHeight = swipeContainer.value.clientHeight
|
|
938
1048
|
swipeOffset.value = -currentSwipeIndex.value * viewportHeight
|
|
@@ -946,12 +1056,12 @@ function handleWindowResize() {
|
|
|
946
1056
|
currentSwipeIndex.value = 0
|
|
947
1057
|
swipeOffset.value = 0
|
|
948
1058
|
}
|
|
949
|
-
|
|
1059
|
+
|
|
950
1060
|
// If switching to swipe mode, ensure we have items loaded
|
|
951
1061
|
if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
|
|
952
1062
|
loadPage(paginationHistory.value[0] as any)
|
|
953
1063
|
}
|
|
954
|
-
|
|
1064
|
+
|
|
955
1065
|
// Re-snap to current item on resize to adjust offset
|
|
956
1066
|
if (useSwipeMode.value) {
|
|
957
1067
|
snapToCurrentItem()
|
|
@@ -962,9 +1072,11 @@ function init(items: any[], page: any, next: any) {
|
|
|
962
1072
|
currentPage.value = page // Track the initial current page
|
|
963
1073
|
paginationHistory.value = [page]
|
|
964
1074
|
paginationHistory.value.push(next)
|
|
1075
|
+
// Update hasReachedEnd if next is null
|
|
1076
|
+
hasReachedEnd.value = next == null
|
|
965
1077
|
// Diagnostics: check incoming initial items
|
|
966
1078
|
checkItemDimensions(items as any[], 'init')
|
|
967
|
-
|
|
1079
|
+
|
|
968
1080
|
if (useSwipeMode.value) {
|
|
969
1081
|
// In swipe mode, just add items without layout calculation
|
|
970
1082
|
masonry.value = [...(masonry.value as any[]), ...items]
|
|
@@ -1004,17 +1116,34 @@ watch(() => props.layoutMode, () => {
|
|
|
1004
1116
|
}
|
|
1005
1117
|
})
|
|
1006
1118
|
|
|
1119
|
+
// Watch container element to attach scroll listener when available
|
|
1120
|
+
watch(container, (el) => {
|
|
1121
|
+
if (el && !useSwipeMode.value) {
|
|
1122
|
+
// Attach scroll listener for masonry mode
|
|
1123
|
+
el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
|
|
1124
|
+
el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
|
|
1125
|
+
} else if (el) {
|
|
1126
|
+
// Remove scroll listener if switching to swipe mode
|
|
1127
|
+
el.removeEventListener('scroll', debouncedScrollHandler)
|
|
1128
|
+
}
|
|
1129
|
+
}, { immediate: true })
|
|
1130
|
+
|
|
1007
1131
|
// Watch for swipe mode changes to refresh layout and setup/teardown handlers
|
|
1008
1132
|
watch(useSwipeMode, (newValue, oldValue) => {
|
|
1009
1133
|
// Skip if this is the initial watch call and values are the same
|
|
1010
1134
|
if (oldValue === undefined && newValue === false) return
|
|
1011
|
-
|
|
1135
|
+
|
|
1012
1136
|
nextTick(() => {
|
|
1013
1137
|
if (newValue) {
|
|
1014
1138
|
// Switching to Swipe Mode
|
|
1015
1139
|
document.addEventListener('mousemove', handleMouseMove)
|
|
1016
1140
|
document.addEventListener('mouseup', handleMouseUp)
|
|
1017
|
-
|
|
1141
|
+
|
|
1142
|
+
// Remove scroll listener
|
|
1143
|
+
if (container.value) {
|
|
1144
|
+
container.value.removeEventListener('scroll', debouncedScrollHandler)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1018
1147
|
// Reset index if needed
|
|
1019
1148
|
currentSwipeIndex.value = 0
|
|
1020
1149
|
swipeOffset.value = 0
|
|
@@ -1025,7 +1154,7 @@ watch(useSwipeMode, (newValue, oldValue) => {
|
|
|
1025
1154
|
// Switching to Masonry Mode
|
|
1026
1155
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
1027
1156
|
document.removeEventListener('mouseup', handleMouseUp)
|
|
1028
|
-
|
|
1157
|
+
|
|
1029
1158
|
if (container.value && wrapper.value) {
|
|
1030
1159
|
// Ensure containerWidth is up to date - use fixed dimensions if set
|
|
1031
1160
|
if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
|
|
@@ -1033,16 +1162,16 @@ watch(useSwipeMode, (newValue, oldValue) => {
|
|
|
1033
1162
|
} else {
|
|
1034
1163
|
containerWidth.value = wrapper.value.clientWidth
|
|
1035
1164
|
}
|
|
1036
|
-
|
|
1037
|
-
//
|
|
1165
|
+
|
|
1166
|
+
// Attach scroll listener (container watcher will handle this, but ensure it's attached)
|
|
1038
1167
|
container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
|
|
1039
1168
|
container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
|
|
1040
|
-
|
|
1169
|
+
|
|
1041
1170
|
// Refresh layout with updated width
|
|
1042
1171
|
if (masonry.value.length > 0) {
|
|
1043
1172
|
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
1044
1173
|
refreshLayout(masonry.value as any)
|
|
1045
|
-
|
|
1174
|
+
|
|
1046
1175
|
// Update viewport state
|
|
1047
1176
|
viewportTop.value = container.value.scrollTop
|
|
1048
1177
|
viewportHeight.value = container.value.clientHeight
|
|
@@ -1078,12 +1207,12 @@ watch(wrapper, (el) => {
|
|
|
1078
1207
|
resizeObserver.disconnect()
|
|
1079
1208
|
resizeObserver = null
|
|
1080
1209
|
}
|
|
1081
|
-
|
|
1210
|
+
|
|
1082
1211
|
if (el && typeof ResizeObserver !== 'undefined') {
|
|
1083
1212
|
resizeObserver = new ResizeObserver((entries) => {
|
|
1084
1213
|
// Skip updates if fixed dimensions are set
|
|
1085
1214
|
if (fixedDimensions.value) return
|
|
1086
|
-
|
|
1215
|
+
|
|
1087
1216
|
for (const entry of entries) {
|
|
1088
1217
|
const newWidth = entry.contentRect.width
|
|
1089
1218
|
const newHeight = entry.contentRect.height
|
|
@@ -1126,14 +1255,14 @@ onMounted(async () => {
|
|
|
1126
1255
|
try {
|
|
1127
1256
|
// Wait for next tick to ensure wrapper is mounted
|
|
1128
1257
|
await nextTick()
|
|
1129
|
-
|
|
1258
|
+
|
|
1130
1259
|
// Container dimensions are managed by ResizeObserver
|
|
1131
1260
|
// Only set initial values if ResizeObserver isn't available
|
|
1132
1261
|
if (wrapper.value && !resizeObserver) {
|
|
1133
1262
|
containerWidth.value = wrapper.value.clientWidth
|
|
1134
1263
|
containerHeight.value = wrapper.value.clientHeight
|
|
1135
1264
|
}
|
|
1136
|
-
|
|
1265
|
+
|
|
1137
1266
|
if (!useSwipeMode.value) {
|
|
1138
1267
|
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
1139
1268
|
if (container.value) {
|
|
@@ -1171,18 +1300,18 @@ onUnmounted(() => {
|
|
|
1171
1300
|
resizeObserver.disconnect()
|
|
1172
1301
|
resizeObserver = null
|
|
1173
1302
|
}
|
|
1174
|
-
|
|
1303
|
+
|
|
1175
1304
|
container.value?.removeEventListener('scroll', debouncedScrollHandler)
|
|
1176
1305
|
window.removeEventListener('resize', debouncedResizeHandler)
|
|
1177
1306
|
window.removeEventListener('resize', handleWindowResize)
|
|
1178
|
-
|
|
1307
|
+
|
|
1179
1308
|
if (swipeContainer.value) {
|
|
1180
1309
|
swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
|
|
1181
1310
|
swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
|
|
1182
1311
|
swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
|
|
1183
1312
|
swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
|
|
1184
1313
|
}
|
|
1185
|
-
|
|
1314
|
+
|
|
1186
1315
|
// Clean up mouse handlers
|
|
1187
1316
|
document.removeEventListener('mousemove', handleMouseMove)
|
|
1188
1317
|
document.removeEventListener('mouseup', handleMouseUp)
|
|
@@ -1192,41 +1321,27 @@ onUnmounted(() => {
|
|
|
1192
1321
|
<template>
|
|
1193
1322
|
<div ref="wrapper" class="w-full h-full flex flex-col relative">
|
|
1194
1323
|
<!-- Swipe Feed Mode (Mobile/Tablet) -->
|
|
1195
|
-
<div v-if="useSwipeMode"
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
height: `${masonry.length * 100}%`
|
|
1206
|
-
}">
|
|
1207
|
-
<div
|
|
1208
|
-
v-for="(item, index) in masonry"
|
|
1209
|
-
:key="`${item.page}-${item.id}`"
|
|
1210
|
-
class="absolute top-0 left-0 w-full"
|
|
1211
|
-
:style="{
|
|
1324
|
+
<div v-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
|
|
1325
|
+
:class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
|
|
1326
|
+
ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
|
|
1327
|
+
<div class="relative w-full" :style="{
|
|
1328
|
+
transform: `translateY(${swipeOffset}px)`,
|
|
1329
|
+
transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
|
|
1330
|
+
height: `${masonry.length * 100}%`
|
|
1331
|
+
}">
|
|
1332
|
+
<div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
|
|
1333
|
+
:style="{
|
|
1212
1334
|
top: `${index * (100 / masonry.length)}%`,
|
|
1213
1335
|
height: `${100 / masonry.length}%`
|
|
1214
1336
|
}">
|
|
1215
1337
|
<div class="w-full h-full flex items-center justify-center p-4">
|
|
1216
1338
|
<div class="w-full h-full max-w-full max-h-full relative">
|
|
1217
1339
|
<slot :item="item" :remove="remove">
|
|
1218
|
-
<MasonryItem
|
|
1219
|
-
:
|
|
1220
|
-
:remove="remove"
|
|
1221
|
-
:header-height="layout.header"
|
|
1222
|
-
:footer-height="layout.footer"
|
|
1223
|
-
:in-swipe-mode="true"
|
|
1224
|
-
:is-active="index === currentSwipeIndex"
|
|
1340
|
+
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1341
|
+
:in-swipe-mode="true" :is-active="index === currentSwipeIndex"
|
|
1225
1342
|
@preload:success="(p) => emits('item:preload:success', p)"
|
|
1226
1343
|
@preload:error="(p) => emits('item:preload:error', p)"
|
|
1227
|
-
@mouse-enter="(p) => emits('item:mouse-enter', p)"
|
|
1228
|
-
@mouse-leave="(p) => emits('item:mouse-leave', p)"
|
|
1229
|
-
>
|
|
1344
|
+
@mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
|
|
1230
1345
|
<!-- Pass through header and footer slots to MasonryItem -->
|
|
1231
1346
|
<template #header="slotProps">
|
|
1232
1347
|
<slot name="item-header" v-bind="slotProps" />
|
|
@@ -1240,39 +1355,35 @@ onUnmounted(() => {
|
|
|
1240
1355
|
</div>
|
|
1241
1356
|
</div>
|
|
1242
1357
|
</div>
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1358
|
+
<!-- End of list message for swipe mode -->
|
|
1359
|
+
<div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
|
|
1360
|
+
<slot name="end-message">
|
|
1361
|
+
<p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
|
|
1362
|
+
</slot>
|
|
1363
|
+
</div>
|
|
1364
|
+
<!-- Error message for swipe mode -->
|
|
1365
|
+
<div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
|
|
1366
|
+
<slot name="error-message" :error="loadError">
|
|
1367
|
+
<p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
|
|
1368
|
+
</slot>
|
|
1369
|
+
</div>
|
|
1247
1370
|
</div>
|
|
1248
1371
|
|
|
1249
1372
|
<!-- Masonry Grid Mode (Desktop) -->
|
|
1250
|
-
<div v-else
|
|
1251
|
-
|
|
1252
|
-
:class="{ 'force-motion': props.forceMotion }"
|
|
1253
|
-
ref="container">
|
|
1373
|
+
<div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
|
|
1374
|
+
ref="container">
|
|
1254
1375
|
<div class="relative"
|
|
1255
|
-
|
|
1256
|
-
<transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter"
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
class="absolute masonry-item"
|
|
1261
|
-
v-bind="getItemAttributes(item, i)">
|
|
1376
|
+
:style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
|
|
1377
|
+
<transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
|
|
1378
|
+
@before-leave="beforeLeave">
|
|
1379
|
+
<div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
|
|
1380
|
+
v-bind="getItemAttributes(item, i)">
|
|
1262
1381
|
<!-- Use default slot if provided, otherwise use MasonryItem -->
|
|
1263
1382
|
<slot :item="item" :remove="remove">
|
|
1264
|
-
<MasonryItem
|
|
1265
|
-
:
|
|
1266
|
-
:remove="remove"
|
|
1267
|
-
:header-height="layout.header"
|
|
1268
|
-
:footer-height="layout.footer"
|
|
1269
|
-
:in-swipe-mode="false"
|
|
1270
|
-
:is-active="false"
|
|
1271
|
-
@preload:success="(p) => emits('item:preload:success', p)"
|
|
1383
|
+
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1384
|
+
:in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
|
|
1272
1385
|
@preload:error="(p) => emits('item:preload:error', p)"
|
|
1273
|
-
@mouse-enter="(p) => emits('item:mouse-enter', p)"
|
|
1274
|
-
@mouse-leave="(p) => emits('item:mouse-leave', p)"
|
|
1275
|
-
>
|
|
1386
|
+
@mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
|
|
1276
1387
|
<!-- Pass through header and footer slots to MasonryItem -->
|
|
1277
1388
|
<template #header="slotProps">
|
|
1278
1389
|
<slot name="item-header" v-bind="slotProps" />
|
|
@@ -1284,8 +1395,18 @@ onUnmounted(() => {
|
|
|
1284
1395
|
</slot>
|
|
1285
1396
|
</div>
|
|
1286
1397
|
</transition-group>
|
|
1287
|
-
|
|
1288
|
-
|
|
1398
|
+
</div>
|
|
1399
|
+
<!-- End of list message -->
|
|
1400
|
+
<div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
|
|
1401
|
+
<slot name="end-message">
|
|
1402
|
+
<p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
|
|
1403
|
+
</slot>
|
|
1404
|
+
</div>
|
|
1405
|
+
<!-- Error message -->
|
|
1406
|
+
<div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
|
|
1407
|
+
<slot name="error-message" :error="loadError">
|
|
1408
|
+
<p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
|
|
1409
|
+
</slot>
|
|
1289
1410
|
</div>
|
|
1290
1411
|
</div>
|
|
1291
1412
|
</div>
|
|
@@ -1300,7 +1421,7 @@ onUnmounted(() => {
|
|
|
1300
1421
|
will-change: transform, opacity;
|
|
1301
1422
|
contain: layout paint;
|
|
1302
1423
|
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
|
|
1303
|
-
|
|
1424
|
+
opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
|
|
1304
1425
|
backface-visibility: hidden;
|
|
1305
1426
|
}
|
|
1306
1427
|
|
|
@@ -1309,6 +1430,7 @@ onUnmounted(() => {
|
|
|
1309
1430
|
}
|
|
1310
1431
|
|
|
1311
1432
|
@media (prefers-reduced-motion: reduce) {
|
|
1433
|
+
|
|
1312
1434
|
.masonry-container:not(.force-motion) .masonry-item,
|
|
1313
1435
|
.masonry-container:not(.force-motion) .masonry-move {
|
|
1314
1436
|
transition-duration: 1ms !important;
|