@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/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
- // 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
208
+ // Initialize dimensions composable first (needed by layout composable)
209
+ const dimensions = useMasonryDimensions({
210
+ masonry: masonry as any
252
211
  })
253
212
 
254
- const updateScrollProgress = (precomputedHeights?: number[]) => {
255
- if (!container.value) return
213
+ // Extract dimension checking function
214
+ const { checkItemDimensions, invalidDimensionIds, reset: resetDimensions } = dimensions
256
215
 
257
- const { scrollTop, clientHeight } = container.value
258
- const visibleBottom = scrollTop + clientHeight
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
- 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)
229
+ // Extract layout functions
230
+ const { refreshLayout, setFixedDimensions: setFixedDimensionsLayout, onResize: onResizeLayout } = layoutComposable
266
231
 
267
- const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
268
- const isNearTrigger = distanceToTrigger <= 100
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
- scrollProgress.value = {
271
- distanceToTrigger: Math.round(distanceToTrigger),
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
- // Setup composables - pass container ref for viewport optimization
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 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
- }
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
- // 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
- })
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
- // 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
- }
278
+ // Extract pagination functions
279
+ const { loadPage, loadNext, refreshCurrentPage, cancelLoad, maybeBackfillToTarget } = pagination
351
280
 
352
- return visible
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
- 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 {
328
+ setFixedDimensionsLayout(dimensions, updateScrollProgress)
329
+ if (!dimensions && wrapper.value) {
384
330
  // When clearing fixed dimensions, restore from wrapper
385
- if (wrapper.value) {
386
- containerWidth.value = wrapper.value.clientWidth
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
- 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
- })
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
- // 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
- }
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
- 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
- }
424
+ // removeAll is now in useMasonryItems composable (removeAllItems)
963
425
 
426
+ // onResize is now in useMasonryLayout composable (onResizeLayout)
964
427
  function onResize() {
965
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
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
- 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
- }
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, then immediately clear cancel so new loads can start
439
+ // Cancel ongoing work
1071
440
  cancelLoad()
1072
- cancelRequested.value = false
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
- scrollProgress.value = {
1088
- distanceToTrigger: 0,
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 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
- }
478
+ // Reset virtualization state
479
+ resetVirtualization()
1123
480
 
1124
481
  // Reset invalid dimension tracking
1125
- invalidDimensionIds.value.clear()
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 ?? props.items.indexOf(item)">
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 ?? items.indexOf(item)">
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)"