@wyxos/vibe 1.6.22 → 1.6.24
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 +1253 -1082
- package/lib/vibe.css +1 -1
- package/package.json +1 -1
- package/src/Masonry.vue +177 -809
- 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/useMasonryTransitions.ts +39 -1
- package/src/useMasonryVirtualization.ts +140 -0
package/src/Masonry.vue
CHANGED
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
import { useMasonryTransitions } from './useMasonryTransitions'
|
|
13
13
|
import { useMasonryScroll } from './useMasonryScroll'
|
|
14
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'
|
|
15
20
|
import MasonryItem from './components/MasonryItem.vue'
|
|
16
21
|
import { normalizeError } from './utils/errorHandler'
|
|
17
22
|
|
|
@@ -40,6 +45,15 @@ const props = defineProps({
|
|
|
40
45
|
type: Boolean,
|
|
41
46
|
default: false
|
|
42
47
|
},
|
|
48
|
+
// Initial pagination state when skipInitialLoad is true and items are provided
|
|
49
|
+
initialPage: {
|
|
50
|
+
type: [Number, String],
|
|
51
|
+
default: null
|
|
52
|
+
},
|
|
53
|
+
initialNextPage: {
|
|
54
|
+
type: [Number, String],
|
|
55
|
+
default: null
|
|
56
|
+
},
|
|
43
57
|
pageSize: {
|
|
44
58
|
type: Number,
|
|
45
59
|
default: 40
|
|
@@ -200,158 +214,90 @@ const loadError = ref<Error | null>(null) // Track load errors
|
|
|
200
214
|
const currentBreakpoint = computed(() => getBreakpointName(containerWidth.value))
|
|
201
215
|
|
|
202
216
|
|
|
203
|
-
//
|
|
204
|
-
const
|
|
205
|
-
|
|
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
|
|
217
|
+
// Initialize dimensions composable first (needed by layout composable)
|
|
218
|
+
const dimensions = useMasonryDimensions({
|
|
219
|
+
masonry: masonry as any
|
|
252
220
|
})
|
|
253
221
|
|
|
254
|
-
|
|
255
|
-
|
|
222
|
+
// Extract dimension checking function
|
|
223
|
+
const { checkItemDimensions, invalidDimensionIds, reset: resetDimensions } = dimensions
|
|
256
224
|
|
|
257
|
-
|
|
258
|
-
|
|
225
|
+
// Initialize layout composable (needs checkItemDimensions from dimensions composable)
|
|
226
|
+
const layoutComposable = useMasonryLayout({
|
|
227
|
+
masonry: masonry as any,
|
|
228
|
+
useSwipeMode,
|
|
229
|
+
container,
|
|
230
|
+
columns,
|
|
231
|
+
containerWidth,
|
|
232
|
+
masonryContentHeight,
|
|
233
|
+
layout,
|
|
234
|
+
fixedDimensions,
|
|
235
|
+
checkItemDimensions
|
|
236
|
+
})
|
|
259
237
|
|
|
260
|
-
|
|
261
|
-
|
|
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)
|
|
238
|
+
// Extract layout functions
|
|
239
|
+
const { refreshLayout, setFixedDimensions: setFixedDimensionsLayout, onResize: onResizeLayout } = layoutComposable
|
|
266
240
|
|
|
267
|
-
|
|
268
|
-
|
|
241
|
+
// Initialize virtualization composable
|
|
242
|
+
const virtualization = useMasonryVirtualization({
|
|
243
|
+
masonry: masonry as any,
|
|
244
|
+
container,
|
|
245
|
+
columns,
|
|
246
|
+
virtualBufferPx: props.virtualBufferPx,
|
|
247
|
+
loadThresholdPx: props.loadThresholdPx,
|
|
248
|
+
handleScroll: () => { } // Will be set after pagination is initialized
|
|
249
|
+
})
|
|
269
250
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
isNearTrigger
|
|
273
|
-
}
|
|
274
|
-
}
|
|
251
|
+
// Extract virtualization state and functions
|
|
252
|
+
const { viewportTop, viewportHeight, virtualizing, scrollProgress, visibleMasonry, updateScrollProgress, updateViewport: updateViewportVirtualization, reset: resetVirtualization } = virtualization
|
|
275
253
|
|
|
276
|
-
//
|
|
254
|
+
// Initialize transitions composable with virtualization support
|
|
277
255
|
const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(
|
|
278
256
|
{ container, masonry: masonry as any },
|
|
279
|
-
{ leaveDurationMs: props.leaveDurationMs }
|
|
257
|
+
{ leaveDurationMs: props.leaveDurationMs, virtualizing }
|
|
280
258
|
)
|
|
281
259
|
|
|
282
|
-
// Transition
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
}
|
|
260
|
+
// Transition functions for template (wrapped to match expected signature)
|
|
261
|
+
const enter = onEnter
|
|
262
|
+
const beforeEnter = onBeforeEnter
|
|
263
|
+
const beforeLeave = onBeforeLeave
|
|
264
|
+
const leave = onLeave
|
|
326
265
|
|
|
327
|
-
//
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
266
|
+
// Initialize pagination composable
|
|
267
|
+
const pagination = useMasonryPagination({
|
|
268
|
+
getNextPage: props.getNextPage as (page: any) => Promise<{ items: any[]; nextPage: any }>,
|
|
269
|
+
masonry: masonry as any,
|
|
270
|
+
isLoading,
|
|
271
|
+
hasReachedEnd,
|
|
272
|
+
loadError,
|
|
273
|
+
currentPage,
|
|
274
|
+
paginationHistory,
|
|
275
|
+
refreshLayout,
|
|
276
|
+
retryMaxAttempts: props.retryMaxAttempts,
|
|
277
|
+
retryInitialDelayMs: props.retryInitialDelayMs,
|
|
278
|
+
retryBackoffStepMs: props.retryBackoffStepMs,
|
|
279
|
+
backfillEnabled: props.backfillEnabled,
|
|
280
|
+
backfillDelayMs: props.backfillDelayMs,
|
|
281
|
+
backfillMaxCalls: props.backfillMaxCalls,
|
|
282
|
+
pageSize: props.pageSize,
|
|
283
|
+
autoRefreshOnEmpty: props.autoRefreshOnEmpty,
|
|
284
|
+
emits
|
|
285
|
+
})
|
|
344
286
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
const itemsWithPositions = items.filter((it: any) =>
|
|
348
|
-
typeof it.top === 'number' && typeof it.columnHeight === 'number'
|
|
349
|
-
)
|
|
350
|
-
}
|
|
287
|
+
// Extract pagination functions
|
|
288
|
+
const { loadPage, loadNext, refreshCurrentPage, cancelLoad, maybeBackfillToTarget } = pagination
|
|
351
289
|
|
|
352
|
-
|
|
290
|
+
// Initialize swipe mode composable (needs loadNext and loadPage from pagination)
|
|
291
|
+
const swipeMode = useSwipeModeComposable({
|
|
292
|
+
useSwipeMode,
|
|
293
|
+
masonry: masonry as any,
|
|
294
|
+
isLoading,
|
|
295
|
+
loadNext,
|
|
296
|
+
loadPage,
|
|
297
|
+
paginationHistory
|
|
353
298
|
})
|
|
354
299
|
|
|
300
|
+
// Initialize scroll handler (needs loadNext from pagination)
|
|
355
301
|
const { handleScroll } = useMasonryScroll({
|
|
356
302
|
container,
|
|
357
303
|
masonry: masonry as any,
|
|
@@ -367,25 +313,32 @@ const { handleScroll } = useMasonryScroll({
|
|
|
367
313
|
loadThresholdPx: props.loadThresholdPx
|
|
368
314
|
})
|
|
369
315
|
|
|
316
|
+
// Update virtualization handleScroll to use the scroll handler
|
|
317
|
+
virtualization.handleScroll.value = handleScroll
|
|
318
|
+
|
|
319
|
+
// Initialize items composable
|
|
320
|
+
const items = useMasonryItems({
|
|
321
|
+
masonry: masonry as any,
|
|
322
|
+
useSwipeMode,
|
|
323
|
+
refreshLayout,
|
|
324
|
+
refreshCurrentPage,
|
|
325
|
+
loadNext,
|
|
326
|
+
maybeBackfillToTarget,
|
|
327
|
+
autoRefreshOnEmpty: props.autoRefreshOnEmpty,
|
|
328
|
+
paginationHistory
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// Extract item management functions
|
|
332
|
+
const { remove, removeMany, restore, restoreMany, removeAll: removeAllItems } = items
|
|
333
|
+
|
|
334
|
+
// setFixedDimensions is now in useMasonryLayout composable
|
|
335
|
+
// Wrapper function to maintain API compatibility and handle wrapper restoration
|
|
370
336
|
function setFixedDimensions(dimensions: { width?: number; height?: number } | null) {
|
|
371
|
-
|
|
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 {
|
|
337
|
+
setFixedDimensionsLayout(dimensions, updateScrollProgress)
|
|
338
|
+
if (!dimensions && wrapper.value) {
|
|
384
339
|
// When clearing fixed dimensions, restore from wrapper
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
containerHeight.value = wrapper.value.clientHeight
|
|
388
|
-
}
|
|
340
|
+
containerWidth.value = wrapper.value.clientWidth
|
|
341
|
+
containerHeight.value = wrapper.value.clientHeight
|
|
389
342
|
}
|
|
390
343
|
}
|
|
391
344
|
|
|
@@ -407,7 +360,7 @@ defineExpose({
|
|
|
407
360
|
setFixedDimensions,
|
|
408
361
|
remove,
|
|
409
362
|
removeMany,
|
|
410
|
-
removeAll,
|
|
363
|
+
removeAll: removeAllItems,
|
|
411
364
|
restore,
|
|
412
365
|
restoreMany,
|
|
413
366
|
loadNext,
|
|
@@ -425,244 +378,8 @@ defineExpose({
|
|
|
425
378
|
currentBreakpoint
|
|
426
379
|
})
|
|
427
380
|
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
})
|
|
381
|
+
// Layout functions are now in useMasonryLayout composable
|
|
382
|
+
// Removed: calculateHeight, refreshLayout - now from layoutComposable
|
|
666
383
|
|
|
667
384
|
// Expose swipe mode computed values and state for template
|
|
668
385
|
const currentItem = swipeMode.currentItem
|
|
@@ -684,241 +401,9 @@ const goToNextItem = swipeMode.goToNextItem
|
|
|
684
401
|
const goToPreviousItem = swipeMode.goToPreviousItem
|
|
685
402
|
const snapToCurrentItem = swipeMode.snapToCurrentItem
|
|
686
403
|
|
|
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
|
-
}
|
|
404
|
+
// refreshCurrentPage is now in useMasonryPagination composable
|
|
859
405
|
|
|
860
|
-
|
|
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
|
-
}
|
|
406
|
+
// Item management functions (remove, removeMany, restore, restoreMany, removeAll) are now in useMasonryItems composable
|
|
922
407
|
|
|
923
408
|
function scrollToTop(options?: ScrollToOptions) {
|
|
924
409
|
if (container.value) {
|
|
@@ -945,131 +430,24 @@ function scrollTo(options: { top?: number; left?: number; behavior?: ScrollBehav
|
|
|
945
430
|
}
|
|
946
431
|
}
|
|
947
432
|
|
|
948
|
-
|
|
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
|
-
}
|
|
433
|
+
// removeAll is now in useMasonryItems composable (removeAllItems)
|
|
963
434
|
|
|
435
|
+
// onResize is now in useMasonryLayout composable (onResizeLayout)
|
|
964
436
|
function onResize() {
|
|
965
|
-
|
|
966
|
-
refreshLayout(masonry.value as any)
|
|
437
|
+
onResizeLayout()
|
|
967
438
|
if (container.value) {
|
|
968
439
|
viewportTop.value = container.value.scrollTop
|
|
969
440
|
viewportHeight.value = container.value.clientHeight
|
|
970
441
|
}
|
|
971
442
|
}
|
|
972
443
|
|
|
973
|
-
|
|
974
|
-
|
|
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
|
-
}
|
|
444
|
+
// maybeBackfillToTarget, cancelLoad are now in useMasonryPagination composable
|
|
445
|
+
// Removed: backfillActive, cancelRequested - now internal to pagination composable
|
|
1068
446
|
|
|
1069
447
|
function reset() {
|
|
1070
|
-
// Cancel ongoing work
|
|
448
|
+
// Cancel ongoing work
|
|
1071
449
|
cancelLoad()
|
|
1072
|
-
|
|
450
|
+
|
|
1073
451
|
if (container.value) {
|
|
1074
452
|
container.value.scrollTo({
|
|
1075
453
|
top: 0,
|
|
@@ -1084,10 +462,11 @@ function reset() {
|
|
|
1084
462
|
hasReachedEnd.value = false // Reset end flag
|
|
1085
463
|
loadError.value = null // Reset error flag
|
|
1086
464
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
465
|
+
// Reset virtualization state
|
|
466
|
+
resetVirtualization()
|
|
467
|
+
|
|
468
|
+
// Reset auto-initialization flag so watcher can work again if needed
|
|
469
|
+
hasInitializedWithItems = false
|
|
1091
470
|
}
|
|
1092
471
|
|
|
1093
472
|
function destroy() {
|
|
@@ -1102,27 +481,17 @@ function destroy() {
|
|
|
1102
481
|
hasReachedEnd.value = false
|
|
1103
482
|
loadError.value = null
|
|
1104
483
|
isLoading.value = false
|
|
1105
|
-
backfillActive = false
|
|
1106
|
-
cancelRequested.value = false
|
|
1107
484
|
|
|
1108
485
|
// Reset swipe mode state
|
|
1109
486
|
currentSwipeIndex.value = 0
|
|
1110
487
|
swipeOffset.value = 0
|
|
1111
488
|
isDragging.value = false
|
|
1112
489
|
|
|
1113
|
-
// Reset
|
|
1114
|
-
|
|
1115
|
-
viewportHeight.value = 0
|
|
1116
|
-
virtualizing.value = false
|
|
1117
|
-
|
|
1118
|
-
// Reset scroll progress
|
|
1119
|
-
scrollProgress.value = {
|
|
1120
|
-
distanceToTrigger: 0,
|
|
1121
|
-
isNearTrigger: false
|
|
1122
|
-
}
|
|
490
|
+
// Reset virtualization state
|
|
491
|
+
resetVirtualization()
|
|
1123
492
|
|
|
1124
493
|
// Reset invalid dimension tracking
|
|
1125
|
-
|
|
494
|
+
resetDimensions()
|
|
1126
495
|
|
|
1127
496
|
// Scroll to top if container exists
|
|
1128
497
|
if (container.value) {
|
|
@@ -1133,35 +502,10 @@ function destroy() {
|
|
|
1133
502
|
}
|
|
1134
503
|
}
|
|
1135
504
|
|
|
505
|
+
// Scroll handler is now handled by virtualization composable's updateViewport
|
|
1136
506
|
const debouncedScrollHandler = debounce(async () => {
|
|
1137
507
|
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)
|
|
508
|
+
await updateViewportVirtualization()
|
|
1165
509
|
}, 200)
|
|
1166
510
|
|
|
1167
511
|
const debouncedResizeHandler = debounce(onResize, 200)
|
|
@@ -1234,7 +578,9 @@ async function restoreItems(items: any[], page: any, next: any) {
|
|
|
1234
578
|
if (next !== null && next !== undefined) {
|
|
1235
579
|
paginationHistory.value.push(next)
|
|
1236
580
|
}
|
|
1237
|
-
hasReachedEnd
|
|
581
|
+
// Only set hasReachedEnd to true if next is explicitly null (end of list)
|
|
582
|
+
// undefined means "unknown" - don't assume end of list
|
|
583
|
+
hasReachedEnd.value = next === null
|
|
1238
584
|
loadError.value = null
|
|
1239
585
|
|
|
1240
586
|
// Diagnostics: check incoming items
|
|
@@ -1251,20 +597,15 @@ async function restoreItems(items: any[], page: any, next: any) {
|
|
|
1251
597
|
}
|
|
1252
598
|
} else {
|
|
1253
599
|
// In masonry mode, refresh layout with the restored items
|
|
1254
|
-
// This is the same pattern as init() - refreshLayout handles all the layout calculation
|
|
1255
600
|
refreshLayout(items)
|
|
1256
601
|
|
|
1257
602
|
// 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
603
|
if (container.value) {
|
|
1261
604
|
viewportTop.value = container.value.scrollTop
|
|
1262
605
|
viewportHeight.value = container.value.clientHeight || window.innerHeight
|
|
1263
606
|
}
|
|
1264
607
|
|
|
1265
608
|
// 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
609
|
await nextTick()
|
|
1269
610
|
if (container.value) {
|
|
1270
611
|
viewportTop.value = container.value.scrollTop
|
|
@@ -1312,6 +653,36 @@ watch(container, (el) => {
|
|
|
1312
653
|
}
|
|
1313
654
|
}, { immediate: true })
|
|
1314
655
|
|
|
656
|
+
// Watch for items when skipInitialLoad is true to auto-initialize pagination state
|
|
657
|
+
// This handles cases where items are provided after mount or updated externally
|
|
658
|
+
let hasInitializedWithItems = false
|
|
659
|
+
watch(
|
|
660
|
+
() => [props.items, props.skipInitialLoad, props.initialPage, props.initialNextPage] as const,
|
|
661
|
+
([items, skipInitialLoad, initialPage, initialNextPage]) => {
|
|
662
|
+
// Only auto-initialize if:
|
|
663
|
+
// 1. skipInitialLoad is true
|
|
664
|
+
// 2. Items exist
|
|
665
|
+
// 3. We haven't already initialized with items (to avoid re-initializing on every update)
|
|
666
|
+
if (
|
|
667
|
+
skipInitialLoad &&
|
|
668
|
+
items &&
|
|
669
|
+
items.length > 0 &&
|
|
670
|
+
!hasInitializedWithItems
|
|
671
|
+
) {
|
|
672
|
+
hasInitializedWithItems = true
|
|
673
|
+
const page = initialPage !== null && initialPage !== undefined
|
|
674
|
+
? initialPage
|
|
675
|
+
: (props.loadAtPage as any)
|
|
676
|
+
const next = initialNextPage !== undefined
|
|
677
|
+
? initialNextPage
|
|
678
|
+
: undefined // undefined means "unknown", null means "end of list"
|
|
679
|
+
|
|
680
|
+
restoreItems(items as any[], page, next)
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
{ immediate: false }
|
|
684
|
+
)
|
|
685
|
+
|
|
1315
686
|
// Watch for swipe mode changes to refresh layout and setup/teardown handlers
|
|
1316
687
|
watch(useSwipeMode, (newValue, oldValue) => {
|
|
1317
688
|
// Skip if this is the initial watch call and values are the same
|
|
@@ -1460,23 +831,20 @@ onMounted(async () => {
|
|
|
1460
831
|
|
|
1461
832
|
if (!props.skipInitialLoad) {
|
|
1462
833
|
await loadPage(paginationHistory.value[0] as any)
|
|
1463
|
-
} else {
|
|
1464
|
-
// When skipInitialLoad is true
|
|
1465
|
-
//
|
|
1466
|
-
if
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
currentPage.value = initialPage
|
|
1478
|
-
paginationHistory.value = [initialPage]
|
|
1479
|
-
}
|
|
834
|
+
} else if (props.items && props.items.length > 0) {
|
|
835
|
+
// When skipInitialLoad is true and items are provided, initialize pagination state
|
|
836
|
+
// Use initialPage/initialNextPage props if provided, otherwise use loadAtPage
|
|
837
|
+
// Only set next to null if initialNextPage is explicitly null (not undefined)
|
|
838
|
+
const page = props.initialPage !== null && props.initialPage !== undefined
|
|
839
|
+
? props.initialPage
|
|
840
|
+
: (props.loadAtPage as any)
|
|
841
|
+
const next = props.initialNextPage !== undefined
|
|
842
|
+
? props.initialNextPage
|
|
843
|
+
: undefined // undefined means "unknown", null means "end of list"
|
|
844
|
+
|
|
845
|
+
await restoreItems(props.items as any[], page, next)
|
|
846
|
+
// Mark as initialized to prevent watcher from running again
|
|
847
|
+
hasInitializedWithItems = true
|
|
1480
848
|
}
|
|
1481
849
|
|
|
1482
850
|
if (!useSwipeMode.value) {
|
|
@@ -1543,7 +911,7 @@ onUnmounted(() => {
|
|
|
1543
911
|
}">
|
|
1544
912
|
<div class="w-full h-full flex items-center justify-center p-4">
|
|
1545
913
|
<div class="w-full h-full max-w-full max-h-full relative">
|
|
1546
|
-
<slot :item="item" :remove="remove" :index="item.originalIndex ??
|
|
914
|
+
<slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
|
|
1547
915
|
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1548
916
|
:in-swipe-mode="true" :is-active="index === currentSwipeIndex"
|
|
1549
917
|
@preload:success="(p) => emits('item:preload:success', p)"
|
|
@@ -1586,7 +954,7 @@ onUnmounted(() => {
|
|
|
1586
954
|
<div v-for="(item, i) in visibleMasonry" :key="`${item.page}-${item.id}`" class="absolute masonry-item"
|
|
1587
955
|
v-bind="getItemAttributes(item, i)">
|
|
1588
956
|
<!-- Use default slot if provided, otherwise use MasonryItem -->
|
|
1589
|
-
<slot :item="item" :remove="remove" :index="item.originalIndex ??
|
|
957
|
+
<slot :item="item" :remove="remove" :index="item.originalIndex ?? masonry.indexOf(item)">
|
|
1590
958
|
<MasonryItem :item="item" :remove="remove" :header-height="layout.header" :footer-height="layout.footer"
|
|
1591
959
|
:in-swipe-mode="false" :is-active="false" @preload:success="(p) => emits('item:preload:success', p)"
|
|
1592
960
|
@preload:error="(p) => emits('item:preload:error', p)"
|