@wyxos/vibe 1.6.22 → 1.6.24

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.
@@ -0,0 +1,342 @@
1
+ import { ref, type Ref } from 'vue'
2
+ import { normalizeError } from './utils/errorHandler'
3
+
4
+ export interface UseMasonryPaginationOptions {
5
+ getNextPage: (page: any) => Promise<{ items: any[]; nextPage: any }>
6
+ masonry: Ref<any[]>
7
+ isLoading: Ref<boolean>
8
+ hasReachedEnd: Ref<boolean>
9
+ loadError: Ref<Error | null>
10
+ currentPage: Ref<any>
11
+ paginationHistory: Ref<any[]>
12
+ refreshLayout: (items: any[]) => void
13
+ retryMaxAttempts: number
14
+ retryInitialDelayMs: number
15
+ retryBackoffStepMs: number
16
+ backfillEnabled: boolean
17
+ backfillDelayMs: number
18
+ backfillMaxCalls: number
19
+ pageSize: number
20
+ autoRefreshOnEmpty: boolean
21
+ emits: {
22
+ (event: 'retry:start', payload: { attempt: number; max: number; totalMs: number }): void
23
+ (event: 'retry:tick', payload: { attempt: number; remainingMs: number; totalMs: number }): void
24
+ (event: 'retry:stop', payload: { attempt: number; success: boolean }): void
25
+ (event: 'backfill:start', payload: { target: number; fetched: number; calls: number }): void
26
+ (event: 'backfill:tick', payload: { fetched: number; target: number; calls: number; remainingMs: number; totalMs: number }): void
27
+ (event: 'backfill:stop', payload: { fetched: number; calls: number; cancelled?: boolean }): void
28
+ }
29
+ }
30
+
31
+ export function useMasonryPagination(options: UseMasonryPaginationOptions) {
32
+ const {
33
+ getNextPage,
34
+ masonry,
35
+ isLoading,
36
+ hasReachedEnd,
37
+ loadError,
38
+ currentPage,
39
+ paginationHistory,
40
+ refreshLayout,
41
+ retryMaxAttempts,
42
+ retryInitialDelayMs,
43
+ retryBackoffStepMs,
44
+ backfillEnabled,
45
+ backfillDelayMs,
46
+ backfillMaxCalls,
47
+ pageSize,
48
+ autoRefreshOnEmpty,
49
+ emits
50
+ } = options
51
+
52
+ const cancelRequested = ref(false)
53
+ let backfillActive = false
54
+
55
+ function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
56
+ return new Promise<void>((resolve) => {
57
+ const total = Math.max(0, totalMs | 0)
58
+ const start = Date.now()
59
+ onTick(total, total)
60
+ const id = setInterval(() => {
61
+ // Check for cancellation
62
+ if (cancelRequested.value) {
63
+ clearInterval(id)
64
+ resolve()
65
+ return
66
+ }
67
+ const elapsed = Date.now() - start
68
+ const remaining = Math.max(0, total - elapsed)
69
+ onTick(remaining, total)
70
+ if (remaining <= 0) {
71
+ clearInterval(id)
72
+ resolve()
73
+ }
74
+ }, 100)
75
+ })
76
+ }
77
+
78
+ async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
79
+ let attempt = 0
80
+ const max = retryMaxAttempts
81
+ let delay = retryInitialDelayMs
82
+ // eslint-disable-next-line no-constant-condition
83
+ while (true) {
84
+ try {
85
+ const res = await fn()
86
+ if (attempt > 0) {
87
+ emits('retry:stop', { attempt, success: true })
88
+ }
89
+ return res
90
+ } catch (err) {
91
+ attempt++
92
+ if (attempt > max) {
93
+ emits('retry:stop', { attempt: attempt - 1, success: false })
94
+ throw err
95
+ }
96
+ emits('retry:start', { attempt, max, totalMs: delay })
97
+ await waitWithProgress(delay, (remaining, total) => {
98
+ emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
99
+ })
100
+ delay += retryBackoffStepMs
101
+ }
102
+ }
103
+ }
104
+
105
+ async function getContent(page: number) {
106
+ try {
107
+ const response = await fetchWithRetry(() => getNextPage(page))
108
+ refreshLayout([...masonry.value, ...response.items])
109
+ return response
110
+ } catch (error) {
111
+ // Error is handled by callers (loadPage, loadNext, etc.) which set loadError
112
+ throw error
113
+ }
114
+ }
115
+
116
+ async function maybeBackfillToTarget(baselineCount: number, force = false) {
117
+ if (!force && !backfillEnabled) return
118
+ if (backfillActive) return
119
+ if (cancelRequested.value) return
120
+ // Don't backfill if we've reached the end
121
+ if (hasReachedEnd.value) return
122
+
123
+ const targetCount = (baselineCount || 0) + (pageSize || 0)
124
+ if (!pageSize || pageSize <= 0) return
125
+
126
+ const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
127
+ if (lastNext == null) {
128
+ hasReachedEnd.value = true
129
+ return
130
+ }
131
+
132
+ if (masonry.value.length >= targetCount) return
133
+
134
+ backfillActive = true
135
+ // Set loading to true at the start of backfill and keep it true throughout
136
+ isLoading.value = true
137
+ try {
138
+ let calls = 0
139
+ emits('backfill:start', { target: targetCount, fetched: masonry.value.length, calls })
140
+
141
+ while (
142
+ masonry.value.length < targetCount &&
143
+ calls < backfillMaxCalls &&
144
+ paginationHistory.value[paginationHistory.value.length - 1] != null &&
145
+ !cancelRequested.value &&
146
+ !hasReachedEnd.value &&
147
+ backfillActive
148
+ ) {
149
+ await waitWithProgress(backfillDelayMs, (remaining, total) => {
150
+ emits('backfill:tick', {
151
+ fetched: masonry.value.length,
152
+ target: targetCount,
153
+ calls,
154
+ remainingMs: remaining,
155
+ totalMs: total
156
+ })
157
+ })
158
+
159
+ if (cancelRequested.value || !backfillActive) break
160
+
161
+ const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
162
+ if (currentPage == null) {
163
+ hasReachedEnd.value = true
164
+ break
165
+ }
166
+ try {
167
+ // Don't toggle isLoading here - keep it true throughout backfill
168
+ // Check cancellation before starting getContent to avoid unnecessary requests
169
+ if (cancelRequested.value || !backfillActive) break
170
+ const response = await getContent(currentPage)
171
+ if (cancelRequested.value || !backfillActive) break
172
+ // Clear error on successful load
173
+ loadError.value = null
174
+ paginationHistory.value.push(response.nextPage)
175
+ // Update hasReachedEnd if nextPage is null
176
+ if (response.nextPage == null) {
177
+ hasReachedEnd.value = true
178
+ }
179
+ } catch (error) {
180
+ // Set load error but don't break the backfill loop unless cancelled
181
+ if (cancelRequested.value || !backfillActive) break
182
+ loadError.value = normalizeError(error)
183
+ }
184
+
185
+ calls++
186
+ }
187
+
188
+ emits('backfill:stop', { fetched: masonry.value.length, calls })
189
+ } finally {
190
+ backfillActive = false
191
+ // Only set loading to false when backfill completes or is cancelled
192
+ isLoading.value = false
193
+ }
194
+ }
195
+
196
+ async function loadPage(page: number) {
197
+ if (isLoading.value) return
198
+ // Starting a new load should clear any previous cancel request
199
+ cancelRequested.value = false
200
+ isLoading.value = true
201
+ // Reset hasReachedEnd and loadError when loading a new page
202
+ hasReachedEnd.value = false
203
+ loadError.value = null
204
+ try {
205
+ const baseline = masonry.value.length
206
+ if (cancelRequested.value) return
207
+ const response = await getContent(page)
208
+ if (cancelRequested.value) return
209
+ // Clear error on successful load
210
+ loadError.value = null
211
+ currentPage.value = page // Track the current page
212
+ paginationHistory.value.push(response.nextPage)
213
+ // Update hasReachedEnd if nextPage is null
214
+ if (response.nextPage == null) {
215
+ hasReachedEnd.value = true
216
+ }
217
+ await maybeBackfillToTarget(baseline)
218
+ return response
219
+ } catch (error) {
220
+ // Set load error - error is handled and exposed to UI via loadError
221
+ loadError.value = normalizeError(error)
222
+ throw error
223
+ } finally {
224
+ isLoading.value = false
225
+ }
226
+ }
227
+
228
+ async function loadNext() {
229
+ if (isLoading.value) return
230
+ // Don't load if we've already reached the end
231
+ if (hasReachedEnd.value) return
232
+ // Starting a new load should clear any previous cancel request
233
+ cancelRequested.value = false
234
+ isLoading.value = true
235
+ // Clear error when attempting to load
236
+ loadError.value = null
237
+ try {
238
+ const baseline = masonry.value.length
239
+ if (cancelRequested.value) return
240
+ const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
241
+ // Don't load if nextPageToLoad is null
242
+ if (nextPageToLoad == null) {
243
+ hasReachedEnd.value = true
244
+ isLoading.value = false
245
+ return
246
+ }
247
+ const response = await getContent(nextPageToLoad)
248
+ if (cancelRequested.value) return
249
+ // Clear error on successful load
250
+ loadError.value = null
251
+ currentPage.value = nextPageToLoad // Track the current page
252
+ paginationHistory.value.push(response.nextPage)
253
+ // Update hasReachedEnd if nextPage is null
254
+ if (response.nextPage == null) {
255
+ hasReachedEnd.value = true
256
+ }
257
+ await maybeBackfillToTarget(baseline)
258
+ return response
259
+ } catch (error) {
260
+ // Set load error - error is handled and exposed to UI via loadError
261
+ loadError.value = normalizeError(error)
262
+ throw error
263
+ } finally {
264
+ isLoading.value = false
265
+ }
266
+ }
267
+
268
+ async function refreshCurrentPage() {
269
+ if (isLoading.value) return
270
+ cancelRequested.value = false
271
+ isLoading.value = true
272
+
273
+ try {
274
+ // Use the tracked current page
275
+ const pageToRefresh = currentPage.value
276
+
277
+ if (pageToRefresh == null) {
278
+ console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
279
+ return
280
+ }
281
+
282
+ // Clear existing items
283
+ masonry.value = []
284
+ // Reset end flag when refreshing
285
+ hasReachedEnd.value = false
286
+ // Reset error flag when refreshing
287
+ loadError.value = null
288
+
289
+ // Reset pagination history to just the current page
290
+ paginationHistory.value = [pageToRefresh]
291
+
292
+ // Reload the current page
293
+ const response = await getContent(pageToRefresh)
294
+ if (cancelRequested.value) return
295
+
296
+ // Clear error on successful load
297
+ loadError.value = null
298
+ // Update pagination state
299
+ currentPage.value = pageToRefresh
300
+ paginationHistory.value.push(response.nextPage)
301
+ // Update hasReachedEnd if nextPage is null
302
+ if (response.nextPage == null) {
303
+ hasReachedEnd.value = true
304
+ }
305
+
306
+ // Optionally backfill if needed
307
+ const baseline = masonry.value.length
308
+ await maybeBackfillToTarget(baseline)
309
+
310
+ return response
311
+ } catch (error) {
312
+ // Set load error - error is handled and exposed to UI via loadError
313
+ loadError.value = normalizeError(error)
314
+ throw error
315
+ } finally {
316
+ isLoading.value = false
317
+ }
318
+ }
319
+
320
+ function cancelLoad() {
321
+ const wasBackfilling = backfillActive
322
+ cancelRequested.value = true
323
+ isLoading.value = false
324
+ // Set backfillActive to false to immediately stop backfilling
325
+ // The backfill loop checks this flag and will exit on the next iteration
326
+ backfillActive = false
327
+ // If backfill was active, emit stop event immediately
328
+ if (wasBackfilling) {
329
+ emits('backfill:stop', { fetched: masonry.value.length, calls: 0, cancelled: true })
330
+ }
331
+ }
332
+
333
+ return {
334
+ loadPage,
335
+ loadNext,
336
+ refreshCurrentPage,
337
+ cancelLoad,
338
+ maybeBackfillToTarget,
339
+ getContent
340
+ }
341
+ }
342
+
@@ -2,7 +2,10 @@
2
2
  * Composable for handling masonry item transitions (typed)
3
3
  * Optimized for large item arrays by skipping DOM operations for items outside viewport
4
4
  */
5
- export function useMasonryTransitions(refs: { container?: any; masonry?: any }, opts?: { leaveDurationMs?: number }) {
5
+ export function useMasonryTransitions(
6
+ refs: { container?: any; masonry?: any },
7
+ opts?: { leaveDurationMs?: number; virtualizing?: { value: boolean } }
8
+ ) {
6
9
  // Cache viewport bounds to avoid repeated calculations
7
10
  let cachedViewportTop = 0
8
11
  let cachedViewportBottom = 0
@@ -32,6 +35,19 @@ export function useMasonryTransitions(refs: { container?: any; masonry?: any },
32
35
  // Get height from computed style or use a reasonable fallback
33
36
  const height = el.offsetHeight || parseInt(getComputedStyle(el).height || '200', 10) || 200
34
37
 
38
+ // Skip animation during virtualization - just set position immediately
39
+ if (opts?.virtualizing?.value) {
40
+ el.style.transition = 'none'
41
+ el.style.opacity = '1'
42
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
43
+ el.style.removeProperty('--masonry-opacity-delay')
44
+ requestAnimationFrame(() => {
45
+ el.style.transition = ''
46
+ done()
47
+ })
48
+ return
49
+ }
50
+
35
51
  // Skip animation for items outside viewport - just set position immediately
36
52
  if (!isItemInViewport(top, height)) {
37
53
  el.style.opacity = '1'
@@ -64,6 +80,16 @@ export function useMasonryTransitions(refs: { container?: any; masonry?: any },
64
80
  function onBeforeEnter(el: HTMLElement) {
65
81
  const left = parseInt(el.dataset.left || '0', 10)
66
82
  const top = parseInt(el.dataset.top || '0', 10)
83
+
84
+ // Skip animation during virtualization
85
+ if (opts?.virtualizing?.value) {
86
+ el.style.transition = 'none'
87
+ el.style.opacity = '1'
88
+ el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
89
+ el.style.removeProperty('--masonry-opacity-delay')
90
+ return
91
+ }
92
+
67
93
  el.style.opacity = '0'
68
94
  el.style.transform = `translate3d(${left}px, ${top + 10}px, 0) scale(0.985)`
69
95
  }
@@ -73,6 +99,12 @@ export function useMasonryTransitions(refs: { container?: any; masonry?: any },
73
99
  const top = parseInt(el.dataset.top || '0', 10)
74
100
  const height = el.offsetHeight || parseInt(getComputedStyle(el).height || '200', 10) || 200
75
101
 
102
+ // Skip animation during virtualization
103
+ if (opts?.virtualizing?.value) {
104
+ // no-op; removal will be immediate in leave
105
+ return
106
+ }
107
+
76
108
  // Skip animation for items outside viewport
77
109
  if (!isItemInViewport(top, height)) {
78
110
  el.style.transition = 'none'
@@ -94,6 +126,12 @@ export function useMasonryTransitions(refs: { container?: any; masonry?: any },
94
126
  const top = parseInt(el.dataset.top || '0', 10)
95
127
  const height = el.offsetHeight || parseInt(getComputedStyle(el).height || '200', 10) || 200
96
128
 
129
+ // Skip animation during virtualization - remove immediately
130
+ if (opts?.virtualizing?.value) {
131
+ done()
132
+ return
133
+ }
134
+
97
135
  // Skip animation for items outside viewport - remove immediately
98
136
  if (!isItemInViewport(top, height)) {
99
137
  el.style.transition = 'none'
@@ -0,0 +1,140 @@
1
+ import { ref, computed, nextTick, type Ref, type ComputedRef } from 'vue'
2
+ import { calculateColumnHeights } from './masonryUtils'
3
+
4
+ export interface UseMasonryVirtualizationOptions {
5
+ masonry: Ref<any[]>
6
+ container: Ref<HTMLElement | null>
7
+ columns: Ref<number>
8
+ virtualBufferPx: number
9
+ loadThresholdPx: number
10
+ handleScroll: (precomputedHeights?: number[]) => void
11
+ }
12
+
13
+ export function useMasonryVirtualization(options: UseMasonryVirtualizationOptions) {
14
+ const {
15
+ masonry,
16
+ container,
17
+ columns,
18
+ virtualBufferPx,
19
+ loadThresholdPx
20
+ } = options
21
+
22
+ // Use a ref for handleScroll so it can be updated after initialization
23
+ const handleScrollRef = ref<(precomputedHeights?: number[]) => void>(options.handleScroll)
24
+
25
+ // Virtualization viewport state
26
+ const viewportTop = ref(0)
27
+ const viewportHeight = ref(0)
28
+ const VIRTUAL_BUFFER_PX = virtualBufferPx
29
+
30
+ // Gate transitions during virtualization-only DOM churn
31
+ const virtualizing = ref(false)
32
+
33
+ // Scroll progress tracking
34
+ const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
35
+ distanceToTrigger: 0,
36
+ isNearTrigger: false
37
+ })
38
+
39
+ // Visible window of items (virtualization)
40
+ const visibleMasonry = computed(() => {
41
+ const top = viewportTop.value - VIRTUAL_BUFFER_PX
42
+ const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
43
+ const items = masonry.value as any[]
44
+ if (!items || items.length === 0) return [] as any[]
45
+
46
+ // Filter items that have valid positions and are within viewport
47
+ const visible = items.filter((it: any) => {
48
+ // If item doesn't have position yet, include it (will be filtered once layout is calculated)
49
+ if (typeof it.top !== 'number' || typeof it.columnHeight !== 'number') {
50
+ return true // Include items without positions to avoid hiding them prematurely
51
+ }
52
+ const itemTop = it.top
53
+ const itemBottom = it.top + it.columnHeight
54
+ return itemBottom >= top && itemTop <= bottom
55
+ })
56
+
57
+ // Log if we're filtering out items (for debugging)
58
+ if (import.meta.env.DEV && items.length > 0 && visible.length === 0 && viewportHeight.value > 0) {
59
+ const itemsWithPositions = items.filter((it: any) =>
60
+ typeof it.top === 'number' && typeof it.columnHeight === 'number'
61
+ )
62
+ }
63
+
64
+ return visible
65
+ })
66
+
67
+ function updateScrollProgress(precomputedHeights?: number[]) {
68
+ if (!container.value) return
69
+
70
+ const { scrollTop, clientHeight } = container.value
71
+ const visibleBottom = scrollTop + clientHeight
72
+
73
+ const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
74
+ const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
75
+ const threshold = typeof loadThresholdPx === 'number' ? loadThresholdPx : 200
76
+ const triggerPoint = threshold >= 0
77
+ ? Math.max(0, tallest - threshold)
78
+ : Math.max(0, tallest + threshold)
79
+
80
+ const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
81
+ const isNearTrigger = distanceToTrigger <= 100
82
+
83
+ scrollProgress.value = {
84
+ distanceToTrigger: Math.round(distanceToTrigger),
85
+ isNearTrigger
86
+ }
87
+ }
88
+
89
+ async function updateViewport() {
90
+ if (container.value) {
91
+ const scrollTop = container.value.scrollTop
92
+ const clientHeight = container.value.clientHeight || window.innerHeight
93
+ // Ensure viewportHeight is never 0 (fallback to window height if container height is 0)
94
+ const safeClientHeight = clientHeight > 0 ? clientHeight : window.innerHeight
95
+ viewportTop.value = scrollTop
96
+ viewportHeight.value = safeClientHeight
97
+ // Log when scroll handler runs (helpful for debugging viewport issues)
98
+ if (import.meta.env.DEV) {
99
+ console.log('[Masonry] scroll: viewport updated', {
100
+ scrollTop,
101
+ clientHeight: safeClientHeight,
102
+ itemsCount: masonry.value.length,
103
+ visibleItemsCount: visibleMasonry.value.length
104
+ })
105
+ }
106
+ }
107
+ // Gate transitions for virtualization-only DOM changes
108
+ virtualizing.value = true
109
+ await nextTick()
110
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
111
+ virtualizing.value = false
112
+
113
+ const heights = calculateColumnHeights(masonry.value as any, columns.value)
114
+ handleScrollRef.value(heights as any)
115
+ updateScrollProgress(heights)
116
+ }
117
+
118
+ function reset() {
119
+ viewportTop.value = 0
120
+ viewportHeight.value = 0
121
+ virtualizing.value = false
122
+ scrollProgress.value = {
123
+ distanceToTrigger: 0,
124
+ isNearTrigger: false
125
+ }
126
+ }
127
+
128
+ return {
129
+ viewportTop,
130
+ viewportHeight,
131
+ virtualizing,
132
+ scrollProgress,
133
+ visibleMasonry,
134
+ updateScrollProgress,
135
+ updateViewport,
136
+ reset,
137
+ handleScroll: handleScrollRef
138
+ }
139
+ }
140
+