@wyxos/vibe 1.2.13 → 1.2.15
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/package.json +1 -1
- package/src/App.vue +2 -1
- package/src/Masonry.vue +42 -153
- package/src/masonryUtils.js +59 -0
- package/src/useMasonryScroll.js +89 -0
- package/src/useMasonryTransitions.js +45 -0
package/package.json
CHANGED
package/src/App.vue
CHANGED
|
@@ -43,8 +43,9 @@ const getPage = async (page) => {
|
|
|
43
43
|
💾 <a href="https://github.com/wyxos/vibe" target="_blank" class="underline hover:text-black">Source on GitHub</a>
|
|
44
44
|
</p>
|
|
45
45
|
|
|
46
|
-
<div v-if="masonry">
|
|
46
|
+
<div v-if="masonry" class="flex gap-4">
|
|
47
47
|
<p>Loading: <span class="bg-blue-500 text-white p-2 rounded">{{ masonry.isLoading }}</span></p>
|
|
48
|
+
<p>Showing: <span class="bg-blue-500 text-white p-2 rounded">{{ items.length }}</span></p>
|
|
48
49
|
</div>
|
|
49
50
|
</header>
|
|
50
51
|
<masonry class="bg-blue-500 " v-model:items="items" :get-next-page="getPage" :load-at-page="1" ref="masonry">
|
package/src/Masonry.vue
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
import {computed, nextTick, onMounted, onUnmounted, ref} from "vue";
|
|
3
3
|
import calculateLayout from "./calculateLayout.js";
|
|
4
4
|
import { debounce } from 'lodash-es'
|
|
5
|
+
import {
|
|
6
|
+
getColumnCount,
|
|
7
|
+
calculateContainerHeight,
|
|
8
|
+
getItemAttributes,
|
|
9
|
+
calculateColumnHeights
|
|
10
|
+
} from './masonryUtils.js'
|
|
11
|
+
import { useMasonryTransitions } from './useMasonryTransitions.js'
|
|
12
|
+
import { useMasonryScroll } from './useMasonryScroll.js'
|
|
5
13
|
|
|
6
14
|
const props = defineProps({
|
|
7
15
|
getNextPage: {
|
|
@@ -28,6 +36,10 @@ const props = defineProps({
|
|
|
28
36
|
skipInitialLoad: {
|
|
29
37
|
type: Boolean,
|
|
30
38
|
default: false
|
|
39
|
+
},
|
|
40
|
+
maxItems: {
|
|
41
|
+
type: Number,
|
|
42
|
+
default: 100
|
|
31
43
|
}
|
|
32
44
|
})
|
|
33
45
|
|
|
@@ -70,13 +82,21 @@ const isLoading = ref(false)
|
|
|
70
82
|
const containerHeight = ref(0)
|
|
71
83
|
|
|
72
84
|
const columnHeights = computed(() => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
return calculateColumnHeights(masonry.value, columns.value)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Setup composables
|
|
89
|
+
const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry)
|
|
90
|
+
|
|
91
|
+
const { handleScroll } = useMasonryScroll({
|
|
92
|
+
container,
|
|
93
|
+
masonry,
|
|
94
|
+
columns,
|
|
95
|
+
containerHeight,
|
|
96
|
+
isLoading,
|
|
97
|
+
maxItems: props.maxItems,
|
|
98
|
+
refreshLayout,
|
|
99
|
+
loadNext
|
|
80
100
|
})
|
|
81
101
|
|
|
82
102
|
defineExpose({
|
|
@@ -84,97 +104,12 @@ defineExpose({
|
|
|
84
104
|
refreshLayout,
|
|
85
105
|
containerHeight,
|
|
86
106
|
onRemove,
|
|
87
|
-
loadNext
|
|
107
|
+
loadNext,
|
|
108
|
+
loadPage
|
|
88
109
|
})
|
|
89
110
|
|
|
90
|
-
async function onScroll() {
|
|
91
|
-
const {scrollTop, clientHeight} = container.value
|
|
92
|
-
const visibleBottom = scrollTop + clientHeight
|
|
93
|
-
|
|
94
|
-
const whitespaceVisible = columnHeights.value.some(height => height + 300 < visibleBottom - 1)
|
|
95
|
-
|
|
96
|
-
const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
|
|
97
|
-
|
|
98
|
-
if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value) {
|
|
99
|
-
isLoading.value = true
|
|
100
|
-
|
|
101
|
-
if (paginationHistory.value.length > 3) {
|
|
102
|
-
// get first item - only proceed if it exists
|
|
103
|
-
const firstItem = masonry.value[0]
|
|
104
|
-
|
|
105
|
-
if (!firstItem) {
|
|
106
|
-
// Skip removal logic if there are no items
|
|
107
|
-
await loadNext()
|
|
108
|
-
await nextTick()
|
|
109
|
-
isLoading.value = false
|
|
110
|
-
return
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// get page number
|
|
114
|
-
const page = firstItem.page
|
|
115
|
-
|
|
116
|
-
// find all item with this page
|
|
117
|
-
const removedItems = masonry.value.filter(i => i.page !== page)
|
|
118
|
-
|
|
119
|
-
// Only proceed with removal if there are actually items to remove
|
|
120
|
-
if (removedItems.length === masonry.value.length) {
|
|
121
|
-
// All items belong to the same page, skip removal logic
|
|
122
|
-
await loadNext()
|
|
123
|
-
await nextTick()
|
|
124
|
-
isLoading.value = false
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
refreshLayout(removedItems)
|
|
129
|
-
|
|
130
|
-
await nextTick()
|
|
131
|
-
|
|
132
|
-
const lowestColumnIndex = columnHeights.value.indexOf(Math.min(...columnHeights.value))
|
|
133
|
-
|
|
134
|
-
// find the last item in that column
|
|
135
|
-
const lastItemInColumn = masonry.value.filter((_, index) => index % columns.value === lowestColumnIndex).pop()
|
|
136
|
-
|
|
137
|
-
// Only proceed with scroll adjustment if we have a valid item
|
|
138
|
-
if (lastItemInColumn) {
|
|
139
|
-
const lastItemInColumnTop = lastItemInColumn.top + lastItemInColumn.columnHeight
|
|
140
|
-
const lastItemInColumnBottom = lastItemInColumnTop + lastItemInColumn.columnHeight
|
|
141
|
-
const containerTop = container.value.scrollTop
|
|
142
|
-
const containerBottom = containerTop + container.value.clientHeight
|
|
143
|
-
const itemInView = lastItemInColumnTop >= containerTop && lastItemInColumnBottom <= containerBottom
|
|
144
|
-
if (!itemInView) {
|
|
145
|
-
container.value.scrollTo({
|
|
146
|
-
top: lastItemInColumnTop - 10,
|
|
147
|
-
behavior: 'smooth'
|
|
148
|
-
})
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
await loadNext()
|
|
154
|
-
|
|
155
|
-
await nextTick()
|
|
156
|
-
|
|
157
|
-
isLoading.value = false
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function getColumnCount() {
|
|
162
|
-
const width = window.innerWidth
|
|
163
|
-
|
|
164
|
-
const sizes = layout.value.sizes
|
|
165
|
-
|
|
166
|
-
if (width >= 1536 && sizes['2xl']) return sizes['2xl']
|
|
167
|
-
if (width >= 1280 && sizes.xl) return sizes.xl
|
|
168
|
-
if (width >= 1024 && sizes.lg) return sizes.lg
|
|
169
|
-
if (width >= 768 && sizes.md) return sizes.md
|
|
170
|
-
if (width >= 640 && sizes.sm) return sizes.sm
|
|
171
|
-
return sizes.base
|
|
172
|
-
}
|
|
173
|
-
|
|
174
111
|
function calculateHeight(content) {
|
|
175
|
-
containerHeight.value = content
|
|
176
|
-
return Math.max(acc, item.top + item.columnHeight)
|
|
177
|
-
}, 0)
|
|
112
|
+
containerHeight.value = calculateContainerHeight(content)
|
|
178
113
|
}
|
|
179
114
|
|
|
180
115
|
function refreshLayout(items) {
|
|
@@ -193,75 +128,30 @@ async function getContent(page) {
|
|
|
193
128
|
return response
|
|
194
129
|
}
|
|
195
130
|
|
|
196
|
-
async function
|
|
197
|
-
const response = await getContent(
|
|
131
|
+
async function loadPage(page) {
|
|
132
|
+
const response = await getContent(page)
|
|
198
133
|
paginationHistory.value.push(response.nextPage)
|
|
134
|
+
return response
|
|
199
135
|
}
|
|
200
136
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
left: `${item.left}px`,
|
|
205
|
-
width: `${item.columnWidth}px`,
|
|
206
|
-
height: `${item.columnHeight}px`
|
|
207
|
-
}
|
|
137
|
+
async function loadNext() {
|
|
138
|
+
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
139
|
+
return await loadPage(currentPage)
|
|
208
140
|
}
|
|
209
141
|
|
|
210
142
|
function onRemove(item) {
|
|
211
143
|
refreshLayout(masonry.value.filter(i => i.id !== item.id))
|
|
212
144
|
}
|
|
213
145
|
|
|
214
|
-
function onEnter(el, done) {
|
|
215
|
-
// set top to data-top
|
|
216
|
-
const top = el.dataset.top
|
|
217
|
-
requestAnimationFrame(() => {
|
|
218
|
-
el.style.top = `${top}px`
|
|
219
|
-
done()
|
|
220
|
-
})
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function onBeforeEnter(el) {
|
|
224
|
-
// set top to last item + 500
|
|
225
|
-
const lastItem = masonry.value[masonry.value.length - 1]
|
|
226
|
-
if (lastItem) {
|
|
227
|
-
const lastTop = lastItem.top + lastItem.columnHeight + 10
|
|
228
|
-
el.style.top = `${lastTop}px`
|
|
229
|
-
} else {
|
|
230
|
-
el.style.top = '0px'
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function onBeforeLeave(el) {
|
|
235
|
-
// Ensure it's at its current position before animating
|
|
236
|
-
el.style.transition = 'none'
|
|
237
|
-
el.style.top = `${el.offsetTop}px`
|
|
238
|
-
void el.offsetWidth // force reflow to flush style
|
|
239
|
-
el.style.transition = '' // allow transition to apply again
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function onLeave(el, done) {
|
|
243
|
-
el.style.top = '-600px'
|
|
244
|
-
el.style.opacity = '0'
|
|
245
|
-
el.addEventListener('transitionend', done)
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function itemAttributes(item) {
|
|
249
|
-
return {
|
|
250
|
-
style: getItemStyle(item),
|
|
251
|
-
'data-top': item.top,
|
|
252
|
-
'data-id': `${item.page}-${item.id}`,
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
146
|
function onResize() {
|
|
257
|
-
columns.value = getColumnCount()
|
|
147
|
+
columns.value = getColumnCount(layout.value)
|
|
258
148
|
refreshLayout(masonry.value)
|
|
259
149
|
}
|
|
260
150
|
|
|
261
151
|
onMounted(async () => {
|
|
262
152
|
isLoading.value = true
|
|
263
153
|
|
|
264
|
-
columns.value = getColumnCount()
|
|
154
|
+
columns.value = getColumnCount(layout.value)
|
|
265
155
|
|
|
266
156
|
// For cursor-based pagination, loadAtPage can be null for the first request
|
|
267
157
|
const initialPage = props.loadAtPage
|
|
@@ -269,8 +159,7 @@ onMounted(async () => {
|
|
|
269
159
|
|
|
270
160
|
// Skip initial load if skipInitialLoad prop is true
|
|
271
161
|
if (!props.skipInitialLoad) {
|
|
272
|
-
|
|
273
|
-
paginationHistory.value.push(response.nextPage)
|
|
162
|
+
await loadPage(paginationHistory.value[0])
|
|
274
163
|
} else {
|
|
275
164
|
await nextTick()
|
|
276
165
|
// Just refresh the layout with any existing items
|
|
@@ -279,13 +168,13 @@ onMounted(async () => {
|
|
|
279
168
|
|
|
280
169
|
isLoading.value = false
|
|
281
170
|
|
|
282
|
-
container.value?.addEventListener('scroll', debounce(
|
|
171
|
+
container.value?.addEventListener('scroll', debounce(handleScroll, 200));
|
|
283
172
|
|
|
284
173
|
window.addEventListener('resize', debounce(onResize, 200));
|
|
285
174
|
})
|
|
286
175
|
|
|
287
176
|
onUnmounted(() => {
|
|
288
|
-
container.value?.removeEventListener('scroll', debounce(
|
|
177
|
+
container.value?.removeEventListener('scroll', debounce(handleScroll, 200));
|
|
289
178
|
|
|
290
179
|
window.removeEventListener('resize', debounce(onResize, 200));
|
|
291
180
|
})
|
|
@@ -299,7 +188,7 @@ onUnmounted(() => {
|
|
|
299
188
|
@before-leave="onBeforeLeave">
|
|
300
189
|
<div v-for="item in masonry" :key="`${item.page}-${item.id}`"
|
|
301
190
|
class="absolute transition-[top,left,opacity] duration-500 ease-in-out"
|
|
302
|
-
v-bind="
|
|
191
|
+
v-bind="getItemAttributes(item)">
|
|
303
192
|
<slot name="item" v-bind="{item, onRemove}">
|
|
304
193
|
<img :src="item.src" class="w-full"/>
|
|
305
194
|
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get responsive column count based on window width and layout sizes
|
|
3
|
+
*/
|
|
4
|
+
export function getColumnCount(layout) {
|
|
5
|
+
const width = window.innerWidth
|
|
6
|
+
const sizes = layout.sizes
|
|
7
|
+
|
|
8
|
+
if (width >= 1536 && sizes['2xl']) return sizes['2xl']
|
|
9
|
+
if (width >= 1280 && sizes.xl) return sizes.xl
|
|
10
|
+
if (width >= 1024 && sizes.lg) return sizes.lg
|
|
11
|
+
if (width >= 768 && sizes.md) return sizes.md
|
|
12
|
+
if (width >= 640 && sizes.sm) return sizes.sm
|
|
13
|
+
return sizes.base
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Calculate container height based on item positions
|
|
18
|
+
*/
|
|
19
|
+
export function calculateContainerHeight(items) {
|
|
20
|
+
return items.reduce((acc, item) => {
|
|
21
|
+
return Math.max(acc, item.top + item.columnHeight)
|
|
22
|
+
}, 0)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get style object for masonry item positioning
|
|
27
|
+
*/
|
|
28
|
+
export function getItemStyle(item) {
|
|
29
|
+
return {
|
|
30
|
+
top: `${item.top}px`,
|
|
31
|
+
left: `${item.left}px`,
|
|
32
|
+
width: `${item.columnWidth}px`,
|
|
33
|
+
height: `${item.columnHeight}px`
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get item attributes for rendering
|
|
39
|
+
*/
|
|
40
|
+
export function getItemAttributes(item) {
|
|
41
|
+
return {
|
|
42
|
+
style: getItemStyle(item),
|
|
43
|
+
'data-top': item.top,
|
|
44
|
+
'data-id': `${item.page}-${item.id}`,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Calculate column heights for masonry layout
|
|
50
|
+
*/
|
|
51
|
+
export function calculateColumnHeights(items, columnCount) {
|
|
52
|
+
const heights = new Array(columnCount).fill(0)
|
|
53
|
+
for (let i = 0; i < items.length; i++) {
|
|
54
|
+
const item = items[i]
|
|
55
|
+
const col = i % columnCount
|
|
56
|
+
heights[col] = Math.max(heights[col], item.top + item.columnHeight)
|
|
57
|
+
}
|
|
58
|
+
return heights
|
|
59
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { nextTick } from 'vue'
|
|
2
|
+
import { calculateColumnHeights } from './masonryUtils.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Composable for handling masonry scroll behavior and item cleanup
|
|
6
|
+
*/
|
|
7
|
+
export function useMasonryScroll({
|
|
8
|
+
container,
|
|
9
|
+
masonry,
|
|
10
|
+
columns,
|
|
11
|
+
containerHeight,
|
|
12
|
+
isLoading,
|
|
13
|
+
maxItems,
|
|
14
|
+
refreshLayout,
|
|
15
|
+
loadNext
|
|
16
|
+
}) {
|
|
17
|
+
|
|
18
|
+
async function handleScroll() {
|
|
19
|
+
const { scrollTop, clientHeight } = container.value
|
|
20
|
+
const visibleBottom = scrollTop + clientHeight
|
|
21
|
+
|
|
22
|
+
const columnHeights = calculateColumnHeights(masonry.value, columns.value)
|
|
23
|
+
const whitespaceVisible = columnHeights.some(height => height + 300 < visibleBottom - 1)
|
|
24
|
+
const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
|
|
25
|
+
|
|
26
|
+
if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value) {
|
|
27
|
+
isLoading.value = true
|
|
28
|
+
|
|
29
|
+
// Handle cleanup when too many items
|
|
30
|
+
if (masonry.value.length > maxItems) {
|
|
31
|
+
await handleItemCleanup(columnHeights)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
await loadNext()
|
|
35
|
+
await nextTick()
|
|
36
|
+
isLoading.value = false
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function handleItemCleanup(columnHeights) {
|
|
41
|
+
const firstItem = masonry.value[0]
|
|
42
|
+
|
|
43
|
+
if (!firstItem) {
|
|
44
|
+
await loadNext()
|
|
45
|
+
await nextTick()
|
|
46
|
+
isLoading.value = false
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const page = firstItem.page
|
|
51
|
+
const removedItems = masonry.value.filter(i => i.page !== page)
|
|
52
|
+
|
|
53
|
+
if (removedItems.length === masonry.value.length) {
|
|
54
|
+
await loadNext()
|
|
55
|
+
await nextTick()
|
|
56
|
+
isLoading.value = false
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
refreshLayout(removedItems)
|
|
61
|
+
await nextTick()
|
|
62
|
+
|
|
63
|
+
await adjustScrollPosition(columnHeights)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function adjustScrollPosition(columnHeights) {
|
|
67
|
+
const lowestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights))
|
|
68
|
+
const lastItemInColumn = masonry.value.filter((_, index) => index % columns.value === lowestColumnIndex).pop()
|
|
69
|
+
|
|
70
|
+
if (lastItemInColumn) {
|
|
71
|
+
const lastItemInColumnTop = lastItemInColumn.top + lastItemInColumn.columnHeight
|
|
72
|
+
const lastItemInColumnBottom = lastItemInColumnTop + lastItemInColumn.columnHeight
|
|
73
|
+
const containerTop = container.value.scrollTop
|
|
74
|
+
const containerBottom = containerTop + container.value.clientHeight
|
|
75
|
+
const itemInView = lastItemInColumnTop >= containerTop && lastItemInColumnBottom <= containerBottom
|
|
76
|
+
|
|
77
|
+
if (!itemInView) {
|
|
78
|
+
container.value.scrollTo({
|
|
79
|
+
top: lastItemInColumnTop - 10,
|
|
80
|
+
behavior: 'smooth'
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
handleScroll
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable for handling masonry item transitions
|
|
3
|
+
*/
|
|
4
|
+
export function useMasonryTransitions(masonry) {
|
|
5
|
+
function onEnter(el, done) {
|
|
6
|
+
// Set top to data-top
|
|
7
|
+
const top = el.dataset.top
|
|
8
|
+
requestAnimationFrame(() => {
|
|
9
|
+
el.style.top = `${top}px`
|
|
10
|
+
done()
|
|
11
|
+
})
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function onBeforeEnter(el) {
|
|
15
|
+
// Set top to last item + offset
|
|
16
|
+
const lastItem = masonry.value[masonry.value.length - 1]
|
|
17
|
+
if (lastItem) {
|
|
18
|
+
const lastTop = lastItem.top + lastItem.columnHeight + 10
|
|
19
|
+
el.style.top = `${lastTop}px`
|
|
20
|
+
} else {
|
|
21
|
+
el.style.top = '0px'
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function onBeforeLeave(el) {
|
|
26
|
+
// Ensure it's at its current position before animating
|
|
27
|
+
el.style.transition = 'none'
|
|
28
|
+
el.style.top = `${el.offsetTop}px`
|
|
29
|
+
void el.offsetWidth // force reflow to flush style
|
|
30
|
+
el.style.transition = '' // allow transition to apply again
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function onLeave(el, done) {
|
|
34
|
+
el.style.top = '-600px'
|
|
35
|
+
el.style.opacity = '0'
|
|
36
|
+
el.addEventListener('transitionend', done)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
onEnter,
|
|
41
|
+
onBeforeEnter,
|
|
42
|
+
onBeforeLeave,
|
|
43
|
+
onLeave
|
|
44
|
+
}
|
|
45
|
+
}
|