@wyxos/vibe 1.2.16 → 1.3.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/package.json +1 -1
- package/src/Masonry.vue +151 -29
- package/src/calculateLayout.js +2 -1
- package/src/masonryUtils.js +12 -4
- package/src/useMasonryScroll.js +106 -33
- package/src/useMasonryTransitions.js +54 -17
package/package.json
CHANGED
package/src/Masonry.vue
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import {computed, nextTick, onMounted, onUnmounted, ref} from "vue";
|
|
3
3
|
import calculateLayout from "./calculateLayout.js";
|
|
4
4
|
import { debounce } from 'lodash-es'
|
|
5
|
-
import {
|
|
6
|
-
getColumnCount,
|
|
7
|
-
calculateContainerHeight,
|
|
5
|
+
import {
|
|
6
|
+
getColumnCount,
|
|
7
|
+
calculateContainerHeight,
|
|
8
8
|
getItemAttributes,
|
|
9
|
-
calculateColumnHeights
|
|
9
|
+
calculateColumnHeights
|
|
10
10
|
} from './masonryUtils.js'
|
|
11
11
|
import { useMasonryTransitions } from './useMasonryTransitions.js'
|
|
12
12
|
import { useMasonryScroll } from './useMasonryScroll.js'
|
|
@@ -40,6 +40,18 @@ const props = defineProps({
|
|
|
40
40
|
maxItems: {
|
|
41
41
|
type: Number,
|
|
42
42
|
default: 100
|
|
43
|
+
},
|
|
44
|
+
pageSize: {
|
|
45
|
+
type: Number,
|
|
46
|
+
default: 40
|
|
47
|
+
},
|
|
48
|
+
transitionDurationMs: {
|
|
49
|
+
type: Number,
|
|
50
|
+
default: 450
|
|
51
|
+
},
|
|
52
|
+
transitionEasing: {
|
|
53
|
+
type: String,
|
|
54
|
+
default: 'cubic-bezier(.22,.61,.36,1)'
|
|
43
55
|
}
|
|
44
56
|
})
|
|
45
57
|
|
|
@@ -81,10 +93,32 @@ const isLoading = ref(false)
|
|
|
81
93
|
|
|
82
94
|
const containerHeight = ref(0)
|
|
83
95
|
|
|
84
|
-
|
|
85
|
-
|
|
96
|
+
// Scroll progress tracking
|
|
97
|
+
const scrollProgress = ref({
|
|
98
|
+
distanceToTrigger: 0,
|
|
99
|
+
isNearTrigger: false
|
|
86
100
|
})
|
|
87
101
|
|
|
102
|
+
const updateScrollProgress = () => {
|
|
103
|
+
if (!container.value) return
|
|
104
|
+
|
|
105
|
+
const { scrollTop, clientHeight } = container.value
|
|
106
|
+
const visibleBottom = scrollTop + clientHeight
|
|
107
|
+
|
|
108
|
+
const columnHeights = calculateColumnHeights(masonry.value, columns.value)
|
|
109
|
+
// Use longest column to match the trigger logic in useMasonryScroll.js
|
|
110
|
+
const longestColumn = Math.max(...columnHeights)
|
|
111
|
+
const triggerPoint = longestColumn + 300 // Match: longestColumn + 300 < visibleBottom
|
|
112
|
+
|
|
113
|
+
const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
|
|
114
|
+
const isNearTrigger = distanceToTrigger <= 100
|
|
115
|
+
|
|
116
|
+
scrollProgress.value = {
|
|
117
|
+
distanceToTrigger: Math.round(distanceToTrigger),
|
|
118
|
+
isNearTrigger
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
88
122
|
// Setup composables
|
|
89
123
|
const { onEnter, onBeforeEnter, onBeforeLeave, onLeave } = useMasonryTransitions(masonry)
|
|
90
124
|
|
|
@@ -95,7 +129,10 @@ const { handleScroll } = useMasonryScroll({
|
|
|
95
129
|
containerHeight,
|
|
96
130
|
isLoading,
|
|
97
131
|
maxItems: props.maxItems,
|
|
132
|
+
pageSize: props.pageSize,
|
|
98
133
|
refreshLayout,
|
|
134
|
+
// Allow scroll composable to set items without recalculating layout (phase-1 cleanup)
|
|
135
|
+
setItemsRaw: (items) => { masonry.value = items },
|
|
99
136
|
loadNext
|
|
100
137
|
})
|
|
101
138
|
|
|
@@ -104,12 +141,22 @@ defineExpose({
|
|
|
104
141
|
refreshLayout,
|
|
105
142
|
containerHeight,
|
|
106
143
|
onRemove,
|
|
144
|
+
removeMany,
|
|
107
145
|
loadNext,
|
|
108
|
-
loadPage
|
|
146
|
+
loadPage,
|
|
147
|
+
reset,
|
|
148
|
+
paginationHistory
|
|
109
149
|
})
|
|
110
150
|
|
|
111
151
|
function calculateHeight(content) {
|
|
112
|
-
|
|
152
|
+
const newHeight = calculateContainerHeight(content)
|
|
153
|
+
let floor = 0
|
|
154
|
+
if (container.value) {
|
|
155
|
+
const { scrollTop, clientHeight } = container.value
|
|
156
|
+
// Ensure the container never shrinks below the visible viewport bottom + small buffer
|
|
157
|
+
floor = scrollTop + clientHeight + 100
|
|
158
|
+
}
|
|
159
|
+
containerHeight.value = Math.max(newHeight, floor)
|
|
113
160
|
}
|
|
114
161
|
|
|
115
162
|
function refreshLayout(items) {
|
|
@@ -121,18 +168,21 @@ function refreshLayout(items) {
|
|
|
121
168
|
}
|
|
122
169
|
|
|
123
170
|
async function getContent(page) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
171
|
+
try {
|
|
172
|
+
const response = await props.getNextPage(page)
|
|
173
|
+
refreshLayout([...masonry.value, ...response.items])
|
|
174
|
+
return response
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('Error in getContent:', error)
|
|
177
|
+
throw error
|
|
178
|
+
}
|
|
129
179
|
}
|
|
130
180
|
|
|
131
181
|
async function loadPage(page) {
|
|
132
182
|
if (isLoading.value) return // Prevent concurrent loading
|
|
133
|
-
|
|
183
|
+
|
|
134
184
|
isLoading.value = true
|
|
135
|
-
|
|
185
|
+
|
|
136
186
|
try {
|
|
137
187
|
const response = await getContent(page)
|
|
138
188
|
paginationHistory.value.push(response.nextPage)
|
|
@@ -147,9 +197,9 @@ async function loadPage(page) {
|
|
|
147
197
|
|
|
148
198
|
async function loadNext() {
|
|
149
199
|
if (isLoading.value) return // Prevent concurrent loading
|
|
150
|
-
|
|
200
|
+
|
|
151
201
|
isLoading.value = true
|
|
152
|
-
|
|
202
|
+
|
|
153
203
|
try {
|
|
154
204
|
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
155
205
|
const response = await getContent(currentPage)
|
|
@@ -167,11 +217,51 @@ function onRemove(item) {
|
|
|
167
217
|
refreshLayout(masonry.value.filter(i => i.id !== item.id))
|
|
168
218
|
}
|
|
169
219
|
|
|
220
|
+
function removeMany(items) {
|
|
221
|
+
if (!items || items.length === 0) return
|
|
222
|
+
const ids = new Set(items.map(i => i.id))
|
|
223
|
+
const next = masonry.value.filter(i => !ids.has(i.id))
|
|
224
|
+
refreshLayout(next)
|
|
225
|
+
}
|
|
226
|
+
|
|
170
227
|
function onResize() {
|
|
171
228
|
columns.value = getColumnCount(layout.value)
|
|
172
229
|
refreshLayout(masonry.value)
|
|
173
230
|
}
|
|
174
231
|
|
|
232
|
+
function reset() {
|
|
233
|
+
// Scroll back to top first (while items still exist to scroll through)
|
|
234
|
+
if (container.value) {
|
|
235
|
+
container.value.scrollTo({
|
|
236
|
+
top: 0,
|
|
237
|
+
behavior: 'smooth'
|
|
238
|
+
})
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Clear all items
|
|
242
|
+
masonry.value = []
|
|
243
|
+
|
|
244
|
+
// Reset container height
|
|
245
|
+
containerHeight.value = 0
|
|
246
|
+
|
|
247
|
+
// Reset pagination history to initial state
|
|
248
|
+
paginationHistory.value = [props.loadAtPage]
|
|
249
|
+
|
|
250
|
+
// Reset scroll progress
|
|
251
|
+
scrollProgress.value = {
|
|
252
|
+
distanceToTrigger: 0,
|
|
253
|
+
isNearTrigger: false
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Create debounced functions with stable references
|
|
258
|
+
const debouncedScrollHandler = debounce(() => {
|
|
259
|
+
handleScroll()
|
|
260
|
+
updateScrollProgress()
|
|
261
|
+
}, 200)
|
|
262
|
+
|
|
263
|
+
const debouncedResizeHandler = debounce(onResize, 200)
|
|
264
|
+
|
|
175
265
|
onMounted(async () => {
|
|
176
266
|
try {
|
|
177
267
|
columns.value = getColumnCount(layout.value)
|
|
@@ -188,31 +278,34 @@ onMounted(async () => {
|
|
|
188
278
|
// Just refresh the layout with any existing items
|
|
189
279
|
refreshLayout(masonry.value)
|
|
190
280
|
}
|
|
281
|
+
|
|
282
|
+
updateScrollProgress()
|
|
283
|
+
|
|
191
284
|
} catch (error) {
|
|
192
285
|
console.error('Error during component initialization:', error)
|
|
286
|
+
// Ensure loading state is reset if error occurs during initialization
|
|
287
|
+
isLoading.value = false
|
|
193
288
|
}
|
|
194
289
|
|
|
195
|
-
container.value?.addEventListener('scroll',
|
|
196
|
-
|
|
197
|
-
window.addEventListener('resize', debounce(onResize, 200));
|
|
290
|
+
container.value?.addEventListener('scroll', debouncedScrollHandler)
|
|
291
|
+
window.addEventListener('resize', debouncedResizeHandler)
|
|
198
292
|
})
|
|
199
293
|
|
|
200
294
|
onUnmounted(() => {
|
|
201
|
-
container.value?.removeEventListener('scroll',
|
|
202
|
-
|
|
203
|
-
window.removeEventListener('resize', debounce(onResize, 200));
|
|
295
|
+
container.value?.removeEventListener('scroll', debouncedScrollHandler)
|
|
296
|
+
window.removeEventListener('resize', debouncedResizeHandler)
|
|
204
297
|
})
|
|
205
298
|
</script>
|
|
206
299
|
|
|
207
300
|
<template>
|
|
208
|
-
<div class="overflow-auto w-full flex-1" ref="container"
|
|
209
|
-
<div class="relative" :style="{height: `${containerHeight}px
|
|
210
|
-
<transition-group :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
|
|
301
|
+
<div class="overflow-auto w-full flex-1 masonry-container" ref="container"
|
|
302
|
+
> <div class="relative" :style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-ease': transitionEasing}">
|
|
303
|
+
<transition-group name="masonry" :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
|
|
211
304
|
@leave="onLeave"
|
|
212
305
|
@before-leave="onBeforeLeave">
|
|
213
|
-
<div v-for="item in masonry" :key="`${item.page}-${item.id}`"
|
|
214
|
-
class="absolute
|
|
215
|
-
v-bind="getItemAttributes(item)">
|
|
306
|
+
<div v-for="(item, i) in masonry" :key="`${item.page}-${item.id}`"
|
|
307
|
+
class="absolute masonry-item"
|
|
308
|
+
v-bind="getItemAttributes(item, i)">
|
|
216
309
|
<slot name="item" v-bind="{item, onRemove}">
|
|
217
310
|
<img :src="item.src" class="w-full"/>
|
|
218
311
|
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
|
|
@@ -222,6 +315,35 @@ onUnmounted(() => {
|
|
|
222
315
|
</slot>
|
|
223
316
|
</div>
|
|
224
317
|
</transition-group>
|
|
318
|
+
|
|
319
|
+
<!-- Scroll Progress Badge -->
|
|
320
|
+
<div v-if="containerHeight > 0"
|
|
321
|
+
class="fixed bottom-4 right-4 bg-gray-800 text-white text-xs rounded-full px-3 py-1.5 shadow-lg z-10 transition-opacity duration-300"
|
|
322
|
+
:class="{'opacity-50 hover:opacity-100': !scrollProgress.isNearTrigger, 'opacity-100': scrollProgress.isNearTrigger}">
|
|
323
|
+
<span>{{ masonry.length }} items</span>
|
|
324
|
+
<span class="mx-2">|</span>
|
|
325
|
+
<span>{{ scrollProgress.distanceToTrigger }}px to load</span>
|
|
326
|
+
</div>
|
|
225
327
|
</div>
|
|
226
328
|
</div>
|
|
227
329
|
</template>
|
|
330
|
+
|
|
331
|
+
<style scoped>
|
|
332
|
+
/* Prevent browser scroll anchoring from adjusting scroll on content changes */
|
|
333
|
+
.masonry-container {
|
|
334
|
+
overflow-anchor: none;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* Items animate transform only for smooth, compositor-driven motion */
|
|
338
|
+
.masonry-item {
|
|
339
|
+
will-change: transform, opacity;
|
|
340
|
+
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22,.61,.36,1)),
|
|
341
|
+
opacity 200ms linear;
|
|
342
|
+
backface-visibility: hidden;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/* TransitionGroup move-class for FLIP reordering */
|
|
346
|
+
.masonry-move {
|
|
347
|
+
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22,.61,.36,1));
|
|
348
|
+
}
|
|
349
|
+
</style>
|
package/src/calculateLayout.js
CHANGED
|
@@ -54,7 +54,8 @@ export default function calculateLayout(items, container, columnCount, options =
|
|
|
54
54
|
const item = items[index];
|
|
55
55
|
const newItem = { ...item };
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
// Find the column with the shortest height for proper masonry layout
|
|
58
|
+
const col = columnHeights.indexOf(Math.min(...columnHeights));
|
|
58
59
|
const originalWidth = item.width;
|
|
59
60
|
const originalHeight = item.height;
|
|
60
61
|
|
package/src/masonryUtils.js
CHANGED
|
@@ -17,9 +17,12 @@ export function getColumnCount(layout) {
|
|
|
17
17
|
* Calculate container height based on item positions
|
|
18
18
|
*/
|
|
19
19
|
export function calculateContainerHeight(items) {
|
|
20
|
-
|
|
20
|
+
const contentHeight = items.reduce((acc, item) => {
|
|
21
21
|
return Math.max(acc, item.top + item.columnHeight)
|
|
22
22
|
}, 0)
|
|
23
|
+
|
|
24
|
+
// Add 500px buffer to the container height
|
|
25
|
+
return contentHeight + 500
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
/**
|
|
@@ -27,8 +30,11 @@ export function calculateContainerHeight(items) {
|
|
|
27
30
|
*/
|
|
28
31
|
export function getItemStyle(item) {
|
|
29
32
|
return {
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
36
|
+
top: '0px',
|
|
37
|
+
left: '0px',
|
|
32
38
|
width: `${item.columnWidth}px`,
|
|
33
39
|
height: `${item.columnHeight}px`
|
|
34
40
|
}
|
|
@@ -37,11 +43,13 @@ export function getItemStyle(item) {
|
|
|
37
43
|
/**
|
|
38
44
|
* Get item attributes for rendering
|
|
39
45
|
*/
|
|
40
|
-
export function getItemAttributes(item) {
|
|
46
|
+
export function getItemAttributes(item, index = 0) {
|
|
41
47
|
return {
|
|
42
48
|
style: getItemStyle(item),
|
|
43
49
|
'data-top': item.top,
|
|
50
|
+
'data-left': item.left,
|
|
44
51
|
'data-id': `${item.page}-${item.id}`,
|
|
52
|
+
'data-index': index,
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
|
package/src/useMasonryScroll.js
CHANGED
|
@@ -11,73 +11,146 @@ export function useMasonryScroll({
|
|
|
11
11
|
containerHeight,
|
|
12
12
|
isLoading,
|
|
13
13
|
maxItems,
|
|
14
|
+
pageSize,
|
|
14
15
|
refreshLayout,
|
|
16
|
+
setItemsRaw,
|
|
15
17
|
loadNext
|
|
16
18
|
}) {
|
|
17
|
-
|
|
19
|
+
let cleanupInProgress = false
|
|
20
|
+
|
|
18
21
|
async function handleScroll() {
|
|
19
22
|
const { scrollTop, clientHeight } = container.value
|
|
20
23
|
const visibleBottom = scrollTop + clientHeight
|
|
21
24
|
|
|
22
25
|
const columnHeights = calculateColumnHeights(masonry.value, columns.value)
|
|
23
|
-
|
|
26
|
+
// Use the longest column instead of shortest for better trigger timing
|
|
27
|
+
const longestColumn = Math.max(...columnHeights)
|
|
28
|
+
const whitespaceVisible = longestColumn + 300 < visibleBottom - 1
|
|
24
29
|
const reachedContainerBottom = scrollTop + clientHeight >= containerHeight.value - 1
|
|
25
30
|
|
|
26
|
-
if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value) {
|
|
31
|
+
if ((whitespaceVisible || reachedContainerBottom) && !isLoading.value && !cleanupInProgress) {
|
|
27
32
|
try {
|
|
28
33
|
// Handle cleanup when too many items
|
|
29
34
|
if (masonry.value.length > maxItems) {
|
|
30
35
|
await handleItemCleanup(columnHeights)
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
await loadNext() // loadNext manages its own loading state
|
|
38
|
+
await loadNext() // loadNext manages its own loading state and error handling
|
|
34
39
|
await nextTick()
|
|
35
40
|
} catch (error) {
|
|
36
41
|
console.error('Error in scroll handler:', error)
|
|
42
|
+
// loadNext already handles its own loading state, no need to reset here
|
|
37
43
|
}
|
|
38
44
|
}
|
|
39
45
|
}
|
|
40
46
|
|
|
41
|
-
async function handleItemCleanup(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
async function handleItemCleanup(columnHeightsBefore) {
|
|
48
|
+
if (!masonry.value.length) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
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)
|
|
63
|
+
return acc
|
|
64
|
+
}, {})
|
|
65
|
+
|
|
66
|
+
const pages = Object.keys(pageGroups).sort((a, b) => parseInt(a) - parseInt(b))
|
|
67
|
+
|
|
68
|
+
if (pages.length === 0) {
|
|
46
69
|
return
|
|
47
70
|
}
|
|
48
71
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
let totalRemovedItems = 0
|
|
73
|
+
let pagesToRemove = []
|
|
74
|
+
|
|
75
|
+
// Remove pages cumulatively until we reach at least pageSize items
|
|
76
|
+
for (const page of pages) {
|
|
77
|
+
pagesToRemove.push(page)
|
|
78
|
+
totalRemovedItems += pageGroups[page].length
|
|
79
|
+
|
|
80
|
+
if (totalRemovedItems >= pageSize) {
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
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
|
|
54
90
|
return
|
|
55
91
|
}
|
|
56
92
|
|
|
57
|
-
|
|
93
|
+
cleanupInProgress = true
|
|
94
|
+
|
|
95
|
+
// Set raw items so TransitionGroup triggers leave on removed items, but remaining items keep their current transforms
|
|
96
|
+
setItemsRaw(remainingItems)
|
|
97
|
+
|
|
58
98
|
await nextTick()
|
|
59
|
-
|
|
60
|
-
await
|
|
99
|
+
// Wait for leave transitions to complete; use a conservative timeout
|
|
100
|
+
await waitFor(msLeaveEstimate())
|
|
101
|
+
|
|
102
|
+
// Phase 2: now recompute layout and animate moves for remaining items
|
|
103
|
+
refreshLayout(remainingItems)
|
|
104
|
+
await nextTick()
|
|
105
|
+
|
|
106
|
+
// Maintain anchor after moves are applied
|
|
107
|
+
await maintainAnchorPosition()
|
|
108
|
+
|
|
109
|
+
cleanupInProgress = false
|
|
61
110
|
}
|
|
62
111
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
112
|
+
function msLeaveEstimate() {
|
|
113
|
+
// Default estimate in ms; tweak if you change CSS leave timings
|
|
114
|
+
return 700
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function waitFor(ms) {
|
|
118
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function maintainAnchorPosition() {
|
|
122
|
+
if (!container.value) return
|
|
123
|
+
|
|
124
|
+
const { scrollTop, clientHeight } = container.value
|
|
125
|
+
const pivotY = scrollTop + clientHeight * 0.4 // aim to keep ~40% down the viewport stable
|
|
126
|
+
|
|
127
|
+
// Recompute column heights with the new layout
|
|
128
|
+
const heights = calculateColumnHeights(masonry.value, columns.value)
|
|
129
|
+
const anchorColumnIndex = heights.indexOf(Math.max(...heights))
|
|
130
|
+
|
|
131
|
+
// Find items belonging to the anchor column
|
|
132
|
+
const itemsInAnchor = masonry.value.filter((_, index) => index % columns.value === anchorColumnIndex)
|
|
133
|
+
if (itemsInAnchor.length === 0) return
|
|
134
|
+
|
|
135
|
+
// Choose the item whose top is the largest <= pivotY (closest above pivot)
|
|
136
|
+
let pivotItem = itemsInAnchor[0]
|
|
137
|
+
for (const it of itemsInAnchor) {
|
|
138
|
+
if (it.top <= pivotY && it.top >= pivotItem.top) {
|
|
139
|
+
pivotItem = it
|
|
79
140
|
}
|
|
80
141
|
}
|
|
142
|
+
|
|
143
|
+
const desiredTop = Math.max(0, pivotItem.top - clientHeight * 0.4)
|
|
144
|
+
|
|
145
|
+
// Only adjust if we drifted significantly (> 4px) to avoid tiny corrections
|
|
146
|
+
if (Math.abs(desiredTop - scrollTop) > 4) {
|
|
147
|
+
container.value.scrollTo({ top: desiredTop, behavior: 'auto' })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Legacy function kept for compatibility; prefer maintainAnchorPosition()
|
|
152
|
+
async function adjustScrollPosition() {
|
|
153
|
+
await maintainAnchorPosition()
|
|
81
154
|
}
|
|
82
155
|
|
|
83
156
|
return {
|
|
@@ -3,37 +3,74 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export function useMasonryTransitions(masonry) {
|
|
5
5
|
function onEnter(el, done) {
|
|
6
|
-
//
|
|
7
|
-
const
|
|
6
|
+
// Animate to its final transform (translate3d(left, top, 0)) with subtle scale/opacity
|
|
7
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
8
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
9
|
+
const index = parseInt(el.dataset.index || '0', 10)
|
|
10
|
+
|
|
11
|
+
// Small stagger per item, capped
|
|
12
|
+
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`
|
|
17
|
+
|
|
8
18
|
requestAnimationFrame(() => {
|
|
9
|
-
el.style.
|
|
10
|
-
|
|
19
|
+
el.style.opacity = '1'
|
|
20
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
21
|
+
const clear = () => {
|
|
22
|
+
el.style.transitionDelay = prevDelay || '' // restore
|
|
23
|
+
el.removeEventListener('transitionend', clear)
|
|
24
|
+
done()
|
|
25
|
+
}
|
|
26
|
+
el.addEventListener('transitionend', clear)
|
|
11
27
|
})
|
|
12
28
|
}
|
|
13
29
|
|
|
14
30
|
function onBeforeEnter(el) {
|
|
15
|
-
//
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} else {
|
|
21
|
-
el.style.top = '0px'
|
|
22
|
-
}
|
|
31
|
+
// Start slightly below and slightly smaller, faded
|
|
32
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
33
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
34
|
+
el.style.opacity = '0'
|
|
35
|
+
el.style.transform = `translate3d(${left}px, ${top + 10}px, 0) scale(0.985)`
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
function onBeforeLeave(el) {
|
|
26
|
-
// Ensure it
|
|
39
|
+
// Ensure it is at its current transform position before animating
|
|
40
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
41
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
27
42
|
el.style.transition = 'none'
|
|
28
|
-
el.style.
|
|
43
|
+
el.style.opacity = '1'
|
|
44
|
+
el.style.transform = `translate3d(${left}px, ${top}px, 0) scale(1)`
|
|
29
45
|
void el.offsetWidth // force reflow to flush style
|
|
30
46
|
el.style.transition = '' // allow transition to apply again
|
|
31
47
|
}
|
|
32
48
|
|
|
33
49
|
function onLeave(el, done) {
|
|
34
|
-
el.
|
|
35
|
-
el.
|
|
36
|
-
|
|
50
|
+
const left = parseInt(el.dataset.left || '0', 10)
|
|
51
|
+
const top = parseInt(el.dataset.top || '0', 10)
|
|
52
|
+
|
|
53
|
+
// Run on next frame to ensure transition styles are applied
|
|
54
|
+
const cleanup = () => {
|
|
55
|
+
el.removeEventListener('transitionend', onEnd)
|
|
56
|
+
clearTimeout(fallback)
|
|
57
|
+
}
|
|
58
|
+
const onEnd = (e) => {
|
|
59
|
+
if (!e || e.target === el) {
|
|
60
|
+
cleanup()
|
|
61
|
+
done()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const fallback = setTimeout(() => {
|
|
65
|
+
cleanup()
|
|
66
|
+
done()
|
|
67
|
+
}, 800)
|
|
68
|
+
|
|
69
|
+
requestAnimationFrame(() => {
|
|
70
|
+
el.style.opacity = '0'
|
|
71
|
+
el.style.transform = `translate3d(${left}px, ${top + 10}px, 0) scale(0.985)`
|
|
72
|
+
el.addEventListener('transitionend', onEnd)
|
|
73
|
+
})
|
|
37
74
|
}
|
|
38
75
|
|
|
39
76
|
return {
|