@wyxos/vibe 1.6.25 → 1.6.27

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.
@@ -1,218 +1,234 @@
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
-
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
+ paginationHistory: Ref<any[]>
11
+ }
12
+
13
+ export function useMasonryItems(options: UseMasonryItemsOptions) {
14
+ const {
15
+ masonry,
16
+ useSwipeMode,
17
+ refreshLayout,
18
+ refreshCurrentPage,
19
+ loadNext,
20
+ maybeBackfillToTarget,
21
+ paginationHistory
22
+ } = options
23
+
24
+ // Batch remove operations to prevent visual glitches from rapid successive calls
25
+ let pendingRemoves = new Set<any>()
26
+ let removeTimeoutId: ReturnType<typeof setTimeout> | null = null
27
+ let isProcessingRemoves = false
28
+
29
+ async function processPendingRemoves() {
30
+ if (pendingRemoves.size === 0 || isProcessingRemoves) return
31
+
32
+ isProcessingRemoves = true
33
+ const itemsToRemove = Array.from(pendingRemoves)
34
+ pendingRemoves.clear()
35
+ removeTimeoutId = null
36
+
37
+ // Use removeManyInternal for batched removal (bypass batching to avoid recursion)
38
+ await removeManyInternal(itemsToRemove)
39
+ isProcessingRemoves = false
40
+ }
41
+
42
+ async function remove(item: any) {
43
+ // Add to pending removes
44
+ pendingRemoves.add(item)
45
+
46
+ // Clear existing timeout
47
+ if (removeTimeoutId) {
48
+ clearTimeout(removeTimeoutId)
49
+ }
50
+
51
+ // Batch removes within a short time window (16ms = ~1 frame at 60fps)
52
+ removeTimeoutId = setTimeout(() => {
53
+ processPendingRemoves()
54
+ }, 16)
55
+ }
56
+
57
+ async function removeManyInternal(items: any[]) {
58
+ if (!items || items.length === 0) return
59
+ const ids = new Set(items.map(i => i.id))
60
+ const next = masonry.value.filter(i => !ids.has(i.id))
61
+ masonry.value = next
62
+ await nextTick()
63
+
64
+ // If all items were removed, load next page
65
+ if (next.length === 0 && paginationHistory.value.length > 0) {
66
+ try {
67
+ await loadNext()
68
+ // Force backfill from 0 to ensure viewport is filled
69
+ await maybeBackfillToTarget(0, true)
70
+ } catch { }
71
+ return
72
+ }
73
+
74
+ // Commit DOM updates without forcing sync reflow
75
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
76
+ // Start FLIP on next frame
77
+ requestAnimationFrame(() => {
78
+ refreshLayout(next)
79
+ })
80
+ }
81
+
82
+ async function removeMany(items: any[]) {
83
+ if (!items || items.length === 0) return
84
+
85
+ // Add all items to pending removes for batching
86
+ items.forEach(item => pendingRemoves.add(item))
87
+
88
+ // Clear existing timeout
89
+ if (removeTimeoutId) {
90
+ clearTimeout(removeTimeoutId)
91
+ }
92
+
93
+ // Batch removes within a short time window (16ms = ~1 frame at 60fps)
94
+ removeTimeoutId = setTimeout(() => {
95
+ processPendingRemoves()
96
+ }, 16)
97
+ }
98
+
99
+ /**
100
+ * Restore a single item at its original index.
101
+ * This is useful for undo operations where an item needs to be restored to its exact position.
102
+ * Handles all index calculation and layout recalculation internally.
103
+ * @param item - Item to restore
104
+ * @param index - Original index of the item
105
+ */
106
+ async function restore(item: any, index: number) {
107
+ if (!item) return
108
+
109
+ const current = masonry.value
110
+ const existingIndex = current.findIndex(i => i.id === item.id)
111
+ if (existingIndex !== -1) return // Item already exists
112
+
113
+ // Insert at the original index (clamped to valid range)
114
+ const newItems = [...current]
115
+ const targetIndex = Math.min(index, newItems.length)
116
+ newItems.splice(targetIndex, 0, item)
117
+
118
+ // Update the masonry array
119
+ masonry.value = newItems
120
+ await nextTick()
121
+
122
+ // Trigger layout recalculation (same pattern as remove)
123
+ if (!useSwipeMode.value) {
124
+ // Commit DOM updates without forcing sync reflow
125
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
126
+ // Start FLIP on next frame
127
+ requestAnimationFrame(() => {
128
+ refreshLayout(newItems)
129
+ })
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Restore multiple items at their original indices.
135
+ * This is useful for undo operations where items need to be restored to their exact positions.
136
+ * Handles all index calculation and layout recalculation internally.
137
+ * @param items - Array of items to restore
138
+ * @param indices - Array of original indices for each item (must match items array length)
139
+ */
140
+ async function restoreMany(items: any[], indices: number[]) {
141
+ if (!items || items.length === 0) return
142
+ if (!indices || indices.length !== items.length) {
143
+ console.warn('[Masonry] restoreMany: items and indices arrays must have the same length')
144
+ return
145
+ }
146
+
147
+ const current = masonry.value
148
+ const existingIds = new Set(current.map(i => i.id))
149
+
150
+ // Filter out items that already exist and pair with their indices
151
+ const itemsToRestore: Array<{ item: any; index: number }> = []
152
+ for (let i = 0; i < items.length; i++) {
153
+ if (!existingIds.has(items[i]?.id)) {
154
+ itemsToRestore.push({ item: items[i], index: indices[i] })
155
+ }
156
+ }
157
+
158
+ if (itemsToRestore.length === 0) return
159
+
160
+ // Build the final array by merging current items and restored items
161
+ // Strategy: Build position by position - for each position, decide if it should be
162
+ // a restored item (at its original index) or a current item (accounting for shifts)
163
+
164
+ // Create a map of restored items by their original index for O(1) lookup
165
+ const restoredByIndex = new Map<number, any>()
166
+ for (const { item, index } of itemsToRestore) {
167
+ restoredByIndex.set(index, item)
168
+ }
169
+
170
+ // Find the maximum position we need to consider
171
+ const maxRestoredIndex = itemsToRestore.length > 0
172
+ ? Math.max(...itemsToRestore.map(({ index }) => index))
173
+ : -1
174
+ const maxPosition = Math.max(current.length - 1, maxRestoredIndex)
175
+
176
+ // Build the final array position by position
177
+ // Key insight: Current array items are in "shifted" positions (missing the removed items).
178
+ // When we restore items at their original positions, current items naturally shift back.
179
+ // We can build the final array by iterating positions and using items sequentially.
180
+ const newItems: any[] = []
181
+ let currentArrayIndex = 0 // Track which current item we should use next
182
+
183
+ // Iterate through all positions up to the maximum we need
184
+ for (let position = 0; position <= maxPosition; position++) {
185
+ // If there's a restored item that belongs at this position, use it
186
+ if (restoredByIndex.has(position)) {
187
+ newItems.push(restoredByIndex.get(position)!)
188
+ } else {
189
+ // Otherwise, this position should be filled by the next current item
190
+ // Since current array is missing restored items, items are shifted left.
191
+ // By using them sequentially, they naturally end up in the correct positions.
192
+ if (currentArrayIndex < current.length) {
193
+ newItems.push(current[currentArrayIndex])
194
+ currentArrayIndex++
195
+ }
196
+ }
197
+ }
198
+
199
+ // Add any remaining current items that come after the last restored position
200
+ // (These are items that were originally after maxRestoredIndex)
201
+ while (currentArrayIndex < current.length) {
202
+ newItems.push(current[currentArrayIndex])
203
+ currentArrayIndex++
204
+ }
205
+
206
+ // Update the masonry array
207
+ masonry.value = newItems
208
+ await nextTick()
209
+
210
+ // Trigger layout recalculation (same pattern as removeMany)
211
+ if (!useSwipeMode.value) {
212
+ // Commit DOM updates without forcing sync reflow
213
+ await new Promise<void>(r => requestAnimationFrame(() => r()))
214
+ // Start FLIP on next frame
215
+ requestAnimationFrame(() => {
216
+ refreshLayout(newItems)
217
+ })
218
+ }
219
+ }
220
+
221
+ async function removeAll() {
222
+ // Clear all items
223
+ masonry.value = []
224
+ }
225
+
226
+ return {
227
+ remove,
228
+ removeMany,
229
+ restore,
230
+ restoreMany,
231
+ removeAll
232
+ }
233
+ }
234
+
@@ -47,6 +47,10 @@ export function useMasonryLayout(options: UseMasonryLayoutOptions) {
47
47
  return
48
48
  }
49
49
 
50
+ // Always update masonry value, even if container isn't ready
51
+ // This ensures items are added in tests and when container isn't available yet
52
+ masonry.value = items as any
53
+
50
54
  if (!container.value) return
51
55
  // Developer diagnostics: warn when dimensions are invalid
52
56
  checkItemDimensions(items as any[], 'refreshLayout')