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