@wyxos/vibe 1.3.1 → 1.4.1
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 +684 -0
- package/lib/vibe.css +1 -0
- package/lib/vite.svg +1 -0
- package/package.json +28 -8
- package/src/App.vue +15 -18
- package/src/Masonry.vue +238 -90
- package/src/archive/InfiniteMansonry.spec.ts +10 -0
- package/src/calculateLayout.ts +177 -0
- package/src/{main.js → main.ts} +5 -5
- package/src/{masonryUtils.js → masonryUtils.ts} +10 -12
- package/src/types.ts +38 -0
- package/src/{useMasonryScroll.js → useMasonryScroll.ts} +45 -56
- package/src/{useMasonryTransitions.js → useMasonryTransitions.ts} +29 -22
- package/index.js +0 -10
- package/src/archive/InfiniteMansonry.spec.js +0 -11
- package/src/calculateLayout.js +0 -74
|
@@ -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
|
+
}
|
package/src/{main.js → main.ts}
RENAMED
|
@@ -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
|
-
|
|
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
|
|
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,100 @@ 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
|
|
34
|
+
let lastScrollTop = 0
|
|
20
35
|
|
|
21
36
|
async function handleScroll() {
|
|
37
|
+
if (!container.value) return
|
|
38
|
+
|
|
22
39
|
const { scrollTop, clientHeight } = container.value
|
|
23
40
|
const visibleBottom = scrollTop + clientHeight
|
|
24
41
|
|
|
42
|
+
// Determine scroll direction (down only)
|
|
43
|
+
const isScrollingDown = scrollTop > lastScrollTop + 1 // tolerate tiny jitter
|
|
44
|
+
lastScrollTop = scrollTop
|
|
45
|
+
|
|
25
46
|
const columnHeights = calculateColumnHeights(masonry.value, columns.value)
|
|
26
|
-
// Use the longest column instead of shortest for better trigger timing
|
|
27
47
|
const longestColumn = Math.max(...columnHeights)
|
|
28
48
|
const whitespaceVisible = longestColumn + 300 < visibleBottom - 1
|
|
29
49
|
const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
|
|
30
50
|
|
|
31
|
-
if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value && !cleanupInProgress) {
|
|
51
|
+
if ((whitespaceVisible || reachedContainerBottom) && isScrollingDown && !isLoading.value && !cleanupInProgress) {
|
|
32
52
|
try {
|
|
33
|
-
// Handle cleanup when too many items
|
|
34
53
|
if (masonry.value.length > maxItems) {
|
|
35
54
|
await handleItemCleanup(columnHeights)
|
|
36
55
|
}
|
|
37
56
|
|
|
38
|
-
await loadNext()
|
|
57
|
+
await loadNext()
|
|
39
58
|
await nextTick()
|
|
40
59
|
} catch (error) {
|
|
41
60
|
console.error('Error in scroll handler:', error)
|
|
42
|
-
// loadNext already handles its own loading state, no need to reset here
|
|
43
61
|
}
|
|
44
62
|
}
|
|
45
63
|
}
|
|
46
64
|
|
|
47
|
-
async function handleItemCleanup(columnHeightsBefore) {
|
|
48
|
-
if (!masonry.value.length)
|
|
49
|
-
|
|
50
|
-
}
|
|
65
|
+
async function handleItemCleanup(columnHeightsBefore: number[]) {
|
|
66
|
+
if (!masonry.value.length) return
|
|
67
|
+
if (masonry.value.length <= pageSize) return
|
|
51
68
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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)
|
|
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)
|
|
63
73
|
return acc
|
|
64
|
-
}, {})
|
|
74
|
+
}, {} as Record<string | number, ProcessedMasonryItem[]>)
|
|
65
75
|
|
|
66
76
|
const pages = Object.keys(pageGroups).sort((a, b) => parseInt(a) - parseInt(b))
|
|
67
|
-
|
|
68
|
-
if (pages.length === 0) {
|
|
69
|
-
return
|
|
70
|
-
}
|
|
77
|
+
if (pages.length === 0) return
|
|
71
78
|
|
|
72
79
|
let totalRemovedItems = 0
|
|
73
|
-
|
|
80
|
+
const pagesToRemove: string[] = []
|
|
74
81
|
|
|
75
|
-
// Remove pages cumulatively until we reach at least pageSize items
|
|
76
82
|
for (const page of pages) {
|
|
77
83
|
pagesToRemove.push(page)
|
|
78
84
|
totalRemovedItems += pageGroups[page].length
|
|
79
|
-
|
|
80
|
-
if (totalRemovedItems >= pageSize) {
|
|
81
|
-
break
|
|
82
|
-
}
|
|
85
|
+
if (totalRemovedItems >= pageSize) break
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (remainingItems.length === masonry.value.length) {
|
|
89
|
-
// No items were removed, nothing to do
|
|
90
|
-
return
|
|
91
|
-
}
|
|
88
|
+
const remainingItems = masonry.value.filter(item => !pagesToRemove.includes(String((item as any).page)))
|
|
89
|
+
if (remainingItems.length === masonry.value.length) return
|
|
92
90
|
|
|
93
91
|
cleanupInProgress = true
|
|
94
92
|
|
|
95
|
-
// Set raw items so TransitionGroup triggers leave on removed items, but remaining items keep their current transforms
|
|
96
93
|
setItemsRaw(remainingItems)
|
|
97
|
-
|
|
98
94
|
await nextTick()
|
|
99
|
-
//
|
|
100
|
-
await
|
|
95
|
+
// Allow leave to start, then FLIP survivors concurrently (single RAF)
|
|
96
|
+
await new Promise<void>(r => requestAnimationFrame(() => r()))
|
|
101
97
|
|
|
102
|
-
// Phase 2: now recompute layout and animate moves for remaining items
|
|
103
98
|
refreshLayout(remainingItems)
|
|
104
99
|
await nextTick()
|
|
105
100
|
|
|
106
|
-
// Maintain anchor after moves are applied
|
|
107
101
|
await maintainAnchorPosition()
|
|
108
102
|
|
|
109
103
|
cleanupInProgress = false
|
|
110
104
|
}
|
|
111
105
|
|
|
112
106
|
function msLeaveEstimate() {
|
|
113
|
-
|
|
114
|
-
return
|
|
107
|
+
const base = typeof leaveEstimateMs === 'number' && leaveEstimateMs > 0 ? leaveEstimateMs : 250
|
|
108
|
+
return base + 50
|
|
115
109
|
}
|
|
116
110
|
|
|
117
|
-
function waitFor(ms) {
|
|
111
|
+
function waitFor(ms: number) {
|
|
118
112
|
return new Promise(resolve => setTimeout(resolve, ms))
|
|
119
113
|
}
|
|
120
114
|
|
|
@@ -122,17 +116,14 @@ export function useMasonryScroll({
|
|
|
122
116
|
if (!container.value) return
|
|
123
117
|
|
|
124
118
|
const { scrollTop, clientHeight } = container.value
|
|
125
|
-
const pivotY = scrollTop + clientHeight * 0.4
|
|
119
|
+
const pivotY = scrollTop + clientHeight * 0.4
|
|
126
120
|
|
|
127
|
-
// Recompute column heights with the new layout
|
|
128
121
|
const heights = calculateColumnHeights(masonry.value, columns.value)
|
|
129
122
|
const anchorColumnIndex = heights.indexOf(Math.max(...heights))
|
|
130
123
|
|
|
131
|
-
// Find items belonging to the anchor column
|
|
132
124
|
const itemsInAnchor = masonry.value.filter((_, index) => index % columns.value === anchorColumnIndex)
|
|
133
125
|
if (itemsInAnchor.length === 0) return
|
|
134
126
|
|
|
135
|
-
// Choose the item whose top is the largest <= pivotY (closest above pivot)
|
|
136
127
|
let pivotItem = itemsInAnchor[0]
|
|
137
128
|
for (const it of itemsInAnchor) {
|
|
138
129
|
if (it.top <= pivotY && it.top >= pivotItem.top) {
|
|
@@ -142,13 +133,11 @@ export function useMasonryScroll({
|
|
|
142
133
|
|
|
143
134
|
const desiredTop = Math.max(0, pivotItem.top - clientHeight * 0.4)
|
|
144
135
|
|
|
145
|
-
// Only adjust if we drifted significantly (> 4px) to avoid tiny corrections
|
|
146
136
|
if (Math.abs(desiredTop - scrollTop) > 4) {
|
|
147
137
|
container.value.scrollTo({ top: desiredTop, behavior: 'auto' })
|
|
148
138
|
}
|
|
149
139
|
}
|
|
150
140
|
|
|
151
|
-
// Legacy function kept for compatibility; prefer maintainAnchorPosition()
|
|
152
141
|
async function adjustScrollPosition() {
|
|
153
142
|
await maintainAnchorPosition()
|
|
154
143
|
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
el.
|
|
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
|
-
|
|
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
|
-
},
|
|
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
package/src/calculateLayout.js
DELETED
|
@@ -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
|
-
}
|