@wyxos/vibe 1.3.1 → 1.4.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.
@@ -0,0 +1,177 @@
1
+ import type { LayoutOptions, MasonryItem, ProcessedMasonryItem } from './types'
2
+
3
+ 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
19
+ }
20
+
21
+ export default function calculateLayout(
22
+ items: MasonryItem[],
23
+ container: HTMLElement,
24
+ columnCount: number,
25
+ options: LayoutOptions = {}
26
+ ): 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
52
+ }
53
+ } catch {
54
+ // noop
55
+ }
56
+
57
+ const effectivePaddingLeft = (paddingLeft || 0) + cssPaddingLeft
58
+ const effectivePaddingRight = (paddingRight || 0) + cssPaddingRight
59
+
60
+ const measuredScrollbarWidth = container.offsetWidth - container.clientWidth
61
+ const scrollbarWidth = measuredScrollbarWidth > 0 ? measuredScrollbarWidth + 2 : getScrollbarWidth() + 2
62
+
63
+ const usableWidth = container.offsetWidth - scrollbarWidth - effectivePaddingLeft - effectivePaddingRight
64
+ const totalGutterX = gutterX * (columnCount - 1)
65
+ const columnWidth = Math.floor((usableWidth - totalGutterX) / columnCount)
66
+
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
+ })
73
+
74
+ if (placement === 'sequential-balanced') {
75
+ const n = baseHeights.length
76
+ if (n === 0) return []
77
+
78
+ const addWithGutter = (currentSum: number, itemsInGroup: number, nextHeight: number) => {
79
+ return currentSum + (itemsInGroup > 0 ? gutterY : 0) + nextHeight
80
+ }
81
+
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
101
+ }
102
+ }
103
+ return groups <= columnCount
104
+ }
105
+
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
+ }
130
+ }
131
+ starts[0] = 0
132
+
133
+ 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
+
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 }
160
+
161
+ const col = columnHeights.indexOf(Math.min(...columnHeights))
162
+ const originalWidth = item.width
163
+ const originalHeight = item.height
164
+
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]
170
+
171
+ columnHeights[col] += newItem.columnHeight + gutterY
172
+
173
+ processedItems.push(newItem)
174
+ }
175
+
176
+ return processedItems
177
+ }
@@ -1,5 +1,5 @@
1
- import { createApp } from 'vue'
2
- import './style.css'
3
- import App from './App.vue'
4
-
5
- createApp(App).mount('#app')
1
+ import { createApp } from 'vue'
2
+ import './style.css'
3
+ import App from './App.vue'
4
+
5
+ createApp(App).mount('#app')
@@ -1,7 +1,9 @@
1
+ import type { LayoutOptions, ProcessedMasonryItem } from './types'
2
+
1
3
  /**
2
4
  * Get responsive column count based on window width and layout sizes
3
5
  */
4
- export function getColumnCount(layout) {
6
+ export function getColumnCount(layout: Pick<LayoutOptions, 'sizes'> & { sizes: Required<NonNullable<LayoutOptions['sizes']>> }): number {
5
7
  const width = window.innerWidth
6
8
  const sizes = layout.sizes
7
9
 
@@ -16,23 +18,19 @@ export function getColumnCount(layout) {
16
18
  /**
17
19
  * Calculate container height based on item positions
18
20
  */
19
- export function calculateContainerHeight(items) {
21
+ export function calculateContainerHeight(items: ProcessedMasonryItem[]): number {
20
22
  const contentHeight = items.reduce((acc, item) => {
21
23
  return Math.max(acc, item.top + item.columnHeight)
22
24
  }, 0)
23
-
24
- // Add 500px buffer to the container height
25
25
  return contentHeight + 500
26
26
  }
27
27
 
28
28
  /**
29
29
  * Get style object for masonry item positioning
30
30
  */
31
- export function getItemStyle(item) {
31
+ export function getItemStyle(item: ProcessedMasonryItem): Record<string, string> {
32
32
  return {
33
- // Use transform-based positioning for smooth, compositor-driven movement
34
- transform: `translate3d(${item.left}px, ${item.top}px, 0)`,
35
- // Keep top/left at 0 so only transform changes between layouts
33
+ transform: `translate3d(${item.left}px, ${item.top}px, 0)` ,
36
34
  top: '0px',
37
35
  left: '0px',
38
36
  width: `${item.columnWidth}px`,
@@ -43,12 +41,12 @@ export function getItemStyle(item) {
43
41
  /**
44
42
  * Get item attributes for rendering
45
43
  */
46
- export function getItemAttributes(item, index = 0) {
44
+ export function getItemAttributes(item: ProcessedMasonryItem, index: number = 0): Record<string, any> {
47
45
  return {
48
46
  style: getItemStyle(item),
49
47
  'data-top': item.top,
50
48
  'data-left': item.left,
51
- 'data-id': `${item.page}-${item.id}`,
49
+ 'data-id': `${(item as any).page}-${(item as any).id}`,
52
50
  'data-index': index,
53
51
  }
54
52
  }
@@ -56,8 +54,8 @@ export function getItemAttributes(item, index = 0) {
56
54
  /**
57
55
  * Calculate column heights for masonry layout
58
56
  */
59
- export function calculateColumnHeights(items, columnCount) {
60
- const heights = new Array(columnCount).fill(0)
57
+ export function calculateColumnHeights(items: ProcessedMasonryItem[], columnCount: number): number[] {
58
+ const heights = new Array<number>(columnCount).fill(0)
61
59
  for (let i = 0; i < items.length; i++) {
62
60
  const item = items[i]
63
61
  const col = i % columnCount
package/src/types.ts ADDED
@@ -0,0 +1,38 @@
1
+ export type MasonryItem = {
2
+ id: string
3
+ width: number
4
+ height: number
5
+ page: number
6
+ index: number
7
+ src: string
8
+ // allow extra fields
9
+ [key: string]: any
10
+ }
11
+
12
+ export type ProcessedMasonryItem = MasonryItem & {
13
+ columnWidth: number
14
+ imageHeight: number
15
+ columnHeight: number
16
+ left: number
17
+ top: number
18
+ }
19
+
20
+ export type LayoutOptions = {
21
+ gutterX?: number
22
+ gutterY?: number
23
+ header?: number
24
+ footer?: number
25
+ paddingLeft?: number
26
+ paddingRight?: number
27
+ sizes?: {
28
+ base: number
29
+ sm?: number
30
+ md?: number
31
+ lg?: number
32
+ xl?: number
33
+ '2xl'?: number
34
+ }
35
+ placement?: 'masonry' | 'sequential-balanced'
36
+ }
37
+
38
+ export type GetPageResult = { items: MasonryItem[]; nextPage: number | null }
@@ -1,5 +1,6 @@
1
- import { nextTick } from 'vue'
2
- import { calculateColumnHeights } from './masonryUtils.js'
1
+ import { nextTick, type Ref } from 'vue'
2
+ import { calculateColumnHeights } from './masonryUtils'
3
+ import type { ProcessedMasonryItem } from './types'
3
4
 
4
5
  /**
5
6
  * Composable for handling masonry scroll behavior and item cleanup
@@ -14,107 +15,94 @@ export function useMasonryScroll({
14
15
  pageSize,
15
16
  refreshLayout,
16
17
  setItemsRaw,
17
- loadNext
18
+ loadNext,
19
+ leaveEstimateMs
20
+ }: {
21
+ container: Ref<HTMLElement | null>
22
+ masonry: Ref<ProcessedMasonryItem[]>
23
+ columns: Ref<number>
24
+ containerHeight: Ref<number>
25
+ isLoading: Ref<boolean>
26
+ maxItems: number
27
+ pageSize: number
28
+ refreshLayout: (items: ProcessedMasonryItem[]) => void
29
+ setItemsRaw: (items: ProcessedMasonryItem[]) => void
30
+ loadNext: () => Promise<any>
31
+ leaveEstimateMs?: number
18
32
  }) {
19
33
  let cleanupInProgress = false
20
34
 
21
35
  async function handleScroll() {
36
+ if (!container.value) return
37
+
22
38
  const { scrollTop, clientHeight } = container.value
23
39
  const visibleBottom = scrollTop + clientHeight
24
40
 
25
41
  const columnHeights = calculateColumnHeights(masonry.value, columns.value)
26
- // Use the longest column instead of shortest for better trigger timing
27
42
  const longestColumn = Math.max(...columnHeights)
28
43
  const whitespaceVisible = longestColumn + 300 < visibleBottom - 1
29
44
  const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
30
45
 
31
46
  if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value && !cleanupInProgress) {
32
47
  try {
33
- // Handle cleanup when too many items
34
48
  if (masonry.value.length > maxItems) {
35
49
  await handleItemCleanup(columnHeights)
36
50
  }
37
51
 
38
- await loadNext() // loadNext manages its own loading state and error handling
52
+ await loadNext()
39
53
  await nextTick()
40
54
  } catch (error) {
41
55
  console.error('Error in scroll handler:', error)
42
- // loadNext already handles its own loading state, no need to reset here
43
56
  }
44
57
  }
45
58
  }
46
59
 
47
- async function handleItemCleanup(columnHeightsBefore) {
48
- if (!masonry.value.length) {
49
- return
50
- }
60
+ async function handleItemCleanup(columnHeightsBefore: number[]) {
61
+ if (!masonry.value.length) return
62
+ if (masonry.value.length <= pageSize) return
51
63
 
52
- if (masonry.value.length <= pageSize) {
53
- // If we have fewer items than pageSize, no cleanup needed
54
- return
55
- }
56
-
57
- // Group items by page to understand page structure
58
- const pageGroups = masonry.value.reduce((acc, item) => {
59
- if (!acc[item.page]) {
60
- acc[item.page] = []
61
- }
62
- acc[item.page].push(item)
64
+ const pageGroups: Record<string | number, ProcessedMasonryItem[]> = masonry.value.reduce((acc, item) => {
65
+ const key = (item as any).page
66
+ if (!acc[key]) acc[key] = []
67
+ acc[key].push(item)
63
68
  return acc
64
- }, {})
69
+ }, {} as Record<string | number, ProcessedMasonryItem[]>)
65
70
 
66
71
  const pages = Object.keys(pageGroups).sort((a, b) => parseInt(a) - parseInt(b))
67
-
68
- if (pages.length === 0) {
69
- return
70
- }
72
+ if (pages.length === 0) return
71
73
 
72
74
  let totalRemovedItems = 0
73
- let pagesToRemove = []
75
+ const pagesToRemove: string[] = []
74
76
 
75
- // Remove pages cumulatively until we reach at least pageSize items
76
77
  for (const page of pages) {
77
78
  pagesToRemove.push(page)
78
79
  totalRemovedItems += pageGroups[page].length
79
-
80
- if (totalRemovedItems >= pageSize) {
81
- break
82
- }
80
+ if (totalRemovedItems >= pageSize) break
83
81
  }
84
82
 
85
- // Phase 1: remove items (trigger leave) WITHOUT moving remaining items
86
- const remainingItems = masonry.value.filter(item => !pagesToRemove.includes(item.page.toString()))
87
-
88
- if (remainingItems.length === masonry.value.length) {
89
- // No items were removed, nothing to do
90
- return
91
- }
83
+ const remainingItems = masonry.value.filter(item => !pagesToRemove.includes(String((item as any).page)))
84
+ if (remainingItems.length === masonry.value.length) return
92
85
 
93
86
  cleanupInProgress = true
94
87
 
95
- // Set raw items so TransitionGroup triggers leave on removed items, but remaining items keep their current transforms
96
88
  setItemsRaw(remainingItems)
97
-
98
89
  await nextTick()
99
- // Wait for leave transitions to complete; use a conservative timeout
100
90
  await waitFor(msLeaveEstimate())
101
91
 
102
- // Phase 2: now recompute layout and animate moves for remaining items
103
92
  refreshLayout(remainingItems)
104
93
  await nextTick()
105
94
 
106
- // Maintain anchor after moves are applied
107
95
  await maintainAnchorPosition()
108
96
 
109
97
  cleanupInProgress = false
110
98
  }
111
99
 
112
100
  function msLeaveEstimate() {
113
- // Default estimate in ms; tweak if you change CSS leave timings
114
- return 700
101
+ const base = typeof leaveEstimateMs === 'number' && leaveEstimateMs > 0 ? leaveEstimateMs : 250
102
+ return base + 50
115
103
  }
116
104
 
117
- function waitFor(ms) {
105
+ function waitFor(ms: number) {
118
106
  return new Promise(resolve => setTimeout(resolve, ms))
119
107
  }
120
108
 
@@ -122,17 +110,14 @@ export function useMasonryScroll({
122
110
  if (!container.value) return
123
111
 
124
112
  const { scrollTop, clientHeight } = container.value
125
- const pivotY = scrollTop + clientHeight * 0.4 // aim to keep ~40% down the viewport stable
113
+ const pivotY = scrollTop + clientHeight * 0.4
126
114
 
127
- // Recompute column heights with the new layout
128
115
  const heights = calculateColumnHeights(masonry.value, columns.value)
129
116
  const anchorColumnIndex = heights.indexOf(Math.max(...heights))
130
117
 
131
- // Find items belonging to the anchor column
132
118
  const itemsInAnchor = masonry.value.filter((_, index) => index % columns.value === anchorColumnIndex)
133
119
  if (itemsInAnchor.length === 0) return
134
120
 
135
- // Choose the item whose top is the largest <= pivotY (closest above pivot)
136
121
  let pivotItem = itemsInAnchor[0]
137
122
  for (const it of itemsInAnchor) {
138
123
  if (it.top <= pivotY && it.top >= pivotItem.top) {
@@ -142,13 +127,11 @@ export function useMasonryScroll({
142
127
 
143
128
  const desiredTop = Math.max(0, pivotItem.top - clientHeight * 0.4)
144
129
 
145
- // Only adjust if we drifted significantly (> 4px) to avoid tiny corrections
146
130
  if (Math.abs(desiredTop - scrollTop) > 4) {
147
131
  container.value.scrollTo({ top: desiredTop, behavior: 'auto' })
148
132
  }
149
133
  }
150
134
 
151
- // Legacy function kept for compatibility; prefer maintainAnchorPosition()
152
135
  async function adjustScrollPosition() {
153
136
  await maintainAnchorPosition()
154
137
  }
@@ -1,25 +1,25 @@
1
1
  /**
2
- * Composable for handling masonry item transitions
2
+ * Composable for handling masonry item transitions (typed)
3
3
  */
4
- export function useMasonryTransitions(masonry) {
5
- function onEnter(el, done) {
6
- // Animate to its final transform (translate3d(left, top, 0)) with subtle scale/opacity
4
+ export function useMasonryTransitions(masonry: any) {
5
+ function onEnter(el: HTMLElement, done: () => void) {
7
6
  const left = parseInt(el.dataset.left || '0', 10)
8
7
  const top = parseInt(el.dataset.top || '0', 10)
9
8
  const index = parseInt(el.dataset.index || '0', 10)
10
9
 
11
- // Small stagger per item, capped
12
10
  const delay = Math.min(index * 20, 160)
13
-
14
- // Apply delay only for the enter; avoid affecting move transitions
15
- const prevDelay = el.style.transitionDelay
16
- el.style.transitionDelay = `${delay}ms`
11
+ const prevOpacityDelay = el.style.getPropertyValue('--masonry-opacity-delay')
12
+ el.style.setProperty('--masonry-opacity-delay', `${delay}ms`)
17
13
 
18
14
  requestAnimationFrame(() => {
19
15
  el.style.opacity = '1'
20
16
  el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
21
17
  const clear = () => {
22
- el.style.transitionDelay = prevDelay || '' // restore
18
+ if (prevOpacityDelay) {
19
+ el.style.setProperty('--masonry-opacity-delay', prevOpacityDelay)
20
+ } else {
21
+ el.style.removeProperty('--masonry-opacity-delay')
22
+ }
23
23
  el.removeEventListener('transitionend', clear)
24
24
  done()
25
25
  }
@@ -27,35 +27,41 @@ export function useMasonryTransitions(masonry) {
27
27
  })
28
28
  }
29
29
 
30
- function onBeforeEnter(el) {
31
- // Start slightly below and slightly smaller, faded
30
+ function onBeforeEnter(el: HTMLElement) {
32
31
  const left = parseInt(el.dataset.left || '0', 10)
33
32
  const top = parseInt(el.dataset.top || '0', 10)
34
33
  el.style.opacity = '0'
35
34
  el.style.transform = `translate3d(${left}px, ${top + 10}px, 0) scale(0.985)`
36
35
  }
37
36
 
38
- function onBeforeLeave(el) {
39
- // Ensure it is at its current transform position before animating
37
+ function onBeforeLeave(el: HTMLElement) {
40
38
  const left = parseInt(el.dataset.left || '0', 10)
41
39
  const top = parseInt(el.dataset.top || '0', 10)
42
40
  el.style.transition = 'none'
43
41
  el.style.opacity = '1'
44
42
  el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
45
- void el.offsetWidth // force reflow to flush style
46
- el.style.transition = '' // allow transition to apply again
43
+ el.style.removeProperty('--masonry-opacity-delay')
44
+ void el.offsetWidth
45
+ el.style.transition = ''
47
46
  }
48
47
 
49
- function onLeave(el, done) {
48
+ function onLeave(el: HTMLElement, done: () => void) {
50
49
  const left = parseInt(el.dataset.left || '0', 10)
51
50
  const top = parseInt(el.dataset.top || '0', 10)
52
51
 
53
- // Run on next frame to ensure transition styles are applied
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
56
+
57
+ const prevDuration = el.style.transitionDuration
58
+
54
59
  const cleanup = () => {
55
- el.removeEventListener('transitionend', onEnd)
60
+ el.removeEventListener('transitionend', onEnd as any)
56
61
  clearTimeout(fallback)
62
+ el.style.transitionDuration = prevDuration || ''
57
63
  }
58
- const onEnd = (e) => {
64
+ const onEnd = (e?: Event) => {
59
65
  if (!e || e.target === el) {
60
66
  cleanup()
61
67
  done()
@@ -64,12 +70,13 @@ export function useMasonryTransitions(masonry) {
64
70
  const fallback = setTimeout(() => {
65
71
  cleanup()
66
72
  done()
67
- }, 800)
73
+ }, leaveMs + 100)
68
74
 
69
75
  requestAnimationFrame(() => {
76
+ el.style.transitionDuration = `${leaveMs}ms`
70
77
  el.style.opacity = '0'
71
78
  el.style.transform = `translate3d(${left}px, ${top + 10}px, 0) scale(0.985)`
72
- el.addEventListener('transitionend', onEnd)
79
+ el.addEventListener('transitionend', onEnd as any)
73
80
  })
74
81
  }
75
82
 
package/index.js DELETED
@@ -1,10 +0,0 @@
1
- import Masonry from './src/Masonry.vue';
2
-
3
- export default {
4
- install(app) {
5
- app.component('WyxosMasonry', Masonry);
6
- app.component('WMasonry', Masonry);
7
- }
8
- };
9
-
10
- export { Masonry };
@@ -1,11 +0,0 @@
1
- /**
2
- * @vitest-environment jsdom
3
- */
4
-
5
- import { describe, it, expect } from 'vitest';
6
-
7
- describe('Example Test', () => {
8
- it('should pass a basic truthiness check', () => {
9
- expect(true).toBe(true)
10
- })
11
- })
@@ -1,74 +0,0 @@
1
- function getScrollbarWidth() {
2
- // Create a temporary div
3
- const div = document.createElement('div')
4
- div.style.visibility = 'hidden'
5
- div.style.overflow = 'scroll' // force scrollbar
6
- div.style.msOverflowStyle = 'scrollbar' // for IE
7
- div.style.width = '100px'
8
- div.style.height = '100px'
9
-
10
- // Append to body
11
- document.body.appendChild(div)
12
-
13
- // Create inner div and measure difference
14
- const inner = document.createElement('div')
15
- inner.style.width = '100%'
16
- div.appendChild(inner)
17
-
18
- const scrollbarWidth = div.offsetWidth - inner.offsetWidth
19
-
20
- // Clean up
21
- document.body.removeChild(div)
22
-
23
- return scrollbarWidth
24
- }
25
-
26
- export default function calculateLayout(items, container, columnCount, options = {}) {
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, // mobile-first default
36
- sm: 2, // ≥ 640px
37
- md: 3, // ≥ 768px
38
- lg: 4, // ≥ 1024px
39
- xl: 5, // ≥ 1280px
40
- '2xl': 6 // ≥ 1536px
41
- }
42
- } = options;
43
-
44
- const measuredScrollbarWidth = container.offsetWidth - container.clientWidth;
45
- const scrollbarWidth = measuredScrollbarWidth > 0 ? measuredScrollbarWidth + 2 : getScrollbarWidth() + 2;
46
- const usableWidth = container.offsetWidth - scrollbarWidth - paddingLeft - paddingRight;
47
- const totalGutterX = gutterX * (columnCount - 1);
48
- const columnWidth = Math.floor((usableWidth - totalGutterX) / columnCount);
49
-
50
- const columnHeights = new Array(columnCount).fill(0);
51
- const processedItems = [];
52
-
53
- for (let index = 0; index < items.length; index++) {
54
- const item = items[index];
55
- const newItem = { ...item };
56
-
57
- // Find the column with the shortest height for proper masonry layout
58
- const col = columnHeights.indexOf(Math.min(...columnHeights));
59
- const originalWidth = item.width;
60
- const originalHeight = item.height;
61
-
62
- newItem.columnWidth = columnWidth;
63
- newItem.left = col * (columnWidth + gutterX);
64
- newItem.imageHeight = Math.round((columnWidth * originalHeight) / originalWidth);
65
- newItem.columnHeight = newItem.imageHeight + footer + header;
66
- newItem.top = columnHeights[col];
67
-
68
- columnHeights[col] += newItem.columnHeight + gutterY;
69
-
70
- processedItems.push(newItem);
71
- }
72
-
73
- return processedItems;
74
- }