@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/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
 
@@ -40,6 +45,15 @@ const props = defineProps({
40
45
  type: Boolean,
41
46
  default: false
42
47
  },
48
+ // Initial pagination state when skipInitialLoad is true and items are provided
49
+ initialPage: {
50
+ type: [Number, String],
51
+ default: null
52
+ },
53
+ initialNextPage: {
54
+ type: [Number, String],
55
+ default: null
56
+ },
43
57
  pageSize: {
44
58
  type: Number,
45
59
  default: 40
@@ -200,158 +214,90 @@ const loadError = ref<Error | null>(null) // Track load errors
200
214
  const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
201
215
 
202
216
 
203
- // Diagnostics: track items missing width/height to help developers
204
- const invalidDimensionIds = ref<Set<number | string>>(new Set())
205
- function isPositiveNumber(value: unknown): boolean {
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
217
+ // Initialize dimensions composable first (needed by layout composable)
218
+ const dimensions = useMasonryDimensions({
219
+ masonry: masonry as any
252
220
  })
253
221
 
254
- const updateScrollProgress = (precomputedHeights?: number[]) => {
255
- if (!container.value) return
222
+ // Extract dimension checking function
223
+ const { checkItemDimensions, invalidDimensionIds, reset: resetDimensions } = dimensions
256
224
 
257
- const { scrollTop, clientHeight } = container.value
258
- const visibleBottom = scrollTop + clientHeight
225
+ // Initialize layout composable (needs checkItemDimensions from dimensions composable)
226
+ const layoutComposable = useMasonryLayout({
227
+ masonry: masonry as any,
228
+ useSwipeMode,
229
+ container,
230
+ columns,
231
+ containerWidth,
232
+ masonryContentHeight,
233
+ layout,
234
+ fixedDimensions,
235
+ checkItemDimensions
236
+ })
259
237
 
260
- const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
261
- const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
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)
238
+ // Extract layout functions
239
+ const { refreshLayout, setFixedDimensions: setFixedDimensionsLayout, onResize: onResizeLayout } = layoutComposable
266
240
 
267
- const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
268
- const isNearTrigger = distanceToTrigger <= 100
241
+ // Initialize virtualization composable
242
+ const virtualization = useMasonryVirtualization({
243
+ masonry: masonry as any,
244
+ container,
245
+ columns,
246
+ virtualBufferPx: props.virtualBufferPx,
247
+ loadThresholdPx: props.loadThresholdPx,
248
+ handleScroll: () => { } // Will be set after pagination is initialized
249
+ })
269
250
 
270
- scrollProgress.value = {
271
- distanceToTrigger: Math.round(distanceToTrigger),
272
- isNearTrigger
273
- }
274
- }
251
+ // Extract virtualization state and functions
252
+ const { viewportTop, viewportHeight, virtualizing, scrollProgress, visibleMasonry, updateScrollProgress, updateViewport: updateViewportVirtualization, reset: resetVirtualization } = virtualization
275
253
 
276
- // Setup composables - pass container ref for viewport optimization
254
+ // Initialize transitions composable with virtualization support
277
255
  const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(
278
256
  { container, masonry: masonry as any },
279
- { leaveDurationMs: props.leaveDurationMs }
257
+ { leaveDurationMs: props.leaveDurationMs, virtualizing }
280
258
  )
281
259
 
282
- // Transition wrappers that skip animation during virtualization
283
- function enter(el: HTMLElement, done: () => void) {
284
- if (virtualizing.value) {
285
- const left = parseInt(el.dataset.left || '0', 10)
286
- const top = parseInt(el.dataset.top || '0', 10)
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
- }
260
+ // Transition functions for template (wrapped to match expected signature)
261
+ const enter = onEnter
262
+ const beforeEnter = onBeforeEnter
263
+ const beforeLeave = onBeforeLeave
264
+ const leave = onLeave
326
265
 
327
- // Visible window of items (virtualization)
328
- const visibleMasonry = computed(() => {
329
- const top = viewportTop.value - VIRTUAL_BUFFER_PX
330
- const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
331
- const items = masonry.value as any[]
332
- if (!items || items.length === 0) return [] as any[]
333
-
334
- // Filter items that have valid positions and are within viewport
335
- const visible = items.filter((it: any) => {
336
- // If item doesn't have position yet, include it (will be filtered once layout is calculated)
337
- if (typeof it.top !== 'number' || typeof it.columnHeight !== 'number') {
338
- return true // Include items without positions to avoid hiding them prematurely
339
- }
340
- const itemTop = it.top
341
- const itemBottom = it.top + it.columnHeight
342
- return itemBottom >= top && itemTop <= bottom
343
- })
266
+ // Initialize pagination composable
267
+ const pagination = useMasonryPagination({
268
+ getNextPage: props.getNextPage as (page: any) => Promise<{ items: any[]; nextPage: any }>,
269
+ masonry: masonry as any,
270
+ isLoading,
271
+ hasReachedEnd,
272
+ loadError,
273
+ currentPage,
274
+ paginationHistory,
275
+ refreshLayout,
276
+ retryMaxAttempts: props.retryMaxAttempts,
277
+ retryInitialDelayMs: props.retryInitialDelayMs,
278
+ retryBackoffStepMs: props.retryBackoffStepMs,
279
+ backfillEnabled: props.backfillEnabled,
280
+ backfillDelayMs: props.backfillDelayMs,
281
+ backfillMaxCalls: props.backfillMaxCalls,
282
+ pageSize: props.pageSize,
283
+ autoRefreshOnEmpty: props.autoRefreshOnEmpty,
284
+ emits
285
+ })
344
286
 
345
- // Log if we're filtering out items (for debugging)
346
- if (import.meta.env.DEV && items.length > 0 && visible.length === 0 && viewportHeight.value > 0) {
347
- const itemsWithPositions = items.filter((it: any) =>
348
- typeof it.top === 'number' && typeof it.columnHeight === 'number'
349
- )
350
- }
287
+ // Extract pagination functions
288
+ const { loadPage, loadNext, refreshCurrentPage, cancelLoad, maybeBackfillToTarget } = pagination
351
289
 
352
- return visible
290
+ // Initialize swipe mode composable (needs loadNext and loadPage from pagination)
291
+ const swipeMode = useSwipeModeComposable({
292
+ useSwipeMode,
293
+ masonry: masonry as any,
294
+ isLoading,
295
+ loadNext,
296
+ loadPage,
297
+ paginationHistory
353
298
  })
354
299
 
300
+ // Initialize scroll handler (needs loadNext from pagination)
355
301
  const { handleScroll } = useMasonryScroll({
356
302
  container,
357
303
  masonry: masonry as any,
@@ -367,25 +313,32 @@ const { handleScroll } = useMasonryScroll({
367
313
  loadThresholdPx: props.loadThresholdPx
368
314
  })
369
315
 
316
+ // Update virtualization handleScroll to use the scroll handler
317
+ virtualization.handleScroll.value = handleScroll
318
+
319
+ // Initialize items composable
320
+ const items = useMasonryItems({
321
+ masonry: masonry as any,
322
+ useSwipeMode,
323
+ refreshLayout,
324
+ refreshCurrentPage,
325
+ loadNext,
326
+ maybeBackfillToTarget,
327
+ autoRefreshOnEmpty: props.autoRefreshOnEmpty,
328
+ paginationHistory
329
+ })
330
+
331
+ // Extract item management functions
332
+ const { remove, removeMany, restore, restoreMany, removeAll: removeAllItems } = items
333
+
334
+ // setFixedDimensions is now in useMasonryLayout composable
335
+ // Wrapper function to maintain API compatibility and handle wrapper restoration
370
336
  function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
371
- fixedDimensions.value = dimensions
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 {
337
+ setFixedDimensionsLayout(dimensions, updateScrollProgress)
338
+ if (!dimensions && wrapper.value) {
384
339
  // When clearing fixed dimensions, restore from wrapper
385
- if (wrapper.value) {
386
- containerWidth.value = wrapper.value.clientWidth
387
- containerHeight.value = wrapper.value.clientHeight
388
- }
340
+ containerWidth.value = wrapper.value.clientWidth
341
+ containerHeight.value = wrapper.value.clientHeight
389
342
  }
390
343
  }
391
344
 
@@ -407,7 +360,7 @@ defineExpose({
407
360
  setFixedDimensions,
408
361
  remove,
409
362
  removeMany,
410
- removeAll,
363
+ removeAll: removeAllItems,
411
364
  restore,
412
365
  restoreMany,
413
366
  loadNext,
@@ -425,244 +378,8 @@ defineExpose({
425
378
  currentBreakpoint
426
379
  })
427
380
 
428
- function calculateHeight(content: any[]) {
429
- const newHeight = calculateContainerHeight(content as any)
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
- })
381
+ // Layout functions are now in useMasonryLayout composable
382
+ // Removed: calculateHeight, refreshLayout - now from layoutComposable
666
383
 
667
384
  // Expose swipe mode computed values and state for template
668
385
  const currentItem = swipeMode.currentItem
@@ -684,241 +401,9 @@ const goToNextItem = swipeMode.goToNextItem
684
401
  const goToPreviousItem = swipeMode.goToPreviousItem
685
402
  const snapToCurrentItem = swipeMode.snapToCurrentItem
686
403
 
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
- }
404
+ // refreshCurrentPage is now in useMasonryPagination composable
859
405
 
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
- }
907
-
908
- // Update the masonry array
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
- }
406
+ // Item management functions (remove, removeMany, restore, restoreMany, removeAll) are now in useMasonryItems composable
922
407
 
923
408
  function scrollToTop(options?: ScrollToOptions) {
924
409
  if (container.value) {
@@ -945,131 +430,24 @@ function scrollTo(options: { top?: number; left?: number; behavior?: ScrollBehav
945
430
  }
946
431
  }
947
432
 
948
- async function removeAll() {
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
- }
433
+ // removeAll is now in useMasonryItems composable (removeAllItems)
963
434
 
435
+ // onResize is now in useMasonryLayout composable (onResizeLayout)
964
436
  function onResize() {
965
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
966
- refreshLayout(masonry.value as any)
437
+ onResizeLayout()
967
438
  if (container.value) {
968
439
  viewportTop.value = container.value.scrollTop
969
440
  viewportHeight.value = container.value.clientHeight
970
441
  }
971
442
  }
972
443
 
973
- let backfillActive = false
974
- const cancelRequested = ref(false)
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
- }
444
+ // maybeBackfillToTarget, cancelLoad are now in useMasonryPagination composable
445
+ // Removed: backfillActive, cancelRequested - now internal to pagination composable
1068
446
 
1069
447
  function reset() {
1070
- // Cancel ongoing work, then immediately clear cancel so new loads can start
448
+ // Cancel ongoing work
1071
449
  cancelLoad()
1072
- cancelRequested.value = false
450
+
1073
451
  if (container.value) {
1074
452
  container.value.scrollTo({
1075
453
  top: 0,
@@ -1084,10 +462,11 @@ function reset() {
1084
462
  hasReachedEnd.value = false // Reset end flag
1085
463
  loadError.value = null // Reset error flag
1086
464
 
1087
- scrollProgress.value = {
1088
- distanceToTrigger: 0,
1089
- isNearTrigger: false
1090
- }
465
+ // Reset virtualization state
466
+ resetVirtualization()
467
+
468
+ // Reset auto-initialization flag so watcher can work again if needed
469
+ hasInitializedWithItems = false
1091
470
  }
1092
471
 
1093
472
  function destroy() {
@@ -1102,27 +481,17 @@ function destroy() {
1102
481
  hasReachedEnd.value = false
1103
482
  loadError.value = null
1104
483
  isLoading.value = false
1105
- backfillActive = false
1106
- cancelRequested.value = false
1107
484
 
1108
485
  // Reset swipe mode state
1109
486
  currentSwipeIndex.value = 0
1110
487
  swipeOffset.value = 0
1111
488
  isDragging.value = false
1112
489
 
1113
- // Reset viewport state
1114
- viewportTop.value = 0
1115
- viewportHeight.value = 0
1116
- virtualizing.value = false
1117
-
1118
- // Reset scroll progress
1119
- scrollProgress.value = {
1120
- distanceToTrigger: 0,
1121
- isNearTrigger: false
1122
- }
490
+ // Reset virtualization state
491
+ resetVirtualization()
1123
492
 
1124
493
  // Reset invalid dimension tracking
1125
- invalidDimensionIds.value.clear()
494
+ resetDimensions()
1126
495
 
1127
496
  // Scroll to top if container exists
1128
497
  if (container.value) {
@@ -1133,35 +502,10 @@ function destroy() {
1133
502
  }
1134
503
  }
1135
504
 
505
+ // Scroll handler is now handled by virtualization composable's updateViewport
1136
506
  const debouncedScrollHandler = debounce(async () => {
1137
507
  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)
508
+ await updateViewportVirtualization()
1165
509
  }, 200)
1166
510
 
1167
511
  const debouncedResizeHandler = debounce(onResize, 200)
@@ -1234,7 +578,9 @@ async function restoreItems(items: any[], page: any, next: any) {
1234
578
  if (next !== null && next !== undefined) {
1235
579
  paginationHistory.value.push(next)
1236
580
  }
1237
- hasReachedEnd.value = next == null
581
+ // Only set hasReachedEnd to true if next is explicitly null (end of list)
582
+ // undefined means "unknown" - don't assume end of list
583
+ hasReachedEnd.value = next === null
1238
584
  loadError.value = null
1239
585
 
1240
586
  // Diagnostics: check incoming items
@@ -1251,20 +597,15 @@ async function restoreItems(items: any[], page: any, next: any) {
1251
597
  }
1252
598
  } else {
1253
599
  // In masonry mode, refresh layout with the restored items
1254
- // This is the same pattern as init() - refreshLayout handles all the layout calculation
1255
600
  refreshLayout(items)
1256
601
 
1257
602
  // 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
603
  if (container.value) {
1261
604
  viewportTop.value = container.value.scrollTop
1262
605
  viewportHeight.value = container.value.clientHeight || window.innerHeight
1263
606
  }
1264
607
 
1265
608
  // 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
609
  await nextTick()
1269
610
  if (container.value) {
1270
611
  viewportTop.value = container.value.scrollTop
@@ -1312,6 +653,36 @@ watch(container, (el) => {
1312
653
  }
1313
654
  }, { immediate: true })
1314
655
 
656
+ // Watch for items when skipInitialLoad is true to auto-initialize pagination state
657
+ // This handles cases where items are provided after mount or updated externally
658
+ let hasInitializedWithItems = false
659
+ watch(
660
+ () => [props.items, props.skipInitialLoad, props.initialPage, props.initialNextPage] as const,
661
+ ([items, skipInitialLoad, initialPage, initialNextPage]) => {
662
+ // Only auto-initialize if:
663
+ // 1. skipInitialLoad is true
664
+ // 2. Items exist
665
+ // 3. We haven't already initialized with items (to avoid re-initializing on every update)
666
+ if (
667
+ skipInitialLoad &&
668
+ items &&
669
+ items.length > 0 &&
670
+ !hasInitializedWithItems
671
+ ) {
672
+ hasInitializedWithItems = true
673
+ const page = initialPage !== null && initialPage !== undefined
674
+ ? initialPage
675
+ : (props.loadAtPage as any)
676
+ const next = initialNextPage !== undefined
677
+ ? initialNextPage
678
+ : undefined // undefined means "unknown", null means "end of list"
679
+
680
+ restoreItems(items as any[], page, next)
681
+ }
682
+ },
683
+ { immediate: false }
684
+ )
685
+
1315
686
  // Watch for swipe mode changes to refresh layout and setup/teardown handlers
1316
687
  watch(useSwipeMode, (newValue, oldValue) => {
1317
688
  // Skip if this is the initial watch call and values are the same
@@ -1460,23 +831,20 @@ onMounted(async () => {
1460
831
 
1461
832
  if (!props.skipInitialLoad) {
1462
833
  await loadPage(paginationHistory.value[0] as any)
1463
- } else {
1464
- // When skipInitialLoad is true, restore items from props if they exist
1465
- // This allows parent components to pass items via v-model and vibe handles restoration
1466
- if (props.items && props.items.length > 0) {
1467
- // Extract page and next from items if available, otherwise use loadAtPage
1468
- const firstItem = props.items[0] as any
1469
- const lastItem = props.items[props.items.length - 1] as any
1470
- const page = firstItem?.page ?? initialPage ?? 1
1471
- const next = lastItem?.next ?? null
1472
-
1473
- // Restore items - this will set masonry.value and handle layout
1474
- await restoreItems(props.items, page, next)
1475
- } else {
1476
- // No items to restore, just initialize pagination state
1477
- currentPage.value = initialPage
1478
- paginationHistory.value = [initialPage]
1479
- }
834
+ } else if (props.items && props.items.length > 0) {
835
+ // When skipInitialLoad is true and items are provided, initialize pagination state
836
+ // Use initialPage/initialNextPage props if provided, otherwise use loadAtPage
837
+ // Only set next to null if initialNextPage is explicitly null (not undefined)
838
+ const page = props.initialPage !== null && props.initialPage !== undefined
839
+ ? props.initialPage
840
+ : (props.loadAtPage as any)
841
+ const next = props.initialNextPage !== undefined
842
+ ? props.initialNextPage
843
+ : undefined // undefined means "unknown", null means "end of list"
844
+
845
+ await restoreItems(props.items as any[], page, next)
846
+ // Mark as initialized to prevent watcher from running again
847
+ hasInitializedWithItems = true
1480
848
  }
1481
849
 
1482
850
  if (!useSwipeMode.value) {
@@ -1543,7 +911,7 @@ onUnmounted(() => {
1543
911
  }">
1544
912
  <div class="w-full h-full flex items-center justify-center p-4">
1545
913
  <div class="w-full h-full max-w-full max-h-full relative">
1546
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? props.items.indexOf(item)">
914
+ <slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
1547
915
  <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1548
916
  :in-swipe-mode="true" :is-active="index === currentSwipeIndex"
1549
917
  @preload:success="(p) => emits('item:preload:success', p)"
@@ -1586,7 +954,7 @@ onUnmounted(() => {
1586
954
  <div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
1587
955
  v-bind="getItemAttributes(item, i)">
1588
956
  <!-- Use default slot if provided, otherwise use MasonryItem -->
1589
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? items.indexOf(item)">
957
+ <slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
1590
958
  <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
1591
959
  :in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
1592
960
  @preload:error="(p) => emits('item:preload:error', p)"