@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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ref, type Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UseMasonryDimensionsOptions {
|
|
4
|
+
masonry: Ref<any[]>
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function useMasonryDimensions(options: UseMasonryDimensionsOptions) {
|
|
8
|
+
const { masonry } = options
|
|
9
|
+
|
|
10
|
+
// Track items with invalid dimensions to avoid duplicate warnings
|
|
11
|
+
const invalidDimensionIds = ref<Set<number | string>>(new Set())
|
|
12
|
+
|
|
13
|
+
function isPositiveNumber(value: any): boolean {
|
|
14
|
+
return typeof value === 'number' && value > 0 && Number.isFinite(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function checkItemDimensions(items: any[], context: string) {
|
|
18
|
+
try {
|
|
19
|
+
if (!Array.isArray(items) || items.length === 0) return
|
|
20
|
+
const missing = items.filter((item) => !isPositiveNumber(item?.width) || !isPositiveNumber(item?.height))
|
|
21
|
+
if (missing.length === 0) return
|
|
22
|
+
|
|
23
|
+
const newIds: Array<number | string> = []
|
|
24
|
+
for (const item of missing) {
|
|
25
|
+
const id = (item?.id as number | string | undefined) ?? `idx:${masonry.value.indexOf(item)}`
|
|
26
|
+
if (!invalidDimensionIds.value.has(id)) {
|
|
27
|
+
invalidDimensionIds.value.add(id)
|
|
28
|
+
newIds.push(id)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (newIds.length > 0) {
|
|
32
|
+
const sample = newIds.slice(0, 10)
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.warn(
|
|
35
|
+
'[Masonry] Items missing width/height detected:',
|
|
36
|
+
{
|
|
37
|
+
context,
|
|
38
|
+
count: newIds.length,
|
|
39
|
+
sampleIds: sample,
|
|
40
|
+
hint: 'Ensure each item has positive width and height. Consider providing fallbacks (e.g., 512x512) at the data layer.'
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// best-effort diagnostics only
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function reset() {
|
|
50
|
+
invalidDimensionIds.value.clear()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
checkItemDimensions,
|
|
55
|
+
invalidDimensionIds,
|
|
56
|
+
reset
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { nextTick, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
|
|
3
|
+
export interface UseMasonryItemsOptions {
|
|
4
|
+
masonry: Ref<any[]>
|
|
5
|
+
useSwipeMode: ComputedRef<boolean>
|
|
6
|
+
refreshLayout: (items: any[]) => void
|
|
7
|
+
refreshCurrentPage: () => Promise<any>
|
|
8
|
+
loadNext: () => Promise<any>
|
|
9
|
+
maybeBackfillToTarget: (baseline: number, force?: boolean) => Promise<void>
|
|
10
|
+
autoRefreshOnEmpty: boolean
|
|
11
|
+
paginationHistory: Ref<any[]>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function useMasonryItems(options: UseMasonryItemsOptions) {
|
|
15
|
+
const {
|
|
16
|
+
masonry,
|
|
17
|
+
useSwipeMode,
|
|
18
|
+
refreshLayout,
|
|
19
|
+
refreshCurrentPage,
|
|
20
|
+
loadNext,
|
|
21
|
+
maybeBackfillToTarget,
|
|
22
|
+
autoRefreshOnEmpty,
|
|
23
|
+
paginationHistory
|
|
24
|
+
} = options
|
|
25
|
+
|
|
26
|
+
async function remove(item: any) {
|
|
27
|
+
const next = masonry.value.filter(i => i.id !== item.id)
|
|
28
|
+
masonry.value = next
|
|
29
|
+
await nextTick()
|
|
30
|
+
|
|
31
|
+
// If all items were removed, either refresh current page or load next based on prop
|
|
32
|
+
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
33
|
+
if (autoRefreshOnEmpty) {
|
|
34
|
+
await refreshCurrentPage()
|
|
35
|
+
} else {
|
|
36
|
+
try {
|
|
37
|
+
await loadNext()
|
|
38
|
+
// Force backfill from 0 to ensure viewport is filled
|
|
39
|
+
// Pass baseline=0 and force=true to trigger backfill even if backfillEnabled was temporarily disabled
|
|
40
|
+
await maybeBackfillToTarget(0, true)
|
|
41
|
+
} catch { }
|
|
42
|
+
}
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Commit DOM updates without forcing sync reflow
|
|
47
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
48
|
+
// Start FLIP on next frame
|
|
49
|
+
requestAnimationFrame(() => {
|
|
50
|
+
refreshLayout(next)
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function removeMany(items: any[]) {
|
|
55
|
+
if (!items || items.length === 0) return
|
|
56
|
+
const ids = new Set(items.map(i => i.id))
|
|
57
|
+
const next = masonry.value.filter(i => !ids.has(i.id))
|
|
58
|
+
masonry.value = next
|
|
59
|
+
await nextTick()
|
|
60
|
+
|
|
61
|
+
// If all items were removed, either refresh current page or load next based on prop
|
|
62
|
+
if (next.length === 0 && paginationHistory.value.length > 0) {
|
|
63
|
+
if (autoRefreshOnEmpty) {
|
|
64
|
+
await refreshCurrentPage()
|
|
65
|
+
} else {
|
|
66
|
+
try {
|
|
67
|
+
await loadNext()
|
|
68
|
+
// Force backfill from 0 to ensure viewport is filled
|
|
69
|
+
await maybeBackfillToTarget(0, true)
|
|
70
|
+
} catch { }
|
|
71
|
+
}
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Commit DOM updates without forcing sync reflow
|
|
76
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
77
|
+
// Start FLIP on next frame
|
|
78
|
+
requestAnimationFrame(() => {
|
|
79
|
+
refreshLayout(next)
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Restore a single item at its original index.
|
|
85
|
+
* This is useful for undo operations where an item needs to be restored to its exact position.
|
|
86
|
+
* Handles all index calculation and layout recalculation internally.
|
|
87
|
+
* @param item - Item to restore
|
|
88
|
+
* @param index - Original index of the item
|
|
89
|
+
*/
|
|
90
|
+
async function restore(item: any, index: number) {
|
|
91
|
+
if (!item) return
|
|
92
|
+
|
|
93
|
+
const current = masonry.value
|
|
94
|
+
const existingIndex = current.findIndex(i => i.id === item.id)
|
|
95
|
+
if (existingIndex !== -1) return // Item already exists
|
|
96
|
+
|
|
97
|
+
// Insert at the original index (clamped to valid range)
|
|
98
|
+
const newItems = [...current]
|
|
99
|
+
const targetIndex = Math.min(index, newItems.length)
|
|
100
|
+
newItems.splice(targetIndex, 0, item)
|
|
101
|
+
|
|
102
|
+
// Update the masonry array
|
|
103
|
+
masonry.value = newItems
|
|
104
|
+
await nextTick()
|
|
105
|
+
|
|
106
|
+
// Trigger layout recalculation (same pattern as remove)
|
|
107
|
+
if (!useSwipeMode.value) {
|
|
108
|
+
// Commit DOM updates without forcing sync reflow
|
|
109
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
110
|
+
// Start FLIP on next frame
|
|
111
|
+
requestAnimationFrame(() => {
|
|
112
|
+
refreshLayout(newItems)
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Restore multiple items at their original indices.
|
|
119
|
+
* This is useful for undo operations where items need to be restored to their exact positions.
|
|
120
|
+
* Handles all index calculation and layout recalculation internally.
|
|
121
|
+
* @param items - Array of items to restore
|
|
122
|
+
* @param indices - Array of original indices for each item (must match items array length)
|
|
123
|
+
*/
|
|
124
|
+
async function restoreMany(items: any[], indices: number[]) {
|
|
125
|
+
if (!items || items.length === 0) return
|
|
126
|
+
if (!indices || indices.length !== items.length) {
|
|
127
|
+
console.warn('[Masonry] restoreMany: items and indices arrays must have the same length')
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const current = masonry.value
|
|
132
|
+
const existingIds = new Set(current.map(i => i.id))
|
|
133
|
+
|
|
134
|
+
// Filter out items that already exist and pair with their indices
|
|
135
|
+
const itemsToRestore: Array<{ item: any; index: number }> = []
|
|
136
|
+
for (let i = 0; i < items.length; i++) {
|
|
137
|
+
if (!existingIds.has(items[i]?.id)) {
|
|
138
|
+
itemsToRestore.push({ item: items[i], index: indices[i] })
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (itemsToRestore.length === 0) return
|
|
143
|
+
|
|
144
|
+
// Build the final array by merging current items and restored items
|
|
145
|
+
// Strategy: Build position by position - for each position, decide if it should be
|
|
146
|
+
// a restored item (at its original index) or a current item (accounting for shifts)
|
|
147
|
+
|
|
148
|
+
// Create a map of restored items by their original index for O(1) lookup
|
|
149
|
+
const restoredByIndex = new Map<number, any>()
|
|
150
|
+
for (const { item, index } of itemsToRestore) {
|
|
151
|
+
restoredByIndex.set(index, item)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Find the maximum position we need to consider
|
|
155
|
+
const maxRestoredIndex = itemsToRestore.length > 0
|
|
156
|
+
? Math.max(...itemsToRestore.map(({ index }) => index))
|
|
157
|
+
: -1
|
|
158
|
+
const maxPosition = Math.max(current.length - 1, maxRestoredIndex)
|
|
159
|
+
|
|
160
|
+
// Build the final array position by position
|
|
161
|
+
// Key insight: Current array items are in "shifted" positions (missing the removed items).
|
|
162
|
+
// When we restore items at their original positions, current items naturally shift back.
|
|
163
|
+
// We can build the final array by iterating positions and using items sequentially.
|
|
164
|
+
const newItems: any[] = []
|
|
165
|
+
let currentArrayIndex = 0 // Track which current item we should use next
|
|
166
|
+
|
|
167
|
+
// Iterate through all positions up to the maximum we need
|
|
168
|
+
for (let position = 0; position <= maxPosition; position++) {
|
|
169
|
+
// If there's a restored item that belongs at this position, use it
|
|
170
|
+
if (restoredByIndex.has(position)) {
|
|
171
|
+
newItems.push(restoredByIndex.get(position)!)
|
|
172
|
+
} else {
|
|
173
|
+
// Otherwise, this position should be filled by the next current item
|
|
174
|
+
// Since current array is missing restored items, items are shifted left.
|
|
175
|
+
// By using them sequentially, they naturally end up in the correct positions.
|
|
176
|
+
if (currentArrayIndex < current.length) {
|
|
177
|
+
newItems.push(current[currentArrayIndex])
|
|
178
|
+
currentArrayIndex++
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Add any remaining current items that come after the last restored position
|
|
184
|
+
// (These are items that were originally after maxRestoredIndex)
|
|
185
|
+
while (currentArrayIndex < current.length) {
|
|
186
|
+
newItems.push(current[currentArrayIndex])
|
|
187
|
+
currentArrayIndex++
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Update the masonry array
|
|
191
|
+
masonry.value = newItems
|
|
192
|
+
await nextTick()
|
|
193
|
+
|
|
194
|
+
// Trigger layout recalculation (same pattern as removeMany)
|
|
195
|
+
if (!useSwipeMode.value) {
|
|
196
|
+
// Commit DOM updates without forcing sync reflow
|
|
197
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
198
|
+
// Start FLIP on next frame
|
|
199
|
+
requestAnimationFrame(() => {
|
|
200
|
+
refreshLayout(newItems)
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function removeAll() {
|
|
206
|
+
// Clear all items
|
|
207
|
+
masonry.value = []
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
remove,
|
|
212
|
+
removeMany,
|
|
213
|
+
restore,
|
|
214
|
+
restoreMany,
|
|
215
|
+
removeAll
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { ref, nextTick, type Ref, type ComputedRef } from 'vue'
|
|
2
|
+
import calculateLayout from './calculateLayout'
|
|
3
|
+
import { getColumnCount, calculateContainerHeight } from './masonryUtils'
|
|
4
|
+
|
|
5
|
+
export interface UseMasonryLayoutOptions {
|
|
6
|
+
masonry: Ref<any[]>
|
|
7
|
+
useSwipeMode: ComputedRef<boolean>
|
|
8
|
+
container: Ref<HTMLElement | null>
|
|
9
|
+
columns: Ref<number>
|
|
10
|
+
containerWidth: Ref<number>
|
|
11
|
+
masonryContentHeight: Ref<number>
|
|
12
|
+
layout: ComputedRef<any>
|
|
13
|
+
fixedDimensions: Ref<{ width?: number; height?: number } | null>
|
|
14
|
+
checkItemDimensions: (items: any[], context: string) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useMasonryLayout(options: UseMasonryLayoutOptions) {
|
|
18
|
+
const {
|
|
19
|
+
masonry,
|
|
20
|
+
useSwipeMode,
|
|
21
|
+
container,
|
|
22
|
+
columns,
|
|
23
|
+
containerWidth,
|
|
24
|
+
masonryContentHeight,
|
|
25
|
+
layout,
|
|
26
|
+
fixedDimensions,
|
|
27
|
+
checkItemDimensions
|
|
28
|
+
} = options
|
|
29
|
+
|
|
30
|
+
// Cache previous layout state for incremental updates
|
|
31
|
+
let previousLayoutItems: any[] = []
|
|
32
|
+
|
|
33
|
+
function calculateHeight(content: any[]) {
|
|
34
|
+
const newHeight = calculateContainerHeight(content as any)
|
|
35
|
+
let floor = 0
|
|
36
|
+
if (container.value) {
|
|
37
|
+
const { scrollTop, clientHeight } = container.value
|
|
38
|
+
floor = scrollTop + clientHeight + 100
|
|
39
|
+
}
|
|
40
|
+
masonryContentHeight.value = Math.max(newHeight, floor)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function refreshLayout(items: any[]) {
|
|
44
|
+
if (useSwipeMode.value) {
|
|
45
|
+
// In swipe mode, no layout calculation needed - items are stacked vertically
|
|
46
|
+
masonry.value = items as any
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!container.value) return
|
|
51
|
+
// Developer diagnostics: warn when dimensions are invalid
|
|
52
|
+
checkItemDimensions(items as any[], 'refreshLayout')
|
|
53
|
+
|
|
54
|
+
// Optimization: For large arrays, check if we can do incremental update
|
|
55
|
+
// Only works if items were removed from the end (common case)
|
|
56
|
+
const canUseIncremental = items.length > 1000 &&
|
|
57
|
+
previousLayoutItems.length > items.length &&
|
|
58
|
+
previousLayoutItems.length - items.length < 100 // Only small removals
|
|
59
|
+
|
|
60
|
+
if (canUseIncremental) {
|
|
61
|
+
// Check if items were removed from the end (most common case)
|
|
62
|
+
let removedFromEnd = true
|
|
63
|
+
for (let i = 0; i < items.length; i++) {
|
|
64
|
+
if (items[i]?.id !== previousLayoutItems[i]?.id) {
|
|
65
|
+
removedFromEnd = false
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (removedFromEnd) {
|
|
71
|
+
// Items removed from end - we can reuse previous positions for remaining items
|
|
72
|
+
// Just update indices and recalculate height
|
|
73
|
+
const itemsWithIndex = items.map((item, index) => ({
|
|
74
|
+
...previousLayoutItems[index],
|
|
75
|
+
originalIndex: index
|
|
76
|
+
}))
|
|
77
|
+
|
|
78
|
+
// Recalculate height only
|
|
79
|
+
calculateHeight(itemsWithIndex as any)
|
|
80
|
+
masonry.value = itemsWithIndex
|
|
81
|
+
previousLayoutItems = itemsWithIndex
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Full recalculation (fallback for all other cases)
|
|
87
|
+
// Update original index to reflect current position in array
|
|
88
|
+
// This ensures indices are correct after items are removed
|
|
89
|
+
const itemsWithIndex = items.map((item, index) => ({
|
|
90
|
+
...item,
|
|
91
|
+
originalIndex: index
|
|
92
|
+
}))
|
|
93
|
+
|
|
94
|
+
// When fixed dimensions are set, ensure container uses the fixed width for layout
|
|
95
|
+
// This prevents gaps when the container's actual width differs from the fixed width
|
|
96
|
+
const containerEl = container.value as HTMLElement
|
|
97
|
+
if (fixedDimensions.value && fixedDimensions.value.width !== undefined) {
|
|
98
|
+
// Temporarily set width to match fixed dimensions for accurate layout calculation
|
|
99
|
+
const originalWidth = containerEl.style.width
|
|
100
|
+
const originalBoxSizing = containerEl.style.boxSizing
|
|
101
|
+
containerEl.style.boxSizing = 'border-box'
|
|
102
|
+
containerEl.style.width = `${fixedDimensions.value.width}px`
|
|
103
|
+
// Force reflow
|
|
104
|
+
containerEl.offsetWidth
|
|
105
|
+
|
|
106
|
+
const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
|
|
107
|
+
|
|
108
|
+
// Restore original width
|
|
109
|
+
containerEl.style.width = originalWidth
|
|
110
|
+
containerEl.style.boxSizing = originalBoxSizing
|
|
111
|
+
|
|
112
|
+
calculateHeight(content as any)
|
|
113
|
+
masonry.value = content
|
|
114
|
+
// Cache for next incremental update
|
|
115
|
+
previousLayoutItems = content
|
|
116
|
+
} else {
|
|
117
|
+
const content = calculateLayout(itemsWithIndex as any, containerEl, columns.value, layout.value as any)
|
|
118
|
+
calculateHeight(content as any)
|
|
119
|
+
masonry.value = content
|
|
120
|
+
// Cache for next incremental update
|
|
121
|
+
previousLayoutItems = content
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setFixedDimensions(
|
|
126
|
+
dimensions: { width?: number; height?: number } | null,
|
|
127
|
+
updateScrollProgress?: () => void
|
|
128
|
+
) {
|
|
129
|
+
fixedDimensions.value = dimensions
|
|
130
|
+
if (dimensions) {
|
|
131
|
+
if (dimensions.width !== undefined) containerWidth.value = dimensions.width
|
|
132
|
+
// Force layout refresh when dimensions change
|
|
133
|
+
if (!useSwipeMode.value && container.value && masonry.value.length > 0) {
|
|
134
|
+
// Use nextTick to ensure DOM has updated
|
|
135
|
+
nextTick(() => {
|
|
136
|
+
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
137
|
+
refreshLayout(masonry.value as any)
|
|
138
|
+
if (updateScrollProgress) {
|
|
139
|
+
updateScrollProgress()
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// When clearing fixed dimensions, restore from wrapper
|
|
145
|
+
// Note: wrapper is not available in this composable, so this needs to be handled by caller
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function onResize() {
|
|
149
|
+
columns.value = getColumnCount(layout.value as any, containerWidth.value)
|
|
150
|
+
refreshLayout(masonry.value as any)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
refreshLayout,
|
|
155
|
+
setFixedDimensions,
|
|
156
|
+
onResize,
|
|
157
|
+
calculateHeight
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|