@wyxos/vibe 1.2.15 → 1.3.0

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.15",
3
+ "version": "1.3.0",
4
4
  "main": "index.js",
5
5
  "module": "index.js",
6
6
  "type": "module",
package/src/Masonry.vue CHANGED
@@ -40,6 +40,10 @@ const props = defineProps({
40
40
  maxItems: {
41
41
  type: Number,
42
42
  default: 100
43
+ },
44
+ pageSize: {
45
+ type: Number,
46
+ default: 40
43
47
  }
44
48
  })
45
49
 
@@ -81,10 +85,31 @@ const isLoading = ref(false)
81
85
 
82
86
  const containerHeight = ref(0)
83
87
 
84
- const columnHeights = computed(() => {
85
- return calculateColumnHeights(masonry.value, columns.value)
88
+ // Scroll progress tracking
89
+ const scrollProgress = ref({
90
+ distanceToTrigger: 0,
91
+ isNearTrigger: false
86
92
  })
87
93
 
94
+ const updateScrollProgress = () => {
95
+ if (!container.value) return
96
+
97
+ const { scrollTop, clientHeight } = container.value
98
+ const visibleBottom = scrollTop + clientHeight
99
+
100
+ const columnHeights = calculateColumnHeights(masonry.value, columns.value)
101
+ const shortestColumn = Math.min(...columnHeights)
102
+ const triggerPoint = shortestColumn - 300 // Same threshold as in scroll handler
103
+
104
+ const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
105
+ const isNearTrigger = distanceToTrigger <= 100
106
+
107
+ scrollProgress.value = {
108
+ distanceToTrigger: Math.round(distanceToTrigger),
109
+ isNearTrigger
110
+ }
111
+ }
112
+
88
113
  // Setup composables
89
114
  const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry)
90
115
 
@@ -95,6 +120,7 @@ const { handleScroll } = useMasonryScroll({
95
120
  containerHeight,
96
121
  isLoading,
97
122
  maxItems: props.maxItems,
123
+ pageSize: props.pageSize,
98
124
  refreshLayout,
99
125
  loadNext
100
126
  })
@@ -105,7 +131,8 @@ defineExpose({
105
131
  containerHeight,
106
132
  onRemove,
107
133
  loadNext,
108
- loadPage
134
+ loadPage,
135
+ reset
109
136
  })
110
137
 
111
138
  function calculateHeight(content) {
@@ -129,14 +156,38 @@ async function getContent(page) {
129
156
  }
130
157
 
131
158
  async function loadPage(page) {
132
- const response = await getContent(page)
133
- paginationHistory.value.push(response.nextPage)
134
- return response
159
+ if (isLoading.value) return // Prevent concurrent loading
160
+
161
+ isLoading.value = true
162
+
163
+ try {
164
+ const response = await getContent(page)
165
+ paginationHistory.value.push(response.nextPage)
166
+ return response
167
+ } catch (error) {
168
+ console.error('Error loading page:', error)
169
+ throw error
170
+ } finally {
171
+ isLoading.value = false
172
+ }
135
173
  }
136
174
 
137
175
  async function loadNext() {
138
- const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
139
- return await loadPage(currentPage)
176
+ if (isLoading.value) return // Prevent concurrent loading
177
+
178
+ isLoading.value = true
179
+
180
+ try {
181
+ const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
182
+ const response = await getContent(currentPage)
183
+ paginationHistory.value.push(response.nextPage)
184
+ return response
185
+ } catch (error) {
186
+ console.error('Error loading next page:', error)
187
+ throw error
188
+ } finally {
189
+ isLoading.value = false
190
+ }
140
191
  }
141
192
 
142
193
  function onRemove(item) {
@@ -148,35 +199,69 @@ function onResize() {
148
199
  refreshLayout(masonry.value)
149
200
  }
150
201
 
151
- onMounted(async () => {
152
- isLoading.value = true
153
-
154
- columns.value = getColumnCount(layout.value)
155
-
156
- // For cursor-based pagination, loadAtPage can be null for the first request
157
- const initialPage = props.loadAtPage
158
- paginationHistory.value = [initialPage]
159
-
160
- // Skip initial load if skipInitialLoad prop is true
161
- if (!props.skipInitialLoad) {
162
- await loadPage(paginationHistory.value[0])
163
- } else {
164
- await nextTick()
165
- // Just refresh the layout with any existing items
166
- refreshLayout(masonry.value)
202
+ function reset() {
203
+ // Scroll back to top first (while items still exist to scroll through)
204
+ if (container.value) {
205
+ container.value.scrollTo({
206
+ top: 0,
207
+ behavior: 'smooth'
208
+ })
209
+ }
210
+
211
+ // Clear all items
212
+ masonry.value = []
213
+
214
+ // Reset container height
215
+ containerHeight.value = 0
216
+
217
+ // Reset pagination history to initial state
218
+ paginationHistory.value = [props.loadAtPage]
219
+
220
+ // Reset scroll progress
221
+ scrollProgress.value = {
222
+ distanceToTrigger: 0,
223
+ isNearTrigger: false
167
224
  }
225
+ }
168
226
 
169
- isLoading.value = false
227
+ // Create debounced functions with stable references
228
+ const debouncedScrollHandler = debounce(() => {
229
+ handleScroll()
230
+ updateScrollProgress()
231
+ }, 200)
170
232
 
171
- container.value?.addEventListener('scroll', debounce(handleScroll, 200));
233
+ const debouncedResizeHandler = debounce(onResize, 200)
172
234
 
173
- window.addEventListener('resize', debounce(onResize, 200));
235
+ onMounted(async () => {
236
+ try {
237
+ columns.value = getColumnCount(layout.value)
238
+
239
+ // For cursor-based pagination, loadAtPage can be null for the first request
240
+ const initialPage = props.loadAtPage
241
+ paginationHistory.value = [initialPage]
242
+
243
+ // Skip initial load if skipInitialLoad prop is true
244
+ if (!props.skipInitialLoad) {
245
+ await loadPage(paginationHistory.value[0]) // loadPage manages its own loading state
246
+ } else {
247
+ await nextTick()
248
+ // Just refresh the layout with any existing items
249
+ refreshLayout(masonry.value)
250
+ }
251
+
252
+ updateScrollProgress()
253
+
254
+ } catch (error) {
255
+ console.error('Error during component initialization:', error)
256
+ }
257
+
258
+ container.value?.addEventListener('scroll', debouncedScrollHandler)
259
+ window.addEventListener('resize', debouncedResizeHandler)
174
260
  })
175
261
 
176
262
  onUnmounted(() => {
177
- container.value?.removeEventListener('scroll', debounce(handleScroll, 200));
178
-
179
- window.removeEventListener('resize', debounce(onResize, 200));
263
+ container.value?.removeEventListener('scroll', debouncedScrollHandler)
264
+ window.removeEventListener('resize', debouncedResizeHandler)
180
265
  })
181
266
  </script>
182
267
 
@@ -198,6 +283,15 @@ onUnmounted(() => {
198
283
  </slot>
199
284
  </div>
200
285
  </transition-group>
286
+
287
+ <!-- Scroll Progress Badge -->
288
+ <div v-if="containerHeight > 0"
289
+ class="fixed bottom-4 right-4 bg-gray-800 text-white text-xs rounded-full px-3 py-1.5 shadow-lg z-10 transition-opacity duration-300"
290
+ :class="{'opacity-50 hover:opacity-100': !scrollProgress.isNearTrigger, 'opacity-100': scrollProgress.isNearTrigger}">
291
+ <span>{{ masonry.length }} items</span>
292
+ <span class="mx-2">|</span>
293
+ <span>{{ scrollProgress.distanceToTrigger }}px to load</span>
294
+ </div>
201
295
  </div>
202
296
  </div>
203
297
  </template>
@@ -54,7 +54,8 @@ export default function calculateLayout(items, container, columnCount, options =
54
54
  const item = items[index];
55
55
  const newItem = { ...item };
56
56
 
57
- const col = index % columnCount;
57
+ // Find the column with the shortest height for proper masonry layout
58
+ const col = columnHeights.indexOf(Math.min(...columnHeights));
58
59
  const originalWidth = item.width;
59
60
  const originalHeight = item.height;
60
61
 
@@ -11,10 +11,10 @@ export function useMasonryScroll({
11
11
  containerHeight,
12
12
  isLoading,
13
13
  maxItems,
14
+ pageSize,
14
15
  refreshLayout,
15
16
  loadNext
16
17
  }) {
17
-
18
18
  async function handleScroll() {
19
19
  const { scrollTop, clientHeight } = container.value
20
20
  const visibleBottom = scrollTop + clientHeight
@@ -24,56 +24,86 @@ export function useMasonryScroll({
24
24
  const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
25
25
 
26
26
  if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value) {
27
- isLoading.value = true
27
+ try {
28
+ // Handle cleanup when too many items
29
+ if (masonry.value.length > maxItems) {
30
+ await handleItemCleanup(columnHeights)
31
+ }
28
32
 
29
- // Handle cleanup when too many items
30
- if (masonry.value.length > maxItems) {
31
- await handleItemCleanup(columnHeights)
32
- }
33
33
 
34
- await loadNext()
35
- await nextTick()
36
- isLoading.value = false
34
+ await loadNext() // loadNext manages its own loading state
35
+ await nextTick()
36
+ } catch (error) {
37
+ console.error('Error in scroll handler:', error)
38
+ }
37
39
  }
38
40
  }
39
41
 
40
42
  async function handleItemCleanup(columnHeights) {
41
- const firstItem = masonry.value[0]
42
-
43
- if (!firstItem) {
44
- await loadNext()
45
- await nextTick()
46
- isLoading.value = false
43
+ if (!masonry.value.length) {
47
44
  return
48
45
  }
49
46
 
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
47
+ if(masonry.value.length <= pageSize) {
48
+ // If we have fewer items than pageSize, no cleanup needed
49
+ return
50
+ }
51
+
52
+ // Group items by page to understand page structure
53
+ const pageGroups = masonry.value.reduce((acc, item) => {
54
+ if (!acc[item.page]) {
55
+ acc[item.page] = []
56
+ }
57
+ acc[item.page].push(item)
58
+ return acc
59
+ }, {})
60
+
61
+ const pages = Object.keys(pageGroups).sort((a, b) => parseInt(a) - parseInt(b))
62
+
63
+ if (pages.length === 0) {
57
64
  return
58
65
  }
59
66
 
60
- refreshLayout(removedItems)
61
- await nextTick()
67
+ let totalRemovedItems = 0
68
+ let pagesToRemove = []
69
+
70
+ // Remove pages cumulatively until we reach at least pageSize items
71
+ for (const page of pages) {
72
+ pagesToRemove.push(page)
73
+ totalRemovedItems += pageGroups[page].length
74
+
75
+ if (totalRemovedItems >= pageSize) {
76
+ break
77
+ }
78
+ }
79
+
80
+ // Filter out items from pages to be removed
81
+ const remainingItems = masonry.value.filter(item => !pagesToRemove.includes(item.page.toString()))
82
+
83
+ if (remainingItems.length === masonry.value.length) {
84
+ // No items were removed, nothing to do
85
+ return
86
+ }
87
+
88
+ refreshLayout(remainingItems)
62
89
 
90
+
91
+ await nextTick()
92
+
63
93
  await adjustScrollPosition(columnHeights)
64
94
  }
65
95
 
66
96
  async function adjustScrollPosition(columnHeights) {
67
97
  const lowestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights))
68
98
  const lastItemInColumn = masonry.value.filter((_, index) => index % columns.value === lowestColumnIndex).pop()
69
-
99
+
70
100
  if (lastItemInColumn) {
71
101
  const lastItemInColumnTop = lastItemInColumn.top + lastItemInColumn.columnHeight
72
102
  const lastItemInColumnBottom = lastItemInColumnTop + lastItemInColumn.columnHeight
73
103
  const containerTop = container.value.scrollTop
74
104
  const containerBottom = containerTop + container.value.clientHeight
75
105
  const itemInView = lastItemInColumnTop >= containerTop && lastItemInColumnBottom <= containerBottom
76
-
106
+
77
107
  if (!itemInView) {
78
108
  container.value.scrollTo({
79
109
  top: lastItemInColumnTop - 10,