@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
package/package.json
CHANGED
|
@@ -1,20 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyxos/vibe",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"main": "index.js",
|
|
5
|
-
"module": "index.js",
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"main": "lib/index.js",
|
|
5
|
+
"module": "lib/index.js",
|
|
6
|
+
"types": "lib/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./lib/index.js",
|
|
10
|
+
"types": "./lib/index.d.ts"
|
|
11
|
+
},
|
|
12
|
+
"./vibe.css": "./lib/vibe.css",
|
|
13
|
+
"./package.json": "./package.json"
|
|
14
|
+
},
|
|
6
15
|
"type": "module",
|
|
7
16
|
"files": [
|
|
8
|
-
"
|
|
9
|
-
"
|
|
17
|
+
"lib",
|
|
18
|
+
"src"
|
|
19
|
+
],
|
|
20
|
+
"sideEffects": [
|
|
21
|
+
"./lib/vibe.css"
|
|
10
22
|
],
|
|
11
23
|
"scripts": {
|
|
12
24
|
"dev": "vite",
|
|
13
|
-
"build": "vite build && node write-cname.js",
|
|
25
|
+
"build": "vue-tsc --noEmit && vite build && node write-cname.js",
|
|
26
|
+
"build:demo": "vue-tsc --noEmit && vite build && node write-cname.js",
|
|
27
|
+
"build:lib": "vite build --config vite.lib.config.ts && vue-tsc --declaration --emitDeclarationOnly --outDir lib",
|
|
14
28
|
"preview": "vite preview",
|
|
15
29
|
"watch": "vite build --watch",
|
|
16
30
|
"release": "node release.mjs",
|
|
17
|
-
"test": "vitest"
|
|
31
|
+
"test": "vitest",
|
|
32
|
+
"type-check": "vue-tsc --noEmit",
|
|
33
|
+
"prepublishOnly": "npm run build:lib"
|
|
18
34
|
},
|
|
19
35
|
"dependencies": {
|
|
20
36
|
"lodash": "^4.17.21",
|
|
@@ -28,13 +44,17 @@
|
|
|
28
44
|
"@tailwindcss/vite": "^4.0.15",
|
|
29
45
|
"@vitejs/plugin-vue": "^5.2.1",
|
|
30
46
|
"@vue/test-utils": "^2.4.6",
|
|
47
|
+
"@types/node": "^24.5.2",
|
|
31
48
|
"chalk": "^5.3.0",
|
|
32
49
|
"inquirer": "^10.1.8",
|
|
33
50
|
"jsdom": "^26.0.0",
|
|
34
51
|
"simple-git": "^3.27.0",
|
|
35
52
|
"tailwindcss": "^4.0.15",
|
|
53
|
+
"typescript": "^5.9.2",
|
|
36
54
|
"uuid": "^11.1.0",
|
|
37
55
|
"vite": "^6.2.0",
|
|
38
|
-
"
|
|
56
|
+
"vite-plugin-vue-mcp": "^0.3.2",
|
|
57
|
+
"vitest": "^3.0.9",
|
|
58
|
+
"vue-tsc": "^3.1.0"
|
|
39
59
|
}
|
|
40
60
|
}
|
package/src/App.vue
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
<script setup>
|
|
1
|
+
<script setup lang="ts">
|
|
2
2
|
import Masonry from "./Masonry.vue";
|
|
3
|
-
import {ref} from "vue";
|
|
3
|
+
import { ref } from "vue";
|
|
4
4
|
import fixture from "./pages.json";
|
|
5
|
+
import type { MasonryItem, GetPageResult } from "./types";
|
|
5
6
|
|
|
6
|
-
const items = ref([])
|
|
7
|
+
const items = ref<MasonryItem[]>([]);
|
|
7
8
|
|
|
8
|
-
const masonry = ref(null)
|
|
9
|
+
const masonry = ref<InstanceType<typeof Masonry> | null>(null);
|
|
9
10
|
|
|
10
|
-
const getPage = async (page) => {
|
|
11
|
+
const getPage = async (page: number): Promise<GetPageResult> => {
|
|
11
12
|
return new Promise((resolve) => {
|
|
12
13
|
setTimeout(() => {
|
|
13
14
|
// Check if the page exists in the fixture
|
|
14
|
-
const pageData = fixture[page - 1];
|
|
15
|
+
const pageData = (fixture as any[])[page - 1] as { items: MasonryItem[] } | undefined;
|
|
15
16
|
|
|
16
17
|
if (!pageData) {
|
|
17
18
|
// Return empty items if page doesn't exist
|
|
@@ -22,15 +23,15 @@ const getPage = async (page) => {
|
|
|
22
23
|
return;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
const output: GetPageResult = {
|
|
26
27
|
items: pageData.items,
|
|
27
|
-
nextPage: page < fixture.length ? page + 1 : null
|
|
28
|
+
nextPage: page < (fixture as any[]).length ? page + 1 : null
|
|
28
29
|
};
|
|
29
30
|
|
|
30
|
-
resolve(output)
|
|
31
|
-
}, 1000)
|
|
32
|
-
})
|
|
33
|
-
}
|
|
31
|
+
resolve(output);
|
|
32
|
+
}, 1000);
|
|
33
|
+
});
|
|
34
|
+
};
|
|
34
35
|
</script>
|
|
35
36
|
<template>
|
|
36
37
|
<main class="flex flex-col items-center p-4 bg-slate-100 h-screen overflow-hidden">
|
|
@@ -49,16 +50,12 @@ const getPage = async (page) => {
|
|
|
49
50
|
</div>
|
|
50
51
|
</header>
|
|
51
52
|
<masonry class="bg-blue-500 " v-model:items="items" :get-next-page="getPage" :load-at-page="1" ref="masonry">
|
|
52
|
-
<template #item="{item,
|
|
53
|
+
<template #item="{item, remove}">
|
|
53
54
|
<img :src="item.src" class="w-full"/>
|
|
54
|
-
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="
|
|
55
|
+
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer" @click="remove(item)">
|
|
55
56
|
<i class="fas fa-trash"></i>
|
|
56
57
|
</button>
|
|
57
58
|
</template>
|
|
58
59
|
</masonry>
|
|
59
60
|
</main>
|
|
60
61
|
</template>
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
package/src/Masonry.vue
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import {computed, nextTick, onMounted, onUnmounted, ref} from "vue";
|
|
3
|
-
import calculateLayout from "./calculateLayout
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
|
|
3
|
+
import calculateLayout from "./calculateLayout";
|
|
4
4
|
import { debounce } from 'lodash-es'
|
|
5
5
|
import {
|
|
6
6
|
getColumnCount,
|
|
7
7
|
calculateContainerHeight,
|
|
8
8
|
getItemAttributes,
|
|
9
9
|
calculateColumnHeights
|
|
10
|
-
} from './masonryUtils
|
|
11
|
-
import { useMasonryTransitions } from './useMasonryTransitions
|
|
12
|
-
import { useMasonryScroll } from './useMasonryScroll
|
|
10
|
+
} from './masonryUtils'
|
|
11
|
+
import { useMasonryTransitions } from './useMasonryTransitions'
|
|
12
|
+
import { useMasonryScroll } from './useMasonryScroll'
|
|
13
13
|
|
|
14
14
|
const props = defineProps({
|
|
15
15
|
getNextPage: {
|
|
16
16
|
type: Function,
|
|
17
|
-
default: () => {
|
|
18
|
-
}
|
|
17
|
+
default: () => {}
|
|
19
18
|
},
|
|
20
19
|
loadAtPage: {
|
|
21
20
|
type: [Number, String],
|
|
@@ -31,7 +30,7 @@ const props = defineProps({
|
|
|
31
30
|
paginationType: {
|
|
32
31
|
type: String,
|
|
33
32
|
default: 'page', // or 'cursor'
|
|
34
|
-
validator: v => ['page', 'cursor'].includes(v)
|
|
33
|
+
validator: (v: string) => ['page', 'cursor'].includes(v)
|
|
35
34
|
},
|
|
36
35
|
skipInitialLoad: {
|
|
37
36
|
type: Boolean,
|
|
@@ -45,24 +44,61 @@ const props = defineProps({
|
|
|
45
44
|
type: Number,
|
|
46
45
|
default: 40
|
|
47
46
|
},
|
|
47
|
+
// Backfill configuration
|
|
48
|
+
backfillEnabled: {
|
|
49
|
+
type: Boolean,
|
|
50
|
+
default: true
|
|
51
|
+
},
|
|
52
|
+
backfillDelayMs: {
|
|
53
|
+
type: Number,
|
|
54
|
+
default: 2000
|
|
55
|
+
},
|
|
56
|
+
backfillMaxCalls: {
|
|
57
|
+
type: Number,
|
|
58
|
+
default: 10
|
|
59
|
+
},
|
|
60
|
+
// Retry configuration
|
|
61
|
+
retryMaxAttempts: {
|
|
62
|
+
type: Number,
|
|
63
|
+
default: 3
|
|
64
|
+
},
|
|
65
|
+
retryInitialDelayMs: {
|
|
66
|
+
type: Number,
|
|
67
|
+
default: 2000
|
|
68
|
+
},
|
|
69
|
+
retryBackoffStepMs: {
|
|
70
|
+
type: Number,
|
|
71
|
+
default: 2000
|
|
72
|
+
},
|
|
48
73
|
transitionDurationMs: {
|
|
49
74
|
type: Number,
|
|
50
75
|
default: 450
|
|
51
76
|
},
|
|
77
|
+
// Shorter, snappier duration specifically for item removal (leave)
|
|
78
|
+
leaveDurationMs: {
|
|
79
|
+
type: Number,
|
|
80
|
+
default: 160
|
|
81
|
+
},
|
|
52
82
|
transitionEasing: {
|
|
53
83
|
type: String,
|
|
54
84
|
default: 'cubic-bezier(.22,.61,.36,1)'
|
|
85
|
+
},
|
|
86
|
+
// Force motion even when user has reduced-motion enabled
|
|
87
|
+
forceMotion: {
|
|
88
|
+
type: Boolean,
|
|
89
|
+
default: false
|
|
55
90
|
}
|
|
56
91
|
})
|
|
57
92
|
|
|
58
93
|
const defaultLayout = {
|
|
59
|
-
sizes: {
|
|
94
|
+
sizes: {base: 1, sm: 2, md: 3, lg: 4, xl: 5, '2xl': 6},
|
|
60
95
|
gutterX: 10,
|
|
61
96
|
gutterY: 10,
|
|
62
97
|
header: 0,
|
|
63
98
|
footer: 0,
|
|
64
99
|
paddingLeft: 0,
|
|
65
|
-
paddingRight: 0
|
|
100
|
+
paddingRight: 0,
|
|
101
|
+
placement: 'masonry'
|
|
66
102
|
}
|
|
67
103
|
|
|
68
104
|
const layout = computed(() => ({
|
|
@@ -74,27 +110,30 @@ const layout = computed(() => ({
|
|
|
74
110
|
}
|
|
75
111
|
}))
|
|
76
112
|
|
|
77
|
-
const emits = defineEmits([
|
|
78
|
-
|
|
79
|
-
|
|
113
|
+
const emits = defineEmits([
|
|
114
|
+
'update:items',
|
|
115
|
+
'backfill:start',
|
|
116
|
+
'backfill:tick',
|
|
117
|
+
'backfill:stop',
|
|
118
|
+
'retry:start',
|
|
119
|
+
'retry:tick',
|
|
120
|
+
'retry:stop'
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
const masonry = computed<any>({
|
|
80
124
|
get: () => props.items,
|
|
81
125
|
set: (val) => emits('update:items', val)
|
|
82
126
|
})
|
|
83
127
|
|
|
84
|
-
const columns = ref(7)
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
const nextPage = ref(null)
|
|
91
|
-
|
|
92
|
-
const isLoading = ref(false)
|
|
93
|
-
|
|
94
|
-
const containerHeight = ref(0)
|
|
128
|
+
const columns = ref<number>(7)
|
|
129
|
+
const container = ref<HTMLElement | null>(null)
|
|
130
|
+
const paginationHistory = ref<any[]>([])
|
|
131
|
+
const nextPage = ref<number | null>(null)
|
|
132
|
+
const isLoading = ref<boolean>(false)
|
|
133
|
+
const containerHeight = ref<number>(0)
|
|
95
134
|
|
|
96
135
|
// Scroll progress tracking
|
|
97
|
-
const scrollProgress = ref({
|
|
136
|
+
const scrollProgress = ref<{ distanceToTrigger: number; isNearTrigger: boolean }>({
|
|
98
137
|
distanceToTrigger: 0,
|
|
99
138
|
isNearTrigger: false
|
|
100
139
|
})
|
|
@@ -102,13 +141,12 @@ const scrollProgress = ref({
|
|
|
102
141
|
const updateScrollProgress = () => {
|
|
103
142
|
if (!container.value) return
|
|
104
143
|
|
|
105
|
-
const {
|
|
144
|
+
const {scrollTop, clientHeight} = container.value
|
|
106
145
|
const visibleBottom = scrollTop + clientHeight
|
|
107
146
|
|
|
108
|
-
const columnHeights = calculateColumnHeights(masonry.value, columns.value)
|
|
109
|
-
// Use longest column to match the trigger logic in useMasonryScroll.js
|
|
147
|
+
const columnHeights = calculateColumnHeights(masonry.value as any, columns.value)
|
|
110
148
|
const longestColumn = Math.max(...columnHeights)
|
|
111
|
-
const triggerPoint = longestColumn + 300
|
|
149
|
+
const triggerPoint = longestColumn + 300
|
|
112
150
|
|
|
113
151
|
const distanceToTrigger = Math.max(0, triggerPoint - visibleBottom)
|
|
114
152
|
const isNearTrigger = distanceToTrigger <= 100
|
|
@@ -120,57 +158,75 @@ const updateScrollProgress = () => {
|
|
|
120
158
|
}
|
|
121
159
|
|
|
122
160
|
// Setup composables
|
|
123
|
-
const {
|
|
161
|
+
const {onEnter, onBeforeEnter, onBeforeLeave, onLeave} = useMasonryTransitions(masonry)
|
|
124
162
|
|
|
125
|
-
const {
|
|
163
|
+
const {handleScroll} = useMasonryScroll({
|
|
126
164
|
container,
|
|
127
|
-
masonry,
|
|
165
|
+
masonry: masonry as any,
|
|
128
166
|
columns,
|
|
129
167
|
containerHeight,
|
|
130
168
|
isLoading,
|
|
131
169
|
maxItems: props.maxItems,
|
|
132
170
|
pageSize: props.pageSize,
|
|
133
171
|
refreshLayout,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
setItemsRaw: (items: any[]) => {
|
|
173
|
+
masonry.value = items
|
|
174
|
+
},
|
|
175
|
+
loadNext,
|
|
176
|
+
leaveEstimateMs: props.leaveDurationMs
|
|
137
177
|
})
|
|
138
178
|
|
|
139
179
|
defineExpose({
|
|
140
180
|
isLoading,
|
|
141
181
|
refreshLayout,
|
|
142
182
|
containerHeight,
|
|
143
|
-
|
|
183
|
+
remove,
|
|
144
184
|
removeMany,
|
|
145
185
|
loadNext,
|
|
146
186
|
loadPage,
|
|
147
187
|
reset,
|
|
188
|
+
init,
|
|
148
189
|
paginationHistory
|
|
149
190
|
})
|
|
150
191
|
|
|
151
|
-
function calculateHeight(content) {
|
|
152
|
-
const newHeight = calculateContainerHeight(content)
|
|
192
|
+
function calculateHeight(content: any[]) {
|
|
193
|
+
const newHeight = calculateContainerHeight(content as any)
|
|
153
194
|
let floor = 0
|
|
154
195
|
if (container.value) {
|
|
155
|
-
const {
|
|
156
|
-
// Ensure the container never shrinks below the visible viewport bottom + small buffer
|
|
196
|
+
const {scrollTop, clientHeight} = container.value
|
|
157
197
|
floor = scrollTop + clientHeight + 100
|
|
158
198
|
}
|
|
159
199
|
containerHeight.value = Math.max(newHeight, floor)
|
|
160
200
|
}
|
|
161
201
|
|
|
162
|
-
function refreshLayout(items) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
calculateHeight(content)
|
|
166
|
-
|
|
202
|
+
function refreshLayout(items: any[]) {
|
|
203
|
+
if (!container.value) return
|
|
204
|
+
const content = calculateLayout(items as any, container.value as HTMLElement, columns.value, layout.value as any)
|
|
205
|
+
calculateHeight(content as any)
|
|
167
206
|
masonry.value = content
|
|
168
207
|
}
|
|
169
208
|
|
|
170
|
-
|
|
209
|
+
function waitWithProgress(totalMs: number, onTick: (remaining: number, total: number) => void) {
|
|
210
|
+
return new Promise<void>((resolve) => {
|
|
211
|
+
const total = Math.max(0, totalMs | 0)
|
|
212
|
+
const start = Date.now()
|
|
213
|
+
onTick(total, total)
|
|
214
|
+
const id = setInterval(() => {
|
|
215
|
+
const elapsed = Date.now() - start
|
|
216
|
+
const remaining = Math.max(0, total - elapsed)
|
|
217
|
+
onTick(remaining, total)
|
|
218
|
+
if (remaining <= 0) {
|
|
219
|
+
clearInterval(id)
|
|
220
|
+
resolve()
|
|
221
|
+
}
|
|
222
|
+
}, 100)
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function getContent(page: number) {
|
|
171
227
|
try {
|
|
172
|
-
const response = await props.getNextPage(page)
|
|
173
|
-
refreshLayout([...masonry.value, ...response.items])
|
|
228
|
+
const response = await fetchWithRetry(() => props.getNextPage(page))
|
|
229
|
+
refreshLayout([...(masonry.value as any[]), ...response.items])
|
|
174
230
|
return response
|
|
175
231
|
} catch (error) {
|
|
176
232
|
console.error('Error in getContent:', error)
|
|
@@ -178,14 +234,41 @@ async function getContent(page) {
|
|
|
178
234
|
}
|
|
179
235
|
}
|
|
180
236
|
|
|
181
|
-
async function
|
|
182
|
-
|
|
237
|
+
async function fetchWithRetry<T = any>(fn: () => Promise<T>): Promise<T> {
|
|
238
|
+
let attempt = 0
|
|
239
|
+
const max = props.retryMaxAttempts
|
|
240
|
+
let delay = props.retryInitialDelayMs
|
|
241
|
+
// eslint-disable-next-line no-constant-condition
|
|
242
|
+
while (true) {
|
|
243
|
+
try {
|
|
244
|
+
const res = await fn()
|
|
245
|
+
if (attempt > 0) {
|
|
246
|
+
emits('retry:stop', {attempt, success: true})
|
|
247
|
+
}
|
|
248
|
+
return res
|
|
249
|
+
} catch (err) {
|
|
250
|
+
attempt++
|
|
251
|
+
if (attempt > max) {
|
|
252
|
+
emits('retry:stop', {attempt: attempt - 1, success: false})
|
|
253
|
+
throw err
|
|
254
|
+
}
|
|
255
|
+
emits('retry:start', {attempt, max, totalMs: delay})
|
|
256
|
+
await waitWithProgress(delay, (remaining, total) => {
|
|
257
|
+
emits('retry:tick', {attempt, remainingMs: remaining, totalMs: total})
|
|
258
|
+
})
|
|
259
|
+
delay += props.retryBackoffStepMs
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
183
263
|
|
|
264
|
+
async function loadPage(page: number) {
|
|
265
|
+
if (isLoading.value) return
|
|
184
266
|
isLoading.value = true
|
|
185
|
-
|
|
186
267
|
try {
|
|
268
|
+
const baseline = (masonry.value as any[]).length
|
|
187
269
|
const response = await getContent(page)
|
|
188
270
|
paginationHistory.value.push(response.nextPage)
|
|
271
|
+
await maybeBackfillToTarget(baseline)
|
|
189
272
|
return response
|
|
190
273
|
} catch (error) {
|
|
191
274
|
console.error('Error loading page:', error)
|
|
@@ -196,14 +279,14 @@ async function loadPage(page) {
|
|
|
196
279
|
}
|
|
197
280
|
|
|
198
281
|
async function loadNext() {
|
|
199
|
-
if (isLoading.value) return
|
|
200
|
-
|
|
282
|
+
if (isLoading.value) return
|
|
201
283
|
isLoading.value = true
|
|
202
|
-
|
|
203
284
|
try {
|
|
285
|
+
const baseline = (masonry.value as any[]).length
|
|
204
286
|
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
205
287
|
const response = await getContent(currentPage)
|
|
206
288
|
paginationHistory.value.push(response.nextPage)
|
|
289
|
+
await maybeBackfillToTarget(baseline)
|
|
207
290
|
return response
|
|
208
291
|
} catch (error) {
|
|
209
292
|
console.error('Error loading next page:', error)
|
|
@@ -213,24 +296,90 @@ async function loadNext() {
|
|
|
213
296
|
}
|
|
214
297
|
}
|
|
215
298
|
|
|
216
|
-
function
|
|
217
|
-
|
|
299
|
+
async function remove(item: any) {
|
|
300
|
+
const next = (masonry.value as any[]).filter(i => i.id !== item.id)
|
|
301
|
+
masonry.value = next
|
|
302
|
+
await nextTick()
|
|
303
|
+
// Force a reflow so current transforms are committed
|
|
304
|
+
void container.value?.offsetHeight
|
|
305
|
+
// Start FLIP on next frame (single RAF)
|
|
306
|
+
requestAnimationFrame(() => {
|
|
307
|
+
refreshLayout(next)
|
|
308
|
+
})
|
|
218
309
|
}
|
|
219
310
|
|
|
220
|
-
function removeMany(items) {
|
|
311
|
+
async function removeMany(items: any[]) {
|
|
221
312
|
if (!items || items.length === 0) return
|
|
222
313
|
const ids = new Set(items.map(i => i.id))
|
|
223
|
-
const next = masonry.value.filter(i => !ids.has(i.id))
|
|
224
|
-
|
|
314
|
+
const next = (masonry.value as any[]).filter(i => !ids.has(i.id))
|
|
315
|
+
masonry.value = next
|
|
316
|
+
await nextTick()
|
|
317
|
+
// Force a reflow so survivors' current transforms are committed
|
|
318
|
+
void container.value?.offsetHeight
|
|
319
|
+
// Start FLIP on next frame (single RAF)
|
|
320
|
+
requestAnimationFrame(() => {
|
|
321
|
+
refreshLayout(next)
|
|
322
|
+
})
|
|
225
323
|
}
|
|
226
324
|
|
|
227
325
|
function onResize() {
|
|
228
|
-
columns.value = getColumnCount(layout.value)
|
|
229
|
-
refreshLayout(masonry.value)
|
|
326
|
+
columns.value = getColumnCount(layout.value as any)
|
|
327
|
+
refreshLayout(masonry.value as any)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let backfillActive = false
|
|
331
|
+
|
|
332
|
+
async function maybeBackfillToTarget(baselineCount: number) {
|
|
333
|
+
if (!props.backfillEnabled) return
|
|
334
|
+
if (backfillActive) return
|
|
335
|
+
|
|
336
|
+
const targetCount = (baselineCount || 0) + (props.pageSize || 0)
|
|
337
|
+
if (!props.pageSize || props.pageSize <= 0) return
|
|
338
|
+
|
|
339
|
+
const lastNext = paginationHistory.value[paginationHistory.value.length - 1]
|
|
340
|
+
if (lastNext == null) return
|
|
341
|
+
|
|
342
|
+
if ((masonry.value as any[]).length >= targetCount) return
|
|
343
|
+
|
|
344
|
+
backfillActive = true
|
|
345
|
+
try {
|
|
346
|
+
let calls = 0
|
|
347
|
+
emits('backfill:start', {target: targetCount, fetched: (masonry.value as any[]).length, calls})
|
|
348
|
+
|
|
349
|
+
while (
|
|
350
|
+
(masonry.value as any[]).length < targetCount &&
|
|
351
|
+
calls < props.backfillMaxCalls &&
|
|
352
|
+
paginationHistory.value[paginationHistory.value.length - 1] != null
|
|
353
|
+
) {
|
|
354
|
+
await waitWithProgress(props.backfillDelayMs, (remaining, total) => {
|
|
355
|
+
emits('backfill:tick', {
|
|
356
|
+
fetched: (masonry.value as any[]).length,
|
|
357
|
+
target: targetCount,
|
|
358
|
+
calls,
|
|
359
|
+
remainingMs: remaining,
|
|
360
|
+
totalMs: total
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
const currentPage = paginationHistory.value[paginationHistory.value.length - 1]
|
|
365
|
+
try {
|
|
366
|
+
isLoading.value = true
|
|
367
|
+
const response = await getContent(currentPage)
|
|
368
|
+
paginationHistory.value.push(response.nextPage)
|
|
369
|
+
} finally {
|
|
370
|
+
isLoading.value = false
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
calls++
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
emits('backfill:stop', {fetched: (masonry.value as any[]).length, calls})
|
|
377
|
+
} finally {
|
|
378
|
+
backfillActive = false
|
|
379
|
+
}
|
|
230
380
|
}
|
|
231
381
|
|
|
232
382
|
function reset() {
|
|
233
|
-
// Scroll back to top first (while items still exist to scroll through)
|
|
234
383
|
if (container.value) {
|
|
235
384
|
container.value.scrollTo({
|
|
236
385
|
top: 0,
|
|
@@ -238,23 +387,16 @@ function reset() {
|
|
|
238
387
|
})
|
|
239
388
|
}
|
|
240
389
|
|
|
241
|
-
// Clear all items
|
|
242
390
|
masonry.value = []
|
|
243
|
-
|
|
244
|
-
// Reset container height
|
|
245
391
|
containerHeight.value = 0
|
|
246
|
-
|
|
247
|
-
// Reset pagination history to initial state
|
|
248
392
|
paginationHistory.value = [props.loadAtPage]
|
|
249
393
|
|
|
250
|
-
// Reset scroll progress
|
|
251
394
|
scrollProgress.value = {
|
|
252
395
|
distanceToTrigger: 0,
|
|
253
396
|
isNearTrigger: false
|
|
254
397
|
}
|
|
255
398
|
}
|
|
256
399
|
|
|
257
|
-
// Create debounced functions with stable references
|
|
258
400
|
const debouncedScrollHandler = debounce(() => {
|
|
259
401
|
handleScroll()
|
|
260
402
|
updateScrollProgress()
|
|
@@ -262,28 +404,28 @@ const debouncedScrollHandler = debounce(() => {
|
|
|
262
404
|
|
|
263
405
|
const debouncedResizeHandler = debounce(onResize, 200)
|
|
264
406
|
|
|
407
|
+
function init(items: any[], page: any, next: any) {
|
|
408
|
+
paginationHistory.value = [page]
|
|
409
|
+
paginationHistory.value.push(next)
|
|
410
|
+
refreshLayout([...(masonry.value as any[]), ...items])
|
|
411
|
+
updateScrollProgress()
|
|
412
|
+
}
|
|
413
|
+
|
|
265
414
|
onMounted(async () => {
|
|
266
415
|
try {
|
|
267
|
-
columns.value = getColumnCount(layout.value)
|
|
416
|
+
columns.value = getColumnCount(layout.value as any)
|
|
268
417
|
|
|
269
|
-
|
|
270
|
-
const initialPage = props.loadAtPage
|
|
418
|
+
const initialPage = props.loadAtPage as any
|
|
271
419
|
paginationHistory.value = [initialPage]
|
|
272
420
|
|
|
273
|
-
// Skip initial load if skipInitialLoad prop is true
|
|
274
421
|
if (!props.skipInitialLoad) {
|
|
275
|
-
await loadPage(paginationHistory.value[0]
|
|
276
|
-
} else {
|
|
277
|
-
await nextTick()
|
|
278
|
-
// Just refresh the layout with any existing items
|
|
279
|
-
refreshLayout(masonry.value)
|
|
422
|
+
await loadPage(paginationHistory.value[0] as any)
|
|
280
423
|
}
|
|
281
424
|
|
|
282
425
|
updateScrollProgress()
|
|
283
426
|
|
|
284
427
|
} catch (error) {
|
|
285
428
|
console.error('Error during component initialization:', error)
|
|
286
|
-
// Ensure loading state is reset if error occurs during initialization
|
|
287
429
|
isLoading.value = false
|
|
288
430
|
}
|
|
289
431
|
|
|
@@ -298,18 +440,19 @@ onUnmounted(() => {
|
|
|
298
440
|
</script>
|
|
299
441
|
|
|
300
442
|
<template>
|
|
301
|
-
<div class="overflow-auto w-full flex-1 masonry-container" ref="container"
|
|
302
|
-
|
|
443
|
+
<div class="overflow-auto w-full flex-1 masonry-container" :class="{ 'force-motion': props.forceMotion }" ref="container">
|
|
444
|
+
<div class="relative"
|
|
445
|
+
:style="{height: `${containerHeight}px`, '--masonry-duration': `${transitionDurationMs}ms`, '--masonry-leave-duration': `${leaveDurationMs}ms`, '--masonry-ease': transitionEasing}">
|
|
303
446
|
<transition-group name="masonry" :css="false" @enter="onEnter" @before-enter="onBeforeEnter"
|
|
304
447
|
@leave="onLeave"
|
|
305
448
|
@before-leave="onBeforeLeave">
|
|
306
449
|
<div v-for="(item, i) in masonry" :key="`${item.page}-${item.id}`"
|
|
307
450
|
class="absolute masonry-item"
|
|
308
451
|
v-bind="getItemAttributes(item, i)">
|
|
309
|
-
<slot name="item" v-bind="{item,
|
|
452
|
+
<slot name="item" v-bind="{item, remove}">
|
|
310
453
|
<img :src="item.src" class="w-full"/>
|
|
311
454
|
<button class="absolute bottom-0 right-0 bg-red-500 text-white p-2 rounded cursor-pointer"
|
|
312
|
-
@click="
|
|
455
|
+
@click="remove(item)">
|
|
313
456
|
<i class="fas fa-trash"></i>
|
|
314
457
|
</button>
|
|
315
458
|
</slot>
|
|
@@ -329,21 +472,26 @@ onUnmounted(() => {
|
|
|
329
472
|
</template>
|
|
330
473
|
|
|
331
474
|
<style scoped>
|
|
332
|
-
/* Prevent browser scroll anchoring from adjusting scroll on content changes */
|
|
333
475
|
.masonry-container {
|
|
334
476
|
overflow-anchor: none;
|
|
335
477
|
}
|
|
336
478
|
|
|
337
|
-
/* Items animate transform only for smooth, compositor-driven motion */
|
|
338
479
|
.masonry-item {
|
|
339
480
|
will-change: transform, opacity;
|
|
340
|
-
|
|
341
|
-
|
|
481
|
+
contain: layout paint;
|
|
482
|
+
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1)),
|
|
483
|
+
opacity var(--masonry-leave-duration, 160ms) ease-out var(--masonry-opacity-delay, 0ms);
|
|
342
484
|
backface-visibility: hidden;
|
|
343
485
|
}
|
|
344
486
|
|
|
345
|
-
/* TransitionGroup move-class for FLIP reordering */
|
|
346
487
|
.masonry-move {
|
|
347
|
-
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22
|
|
488
|
+
transition: transform var(--masonry-duration, 450ms) var(--masonry-ease, cubic-bezier(.22, .61, .36, 1));
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
@media (prefers-reduced-motion: reduce) {
|
|
492
|
+
.masonry-container:not(.force-motion) .masonry-item,
|
|
493
|
+
.masonry-container:not(.force-motion) .masonry-move {
|
|
494
|
+
transition-duration: 1ms !important;
|
|
495
|
+
}
|
|
348
496
|
}
|
|
349
497
|
</style>
|