@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/vibe",
3
- "version": "1.2.13",
3
+ "version": "1.2.15",
4
4
  "main": "index.js",
5
5
  "module": "index.js",
6
6
  "type": "module",
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
- const heights = new Array(columns.value).fill(0)
74
- for (let i = 0; i < masonry.value.length; i++) {
75
- const item = masonry.value[i]
76
- const col = i % columns.value
77
- heights[col] = Math.max(heights[col], item.top + item.columnHeight)
78
- }
79
- return heights
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.reduce((acc, item) => {
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 loadNext() {
197
- const response = await getContent(paginationHistory.value[paginationHistory.value.length - 1])
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
- const getItemStyle = (item) => {
202
- return {
203
- top: `${item.top}px`,
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
- const response = await getContent(paginationHistory.value[0])
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(onScroll, 200));
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(onScroll, 200));
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="itemAttributes(item)">
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
+ }