@wyxos/vibe 1.6.27 → 1.6.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +1063 -1023
- package/lib/vibe.css +1 -1
- package/package.json +1 -1
- package/src/Masonry.vue +1030 -1008
- package/src/components/MasonryItem.vue +499 -501
- package/src/createMasonryTransitions.ts +18 -27
- package/src/types.ts +101 -38
- package/src/useMasonryItems.ts +231 -234
- package/src/useMasonryLayout.ts +164 -164
- package/src/useMasonryPagination.ts +116 -42
- package/src/useMasonryVirtualization.ts +1 -1
- package/src/views/Home.vue +2 -2
package/src/Masonry.vue
CHANGED
|
@@ -1,1008 +1,1030 @@
|
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
// Container
|
|
368
|
-
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
// Current
|
|
372
|
-
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
//
|
|
380
|
-
|
|
381
|
-
// Boolean indicating if
|
|
382
|
-
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
// Loads
|
|
388
|
-
|
|
389
|
-
//
|
|
390
|
-
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
//
|
|
396
|
-
|
|
397
|
-
// Removes
|
|
398
|
-
|
|
399
|
-
// Removes
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
//
|
|
404
|
-
|
|
405
|
-
//
|
|
406
|
-
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
//
|
|
414
|
-
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
const
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
const
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
// Reset
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
// Reset
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
if (
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
//
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
)
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
document.
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (container.value
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
//
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
resizeObserver
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
//
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
})
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
.
|
|
998
|
-
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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>
|