@wyxos/vibe 1.6.29 → 2.0.2

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.
Files changed (43) hide show
  1. package/README.md +29 -287
  2. package/lib/index.cjs +1 -0
  3. package/lib/index.js +795 -1791
  4. package/lib/logo-dark.svg +36 -36
  5. package/lib/logo-light.svg +29 -29
  6. package/lib/logo.svg +32 -32
  7. package/lib/manifest.json +1 -1
  8. package/package.json +82 -96
  9. package/LICENSE +0 -21
  10. package/lib/vibe.css +0 -1
  11. package/lib/vite.svg +0 -1
  12. package/src/App.vue +0 -35
  13. package/src/Masonry.vue +0 -1030
  14. package/src/archive/App.vue +0 -96
  15. package/src/archive/InfiniteMansonry.spec.ts +0 -10
  16. package/src/archive/InfiniteMasonry.vue +0 -218
  17. package/src/assets/vue.svg +0 -1
  18. package/src/calculateLayout.ts +0 -194
  19. package/src/components/CodeTabs.vue +0 -158
  20. package/src/components/MasonryItem.vue +0 -499
  21. package/src/components/examples/BasicExample.vue +0 -46
  22. package/src/components/examples/CustomItemExample.vue +0 -87
  23. package/src/components/examples/HeaderFooterExample.vue +0 -79
  24. package/src/components/examples/ManualInitExample.vue +0 -78
  25. package/src/components/examples/SwipeModeExample.vue +0 -40
  26. package/src/createMasonryTransitions.ts +0 -176
  27. package/src/main.ts +0 -6
  28. package/src/masonryUtils.ts +0 -96
  29. package/src/pages.json +0 -36402
  30. package/src/router/index.ts +0 -20
  31. package/src/style.css +0 -32
  32. package/src/types.ts +0 -101
  33. package/src/useMasonryDimensions.ts +0 -59
  34. package/src/useMasonryItems.ts +0 -231
  35. package/src/useMasonryLayout.ts +0 -164
  36. package/src/useMasonryPagination.ts +0 -539
  37. package/src/useMasonryScroll.ts +0 -61
  38. package/src/useMasonryVirtualization.ts +0 -140
  39. package/src/useSwipeMode.ts +0 -233
  40. package/src/utils/errorHandler.ts +0 -8
  41. package/src/views/Examples.vue +0 -323
  42. package/src/views/Home.vue +0 -321
  43. package/toggle-link.mjs +0 -92
package/src/Masonry.vue DELETED
@@ -1,1030 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
- import calculateLayout from "./calculateLayout";
4
- import { debounce } from 'lodash-es'
5
- import {
6
- getColumnCount,
7
- getBreakpointName,
8
- calculateContainerHeight,
9
- getItemAttributes,
10
- calculateColumnHeights
11
- } from './masonryUtils'
12
- import { createMasonryTransitions } from './createMasonryTransitions'
13
- import { useMasonryScroll } from './useMasonryScroll'
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'
20
- import MasonryItem from './components/MasonryItem.vue'
21
- import { normalizeError } from './utils/errorHandler'
22
-
23
- const props = defineProps({
24
- getPage: {
25
- type: Function,
26
- default: () => { }
27
- },
28
- loadAtPage: {
29
- type: [Number, String],
30
- default: null
31
- },
32
- items: {
33
- type: Array,
34
- default: () => []
35
- },
36
- // Opaque caller-owned context passed through to getPage(page, context).
37
- // Useful for including filters, service selection, tabId, etc.
38
- context: {
39
- type: Object,
40
- default: null
41
- },
42
- layout: {
43
- type: Object
44
- },
45
- paginationType: {
46
- type: String,
47
- default: 'page', // or 'cursor'
48
- validator: (v: string) => ['page', 'cursor'].includes(v)
49
- },
50
- init: {
51
- type: String,
52
- default: 'manual',
53
- validator: (v: string) => ['auto', 'manual'].includes(v)
54
- },
55
- pageSize: {
56
- type: Number,
57
- default: 40
58
- },
59
- // Backfill configuration
60
- mode: {
61
- type: String,
62
- default: 'backfill',
63
- validator: (value: string) => ['backfill', 'none', 'refresh'].includes(value)
64
- },
65
- backfillDelayMs: {
66
- type: Number,
67
- default: 2000
68
- },
69
- backfillMaxCalls: {
70
- type: Number,
71
- default: 10
72
- },
73
- // Retry configuration
74
- retryMaxAttempts: {
75
- type: Number,
76
- default: 3
77
- },
78
- retryInitialDelayMs: {
79
- type: Number,
80
- default: 2000
81
- },
82
- retryBackoffStepMs: {
83
- type: Number,
84
- default: 2000
85
- },
86
- transitionDurationMs: {
87
- type: Number,
88
- default: 450
89
- },
90
- // Shorter, snappier duration specifically for item removal (leave)
91
- leaveDurationMs: {
92
- type: Number,
93
- default: 160
94
- },
95
- transitionEasing: {
96
- type: String,
97
- default: 'cubic-bezier(.22,.61,.36,1)'
98
- },
99
- // Force motion even when user has reduced-motion enabled
100
- forceMotion: {
101
- type: Boolean,
102
- default: false
103
- },
104
- virtualBufferPx: {
105
- type: Number,
106
- default: 600
107
- },
108
- loadThresholdPx: {
109
- type: Number,
110
- default: 200
111
- },
112
- // Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
113
- layoutMode: {
114
- type: String,
115
- default: 'auto',
116
- validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
117
- },
118
- // Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
119
- mobileBreakpoint: {
120
- type: [Number, String],
121
- default: 768 // 'md' breakpoint
122
- },
123
- })
124
-
125
- const defaultLayout = {
126
- sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
127
- gutterX: 10,
128
- gutterY: 10,
129
- header: 0,
130
- footer: 0,
131
- paddingLeft: 0,
132
- paddingRight: 0,
133
- placement: 'masonry'
134
- }
135
-
136
- const layout = computed(() => ({
137
- ...defaultLayout,
138
- ...props.layout,
139
- sizes: {
140
- ...defaultLayout.sizes,
141
- ...(props.layout?.sizes || {})
142
- }
143
- }))
144
-
145
- const wrapper = ref<HTMLElement | null>(null)
146
- const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
147
- const containerHeight = ref<number>(typeof window !== 'undefined' ? window.innerHeight : 768)
148
- const fixedDimensions = ref<{ width?: number; height?: number } | null>(null)
149
- let resizeObserver: ResizeObserver | null = null
150
-
151
- // Get breakpoint value from Tailwind breakpoint name
152
- function getBreakpointValue(breakpoint: string): number {
153
- const breakpoints: Record<string, number> = {
154
- 'sm': 640,
155
- 'md': 768,
156
- 'lg': 1024,
157
- 'xl': 1280,
158
- '2xl': 1536
159
- }
160
- return breakpoints[breakpoint] || 768
161
- }
162
-
163
- // Determine if we should use swipe mode
164
- const useSwipeMode = computed(() => {
165
- if (props.layoutMode === 'masonry') return false
166
- if (props.layoutMode === 'swipe') return true
167
-
168
- // Auto mode: check container width
169
- const breakpoint = typeof props.mobileBreakpoint === 'string'
170
- ? getBreakpointValue(props.mobileBreakpoint)
171
- : props.mobileBreakpoint
172
-
173
- return containerWidth.value < breakpoint
174
- })
175
-
176
-
177
- const emits = defineEmits([
178
- 'update:items',
179
- 'loading:start',
180
- 'backfill:start',
181
- 'backfill:tick',
182
- 'backfill:stop',
183
- 'retry:start',
184
- 'retry:tick',
185
- 'retry:stop',
186
- 'loading:stop',
187
- 'remove-all:complete',
188
- // Re-emit item-level preload events from the default MasonryItem
189
- 'item:preload:success',
190
- 'item:preload:error',
191
- // Mouse events from MasonryItem content
192
- 'item:mouse-enter',
193
- 'item:mouse-leave',
194
- 'update:context'
195
- ])
196
-
197
- const masonry = computed<any>({
198
- get: () => props.items,
199
- set: (val) => emits('update:items', val)
200
- })
201
-
202
- const context = computed<any>({
203
- get: () => props.context,
204
- set: (val) => emits('update:context', val)
205
- })
206
-
207
- function setContext(val: any) {
208
- context.value = val
209
- }
210
-
211
- const masonryLength = computed((): number => {
212
- const items = masonry.value as any[]
213
- return items?.length ?? 0
214
- })
215
-
216
- const columns = ref<number>(7)
217
- const container = ref<HTMLElement | null>(null)
218
- const paginationHistory = ref<any[]>([])
219
- const currentPage = ref<any>(null) // Track the actual current page being displayed
220
- const isLoading = ref<boolean>(false)
221
- const masonryContentHeight = ref<number>(0)
222
- const hasReachedEnd = ref<boolean>(false) // Track when we've reached the last page
223
- const loadError = ref<Error | null>(null) // Track load errors
224
- // Track when first content has loaded
225
- // For 'manual' init, show masonry immediately since we're about to load
226
- // For 'auto' init, wait for items to be provided or loaded
227
- const isInitialized = ref<boolean>(false)
228
-
229
- // Current breakpoint
230
- const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
231
-
232
-
233
- // Initialize dimensions composable first (needed by layout composable)
234
- const dimensions = useMasonryDimensions({
235
- masonry: masonry as any
236
- })
237
-
238
- // Extract dimension checking function
239
- const { checkItemDimensions, invalidDimensionIds, reset: resetDimensions } = dimensions
240
-
241
- // Initialize layout composable (needs checkItemDimensions from dimensions composable)
242
- const layoutComposable = useMasonryLayout({
243
- masonry: masonry as any,
244
- useSwipeMode,
245
- container,
246
- columns,
247
- containerWidth,
248
- masonryContentHeight,
249
- layout,
250
- fixedDimensions,
251
- checkItemDimensions
252
- })
253
-
254
- // Extract layout functions
255
- const { refreshLayout, setFixedDimensions: setFixedDimensionsLayout, onResize: onResizeLayout } = layoutComposable
256
-
257
- // Initialize virtualization composable
258
- const virtualization = useMasonryVirtualization({
259
- masonry: masonry as any,
260
- container,
261
- columns,
262
- virtualBufferPx: props.virtualBufferPx,
263
- loadThresholdPx: props.loadThresholdPx,
264
- handleScroll: () => { } // Will be set after pagination is initialized
265
- })
266
-
267
- // Extract virtualization state and functions
268
- const { viewportTop, viewportHeight, virtualizing, scrollProgress, visibleMasonry, updateScrollProgress, updateViewport: updateViewportVirtualization, reset: resetVirtualization } = virtualization
269
-
270
- // Initialize transitions factory with virtualization support
271
- const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = createMasonryTransitions(
272
- { container, masonry: masonry as any },
273
- { leaveDurationMs: props.leaveDurationMs, virtualizing }
274
- )
275
-
276
- // Transition functions for template (wrapped to match expected signature)
277
- const enter = onEnter
278
- const beforeEnter = onBeforeEnter
279
- const beforeLeave = onBeforeLeave
280
- const leave = onLeave
281
-
282
- // Initialize pagination composable
283
- // Make mode reactive so it updates when the prop changes
284
- const modeRef = computed(() => props.mode)
285
- const pagination = useMasonryPagination({
286
- getPage: props.getPage as (page: any, ctx?: any) => Promise<{ items: any[]; nextPage: any }>,
287
- context,
288
- masonry: masonry as any,
289
- isLoading,
290
- hasReachedEnd,
291
- loadError,
292
- currentPage,
293
- paginationHistory,
294
- refreshLayout,
295
- retryMaxAttempts: props.retryMaxAttempts,
296
- retryInitialDelayMs: props.retryInitialDelayMs,
297
- retryBackoffStepMs: props.retryBackoffStepMs,
298
- mode: modeRef,
299
- backfillDelayMs: props.backfillDelayMs,
300
- backfillMaxCalls: props.backfillMaxCalls,
301
- pageSize: props.pageSize,
302
- emits
303
- })
304
-
305
- // Extract pagination functions
306
- const { loadPage, loadNext, refreshCurrentPage, cancelLoad, maybeBackfillToTarget } = pagination
307
-
308
- // Initialize swipe mode composable (needs loadNext and loadPage from pagination)
309
- const swipeMode = useSwipeModeComposable({
310
- useSwipeMode,
311
- masonry: masonry as any,
312
- isLoading,
313
- loadNext,
314
- loadPage,
315
- paginationHistory
316
- })
317
-
318
- // Initialize scroll handler (needs loadNext from pagination)
319
- const { handleScroll } = useMasonryScroll({
320
- container,
321
- masonry: masonry as any,
322
- columns,
323
- containerHeight: masonryContentHeight,
324
- isLoading,
325
- pageSize: props.pageSize,
326
- refreshLayout,
327
- setItemsRaw: (items: any[]) => {
328
- masonry.value = items
329
- },
330
- loadNext,
331
- loadThresholdPx: props.loadThresholdPx
332
- })
333
-
334
- // Update virtualization handleScroll to use the scroll handler
335
- virtualization.handleScroll.value = handleScroll
336
-
337
- // Initialize items composable
338
- const items = useMasonryItems({
339
- masonry: masonry as any,
340
- useSwipeMode,
341
- refreshLayout,
342
- refreshCurrentPage,
343
- loadNext,
344
- maybeBackfillToTarget,
345
- paginationHistory
346
- })
347
-
348
- // Extract item management functions
349
- const { remove, removeMany, restore, restoreMany, removeAll } = items
350
-
351
- // setFixedDimensions is now in useMasonryLayout composable
352
- // Wrapper function to maintain API compatibility and handle wrapper restoration
353
- function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
354
- setFixedDimensionsLayout(dimensions, updateScrollProgress)
355
- if (!dimensions && wrapper.value) {
356
- // When clearing fixed dimensions, restore from wrapper
357
- containerWidth.value = wrapper.value.clientWidth
358
- containerHeight.value = wrapper.value.clientHeight
359
- }
360
- }
361
-
362
- defineExpose({
363
- // Cancels any ongoing load operations (page loads, backfills, etc.)
364
- cancelLoad,
365
- // Opaque caller context passed through to getPage(page, context). Useful for including filters, service selection, tabId, etc.
366
- context,
367
- // Container height (wrapper element) in pixels
368
- containerHeight,
369
- // Container width (wrapper element) in pixels
370
- containerWidth,
371
- // Current Tailwind breakpoint name (base, sm, md, lg, xl, 2xl) based on containerWidth
372
- currentBreakpoint,
373
- // Current page number or cursor being displayed
374
- currentPage,
375
- // Completely destroys the component, clearing all state and resetting to initial state
376
- destroy,
377
- // Boolean indicating if the end of the list has been reached (no more pages to load)
378
- hasReachedEnd,
379
- // Initializes the component with items, page, and next page cursor. Use this for manual init mode.
380
- initialize,
381
- // Boolean indicating if the component has been initialized (first content has loaded)
382
- isInitialized,
383
- // Boolean indicating if a page load or backfill operation is currently in progress
384
- isLoading,
385
- // Error object if the last load operation failed, null otherwise
386
- loadError,
387
- // Loads the next page of items asynchronously
388
- loadNext,
389
- // Loads a specific page number or cursor asynchronously
390
- loadPage,
391
- // Array tracking pagination history (pages/cursors that have been loaded)
392
- paginationHistory,
393
- // Refreshes the current page by clearing items and reloading from the current page
394
- refreshCurrentPage,
395
- // Recalculates the layout positions for all items. Call this after manually modifying items.
396
- refreshLayout,
397
- // Removes a single item from the masonry
398
- remove,
399
- // Removes all items from the masonry
400
- removeAll,
401
- // Clears all items and pagination history (useful when applying filters)
402
- clear,
403
- // Removes multiple items from the masonry in a single operation
404
- removeMany,
405
- // Resets the component to initial state (clears items, resets pagination, scrolls to top)
406
- reset,
407
- // Restores a single item at its original index (useful for undo operations)
408
- restore,
409
- // Restores multiple items at their original indices (useful for undo operations)
410
- restoreMany,
411
- // Scrolls the container to a specific position
412
- scrollTo,
413
- // Scrolls the container to the top
414
- scrollToTop,
415
- // Sets the opaque caller context (alternative to v-model:context)
416
- setContext,
417
- // Sets fixed dimensions for the container, overriding ResizeObserver. Pass null to restore automatic sizing.
418
- setFixedDimensions,
419
- // Computed property returning the total number of items currently in the masonry
420
- totalItems: computed(() => (masonry.value as any[]).length)
421
- })
422
-
423
- // Layout functions are now in useMasonryLayout composable
424
- // Removed: calculateHeight, refreshLayout - now from layoutComposable
425
-
426
- // Expose swipe mode computed values and state for template
427
- const currentItem = swipeMode.currentItem
428
- const nextItem = swipeMode.nextItem
429
- const previousItem = swipeMode.previousItem
430
- const currentSwipeIndex = swipeMode.currentSwipeIndex
431
- const swipeOffset = swipeMode.swipeOffset
432
- const isDragging = swipeMode.isDragging
433
- const swipeContainer = swipeMode.swipeContainer
434
-
435
- // Swipe gesture handlers (delegated to composable)
436
- const handleTouchStart = swipeMode.handleTouchStart
437
- const handleTouchMove = swipeMode.handleTouchMove
438
- const handleTouchEnd = swipeMode.handleTouchEnd
439
- const handleMouseDown = swipeMode.handleMouseDown
440
- const handleMouseMove = swipeMode.handleMouseMove
441
- const handleMouseUp = swipeMode.handleMouseUp
442
- const goToNextItem = swipeMode.goToNextItem
443
- const goToPreviousItem = swipeMode.goToPreviousItem
444
- const snapToCurrentItem = swipeMode.snapToCurrentItem
445
-
446
- // Helper functions for swipe mode percentage calculations
447
- function getSwipeItemTop(index: string | number): string {
448
- const length = masonryLength.value
449
- const numIndex = typeof index === 'string' ? parseInt(index, 10) : index
450
- return length > 0 ? `${numIndex * (100 / length)}%` : '0%'
451
- }
452
-
453
- function getSwipeItemHeight(): string {
454
- const length = masonryLength.value
455
- return length > 0 ? `${100 / length}%` : '0%'
456
- }
457
-
458
- // refreshCurrentPage is now in useMasonryPagination composable
459
-
460
- // Item management functions (remove, removeMany, restore, restoreMany, removeAll) are now in useMasonryItems composable
461
-
462
- function scrollToTop(options?: ScrollToOptions) {
463
- if (container.value) {
464
- container.value.scrollTo({
465
- top: 0,
466
- behavior: options?.behavior ?? 'smooth',
467
- ...options
468
- })
469
- }
470
- }
471
-
472
- function scrollTo(options: { top?: number; left?: number; behavior?: ScrollBehavior }) {
473
- if (container.value) {
474
- container.value.scrollTo({
475
- top: options.top ?? container.value.scrollTop,
476
- left: options.left ?? container.value.scrollLeft,
477
- behavior: options.behavior ?? 'auto',
478
- })
479
- // Update viewport state immediately after scrolling
480
- if (container.value) {
481
- viewportTop.value = container.value.scrollTop
482
- viewportHeight.value = container.value.clientHeight || window.innerHeight
483
- }
484
- }
485
- }
486
-
487
- // removeAll is now in useMasonryItems composable
488
-
489
- // onResize is now in useMasonryLayout composable (onResizeLayout)
490
- function onResize() {
491
- onResizeLayout()
492
- if (container.value) {
493
- viewportTop.value = container.value.scrollTop
494
- viewportHeight.value = container.value.clientHeight
495
- }
496
- }
497
-
498
- // maybeBackfillToTarget, cancelLoad are now in useMasonryPagination composable
499
- // Removed: backfillActive, cancelRequested - now internal to pagination composable
500
-
501
- function clear() {
502
- // Clear all items and pagination history (useful when applying filters)
503
- masonry.value = []
504
- paginationHistory.value = []
505
- }
506
-
507
- function reset() {
508
- // Cancel ongoing work
509
- cancelLoad()
510
-
511
- if (container.value) {
512
- container.value.scrollTo({
513
- top: 0,
514
- behavior: 'smooth'
515
- })
516
- }
517
-
518
- masonry.value = []
519
- containerHeight.value = 0
520
- currentPage.value = props.loadAtPage // Reset current page tracking
521
- paginationHistory.value = [props.loadAtPage]
522
- hasReachedEnd.value = false // Reset end flag
523
- loadError.value = null // Reset error flag
524
- isInitialized.value = false // Reset initialization flag
525
-
526
- // Reset virtualization state
527
- resetVirtualization()
528
- }
529
-
530
- function destroy() {
531
- // Cancel any ongoing loads
532
- cancelLoad()
533
-
534
- // Reset all state
535
- masonry.value = []
536
- masonryContentHeight.value = 0
537
- currentPage.value = null
538
- paginationHistory.value = []
539
- hasReachedEnd.value = false
540
- loadError.value = null
541
- isLoading.value = false
542
- isInitialized.value = false
543
-
544
- // Reset swipe mode state
545
- currentSwipeIndex.value = 0
546
- swipeOffset.value = 0
547
- isDragging.value = false
548
-
549
- // Reset virtualization state
550
- resetVirtualization()
551
-
552
- // Reset invalid dimension tracking
553
- resetDimensions()
554
-
555
- // Scroll to top if container exists
556
- if (container.value) {
557
- container.value.scrollTo({
558
- top: 0,
559
- behavior: 'auto' // Instant scroll for destroy
560
- })
561
- }
562
- }
563
-
564
- // Scroll handler is now handled by virtualization composable's updateViewport
565
- const debouncedScrollHandler = debounce(async () => {
566
- if (useSwipeMode.value) return // Skip scroll handling in swipe mode
567
- await updateViewportVirtualization()
568
- }, 200)
569
-
570
- const debouncedResizeHandler = debounce(onResize, 200)
571
-
572
- // Window resize handler (combines swipe and general resize logic)
573
- function handleWindowResize() {
574
- // Delegate swipe-specific resize handling
575
- swipeMode.handleWindowResize()
576
-
577
- // General resize handling (if needed)
578
- // Note: containerWidth is updated by ResizeObserver
579
- }
580
-
581
- async function initialize(items: any[], page: any, next: any) {
582
- currentPage.value = page // Track the initial current page
583
- paginationHistory.value = [page]
584
- if (next !== null && next !== undefined) {
585
- paginationHistory.value.push(next)
586
- }
587
- // Only treat explicit null as end-of-list. Undefined means "unknown".
588
- hasReachedEnd.value = next === null
589
- // Diagnostics: check incoming initial items
590
- checkItemDimensions(items as any[], 'initialize')
591
-
592
- // If masonry is empty, replace items; otherwise add them
593
- const currentItems = masonry.value as any[]
594
- const newItems = currentItems.length === 0 ? items : [...currentItems, ...items]
595
-
596
- // Set items first (this updates the v-model)
597
- masonry.value = newItems
598
- await nextTick()
599
-
600
- if (useSwipeMode.value) {
601
- // In swipe mode, just set items without layout calculation
602
- // Reset swipe index if we're at the start
603
- if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
604
- swipeOffset.value = 0
605
- }
606
- } else {
607
- // Commit DOM updates without forcing sync reflow
608
- await nextTick()
609
- // Start FLIP on next tick (same pattern as restore/restoreMany)
610
- await nextTick()
611
- refreshLayout(newItems)
612
-
613
- // Update viewport state from container's scroll position
614
- // Critical after refresh when browser may restore scroll position
615
- if (container.value) {
616
- viewportTop.value = container.value.scrollTop
617
- viewportHeight.value = container.value.clientHeight || window.innerHeight
618
- }
619
-
620
- // Update again after DOM updates to catch browser scroll restoration
621
- // The debounced scroll handler will also catch any scroll changes
622
- nextTick(() => {
623
- if (container.value) {
624
- viewportTop.value = container.value.scrollTop
625
- viewportHeight.value = container.value.clientHeight || window.innerHeight
626
- updateScrollProgress()
627
- }
628
- })
629
- }
630
-
631
- // Mark as initialized when items are provided
632
- if (items && items.length > 0) {
633
- isInitialized.value = true
634
- }
635
- }
636
-
637
-
638
- // Watch for layout changes and update columns + refresh layout dynamically
639
- watch(
640
- layout,
641
- () => {
642
- if (useSwipeMode.value) {
643
- // In swipe mode, no layout recalculation needed
644
- return
645
- }
646
- if (container.value) {
647
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
648
- refreshLayout(masonry.value as any)
649
- }
650
- },
651
- { deep: true }
652
- )
653
-
654
- // Watch for layout-mode prop changes to ensure proper mode switching
655
- watch(() => props.layoutMode, () => {
656
- // Force update containerWidth when layout-mode changes to ensure useSwipeMode computes correctly
657
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
658
- containerWidth.value = fixedDimensions.value.width
659
- } else if (wrapper.value) {
660
- containerWidth.value = wrapper.value.clientWidth
661
- }
662
- })
663
-
664
- // Watch container element to attach scroll listener when available
665
- watch(container, (el) => {
666
- if (el && !useSwipeMode.value) {
667
- // Attach scroll listener for masonry mode
668
- el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
669
- el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
670
- } else if (el) {
671
- // Remove scroll listener if switching to swipe mode
672
- el.removeEventListener('scroll', debouncedScrollHandler)
673
- }
674
- }, { immediate: true })
675
-
676
- // Watch for when items are first loaded (for init='manual' when items are loaded via initialize)
677
- watch(
678
- () => masonry.value.length,
679
- (newLength, oldLength) => {
680
- // For manual mode, mark as initialized when items first appear
681
- // This handles the case where items are loaded via initialize after mount
682
- if (props.init === 'manual' && !isInitialized.value && newLength > 0 && oldLength === 0) {
683
- isInitialized.value = true
684
- }
685
- },
686
- { immediate: false }
687
- )
688
-
689
- // Watch for swipe mode changes to refresh layout and setup/teardown handlers
690
- watch(useSwipeMode, (newValue, oldValue) => {
691
- // Skip if this is the initial watch call and values are the same
692
- if (oldValue === undefined && newValue === false) return
693
-
694
- nextTick(() => {
695
- if (newValue) {
696
- // Switching to Swipe Mode
697
- document.addEventListener('mousemove', handleMouseMove)
698
- document.addEventListener('mouseup', handleMouseUp)
699
-
700
- // Remove scroll listener
701
- if (container.value) {
702
- container.value.removeEventListener('scroll', debouncedScrollHandler)
703
- }
704
-
705
- // Reset index if needed
706
- currentSwipeIndex.value = 0
707
- swipeOffset.value = 0
708
- if (masonry.value.length > 0) {
709
- snapToCurrentItem()
710
- }
711
- } else {
712
- // Switching to Masonry Mode
713
- document.removeEventListener('mousemove', handleMouseMove)
714
- document.removeEventListener('mouseup', handleMouseUp)
715
-
716
- if (container.value && wrapper.value) {
717
- // Ensure containerWidth is up to date - use fixed dimensions if set
718
- if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
719
- containerWidth.value = fixedDimensions.value.width
720
- } else {
721
- containerWidth.value = wrapper.value.clientWidth
722
- }
723
-
724
- // Attach scroll listener (container watcher will handle this, but ensure it's attached)
725
- container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
726
- container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
727
-
728
- // Refresh layout with updated width
729
- if (masonry.value.length > 0) {
730
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
731
- refreshLayout(masonry.value as any)
732
-
733
- // Update viewport state
734
- viewportTop.value = container.value.scrollTop
735
- viewportHeight.value = container.value.clientHeight
736
- updateScrollProgress()
737
- }
738
- }
739
- }
740
- })
741
- }, { immediate: true })
742
-
743
- // Watch for swipe container element to attach touch listeners
744
- watch(swipeContainer, (el) => {
745
- if (el) {
746
- el.addEventListener('touchstart', handleTouchStart, { passive: false })
747
- el.addEventListener('touchmove', handleTouchMove, { passive: false })
748
- el.addEventListener('touchend', handleTouchEnd)
749
- el.addEventListener('mousedown', handleMouseDown)
750
- }
751
- })
752
-
753
- // Watch for items changes in swipe mode to reset index if needed
754
- watch(() => masonry.value.length, (newLength, oldLength) => {
755
- if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
756
- // First items loaded, ensure we're at index 0
757
- currentSwipeIndex.value = 0
758
- nextTick(() => snapToCurrentItem())
759
- }
760
- })
761
-
762
- // Watch wrapper element to setup ResizeObserver for container width
763
- watch(wrapper, (el) => {
764
- if (resizeObserver) {
765
- resizeObserver.disconnect()
766
- resizeObserver = null
767
- }
768
-
769
- if (el && typeof ResizeObserver !== 'undefined') {
770
- resizeObserver = new ResizeObserver((entries) => {
771
- // Skip updates if fixed dimensions are set
772
- if (fixedDimensions.value) return
773
-
774
- for (const entry of entries) {
775
- const newWidth = entry.contentRect.width
776
- const newHeight = entry.contentRect.height
777
- if (containerWidth.value !== newWidth) {
778
- containerWidth.value = newWidth
779
- }
780
- if (containerHeight.value !== newHeight) {
781
- containerHeight.value = newHeight
782
- }
783
- }
784
- })
785
- resizeObserver.observe(el)
786
- // Initial dimensions (only if not fixed)
787
- if (!fixedDimensions.value) {
788
- containerWidth.value = el.clientWidth
789
- containerHeight.value = el.clientHeight
790
- }
791
- } else if (el) {
792
- // Fallback if ResizeObserver not available
793
- if (!fixedDimensions.value) {
794
- containerWidth.value = el.clientWidth
795
- containerHeight.value = el.clientHeight
796
- }
797
- }
798
- }, { immediate: true })
799
-
800
- // Watch containerWidth changes to refresh layout in masonry mode
801
- watch(containerWidth, (newWidth, oldWidth) => {
802
- if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
803
- // Use nextTick to ensure DOM has updated
804
- nextTick(() => {
805
- columns.value = getColumnCount(layout.value as any, newWidth)
806
- refreshLayout(masonry.value as any)
807
- updateScrollProgress()
808
- })
809
- }
810
- })
811
-
812
- onMounted(async () => {
813
- try {
814
- // Wait for next tick to ensure wrapper is mounted
815
- await nextTick()
816
-
817
- // Container dimensions are managed by ResizeObserver
818
- // Only set initial values if ResizeObserver isn't available
819
- if (wrapper.value && !resizeObserver) {
820
- containerWidth.value = wrapper.value.clientWidth
821
- containerHeight.value = wrapper.value.clientHeight
822
- }
823
-
824
- if (!useSwipeMode.value) {
825
- columns.value = getColumnCount(layout.value as any, containerWidth.value)
826
- if (container.value) {
827
- viewportTop.value = container.value.scrollTop
828
- viewportHeight.value = container.value.clientHeight
829
- }
830
- }
831
-
832
- const initialPage = props.loadAtPage as any
833
-
834
- // Only set paginationHistory in onMounted if:
835
- // 1. init is 'auto' (we need it for auto-loading)
836
- // 2. paginationHistory is empty (hasn't been set by initialize yet)
837
- // If init is 'manual', initialize() will set it, so don't overwrite
838
- if (props.init === 'auto' && paginationHistory.value.length === 0) {
839
- paginationHistory.value = [initialPage]
840
- }
841
-
842
- if (props.init === 'auto') {
843
- // Auto mode: automatically call loadPage on mount
844
- // Set initialized BEFORE loading so the masonry container renders
845
- // This allows refreshLayout to access the container element for measurements
846
- isInitialized.value = true
847
- await nextTick() // Ensure container is rendered before loading
848
-
849
- try {
850
- await loadPage(initialPage)
851
- } catch (error) {
852
- // Error is already handled by loadPage via loadError
853
- // Continue - component is already initialized
854
- }
855
- }
856
- // Manual mode: do nothing, user will manually call restore()
857
-
858
- if (!useSwipeMode.value) {
859
- updateScrollProgress()
860
- } else {
861
- // In swipe mode, snap to first item
862
- nextTick(() => snapToCurrentItem())
863
- }
864
-
865
- } catch (error) {
866
- // If error is from loadPage, it's already handled via loadError
867
- // Only log truly unexpected initialization errors
868
- if (!loadError.value) {
869
- console.error('Error during component initialization:', error)
870
- // Set loadError for unexpected errors too
871
- loadError.value = normalizeError(error)
872
- }
873
- isLoading.value = false
874
- // isInitialized is already set to true before loadPage for 'auto' mode
875
- }
876
-
877
- // Scroll listener is handled by watcher now for consistency
878
- window.addEventListener('resize', debouncedResizeHandler)
879
- window.addEventListener('resize', handleWindowResize)
880
- })
881
-
882
- onUnmounted(() => {
883
- if (resizeObserver) {
884
- resizeObserver.disconnect()
885
- resizeObserver = null
886
- }
887
-
888
- container.value?.removeEventListener('scroll', debouncedScrollHandler)
889
- window.removeEventListener('resize', debouncedResizeHandler)
890
- window.removeEventListener('resize', handleWindowResize)
891
-
892
- if (swipeContainer.value) {
893
- swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
894
- swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
895
- swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
896
- swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
897
- }
898
-
899
- // Clean up mouse handlers
900
- document.removeEventListener('mousemove', handleMouseMove)
901
- document.removeEventListener('mouseup', handleMouseUp)
902
- })
903
- </script>
904
-
905
- <template>
906
- <div ref="wrapper" class="w-full h-full flex flex-col relative">
907
- <!-- Loading message while waiting for initial content -->
908
- <div v-if="!isInitialized" class="w-full h-full flex items-center justify-center">
909
- <slot name="loading-message">
910
- <p class="text-gray-500 dark:text-gray-400">Waiting for content to load...</p>
911
- </slot>
912
- </div>
913
-
914
- <!-- Swipe Feed Mode (Mobile/Tablet) -->
915
- <div v-else-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
916
- :class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
917
- ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
918
- <div class="relative w-full" :style="{
919
- transform: `translateY(${swipeOffset}px)`,
920
- transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
921
- height: `${masonryLength * 100}%`
922
- }">
923
- <div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
924
- :style="{
925
- top: getSwipeItemTop(index),
926
- height: getSwipeItemHeight()
927
- }">
928
- <div class="w-full h-full flex items-center justify-center p-4">
929
- <div class="w-full h-full max-w-full max-h-full relative">
930
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? index">
931
- <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
932
- :in-swipe-mode="true" :is-active="index === currentSwipeIndex"
933
- @preload:success="(p) => emits('item:preload:success', p)"
934
- @preload:error="(p) => emits('item:preload:error', p)"
935
- @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
936
- <!-- Pass through header and footer slots to MasonryItem -->
937
- <template #header="slotProps">
938
- <slot name="item-header" v-bind="slotProps" />
939
- </template>
940
- <template #footer="slotProps">
941
- <slot name="item-footer" v-bind="slotProps" />
942
- </template>
943
- </MasonryItem>
944
- </slot>
945
- </div>
946
- </div>
947
- </div>
948
- </div>
949
- <!-- End of list message for swipe mode -->
950
- <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
951
- <slot name="end-message">
952
- <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
953
- </slot>
954
- </div>
955
- <!-- Error message for swipe mode -->
956
- <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
957
- <slot name="error-message" :error="loadError">
958
- <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
959
- </slot>
960
- </div>
961
- </div>
962
-
963
- <!-- Masonry Grid Mode (Desktop) -->
964
- <div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
965
- ref="container">
966
- <div class="relative"
967
- :style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
968
- <transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
969
- @before-leave="beforeLeave">
970
- <div v-for="(item, index) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
971
- v-bind="getItemAttributes(item, index)">
972
- <!-- Use default slot if provided, otherwise use MasonryItem -->
973
- <slot :item="item" :remove="remove" :index="item.originalIndex ?? index">
974
- <MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
975
- :in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
976
- @preload:error="(p) => emits('item:preload:error', p)"
977
- @mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
978
- <!-- Pass through header and footer slots to MasonryItem -->
979
- <template #header="slotProps">
980
- <slot name="item-header" v-bind="slotProps" />
981
- </template>
982
- <template #footer="slotProps">
983
- <slot name="item-footer" v-bind="slotProps" />
984
- </template>
985
- </MasonryItem>
986
- </slot>
987
- </div>
988
- </transition-group>
989
- </div>
990
- <!-- End of list message -->
991
- <div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
992
- <slot name="end-message">
993
- <p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
994
- </slot>
995
- </div>
996
- <!-- Error message -->
997
- <div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
998
- <slot name="error-message" :error="loadError">
999
- <p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
1000
- </slot>
1001
- </div>
1002
- </div>
1003
- </div>
1004
- </template>
1005
-
1006
- <style scoped>
1007
- .masonry-container {
1008
- overflow-anchor: none;
1009
- }
1010
-
1011
- .masonry-item {
1012
- will-change: transform, opacity;
1013
- contain: layout paint;
1014
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
1015
- opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
1016
- backface-visibility: hidden;
1017
- }
1018
-
1019
- .masonry-move {
1020
- transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
1021
- }
1022
-
1023
- @media (prefers-reduced-motion: reduce) {
1024
-
1025
- .masonry-container:not(.force-motion) .masonry-item,
1026
- .masonry-container:not(.force-motion) .masonry-move {
1027
- transition-duration: 1ms !important;
1028
- }
1029
- }
1030
- </style>