@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.
@@ -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
+