@wyxos/vibe 1.2.14 → 1.2.16

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.14",
3
+ "version": "1.2.16",
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: {
@@ -74,13 +82,21 @@ const isLoading = ref(false)
74
82
  const containerHeight = ref(0)
75
83
 
76
84
  const columnHeights = computed(() => {
77
- const heights = new Array(columns.value).fill(0)
78
- for (let i = 0; i < masonry.value.length; i++) {
79
- const item = masonry.value[i]
80
- const col = i % columns.value
81
- heights[col] = Math.max(heights[col], item.top + item.columnHeight)
82
- }
83
- 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
84
100
  })
85
101
 
86
102
  defineExpose({
@@ -88,97 +104,12 @@ defineExpose({
88
104
  refreshLayout,
89
105
  containerHeight,
90
106
  onRemove,
91
- loadNext
107
+ loadNext,
108
+ loadPage
92
109
  })
93
110
 
94
- async function onScroll() {
95
- const {scrollTop, clientHeight} = container.value
96
- const visibleBottom = scrollTop + clientHeight
97
-
98
- const whitespaceVisible = columnHeights.value.some(height => height + 300 < visibleBottom - 1)
99
-
100
- const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
101
-
102
- if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value) {
103
- isLoading.value = true
104
-
105
- if (masonry.value.length > props.maxItems) {
106
- // get first item - only proceed if it exists
107
- const firstItem = masonry.value[0]
108
-
109
- if (!firstItem) {
110
- // Skip removal logic if there are no items
111
- await loadNext()
112
- await nextTick()
113
- isLoading.value = false
114
- return
115
- }
116
-
117
- // get page number
118
- const page = firstItem.page
119
-
120
- // find all item with this page
121
- const removedItems = masonry.value.filter(i => i.page !== page)
122
-
123
- // Only proceed with removal if there are actually items to remove
124
- if (removedItems.length === masonry.value.length) {
125
- // All items belong to the same page, skip removal logic
126
- await loadNext()
127
- await nextTick()
128
- isLoading.value = false
129
- return
130
- }
131
-
132
- refreshLayout(removedItems)
133
-
134
- await nextTick()
135
-
136
- const lowestColumnIndex = columnHeights.value.indexOf(Math.min(...columnHeights.value))
137
-
138
- // find the last item in that column
139
- const lastItemInColumn = masonry.value.filter((_, index) => index % columns.value === lowestColumnIndex).pop()
140
-
141
- // Only proceed with scroll adjustment if we have a valid item
142
- if (lastItemInColumn) {
143
- const lastItemInColumnTop = lastItemInColumn.top + lastItemInColumn.columnHeight
144
- const lastItemInColumnBottom = lastItemInColumnTop + lastItemInColumn.columnHeight
145
- const containerTop = container.value.scrollTop
146
- const containerBottom = containerTop + container.value.clientHeight
147
- const itemInView = lastItemInColumnTop >= containerTop && lastItemInColumnBottom <= containerBottom
148
- if (!itemInView) {
149
- container.value.scrollTo({
150
- top: lastItemInColumnTop - 10,
151
- behavior: 'smooth'
152
- })
153
- }
154
- }
155
- }
156
-
157
- await loadNext()
158
-
159
- await nextTick()
160
-
161
- isLoading.value = false
162
- }
163
- }
164
-
165
- function getColumnCount() {
166
- const width = window.innerWidth
167
-
168
- const sizes = layout.value.sizes
169
-
170
- if (width >= 1536 && sizes['2xl']) return sizes['2xl']
171
- if (width >= 1280 && sizes.xl) return sizes.xl
172
- if (width >= 1024 && sizes.lg) return sizes.lg
173
- if (width >= 768 && sizes.md) return sizes.md
174
- if (width >= 640 && sizes.sm) return sizes.sm
175
- return sizes.base
176
- }
177
-
178
111
  function calculateHeight(content) {
179
- containerHeight.value = content.reduce((acc, item) => {
180
- return Math.max(acc, item.top + item.columnHeight)
181
- }, 0)
112
+ containerHeight.value = calculateContainerHeight(content)
182
113
  }
183
114
 
184
115
  function refreshLayout(items) {
@@ -197,17 +128,38 @@ async function getContent(page) {
197
128
  return response
198
129
  }
199
130
 
200
- async function loadNext() {
201
- const response = await getContent(paginationHistory.value[paginationHistory.value.length - 1])
202
- paginationHistory.value.push(response.nextPage)
131
+ async function loadPage(page) {
132
+ if (isLoading.value) return // Prevent concurrent loading
133
+
134
+ isLoading.value = true
135
+
136
+ try {
137
+ const response = await getContent(page)
138
+ paginationHistory.value.push(response.nextPage)
139
+ return response
140
+ } catch (error) {
141
+ console.error('Error loading page:', error)
142
+ throw error
143
+ } finally {
144
+ isLoading.value = false
145
+ }
203
146
  }
204
147
 
205
- const getItemStyle = (item) => {
206
- return {
207
- top: `${item.top}px`,
208
- left: `${item.left}px`,
209
- width: `${item.columnWidth}px`,
210
- height: `${item.columnHeight}px`
148
+ async function loadNext() {
149
+ if (isLoading.value) return // Prevent concurrent loading
150
+
151
+ isLoading.value = true
152
+
153
+ try {
154
+ const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
155
+ const response = await getContent(currentPage)
156
+ paginationHistory.value.push(response.nextPage)
157
+ return response
158
+ } catch (error) {
159
+ console.error('Error loading next page:', error)
160
+ throw error
161
+ } finally {
162
+ isLoading.value = false
211
163
  }
212
164
  }
213
165
 
@@ -215,81 +167,38 @@ function onRemove(item) {
215
167
  refreshLayout(masonry.value.filter(i => i.id !== item.id))
216
168
  }
217
169
 
218
- function onEnter(el, done) {
219
- // set top to data-top
220
- const top = el.dataset.top
221
- requestAnimationFrame(() => {
222
- el.style.top = `${top}px`
223
- done()
224
- })
225
- }
226
-
227
- function onBeforeEnter(el) {
228
- // set top to last item + 500
229
- const lastItem = masonry.value[masonry.value.length - 1]
230
- if (lastItem) {
231
- const lastTop = lastItem.top + lastItem.columnHeight + 10
232
- el.style.top = `${lastTop}px`
233
- } else {
234
- el.style.top = '0px'
235
- }
236
- }
237
-
238
- function onBeforeLeave(el) {
239
- // Ensure it's at its current position before animating
240
- el.style.transition = 'none'
241
- el.style.top = `${el.offsetTop}px`
242
- void el.offsetWidth // force reflow to flush style
243
- el.style.transition = '' // allow transition to apply again
244
- }
245
-
246
- function onLeave(el, done) {
247
- el.style.top = '-600px'
248
- el.style.opacity = '0'
249
- el.addEventListener('transitionend', done)
250
- }
251
-
252
- function itemAttributes(item) {
253
- return {
254
- style: getItemStyle(item),
255
- 'data-top': item.top,
256
- 'data-id': `${item.page}-${item.id}`,
257
- }
258
- }
259
-
260
170
  function onResize() {
261
- columns.value = getColumnCount()
171
+ columns.value = getColumnCount(layout.value)
262
172
  refreshLayout(masonry.value)
263
173
  }
264
174
 
265
175
  onMounted(async () => {
266
- isLoading.value = true
267
-
268
- columns.value = getColumnCount()
176
+ try {
177
+ columns.value = getColumnCount(layout.value)
269
178
 
270
- // For cursor-based pagination, loadAtPage can be null for the first request
271
- const initialPage = props.loadAtPage
272
- paginationHistory.value = [initialPage]
179
+ // For cursor-based pagination, loadAtPage can be null for the first request
180
+ const initialPage = props.loadAtPage
181
+ paginationHistory.value = [initialPage]
273
182
 
274
- // Skip initial load if skipInitialLoad prop is true
275
- if (!props.skipInitialLoad) {
276
- const response = await getContent(paginationHistory.value[0])
277
- paginationHistory.value.push(response.nextPage)
278
- } else {
279
- await nextTick()
280
- // Just refresh the layout with any existing items
281
- refreshLayout(masonry.value)
183
+ // Skip initial load if skipInitialLoad prop is true
184
+ if (!props.skipInitialLoad) {
185
+ await loadPage(paginationHistory.value[0]) // loadPage manages its own loading state
186
+ } else {
187
+ await nextTick()
188
+ // Just refresh the layout with any existing items
189
+ refreshLayout(masonry.value)
190
+ }
191
+ } catch (error) {
192
+ console.error('Error during component initialization:', error)
282
193
  }
283
194
 
284
- isLoading.value = false
285
-
286
- container.value?.addEventListener('scroll', debounce(onScroll, 200));
195
+ container.value?.addEventListener('scroll', debounce(handleScroll, 200));
287
196
 
288
197
  window.addEventListener('resize', debounce(onResize, 200));
289
198
  })
290
199
 
291
200
  onUnmounted(() => {
292
- container.value?.removeEventListener('scroll', debounce(onScroll, 200));
201
+ container.value?.removeEventListener('scroll', debounce(handleScroll, 200));
293
202
 
294
203
  window.removeEventListener('resize', debounce(onResize, 200));
295
204
  })
@@ -303,7 +212,7 @@ onUnmounted(() => {
303
212
  @before-leave="onBeforeLeave">
304
213
  <div v-for="item in masonry" :key="`${item.page}-${item.id}`"
305
214
  class="absolute transition-[top,left,opacity] duration-500 ease-in-out"
306
- v-bind="itemAttributes(item)">
215
+ v-bind="getItemAttributes(item)">
307
216
  <slot name="item" v-bind="{item, onRemove}">
308
217
  <img :src="item.src" class="w-full"/>
309
218
  <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,86 @@
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
+ try {
28
+ // Handle cleanup when too many items
29
+ if (masonry.value.length > maxItems) {
30
+ await handleItemCleanup(columnHeights)
31
+ }
32
+
33
+ await loadNext() // loadNext manages its own loading state
34
+ await nextTick()
35
+ } catch (error) {
36
+ console.error('Error in scroll handler:', error)
37
+ }
38
+ }
39
+ }
40
+
41
+ async function handleItemCleanup(columnHeights) {
42
+ const firstItem = masonry.value[0]
43
+
44
+ if (!firstItem) {
45
+ // Don't set isLoading here - let main handleScroll manage it
46
+ return
47
+ }
48
+
49
+ const page = firstItem.page
50
+ const removedItems = masonry.value.filter(i => i.page !== page)
51
+
52
+ if (removedItems.length === masonry.value.length) {
53
+ // Don't set isLoading here - let main handleScroll manage it
54
+ return
55
+ }
56
+
57
+ refreshLayout(removedItems)
58
+ await nextTick()
59
+
60
+ await adjustScrollPosition(columnHeights)
61
+ }
62
+
63
+ async function adjustScrollPosition(columnHeights) {
64
+ const lowestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights))
65
+ const lastItemInColumn = masonry.value.filter((_, index) => index % columns.value === lowestColumnIndex).pop()
66
+
67
+ if (lastItemInColumn) {
68
+ const lastItemInColumnTop = lastItemInColumn.top + lastItemInColumn.columnHeight
69
+ const lastItemInColumnBottom = lastItemInColumnTop + lastItemInColumn.columnHeight
70
+ const containerTop = container.value.scrollTop
71
+ const containerBottom = containerTop + container.value.clientHeight
72
+ const itemInView = lastItemInColumnTop >= containerTop && lastItemInColumnBottom <= containerBottom
73
+
74
+ if (!itemInView) {
75
+ container.value.scrollTo({
76
+ top: lastItemInColumnTop - 10,
77
+ behavior: 'smooth'
78
+ })
79
+ }
80
+ }
81
+ }
82
+
83
+ return {
84
+ handleScroll
85
+ }
86
+ }
@@ -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
+ }