@wyxos/vibe 1.6.17 → 1.6.18
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 +763 -700
- package/lib/vibe.css +1 -1
- package/package.json +2 -2
- package/src/Masonry.vue +1439 -1334
- package/src/useMasonryScroll.ts +60 -60
package/src/Masonry.vue
CHANGED
|
@@ -1,1334 +1,1439 @@
|
|
|
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 { useMasonryTransitions } from './useMasonryTransitions'
|
|
13
|
-
import { useMasonryScroll } from './useMasonryScroll'
|
|
14
|
-
import MasonryItem from './components/MasonryItem.vue'
|
|
15
|
-
|
|
16
|
-
const props = defineProps({
|
|
17
|
-
getNextPage: {
|
|
18
|
-
type: Function,
|
|
19
|
-
default: () => {}
|
|
20
|
-
},
|
|
21
|
-
loadAtPage: {
|
|
22
|
-
type: [Number, String],
|
|
23
|
-
default: null
|
|
24
|
-
},
|
|
25
|
-
items: {
|
|
26
|
-
type: Array,
|
|
27
|
-
default: () => []
|
|
28
|
-
},
|
|
29
|
-
layout: {
|
|
30
|
-
type: Object
|
|
31
|
-
},
|
|
32
|
-
paginationType: {
|
|
33
|
-
type: String,
|
|
34
|
-
default: 'page', // or 'cursor'
|
|
35
|
-
validator: (v: string) => ['page', 'cursor'].includes(v)
|
|
36
|
-
},
|
|
37
|
-
skipInitialLoad: {
|
|
38
|
-
type: Boolean,
|
|
39
|
-
default: false
|
|
40
|
-
},
|
|
41
|
-
pageSize: {
|
|
42
|
-
type: Number,
|
|
43
|
-
default: 40
|
|
44
|
-
},
|
|
45
|
-
// Backfill configuration
|
|
46
|
-
backfillEnabled: {
|
|
47
|
-
type: Boolean,
|
|
48
|
-
default: true
|
|
49
|
-
},
|
|
50
|
-
backfillDelayMs: {
|
|
51
|
-
type: Number,
|
|
52
|
-
default: 2000
|
|
53
|
-
},
|
|
54
|
-
backfillMaxCalls: {
|
|
55
|
-
type: Number,
|
|
56
|
-
default: 10
|
|
57
|
-
},
|
|
58
|
-
// Retry configuration
|
|
59
|
-
retryMaxAttempts: {
|
|
60
|
-
type: Number,
|
|
61
|
-
default: 3
|
|
62
|
-
},
|
|
63
|
-
retryInitialDelayMs: {
|
|
64
|
-
type: Number,
|
|
65
|
-
default: 2000
|
|
66
|
-
},
|
|
67
|
-
retryBackoffStepMs: {
|
|
68
|
-
type: Number,
|
|
69
|
-
default: 2000
|
|
70
|
-
},
|
|
71
|
-
transitionDurationMs: {
|
|
72
|
-
type: Number,
|
|
73
|
-
default: 450
|
|
74
|
-
},
|
|
75
|
-
// Shorter, snappier duration specifically for item removal (leave)
|
|
76
|
-
leaveDurationMs: {
|
|
77
|
-
type: Number,
|
|
78
|
-
default: 160
|
|
79
|
-
},
|
|
80
|
-
transitionEasing: {
|
|
81
|
-
type: String,
|
|
82
|
-
default: 'cubic-bezier(.22,.61,.36,1)'
|
|
83
|
-
},
|
|
84
|
-
// Force motion even when user has reduced-motion enabled
|
|
85
|
-
forceMotion: {
|
|
86
|
-
type: Boolean,
|
|
87
|
-
default: false
|
|
88
|
-
},
|
|
89
|
-
virtualBufferPx: {
|
|
90
|
-
type: Number,
|
|
91
|
-
default: 600
|
|
92
|
-
},
|
|
93
|
-
loadThresholdPx: {
|
|
94
|
-
type: Number,
|
|
95
|
-
default: 200
|
|
96
|
-
},
|
|
97
|
-
autoRefreshOnEmpty: {
|
|
98
|
-
type: Boolean,
|
|
99
|
-
default: false
|
|
100
|
-
},
|
|
101
|
-
// Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
|
|
102
|
-
layoutMode: {
|
|
103
|
-
type: String,
|
|
104
|
-
default: 'auto',
|
|
105
|
-
validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
|
|
106
|
-
},
|
|
107
|
-
// Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
|
|
108
|
-
mobileBreakpoint: {
|
|
109
|
-
type: [Number, String],
|
|
110
|
-
default: 768 // 'md' breakpoint
|
|
111
|
-
},
|
|
112
|
-
})
|
|
113
|
-
|
|
114
|
-
const defaultLayout = {
|
|
115
|
-
sizes: {base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6},
|
|
116
|
-
gutterX: 10,
|
|
117
|
-
gutterY: 10,
|
|
118
|
-
header: 0,
|
|
119
|
-
footer: 0,
|
|
120
|
-
paddingLeft: 0,
|
|
121
|
-
paddingRight: 0,
|
|
122
|
-
placement: 'masonry'
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const layout = computed(() => ({
|
|
126
|
-
...defaultLayout,
|
|
127
|
-
...props.layout,
|
|
128
|
-
sizes: {
|
|
129
|
-
...defaultLayout.sizes,
|
|
130
|
-
...(props.layout?.sizes || {})
|
|
131
|
-
}
|
|
132
|
-
}))
|
|
133
|
-
|
|
134
|
-
const wrapper = ref<HTMLElement | null>(null)
|
|
135
|
-
const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
|
|
136
|
-
const containerHeight = ref<number>(typeof window !== 'undefined' ? window.innerHeight : 768)
|
|
137
|
-
const fixedDimensions = ref<{ width?: number; height?: number } | null>(null)
|
|
138
|
-
let resizeObserver: ResizeObserver | null = null
|
|
139
|
-
|
|
140
|
-
// Get breakpoint value from Tailwind breakpoint name
|
|
141
|
-
function getBreakpointValue(breakpoint: string): number {
|
|
142
|
-
const breakpoints: Record<string, number> = {
|
|
143
|
-
'sm': 640,
|
|
144
|
-
'md': 768,
|
|
145
|
-
'lg': 1024,
|
|
146
|
-
'xl': 1280,
|
|
147
|
-
'2xl': 1536
|
|
148
|
-
}
|
|
149
|
-
return breakpoints[breakpoint] || 768
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Determine if we should use swipe mode
|
|
153
|
-
const useSwipeMode = computed(() => {
|
|
154
|
-
if (props.layoutMode === 'masonry') return false
|
|
155
|
-
if (props.layoutMode === 'swipe') return true
|
|
156
|
-
|
|
157
|
-
// Auto mode: check container width
|
|
158
|
-
const breakpoint = typeof props.mobileBreakpoint === 'string'
|
|
159
|
-
? getBreakpointValue(props.mobileBreakpoint)
|
|
160
|
-
: props.mobileBreakpoint
|
|
161
|
-
|
|
162
|
-
return containerWidth.value < breakpoint
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
// Get current item index for swipe mode
|
|
166
|
-
const currentItem = computed(() => {
|
|
167
|
-
if (!useSwipeMode.value || masonry.value.length === 0) return null
|
|
168
|
-
const index = Math.max(0, Math.min(currentSwipeIndex.value, masonry.value.length - 1))
|
|
169
|
-
return (masonry.value as any[])[index] || null
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
// Get next/previous items for preloading in swipe mode
|
|
173
|
-
const nextItem = computed(() => {
|
|
174
|
-
if (!useSwipeMode.value || !currentItem.value) return null
|
|
175
|
-
const nextIndex = currentSwipeIndex.value + 1
|
|
176
|
-
if (nextIndex >= masonry.value.length) return null
|
|
177
|
-
return (masonry.value as any[])[nextIndex] || null
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
const previousItem = computed(() => {
|
|
181
|
-
if (!useSwipeMode.value || !currentItem.value) return null
|
|
182
|
-
const prevIndex = currentSwipeIndex.value - 1
|
|
183
|
-
if (prevIndex < 0) return null
|
|
184
|
-
return (masonry.value as any[])[prevIndex] || null
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
const emits = defineEmits([
|
|
188
|
-
'update:items',
|
|
189
|
-
'backfill:start',
|
|
190
|
-
'backfill:tick',
|
|
191
|
-
'backfill:stop',
|
|
192
|
-
'retry:start',
|
|
193
|
-
'retry:tick',
|
|
194
|
-
'retry:stop',
|
|
195
|
-
'remove-all:complete',
|
|
196
|
-
// Re-emit item-level preload events from the default MasonryItem
|
|
197
|
-
'item:preload:success',
|
|
198
|
-
'item:preload:error',
|
|
199
|
-
// Mouse events from MasonryItem content
|
|
200
|
-
'item:mouse-enter',
|
|
201
|
-
'item:mouse-leave'
|
|
202
|
-
])
|
|
203
|
-
|
|
204
|
-
const masonry = computed<any>({
|
|
205
|
-
get: () => props.items,
|
|
206
|
-
set: (val) => emits('update:items', val)
|
|
207
|
-
})
|
|
208
|
-
|
|
209
|
-
const columns = ref<number>(7)
|
|
210
|
-
const container = ref<HTMLElement | null>(null)
|
|
211
|
-
const paginationHistory = ref<any[]>([])
|
|
212
|
-
const currentPage = ref<any>(null) // Track the actual current page being displayed
|
|
213
|
-
const isLoading = ref<boolean>(false)
|
|
214
|
-
const masonryContentHeight = ref<number>(0)
|
|
215
|
-
|
|
216
|
-
//
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
const
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
el.
|
|
309
|
-
el.
|
|
310
|
-
el.style.
|
|
311
|
-
el.style.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
el.
|
|
325
|
-
el.
|
|
326
|
-
el.style.
|
|
327
|
-
el.style.
|
|
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
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
//
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
containerEl.style.width =
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
//
|
|
701
|
-
|
|
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
|
-
if (
|
|
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
|
-
|
|
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
|
-
if
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
if (
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
} else
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
if
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
//
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
//
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
if (
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
.
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
.
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
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 { useMasonryTransitions } from './useMasonryTransitions'
|
|
13
|
+
import { useMasonryScroll } from './useMasonryScroll'
|
|
14
|
+
import MasonryItem from './components/MasonryItem.vue'
|
|
15
|
+
|
|
16
|
+
const props = defineProps({
|
|
17
|
+
getNextPage: {
|
|
18
|
+
type: Function,
|
|
19
|
+
default: () => { }
|
|
20
|
+
},
|
|
21
|
+
loadAtPage: {
|
|
22
|
+
type: [Number, String],
|
|
23
|
+
default: null
|
|
24
|
+
},
|
|
25
|
+
items: {
|
|
26
|
+
type: Array,
|
|
27
|
+
default: () => []
|
|
28
|
+
},
|
|
29
|
+
layout: {
|
|
30
|
+
type: Object
|
|
31
|
+
},
|
|
32
|
+
paginationType: {
|
|
33
|
+
type: String,
|
|
34
|
+
default: 'page', // or 'cursor'
|
|
35
|
+
validator: (v: string) => ['page', 'cursor'].includes(v)
|
|
36
|
+
},
|
|
37
|
+
skipInitialLoad: {
|
|
38
|
+
type: Boolean,
|
|
39
|
+
default: false
|
|
40
|
+
},
|
|
41
|
+
pageSize: {
|
|
42
|
+
type: Number,
|
|
43
|
+
default: 40
|
|
44
|
+
},
|
|
45
|
+
// Backfill configuration
|
|
46
|
+
backfillEnabled: {
|
|
47
|
+
type: Boolean,
|
|
48
|
+
default: true
|
|
49
|
+
},
|
|
50
|
+
backfillDelayMs: {
|
|
51
|
+
type: Number,
|
|
52
|
+
default: 2000
|
|
53
|
+
},
|
|
54
|
+
backfillMaxCalls: {
|
|
55
|
+
type: Number,
|
|
56
|
+
default: 10
|
|
57
|
+
},
|
|
58
|
+
// Retry configuration
|
|
59
|
+
retryMaxAttempts: {
|
|
60
|
+
type: Number,
|
|
61
|
+
default: 3
|
|
62
|
+
},
|
|
63
|
+
retryInitialDelayMs: {
|
|
64
|
+
type: Number,
|
|
65
|
+
default: 2000
|
|
66
|
+
},
|
|
67
|
+
retryBackoffStepMs: {
|
|
68
|
+
type: Number,
|
|
69
|
+
default: 2000
|
|
70
|
+
},
|
|
71
|
+
transitionDurationMs: {
|
|
72
|
+
type: Number,
|
|
73
|
+
default: 450
|
|
74
|
+
},
|
|
75
|
+
// Shorter, snappier duration specifically for item removal (leave)
|
|
76
|
+
leaveDurationMs: {
|
|
77
|
+
type: Number,
|
|
78
|
+
default: 160
|
|
79
|
+
},
|
|
80
|
+
transitionEasing: {
|
|
81
|
+
type: String,
|
|
82
|
+
default: 'cubic-bezier(.22,.61,.36,1)'
|
|
83
|
+
},
|
|
84
|
+
// Force motion even when user has reduced-motion enabled
|
|
85
|
+
forceMotion: {
|
|
86
|
+
type: Boolean,
|
|
87
|
+
default: false
|
|
88
|
+
},
|
|
89
|
+
virtualBufferPx: {
|
|
90
|
+
type: Number,
|
|
91
|
+
default: 600
|
|
92
|
+
},
|
|
93
|
+
loadThresholdPx: {
|
|
94
|
+
type: Number,
|
|
95
|
+
default: 200
|
|
96
|
+
},
|
|
97
|
+
autoRefreshOnEmpty: {
|
|
98
|
+
type: Boolean,
|
|
99
|
+
default: false
|
|
100
|
+
},
|
|
101
|
+
// Layout mode: 'auto' (detect from screen size), 'masonry', or 'swipe'
|
|
102
|
+
layoutMode: {
|
|
103
|
+
type: String,
|
|
104
|
+
default: 'auto',
|
|
105
|
+
validator: (v: string) => ['auto', 'masonry', 'swipe'].includes(v)
|
|
106
|
+
},
|
|
107
|
+
// Breakpoint for switching to swipe mode (in pixels or Tailwind breakpoint name)
|
|
108
|
+
mobileBreakpoint: {
|
|
109
|
+
type: [Number, String],
|
|
110
|
+
default: 768 // 'md' breakpoint
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const defaultLayout = {
|
|
115
|
+
sizes: { base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6 },
|
|
116
|
+
gutterX: 10,
|
|
117
|
+
gutterY: 10,
|
|
118
|
+
header: 0,
|
|
119
|
+
footer: 0,
|
|
120
|
+
paddingLeft: 0,
|
|
121
|
+
paddingRight: 0,
|
|
122
|
+
placement: 'masonry'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const layout = computed(() => ({
|
|
126
|
+
...defaultLayout,
|
|
127
|
+
...props.layout,
|
|
128
|
+
sizes: {
|
|
129
|
+
...defaultLayout.sizes,
|
|
130
|
+
...(props.layout?.sizes || {})
|
|
131
|
+
}
|
|
132
|
+
}))
|
|
133
|
+
|
|
134
|
+
const wrapper = ref<HTMLElement | null>(null)
|
|
135
|
+
const containerWidth = ref<number>(typeof window !== 'undefined' ? window.innerWidth : 1024)
|
|
136
|
+
const containerHeight = ref<number>(typeof window !== 'undefined' ? window.innerHeight : 768)
|
|
137
|
+
const fixedDimensions = ref<{ width?: number; height?: number } | null>(null)
|
|
138
|
+
let resizeObserver: ResizeObserver | null = null
|
|
139
|
+
|
|
140
|
+
// Get breakpoint value from Tailwind breakpoint name
|
|
141
|
+
function getBreakpointValue(breakpoint: string): number {
|
|
142
|
+
const breakpoints: Record<string, number> = {
|
|
143
|
+
'sm': 640,
|
|
144
|
+
'md': 768,
|
|
145
|
+
'lg': 1024,
|
|
146
|
+
'xl': 1280,
|
|
147
|
+
'2xl': 1536
|
|
148
|
+
}
|
|
149
|
+
return breakpoints[breakpoint] || 768
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Determine if we should use swipe mode
|
|
153
|
+
const useSwipeMode = computed(() => {
|
|
154
|
+
if (props.layoutMode === 'masonry') return false
|
|
155
|
+
if (props.layoutMode === 'swipe') return true
|
|
156
|
+
|
|
157
|
+
// Auto mode: check container width
|
|
158
|
+
const breakpoint = typeof props.mobileBreakpoint === 'string'
|
|
159
|
+
? getBreakpointValue(props.mobileBreakpoint)
|
|
160
|
+
: props.mobileBreakpoint
|
|
161
|
+
|
|
162
|
+
return containerWidth.value < breakpoint
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
// Get current item index for swipe mode
|
|
166
|
+
const currentItem = computed(() => {
|
|
167
|
+
if (!useSwipeMode.value || masonry.value.length === 0) return null
|
|
168
|
+
const index = Math.max(0, Math.min(currentSwipeIndex.value, masonry.value.length - 1))
|
|
169
|
+
return (masonry.value as any[])[index] || null
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Get next/previous items for preloading in swipe mode
|
|
173
|
+
const nextItem = computed(() => {
|
|
174
|
+
if (!useSwipeMode.value || !currentItem.value) return null
|
|
175
|
+
const nextIndex = currentSwipeIndex.value + 1
|
|
176
|
+
if (nextIndex >= masonry.value.length) return null
|
|
177
|
+
return (masonry.value as any[])[nextIndex] || null
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
const previousItem = computed(() => {
|
|
181
|
+
if (!useSwipeMode.value || !currentItem.value) return null
|
|
182
|
+
const prevIndex = currentSwipeIndex.value - 1
|
|
183
|
+
if (prevIndex < 0) return null
|
|
184
|
+
return (masonry.value as any[])[prevIndex] || null
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
const emits = defineEmits([
|
|
188
|
+
'update:items',
|
|
189
|
+
'backfill:start',
|
|
190
|
+
'backfill:tick',
|
|
191
|
+
'backfill:stop',
|
|
192
|
+
'retry:start',
|
|
193
|
+
'retry:tick',
|
|
194
|
+
'retry:stop',
|
|
195
|
+
'remove-all:complete',
|
|
196
|
+
// Re-emit item-level preload events from the default MasonryItem
|
|
197
|
+
'item:preload:success',
|
|
198
|
+
'item:preload:error',
|
|
199
|
+
// Mouse events from MasonryItem content
|
|
200
|
+
'item:mouse-enter',
|
|
201
|
+
'item:mouse-leave'
|
|
202
|
+
])
|
|
203
|
+
|
|
204
|
+
const masonry = computed<any>({
|
|
205
|
+
get: () => props.items,
|
|
206
|
+
set: (val) => emits('update:items', val)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
const columns = ref<number>(7)
|
|
210
|
+
const container = ref<HTMLElement | null>(null)
|
|
211
|
+
const paginationHistory = ref<any[]>([])
|
|
212
|
+
const currentPage = ref<any>(null) // Track the actual current page being displayed
|
|
213
|
+
const isLoading = ref<boolean>(false)
|
|
214
|
+
const masonryContentHeight = ref<number>(0)
|
|
215
|
+
const hasReachedEnd = ref<boolean>(false) // Track when we've reached the last page
|
|
216
|
+
const loadError = ref<Error | null>(null) // Track load errors
|
|
217
|
+
|
|
218
|
+
// Current breakpoint
|
|
219
|
+
const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
|
|
220
|
+
|
|
221
|
+
// Swipe mode state
|
|
222
|
+
const currentSwipeIndex = ref<number>(0)
|
|
223
|
+
const swipeOffset = ref<number>(0)
|
|
224
|
+
const isDragging = ref<boolean>(false)
|
|
225
|
+
const dragStartY = ref<number>(0)
|
|
226
|
+
const dragStartOffset = ref<number>(0)
|
|
227
|
+
const swipeContainer = ref<HTMLElement | null>(null)
|
|
228
|
+
|
|
229
|
+
// Diagnostics: track items missing width/height to help developers
|
|
230
|
+
const invalidDimensionIds = ref<Set<number | string>>(new Set())
|
|
231
|
+
function isPositiveNumber(value: unknown): boolean {
|
|
232
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
233
|
+
}
|
|
234
|
+
function checkItemDimensions(items: any[], context: string) {
|
|
235
|
+
try {
|
|
236
|
+
if (!Array.isArray(items) || items.length === 0) return
|
|
237
|
+
const missing = items.filter((item) => !isPositiveNumber(item?.width) || !isPositiveNumber(item?.height))
|
|
238
|
+
if (missing.length === 0) return
|
|
239
|
+
|
|
240
|
+
const newIds: Array<number | string> = []
|
|
241
|
+
for (const item of missing) {
|
|
242
|
+
const id = (item?.id as number | string | undefined) ?? `idx:${items.indexOf(item)}`
|
|
243
|
+
if (!invalidDimensionIds.value.has(id)) {
|
|
244
|
+
invalidDimensionIds.value.add(id)
|
|
245
|
+
newIds.push(id)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (newIds.length > 0) {
|
|
249
|
+
const sample = newIds.slice(0, 10)
|
|
250
|
+
// eslint-disable-next-line no-console
|
|
251
|
+
console.warn(
|
|
252
|
+
'[Masonry] Items missing width/height detected:',
|
|
253
|
+
{
|
|
254
|
+
context,
|
|
255
|
+
count: newIds.length,
|
|
256
|
+
sampleIds: sample,
|
|
257
|
+
hint: 'Ensure each item has positive width and height. Consider providing fallbacks (e.g., 512x512) at the data layer.'
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
} catch {
|
|
262
|
+
// best-effort diagnostics only
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Virtualization viewport state
|
|
267
|
+
const viewportTop = ref(0)
|
|
268
|
+
const viewportHeight = ref(0)
|
|
269
|
+
const VIRTUAL_BUFFER_PX = props.virtualBufferPx
|
|
270
|
+
|
|
271
|
+
// Gate transitions during virtualization-only DOM churn
|
|
272
|
+
const virtualizing = ref(false)
|
|
273
|
+
|
|
274
|
+
// Scroll progress tracking
|
|
275
|
+
const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
|
|
276
|
+
distanceToTrigger: 0,
|
|
277
|
+
isNearTrigger: false
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
const updateScrollProgress = (precomputedHeights?: number[]) => {
|
|
281
|
+
if (!container.value) return
|
|
282
|
+
|
|
283
|
+
const { scrollTop, clientHeight } = container.value
|
|
284
|
+
const visibleBottom = scrollTop + clientHeight
|
|
285
|
+
|
|
286
|
+
const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value as any, columns.value)
|
|
287
|
+
const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
|
|
288
|
+
const threshold = typeof props.loadThresholdPx === 'number' ? props.loadThresholdPx : 200
|
|
289
|
+
const triggerPoint = threshold >= 0
|
|
290
|
+
? Math.max(0, tallest - threshold)
|
|
291
|
+
: Math.max(0, tallest + threshold)
|
|
292
|
+
|
|
293
|
+
const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
|
|
294
|
+
const isNearTrigger = distanceToTrigger <= 100
|
|
295
|
+
|
|
296
|
+
scrollProgress.value = {
|
|
297
|
+
distanceToTrigger: Math.round(distanceToTrigger),
|
|
298
|
+
isNearTrigger
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Setup composables
|
|
303
|
+
const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry, { leaveDurationMs: props.leaveDurationMs })
|
|
304
|
+
|
|
305
|
+
// Transition wrappers that skip animation during virtualization
|
|
306
|
+
function enter(el: HTMLElement, done: () => void) {
|
|
307
|
+
if (virtualizing.value) {
|
|
308
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
309
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
310
|
+
el.style.transition = 'none'
|
|
311
|
+
el.style.opacity = '1'
|
|
312
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
313
|
+
el.style.removeProperty('--masonry-opacity-delay')
|
|
314
|
+
requestAnimationFrame(() => {
|
|
315
|
+
el.style.transition = ''
|
|
316
|
+
done()
|
|
317
|
+
})
|
|
318
|
+
} else {
|
|
319
|
+
onEnter(el, done)
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
function beforeEnter(el: HTMLElement) {
|
|
323
|
+
if (virtualizing.value) {
|
|
324
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
325
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
326
|
+
el.style.transition = 'none'
|
|
327
|
+
el.style.opacity = '1'
|
|
328
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
329
|
+
el.style.removeProperty('--masonry-opacity-delay')
|
|
330
|
+
} else {
|
|
331
|
+
onBeforeEnter(el)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function beforeLeave(el: HTMLElement) {
|
|
335
|
+
if (virtualizing.value) {
|
|
336
|
+
// no-op; removal will be immediate in leave
|
|
337
|
+
} else {
|
|
338
|
+
onBeforeLeave(el)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function leave(el: HTMLElement, done: () => void) {
|
|
342
|
+
if (virtualizing.value) {
|
|
343
|
+
// Skip animation during virtualization
|
|
344
|
+
done()
|
|
345
|
+
} else {
|
|
346
|
+
onLeave(el, done)
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Visible window of items (virtualization)
|
|
351
|
+
const visibleMasonry = computed(() => {
|
|
352
|
+
const top = viewportTop.value - VIRTUAL_BUFFER_PX
|
|
353
|
+
const bottom = viewportTop.value + viewportHeight.value + VIRTUAL_BUFFER_PX
|
|
354
|
+
const items = masonry.value as any[]
|
|
355
|
+
if (!items || items.length === 0) return [] as any[]
|
|
356
|
+
return items.filter((it: any) => {
|
|
357
|
+
const itemTop = it.top
|
|
358
|
+
const itemBottom = it.top + it.columnHeight
|
|
359
|
+
return itemBottom >= top && itemTop <= bottom
|
|
360
|
+
})
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
const { handleScroll } = useMasonryScroll({
|
|
364
|
+
container,
|
|
365
|
+
masonry: masonry as any,
|
|
366
|
+
columns,
|
|
367
|
+
containerHeight: masonryContentHeight,
|
|
368
|
+
isLoading,
|
|
369
|
+
pageSize: props.pageSize,
|
|
370
|
+
refreshLayout,
|
|
371
|
+
setItemsRaw: (items: any[]) => {
|
|
372
|
+
masonry.value = items
|
|
373
|
+
},
|
|
374
|
+
loadNext,
|
|
375
|
+
loadThresholdPx: props.loadThresholdPx
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
|
|
379
|
+
fixedDimensions.value = dimensions
|
|
380
|
+
if (dimensions) {
|
|
381
|
+
if (dimensions.width !== undefined) containerWidth.value = dimensions.width
|
|
382
|
+
if (dimensions.height !== undefined) containerHeight.value = dimensions.height
|
|
383
|
+
// Force layout refresh when dimensions change
|
|
384
|
+
if (!useSwipeMode.value && container.value && masonry.value.length > 0) {
|
|
385
|
+
nextTick(() => {
|
|
386
|
+
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
387
|
+
refreshLayout(masonry.value as any)
|
|
388
|
+
updateScrollProgress()
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
// When clearing fixed dimensions, restore from wrapper
|
|
393
|
+
if (wrapper.value) {
|
|
394
|
+
containerWidth.value = wrapper.value.clientWidth
|
|
395
|
+
containerHeight.value = wrapper.value.clientHeight
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
defineExpose({
|
|
401
|
+
isLoading,
|
|
402
|
+
refreshLayout,
|
|
403
|
+
// Container dimensions (wrapper element)
|
|
404
|
+
containerWidth,
|
|
405
|
+
containerHeight,
|
|
406
|
+
// Masonry content height (for backward compatibility, old containerHeight)
|
|
407
|
+
contentHeight: masonryContentHeight,
|
|
408
|
+
// Current page
|
|
409
|
+
currentPage,
|
|
410
|
+
// End of list tracking
|
|
411
|
+
hasReachedEnd,
|
|
412
|
+
// Load error tracking
|
|
413
|
+
loadError,
|
|
414
|
+
// Set fixed dimensions (overrides ResizeObserver)
|
|
415
|
+
setFixedDimensions,
|
|
416
|
+
remove,
|
|
417
|
+
removeMany,
|
|
418
|
+
removeAll,
|
|
419
|
+
loadNext,
|
|
420
|
+
loadPage,
|
|
421
|
+
refreshCurrentPage,
|
|
422
|
+
reset,
|
|
423
|
+
destroy,
|
|
424
|
+
init,
|
|
425
|
+
paginationHistory,
|
|
426
|
+
cancelLoad,
|
|
427
|
+
scrollToTop,
|
|
428
|
+
totalItems: computed(() => (masonry.value as any[]).length),
|
|
429
|
+
currentBreakpoint
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
function calculateHeight(content: any[]) {
|
|
433
|
+
const newHeight = calculateContainerHeight(content as any)
|
|
434
|
+
let floor = 0
|
|
435
|
+
if (container.value) {
|
|
436
|
+
const { scrollTop, clientHeight } = container.value
|
|
437
|
+
floor = scrollTop + clientHeight + 100
|
|
438
|
+
}
|
|
439
|
+
masonryContentHeight.value = Math.max(newHeight, floor)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function refreshLayout(items: any[]) {
|
|
443
|
+
if (useSwipeMode.value) {
|
|
444
|
+
// In swipe mode, no layout calculation needed - items are stacked vertically
|
|
445
|
+
masonry.value = items as any
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (!container.value) return
|
|
450
|
+
// Developer diagnostics: warn when dimensions are invalid
|
|
451
|
+
checkItemDimensions(items as any[], 'refreshLayout')
|
|
452
|
+
// Preserve original index before layout reordering
|
|
453
|
+
const itemsWithIndex = items.map((item, index) => ({
|
|
454
|
+
...item,
|
|
455
|
+
originalIndex: item.originalIndex ?? index
|
|
456
|
+
}))
|
|
457
|
+
|
|
458
|
+
// When fixed dimensions are set, ensure container uses the fixed width for layout
|
|
459
|
+
// This prevents gaps when the container's actual width differs from the fixed width
|
|
460
|
+
const containerEl = container.value as HTMLElement
|
|
461
|
+
if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
|
|
462
|
+
// Temporarily set width to match fixed dimensions for accurate layout calculation
|
|
463
|
+
const originalWidth = containerEl.style.width
|
|
464
|
+
const originalBoxSizing = containerEl.style.boxSizing
|
|
465
|
+
containerEl.style.boxSizing = 'border-box'
|
|
466
|
+
containerEl.style.width = `${fixedDimensions.value.width}px`
|
|
467
|
+
// Force reflow
|
|
468
|
+
containerEl.offsetWidth
|
|
469
|
+
|
|
470
|
+
const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
|
|
471
|
+
|
|
472
|
+
// Restore original width
|
|
473
|
+
containerEl.style.width = originalWidth
|
|
474
|
+
containerEl.style.boxSizing = originalBoxSizing
|
|
475
|
+
|
|
476
|
+
calculateHeight(content as any)
|
|
477
|
+
masonry.value = content
|
|
478
|
+
} else {
|
|
479
|
+
const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
|
|
480
|
+
calculateHeight(content as any)
|
|
481
|
+
masonry.value = content
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
|
|
486
|
+
return new Promise<void>((resolve) => {
|
|
487
|
+
const total = Math.max(0, totalMs | 0)
|
|
488
|
+
const start = Date.now()
|
|
489
|
+
onTick(total, total)
|
|
490
|
+
const id = setInterval(() => {
|
|
491
|
+
// Check for cancellation
|
|
492
|
+
if (cancelRequested.value) {
|
|
493
|
+
clearInterval(id)
|
|
494
|
+
resolve()
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
const elapsed = Date.now() - start
|
|
498
|
+
const remaining = Math.max(0, total - elapsed)
|
|
499
|
+
onTick(remaining, total)
|
|
500
|
+
if (remaining <= 0) {
|
|
501
|
+
clearInterval(id)
|
|
502
|
+
resolve()
|
|
503
|
+
}
|
|
504
|
+
}, 100)
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function getContent(page: number) {
|
|
509
|
+
try {
|
|
510
|
+
const response = await fetchWithRetry(() => props.getNextPage(page))
|
|
511
|
+
refreshLayout([...(masonry.value as any[]), ...response.items])
|
|
512
|
+
return response
|
|
513
|
+
} catch (error) {
|
|
514
|
+
console.error('Error in getContent:', error)
|
|
515
|
+
throw error
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
|
|
520
|
+
let attempt = 0
|
|
521
|
+
const max = props.retryMaxAttempts
|
|
522
|
+
let delay = props.retryInitialDelayMs
|
|
523
|
+
// eslint-disable-next-line no-constant-condition
|
|
524
|
+
while (true) {
|
|
525
|
+
try {
|
|
526
|
+
const res = await fn()
|
|
527
|
+
if (attempt > 0) {
|
|
528
|
+
emits('retry:stop', { attempt, success: true })
|
|
529
|
+
}
|
|
530
|
+
return res
|
|
531
|
+
} catch (err) {
|
|
532
|
+
attempt++
|
|
533
|
+
if (attempt > max) {
|
|
534
|
+
emits('retry:stop', { attempt: attempt - 1, success: false })
|
|
535
|
+
throw err
|
|
536
|
+
}
|
|
537
|
+
emits('retry:start', { attempt, max, totalMs: delay })
|
|
538
|
+
await waitWithProgress(delay, (remaining, total) => {
|
|
539
|
+
emits('retry:tick', { attempt, remainingMs: remaining, totalMs: total })
|
|
540
|
+
})
|
|
541
|
+
delay += props.retryBackoffStepMs
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function loadPage(page: number) {
|
|
547
|
+
if (isLoading.value) return
|
|
548
|
+
// Starting a new load should clear any previous cancel request
|
|
549
|
+
cancelRequested.value = false
|
|
550
|
+
isLoading.value = true
|
|
551
|
+
// Reset hasReachedEnd and loadError when loading a new page
|
|
552
|
+
hasReachedEnd.value = false
|
|
553
|
+
loadError.value = null
|
|
554
|
+
try {
|
|
555
|
+
const baseline = (masonry.value as any[]).length
|
|
556
|
+
if (cancelRequested.value) return
|
|
557
|
+
const response = await getContent(page)
|
|
558
|
+
if (cancelRequested.value) return
|
|
559
|
+
// Clear error on successful load
|
|
560
|
+
loadError.value = null
|
|
561
|
+
currentPage.value = page // Track the current page
|
|
562
|
+
paginationHistory.value.push(response.nextPage)
|
|
563
|
+
// Update hasReachedEnd if nextPage is null
|
|
564
|
+
if (response.nextPage == null) {
|
|
565
|
+
hasReachedEnd.value = true
|
|
566
|
+
}
|
|
567
|
+
await maybeBackfillToTarget(baseline)
|
|
568
|
+
return response
|
|
569
|
+
} catch (error) {
|
|
570
|
+
console.error('Error loading page:', error)
|
|
571
|
+
// Set load error
|
|
572
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
573
|
+
throw error
|
|
574
|
+
} finally {
|
|
575
|
+
isLoading.value = false
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async function loadNext() {
|
|
580
|
+
if (isLoading.value) return
|
|
581
|
+
// Don't load if we've already reached the end
|
|
582
|
+
if (hasReachedEnd.value) return
|
|
583
|
+
// Starting a new load should clear any previous cancel request
|
|
584
|
+
cancelRequested.value = false
|
|
585
|
+
isLoading.value = true
|
|
586
|
+
// Clear error when attempting to load
|
|
587
|
+
loadError.value = null
|
|
588
|
+
try {
|
|
589
|
+
const baseline = (masonry.value as any[]).length
|
|
590
|
+
if (cancelRequested.value) return
|
|
591
|
+
const nextPageToLoad = paginationHistory.value[paginationHistory.value.length - 1]
|
|
592
|
+
// Don't load if nextPageToLoad is null
|
|
593
|
+
if (nextPageToLoad == null) {
|
|
594
|
+
hasReachedEnd.value = true
|
|
595
|
+
isLoading.value = false
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
const response = await getContent(nextPageToLoad)
|
|
599
|
+
if (cancelRequested.value) return
|
|
600
|
+
// Clear error on successful load
|
|
601
|
+
loadError.value = null
|
|
602
|
+
currentPage.value = nextPageToLoad // Track the current page
|
|
603
|
+
paginationHistory.value.push(response.nextPage)
|
|
604
|
+
// Update hasReachedEnd if nextPage is null
|
|
605
|
+
if (response.nextPage == null) {
|
|
606
|
+
hasReachedEnd.value = true
|
|
607
|
+
}
|
|
608
|
+
await maybeBackfillToTarget(baseline)
|
|
609
|
+
return response
|
|
610
|
+
} catch (error) {
|
|
611
|
+
console.error('Error loading next page:', error)
|
|
612
|
+
// Set load error
|
|
613
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
614
|
+
throw error
|
|
615
|
+
} finally {
|
|
616
|
+
isLoading.value = false
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Refresh the current page by clearing items and reloading from current page
|
|
622
|
+
* Useful when items are removed and you want to stay on the same page
|
|
623
|
+
*/
|
|
624
|
+
async function refreshCurrentPage() {
|
|
625
|
+
if (isLoading.value) return
|
|
626
|
+
cancelRequested.value = false
|
|
627
|
+
isLoading.value = true
|
|
628
|
+
|
|
629
|
+
try {
|
|
630
|
+
// Use the tracked current page
|
|
631
|
+
const pageToRefresh = currentPage.value
|
|
632
|
+
|
|
633
|
+
if (pageToRefresh == null) {
|
|
634
|
+
console.warn('[Masonry] No current page to refresh - currentPage:', currentPage.value, 'paginationHistory:', paginationHistory.value)
|
|
635
|
+
return
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Clear existing items
|
|
639
|
+
masonry.value = []
|
|
640
|
+
masonryContentHeight.value = 0
|
|
641
|
+
hasReachedEnd.value = false // Reset end flag when refreshing
|
|
642
|
+
loadError.value = null // Reset error flag when refreshing
|
|
643
|
+
|
|
644
|
+
// Reset pagination history to just the current page
|
|
645
|
+
paginationHistory.value = [pageToRefresh]
|
|
646
|
+
|
|
647
|
+
await nextTick()
|
|
648
|
+
|
|
649
|
+
// Reload the current page
|
|
650
|
+
const response = await getContent(pageToRefresh)
|
|
651
|
+
if (cancelRequested.value) return
|
|
652
|
+
|
|
653
|
+
// Clear error on successful load
|
|
654
|
+
loadError.value = null
|
|
655
|
+
// Update pagination state
|
|
656
|
+
currentPage.value = pageToRefresh
|
|
657
|
+
paginationHistory.value.push(response.nextPage)
|
|
658
|
+
// Update hasReachedEnd if nextPage is null
|
|
659
|
+
if (response.nextPage == null) {
|
|
660
|
+
hasReachedEnd.value = true
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Optionally backfill if needed
|
|
664
|
+
const baseline = (masonry.value as any[]).length
|
|
665
|
+
await maybeBackfillToTarget(baseline)
|
|
666
|
+
|
|
667
|
+
return response
|
|
668
|
+
} catch (error) {
|
|
669
|
+
console.error('[Masonry] Error refreshing current page:', error)
|
|
670
|
+
// Set load error
|
|
671
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
672
|
+
throw error
|
|
673
|
+
} finally {
|
|
674
|
+
isLoading.value = false
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function remove(item: any) {
|
|
679
|
+
const next = (masonry.value as any[]).filter(i => i.id !== item.id)
|
|
680
|
+
masonry.value = next
|
|
681
|
+
await nextTick()
|
|
682
|
+
|
|
683
|
+
// If all items were removed, either refresh current page or load next based on prop
|
|
684
|
+
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
685
|
+
if (props.autoRefreshOnEmpty) {
|
|
686
|
+
await refreshCurrentPage()
|
|
687
|
+
} else {
|
|
688
|
+
try {
|
|
689
|
+
await loadNext()
|
|
690
|
+
// Force backfill from 0 to ensure viewport is filled
|
|
691
|
+
// Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
|
|
692
|
+
await maybeBackfillToTarget(0, true)
|
|
693
|
+
} catch { }
|
|
694
|
+
}
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Commit DOM updates without forcing sync reflow
|
|
699
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
700
|
+
// Start FLIP on next frame
|
|
701
|
+
requestAnimationFrame(() => {
|
|
702
|
+
refreshLayout(next)
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function removeMany(items: any[]) {
|
|
707
|
+
if (!items || items.length === 0) return
|
|
708
|
+
const ids = new Set(items.map(i => i.id))
|
|
709
|
+
const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
|
|
710
|
+
masonry.value = next
|
|
711
|
+
await nextTick()
|
|
712
|
+
|
|
713
|
+
// If all items were removed, either refresh current page or load next based on prop
|
|
714
|
+
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
715
|
+
if (props.autoRefreshOnEmpty) {
|
|
716
|
+
await refreshCurrentPage()
|
|
717
|
+
} else {
|
|
718
|
+
try {
|
|
719
|
+
await loadNext()
|
|
720
|
+
// Force backfill from 0 to ensure viewport is filled
|
|
721
|
+
await maybeBackfillToTarget(0, true)
|
|
722
|
+
} catch { }
|
|
723
|
+
}
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// Commit DOM updates without forcing sync reflow
|
|
728
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
729
|
+
// Start FLIP on next frame
|
|
730
|
+
requestAnimationFrame(() => {
|
|
731
|
+
refreshLayout(next)
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function scrollToTop(options?: ScrollToOptions) {
|
|
736
|
+
if (container.value) {
|
|
737
|
+
container.value.scrollTo({
|
|
738
|
+
top: 0,
|
|
739
|
+
behavior: options?.behavior ?? 'smooth',
|
|
740
|
+
...options
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function removeAll() {
|
|
746
|
+
// Scroll to top first for better UX
|
|
747
|
+
scrollToTop({ behavior: 'smooth' })
|
|
748
|
+
|
|
749
|
+
// Clear all items
|
|
750
|
+
masonry.value = []
|
|
751
|
+
|
|
752
|
+
// Recalculate height to 0
|
|
753
|
+
containerHeight.value = 0
|
|
754
|
+
|
|
755
|
+
await nextTick()
|
|
756
|
+
|
|
757
|
+
// Emit completion event
|
|
758
|
+
emits('remove-all:complete')
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function onResize() {
|
|
762
|
+
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
763
|
+
refreshLayout(masonry.value as any)
|
|
764
|
+
if (container.value) {
|
|
765
|
+
viewportTop.value = container.value.scrollTop
|
|
766
|
+
viewportHeight.value = container.value.clientHeight
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
let backfillActive = false
|
|
771
|
+
const cancelRequested = ref(false)
|
|
772
|
+
|
|
773
|
+
async function maybeBackfillToTarget(baselineCount: number, force = false) {
|
|
774
|
+
if (!force && !props.backfillEnabled) return
|
|
775
|
+
if (backfillActive) return
|
|
776
|
+
if (cancelRequested.value) return
|
|
777
|
+
// Don't backfill if we've reached the end
|
|
778
|
+
if (hasReachedEnd.value) return
|
|
779
|
+
|
|
780
|
+
const targetCount = (baselineCount || 0) + (props.pageSize || 0)
|
|
781
|
+
if (!props.pageSize || props.pageSize <= 0) return
|
|
782
|
+
|
|
783
|
+
const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
|
|
784
|
+
if (lastNext == null) {
|
|
785
|
+
hasReachedEnd.value = true
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if ((masonry.value as any[]).length >= targetCount) return
|
|
790
|
+
|
|
791
|
+
backfillActive = true
|
|
792
|
+
try {
|
|
793
|
+
let calls = 0
|
|
794
|
+
emits('backfill:start', { target: targetCount, fetched: (masonry.value as any[]).length, calls })
|
|
795
|
+
|
|
796
|
+
while (
|
|
797
|
+
(masonry.value as any[]).length < targetCount &&
|
|
798
|
+
calls < props.backfillMaxCalls &&
|
|
799
|
+
paginationHistory.value[paginationHistory.value.length - 1] != null &&
|
|
800
|
+
!cancelRequested.value &&
|
|
801
|
+
!hasReachedEnd.value
|
|
802
|
+
) {
|
|
803
|
+
await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
|
|
804
|
+
emits('backfill:tick', {
|
|
805
|
+
fetched: (masonry.value as any[]).length,
|
|
806
|
+
target: targetCount,
|
|
807
|
+
calls,
|
|
808
|
+
remainingMs: remaining,
|
|
809
|
+
totalMs: total
|
|
810
|
+
})
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
if (cancelRequested.value) break
|
|
814
|
+
|
|
815
|
+
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
816
|
+
if (currentPage == null) {
|
|
817
|
+
hasReachedEnd.value = true
|
|
818
|
+
break
|
|
819
|
+
}
|
|
820
|
+
try {
|
|
821
|
+
isLoading.value = true
|
|
822
|
+
const response = await getContent(currentPage)
|
|
823
|
+
if (cancelRequested.value) break
|
|
824
|
+
// Clear error on successful load
|
|
825
|
+
loadError.value = null
|
|
826
|
+
paginationHistory.value.push(response.nextPage)
|
|
827
|
+
// Update hasReachedEnd if nextPage is null
|
|
828
|
+
if (response.nextPage == null) {
|
|
829
|
+
hasReachedEnd.value = true
|
|
830
|
+
}
|
|
831
|
+
} catch (error) {
|
|
832
|
+
// Set load error but don't break the backfill loop
|
|
833
|
+
loadError.value = error instanceof Error ? error : new Error(String(error))
|
|
834
|
+
} finally {
|
|
835
|
+
isLoading.value = false
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
calls++
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
emits('backfill:stop', { fetched: (masonry.value as any[]).length, calls })
|
|
842
|
+
} finally {
|
|
843
|
+
backfillActive = false
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function cancelLoad() {
|
|
848
|
+
cancelRequested.value = true
|
|
849
|
+
isLoading.value = false
|
|
850
|
+
backfillActive = false
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function reset() {
|
|
854
|
+
// Cancel ongoing work, then immediately clear cancel so new loads can start
|
|
855
|
+
cancelLoad()
|
|
856
|
+
cancelRequested.value = false
|
|
857
|
+
if (container.value) {
|
|
858
|
+
container.value.scrollTo({
|
|
859
|
+
top: 0,
|
|
860
|
+
behavior: 'smooth'
|
|
861
|
+
})
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
masonry.value = []
|
|
865
|
+
containerHeight.value = 0
|
|
866
|
+
currentPage.value = props.loadAtPage // Reset current page tracking
|
|
867
|
+
paginationHistory.value = [props.loadAtPage]
|
|
868
|
+
hasReachedEnd.value = false // Reset end flag
|
|
869
|
+
loadError.value = null // Reset error flag
|
|
870
|
+
|
|
871
|
+
scrollProgress.value = {
|
|
872
|
+
distanceToTrigger: 0,
|
|
873
|
+
isNearTrigger: false
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function destroy() {
|
|
878
|
+
// Cancel any ongoing loads
|
|
879
|
+
cancelLoad()
|
|
880
|
+
|
|
881
|
+
// Reset all state
|
|
882
|
+
masonry.value = []
|
|
883
|
+
masonryContentHeight.value = 0
|
|
884
|
+
currentPage.value = null
|
|
885
|
+
paginationHistory.value = []
|
|
886
|
+
hasReachedEnd.value = false
|
|
887
|
+
loadError.value = null
|
|
888
|
+
isLoading.value = false
|
|
889
|
+
backfillActive = false
|
|
890
|
+
cancelRequested.value = false
|
|
891
|
+
|
|
892
|
+
// Reset swipe mode state
|
|
893
|
+
currentSwipeIndex.value = 0
|
|
894
|
+
swipeOffset.value = 0
|
|
895
|
+
isDragging.value = false
|
|
896
|
+
|
|
897
|
+
// Reset viewport state
|
|
898
|
+
viewportTop.value = 0
|
|
899
|
+
viewportHeight.value = 0
|
|
900
|
+
virtualizing.value = false
|
|
901
|
+
|
|
902
|
+
// Reset scroll progress
|
|
903
|
+
scrollProgress.value = {
|
|
904
|
+
distanceToTrigger: 0,
|
|
905
|
+
isNearTrigger: false
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Reset invalid dimension tracking
|
|
909
|
+
invalidDimensionIds.value.clear()
|
|
910
|
+
|
|
911
|
+
// Scroll to top if container exists
|
|
912
|
+
if (container.value) {
|
|
913
|
+
container.value.scrollTo({
|
|
914
|
+
top: 0,
|
|
915
|
+
behavior: 'auto' // Instant scroll for destroy
|
|
916
|
+
})
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const debouncedScrollHandler = debounce(async () => {
|
|
921
|
+
if (useSwipeMode.value) return // Skip scroll handling in swipe mode
|
|
922
|
+
|
|
923
|
+
if (container.value) {
|
|
924
|
+
viewportTop.value = container.value.scrollTop
|
|
925
|
+
viewportHeight.value = container.value.clientHeight
|
|
926
|
+
}
|
|
927
|
+
// Gate transitions for virtualization-only DOM changes
|
|
928
|
+
virtualizing.value = true
|
|
929
|
+
await nextTick()
|
|
930
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
931
|
+
virtualizing.value = false
|
|
932
|
+
|
|
933
|
+
const heights = calculateColumnHeights(masonry.value as any, columns.value)
|
|
934
|
+
handleScroll(heights as any)
|
|
935
|
+
updateScrollProgress(heights)
|
|
936
|
+
}, 200)
|
|
937
|
+
|
|
938
|
+
const debouncedResizeHandler = debounce(onResize, 200)
|
|
939
|
+
|
|
940
|
+
// Swipe gesture handlers
|
|
941
|
+
function handleTouchStart(e: TouchEvent) {
|
|
942
|
+
if (!useSwipeMode.value) return
|
|
943
|
+
isDragging.value = true
|
|
944
|
+
dragStartY.value = e.touches[0].clientY
|
|
945
|
+
dragStartOffset.value = swipeOffset.value
|
|
946
|
+
e.preventDefault()
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
function handleTouchMove(e: TouchEvent) {
|
|
950
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
951
|
+
const deltaY = e.touches[0].clientY - dragStartY.value
|
|
952
|
+
swipeOffset.value = dragStartOffset.value + deltaY
|
|
953
|
+
e.preventDefault()
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function handleTouchEnd(e: TouchEvent) {
|
|
957
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
958
|
+
isDragging.value = false
|
|
959
|
+
|
|
960
|
+
const deltaY = swipeOffset.value - dragStartOffset.value
|
|
961
|
+
const threshold = 100 // Minimum swipe distance to trigger navigation
|
|
962
|
+
|
|
963
|
+
if (Math.abs(deltaY) > threshold) {
|
|
964
|
+
if (deltaY > 0 && previousItem.value) {
|
|
965
|
+
// Swipe down - go to previous
|
|
966
|
+
goToPreviousItem()
|
|
967
|
+
} else if (deltaY < 0 && nextItem.value) {
|
|
968
|
+
// Swipe up - go to next
|
|
969
|
+
goToNextItem()
|
|
970
|
+
} else {
|
|
971
|
+
// Snap back
|
|
972
|
+
snapToCurrentItem()
|
|
973
|
+
}
|
|
974
|
+
} else {
|
|
975
|
+
// Snap back if swipe wasn't far enough
|
|
976
|
+
snapToCurrentItem()
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
e.preventDefault()
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
// Mouse drag handlers for desktop testing
|
|
983
|
+
function handleMouseDown(e: MouseEvent) {
|
|
984
|
+
if (!useSwipeMode.value) return
|
|
985
|
+
isDragging.value = true
|
|
986
|
+
dragStartY.value = e.clientY
|
|
987
|
+
dragStartOffset.value = swipeOffset.value
|
|
988
|
+
e.preventDefault()
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function handleMouseMove(e: MouseEvent) {
|
|
992
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
993
|
+
const deltaY = e.clientY - dragStartY.value
|
|
994
|
+
swipeOffset.value = dragStartOffset.value + deltaY
|
|
995
|
+
e.preventDefault()
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function handleMouseUp(e: MouseEvent) {
|
|
999
|
+
if (!useSwipeMode.value || !isDragging.value) return
|
|
1000
|
+
isDragging.value = false
|
|
1001
|
+
|
|
1002
|
+
const deltaY = swipeOffset.value - dragStartOffset.value
|
|
1003
|
+
const threshold = 100
|
|
1004
|
+
|
|
1005
|
+
if (Math.abs(deltaY) > threshold) {
|
|
1006
|
+
if (deltaY > 0 && previousItem.value) {
|
|
1007
|
+
goToPreviousItem()
|
|
1008
|
+
} else if (deltaY < 0 && nextItem.value) {
|
|
1009
|
+
goToNextItem()
|
|
1010
|
+
} else {
|
|
1011
|
+
snapToCurrentItem()
|
|
1012
|
+
}
|
|
1013
|
+
} else {
|
|
1014
|
+
snapToCurrentItem()
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
e.preventDefault()
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
function goToNextItem() {
|
|
1021
|
+
if (!nextItem.value) {
|
|
1022
|
+
// Try to load next page
|
|
1023
|
+
loadNext()
|
|
1024
|
+
return
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
currentSwipeIndex.value++
|
|
1028
|
+
snapToCurrentItem()
|
|
1029
|
+
|
|
1030
|
+
// Preload next item if we're near the end
|
|
1031
|
+
if (currentSwipeIndex.value >= masonry.value.length - 5) {
|
|
1032
|
+
loadNext()
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function goToPreviousItem() {
|
|
1037
|
+
if (!previousItem.value) return
|
|
1038
|
+
|
|
1039
|
+
currentSwipeIndex.value--
|
|
1040
|
+
snapToCurrentItem()
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function snapToCurrentItem() {
|
|
1044
|
+
if (!swipeContainer.value) return
|
|
1045
|
+
|
|
1046
|
+
// Use container height for swipe mode instead of window height
|
|
1047
|
+
const viewportHeight = swipeContainer.value.clientHeight
|
|
1048
|
+
swipeOffset.value = -currentSwipeIndex.value * viewportHeight
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Watch for container/window resize to update swipe mode
|
|
1052
|
+
// Note: containerWidth is updated by ResizeObserver, not here
|
|
1053
|
+
function handleWindowResize() {
|
|
1054
|
+
// If switching from swipe to masonry, reset swipe state
|
|
1055
|
+
if (!useSwipeMode.value && currentSwipeIndex.value > 0) {
|
|
1056
|
+
currentSwipeIndex.value = 0
|
|
1057
|
+
swipeOffset.value = 0
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// If switching to swipe mode, ensure we have items loaded
|
|
1061
|
+
if (useSwipeMode.value && masonry.value.length === 0 && !isLoading.value) {
|
|
1062
|
+
loadPage(paginationHistory.value[0] as any)
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Re-snap to current item on resize to adjust offset
|
|
1066
|
+
if (useSwipeMode.value) {
|
|
1067
|
+
snapToCurrentItem()
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function init(items: any[], page: any, next: any) {
|
|
1072
|
+
currentPage.value = page // Track the initial current page
|
|
1073
|
+
paginationHistory.value = [page]
|
|
1074
|
+
paginationHistory.value.push(next)
|
|
1075
|
+
// Update hasReachedEnd if next is null
|
|
1076
|
+
hasReachedEnd.value = next == null
|
|
1077
|
+
// Diagnostics: check incoming initial items
|
|
1078
|
+
checkItemDimensions(items as any[], 'init')
|
|
1079
|
+
|
|
1080
|
+
if (useSwipeMode.value) {
|
|
1081
|
+
// In swipe mode, just add items without layout calculation
|
|
1082
|
+
masonry.value = [...(masonry.value as any[]), ...items]
|
|
1083
|
+
// Reset swipe index if we're at the start
|
|
1084
|
+
if (currentSwipeIndex.value === 0 && masonry.value.length > 0) {
|
|
1085
|
+
swipeOffset.value = 0
|
|
1086
|
+
}
|
|
1087
|
+
} else {
|
|
1088
|
+
refreshLayout([...(masonry.value as any[]), ...items])
|
|
1089
|
+
updateScrollProgress()
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// Watch for layout changes and update columns + refresh layout dynamically
|
|
1094
|
+
watch(
|
|
1095
|
+
layout,
|
|
1096
|
+
() => {
|
|
1097
|
+
if (useSwipeMode.value) {
|
|
1098
|
+
// In swipe mode, no layout recalculation needed
|
|
1099
|
+
return
|
|
1100
|
+
}
|
|
1101
|
+
if (container.value) {
|
|
1102
|
+
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
1103
|
+
refreshLayout(masonry.value as any)
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
{ deep: true }
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
// Watch for layout-mode prop changes to ensure proper mode switching
|
|
1110
|
+
watch(() => props.layoutMode, () => {
|
|
1111
|
+
// Force update containerWidth when layout-mode changes to ensure useSwipeMode computes correctly
|
|
1112
|
+
if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
|
|
1113
|
+
containerWidth.value = fixedDimensions.value.width
|
|
1114
|
+
} else if (wrapper.value) {
|
|
1115
|
+
containerWidth.value = wrapper.value.clientWidth
|
|
1116
|
+
}
|
|
1117
|
+
})
|
|
1118
|
+
|
|
1119
|
+
// Watch container element to attach scroll listener when available
|
|
1120
|
+
watch(container, (el) => {
|
|
1121
|
+
if (el && !useSwipeMode.value) {
|
|
1122
|
+
// Attach scroll listener for masonry mode
|
|
1123
|
+
el.removeEventListener('scroll', debouncedScrollHandler) // Just in case
|
|
1124
|
+
el.addEventListener('scroll', debouncedScrollHandler, { passive: true })
|
|
1125
|
+
} else if (el) {
|
|
1126
|
+
// Remove scroll listener if switching to swipe mode
|
|
1127
|
+
el.removeEventListener('scroll', debouncedScrollHandler)
|
|
1128
|
+
}
|
|
1129
|
+
}, { immediate: true })
|
|
1130
|
+
|
|
1131
|
+
// Watch for swipe mode changes to refresh layout and setup/teardown handlers
|
|
1132
|
+
watch(useSwipeMode, (newValue, oldValue) => {
|
|
1133
|
+
// Skip if this is the initial watch call and values are the same
|
|
1134
|
+
if (oldValue === undefined && newValue === false) return
|
|
1135
|
+
|
|
1136
|
+
nextTick(() => {
|
|
1137
|
+
if (newValue) {
|
|
1138
|
+
// Switching to Swipe Mode
|
|
1139
|
+
document.addEventListener('mousemove', handleMouseMove)
|
|
1140
|
+
document.addEventListener('mouseup', handleMouseUp)
|
|
1141
|
+
|
|
1142
|
+
// Remove scroll listener
|
|
1143
|
+
if (container.value) {
|
|
1144
|
+
container.value.removeEventListener('scroll', debouncedScrollHandler)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Reset index if needed
|
|
1148
|
+
currentSwipeIndex.value = 0
|
|
1149
|
+
swipeOffset.value = 0
|
|
1150
|
+
if (masonry.value.length > 0) {
|
|
1151
|
+
snapToCurrentItem()
|
|
1152
|
+
}
|
|
1153
|
+
} else {
|
|
1154
|
+
// Switching to Masonry Mode
|
|
1155
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
1156
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
1157
|
+
|
|
1158
|
+
if (container.value && wrapper.value) {
|
|
1159
|
+
// Ensure containerWidth is up to date - use fixed dimensions if set
|
|
1160
|
+
if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
|
|
1161
|
+
containerWidth.value = fixedDimensions.value.width
|
|
1162
|
+
} else {
|
|
1163
|
+
containerWidth.value = wrapper.value.clientWidth
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// Attach scroll listener (container watcher will handle this, but ensure it's attached)
|
|
1167
|
+
container.value.removeEventListener('scroll', debouncedScrollHandler) // Just in case
|
|
1168
|
+
container.value.addEventListener('scroll', debouncedScrollHandler, { passive: true })
|
|
1169
|
+
|
|
1170
|
+
// Refresh layout with updated width
|
|
1171
|
+
if (masonry.value.length > 0) {
|
|
1172
|
+
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
1173
|
+
refreshLayout(masonry.value as any)
|
|
1174
|
+
|
|
1175
|
+
// Update viewport state
|
|
1176
|
+
viewportTop.value = container.value.scrollTop
|
|
1177
|
+
viewportHeight.value = container.value.clientHeight
|
|
1178
|
+
updateScrollProgress()
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
})
|
|
1183
|
+
}, { immediate: true })
|
|
1184
|
+
|
|
1185
|
+
// Watch for swipe container element to attach touch listeners
|
|
1186
|
+
watch(swipeContainer, (el) => {
|
|
1187
|
+
if (el) {
|
|
1188
|
+
el.addEventListener('touchstart', handleTouchStart, { passive: false })
|
|
1189
|
+
el.addEventListener('touchmove', handleTouchMove, { passive: false })
|
|
1190
|
+
el.addEventListener('touchend', handleTouchEnd)
|
|
1191
|
+
el.addEventListener('mousedown', handleMouseDown)
|
|
1192
|
+
}
|
|
1193
|
+
})
|
|
1194
|
+
|
|
1195
|
+
// Watch for items changes in swipe mode to reset index if needed
|
|
1196
|
+
watch(() => masonry.value.length, (newLength, oldLength) => {
|
|
1197
|
+
if (useSwipeMode.value && newLength > 0 && oldLength === 0) {
|
|
1198
|
+
// First items loaded, ensure we're at index 0
|
|
1199
|
+
currentSwipeIndex.value = 0
|
|
1200
|
+
nextTick(() => snapToCurrentItem())
|
|
1201
|
+
}
|
|
1202
|
+
})
|
|
1203
|
+
|
|
1204
|
+
// Watch wrapper element to setup ResizeObserver for container width
|
|
1205
|
+
watch(wrapper, (el) => {
|
|
1206
|
+
if (resizeObserver) {
|
|
1207
|
+
resizeObserver.disconnect()
|
|
1208
|
+
resizeObserver = null
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
if (el && typeof ResizeObserver !== 'undefined') {
|
|
1212
|
+
resizeObserver = new ResizeObserver((entries) => {
|
|
1213
|
+
// Skip updates if fixed dimensions are set
|
|
1214
|
+
if (fixedDimensions.value) return
|
|
1215
|
+
|
|
1216
|
+
for (const entry of entries) {
|
|
1217
|
+
const newWidth = entry.contentRect.width
|
|
1218
|
+
const newHeight = entry.contentRect.height
|
|
1219
|
+
if (containerWidth.value !== newWidth) {
|
|
1220
|
+
containerWidth.value = newWidth
|
|
1221
|
+
}
|
|
1222
|
+
if (containerHeight.value !== newHeight) {
|
|
1223
|
+
containerHeight.value = newHeight
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
})
|
|
1227
|
+
resizeObserver.observe(el)
|
|
1228
|
+
// Initial dimensions (only if not fixed)
|
|
1229
|
+
if (!fixedDimensions.value) {
|
|
1230
|
+
containerWidth.value = el.clientWidth
|
|
1231
|
+
containerHeight.value = el.clientHeight
|
|
1232
|
+
}
|
|
1233
|
+
} else if (el) {
|
|
1234
|
+
// Fallback if ResizeObserver not available
|
|
1235
|
+
if (!fixedDimensions.value) {
|
|
1236
|
+
containerWidth.value = el.clientWidth
|
|
1237
|
+
containerHeight.value = el.clientHeight
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}, { immediate: true })
|
|
1241
|
+
|
|
1242
|
+
// Watch containerWidth changes to refresh layout in masonry mode
|
|
1243
|
+
watch(containerWidth, (newWidth, oldWidth) => {
|
|
1244
|
+
if (newWidth !== oldWidth && newWidth > 0 && !useSwipeMode.value && container.value && masonry.value.length > 0) {
|
|
1245
|
+
// Use nextTick to ensure DOM has updated
|
|
1246
|
+
nextTick(() => {
|
|
1247
|
+
columns.value = getColumnCount(layout.value as any, newWidth)
|
|
1248
|
+
refreshLayout(masonry.value as any)
|
|
1249
|
+
updateScrollProgress()
|
|
1250
|
+
})
|
|
1251
|
+
}
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
onMounted(async () => {
|
|
1255
|
+
try {
|
|
1256
|
+
// Wait for next tick to ensure wrapper is mounted
|
|
1257
|
+
await nextTick()
|
|
1258
|
+
|
|
1259
|
+
// Container dimensions are managed by ResizeObserver
|
|
1260
|
+
// Only set initial values if ResizeObserver isn't available
|
|
1261
|
+
if (wrapper.value && !resizeObserver) {
|
|
1262
|
+
containerWidth.value = wrapper.value.clientWidth
|
|
1263
|
+
containerHeight.value = wrapper.value.clientHeight
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
if (!useSwipeMode.value) {
|
|
1267
|
+
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
1268
|
+
if (container.value) {
|
|
1269
|
+
viewportTop.value = container.value.scrollTop
|
|
1270
|
+
viewportHeight.value = container.value.clientHeight
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const initialPage = props.loadAtPage as any
|
|
1275
|
+
paginationHistory.value = [initialPage]
|
|
1276
|
+
|
|
1277
|
+
if (!props.skipInitialLoad) {
|
|
1278
|
+
await loadPage(paginationHistory.value[0] as any)
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
if (!useSwipeMode.value) {
|
|
1282
|
+
updateScrollProgress()
|
|
1283
|
+
} else {
|
|
1284
|
+
// In swipe mode, snap to first item
|
|
1285
|
+
nextTick(() => snapToCurrentItem())
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
} catch (error) {
|
|
1289
|
+
console.error('Error during component initialization:', error)
|
|
1290
|
+
isLoading.value = false
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Scroll listener is handled by watcher now for consistency
|
|
1294
|
+
window.addEventListener('resize', debouncedResizeHandler)
|
|
1295
|
+
window.addEventListener('resize', handleWindowResize)
|
|
1296
|
+
})
|
|
1297
|
+
|
|
1298
|
+
onUnmounted(() => {
|
|
1299
|
+
if (resizeObserver) {
|
|
1300
|
+
resizeObserver.disconnect()
|
|
1301
|
+
resizeObserver = null
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
container.value?.removeEventListener('scroll', debouncedScrollHandler)
|
|
1305
|
+
window.removeEventListener('resize', debouncedResizeHandler)
|
|
1306
|
+
window.removeEventListener('resize', handleWindowResize)
|
|
1307
|
+
|
|
1308
|
+
if (swipeContainer.value) {
|
|
1309
|
+
swipeContainer.value.removeEventListener('touchstart', handleTouchStart)
|
|
1310
|
+
swipeContainer.value.removeEventListener('touchmove', handleTouchMove)
|
|
1311
|
+
swipeContainer.value.removeEventListener('touchend', handleTouchEnd)
|
|
1312
|
+
swipeContainer.value.removeEventListener('mousedown', handleMouseDown)
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// Clean up mouse handlers
|
|
1316
|
+
document.removeEventListener('mousemove', handleMouseMove)
|
|
1317
|
+
document.removeEventListener('mouseup', handleMouseUp)
|
|
1318
|
+
})
|
|
1319
|
+
</script>
|
|
1320
|
+
|
|
1321
|
+
<template>
|
|
1322
|
+
<div ref="wrapper" class="w-full h-full flex flex-col relative">
|
|
1323
|
+
<!-- Swipe Feed Mode (Mobile/Tablet) -->
|
|
1324
|
+
<div v-if="useSwipeMode" class="overflow-hidden w-full flex-1 swipe-container touch-none select-none"
|
|
1325
|
+
:class="{ 'force-motion': props.forceMotion, 'cursor-grab': !isDragging, 'cursor-grabbing': isDragging }"
|
|
1326
|
+
ref="swipeContainer" style="height: 100%; max-height: 100%; position: relative;">
|
|
1327
|
+
<div class="relative w-full" :style="{
|
|
1328
|
+
transform: `translateY(${swipeOffset}px)`,
|
|
1329
|
+
transition: isDragging ? 'none' : `transform ${transitionDurationMs}ms ${transitionEasing}`,
|
|
1330
|
+
height: `${masonry.length * 100}%`
|
|
1331
|
+
}">
|
|
1332
|
+
<div v-for="(item, index) in masonry" :key="`${item.page}-${item.id}`" class="absolute top-0 left-0 w-full"
|
|
1333
|
+
:style="{
|
|
1334
|
+
top: `${index * (100 / masonry.length)}%`,
|
|
1335
|
+
height: `${100 / masonry.length}%`
|
|
1336
|
+
}">
|
|
1337
|
+
<div class="w-full h-full flex items-center justify-center p-4">
|
|
1338
|
+
<div class="w-full h-full max-w-full max-h-full relative">
|
|
1339
|
+
<slot :item="item" :remove="remove">
|
|
1340
|
+
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1341
|
+
:in-swipe-mode="true" :is-active="index === currentSwipeIndex"
|
|
1342
|
+
@preload:success="(p) => emits('item:preload:success', p)"
|
|
1343
|
+
@preload:error="(p) => emits('item:preload:error', p)"
|
|
1344
|
+
@mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
|
|
1345
|
+
<!-- Pass through header and footer slots to MasonryItem -->
|
|
1346
|
+
<template #header="slotProps">
|
|
1347
|
+
<slot name="item-header" v-bind="slotProps" />
|
|
1348
|
+
</template>
|
|
1349
|
+
<template #footer="slotProps">
|
|
1350
|
+
<slot name="item-footer" v-bind="slotProps" />
|
|
1351
|
+
</template>
|
|
1352
|
+
</MasonryItem>
|
|
1353
|
+
</slot>
|
|
1354
|
+
</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
</div>
|
|
1357
|
+
</div>
|
|
1358
|
+
<!-- End of list message for swipe mode -->
|
|
1359
|
+
<div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
|
|
1360
|
+
<slot name="end-message">
|
|
1361
|
+
<p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
|
|
1362
|
+
</slot>
|
|
1363
|
+
</div>
|
|
1364
|
+
<!-- Error message for swipe mode -->
|
|
1365
|
+
<div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
|
|
1366
|
+
<slot name="error-message" :error="loadError">
|
|
1367
|
+
<p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
|
|
1368
|
+
</slot>
|
|
1369
|
+
</div>
|
|
1370
|
+
</div>
|
|
1371
|
+
|
|
1372
|
+
<!-- Masonry Grid Mode (Desktop) -->
|
|
1373
|
+
<div v-else class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }"
|
|
1374
|
+
ref="container">
|
|
1375
|
+
<div class="relative"
|
|
1376
|
+
:style="{ height: `${masonryContentHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing }">
|
|
1377
|
+
<transition-group name="masonry" :css="false" @enter="enter" @before-enter="beforeEnter" @leave="leave"
|
|
1378
|
+
@before-leave="beforeLeave">
|
|
1379
|
+
<div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
|
|
1380
|
+
v-bind="getItemAttributes(item, i)">
|
|
1381
|
+
<!-- Use default slot if provided, otherwise use MasonryItem -->
|
|
1382
|
+
<slot :item="item" :remove="remove">
|
|
1383
|
+
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1384
|
+
:in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
|
|
1385
|
+
@preload:error="(p) => emits('item:preload:error', p)"
|
|
1386
|
+
@mouse-enter="(p) => emits('item:mouse-enter', p)" @mouse-leave="(p) => emits('item:mouse-leave', p)">
|
|
1387
|
+
<!-- Pass through header and footer slots to MasonryItem -->
|
|
1388
|
+
<template #header="slotProps">
|
|
1389
|
+
<slot name="item-header" v-bind="slotProps" />
|
|
1390
|
+
</template>
|
|
1391
|
+
<template #footer="slotProps">
|
|
1392
|
+
<slot name="item-footer" v-bind="slotProps" />
|
|
1393
|
+
</template>
|
|
1394
|
+
</MasonryItem>
|
|
1395
|
+
</slot>
|
|
1396
|
+
</div>
|
|
1397
|
+
</transition-group>
|
|
1398
|
+
</div>
|
|
1399
|
+
<!-- End of list message -->
|
|
1400
|
+
<div v-if="hasReachedEnd && masonry.length > 0" class="w-full py-8 text-center">
|
|
1401
|
+
<slot name="end-message">
|
|
1402
|
+
<p class="text-gray-500 dark:text-gray-400">You've reached the end</p>
|
|
1403
|
+
</slot>
|
|
1404
|
+
</div>
|
|
1405
|
+
<!-- Error message -->
|
|
1406
|
+
<div v-if="loadError && masonry.length > 0" class="w-full py-8 text-center">
|
|
1407
|
+
<slot name="error-message" :error="loadError">
|
|
1408
|
+
<p class="text-red-500 dark:text-red-400">Failed to load content: {{ loadError.message }}</p>
|
|
1409
|
+
</slot>
|
|
1410
|
+
</div>
|
|
1411
|
+
</div>
|
|
1412
|
+
</div>
|
|
1413
|
+
</template>
|
|
1414
|
+
|
|
1415
|
+
<style scoped>
|
|
1416
|
+
.masonry-container {
|
|
1417
|
+
overflow-anchor: none;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
.masonry-item {
|
|
1421
|
+
will-change: transform, opacity;
|
|
1422
|
+
contain: layout paint;
|
|
1423
|
+
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
|
|
1424
|
+
opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
|
|
1425
|
+
backface-visibility: hidden;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
.masonry-move {
|
|
1429
|
+
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1433
|
+
|
|
1434
|
+
.masonry-container:not(.force-motion) .masonry-item,
|
|
1435
|
+
.masonry-container:not(.force-motion) .masonry-move {
|
|
1436
|
+
transition-duration: 1ms !important;
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
</style>
|