@wyxos/vibe 1.6.20 → 1.6.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +961 -836
- package/lib/vibe.css +1 -1
- package/package.json +1 -1
- package/src/Masonry.vue +1646 -1566
- package/src/components/MasonryItem.vue +4 -1
- package/src/pages.json +16472 -16472
- package/src/useMasonryScroll.ts +60 -60
- package/src/useMasonryTransitions.ts +52 -1
- package/src/useSwipeMode.ts +233 -0
- package/src/utils/errorHandler.ts +8 -0
package/src/useMasonryScroll.ts
CHANGED
|
@@ -1,60 +1,60 @@
|
|
|
1
|
-
import { nextTick, type Ref } from 'vue'
|
|
2
|
-
import { calculateColumnHeights } from './masonryUtils'
|
|
3
|
-
import type { ProcessedMasonryItem } from './types'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Composable for handling masonry scroll behavior and item cleanup
|
|
7
|
-
*/
|
|
8
|
-
export function useMasonryScroll({
|
|
9
|
-
container,
|
|
10
|
-
masonry,
|
|
11
|
-
columns,
|
|
12
|
-
containerHeight,
|
|
13
|
-
isLoading,
|
|
14
|
-
pageSize,
|
|
15
|
-
refreshLayout,
|
|
16
|
-
setItemsRaw,
|
|
17
|
-
loadNext,
|
|
18
|
-
loadThresholdPx
|
|
19
|
-
}: {
|
|
20
|
-
container: Ref<HTMLElement | null>
|
|
21
|
-
masonry: Ref<ProcessedMasonryItem[]>
|
|
22
|
-
columns: Ref<number>
|
|
23
|
-
containerHeight: Ref<number>
|
|
24
|
-
isLoading: Ref<boolean>
|
|
25
|
-
pageSize: number
|
|
26
|
-
refreshLayout: (items: ProcessedMasonryItem[]) => void
|
|
27
|
-
setItemsRaw: (items: ProcessedMasonryItem[]) => void
|
|
28
|
-
loadNext: () => Promise<any>
|
|
29
|
-
loadThresholdPx?: number
|
|
30
|
-
}) {
|
|
31
|
-
let cleanupInProgress = false
|
|
32
|
-
let lastScrollTop = 0
|
|
33
|
-
|
|
34
|
-
async function handleScroll(precomputedHeights?: number[]) {
|
|
35
|
-
if (!container.value) return
|
|
36
|
-
|
|
37
|
-
const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value, columns.value)
|
|
38
|
-
const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
|
|
39
|
-
const scrollerBottom = container.value.scrollTop + container.value.clientHeight
|
|
40
|
-
|
|
41
|
-
const isScrollingDown = container.value.scrollTop > lastScrollTop + 1 // tolerate tiny jitter
|
|
42
|
-
lastScrollTop = container.value.scrollTop
|
|
43
|
-
|
|
44
|
-
const threshold = typeof loadThresholdPx === 'number' ? loadThresholdPx : 200
|
|
45
|
-
const triggerPoint = threshold >= 0
|
|
46
|
-
? Math.max(0, tallest - threshold)
|
|
47
|
-
: Math.max(0, tallest + threshold)
|
|
48
|
-
const nearBottom = scrollerBottom >= triggerPoint
|
|
49
|
-
|
|
50
|
-
if (nearBottom && isScrollingDown && !isLoading.value) {
|
|
51
|
-
await loadNext()
|
|
52
|
-
await nextTick()
|
|
53
|
-
return
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return {
|
|
58
|
-
handleScroll
|
|
59
|
-
}
|
|
60
|
-
}
|
|
1
|
+
import { nextTick, type Ref } from 'vue'
|
|
2
|
+
import { calculateColumnHeights } from './masonryUtils'
|
|
3
|
+
import type { ProcessedMasonryItem } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Composable for handling masonry scroll behavior and item cleanup
|
|
7
|
+
*/
|
|
8
|
+
export function useMasonryScroll({
|
|
9
|
+
container,
|
|
10
|
+
masonry,
|
|
11
|
+
columns,
|
|
12
|
+
containerHeight,
|
|
13
|
+
isLoading,
|
|
14
|
+
pageSize,
|
|
15
|
+
refreshLayout,
|
|
16
|
+
setItemsRaw,
|
|
17
|
+
loadNext,
|
|
18
|
+
loadThresholdPx
|
|
19
|
+
}: {
|
|
20
|
+
container: Ref<HTMLElement | null>
|
|
21
|
+
masonry: Ref<ProcessedMasonryItem[]>
|
|
22
|
+
columns: Ref<number>
|
|
23
|
+
containerHeight: Ref<number>
|
|
24
|
+
isLoading: Ref<boolean>
|
|
25
|
+
pageSize: number
|
|
26
|
+
refreshLayout: (items: ProcessedMasonryItem[]) => void
|
|
27
|
+
setItemsRaw: (items: ProcessedMasonryItem[]) => void
|
|
28
|
+
loadNext: () => Promise<any>
|
|
29
|
+
loadThresholdPx?: number
|
|
30
|
+
}) {
|
|
31
|
+
let cleanupInProgress = false
|
|
32
|
+
let lastScrollTop = 0
|
|
33
|
+
|
|
34
|
+
async function handleScroll(precomputedHeights?: number[]) {
|
|
35
|
+
if (!container.value) return
|
|
36
|
+
|
|
37
|
+
const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value, columns.value)
|
|
38
|
+
const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
|
|
39
|
+
const scrollerBottom = container.value.scrollTop + container.value.clientHeight
|
|
40
|
+
|
|
41
|
+
const isScrollingDown = container.value.scrollTop > lastScrollTop + 1 // tolerate tiny jitter
|
|
42
|
+
lastScrollTop = container.value.scrollTop
|
|
43
|
+
|
|
44
|
+
const threshold = typeof loadThresholdPx === 'number' ? loadThresholdPx : 200
|
|
45
|
+
const triggerPoint = threshold >= 0
|
|
46
|
+
? Math.max(0, tallest - threshold)
|
|
47
|
+
: Math.max(0, tallest + threshold)
|
|
48
|
+
const nearBottom = scrollerBottom >= triggerPoint
|
|
49
|
+
|
|
50
|
+
if (nearBottom && isScrollingDown && !isLoading.value) {
|
|
51
|
+
await loadNext()
|
|
52
|
+
await nextTick()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
handleScroll
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -1,11 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Composable for handling masonry item transitions (typed)
|
|
3
|
+
* Optimized for large item arrays by skipping DOM operations for items outside viewport
|
|
3
4
|
*/
|
|
4
|
-
export function useMasonryTransitions(
|
|
5
|
+
export function useMasonryTransitions(refs: { container?: any; masonry?: any }, opts?: { leaveDurationMs?: number }) {
|
|
6
|
+
// Cache viewport bounds to avoid repeated calculations
|
|
7
|
+
let cachedViewportTop = 0
|
|
8
|
+
let cachedViewportBottom = 0
|
|
9
|
+
let cachedViewportHeight = 0
|
|
10
|
+
const VIEWPORT_BUFFER_PX = 1000 // Buffer zone for items near viewport
|
|
11
|
+
|
|
12
|
+
// Check if item is in viewport (with buffer) - optimized to skip DOM reads
|
|
13
|
+
function isItemInViewport(itemTop: number, itemHeight: number): boolean {
|
|
14
|
+
// Update cached viewport bounds if container exists
|
|
15
|
+
const container = refs.container?.value
|
|
16
|
+
if (container) {
|
|
17
|
+
const scrollTop = container.scrollTop
|
|
18
|
+
const clientHeight = container.clientHeight
|
|
19
|
+
cachedViewportTop = scrollTop - VIEWPORT_BUFFER_PX
|
|
20
|
+
cachedViewportBottom = scrollTop + clientHeight + VIEWPORT_BUFFER_PX
|
|
21
|
+
cachedViewportHeight = clientHeight
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const itemBottom = itemTop + itemHeight
|
|
25
|
+
return itemBottom >= cachedViewportTop && itemTop <= cachedViewportBottom
|
|
26
|
+
}
|
|
27
|
+
|
|
5
28
|
function onEnter(el: HTMLElement, done: () => void) {
|
|
6
29
|
const left = parseInt(el.dataset.left || '0', 10)
|
|
7
30
|
const top = parseInt(el.dataset.top || '0', 10)
|
|
8
31
|
const index = parseInt(el.dataset.index || '0', 10)
|
|
32
|
+
// Get height from computed style or use a reasonable fallback
|
|
33
|
+
const height = el.offsetHeight || parseInt(getComputedStyle(el).height || '200', 10) || 200
|
|
34
|
+
|
|
35
|
+
// Skip animation for items outside viewport - just set position immediately
|
|
36
|
+
if (!isItemInViewport(top, height)) {
|
|
37
|
+
el.style.opacity = '1'
|
|
38
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
39
|
+
el.style.transition = 'none'
|
|
40
|
+
done()
|
|
41
|
+
return
|
|
42
|
+
}
|
|
9
43
|
|
|
10
44
|
const delay = Math.min(index * 20, 160)
|
|
11
45
|
const prevOpacityDelay = el.style.getPropertyValue('--masonry-opacity-delay')
|
|
@@ -37,6 +71,14 @@ export function useMasonryTransitions(masonry: any, opts?: { leaveDurationMs?: n
|
|
|
37
71
|
function onBeforeLeave(el: HTMLElement) {
|
|
38
72
|
const left = parseInt(el.dataset.left || '0', 10)
|
|
39
73
|
const top = parseInt(el.dataset.top || '0', 10)
|
|
74
|
+
const height = el.offsetHeight || parseInt(getComputedStyle(el).height || '200', 10) || 200
|
|
75
|
+
|
|
76
|
+
// Skip animation for items outside viewport
|
|
77
|
+
if (!isItemInViewport(top, height)) {
|
|
78
|
+
el.style.transition = 'none'
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
40
82
|
el.style.transition = 'none'
|
|
41
83
|
el.style.opacity = '1'
|
|
42
84
|
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
@@ -50,6 +92,15 @@ export function useMasonryTransitions(masonry: any, opts?: { leaveDurationMs?: n
|
|
|
50
92
|
function onLeave(el: HTMLElement, done: () => void) {
|
|
51
93
|
const left = parseInt(el.dataset.left || '0', 10)
|
|
52
94
|
const top = parseInt(el.dataset.top || '0', 10)
|
|
95
|
+
const height = el.offsetHeight || parseInt(getComputedStyle(el).height || '200', 10) || 200
|
|
96
|
+
|
|
97
|
+
// Skip animation for items outside viewport - remove immediately
|
|
98
|
+
if (!isItemInViewport(top, height)) {
|
|
99
|
+
el.style.transition = 'none'
|
|
100
|
+
el.style.opacity = '0'
|
|
101
|
+
done()
|
|
102
|
+
return
|
|
103
|
+
}
|
|
53
104
|
|
|
54
105
|
// Prefer explicit option, fallback to CSS variable for safety
|
|
55
106
|
const fromOpts = typeof opts?.leaveDurationMs === 'number' ? opts!.leaveDurationMs : NaN
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { computed, ref, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UseSwipeModeOptions {
|
|
4
|
+
useSwipeMode: ComputedRef<boolean>
|
|
5
|
+
masonry: Ref<any[]>
|
|
6
|
+
isLoading: Ref<boolean>
|
|
7
|
+
loadNext: () => Promise<any>
|
|
8
|
+
loadPage: (page: any) => Promise<any>
|
|
9
|
+
paginationHistory: Ref<any[]>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UseSwipeModeReturn {
|
|
13
|
+
// State
|
|
14
|
+
currentSwipeIndex: Ref<number>
|
|
15
|
+
swipeOffset: Ref<number>
|
|
16
|
+
isDragging: Ref<boolean>
|
|
17
|
+
swipeContainer: Ref<HTMLElement | null>
|
|
18
|
+
|
|
19
|
+
// Computed
|
|
20
|
+
currentItem: ComputedRef<any | null>
|
|
21
|
+
nextItem: ComputedRef<any | null>
|
|
22
|
+
previousItem: ComputedRef<any | null>
|
|
23
|
+
|
|
24
|
+
// Functions
|
|
25
|
+
handleTouchStart: (e: TouchEvent) => void
|
|
26
|
+
handleTouchMove: (e: TouchEvent) => void
|
|
27
|
+
handleTouchEnd: (e: TouchEvent) => void
|
|
28
|
+
handleMouseDown: (e: MouseEvent) => void
|
|
29
|
+
handleMouseMove: (e: MouseEvent) => void
|
|
30
|
+
handleMouseUp: (e: MouseEvent) => void
|
|
31
|
+
goToNextItem: () => void
|
|
32
|
+
goToPreviousItem: () => void
|
|
33
|
+
snapToCurrentItem: () => void
|
|
34
|
+
handleWindowResize: () => void
|
|
35
|
+
reset: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function useSwipeMode(options: UseSwipeModeOptions): UseSwipeModeReturn {
|
|
39
|
+
const { useSwipeMode, masonry, isLoading, loadNext, loadPage, paginationHistory } = options
|
|
40
|
+
|
|
41
|
+
// Swipe mode state
|
|
42
|
+
const currentSwipeIndex = ref<number>(0)
|
|
43
|
+
const swipeOffset = ref<number>(0)
|
|
44
|
+
const isDragging = ref<boolean>(false)
|
|
45
|
+
const dragStartY = ref<number>(0)
|
|
46
|
+
const dragStartOffset = ref<number>(0)
|
|
47
|
+
const swipeContainer = ref<HTMLElement | null>(null)
|
|
48
|
+
|
|
49
|
+
// Get current item index for swipe mode
|
|
50
|
+
const currentItem = computed(() => {
|
|
51
|
+
if (!useSwipeMode.value || masonry.value.length === 0) return null
|
|
52
|
+
const index = Math.max(0, Math.min(currentSwipeIndex.value, masonry.value.length - 1))
|
|
53
|
+
return masonry.value[index] || null
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Get next/previous items for preloading in swipe mode
|
|
57
|
+
const nextItem = computed(() => {
|
|
58
|
+
if (!useSwipeMode.value || !currentItem.value) return null
|
|
59
|
+
const nextIndex = currentSwipeIndex.value + 1
|
|
60
|
+
if (nextIndex >= masonry.value.length) return null
|
|
61
|
+
return masonry.value[nextIndex] || null
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const previousItem = computed(() => {
|
|
65
|
+
if (!useSwipeMode.value || !currentItem.value) return null
|
|
66
|
+
const prevIndex = currentSwipeIndex.value - 1
|
|
67
|
+
if (prevIndex < 0) return null
|
|
68
|
+
return masonry.value[prevIndex] || null
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
function snapToCurrentItem() {
|
|
72
|
+
if (!swipeContainer.value) return
|
|
73
|
+
|
|
74
|
+
// Use container height for swipe mode instead of window height
|
|
75
|
+
const viewportHeight = swipeContainer.value.clientHeight
|
|
76
|
+
swipeOffset.value = -currentSwipeIndex.value * viewportHeight
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function goToNextItem() {
|
|
80
|
+
if (!nextItem.value) {
|
|
81
|
+
// Try to load next page
|
|
82
|
+
loadNext()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
currentSwipeIndex.value++
|
|
87
|
+
snapToCurrentItem()
|
|
88
|
+
|
|
89
|
+
// Preload next item if we're near the end
|
|
90
|
+
if (currentSwipeIndex.value >= masonry.value.length - 5) {
|
|
91
|
+
loadNext()
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function goToPreviousItem() {
|
|
96
|
+
if (!previousItem.value) return
|
|
97
|
+
|
|
98
|
+
currentSwipeIndex.value--
|
|
99
|
+
snapToCurrentItem()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Swipe gesture handlers
|
|
103
|
+
function handleTouchStart(e: TouchEvent) {
|
|
104
|
+
if (!useSwipeMode.value) return
|
|
105
|
+
isDragging.value = true
|
|
106
|
+
dragStartY.value = e.touches[0].clientY
|
|
107
|
+
dragStartOffset.value = swipeOffset.value
|
|
108
|
+
e.preventDefault()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function handleTouchMove(e: TouchEvent) {
|
|
112
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
113
|
+
const deltaY = e.touches[0].clientY - dragStartY.value
|
|
114
|
+
swipeOffset.value = dragStartOffset.value + deltaY
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function handleTouchEnd(e: TouchEvent) {
|
|
119
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
120
|
+
isDragging.value = false
|
|
121
|
+
|
|
122
|
+
const deltaY = swipeOffset.value - dragStartOffset.value
|
|
123
|
+
const threshold = 100 // Minimum swipe distance to trigger navigation
|
|
124
|
+
|
|
125
|
+
if (Math.abs(deltaY) > threshold) {
|
|
126
|
+
if (deltaY > 0 && previousItem.value) {
|
|
127
|
+
// Swipe down - go to previous
|
|
128
|
+
goToPreviousItem()
|
|
129
|
+
} else if (deltaY < 0 && nextItem.value) {
|
|
130
|
+
// Swipe up - go to next
|
|
131
|
+
goToNextItem()
|
|
132
|
+
} else {
|
|
133
|
+
// Snap back
|
|
134
|
+
snapToCurrentItem()
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
// Snap back if swipe wasn't far enough
|
|
138
|
+
snapToCurrentItem()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
e.preventDefault()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Mouse drag handlers for desktop testing
|
|
145
|
+
function handleMouseDown(e: MouseEvent) {
|
|
146
|
+
if (!useSwipeMode.value) return
|
|
147
|
+
isDragging.value = true
|
|
148
|
+
dragStartY.value = e.clientY
|
|
149
|
+
dragStartOffset.value = swipeOffset.value
|
|
150
|
+
e.preventDefault()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleMouseMove(e: MouseEvent) {
|
|
154
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
155
|
+
const deltaY = e.clientY - dragStartY.value
|
|
156
|
+
swipeOffset.value = dragStartOffset.value + deltaY
|
|
157
|
+
e.preventDefault()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function handleMouseUp(e: MouseEvent) {
|
|
161
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
162
|
+
isDragging.value = false
|
|
163
|
+
|
|
164
|
+
const deltaY = swipeOffset.value - dragStartOffset.value
|
|
165
|
+
const threshold = 100
|
|
166
|
+
|
|
167
|
+
if (Math.abs(deltaY) > threshold) {
|
|
168
|
+
if (deltaY > 0 && previousItem.value) {
|
|
169
|
+
goToPreviousItem()
|
|
170
|
+
} else if (deltaY < 0 && nextItem.value) {
|
|
171
|
+
goToNextItem()
|
|
172
|
+
} else {
|
|
173
|
+
snapToCurrentItem()
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
snapToCurrentItem()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
e.preventDefault()
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Watch for container/window resize to update swipe mode
|
|
183
|
+
function handleWindowResize() {
|
|
184
|
+
// If switching from swipe to masonry, reset swipe state
|
|
185
|
+
if (!useSwipeMode.value && currentSwipeIndex.value > 0) {
|
|
186
|
+
currentSwipeIndex.value = 0
|
|
187
|
+
swipeOffset.value = 0
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// If switching to swipe mode, ensure we have items loaded
|
|
191
|
+
if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
|
|
192
|
+
loadPage(paginationHistory.value[0] as any)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Re-snap to current item on resize to adjust offset
|
|
196
|
+
if (useSwipeMode.value) {
|
|
197
|
+
snapToCurrentItem()
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function reset() {
|
|
202
|
+
currentSwipeIndex.value = 0
|
|
203
|
+
swipeOffset.value = 0
|
|
204
|
+
isDragging.value = false
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
// State
|
|
209
|
+
currentSwipeIndex,
|
|
210
|
+
swipeOffset,
|
|
211
|
+
isDragging,
|
|
212
|
+
swipeContainer,
|
|
213
|
+
|
|
214
|
+
// Computed
|
|
215
|
+
currentItem,
|
|
216
|
+
nextItem,
|
|
217
|
+
previousItem,
|
|
218
|
+
|
|
219
|
+
// Functions
|
|
220
|
+
handleTouchStart,
|
|
221
|
+
handleTouchMove,
|
|
222
|
+
handleTouchEnd,
|
|
223
|
+
handleMouseDown,
|
|
224
|
+
handleMouseMove,
|
|
225
|
+
handleMouseUp,
|
|
226
|
+
goToNextItem,
|
|
227
|
+
goToPreviousItem,
|
|
228
|
+
snapToCurrentItem,
|
|
229
|
+
handleWindowResize,
|
|
230
|
+
reset
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|