@wyxos/vibe 1.6.22 → 1.6.23
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 +1236 -1085
- package/lib/vibe.css +1 -1
- package/package.json +1 -1
- package/src/Masonry.vue +118 -791
- 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
package/src/Masonry.vue
CHANGED
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
import { useMasonryTransitions } from './useMasonryTransitions'
|
|
13
13
|
import { useMasonryScroll } from './useMasonryScroll'
|
|
14
14
|
import { useSwipeMode as useSwipeModeComposable } from './useSwipeMode'
|
|
15
|
+
import { useMasonryPagination } from './useMasonryPagination'
|
|
16
|
+
import { useMasonryItems } from './useMasonryItems'
|
|
17
|
+
import { useMasonryLayout } from './useMasonryLayout'
|
|
18
|
+
import { useMasonryVirtualization } from './useMasonryVirtualization'
|
|
19
|
+
import { useMasonryDimensions } from './useMasonryDimensions'
|
|
15
20
|
import MasonryItem from './components/MasonryItem.vue'
|
|
16
21
|
import { normalizeError } from './utils/errorHandler'
|
|
17
22
|
|
|
@@ -200,158 +205,90 @@ const loadError = ref<Error | null>(null) // Track load errors
|
|
|
200
205
|
const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
|
|
201
206
|
|
|
202
207
|
|
|
203
|
-
//
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
207
|
-
}
|
|
208
|
-
function checkItemDimensions(items: any[], context: string) {
|
|
209
|
-
try {
|
|
210
|
-
if (!Array.isArray(items) || items.length === 0) return
|
|
211
|
-
const missing = items.filter((item) => !isPositiveNumber(item?.width) || !isPositiveNumber(item?.height))
|
|
212
|
-
if (missing.length === 0) return
|
|
213
|
-
|
|
214
|
-
const newIds: Array<number | string> = []
|
|
215
|
-
for (const item of missing) {
|
|
216
|
-
const id = (item?.id as number | string | undefined) ?? `idx:${items.indexOf(item)}`
|
|
217
|
-
if (!invalidDimensionIds.value.has(id)) {
|
|
218
|
-
invalidDimensionIds.value.add(id)
|
|
219
|
-
newIds.push(id)
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
if (newIds.length > 0) {
|
|
223
|
-
const sample = newIds.slice(0, 10)
|
|
224
|
-
// eslint-disable-next-line no-console
|
|
225
|
-
console.warn(
|
|
226
|
-
'[Masonry] Items missing width/height detected:',
|
|
227
|
-
{
|
|
228
|
-
context,
|
|
229
|
-
count: newIds.length,
|
|
230
|
-
sampleIds: sample,
|
|
231
|
-
hint: 'Ensure each item has positive width and height. Consider providing fallbacks (e.g., 512x512) at the data layer.'
|
|
232
|
-
}
|
|
233
|
-
)
|
|
234
|
-
}
|
|
235
|
-
} catch {
|
|
236
|
-
// best-effort diagnostics only
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// Virtualization viewport state
|
|
241
|
-
const viewportTop = ref(0)
|
|
242
|
-
const viewportHeight = ref(0)
|
|
243
|
-
const VIRTUAL_BUFFER_PX = props.virtualBufferPx
|
|
244
|
-
|
|
245
|
-
// Gate transitions during virtualization-only DOM churn
|
|
246
|
-
const virtualizing = ref(false)
|
|
247
|
-
|
|
248
|
-
// Scroll progress tracking
|
|
249
|
-
const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
|
|
250
|
-
distanceToTrigger: 0,
|
|
251
|
-
isNearTrigger: false
|
|
208
|
+
// Initialize dimensions composable first (needed by layout composable)
|
|
209
|
+
const dimensions = useMasonryDimensions({
|
|
210
|
+
masonry: masonry as any
|
|
252
211
|
})
|
|
253
212
|
|
|
254
|
-
|
|
255
|
-
|
|
213
|
+
// Extract dimension checking function
|
|
214
|
+
const { checkItemDimensions, invalidDimensionIds, reset: resetDimensions } = dimensions
|
|
256
215
|
|
|
257
|
-
|
|
258
|
-
|
|
216
|
+
// Initialize layout composable (needs checkItemDimensions from dimensions composable)
|
|
217
|
+
const layoutComposable = useMasonryLayout({
|
|
218
|
+
masonry: masonry as any,
|
|
219
|
+
useSwipeMode,
|
|
220
|
+
container,
|
|
221
|
+
columns,
|
|
222
|
+
containerWidth,
|
|
223
|
+
masonryContentHeight,
|
|
224
|
+
layout,
|
|
225
|
+
fixedDimensions,
|
|
226
|
+
checkItemDimensions
|
|
227
|
+
})
|
|
259
228
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const threshold = typeof props.loadThresholdPx === 'number' ? props.loadThresholdPx : 200
|
|
263
|
-
const triggerPoint = threshold >= 0
|
|
264
|
-
? Math.max(0, tallest - threshold)
|
|
265
|
-
: Math.max(0, tallest + threshold)
|
|
229
|
+
// Extract layout functions
|
|
230
|
+
const { refreshLayout, setFixedDimensions: setFixedDimensionsLayout, onResize: onResizeLayout } = layoutComposable
|
|
266
231
|
|
|
267
|
-
|
|
268
|
-
|
|
232
|
+
// Initialize virtualization composable
|
|
233
|
+
const virtualization = useMasonryVirtualization({
|
|
234
|
+
masonry: masonry as any,
|
|
235
|
+
container,
|
|
236
|
+
columns,
|
|
237
|
+
virtualBufferPx: props.virtualBufferPx,
|
|
238
|
+
loadThresholdPx: props.loadThresholdPx,
|
|
239
|
+
handleScroll: () => { } // Will be set after pagination is initialized
|
|
240
|
+
})
|
|
269
241
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
isNearTrigger
|
|
273
|
-
}
|
|
274
|
-
}
|
|
242
|
+
// Extract virtualization state and functions
|
|
243
|
+
const { viewportTop, viewportHeight, virtualizing, scrollProgress, visibleMasonry, updateScrollProgress, updateViewport: updateViewportVirtualization, reset: resetVirtualization } = virtualization
|
|
275
244
|
|
|
276
|
-
//
|
|
245
|
+
// Initialize transitions composable with virtualization support
|
|
277
246
|
const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(
|
|
278
247
|
{ container, masonry: masonry as any },
|
|
279
|
-
{ leaveDurationMs: props.leaveDurationMs }
|
|
248
|
+
{ leaveDurationMs: props.leaveDurationMs, virtualizing }
|
|
280
249
|
)
|
|
281
250
|
|
|
282
|
-
// Transition
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
el.style.transition = 'none'
|
|
288
|
-
el.style.opacity = '1'
|
|
289
|
-
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
290
|
-
el.style.removeProperty('--masonry-opacity-delay')
|
|
291
|
-
requestAnimationFrame(() => {
|
|
292
|
-
el.style.transition = ''
|
|
293
|
-
done()
|
|
294
|
-
})
|
|
295
|
-
} else {
|
|
296
|
-
onEnter(el, done)
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
function beforeEnter(el: HTMLElement) {
|
|
300
|
-
if (virtualizing.value) {
|
|
301
|
-
const left = parseInt(el.dataset.left || '0', 10)
|
|
302
|
-
const top = parseInt(el.dataset.top || '0', 10)
|
|
303
|
-
el.style.transition = 'none'
|
|
304
|
-
el.style.opacity = '1'
|
|
305
|
-
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
306
|
-
el.style.removeProperty('--masonry-opacity-delay')
|
|
307
|
-
} else {
|
|
308
|
-
onBeforeEnter(el)
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
function beforeLeave(el: HTMLElement) {
|
|
312
|
-
if (virtualizing.value) {
|
|
313
|
-
// no-op; removal will be immediate in leave
|
|
314
|
-
} else {
|
|
315
|
-
onBeforeLeave(el)
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
function leave(el: HTMLElement, done: () => void) {
|
|
319
|
-
if (virtualizing.value) {
|
|
320
|
-
// Skip animation during virtualization
|
|
321
|
-
done()
|
|
322
|
-
} else {
|
|
323
|
-
onLeave(el, done)
|
|
324
|
-
}
|
|
325
|
-
}
|
|
251
|
+
// Transition functions for template (wrapped to match expected signature)
|
|
252
|
+
const enter = onEnter
|
|
253
|
+
const beforeEnter = onBeforeEnter
|
|
254
|
+
const beforeLeave = onBeforeLeave
|
|
255
|
+
const leave = onLeave
|
|
326
256
|
|
|
327
|
-
//
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
257
|
+
// Initialize pagination composable
|
|
258
|
+
const pagination = useMasonryPagination({
|
|
259
|
+
getNextPage: props.getNextPage as (page: any) => Promise<{ items: any[]; nextPage: any }>,
|
|
260
|
+
masonry: masonry as any,
|
|
261
|
+
isLoading,
|
|
262
|
+
hasReachedEnd,
|
|
263
|
+
loadError,
|
|
264
|
+
currentPage,
|
|
265
|
+
paginationHistory,
|
|
266
|
+
refreshLayout,
|
|
267
|
+
retryMaxAttempts: props.retryMaxAttempts,
|
|
268
|
+
retryInitialDelayMs: props.retryInitialDelayMs,
|
|
269
|
+
retryBackoffStepMs: props.retryBackoffStepMs,
|
|
270
|
+
backfillEnabled: props.backfillEnabled,
|
|
271
|
+
backfillDelayMs: props.backfillDelayMs,
|
|
272
|
+
backfillMaxCalls: props.backfillMaxCalls,
|
|
273
|
+
pageSize: props.pageSize,
|
|
274
|
+
autoRefreshOnEmpty: props.autoRefreshOnEmpty,
|
|
275
|
+
emits
|
|
276
|
+
})
|
|
344
277
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const itemsWithPositions = items.filter((it: any) =>
|
|
348
|
-
typeof it.top === 'number' && typeof it.columnHeight === 'number'
|
|
349
|
-
)
|
|
350
|
-
}
|
|
278
|
+
// Extract pagination functions
|
|
279
|
+
const { loadPage, loadNext, refreshCurrentPage, cancelLoad, maybeBackfillToTarget } = pagination
|
|
351
280
|
|
|
352
|
-
|
|
281
|
+
// Initialize swipe mode composable (needs loadNext and loadPage from pagination)
|
|
282
|
+
const swipeMode = useSwipeModeComposable({
|
|
283
|
+
useSwipeMode,
|
|
284
|
+
masonry: masonry as any,
|
|
285
|
+
isLoading,
|
|
286
|
+
loadNext,
|
|
287
|
+
loadPage,
|
|
288
|
+
paginationHistory
|
|
353
289
|
})
|
|
354
290
|
|
|
291
|
+
// Initialize scroll handler (needs loadNext from pagination)
|
|
355
292
|
const { handleScroll } = useMasonryScroll({
|
|
356
293
|
container,
|
|
357
294
|
masonry: masonry as any,
|
|
@@ -367,25 +304,32 @@ const { handleScroll } = useMasonryScroll({
|
|
|
367
304
|
loadThresholdPx: props.loadThresholdPx
|
|
368
305
|
})
|
|
369
306
|
|
|
307
|
+
// Update virtualization handleScroll to use the scroll handler
|
|
308
|
+
virtualization.handleScroll.value = handleScroll
|
|
309
|
+
|
|
310
|
+
// Initialize items composable
|
|
311
|
+
const items = useMasonryItems({
|
|
312
|
+
masonry: masonry as any,
|
|
313
|
+
useSwipeMode,
|
|
314
|
+
refreshLayout,
|
|
315
|
+
refreshCurrentPage,
|
|
316
|
+
loadNext,
|
|
317
|
+
maybeBackfillToTarget,
|
|
318
|
+
autoRefreshOnEmpty: props.autoRefreshOnEmpty,
|
|
319
|
+
paginationHistory
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// Extract item management functions
|
|
323
|
+
const { remove, removeMany, restore, restoreMany, removeAll: removeAllItems } = items
|
|
324
|
+
|
|
325
|
+
// setFixedDimensions is now in useMasonryLayout composable
|
|
326
|
+
// Wrapper function to maintain API compatibility and handle wrapper restoration
|
|
370
327
|
function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
|
|
371
|
-
|
|
372
|
-
if (dimensions) {
|
|
373
|
-
if (dimensions.width !== undefined) containerWidth.value = dimensions.width
|
|
374
|
-
if (dimensions.height !== undefined) containerHeight.value = dimensions.height
|
|
375
|
-
// Force layout refresh when dimensions change
|
|
376
|
-
if (!useSwipeMode.value && container.value && masonry.value.length > 0) {
|
|
377
|
-
nextTick(() => {
|
|
378
|
-
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
379
|
-
refreshLayout(masonry.value as any)
|
|
380
|
-
updateScrollProgress()
|
|
381
|
-
})
|
|
382
|
-
}
|
|
383
|
-
} else {
|
|
328
|
+
setFixedDimensionsLayout(dimensions, updateScrollProgress)
|
|
329
|
+
if (!dimensions && wrapper.value) {
|
|
384
330
|
// When clearing fixed dimensions, restore from wrapper
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
containerHeight.value = wrapper.value.clientHeight
|
|
388
|
-
}
|
|
331
|
+
containerWidth.value = wrapper.value.clientWidth
|
|
332
|
+
containerHeight.value = wrapper.value.clientHeight
|
|
389
333
|
}
|
|
390
334
|
}
|
|
391
335
|
|
|
@@ -407,7 +351,7 @@ defineExpose({
|
|
|
407
351
|
setFixedDimensions,
|
|
408
352
|
remove,
|
|
409
353
|
removeMany,
|
|
410
|
-
removeAll,
|
|
354
|
+
removeAll: removeAllItems,
|
|
411
355
|
restore,
|
|
412
356
|
restoreMany,
|
|
413
357
|
loadNext,
|
|
@@ -425,244 +369,8 @@ defineExpose({
|
|
|
425
369
|
currentBreakpoint
|
|
426
370
|
})
|
|
427
371
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
let floor = 0
|
|
431
|
-
if (container.value) {
|
|
432
|
-
const { scrollTop, clientHeight } = container.value
|
|
433
|
-
floor = scrollTop + clientHeight + 100
|
|
434
|
-
}
|
|
435
|
-
masonryContentHeight.value = Math.max(newHeight, floor)
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Cache previous layout state for incremental updates
|
|
439
|
-
let previousLayoutItems: any[] = []
|
|
440
|
-
let previousColumnHeights: number[] = []
|
|
441
|
-
|
|
442
|
-
function refreshLayout(items: any[]) {
|
|
443
|
-
if (useSwipeMode.value) {
|
|
444
|
-
// In swipe mode, no layout calculation needed - items are stacked vertically
|
|
445
|
-
masonry.value = items as any
|
|
446
|
-
return
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
if (!container.value) return
|
|
450
|
-
// Developer diagnostics: warn when dimensions are invalid
|
|
451
|
-
checkItemDimensions(items as any[], 'refreshLayout')
|
|
452
|
-
|
|
453
|
-
// Optimization: For large arrays, check if we can do incremental update
|
|
454
|
-
// Only works if items were removed from the end (common case)
|
|
455
|
-
const canUseIncremental = items.length > 1000 &&
|
|
456
|
-
previousLayoutItems.length > items.length &&
|
|
457
|
-
previousLayoutItems.length - items.length < 100 // Only small removals
|
|
458
|
-
|
|
459
|
-
if (canUseIncremental) {
|
|
460
|
-
// Check if items were removed from the end (most common case)
|
|
461
|
-
let removedFromEnd = true
|
|
462
|
-
for (let i = 0; i < items.length; i++) {
|
|
463
|
-
if (items[i]?.id !== previousLayoutItems[i]?.id) {
|
|
464
|
-
removedFromEnd = false
|
|
465
|
-
break
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (removedFromEnd) {
|
|
470
|
-
// Items removed from end - we can reuse previous positions for remaining items
|
|
471
|
-
// Just update indices and recalculate height
|
|
472
|
-
const itemsWithIndex = items.map((item, index) => ({
|
|
473
|
-
...previousLayoutItems[index],
|
|
474
|
-
originalIndex: index
|
|
475
|
-
}))
|
|
476
|
-
|
|
477
|
-
// Recalculate height only
|
|
478
|
-
calculateHeight(itemsWithIndex as any)
|
|
479
|
-
masonry.value = itemsWithIndex
|
|
480
|
-
previousLayoutItems = itemsWithIndex
|
|
481
|
-
return
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Full recalculation (fallback for all other cases)
|
|
486
|
-
// Update original index to reflect current position in array
|
|
487
|
-
// This ensures indices are correct after items are removed
|
|
488
|
-
const itemsWithIndex = items.map((item, index) => ({
|
|
489
|
-
...item,
|
|
490
|
-
originalIndex: index
|
|
491
|
-
}))
|
|
492
|
-
|
|
493
|
-
// When fixed dimensions are set, ensure container uses the fixed width for layout
|
|
494
|
-
// This prevents gaps when the container's actual width differs from the fixed width
|
|
495
|
-
const containerEl = container.value as HTMLElement
|
|
496
|
-
if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
|
|
497
|
-
// Temporarily set width to match fixed dimensions for accurate layout calculation
|
|
498
|
-
const originalWidth = containerEl.style.width
|
|
499
|
-
const originalBoxSizing = containerEl.style.boxSizing
|
|
500
|
-
containerEl.style.boxSizing = 'border-box'
|
|
501
|
-
containerEl.style.width = `${fixedDimensions.value.width}px`
|
|
502
|
-
// Force reflow
|
|
503
|
-
containerEl.offsetWidth
|
|
504
|
-
|
|
505
|
-
const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
|
|
506
|
-
|
|
507
|
-
// Restore original width
|
|
508
|
-
containerEl.style.width = originalWidth
|
|
509
|
-
containerEl.style.boxSizing = originalBoxSizing
|
|
510
|
-
|
|
511
|
-
calculateHeight(content as any)
|
|
512
|
-
masonry.value = content
|
|
513
|
-
// Cache for next incremental update
|
|
514
|
-
previousLayoutItems = content
|
|
515
|
-
} else {
|
|
516
|
-
const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
|
|
517
|
-
calculateHeight(content as any)
|
|
518
|
-
masonry.value = content
|
|
519
|
-
// Cache for next incremental update
|
|
520
|
-
previousLayoutItems = content
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
|
|
525
|
-
return new Promise<void>((resolve) => {
|
|
526
|
-
const total = Math.max(0, totalMs | 0)
|
|
527
|
-
const start = Date.now()
|
|
528
|
-
onTick(total, total)
|
|
529
|
-
const id = setInterval(() => {
|
|
530
|
-
// Check for cancellation
|
|
531
|
-
if (cancelRequested.value) {
|
|
532
|
-
clearInterval(id)
|
|
533
|
-
resolve()
|
|
534
|
-
return
|
|
535
|
-
}
|
|
536
|
-
const elapsed = Date.now() - start
|
|
537
|
-
const remaining = Math.max(0, total - elapsed)
|
|
538
|
-
onTick(remaining, total)
|
|
539
|
-
if (remaining <= 0) {
|
|
540
|
-
clearInterval(id)
|
|
541
|
-
resolve()
|
|
542
|
-
}
|
|
543
|
-
}, 100)
|
|
544
|
-
})
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
async function getContent(page: number) {
|
|
548
|
-
try {
|
|
549
|
-
const response = await fetchWithRetry(() => props.getNextPage(page))
|
|
550
|
-
refreshLayout([...(masonry.value as any[]), ...response.items])
|
|
551
|
-
return response
|
|
552
|
-
} catch (error) {
|
|
553
|
-
// Error is handled by callers (loadPage, loadNext, etc.) which set loadError
|
|
554
|
-
throw error
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
|
|
559
|
-
let attempt = 0
|
|
560
|
-
const max = props.retryMaxAttempts
|
|
561
|
-
let delay = props.retryInitialDelayMs
|
|
562
|
-
// eslint-disable-next-line no-constant-condition
|
|
563
|
-
while (true) {
|
|
564
|
-
try {
|
|
565
|
-
const res = await fn()
|
|
566
|
-
if (attempt > 0) {
|
|
567
|
-
emits('retry:stop', { attempt, success: true })
|
|
568
|
-
}
|
|
569
|
-
return res
|
|
570
|
-
} catch (err) {
|
|
571
|
-
attempt++
|
|
572
|
-
if (attempt > max) {
|
|
573
|
-
emits('retry:stop', { attempt: attempt - 1, success: false })
|
|
574
|
-
throw err
|
|
575
|
-
}
|
|
576
|
-
emits('retry:start', { attempt, max, totalMs: delay })
|
|
577
|
-
await waitWithProgress(delay, (remaining, total) => {
|
|
578
|
-
emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
|
|
579
|
-
})
|
|
580
|
-
delay += props.retryBackoffStepMs
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
async function loadPage(page: number) {
|
|
586
|
-
if (isLoading.value) return
|
|
587
|
-
// Starting a new load should clear any previous cancel request
|
|
588
|
-
cancelRequested.value = false
|
|
589
|
-
isLoading.value = true
|
|
590
|
-
// Reset hasReachedEnd and loadError when loading a new page
|
|
591
|
-
hasReachedEnd.value = false
|
|
592
|
-
loadError.value = null
|
|
593
|
-
try {
|
|
594
|
-
const baseline = (masonry.value as any[]).length
|
|
595
|
-
if (cancelRequested.value) return
|
|
596
|
-
const response = await getContent(page)
|
|
597
|
-
if (cancelRequested.value) return
|
|
598
|
-
// Clear error on successful load
|
|
599
|
-
loadError.value = null
|
|
600
|
-
currentPage.value = page // Track the current page
|
|
601
|
-
paginationHistory.value.push(response.nextPage)
|
|
602
|
-
// Update hasReachedEnd if nextPage is null
|
|
603
|
-
if (response.nextPage == null) {
|
|
604
|
-
hasReachedEnd.value = true
|
|
605
|
-
}
|
|
606
|
-
await maybeBackfillToTarget(baseline)
|
|
607
|
-
return response
|
|
608
|
-
} catch (error) {
|
|
609
|
-
// Set load error - error is handled and exposed to UI via loadError
|
|
610
|
-
loadError.value = normalizeError(error)
|
|
611
|
-
throw error
|
|
612
|
-
} finally {
|
|
613
|
-
isLoading.value = false
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
async function loadNext() {
|
|
618
|
-
if (isLoading.value) return
|
|
619
|
-
// Don't load if we've already reached the end
|
|
620
|
-
if (hasReachedEnd.value) return
|
|
621
|
-
// Starting a new load should clear any previous cancel request
|
|
622
|
-
cancelRequested.value = false
|
|
623
|
-
isLoading.value = true
|
|
624
|
-
// Clear error when attempting to load
|
|
625
|
-
loadError.value = null
|
|
626
|
-
try {
|
|
627
|
-
const baseline = (masonry.value as any[]).length
|
|
628
|
-
if (cancelRequested.value) return
|
|
629
|
-
const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
|
|
630
|
-
// Don't load if nextPageToLoad is null
|
|
631
|
-
if (nextPageToLoad == null) {
|
|
632
|
-
hasReachedEnd.value = true
|
|
633
|
-
isLoading.value = false
|
|
634
|
-
return
|
|
635
|
-
}
|
|
636
|
-
const response = await getContent(nextPageToLoad)
|
|
637
|
-
if (cancelRequested.value) return
|
|
638
|
-
// Clear error on successful load
|
|
639
|
-
loadError.value = null
|
|
640
|
-
currentPage.value = nextPageToLoad // Track the current page
|
|
641
|
-
paginationHistory.value.push(response.nextPage)
|
|
642
|
-
// Update hasReachedEnd if nextPage is null
|
|
643
|
-
if (response.nextPage == null) {
|
|
644
|
-
hasReachedEnd.value = true
|
|
645
|
-
}
|
|
646
|
-
await maybeBackfillToTarget(baseline)
|
|
647
|
-
return response
|
|
648
|
-
} catch (error) {
|
|
649
|
-
// Set load error - error is handled and exposed to UI via loadError
|
|
650
|
-
loadError.value = normalizeError(error)
|
|
651
|
-
throw error
|
|
652
|
-
} finally {
|
|
653
|
-
isLoading.value = false
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Initialize swipe mode composable (after loadNext and loadPage are defined)
|
|
658
|
-
const swipeMode = useSwipeModeComposable({
|
|
659
|
-
useSwipeMode,
|
|
660
|
-
masonry: masonry as any,
|
|
661
|
-
isLoading,
|
|
662
|
-
loadNext,
|
|
663
|
-
loadPage,
|
|
664
|
-
paginationHistory
|
|
665
|
-
})
|
|
372
|
+
// Layout functions are now in useMasonryLayout composable
|
|
373
|
+
// Removed: calculateHeight, refreshLayout - now from layoutComposable
|
|
666
374
|
|
|
667
375
|
// Expose swipe mode computed values and state for template
|
|
668
376
|
const currentItem = swipeMode.currentItem
|
|
@@ -684,241 +392,9 @@ const goToNextItem = swipeMode.goToNextItem
|
|
|
684
392
|
const goToPreviousItem = swipeMode.goToPreviousItem
|
|
685
393
|
const snapToCurrentItem = swipeMode.snapToCurrentItem
|
|
686
394
|
|
|
687
|
-
|
|
688
|
-
* Refresh the current page by clearing items and reloading from current page
|
|
689
|
-
* Useful when items are removed and you want to stay on the same page
|
|
690
|
-
*/
|
|
691
|
-
async function refreshCurrentPage() {
|
|
692
|
-
if (isLoading.value) return
|
|
693
|
-
cancelRequested.value = false
|
|
694
|
-
isLoading.value = true
|
|
695
|
-
|
|
696
|
-
try {
|
|
697
|
-
// Use the tracked current page
|
|
698
|
-
const pageToRefresh = currentPage.value
|
|
699
|
-
|
|
700
|
-
if (pageToRefresh == null) {
|
|
701
|
-
console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
|
|
702
|
-
return
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
// Clear existing items
|
|
706
|
-
masonry.value = []
|
|
707
|
-
masonryContentHeight.value = 0
|
|
708
|
-
hasReachedEnd.value = false // Reset end flag when refreshing
|
|
709
|
-
loadError.value = null // Reset error flag when refreshing
|
|
710
|
-
|
|
711
|
-
// Reset pagination history to just the current page
|
|
712
|
-
paginationHistory.value = [pageToRefresh]
|
|
713
|
-
|
|
714
|
-
await nextTick()
|
|
715
|
-
|
|
716
|
-
// Reload the current page
|
|
717
|
-
const response = await getContent(pageToRefresh)
|
|
718
|
-
if (cancelRequested.value) return
|
|
719
|
-
|
|
720
|
-
// Clear error on successful load
|
|
721
|
-
loadError.value = null
|
|
722
|
-
// Update pagination state
|
|
723
|
-
currentPage.value = pageToRefresh
|
|
724
|
-
paginationHistory.value.push(response.nextPage)
|
|
725
|
-
// Update hasReachedEnd if nextPage is null
|
|
726
|
-
if (response.nextPage == null) {
|
|
727
|
-
hasReachedEnd.value = true
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
// Optionally backfill if needed
|
|
731
|
-
const baseline = (masonry.value as any[]).length
|
|
732
|
-
await maybeBackfillToTarget(baseline)
|
|
733
|
-
|
|
734
|
-
return response
|
|
735
|
-
} catch (error) {
|
|
736
|
-
// Set load error - error is handled and exposed to UI via loadError
|
|
737
|
-
loadError.value = normalizeError(error)
|
|
738
|
-
throw error
|
|
739
|
-
} finally {
|
|
740
|
-
isLoading.value = false
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
async function remove(item: any) {
|
|
745
|
-
const next = (masonry.value as any[]).filter(i => i.id !== item.id)
|
|
746
|
-
masonry.value = next
|
|
747
|
-
await nextTick()
|
|
748
|
-
|
|
749
|
-
// If all items were removed, either refresh current page or load next based on prop
|
|
750
|
-
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
751
|
-
if (props.autoRefreshOnEmpty) {
|
|
752
|
-
await refreshCurrentPage()
|
|
753
|
-
} else {
|
|
754
|
-
try {
|
|
755
|
-
await loadNext()
|
|
756
|
-
// Force backfill from 0 to ensure viewport is filled
|
|
757
|
-
// Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
|
|
758
|
-
await maybeBackfillToTarget(0, true)
|
|
759
|
-
} catch { }
|
|
760
|
-
}
|
|
761
|
-
return
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// Commit DOM updates without forcing sync reflow
|
|
765
|
-
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
766
|
-
// Start FLIP on next frame
|
|
767
|
-
requestAnimationFrame(() => {
|
|
768
|
-
refreshLayout(next)
|
|
769
|
-
})
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
async function removeMany(items: any[]) {
|
|
773
|
-
if (!items || items.length === 0) return
|
|
774
|
-
const ids = new Set(items.map(i => i.id))
|
|
775
|
-
const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
|
|
776
|
-
masonry.value = next
|
|
777
|
-
await nextTick()
|
|
778
|
-
|
|
779
|
-
// If all items were removed, either refresh current page or load next based on prop
|
|
780
|
-
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
781
|
-
if (props.autoRefreshOnEmpty) {
|
|
782
|
-
await refreshCurrentPage()
|
|
783
|
-
} else {
|
|
784
|
-
try {
|
|
785
|
-
await loadNext()
|
|
786
|
-
// Force backfill from 0 to ensure viewport is filled
|
|
787
|
-
await maybeBackfillToTarget(0, true)
|
|
788
|
-
} catch { }
|
|
789
|
-
}
|
|
790
|
-
return
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
// Commit DOM updates without forcing sync reflow
|
|
794
|
-
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
795
|
-
// Start FLIP on next frame
|
|
796
|
-
requestAnimationFrame(() => {
|
|
797
|
-
refreshLayout(next)
|
|
798
|
-
})
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
/**
|
|
802
|
-
* Restore a single item at its original index.
|
|
803
|
-
* This is useful for undo operations where an item needs to be restored to its exact position.
|
|
804
|
-
* Handles all index calculation and layout recalculation internally.
|
|
805
|
-
* @param item - Item to restore
|
|
806
|
-
* @param index - Original index of the item
|
|
807
|
-
*/
|
|
808
|
-
async function restore(item: any, index: number) {
|
|
809
|
-
if (!item) return
|
|
810
|
-
|
|
811
|
-
const current = masonry.value as any[]
|
|
812
|
-
const existingIndex = current.findIndex(i => i.id === item.id)
|
|
813
|
-
if (existingIndex !== -1) return // Item already exists
|
|
814
|
-
|
|
815
|
-
// Insert at the original index (clamped to valid range)
|
|
816
|
-
const newItems = [...current]
|
|
817
|
-
const targetIndex = Math.min(index, newItems.length)
|
|
818
|
-
newItems.splice(targetIndex, 0, item)
|
|
819
|
-
|
|
820
|
-
// Update the masonry array
|
|
821
|
-
masonry.value = newItems
|
|
822
|
-
await nextTick()
|
|
823
|
-
|
|
824
|
-
// Trigger layout recalculation (same pattern as remove)
|
|
825
|
-
if (!useSwipeMode.value) {
|
|
826
|
-
// Commit DOM updates without forcing sync reflow
|
|
827
|
-
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
828
|
-
// Start FLIP on next frame
|
|
829
|
-
requestAnimationFrame(() => {
|
|
830
|
-
refreshLayout(newItems)
|
|
831
|
-
})
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
* Restore multiple items at their original indices.
|
|
837
|
-
* This is useful for undo operations where items need to be restored to their exact positions.
|
|
838
|
-
* Handles all index calculation and layout recalculation internally.
|
|
839
|
-
* @param items - Array of items to restore
|
|
840
|
-
* @param indices - Array of original indices for each item (must match items array length)
|
|
841
|
-
*/
|
|
842
|
-
async function restoreMany(items: any[], indices: number[]) {
|
|
843
|
-
if (!items || items.length === 0) return
|
|
844
|
-
if (!indices || indices.length !== items.length) {
|
|
845
|
-
console.warn('[Masonry] restoreMany: items and indices arrays must have the same length')
|
|
846
|
-
return
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
const current = masonry.value as any[]
|
|
850
|
-
const existingIds = new Set(current.map(i => i.id))
|
|
851
|
-
|
|
852
|
-
// Filter out items that already exist and pair with their indices
|
|
853
|
-
const itemsToRestore: Array<{ item: any; index: number }> = []
|
|
854
|
-
for (let i = 0; i < items.length; i++) {
|
|
855
|
-
if (!existingIds.has(items[i]?.id)) {
|
|
856
|
-
itemsToRestore.push({ item: items[i], index: indices[i] })
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
if (itemsToRestore.length === 0) return
|
|
861
|
-
|
|
862
|
-
// Build the final array by merging current items and restored items
|
|
863
|
-
// Strategy: Build position by position - for each position, decide if it should be
|
|
864
|
-
// a restored item (at its original index) or a current item (accounting for shifts)
|
|
865
|
-
|
|
866
|
-
// Create a map of restored items by their original index for O(1) lookup
|
|
867
|
-
const restoredByIndex = new Map<number, any>()
|
|
868
|
-
for (const { item, index } of itemsToRestore) {
|
|
869
|
-
restoredByIndex.set(index, item)
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// Find the maximum position we need to consider
|
|
873
|
-
const maxRestoredIndex = itemsToRestore.length > 0
|
|
874
|
-
? Math.max(...itemsToRestore.map(({ index }) => index))
|
|
875
|
-
: -1
|
|
876
|
-
const maxPosition = Math.max(current.length - 1, maxRestoredIndex)
|
|
877
|
-
|
|
878
|
-
// Build the final array position by position
|
|
879
|
-
// Key insight: Current array items are in "shifted" positions (missing the removed items).
|
|
880
|
-
// When we restore items at their original positions, current items naturally shift back.
|
|
881
|
-
// We can build the final array by iterating positions and using items sequentially.
|
|
882
|
-
const newItems: any[] = []
|
|
883
|
-
let currentArrayIndex = 0 // Track which current item we should use next
|
|
884
|
-
|
|
885
|
-
// Iterate through all positions up to the maximum we need
|
|
886
|
-
for (let position = 0; position <= maxPosition; position++) {
|
|
887
|
-
// If there's a restored item that belongs at this position, use it
|
|
888
|
-
if (restoredByIndex.has(position)) {
|
|
889
|
-
newItems.push(restoredByIndex.get(position)!)
|
|
890
|
-
} else {
|
|
891
|
-
// Otherwise, this position should be filled by the next current item
|
|
892
|
-
// Since current array is missing restored items, items are shifted left.
|
|
893
|
-
// By using them sequentially, they naturally end up in the correct positions.
|
|
894
|
-
if (currentArrayIndex < current.length) {
|
|
895
|
-
newItems.push(current[currentArrayIndex])
|
|
896
|
-
currentArrayIndex++
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
// Add any remaining current items that come after the last restored position
|
|
902
|
-
// (These are items that were originally after maxRestoredIndex)
|
|
903
|
-
while (currentArrayIndex < current.length) {
|
|
904
|
-
newItems.push(current[currentArrayIndex])
|
|
905
|
-
currentArrayIndex++
|
|
906
|
-
}
|
|
395
|
+
// refreshCurrentPage is now in useMasonryPagination composable
|
|
907
396
|
|
|
908
|
-
|
|
909
|
-
masonry.value = newItems
|
|
910
|
-
await nextTick()
|
|
911
|
-
|
|
912
|
-
// Trigger layout recalculation (same pattern as removeMany)
|
|
913
|
-
if (!useSwipeMode.value) {
|
|
914
|
-
// Commit DOM updates without forcing sync reflow
|
|
915
|
-
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
916
|
-
// Start FLIP on next frame
|
|
917
|
-
requestAnimationFrame(() => {
|
|
918
|
-
refreshLayout(newItems)
|
|
919
|
-
})
|
|
920
|
-
}
|
|
921
|
-
}
|
|
397
|
+
// Item management functions (remove, removeMany, restore, restoreMany, removeAll) are now in useMasonryItems composable
|
|
922
398
|
|
|
923
399
|
function scrollToTop(options?: ScrollToOptions) {
|
|
924
400
|
if (container.value) {
|
|
@@ -945,131 +421,24 @@ function scrollTo(options: { top?: number; left?: number; behavior?: ScrollBehav
|
|
|
945
421
|
}
|
|
946
422
|
}
|
|
947
423
|
|
|
948
|
-
|
|
949
|
-
// Scroll to top first for better UX
|
|
950
|
-
scrollToTop({ behavior: 'smooth' })
|
|
951
|
-
|
|
952
|
-
// Clear all items
|
|
953
|
-
masonry.value = []
|
|
954
|
-
|
|
955
|
-
// Recalculate height to 0
|
|
956
|
-
containerHeight.value = 0
|
|
957
|
-
|
|
958
|
-
await nextTick()
|
|
959
|
-
|
|
960
|
-
// Emit completion event
|
|
961
|
-
emits('remove-all:complete')
|
|
962
|
-
}
|
|
424
|
+
// removeAll is now in useMasonryItems composable (removeAllItems)
|
|
963
425
|
|
|
426
|
+
// onResize is now in useMasonryLayout composable (onResizeLayout)
|
|
964
427
|
function onResize() {
|
|
965
|
-
|
|
966
|
-
refreshLayout(masonry.value as any)
|
|
428
|
+
onResizeLayout()
|
|
967
429
|
if (container.value) {
|
|
968
430
|
viewportTop.value = container.value.scrollTop
|
|
969
431
|
viewportHeight.value = container.value.clientHeight
|
|
970
432
|
}
|
|
971
433
|
}
|
|
972
434
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
async function maybeBackfillToTarget(baselineCount: number, force = false) {
|
|
977
|
-
if (!force && !props.backfillEnabled) return
|
|
978
|
-
if (backfillActive) return
|
|
979
|
-
if (cancelRequested.value) return
|
|
980
|
-
// Don't backfill if we've reached the end
|
|
981
|
-
if (hasReachedEnd.value) return
|
|
982
|
-
|
|
983
|
-
const targetCount = (baselineCount || 0) + (props.pageSize || 0)
|
|
984
|
-
if (!props.pageSize || props.pageSize <= 0) return
|
|
985
|
-
|
|
986
|
-
const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
|
|
987
|
-
if (lastNext == null) {
|
|
988
|
-
hasReachedEnd.value = true
|
|
989
|
-
return
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
if ((masonry.value as any[]).length >= targetCount) return
|
|
993
|
-
|
|
994
|
-
backfillActive = true
|
|
995
|
-
// Set loading to true at the start of backfill and keep it true throughout
|
|
996
|
-
isLoading.value = true
|
|
997
|
-
try {
|
|
998
|
-
let calls = 0
|
|
999
|
-
emits('backfill:start', { target: targetCount, fetched: (masonry.value as any[]).length, calls })
|
|
1000
|
-
|
|
1001
|
-
while (
|
|
1002
|
-
(masonry.value as any[]).length < targetCount &&
|
|
1003
|
-
calls < props.backfillMaxCalls &&
|
|
1004
|
-
paginationHistory.value[paginationHistory.value.length - 1] != null &&
|
|
1005
|
-
!cancelRequested.value &&
|
|
1006
|
-
!hasReachedEnd.value &&
|
|
1007
|
-
backfillActive
|
|
1008
|
-
) {
|
|
1009
|
-
await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
|
|
1010
|
-
emits('backfill:tick', {
|
|
1011
|
-
fetched: (masonry.value as any[]).length,
|
|
1012
|
-
target: targetCount,
|
|
1013
|
-
calls,
|
|
1014
|
-
remainingMs: remaining,
|
|
1015
|
-
totalMs: total
|
|
1016
|
-
})
|
|
1017
|
-
})
|
|
1018
|
-
|
|
1019
|
-
if (cancelRequested.value || !backfillActive) break
|
|
1020
|
-
|
|
1021
|
-
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
1022
|
-
if (currentPage == null) {
|
|
1023
|
-
hasReachedEnd.value = true
|
|
1024
|
-
break
|
|
1025
|
-
}
|
|
1026
|
-
try {
|
|
1027
|
-
// Don't toggle isLoading here - keep it true throughout backfill
|
|
1028
|
-
// Check cancellation before starting getContent to avoid unnecessary requests
|
|
1029
|
-
if (cancelRequested.value || !backfillActive) break
|
|
1030
|
-
const response = await getContent(currentPage)
|
|
1031
|
-
if (cancelRequested.value || !backfillActive) break
|
|
1032
|
-
// Clear error on successful load
|
|
1033
|
-
loadError.value = null
|
|
1034
|
-
paginationHistory.value.push(response.nextPage)
|
|
1035
|
-
// Update hasReachedEnd if nextPage is null
|
|
1036
|
-
if (response.nextPage == null) {
|
|
1037
|
-
hasReachedEnd.value = true
|
|
1038
|
-
}
|
|
1039
|
-
} catch (error) {
|
|
1040
|
-
// Set load error but don't break the backfill loop unless cancelled
|
|
1041
|
-
if (cancelRequested.value || !backfillActive) break
|
|
1042
|
-
loadError.value = normalizeError(error)
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
calls++
|
|
1046
|
-
}
|
|
1047
|
-
|
|
1048
|
-
emits('backfill:stop', { fetched: (masonry.value as any[]).length, calls })
|
|
1049
|
-
} finally {
|
|
1050
|
-
backfillActive = false
|
|
1051
|
-
// Only set loading to false when backfill completes or is cancelled
|
|
1052
|
-
isLoading.value = false
|
|
1053
|
-
}
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
function cancelLoad() {
|
|
1057
|
-
const wasBackfilling = backfillActive
|
|
1058
|
-
cancelRequested.value = true
|
|
1059
|
-
isLoading.value = false
|
|
1060
|
-
// Set backfillActive to false to immediately stop backfilling
|
|
1061
|
-
// The backfill loop checks this flag and will exit on the next iteration
|
|
1062
|
-
backfillActive = false
|
|
1063
|
-
// If backfill was active, emit stop event immediately
|
|
1064
|
-
if (wasBackfilling) {
|
|
1065
|
-
emits('backfill:stop', { fetched: (masonry.value as any[]).length, calls: 0, cancelled: true })
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
435
|
+
// maybeBackfillToTarget, cancelLoad are now in useMasonryPagination composable
|
|
436
|
+
// Removed: backfillActive, cancelRequested - now internal to pagination composable
|
|
1068
437
|
|
|
1069
438
|
function reset() {
|
|
1070
|
-
// Cancel ongoing work
|
|
439
|
+
// Cancel ongoing work
|
|
1071
440
|
cancelLoad()
|
|
1072
|
-
|
|
441
|
+
|
|
1073
442
|
if (container.value) {
|
|
1074
443
|
container.value.scrollTo({
|
|
1075
444
|
top: 0,
|
|
@@ -1084,10 +453,8 @@ function reset() {
|
|
|
1084
453
|
hasReachedEnd.value = false // Reset end flag
|
|
1085
454
|
loadError.value = null // Reset error flag
|
|
1086
455
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
isNearTrigger: false
|
|
1090
|
-
}
|
|
456
|
+
// Reset virtualization state
|
|
457
|
+
resetVirtualization()
|
|
1091
458
|
}
|
|
1092
459
|
|
|
1093
460
|
function destroy() {
|
|
@@ -1102,27 +469,17 @@ function destroy() {
|
|
|
1102
469
|
hasReachedEnd.value = false
|
|
1103
470
|
loadError.value = null
|
|
1104
471
|
isLoading.value = false
|
|
1105
|
-
backfillActive = false
|
|
1106
|
-
cancelRequested.value = false
|
|
1107
472
|
|
|
1108
473
|
// Reset swipe mode state
|
|
1109
474
|
currentSwipeIndex.value = 0
|
|
1110
475
|
swipeOffset.value = 0
|
|
1111
476
|
isDragging.value = false
|
|
1112
477
|
|
|
1113
|
-
// Reset
|
|
1114
|
-
|
|
1115
|
-
viewportHeight.value = 0
|
|
1116
|
-
virtualizing.value = false
|
|
1117
|
-
|
|
1118
|
-
// Reset scroll progress
|
|
1119
|
-
scrollProgress.value = {
|
|
1120
|
-
distanceToTrigger: 0,
|
|
1121
|
-
isNearTrigger: false
|
|
1122
|
-
}
|
|
478
|
+
// Reset virtualization state
|
|
479
|
+
resetVirtualization()
|
|
1123
480
|
|
|
1124
481
|
// Reset invalid dimension tracking
|
|
1125
|
-
|
|
482
|
+
resetDimensions()
|
|
1126
483
|
|
|
1127
484
|
// Scroll to top if container exists
|
|
1128
485
|
if (container.value) {
|
|
@@ -1133,35 +490,10 @@ function destroy() {
|
|
|
1133
490
|
}
|
|
1134
491
|
}
|
|
1135
492
|
|
|
493
|
+
// Scroll handler is now handled by virtualization composable's updateViewport
|
|
1136
494
|
const debouncedScrollHandler = debounce(async () => {
|
|
1137
495
|
if (useSwipeMode.value) return // Skip scroll handling in swipe mode
|
|
1138
|
-
|
|
1139
|
-
if (container.value) {
|
|
1140
|
-
const scrollTop = container.value.scrollTop
|
|
1141
|
-
const clientHeight = container.value.clientHeight || window.innerHeight
|
|
1142
|
-
// Ensure viewportHeight is never 0 (fallback to window height if container height is 0)
|
|
1143
|
-
const safeClientHeight = clientHeight > 0 ? clientHeight : window.innerHeight
|
|
1144
|
-
viewportTop.value = scrollTop
|
|
1145
|
-
viewportHeight.value = safeClientHeight
|
|
1146
|
-
// Log when scroll handler runs (helpful for debugging viewport issues)
|
|
1147
|
-
if (import.meta.env.DEV) {
|
|
1148
|
-
console.log('[Masonry] scroll: viewport updated', {
|
|
1149
|
-
scrollTop,
|
|
1150
|
-
clientHeight: safeClientHeight,
|
|
1151
|
-
itemsCount: masonry.value.length,
|
|
1152
|
-
visibleItemsCount: visibleMasonry.value.length
|
|
1153
|
-
})
|
|
1154
|
-
}
|
|
1155
|
-
}
|
|
1156
|
-
// Gate transitions for virtualization-only DOM changes
|
|
1157
|
-
virtualizing.value = true
|
|
1158
|
-
await nextTick()
|
|
1159
|
-
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
1160
|
-
virtualizing.value = false
|
|
1161
|
-
|
|
1162
|
-
const heights = calculateColumnHeights(masonry.value as any, columns.value)
|
|
1163
|
-
handleScroll(heights as any)
|
|
1164
|
-
updateScrollProgress(heights)
|
|
496
|
+
await updateViewportVirtualization()
|
|
1165
497
|
}, 200)
|
|
1166
498
|
|
|
1167
499
|
const debouncedResizeHandler = debounce(onResize, 200)
|
|
@@ -1251,20 +583,15 @@ async function restoreItems(items: any[], page: any, next: any) {
|
|
|
1251
583
|
}
|
|
1252
584
|
} else {
|
|
1253
585
|
// In masonry mode, refresh layout with the restored items
|
|
1254
|
-
// This is the same pattern as init() - refreshLayout handles all the layout calculation
|
|
1255
586
|
refreshLayout(items)
|
|
1256
587
|
|
|
1257
588
|
// Update viewport state from container's scroll position
|
|
1258
|
-
// Critical after refresh when browser may restore scroll position
|
|
1259
|
-
// This matches the pattern in init()
|
|
1260
589
|
if (container.value) {
|
|
1261
590
|
viewportTop.value = container.value.scrollTop
|
|
1262
591
|
viewportHeight.value = container.value.clientHeight || window.innerHeight
|
|
1263
592
|
}
|
|
1264
593
|
|
|
1265
594
|
// Update again after DOM updates to catch browser scroll restoration
|
|
1266
|
-
// The debounced scroll handler will also catch any scroll changes
|
|
1267
|
-
// This matches the pattern in init()
|
|
1268
595
|
await nextTick()
|
|
1269
596
|
if (container.value) {
|
|
1270
597
|
viewportTop.value = container.value.scrollTop
|
|
@@ -1543,7 +870,7 @@ onUnmounted(() => {
|
|
|
1543
870
|
}">
|
|
1544
871
|
<div class="w-full h-full flex items-center justify-center p-4">
|
|
1545
872
|
<div class="w-full h-full max-w-full max-h-full relative">
|
|
1546
|
-
<slot :item="item" :remove="remove" :index="item.originalIndex ??
|
|
873
|
+
<slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
|
|
1547
874
|
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1548
875
|
:in-swipe-mode="true" :is-active="index === currentSwipeIndex"
|
|
1549
876
|
@preload:success="(p) => emits('item:preload:success', p)"
|
|
@@ -1586,7 +913,7 @@ onUnmounted(() => {
|
|
|
1586
913
|
<div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
|
|
1587
914
|
v-bind="getItemAttributes(item, i)">
|
|
1588
915
|
<!-- Use default slot if provided, otherwise use MasonryItem -->
|
|
1589
|
-
<slot :item="item" :remove="remove" :index="item.originalIndex ??
|
|
916
|
+
<slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
|
|
1590
917
|
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1591
918
|
:in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
|
|
1592
919
|
@preload:error="(p) => emits('item:preload:error', p)"
|