@wyxos/vibe 1.4.1 → 1.5.0

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,177 +1,194 @@
1
- import type { LayoutOptions, MasonryItem, ProcessedMasonryItem } from './types'
1
+ import type {LayoutOptions, MasonryItem, ProcessedMasonryItem} from './types'
2
+
3
+ let __cachedScrollbarWidth: number | null = null
2
4
 
3
5
  function getScrollbarWidth(): number {
4
- const div = document.createElement('div')
5
- div.style.visibility = 'hidden'
6
- div.style.overflow = 'scroll'
7
- ;(div.style as any).msOverflowStyle = 'scrollbar'
8
- div.style.width = '100px'
9
- div.style.height = '100px'
10
- document.body.appendChild(div)
11
-
12
- const inner = document.createElement('div')
13
- inner.style.width = '100%'
14
- div.appendChild(inner)
15
-
16
- const scrollbarWidth = div.offsetWidth - inner.offsetWidth
17
- document.body.removeChild(div)
18
- return scrollbarWidth
6
+ if (__cachedScrollbarWidth != null) return __cachedScrollbarWidth
7
+ const div = document.createElement('div')
8
+ div.style.visibility = 'hidden'
9
+ div.style.overflow = 'scroll'
10
+ ;(div.style as any).msOverflowStyle = 'scrollbar'
11
+ div.style.width = '100px'
12
+ div.style.height = '100px'
13
+ document.body.appendChild(div)
14
+
15
+ const inner = document.createElement('div')
16
+ inner.style.width = '100%'
17
+ div.appendChild(inner)
18
+
19
+ const scrollbarWidth = div.offsetWidth - inner.offsetWidth
20
+ document.body.removeChild(div)
21
+ __cachedScrollbarWidth = scrollbarWidth
22
+ return scrollbarWidth
19
23
  }
20
24
 
21
25
  export default function calculateLayout(
22
- items: MasonryItem[],
23
- container: HTMLElement,
24
- columnCount: number,
25
- options: LayoutOptions = {}
26
+ items: MasonryItem[],
27
+ container: HTMLElement,
28
+ columnCount: number,
29
+ options: LayoutOptions = {}
26
30
  ): ProcessedMasonryItem[] {
27
- const {
28
- gutterX = 0,
29
- gutterY = 0,
30
- header = 0,
31
- footer = 0,
32
- paddingLeft = 0,
33
- paddingRight = 0,
34
- sizes = {
35
- base: 1,
36
- sm: 2,
37
- md: 3,
38
- lg: 4,
39
- xl: 5,
40
- '2xl': 6
41
- },
42
- placement = 'masonry'
43
- } = options
44
-
45
- let cssPaddingLeft = 0
46
- let cssPaddingRight = 0
47
- try {
48
- if (container && container.nodeType === 1 && typeof window !== 'undefined' && window.getComputedStyle) {
49
- const styles = window.getComputedStyle(container)
50
- cssPaddingLeft = parseFloat(styles.paddingLeft) || 0
51
- cssPaddingRight = parseFloat(styles.paddingRight) || 0
31
+ const {
32
+ gutterX = 0,
33
+ gutterY = 0,
34
+ header = 0,
35
+ footer = 0,
36
+ paddingLeft = 0,
37
+ paddingRight = 0,
38
+ sizes = {
39
+ base: 1,
40
+ sm: 2,
41
+ md: 3,
42
+ lg: 4,
43
+ xl: 5,
44
+ '2xl': 6
45
+ },
46
+ placement = 'masonry'
47
+ } = options
48
+
49
+ let cssPaddingLeft = 0
50
+ let cssPaddingRight = 0
51
+ try {
52
+ if (container && container.nodeType === 1 && typeof window !== 'undefined' && window.getComputedStyle) {
53
+ const styles = window.getComputedStyle(container)
54
+ cssPaddingLeft = parseFloat(styles.paddingLeft) || 0
55
+ cssPaddingRight = parseFloat(styles.paddingRight) || 0
56
+ }
57
+ } catch {
58
+ // noop
52
59
  }
53
- } catch {
54
- // noop
55
- }
56
60
 
57
- const effectivePaddingLeft = (paddingLeft || 0) + cssPaddingLeft
58
- const effectivePaddingRight = (paddingRight || 0) + cssPaddingRight
61
+ const effectivePaddingLeft = (paddingLeft || 0) + cssPaddingLeft
62
+ const effectivePaddingRight = (paddingRight || 0) + cssPaddingRight
59
63
 
60
- const measuredScrollbarWidth = container.offsetWidth - container.clientWidth
61
- const scrollbarWidth = measuredScrollbarWidth > 0 ? measuredScrollbarWidth + 2 : getScrollbarWidth() + 2
64
+ const measuredScrollbarWidth = container.offsetWidth - container.clientWidth
65
+ const scrollbarWidth = measuredScrollbarWidth > 0 ? measuredScrollbarWidth + 2 : getScrollbarWidth() + 2
62
66
 
63
- const usableWidth = container.offsetWidth - scrollbarWidth - effectivePaddingLeft - effectivePaddingRight
64
- const totalGutterX = gutterX * (columnCount - 1)
65
- const columnWidth = Math.floor((usableWidth - totalGutterX) / columnCount)
67
+ const usableWidth = container.offsetWidth - scrollbarWidth - effectivePaddingLeft - effectivePaddingRight
68
+ const totalGutterX = gutterX * (columnCount - 1)
69
+ const columnWidth = Math.floor((usableWidth - totalGutterX) / columnCount)
66
70
 
67
- const baseHeights = items.map((item) => {
68
- const originalWidth = item.width
69
- const originalHeight = item.height
70
- const imageHeight = Math.round((columnWidth * originalHeight) / originalWidth)
71
- return imageHeight + footer + header
72
- })
71
+ const baseHeights = items.map((item) => {
72
+ const originalWidth = item.width
73
+ const originalHeight = item.height
74
+ const imageHeight = Math.round((columnWidth * originalHeight) / originalWidth)
75
+ return imageHeight + footer + header
76
+ })
73
77
 
74
- if (placement === 'sequential-balanced') {
75
- const n = baseHeights.length
76
- if (n === 0) return []
78
+ if (placement === 'sequential-balanced') {
79
+ const n = baseHeights.length
80
+ if (n === 0) return []
77
81
 
78
- const addWithGutter = (currentSum: number, itemsInGroup: number, nextHeight: number) => {
79
- return currentSum + (itemsInGroup > 0 ? gutterY : 0) + nextHeight
80
- }
82
+ const addWithGutter = (currentSum: number, itemsInGroup: number, nextHeight: number) => {
83
+ return currentSum + (itemsInGroup > 0 ? gutterY : 0) + nextHeight
84
+ }
81
85
 
82
- let low = Math.max(...baseHeights)
83
- let high = baseHeights.reduce((sum, h) => sum + h, 0) + gutterY * Math.max(0, n - 1)
84
-
85
- const canPartition = (cap: number) => {
86
- let groups = 1
87
- let sum = 0
88
- let count = 0
89
- for (let i = 0; i < n; i++) {
90
- const h = baseHeights[i]
91
- const next = addWithGutter(sum, count, h)
92
- if (next <= cap) {
93
- sum = next
94
- count++
95
- } else {
96
- groups++
97
- sum = h
98
- count = 1
99
- if (h > cap) return false
100
- if (groups > columnCount) return false
86
+ let low = Math.max(...baseHeights)
87
+ let high = baseHeights.reduce((sum, h) => sum + h, 0) + gutterY * Math.max(0, n - 1)
88
+
89
+ const canPartition = (cap: number) => {
90
+ let groups = 1
91
+ let sum = 0
92
+ let count = 0
93
+ for (let i = 0; i < n; i++) {
94
+ const h = baseHeights[i]
95
+ const next = addWithGutter(sum, count, h)
96
+ if (next <= cap) {
97
+ sum = next
98
+ count++
99
+ } else {
100
+ groups++
101
+ sum = h
102
+ count = 1
103
+ if (h > cap) return false
104
+ if (groups > columnCount) return false
105
+ }
106
+ }
107
+ return groups <= columnCount
101
108
  }
102
- }
103
- return groups <= columnCount
104
- }
105
109
 
106
- while (low < high) {
107
- const mid = Math.floor((low + high) / 2)
108
- if (canPartition(mid)) high = mid
109
- else low = mid + 1
110
- }
111
- const cap = high
112
-
113
- const starts = new Array<number>(columnCount).fill(0)
114
- let groupIndex = columnCount - 1
115
- let sum = 0
116
- let count = 0
117
- for (let i = n - 1; i >= 0; i--) {
118
- const h = baseHeights[i]
119
- const needAtLeast = i < groupIndex
120
- const canFit = addWithGutter(sum, count, h) <= cap
121
- if (!canFit || needAtLeast) {
122
- starts[groupIndex] = i + 1
123
- groupIndex--
124
- sum = h
125
- count = 1
126
- } else {
127
- sum = addWithGutter(sum, count, h)
128
- count++
129
- }
110
+ while (low < high) {
111
+ const mid = Math.floor((low + high) / 2)
112
+ if (canPartition(mid)) high = mid
113
+ else low = mid + 1
114
+ }
115
+ const cap = high
116
+
117
+ const starts = new Array<number>(columnCount).fill(0)
118
+ let groupIndex = columnCount - 1
119
+ let sum = 0
120
+ let count = 0
121
+ for (let i = n - 1; i >= 0; i--) {
122
+ const h = baseHeights[i]
123
+ const needAtLeast = i < groupIndex
124
+ const canFit = addWithGutter(sum, count, h) <= cap
125
+ if (!canFit || needAtLeast) {
126
+ starts[groupIndex] = i + 1
127
+ groupIndex--
128
+ sum = h
129
+ count = 1
130
+ } else {
131
+ sum = addWithGutter(sum, count, h)
132
+ count++
133
+ }
134
+ }
135
+ starts[0] = 0
136
+
137
+ const processedItems: ProcessedMasonryItem[] = []
138
+ const tops = new Array<number>(columnCount).fill(0)
139
+ for (let col = 0; col < columnCount; col++) {
140
+ const start = starts[col]
141
+ const end = col + 1 < columnCount ? starts[col + 1] : n
142
+ const left = col * (columnWidth + gutterX)
143
+ for (let i = start; i < end; i++) {
144
+ const item = items[i]
145
+ const newItem: ProcessedMasonryItem = {
146
+ ...(item as any),
147
+ columnWidth,
148
+ imageHeight: 0,
149
+ columnHeight: 0,
150
+ left: 0,
151
+ top: 0
152
+ }
153
+ newItem.imageHeight = baseHeights[i] - (footer + header)
154
+ newItem.columnHeight = baseHeights[i]
155
+ newItem.left = left
156
+ newItem.top = tops[col]
157
+ tops[col] += newItem.columnHeight + (i + 1 < end ? gutterY : 0)
158
+ processedItems.push(newItem)
159
+ }
160
+ }
161
+ return processedItems
130
162
  }
131
- starts[0] = 0
132
163
 
164
+ const columnHeights = new Array<number>(columnCount).fill(0)
133
165
  const processedItems: ProcessedMasonryItem[] = []
134
- const tops = new Array<number>(columnCount).fill(0)
135
- for (let col = 0; col < columnCount; col++) {
136
- const start = starts[col]
137
- const end = col + 1 < columnCount ? starts[col + 1] : n
138
- const left = col * (columnWidth + gutterX)
139
- for (let i = start; i < end; i++) {
140
- const item = items[i]
141
- const newItem: ProcessedMasonryItem = { ...(item as any), columnWidth, imageHeight: 0, columnHeight: 0, left: 0, top: 0 }
142
- const imageHeight = baseHeights[i] - (footer + header)
143
- newItem.imageHeight = imageHeight
144
- newItem.columnHeight = baseHeights[i]
145
- newItem.left = left
146
- newItem.top = tops[col]
147
- tops[col] += newItem.columnHeight + (i + 1 < end ? gutterY : 0)
148
- processedItems.push(newItem)
149
- }
150
- }
151
- return processedItems
152
- }
153
-
154
- const columnHeights = new Array<number>(columnCount).fill(0)
155
- const processedItems: ProcessedMasonryItem[] = []
156
166
 
157
- for (let index = 0; index < items.length; index++) {
158
- const item = items[index]
159
- const newItem: ProcessedMasonryItem = { ...(item as any), columnWidth: 0, imageHeight: 0, columnHeight: 0, left: 0, top: 0 }
167
+ for (let index = 0; index < items.length; index++) {
168
+ const item = items[index]
169
+ const newItem: ProcessedMasonryItem = {
170
+ ...(item as any),
171
+ columnWidth: 0,
172
+ imageHeight: 0,
173
+ columnHeight: 0,
174
+ left: 0,
175
+ top: 0
176
+ }
160
177
 
161
- const col = columnHeights.indexOf(Math.min(...columnHeights))
162
- const originalWidth = item.width
163
- const originalHeight = item.height
178
+ const col = columnHeights.indexOf(Math.min(...columnHeights))
179
+ const originalWidth = item.width
180
+ const originalHeight = item.height
164
181
 
165
- newItem.columnWidth = columnWidth
166
- newItem.left = col * (columnWidth + gutterX)
167
- newItem.imageHeight = Math.round((columnWidth * originalHeight) / originalWidth)
168
- newItem.columnHeight = newItem.imageHeight + footer + header
169
- newItem.top = columnHeights[col]
182
+ newItem.columnWidth = columnWidth
183
+ newItem.left = col * (columnWidth + gutterX)
184
+ newItem.imageHeight = Math.round((columnWidth * originalHeight) / originalWidth)
185
+ newItem.columnHeight = newItem.imageHeight + footer + header
186
+ newItem.top = columnHeights[col]
170
187
 
171
- columnHeights[col] += newItem.columnHeight + gutterY
188
+ columnHeights[col] += newItem.columnHeight + gutterY
172
189
 
173
- processedItems.push(newItem)
174
- }
190
+ processedItems.push(newItem)
191
+ }
175
192
 
176
- return processedItems
193
+ return processedItems
177
194
  }
@@ -55,11 +55,24 @@ export function getItemAttributes(item: ProcessedMasonryItem, index: number = 0)
55
55
  * Calculate column heights for masonry layout
56
56
  */
57
57
  export function calculateColumnHeights(items: ProcessedMasonryItem[], columnCount: number): number[] {
58
- const heights = new Array<number>(columnCount).fill(0)
59
- for (let i = 0; i < items.length; i++) {
60
- const item = items[i]
61
- const col = i % columnCount
62
- heights[col] = Math.max(heights[col], item.top + item.columnHeight)
58
+ // Derive columns by actual left positions to reflect shortest-column placement
59
+ if (!items.length || columnCount <= 0) {
60
+ return new Array<number>(Math.max(1, columnCount)).fill(0)
63
61
  }
62
+ // Unique lefts (sorted) represent the columns in visual order
63
+ const uniqueLefts = Array.from(new Set(items.map(i => i.left))).sort((a, b) => a - b)
64
+ const limitedLefts = uniqueLefts.slice(0, columnCount)
65
+ const leftIndexMap = new Map<number, number>()
66
+ for (let idx = 0; idx < limitedLefts.length; idx++) leftIndexMap.set(limitedLefts[idx], idx)
67
+
68
+ const heights = new Array<number>(limitedLefts.length).fill(0)
69
+ for (const it of items) {
70
+ const col = leftIndexMap.get(it.left)
71
+ if (col != null) {
72
+ heights[col] = Math.max(heights[col], it.top + it.columnHeight)
73
+ }
74
+ }
75
+ // Pad if some columns haven't been populated yet (e.g., initial render)
76
+ while (heights.length < columnCount) heights.push(0)
64
77
  return heights
65
78
  }
@@ -11,135 +11,47 @@ export function useMasonryScroll({
11
11
  columns,
12
12
  containerHeight,
13
13
  isLoading,
14
- maxItems,
15
14
  pageSize,
16
15
  refreshLayout,
17
16
  setItemsRaw,
18
17
  loadNext,
19
- leaveEstimateMs
18
+ loadThresholdPx
20
19
  }: {
21
20
  container: Ref<HTMLElement | null>
22
21
  masonry: Ref<ProcessedMasonryItem[]>
23
22
  columns: Ref<number>
24
23
  containerHeight: Ref<number>
25
24
  isLoading: Ref<boolean>
26
- maxItems: number
27
25
  pageSize: number
28
26
  refreshLayout: (items: ProcessedMasonryItem[]) => void
29
27
  setItemsRaw: (items: ProcessedMasonryItem[]) => void
30
28
  loadNext: () => Promise<any>
31
- leaveEstimateMs?: number
29
+ loadThresholdPx?: number
32
30
  }) {
33
31
  let cleanupInProgress = false
34
32
  let lastScrollTop = 0
35
33
 
36
- async function handleScroll() {
34
+ async function handleScroll(precomputedHeights?: number[]) {
37
35
  if (!container.value) return
38
36
 
39
- const { scrollTop, clientHeight } = container.value
40
- const visibleBottom = scrollTop + clientHeight
37
+ const columnHeights = precomputedHeights ?? calculateColumnHeights(masonry.value, columns.value)
38
+ const tallest = columnHeights.length ? Math.max(...columnHeights) : 0
39
+ const scrollerBottom = container.value.scrollTop + container.value.clientHeight
41
40
 
42
- // Determine scroll direction (down only)
43
- const isScrollingDown = scrollTop > lastScrollTop + 1 // tolerate tiny jitter
44
- lastScrollTop = scrollTop
41
+ const isScrollingDown = container.value.scrollTop > lastScrollTop + 1 // tolerate tiny jitter
42
+ lastScrollTop = container.value.scrollTop
45
43
 
46
- const columnHeights = calculateColumnHeights(masonry.value, columns.value)
47
- const longestColumn = Math.max(...columnHeights)
48
- const whitespaceVisible = longestColumn + 300 < visibleBottom - 1
49
- const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
44
+ const threshold = typeof loadThresholdPx === 'number' ? loadThresholdPx : 200
45
+ const triggerPoint = threshold >= 0
46
+ ? Math.max(0, tallest - threshold)
47
+ : Math.max(0, tallest + threshold)
48
+ const nearBottom = scrollerBottom >= triggerPoint
50
49
 
51
- if ((whitespaceVisible || reachedContainerBottom) && isScrollingDown && !isLoading.value && !cleanupInProgress) {
52
- try {
53
- if (masonry.value.length > maxItems) {
54
- await handleItemCleanup(columnHeights)
55
- }
56
-
57
- await loadNext()
58
- await nextTick()
59
- } catch (error) {
60
- console.error('Error in scroll handler:', error)
61
- }
62
- }
63
- }
64
-
65
- async function handleItemCleanup(columnHeightsBefore: number[]) {
66
- if (!masonry.value.length) return
67
- if (masonry.value.length <= pageSize) return
68
-
69
- const pageGroups: Record<string | number, ProcessedMasonryItem[]> = masonry.value.reduce((acc, item) => {
70
- const key = (item as any).page
71
- if (!acc[key]) acc[key] = []
72
- acc[key].push(item)
73
- return acc
74
- }, {} as Record<string | number, ProcessedMasonryItem[]>)
75
-
76
- const pages = Object.keys(pageGroups).sort((a, b) => parseInt(a) - parseInt(b))
77
- if (pages.length === 0) return
78
-
79
- let totalRemovedItems = 0
80
- const pagesToRemove: string[] = []
81
-
82
- for (const page of pages) {
83
- pagesToRemove.push(page)
84
- totalRemovedItems += pageGroups[page].length
85
- if (totalRemovedItems >= pageSize) break
50
+ if (nearBottom && isScrollingDown && !isLoading.value) {
51
+ await loadNext()
52
+ await nextTick()
53
+ return
86
54
  }
87
-
88
- const remainingItems = masonry.value.filter(item => !pagesToRemove.includes(String((item as any).page)))
89
- if (remainingItems.length === masonry.value.length) return
90
-
91
- cleanupInProgress = true
92
-
93
- setItemsRaw(remainingItems)
94
- await nextTick()
95
- // Allow leave to start, then FLIP survivors concurrently (single RAF)
96
- await new Promise<void>(r => requestAnimationFrame(() => r()))
97
-
98
- refreshLayout(remainingItems)
99
- await nextTick()
100
-
101
- await maintainAnchorPosition()
102
-
103
- cleanupInProgress = false
104
- }
105
-
106
- function msLeaveEstimate() {
107
- const base = typeof leaveEstimateMs === 'number' && leaveEstimateMs > 0 ? leaveEstimateMs : 250
108
- return base + 50
109
- }
110
-
111
- function waitFor(ms: number) {
112
- return new Promise(resolve => setTimeout(resolve, ms))
113
- }
114
-
115
- async function maintainAnchorPosition() {
116
- if (!container.value) return
117
-
118
- const { scrollTop, clientHeight } = container.value
119
- const pivotY = scrollTop + clientHeight * 0.4
120
-
121
- const heights = calculateColumnHeights(masonry.value, columns.value)
122
- const anchorColumnIndex = heights.indexOf(Math.max(...heights))
123
-
124
- const itemsInAnchor = masonry.value.filter((_, index) => index % columns.value === anchorColumnIndex)
125
- if (itemsInAnchor.length === 0) return
126
-
127
- let pivotItem = itemsInAnchor[0]
128
- for (const it of itemsInAnchor) {
129
- if (it.top <= pivotY && it.top >= pivotItem.top) {
130
- pivotItem = it
131
- }
132
- }
133
-
134
- const desiredTop = Math.max(0, pivotItem.top - clientHeight * 0.4)
135
-
136
- if (Math.abs(desiredTop - scrollTop) > 4) {
137
- container.value.scrollTo({ top: desiredTop, behavior: 'auto' })
138
- }
139
- }
140
-
141
- async function adjustScrollPosition() {
142
- await maintainAnchorPosition()
143
55
  }
144
56
 
145
57
  return {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Composable for handling masonry item transitions (typed)
3
3
  */
4
- export function useMasonryTransitions(masonry: any) {
4
+ export function useMasonryTransitions(masonry: any, opts?: { leaveDurationMs?: number }) {
5
5
  function onEnter(el: HTMLElement, done: () => void) {
6
6
  const left = parseInt(el.dataset.left || '0', 10)
7
7
  const top = parseInt(el.dataset.top || '0', 10)
@@ -41,18 +41,25 @@ export function useMasonryTransitions(masonry: any) {
41
41
  el.style.opacity = '1'
42
42
  el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
43
43
  el.style.removeProperty('--masonry-opacity-delay')
44
- void el.offsetWidth
45
- el.style.transition = ''
44
+ // Avoid forced reflow: re-enable transition on the next frame
45
+ requestAnimationFrame(() => {
46
+ el.style.transition = ''
47
+ })
46
48
  }
47
49
 
48
50
  function onLeave(el: HTMLElement, done: () => void) {
49
51
  const left = parseInt(el.dataset.left || '0', 10)
50
52
  const top = parseInt(el.dataset.top || '0', 10)
51
53
 
52
- const cs = getComputedStyle(el)
53
- const varVal = cs.getPropertyValue('--masonry-leave-duration') || ''
54
- const parsed = parseFloat(varVal)
55
- const leaveMs = Number.isFinite(parsed) && parsed > 0 ? parsed : 200
54
+ // Prefer explicit option, fallback to CSS variable for safety
55
+ const fromOpts = typeof opts?.leaveDurationMs === 'number' ? opts!.leaveDurationMs : NaN
56
+ let leaveMs = Number.isFinite(fromOpts) && fromOpts > 0 ? fromOpts : NaN
57
+ if (!Number.isFinite(leaveMs)) {
58
+ const cs = getComputedStyle(el)
59
+ const varVal = cs.getPropertyValue('--masonry-leave-duration') || ''
60
+ const parsed = parseFloat(varVal)
61
+ leaveMs = Number.isFinite(parsed) && parsed > 0 ? parsed : 200
62
+ }
56
63
 
57
64
  const prevDuration = el.style.transitionDuration
58
65