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