@wyxos/vibe 1.2.16 → 1.3.1

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.16",
3
+ "version": "1.3.1",
4
4
  "main": "index.js",
5
5
  "module": "index.js",
6
6
  "type": "module",
package/src/Masonry.vue CHANGED
@@ -2,11 +2,11 @@
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,
5
+ import {
6
+ getColumnCount,
7
+ calculateContainerHeight,
8
8
  getItemAttributes,
9
- calculateColumnHeights
9
+ calculateColumnHeights
10
10
  } from './masonryUtils.js'
11
11
  import { useMasonryTransitions } from './useMasonryTransitions.js'
12
12
  import { useMasonryScroll } from './useMasonryScroll.js'
@@ -40,6 +40,18 @@ const props = defineProps({
40
40
  maxItems: {
41
41
  type: Number,
42
42
  default: 100
43
+ },
44
+ pageSize: {
45
+ type: Number,
46
+ default: 40
47
+ },
48
+ transitionDurationMs: {
49
+ type: Number,
50
+ default: 450
51
+ },
52
+ transitionEasing: {
53
+ type: String,
54
+ default: 'cubic-bezier(.22,.61,.36,1)'
43
55
  }
44
56
  })
45
57
 
@@ -81,10 +93,32 @@ const isLoading = ref(false)
81
93
 
82
94
  const containerHeight = ref(0)
83
95
 
84
- const columnHeights = computed(() => {
85
- return calculateColumnHeights(masonry.value, columns.value)
96
+ // Scroll progress tracking
97
+ const scrollProgress = ref({
98
+ distanceToTrigger: 0,
99
+ isNearTrigger: false
86
100
  })
87
101
 
102
+ const updateScrollProgress = () => {
103
+ if (!container.value) return
104
+
105
+ const { scrollTop, clientHeight } = container.value
106
+ const visibleBottom = scrollTop + clientHeight
107
+
108
+ const columnHeights = calculateColumnHeights(masonry.value, columns.value)
109
+ // Use longest column to match the trigger logic in useMasonryScroll.js
110
+ const longestColumn = Math.max(...columnHeights)
111
+ const triggerPoint = longestColumn + 300 // Match: longestColumn + 300 < visibleBottom
112
+
113
+ const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
114
+ const isNearTrigger = distanceToTrigger <= 100
115
+
116
+ scrollProgress.value = {
117
+ distanceToTrigger: Math.round(distanceToTrigger),
118
+ isNearTrigger
119
+ }
120
+ }
121
+
88
122
  // Setup composables
89
123
  const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry)
90
124
 
@@ -95,7 +129,10 @@ const { handleScroll } = useMasonryScroll({
95
129
  containerHeight,
96
130
  isLoading,
97
131
  maxItems: props.maxItems,
132
+ pageSize: props.pageSize,
98
133
  refreshLayout,
134
+ // Allow scroll composable to set items without recalculating layout (phase-1 cleanup)
135
+ setItemsRaw: (items) => { masonry.value = items },
99
136
  loadNext
100
137
  })
101
138
 
@@ -104,12 +141,22 @@ defineExpose({
104
141
  refreshLayout,
105
142
  containerHeight,
106
143
  onRemove,
144
+ removeMany,
107
145
  loadNext,
108
- loadPage
146
+ loadPage,
147
+ reset,
148
+ paginationHistory
109
149
  })
110
150
 
111
151
  function calculateHeight(content) {
112
- containerHeight.value = calculateContainerHeight(content)
152
+ const newHeight = calculateContainerHeight(content)
153
+ let floor = 0
154
+ if (container.value) {
155
+ const { scrollTop, clientHeight } = container.value
156
+ // Ensure the container never shrinks below the visible viewport bottom + small buffer
157
+ floor = scrollTop + clientHeight + 100
158
+ }
159
+ containerHeight.value = Math.max(newHeight, floor)
113
160
  }
114
161
 
115
162
  function refreshLayout(items) {
@@ -121,18 +168,21 @@ function refreshLayout(items) {
121
168
  }
122
169
 
123
170
  async function getContent(page) {
124
- const response = await props.getNextPage(page)
125
-
126
- refreshLayout([...masonry.value, ...response.items])
127
-
128
- return response
171
+ try {
172
+ const response = await props.getNextPage(page)
173
+ refreshLayout([...masonry.value, ...response.items])
174
+ return response
175
+ } catch (error) {
176
+ console.error('Error in getContent:', error)
177
+ throw error
178
+ }
129
179
  }
130
180
 
131
181
  async function loadPage(page) {
132
182
  if (isLoading.value) return // Prevent concurrent loading
133
-
183
+
134
184
  isLoading.value = true
135
-
185
+
136
186
  try {
137
187
  const response = await getContent(page)
138
188
  paginationHistory.value.push(response.nextPage)
@@ -147,9 +197,9 @@ async function loadPage(page) {
147
197
 
148
198
  async function loadNext() {
149
199
  if (isLoading.value) return // Prevent concurrent loading
150
-
200
+
151
201
  isLoading.value = true
152
-
202
+
153
203
  try {
154
204
  const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
155
205
  const response = await getContent(currentPage)
@@ -167,11 +217,51 @@ function onRemove(item) {
167
217
  refreshLayout(masonry.value.filter(i => i.id !== item.id))
168
218
  }
169
219
 
220
+ function removeMany(items) {
221
+ if (!items || items.length === 0) return
222
+ const ids = new Set(items.map(i => i.id))
223
+ const next = masonry.value.filter(i => !ids.has(i.id))
224
+ refreshLayout(next)
225
+ }
226
+
170
227
  function onResize() {
171
228
  columns.value = getColumnCount(layout.value)
172
229
  refreshLayout(masonry.value)
173
230
  }
174
231
 
232
+ function reset() {
233
+ // Scroll back to top first (while items still exist to scroll through)
234
+ if (container.value) {
235
+ container.value.scrollTo({
236
+ top: 0,
237
+ behavior: 'smooth'
238
+ })
239
+ }
240
+
241
+ // Clear all items
242
+ masonry.value = []
243
+
244
+ // Reset container height
245
+ containerHeight.value = 0
246
+
247
+ // Reset pagination history to initial state
248
+ paginationHistory.value = [props.loadAtPage]
249
+
250
+ // Reset scroll progress
251
+ scrollProgress.value = {
252
+ distanceToTrigger: 0,
253
+ isNearTrigger: false
254
+ }
255
+ }
256
+
257
+ // Create debounced functions with stable references
258
+ const debouncedScrollHandler = debounce(() => {
259
+ handleScroll()
260
+ updateScrollProgress()
261
+ }, 200)
262
+
263
+ const debouncedResizeHandler = debounce(onResize, 200)
264
+
175
265
  onMounted(async () => {
176
266
  try {
177
267
  columns.value = getColumnCount(layout.value)
@@ -188,31 +278,34 @@ onMounted(async () => {
188
278
  // Just refresh the layout with any existing items
189
279
  refreshLayout(masonry.value)
190
280
  }
281
+
282
+ updateScrollProgress()
283
+
191
284
  } catch (error) {
192
285
  console.error('Error during component initialization:', error)
286
+ // Ensure loading state is reset if error occurs during initialization
287
+ isLoading.value = false
193
288
  }
194
289
 
195
- container.value?.addEventListener('scroll', debounce(handleScroll, 200));
196
-
197
- window.addEventListener('resize', debounce(onResize, 200));
290
+ container.value?.addEventListener('scroll', debouncedScrollHandler)
291
+ window.addEventListener('resize', debouncedResizeHandler)
198
292
  })
199
293
 
200
294
  onUnmounted(() => {
201
- container.value?.removeEventListener('scroll', debounce(handleScroll, 200));
202
-
203
- window.removeEventListener('resize', debounce(onResize, 200));
295
+ container.value?.removeEventListener('scroll', debouncedScrollHandler)
296
+ window.removeEventListener('resize', debouncedResizeHandler)
204
297
  })
205
298
  </script>
206
299
 
207
300
  <template>
208
- <div class="overflow-auto w-full flex-1" ref="container">
209
- <div class="relative" :style="{height: `${containerHeight}px`}">
210
- <transition-group :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
301
+ <div class="overflow-auto w-full flex-1 masonry-container" ref="container"
302
+ > <div class="relative" :style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-ease': transitionEasing}">
303
+ <transition-group name="masonry" :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
211
304
  @leave="onLeave"
212
305
  @before-leave="onBeforeLeave">
213
- <div v-for="item in masonry" :key="`${item.page}-${item.id}`"
214
- class="absolute transition-[top,left,opacity] duration-500 ease-in-out"
215
- v-bind="getItemAttributes(item)">
306
+ <div v-for="(item, i) in masonry" :key="`${item.page}-${item.id}`"
307
+ class="absolute masonry-item"
308
+ v-bind="getItemAttributes(item, i)">
216
309
  <slot name="item" v-bind="{item, onRemove}">
217
310
  <img :src="item.src" class="w-full"/>
218
311
  <button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
@@ -222,6 +315,35 @@ onUnmounted(() => {
222
315
  </slot>
223
316
  </div>
224
317
  </transition-group>
318
+
319
+ <!-- Scroll Progress Badge -->
320
+ <div v-if="containerHeight > 0"
321
+ 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"
322
+ :class="{'opacity-50 hover:opacity-100': !scrollProgress.isNearTrigger, 'opacity-100': scrollProgress.isNearTrigger}">
323
+ <span>{{ masonry.length }} items</span>
324
+ <span class="mx-2">|</span>
325
+ <span>{{ scrollProgress.distanceToTrigger }}px to load</span>
326
+ </div>
225
327
  </div>
226
328
  </div>
227
329
  </template>
330
+
331
+ <style scoped>
332
+ /* Prevent browser scroll anchoring from adjusting scroll on content changes */
333
+ .masonry-container {
334
+ overflow-anchor: none;
335
+ }
336
+
337
+ /* Items animate transform only for smooth, compositor-driven motion */
338
+ .masonry-item {
339
+ will-change: transform, opacity;
340
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22,.61,.36,1)),
341
+ opacity 200ms linear;
342
+ backface-visibility: hidden;
343
+ }
344
+
345
+ /* TransitionGroup move-class for FLIP reordering */
346
+ .masonry-move {
347
+ transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22,.61,.36,1));
348
+ }
349
+ </style>
@@ -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
 
@@ -17,9 +17,12 @@ export function getColumnCount(layout) {
17
17
  * Calculate container height based on item positions
18
18
  */
19
19
  export function calculateContainerHeight(items) {
20
- return items.reduce((acc, item) => {
20
+ const contentHeight = items.reduce((acc, item) => {
21
21
  return Math.max(acc, item.top + item.columnHeight)
22
22
  }, 0)
23
+
24
+ // Add 500px buffer to the container height
25
+ return contentHeight + 500
23
26
  }
24
27
 
25
28
  /**
@@ -27,8 +30,11 @@ export function calculateContainerHeight(items) {
27
30
  */
28
31
  export function getItemStyle(item) {
29
32
  return {
30
- top: `${item.top}px`,
31
- left: `${item.left}px`,
33
+ // Use transform-based positioning for smooth, compositor-driven movement
34
+ transform: `translate3d(${item.left}px, ${item.top}px, 0)`,
35
+ // Keep top/left at 0 so only transform changes between layouts
36
+ top: '0px',
37
+ left: '0px',
32
38
  width: `${item.columnWidth}px`,
33
39
  height: `${item.columnHeight}px`
34
40
  }
@@ -37,11 +43,13 @@ export function getItemStyle(item) {
37
43
  /**
38
44
  * Get item attributes for rendering
39
45
  */
40
- export function getItemAttributes(item) {
46
+ export function getItemAttributes(item, index = 0) {
41
47
  return {
42
48
  style: getItemStyle(item),
43
49
  'data-top': item.top,
50
+ 'data-left': item.left,
44
51
  'data-id': `${item.page}-${item.id}`,
52
+ 'data-index': index,
45
53
  }
46
54
  }
47
55
 
@@ -11,73 +11,146 @@ export function useMasonryScroll({
11
11
  containerHeight,
12
12
  isLoading,
13
13
  maxItems,
14
+ pageSize,
14
15
  refreshLayout,
16
+ setItemsRaw,
15
17
  loadNext
16
18
  }) {
17
-
19
+ let cleanupInProgress = false
20
+
18
21
  async function handleScroll() {
19
22
  const { scrollTop, clientHeight } = container.value
20
23
  const visibleBottom = scrollTop + clientHeight
21
24
 
22
25
  const columnHeights = calculateColumnHeights(masonry.value, columns.value)
23
- const whitespaceVisible = columnHeights.some(height => height + 300 < visibleBottom - 1)
26
+ // Use the longest column instead of shortest for better trigger timing
27
+ const longestColumn = Math.max(...columnHeights)
28
+ const whitespaceVisible = longestColumn + 300 < visibleBottom - 1
24
29
  const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
25
30
 
26
- if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value) {
31
+ if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value && !cleanupInProgress) {
27
32
  try {
28
33
  // Handle cleanup when too many items
29
34
  if (masonry.value.length > maxItems) {
30
35
  await handleItemCleanup(columnHeights)
31
36
  }
32
37
 
33
- await loadNext() // loadNext manages its own loading state
38
+ await loadNext() // loadNext manages its own loading state and error handling
34
39
  await nextTick()
35
40
  } catch (error) {
36
41
  console.error('Error in scroll handler:', error)
42
+ // loadNext already handles its own loading state, no need to reset here
37
43
  }
38
44
  }
39
45
  }
40
46
 
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
47
+ async function handleItemCleanup(columnHeightsBefore) {
48
+ if (!masonry.value.length) {
49
+ return
50
+ }
51
+
52
+ if (masonry.value.length <= pageSize) {
53
+ // If we have fewer items than pageSize, no cleanup needed
54
+ return
55
+ }
56
+
57
+ // Group items by page to understand page structure
58
+ const pageGroups = masonry.value.reduce((acc, item) => {
59
+ if (!acc[item.page]) {
60
+ acc[item.page] = []
61
+ }
62
+ acc[item.page].push(item)
63
+ return acc
64
+ }, {})
65
+
66
+ const pages = Object.keys(pageGroups).sort((a, b) => parseInt(a) - parseInt(b))
67
+
68
+ if (pages.length === 0) {
46
69
  return
47
70
  }
48
71
 
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
72
+ let totalRemovedItems = 0
73
+ let pagesToRemove = []
74
+
75
+ // Remove pages cumulatively until we reach at least pageSize items
76
+ for (const page of pages) {
77
+ pagesToRemove.push(page)
78
+ totalRemovedItems += pageGroups[page].length
79
+
80
+ if (totalRemovedItems >= pageSize) {
81
+ break
82
+ }
83
+ }
84
+
85
+ // Phase 1: remove items (trigger leave) WITHOUT moving remaining items
86
+ const remainingItems = masonry.value.filter(item => !pagesToRemove.includes(item.page.toString()))
87
+
88
+ if (remainingItems.length === masonry.value.length) {
89
+ // No items were removed, nothing to do
54
90
  return
55
91
  }
56
92
 
57
- refreshLayout(removedItems)
93
+ cleanupInProgress = true
94
+
95
+ // Set raw items so TransitionGroup triggers leave on removed items, but remaining items keep their current transforms
96
+ setItemsRaw(remainingItems)
97
+
58
98
  await nextTick()
59
-
60
- await adjustScrollPosition(columnHeights)
99
+ // Wait for leave transitions to complete; use a conservative timeout
100
+ await waitFor(msLeaveEstimate())
101
+
102
+ // Phase 2: now recompute layout and animate moves for remaining items
103
+ refreshLayout(remainingItems)
104
+ await nextTick()
105
+
106
+ // Maintain anchor after moves are applied
107
+ await maintainAnchorPosition()
108
+
109
+ cleanupInProgress = false
61
110
  }
62
111
 
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
- })
112
+ function msLeaveEstimate() {
113
+ // Default estimate in ms; tweak if you change CSS leave timings
114
+ return 700
115
+ }
116
+
117
+ function waitFor(ms) {
118
+ return new Promise(resolve => setTimeout(resolve, ms))
119
+ }
120
+
121
+ async function maintainAnchorPosition() {
122
+ if (!container.value) return
123
+
124
+ const { scrollTop, clientHeight } = container.value
125
+ const pivotY = scrollTop + clientHeight * 0.4 // aim to keep ~40% down the viewport stable
126
+
127
+ // Recompute column heights with the new layout
128
+ const heights = calculateColumnHeights(masonry.value, columns.value)
129
+ const anchorColumnIndex = heights.indexOf(Math.max(...heights))
130
+
131
+ // Find items belonging to the anchor column
132
+ const itemsInAnchor = masonry.value.filter((_, index) => index % columns.value === anchorColumnIndex)
133
+ if (itemsInAnchor.length === 0) return
134
+
135
+ // Choose the item whose top is the largest <= pivotY (closest above pivot)
136
+ let pivotItem = itemsInAnchor[0]
137
+ for (const it of itemsInAnchor) {
138
+ if (it.top <= pivotY && it.top >= pivotItem.top) {
139
+ pivotItem = it
79
140
  }
80
141
  }
142
+
143
+ const desiredTop = Math.max(0, pivotItem.top - clientHeight * 0.4)
144
+
145
+ // Only adjust if we drifted significantly (> 4px) to avoid tiny corrections
146
+ if (Math.abs(desiredTop - scrollTop) > 4) {
147
+ container.value.scrollTo({ top: desiredTop, behavior: 'auto' })
148
+ }
149
+ }
150
+
151
+ // Legacy function kept for compatibility; prefer maintainAnchorPosition()
152
+ async function adjustScrollPosition() {
153
+ await maintainAnchorPosition()
81
154
  }
82
155
 
83
156
  return {
@@ -3,37 +3,74 @@
3
3
  */
4
4
  export function useMasonryTransitions(masonry) {
5
5
  function onEnter(el, done) {
6
- // Set top to data-top
7
- const top = el.dataset.top
6
+ // Animate to its final transform (translate3d(left, top, 0)) with subtle scale/opacity
7
+ const left = parseInt(el.dataset.left || '0', 10)
8
+ const top = parseInt(el.dataset.top || '0', 10)
9
+ const index = parseInt(el.dataset.index || '0', 10)
10
+
11
+ // Small stagger per item, capped
12
+ const delay = Math.min(index * 20, 160)
13
+
14
+ // Apply delay only for the enter; avoid affecting move transitions
15
+ const prevDelay = el.style.transitionDelay
16
+ el.style.transitionDelay = `${delay}ms`
17
+
8
18
  requestAnimationFrame(() => {
9
- el.style.top = `${top}px`
10
- done()
19
+ el.style.opacity = '1'
20
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
21
+ const clear = () => {
22
+ el.style.transitionDelay = prevDelay || '' // restore
23
+ el.removeEventListener('transitionend', clear)
24
+ done()
25
+ }
26
+ el.addEventListener('transitionend', clear)
11
27
  })
12
28
  }
13
29
 
14
30
  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
- }
31
+ // Start slightly below and slightly smaller, faded
32
+ const left = parseInt(el.dataset.left || '0', 10)
33
+ const top = parseInt(el.dataset.top || '0', 10)
34
+ el.style.opacity = '0'
35
+ el.style.transform = `translate3d(${left}px, ${top + 10}px, 0) scale(0.985)`
23
36
  }
24
37
 
25
38
  function onBeforeLeave(el) {
26
- // Ensure it's at its current position before animating
39
+ // Ensure it is at its current transform position before animating
40
+ const left = parseInt(el.dataset.left || '0', 10)
41
+ const top = parseInt(el.dataset.top || '0', 10)
27
42
  el.style.transition = 'none'
28
- el.style.top = `${el.offsetTop}px`
43
+ el.style.opacity = '1'
44
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
29
45
  void el.offsetWidth // force reflow to flush style
30
46
  el.style.transition = '' // allow transition to apply again
31
47
  }
32
48
 
33
49
  function onLeave(el, done) {
34
- el.style.top = '-600px'
35
- el.style.opacity = '0'
36
- el.addEventListener('transitionend', done)
50
+ const left = parseInt(el.dataset.left || '0', 10)
51
+ const top = parseInt(el.dataset.top || '0', 10)
52
+
53
+ // Run on next frame to ensure transition styles are applied
54
+ const cleanup = () => {
55
+ el.removeEventListener('transitionend', onEnd)
56
+ clearTimeout(fallback)
57
+ }
58
+ const onEnd = (e) => {
59
+ if (!e || e.target === el) {
60
+ cleanup()
61
+ done()
62
+ }
63
+ }
64
+ const fallback = setTimeout(() => {
65
+ cleanup()
66
+ done()
67
+ }, 800)
68
+
69
+ requestAnimationFrame(() => {
70
+ el.style.opacity = '0'
71
+ el.style.transform = `translate3d(${left}px, ${top + 10}px, 0) scale(0.985)`
72
+ el.addEventListener('transitionend', onEnd)
73
+ })
37
74
  }
38
75
 
39
76
  return {