@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.
- package/lib/index.js +1253 -1082
- package/lib/vibe.css +1 -1
- package/package.json +1 -1
- package/src/Masonry.vue +177 -809
- package/src/useMasonryDimensions.ts +59 -0
- package/src/useMasonryItems.ts +218 -0
- package/src/useMasonryLayout.ts +160 -0
- package/src/useMasonryPagination.ts +342 -0
- package/src/useMasonryTransitions.ts +39 -1
- package/src/useMasonryVirtualization.ts +140 -0
|
@@ -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(
|
|
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
|
+
|