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