@cleverbamboo/react-virtual-masonry 1.0.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/LICENSE +21 -0
- package/README.md +343 -0
- package/README.zh-CN.md +490 -0
- package/dist/DynamicMasonryView.d.ts +97 -0
- package/dist/DynamicMasonryView.d.ts.map +1 -0
- package/dist/FullWidthEqualHeightMasonry.d.ts +51 -0
- package/dist/FullWidthEqualHeightMasonry.d.ts.map +1 -0
- package/dist/VirtualMasonry.d.ts +35 -0
- package/dist/VirtualMasonry.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.esm.js +674 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +680 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var React = require('react');
|
|
4
|
+
|
|
5
|
+
// RAF 节流 Hook - 使用 requestAnimationFrame 优化滚动性能
|
|
6
|
+
function useRafThrottle$1(value) {
|
|
7
|
+
const [throttledValue, setThrottledValue] = React.useState(value);
|
|
8
|
+
const rafRef = React.useRef(null);
|
|
9
|
+
const valueRef = React.useRef(value);
|
|
10
|
+
React.useEffect(() => {
|
|
11
|
+
valueRef.current = value;
|
|
12
|
+
if (rafRef.current !== null) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
16
|
+
setThrottledValue(valueRef.current);
|
|
17
|
+
rafRef.current = null;
|
|
18
|
+
});
|
|
19
|
+
return () => {
|
|
20
|
+
if (rafRef.current !== null) {
|
|
21
|
+
cancelAnimationFrame(rafRef.current);
|
|
22
|
+
rafRef.current = null;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}, [value]);
|
|
26
|
+
return throttledValue;
|
|
27
|
+
}
|
|
28
|
+
// ==================== 布局算法 Hook ====================
|
|
29
|
+
function useMasonryLayout(items, containerWidth, minColumnWidth = 200, maxColumnWidth, gap = 16) {
|
|
30
|
+
const columnCount = React.useMemo(() => {
|
|
31
|
+
let cols = Math.max(1, Math.floor(containerWidth / minColumnWidth));
|
|
32
|
+
if (maxColumnWidth) {
|
|
33
|
+
const calculatedWidth = (containerWidth - gap * (cols - 1)) / cols;
|
|
34
|
+
while (calculatedWidth > maxColumnWidth && cols < 20) {
|
|
35
|
+
cols++;
|
|
36
|
+
const newWidth = (containerWidth - gap * (cols - 1)) / cols;
|
|
37
|
+
if (newWidth <= maxColumnWidth)
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return cols;
|
|
42
|
+
}, [containerWidth, minColumnWidth, maxColumnWidth, gap]);
|
|
43
|
+
const layout = React.useMemo(() => {
|
|
44
|
+
if (!containerWidth)
|
|
45
|
+
return [];
|
|
46
|
+
const columnHeights = Array(columnCount).fill(0);
|
|
47
|
+
let columnWidth = (containerWidth - gap * (columnCount - 1)) / columnCount;
|
|
48
|
+
if (maxColumnWidth && columnWidth > maxColumnWidth) {
|
|
49
|
+
columnWidth = maxColumnWidth;
|
|
50
|
+
}
|
|
51
|
+
return items.map((item) => {
|
|
52
|
+
const minCol = columnHeights.indexOf(Math.min(...columnHeights));
|
|
53
|
+
const x = (columnWidth + gap) * minCol;
|
|
54
|
+
const y = columnHeights[minCol];
|
|
55
|
+
const aspectRatio = item.height / item.width;
|
|
56
|
+
const scaledHeight = columnWidth * aspectRatio;
|
|
57
|
+
columnHeights[minCol] += scaledHeight + gap;
|
|
58
|
+
return Object.assign(Object.assign({}, item), { x,
|
|
59
|
+
y, width: columnWidth, height: scaledHeight });
|
|
60
|
+
});
|
|
61
|
+
}, [items, columnCount, containerWidth, maxColumnWidth, gap]);
|
|
62
|
+
const totalHeight = React.useMemo(() => {
|
|
63
|
+
if (layout.length === 0)
|
|
64
|
+
return 0;
|
|
65
|
+
const columnHeights = Array(columnCount).fill(0);
|
|
66
|
+
layout.forEach((item) => {
|
|
67
|
+
const colIndex = Math.round(item.x /
|
|
68
|
+
((containerWidth - gap * (columnCount - 1)) / columnCount + gap));
|
|
69
|
+
columnHeights[colIndex] = Math.max(columnHeights[colIndex], item.y + item.height);
|
|
70
|
+
});
|
|
71
|
+
return Math.max(...columnHeights);
|
|
72
|
+
}, [layout, columnCount, containerWidth, gap]);
|
|
73
|
+
return { layout, totalHeight, columnCount };
|
|
74
|
+
}
|
|
75
|
+
// ==================== 主组件 ====================
|
|
76
|
+
function VirtualMasonryCore({ items, renderItem, onLoadMore, minColumnWidth = 200, maxColumnWidth, gap = 16, buffer = 300, hasMore = true, loading = false, loadMoreThreshold = 500, }) {
|
|
77
|
+
const [containerWidth, setContainerWidth] = React.useState(0);
|
|
78
|
+
const [scrollTop, setScrollTop] = React.useState(0);
|
|
79
|
+
const containerRef = React.useRef(null);
|
|
80
|
+
const loadMoreTriggerRef = React.useRef(null);
|
|
81
|
+
const throttledScrollTop = useRafThrottle$1(scrollTop);
|
|
82
|
+
React.useLayoutEffect(() => {
|
|
83
|
+
const container = containerRef.current;
|
|
84
|
+
if (!container)
|
|
85
|
+
return;
|
|
86
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
87
|
+
setContainerWidth(container.clientWidth);
|
|
88
|
+
});
|
|
89
|
+
resizeObserver.observe(container);
|
|
90
|
+
setContainerWidth(container.clientWidth);
|
|
91
|
+
return () => resizeObserver.disconnect();
|
|
92
|
+
}, []);
|
|
93
|
+
const { layout, totalHeight } = useMasonryLayout(items, containerWidth, minColumnWidth, maxColumnWidth, gap);
|
|
94
|
+
const handleScroll = React.useCallback(() => {
|
|
95
|
+
if (!containerRef.current)
|
|
96
|
+
return;
|
|
97
|
+
// 使用 getBoundingClientRect 获取准确的容器位置
|
|
98
|
+
const rect = containerRef.current.getBoundingClientRect();
|
|
99
|
+
// rect.top 为负数表示容器顶部已滚出视口,取其绝对值即为已滚动距离
|
|
100
|
+
const scrollTop = Math.max(0, -rect.top);
|
|
101
|
+
setScrollTop(scrollTop);
|
|
102
|
+
}, []);
|
|
103
|
+
React.useEffect(() => {
|
|
104
|
+
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
105
|
+
return () => window.removeEventListener("scroll", handleScroll);
|
|
106
|
+
}, [handleScroll]);
|
|
107
|
+
React.useEffect(() => {
|
|
108
|
+
if (!onLoadMore || !hasMore || loading)
|
|
109
|
+
return;
|
|
110
|
+
const trigger = loadMoreTriggerRef.current;
|
|
111
|
+
if (!trigger)
|
|
112
|
+
return;
|
|
113
|
+
const observer = new IntersectionObserver((entries) => {
|
|
114
|
+
if (entries[0].isIntersecting) {
|
|
115
|
+
onLoadMore();
|
|
116
|
+
}
|
|
117
|
+
}, { rootMargin: `${loadMoreThreshold}px` });
|
|
118
|
+
observer.observe(trigger);
|
|
119
|
+
return () => observer.disconnect();
|
|
120
|
+
}, [onLoadMore, hasMore, loading, loadMoreThreshold]);
|
|
121
|
+
const visibleItems = React.useMemo(() => {
|
|
122
|
+
return layout.filter((item) => {
|
|
123
|
+
const viewTop = throttledScrollTop - buffer;
|
|
124
|
+
const viewBottom = throttledScrollTop + window.innerHeight + buffer;
|
|
125
|
+
return item.y + item.height >= viewTop && item.y <= viewBottom;
|
|
126
|
+
});
|
|
127
|
+
}, [layout, throttledScrollTop, buffer]);
|
|
128
|
+
return (React.createElement("div", { ref: containerRef, style: {
|
|
129
|
+
position: "relative",
|
|
130
|
+
width: "100%",
|
|
131
|
+
minHeight: totalHeight,
|
|
132
|
+
} },
|
|
133
|
+
visibleItems.map((item, index) => renderItem(item, index)),
|
|
134
|
+
onLoadMore && (React.createElement("div", { ref: loadMoreTriggerRef, style: {
|
|
135
|
+
position: "absolute",
|
|
136
|
+
bottom: 0,
|
|
137
|
+
left: 0,
|
|
138
|
+
right: 0,
|
|
139
|
+
height: 1,
|
|
140
|
+
pointerEvents: "none",
|
|
141
|
+
} }))));
|
|
142
|
+
}
|
|
143
|
+
function VirtualMasonry({ mapSize, renderItem, loadData, pageSize = 50, minColumnWidth = 200, maxColumnWidth, gap = 16, buffer = 1500, loadMoreThreshold = 800, }) {
|
|
144
|
+
const [items, setItems] = React.useState([]);
|
|
145
|
+
const [loading, setLoading] = React.useState(true); // 初始设为 true,立即显示 Loader
|
|
146
|
+
const [hasMore, setHasMore] = React.useState(true);
|
|
147
|
+
const [page, setPage] = React.useState(1);
|
|
148
|
+
// 防止重复初始加载
|
|
149
|
+
const initialLoadRef = React.useRef(false);
|
|
150
|
+
const defaultMapSize = (d) => {
|
|
151
|
+
var _a, _b, _c, _d;
|
|
152
|
+
return ({
|
|
153
|
+
width: (_b = (_a = d.width) !== null && _a !== void 0 ? _a : d.w) !== null && _b !== void 0 ? _b : d.imgW,
|
|
154
|
+
height: (_d = (_c = d.height) !== null && _c !== void 0 ? _c : d.h) !== null && _d !== void 0 ? _d : d.imgH,
|
|
155
|
+
});
|
|
156
|
+
};
|
|
157
|
+
// 使用 ref 存储最新的函数引用,避免依赖变化
|
|
158
|
+
const loadDataRef = React.useRef(loadData);
|
|
159
|
+
const mapSizeRef = React.useRef(mapSize);
|
|
160
|
+
React.useEffect(() => {
|
|
161
|
+
loadDataRef.current = loadData;
|
|
162
|
+
mapSizeRef.current = mapSize;
|
|
163
|
+
}, [loadData, mapSize]);
|
|
164
|
+
const handleLoadMore = React.useCallback((force = false) => {
|
|
165
|
+
// force 参数用于初始加载时跳过 loading 检查
|
|
166
|
+
if (!force && loading)
|
|
167
|
+
return;
|
|
168
|
+
setLoading(true);
|
|
169
|
+
if (loadDataRef.current) {
|
|
170
|
+
loadDataRef
|
|
171
|
+
.current(page, pageSize)
|
|
172
|
+
.then(({ data, hasMore: more }) => {
|
|
173
|
+
var _a;
|
|
174
|
+
const effectiveMapSize = (_a = mapSizeRef.current) !== null && _a !== void 0 ? _a : defaultMapSize;
|
|
175
|
+
setItems((prev) => [
|
|
176
|
+
...prev,
|
|
177
|
+
...data.map((d) => {
|
|
178
|
+
const { width, height } = effectiveMapSize(d);
|
|
179
|
+
return Object.assign(Object.assign({}, d), { width,
|
|
180
|
+
height, widthRatio: width / height });
|
|
181
|
+
}),
|
|
182
|
+
]);
|
|
183
|
+
setHasMore(more);
|
|
184
|
+
setPage((prev) => prev + 1);
|
|
185
|
+
})
|
|
186
|
+
.catch((error) => {
|
|
187
|
+
console.error("Failed to load data:", error);
|
|
188
|
+
})
|
|
189
|
+
.finally(() => {
|
|
190
|
+
setLoading(false);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}, [loading, page, pageSize]);
|
|
194
|
+
// 初始加载 - 使用 ref 防止重复调用
|
|
195
|
+
React.useEffect(() => {
|
|
196
|
+
if (!initialLoadRef.current && items.length === 0) {
|
|
197
|
+
initialLoadRef.current = true;
|
|
198
|
+
handleLoadMore(true); // 传入 force = true,跳过 loading 检查
|
|
199
|
+
}
|
|
200
|
+
}, [handleLoadMore, items.length]);
|
|
201
|
+
return (React.createElement(VirtualMasonryCore, { items: items, renderItem: renderItem, onLoadMore: handleLoadMore, minColumnWidth: minColumnWidth, maxColumnWidth: maxColumnWidth, gap: gap, buffer: buffer, hasMore: hasMore, loading: loading, loadMoreThreshold: loadMoreThreshold }));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const GAP = 8;
|
|
205
|
+
// RAF 节流 Hook
|
|
206
|
+
function useRafThrottle(value) {
|
|
207
|
+
const [throttledValue, setThrottledValue] = React.useState(value);
|
|
208
|
+
const rafRef = React.useRef();
|
|
209
|
+
React.useEffect(() => {
|
|
210
|
+
if (rafRef.current) {
|
|
211
|
+
cancelAnimationFrame(rafRef.current);
|
|
212
|
+
}
|
|
213
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
214
|
+
setThrottledValue(value);
|
|
215
|
+
});
|
|
216
|
+
return () => {
|
|
217
|
+
if (rafRef.current) {
|
|
218
|
+
cancelAnimationFrame(rafRef.current);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}, [value]);
|
|
222
|
+
return throttledValue;
|
|
223
|
+
}
|
|
224
|
+
// ==================== 布局算法 Hook ====================
|
|
225
|
+
function useFullWidthFillMasonry(items, containerWidth, targetRowHeight = 245, gap = GAP, sizeRange = [230, 260], maxItemWidth = 975, maxStretchRatio = 1.5) {
|
|
226
|
+
const layout = React.useMemo(() => {
|
|
227
|
+
if (!containerWidth || items.length === 0)
|
|
228
|
+
return [];
|
|
229
|
+
const positioned = [];
|
|
230
|
+
const [minHeight, maxHeight] = sizeRange;
|
|
231
|
+
let y = 0;
|
|
232
|
+
let i = 0;
|
|
233
|
+
while (i < items.length) {
|
|
234
|
+
let currentRow = [];
|
|
235
|
+
let effectiveTargetRowHeight = targetRowHeight;
|
|
236
|
+
if (i < items.length) {
|
|
237
|
+
currentRow.push(items[i]);
|
|
238
|
+
i++;
|
|
239
|
+
}
|
|
240
|
+
const isLastItem = i >= items.length;
|
|
241
|
+
if (!isLastItem && currentRow.length === 1) {
|
|
242
|
+
const firstItem = currentRow[0];
|
|
243
|
+
const secondItem = items[i];
|
|
244
|
+
let firstWidth = firstItem.widthRatio * targetRowHeight;
|
|
245
|
+
let secondWidth = secondItem.widthRatio * targetRowHeight;
|
|
246
|
+
let totalWidth = firstWidth + secondWidth + gap;
|
|
247
|
+
if (totalWidth > containerWidth) {
|
|
248
|
+
effectiveTargetRowHeight =
|
|
249
|
+
(containerWidth - gap) /
|
|
250
|
+
(firstItem.widthRatio + secondItem.widthRatio);
|
|
251
|
+
effectiveTargetRowHeight = Math.min(effectiveTargetRowHeight, targetRowHeight);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
while (i < items.length) {
|
|
255
|
+
const item = items[i];
|
|
256
|
+
const idealWidth = item.widthRatio * effectiveTargetRowHeight;
|
|
257
|
+
if (currentRow.length === 1) {
|
|
258
|
+
currentRow.push(item);
|
|
259
|
+
i++;
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
const currentRowWidth = currentRow.reduce((sum, rowItem) => {
|
|
263
|
+
return sum + rowItem.widthRatio * effectiveTargetRowHeight;
|
|
264
|
+
}, 0);
|
|
265
|
+
const totalWidthWithNew = currentRowWidth + idealWidth;
|
|
266
|
+
const totalGaps = currentRow.length * gap;
|
|
267
|
+
const requiredWidth = totalWidthWithNew + totalGaps;
|
|
268
|
+
const currentTotalWidth = currentRowWidth + (currentRow.length - 1) * gap;
|
|
269
|
+
if (requiredWidth <= containerWidth) {
|
|
270
|
+
currentRow.push(item);
|
|
271
|
+
i++;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
const scaleWithNew = containerWidth / (totalWidthWithNew + totalGaps);
|
|
275
|
+
const scaleCurrent = containerWidth / currentTotalWidth;
|
|
276
|
+
if (Math.abs(scaleWithNew - 1) < Math.abs(scaleCurrent - 1) &&
|
|
277
|
+
scaleWithNew <= maxStretchRatio) {
|
|
278
|
+
currentRow.push(item);
|
|
279
|
+
i++;
|
|
280
|
+
}
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const isLastRow = i >= items.length;
|
|
286
|
+
const totalGaps = (currentRow.length - 1) * gap;
|
|
287
|
+
const availableWidth = containerWidth - totalGaps;
|
|
288
|
+
const idealTotalWidthForScale = currentRow.reduce((sum, item) => {
|
|
289
|
+
return sum + item.widthRatio * effectiveTargetRowHeight;
|
|
290
|
+
}, 0);
|
|
291
|
+
let scale = availableWidth / idealTotalWidthForScale;
|
|
292
|
+
let adjustedRowHeight = effectiveTargetRowHeight * scale;
|
|
293
|
+
if (isLastRow) {
|
|
294
|
+
adjustedRowHeight = Math.min(Math.max(adjustedRowHeight, minHeight), targetRowHeight);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
if (scale > maxStretchRatio) {
|
|
298
|
+
if (currentRow.length > 2) {
|
|
299
|
+
i--;
|
|
300
|
+
currentRow.pop();
|
|
301
|
+
const newTotalGaps = (currentRow.length - 1) * gap;
|
|
302
|
+
const newAvailableWidth = containerWidth - newTotalGaps;
|
|
303
|
+
const newIdealWidth = currentRow.reduce((sum, item) => {
|
|
304
|
+
return sum + item.widthRatio * effectiveTargetRowHeight;
|
|
305
|
+
}, 0);
|
|
306
|
+
scale = newAvailableWidth / newIdealWidth;
|
|
307
|
+
adjustedRowHeight = effectiveTargetRowHeight * scale;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
adjustedRowHeight = Math.min(Math.max(adjustedRowHeight, minHeight), maxHeight);
|
|
311
|
+
}
|
|
312
|
+
let x = 0;
|
|
313
|
+
const idealWidths = currentRow.map((item) => item.widthRatio * adjustedRowHeight);
|
|
314
|
+
const idealTotalWidth = idealWidths.reduce((sum, w) => sum + w, 0);
|
|
315
|
+
if (isLastRow) {
|
|
316
|
+
const totalGapsWidth = (currentRow.length - 1) * gap;
|
|
317
|
+
const totalRequiredWidth = idealTotalWidth + totalGapsWidth;
|
|
318
|
+
if (totalRequiredWidth > containerWidth) {
|
|
319
|
+
const scale = (containerWidth - totalGapsWidth) / idealTotalWidth;
|
|
320
|
+
let remainingWidth = containerWidth;
|
|
321
|
+
currentRow.forEach((item, index) => {
|
|
322
|
+
const isLast = index === currentRow.length - 1;
|
|
323
|
+
let finalWidth;
|
|
324
|
+
if (isLast) {
|
|
325
|
+
finalWidth = remainingWidth;
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
finalWidth = Math.round(idealWidths[index] * scale);
|
|
329
|
+
finalWidth = Math.min(finalWidth, maxItemWidth);
|
|
330
|
+
}
|
|
331
|
+
positioned.push(Object.assign(Object.assign({}, item), { x,
|
|
332
|
+
y, width: finalWidth, height: Math.round(adjustedRowHeight) }));
|
|
333
|
+
x += finalWidth + gap;
|
|
334
|
+
remainingWidth -= finalWidth + gap;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
currentRow.forEach((item, index) => {
|
|
339
|
+
const idealWidth = idealWidths[index];
|
|
340
|
+
const finalWidth = Math.min(Math.round(idealWidth), maxItemWidth);
|
|
341
|
+
positioned.push(Object.assign(Object.assign({}, item), { x,
|
|
342
|
+
y, width: finalWidth, height: Math.round(adjustedRowHeight) }));
|
|
343
|
+
x += finalWidth + gap;
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
let remainingWidth = containerWidth;
|
|
349
|
+
let accumulatedIdealWidth = 0;
|
|
350
|
+
currentRow.forEach((item, index) => {
|
|
351
|
+
const isLast = index === currentRow.length - 1;
|
|
352
|
+
let finalWidth;
|
|
353
|
+
if (isLast) {
|
|
354
|
+
finalWidth = remainingWidth;
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
const currentIdealWidth = idealWidths[index];
|
|
358
|
+
accumulatedIdealWidth += currentIdealWidth;
|
|
359
|
+
const targetX = (accumulatedIdealWidth / idealTotalWidth) * containerWidth -
|
|
360
|
+
index * gap;
|
|
361
|
+
finalWidth = Math.round(targetX - x);
|
|
362
|
+
finalWidth = Math.min(finalWidth, maxItemWidth);
|
|
363
|
+
}
|
|
364
|
+
positioned.push(Object.assign(Object.assign({}, item), { x,
|
|
365
|
+
y, width: finalWidth, height: Math.round(adjustedRowHeight) }));
|
|
366
|
+
x += finalWidth + gap;
|
|
367
|
+
remainingWidth -= finalWidth + gap;
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
y += Math.round(adjustedRowHeight) + gap;
|
|
371
|
+
}
|
|
372
|
+
return positioned;
|
|
373
|
+
}, [
|
|
374
|
+
items,
|
|
375
|
+
containerWidth,
|
|
376
|
+
targetRowHeight,
|
|
377
|
+
gap,
|
|
378
|
+
sizeRange,
|
|
379
|
+
maxItemWidth,
|
|
380
|
+
maxStretchRatio,
|
|
381
|
+
]);
|
|
382
|
+
const totalHeight = React.useMemo(() => {
|
|
383
|
+
if (layout.length === 0)
|
|
384
|
+
return 0;
|
|
385
|
+
return layout.reduce((max, item) => Math.max(max, item.y + item.height), 0);
|
|
386
|
+
}, [layout]);
|
|
387
|
+
return { layout, totalHeight };
|
|
388
|
+
}
|
|
389
|
+
// ==================== 主组件 ====================
|
|
390
|
+
function FullWidthEqualHeightMasonryCore({ items, renderItem, onLoadMore, targetRowHeight = 245, gap = GAP, buffer = 1000, hasMore = true, loading = false, sizeRange = [230, 260], maxItemWidth = 975, maxStretchRatio = 1.5, loadMoreThreshold = 800, }) {
|
|
391
|
+
const [containerWidth, setContainerWidth] = React.useState(0);
|
|
392
|
+
const [scrollTop, setScrollTop] = React.useState(0);
|
|
393
|
+
const containerRef = React.useRef(null);
|
|
394
|
+
const loadMoreTriggerRef = React.useRef(null);
|
|
395
|
+
const [containerOffsetTop, setContainerOffsetTop] = React.useState(0);
|
|
396
|
+
const throttledScrollTop = useRafThrottle(scrollTop);
|
|
397
|
+
React.useLayoutEffect(() => {
|
|
398
|
+
if (!containerRef.current)
|
|
399
|
+
return;
|
|
400
|
+
setContainerOffsetTop(containerRef.current.offsetTop);
|
|
401
|
+
const ro = new ResizeObserver(() => {
|
|
402
|
+
if (containerRef.current) {
|
|
403
|
+
setContainerWidth(containerRef.current.clientWidth);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
ro.observe(containerRef.current);
|
|
407
|
+
setContainerWidth(containerRef.current.clientWidth);
|
|
408
|
+
return () => ro.disconnect();
|
|
409
|
+
}, []);
|
|
410
|
+
const { layout, totalHeight } = useFullWidthFillMasonry(items, containerWidth, targetRowHeight, gap, sizeRange, maxItemWidth, maxStretchRatio);
|
|
411
|
+
const handleScroll = React.useCallback(() => {
|
|
412
|
+
const scrollY = window.scrollY || window.pageYOffset;
|
|
413
|
+
setScrollTop(Math.max(0, scrollY - containerOffsetTop));
|
|
414
|
+
}, [containerOffsetTop]);
|
|
415
|
+
React.useEffect(() => {
|
|
416
|
+
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
417
|
+
return () => window.removeEventListener("scroll", handleScroll);
|
|
418
|
+
}, [handleScroll]);
|
|
419
|
+
React.useEffect(() => {
|
|
420
|
+
if (!onLoadMore || !hasMore || loading)
|
|
421
|
+
return;
|
|
422
|
+
const trigger = loadMoreTriggerRef.current;
|
|
423
|
+
if (!trigger)
|
|
424
|
+
return;
|
|
425
|
+
const observer = new IntersectionObserver((entries) => {
|
|
426
|
+
if (entries[0].isIntersecting) {
|
|
427
|
+
onLoadMore();
|
|
428
|
+
}
|
|
429
|
+
}, { rootMargin: `${loadMoreThreshold}px` });
|
|
430
|
+
observer.observe(trigger);
|
|
431
|
+
return () => observer.disconnect();
|
|
432
|
+
}, [onLoadMore, hasMore, loading, loadMoreThreshold]);
|
|
433
|
+
const visibleItems = React.useMemo(() => {
|
|
434
|
+
return layout
|
|
435
|
+
.map((item, originalIndex) => (Object.assign(Object.assign({}, item), { originalIndex })))
|
|
436
|
+
.filter((item) => {
|
|
437
|
+
const viewTop = throttledScrollTop - buffer;
|
|
438
|
+
const viewBottom = throttledScrollTop + window.innerHeight + buffer;
|
|
439
|
+
return item.y + item.height >= viewTop && item.y <= viewBottom;
|
|
440
|
+
});
|
|
441
|
+
}, [layout, throttledScrollTop, buffer]);
|
|
442
|
+
return (React.createElement("div", { ref: containerRef, style: {
|
|
443
|
+
position: "relative",
|
|
444
|
+
width: "100%",
|
|
445
|
+
minHeight: totalHeight,
|
|
446
|
+
} },
|
|
447
|
+
visibleItems.map((item) => (React.createElement(React.Fragment, { key: item.originalIndex }, renderItem(item, item.originalIndex)))),
|
|
448
|
+
onLoadMore && (React.createElement("div", { ref: loadMoreTriggerRef, style: {
|
|
449
|
+
position: "absolute",
|
|
450
|
+
bottom: 0,
|
|
451
|
+
left: 0,
|
|
452
|
+
right: 0,
|
|
453
|
+
height: 1,
|
|
454
|
+
pointerEvents: "none",
|
|
455
|
+
} }))));
|
|
456
|
+
}
|
|
457
|
+
function FullWidthEqualHeightMasonry({ mapSize, renderItem, enableAnimation: _enableAnimation = true, loadData, pageSize = 50, targetRowHeight = 245, sizeRange = [230, 260], maxItemWidth = 975, maxStretchRatio = 1.5, gap = GAP, buffer = 1500, loadMoreThreshold = 500, }) {
|
|
458
|
+
const [items, setItems] = React.useState([]);
|
|
459
|
+
const [loading, setLoading] = React.useState(true); // 初始设为 true,立即显示 Loader
|
|
460
|
+
const [hasMore, setHasMore] = React.useState(true);
|
|
461
|
+
const [page, setPage] = React.useState(1);
|
|
462
|
+
// 防止重复初始加载
|
|
463
|
+
const initialLoadRef = React.useRef(false);
|
|
464
|
+
const defaultMapSize = (d) => {
|
|
465
|
+
var _a, _b, _c, _d;
|
|
466
|
+
return ({
|
|
467
|
+
width: (_b = (_a = d.width) !== null && _a !== void 0 ? _a : d.w) !== null && _b !== void 0 ? _b : d.imgW,
|
|
468
|
+
height: (_d = (_c = d.height) !== null && _c !== void 0 ? _c : d.h) !== null && _d !== void 0 ? _d : d.imgH,
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
// 使用 ref 存储最新的函数引用,避免依赖变化
|
|
472
|
+
const loadDataRef = React.useRef(loadData);
|
|
473
|
+
const mapSizeRef = React.useRef(mapSize);
|
|
474
|
+
React.useEffect(() => {
|
|
475
|
+
loadDataRef.current = loadData;
|
|
476
|
+
mapSizeRef.current = mapSize;
|
|
477
|
+
}, [loadData, mapSize]);
|
|
478
|
+
const handleLoadMore = React.useCallback((force = false) => {
|
|
479
|
+
// force 参数用于初始加载时跳过 loading 检查
|
|
480
|
+
if (!force && loading)
|
|
481
|
+
return;
|
|
482
|
+
setLoading(true);
|
|
483
|
+
if (loadDataRef.current) {
|
|
484
|
+
loadDataRef
|
|
485
|
+
.current(page, pageSize)
|
|
486
|
+
.then(({ data, hasMore: more }) => {
|
|
487
|
+
var _a;
|
|
488
|
+
const effectiveMapSize = (_a = mapSizeRef.current) !== null && _a !== void 0 ? _a : defaultMapSize;
|
|
489
|
+
setItems((prev) => [
|
|
490
|
+
...prev,
|
|
491
|
+
...data.map((d) => {
|
|
492
|
+
const { width, height } = effectiveMapSize(d);
|
|
493
|
+
return Object.assign(Object.assign({}, d), { width,
|
|
494
|
+
height, widthRatio: width / height });
|
|
495
|
+
}),
|
|
496
|
+
]);
|
|
497
|
+
setHasMore(more);
|
|
498
|
+
setPage((prev) => prev + 1);
|
|
499
|
+
})
|
|
500
|
+
.catch((error) => {
|
|
501
|
+
console.error("Failed to load data:", error);
|
|
502
|
+
})
|
|
503
|
+
.finally(() => {
|
|
504
|
+
setLoading(false);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}, [loading, page, pageSize]);
|
|
508
|
+
// 初始加载 - 使用 ref 防止重复调用
|
|
509
|
+
React.useEffect(() => {
|
|
510
|
+
if (!initialLoadRef.current && items.length === 0) {
|
|
511
|
+
initialLoadRef.current = true;
|
|
512
|
+
handleLoadMore(true); // 传入 force = true,跳过 loading 检查
|
|
513
|
+
}
|
|
514
|
+
}, [handleLoadMore, items.length]);
|
|
515
|
+
return (React.createElement(FullWidthEqualHeightMasonryCore, { items: items, renderItem: renderItem, onLoadMore: handleLoadMore, targetRowHeight: targetRowHeight, sizeRange: sizeRange, maxItemWidth: maxItemWidth, maxStretchRatio: maxStretchRatio, gap: gap, buffer: buffer, hasMore: hasMore, loading: loading, loadMoreThreshold: loadMoreThreshold }));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/******************************************************************************
|
|
519
|
+
Copyright (c) Microsoft Corporation.
|
|
520
|
+
|
|
521
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
522
|
+
purpose with or without fee is hereby granted.
|
|
523
|
+
|
|
524
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
525
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
526
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
527
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
528
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
529
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
530
|
+
PERFORMANCE OF THIS SOFTWARE.
|
|
531
|
+
***************************************************************************** */
|
|
532
|
+
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
function __awaiter(thisArg, _arguments, P, generator) {
|
|
536
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
537
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
538
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
539
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
540
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
541
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
546
|
+
var e = new Error(message);
|
|
547
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// ==================== 类型定义 ====================
|
|
551
|
+
/**
|
|
552
|
+
* 视图类型枚举
|
|
553
|
+
* 1 - 瀑布流(不等宽不等高)
|
|
554
|
+
* 2 - 等高不等宽
|
|
555
|
+
*/
|
|
556
|
+
exports.ViewType = void 0;
|
|
557
|
+
(function (ViewType) {
|
|
558
|
+
ViewType[ViewType["WATERFALL"] = 1] = "WATERFALL";
|
|
559
|
+
ViewType[ViewType["EQUAL_HEIGHT"] = 2] = "EQUAL_HEIGHT";
|
|
560
|
+
})(exports.ViewType || (exports.ViewType = {}));
|
|
561
|
+
// ==================== 主组件 ====================
|
|
562
|
+
/**
|
|
563
|
+
* 动态瀑布流视图组件
|
|
564
|
+
*
|
|
565
|
+
* 支持两种视图模式:
|
|
566
|
+
* 1. 瀑布流(不等宽不等高) - Pinterest 风格
|
|
567
|
+
* 2. 等高不等宽 - Google Photos 风格
|
|
568
|
+
*/
|
|
569
|
+
function DynamicMasonryView({ isMasonry: controlledIsMasonry, defaultIsMasonry = true, enableAnimation = true, waterfallConfig = {}, equalHeightConfig = {}, loadData, pageSize = 50, renderItem, mapSize, renderInitialLoader, onLayoutTypeLoaded, onError, }) {
|
|
570
|
+
// ==================== 状态管理 ====================
|
|
571
|
+
const [isMasonry, setIsMasonry] = React.useState(controlledIsMasonry !== null && controlledIsMasonry !== void 0 ? controlledIsMasonry : null);
|
|
572
|
+
const [layoutTypeLoading, setLayoutTypeLoading] = React.useState(controlledIsMasonry === undefined);
|
|
573
|
+
const [firstPageData, setFirstPageData] = React.useState(null);
|
|
574
|
+
const loadDataRef = React.useRef(loadData);
|
|
575
|
+
const onLayoutTypeLoadedRef = React.useRef(onLayoutTypeLoaded);
|
|
576
|
+
const onErrorRef = React.useRef(onError);
|
|
577
|
+
React.useEffect(() => {
|
|
578
|
+
loadDataRef.current = loadData;
|
|
579
|
+
onLayoutTypeLoadedRef.current = onLayoutTypeLoaded;
|
|
580
|
+
onErrorRef.current = onError;
|
|
581
|
+
}, [loadData, onLayoutTypeLoaded, onError]);
|
|
582
|
+
// ==================== 包装的数据加载函数 ====================
|
|
583
|
+
const wrappedLoadData = React.useCallback((page, size) => __awaiter(this, void 0, void 0, function* () {
|
|
584
|
+
if (!loadDataRef.current) {
|
|
585
|
+
return { data: [], hasMore: false };
|
|
586
|
+
}
|
|
587
|
+
if (page === 1 && firstPageData) {
|
|
588
|
+
const cached = firstPageData;
|
|
589
|
+
setFirstPageData(null);
|
|
590
|
+
return cached;
|
|
591
|
+
}
|
|
592
|
+
const result = yield loadDataRef.current(page, size);
|
|
593
|
+
return {
|
|
594
|
+
data: result.data,
|
|
595
|
+
hasMore: result.hasMore,
|
|
596
|
+
};
|
|
597
|
+
}), [firstPageData]);
|
|
598
|
+
// ==================== 初始化布局类型 ====================
|
|
599
|
+
React.useEffect(() => {
|
|
600
|
+
var _a;
|
|
601
|
+
if (controlledIsMasonry !== undefined) {
|
|
602
|
+
setIsMasonry(controlledIsMasonry);
|
|
603
|
+
setLayoutTypeLoading(false);
|
|
604
|
+
(_a = onLayoutTypeLoadedRef.current) === null || _a === void 0 ? void 0 : _a.call(onLayoutTypeLoadedRef, controlledIsMasonry);
|
|
605
|
+
}
|
|
606
|
+
}, [controlledIsMasonry]);
|
|
607
|
+
const initializedRef = React.useRef(false);
|
|
608
|
+
React.useEffect(() => {
|
|
609
|
+
if (controlledIsMasonry !== undefined) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (initializedRef.current) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
const initializeLayoutType = () => __awaiter(this, void 0, void 0, function* () {
|
|
616
|
+
var _a, _b, _c, _d;
|
|
617
|
+
try {
|
|
618
|
+
setLayoutTypeLoading(true);
|
|
619
|
+
initializedRef.current = true;
|
|
620
|
+
if (loadDataRef.current) {
|
|
621
|
+
const result = yield loadDataRef.current(1, pageSize);
|
|
622
|
+
setFirstPageData({
|
|
623
|
+
data: result.data,
|
|
624
|
+
hasMore: result.hasMore,
|
|
625
|
+
});
|
|
626
|
+
if (result.isMasonry !== undefined) {
|
|
627
|
+
setIsMasonry(result.isMasonry);
|
|
628
|
+
(_a = onLayoutTypeLoadedRef.current) === null || _a === void 0 ? void 0 : _a.call(onLayoutTypeLoadedRef, result.isMasonry);
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
setIsMasonry(defaultIsMasonry);
|
|
632
|
+
(_b = onLayoutTypeLoadedRef.current) === null || _b === void 0 ? void 0 : _b.call(onLayoutTypeLoadedRef, defaultIsMasonry);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
setIsMasonry(defaultIsMasonry);
|
|
637
|
+
(_c = onLayoutTypeLoadedRef.current) === null || _c === void 0 ? void 0 : _c.call(onLayoutTypeLoadedRef, defaultIsMasonry);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
catch (error) {
|
|
641
|
+
console.error("Failed to load layout type:", error);
|
|
642
|
+
(_d = onErrorRef.current) === null || _d === void 0 ? void 0 : _d.call(onErrorRef, error);
|
|
643
|
+
setIsMasonry(defaultIsMasonry);
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
setLayoutTypeLoading(false);
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
initializeLayoutType();
|
|
650
|
+
// 只在 controlledIsMasonry, pageSize 变化时重新初始化
|
|
651
|
+
// 移除 defaultIsMasonry 依赖,避免接口返回后更新 layoutType 导致重复初始化
|
|
652
|
+
// loadData 等函数通过 ref 访问,不需要作为依赖项
|
|
653
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
654
|
+
}, [controlledIsMasonry, pageSize]);
|
|
655
|
+
// ==================== 渲染逻辑 ====================
|
|
656
|
+
if (layoutTypeLoading || isMasonry === null) {
|
|
657
|
+
// 使用自定义初始加载状态,或使用默认占位符
|
|
658
|
+
if (renderInitialLoader) {
|
|
659
|
+
return React.createElement(React.Fragment, null, renderInitialLoader());
|
|
660
|
+
}
|
|
661
|
+
// 默认占位符
|
|
662
|
+
return (React.createElement("div", { style: {
|
|
663
|
+
width: "100%",
|
|
664
|
+
display: "flex",
|
|
665
|
+
alignItems: "center",
|
|
666
|
+
justifyContent: "center",
|
|
667
|
+
} }));
|
|
668
|
+
}
|
|
669
|
+
if (isMasonry) {
|
|
670
|
+
return (React.createElement(VirtualMasonry, { renderItem: (item, index) => renderItem(item, index, true), enableAnimation: enableAnimation, loadData: wrappedLoadData, pageSize: pageSize, minColumnWidth: waterfallConfig.minColumnWidth, maxColumnWidth: waterfallConfig.maxColumnWidth, gap: waterfallConfig.gap, buffer: waterfallConfig.buffer, mapSize: mapSize }));
|
|
671
|
+
}
|
|
672
|
+
return (React.createElement(FullWidthEqualHeightMasonry, { renderItem: (item, index) => renderItem(item, index, false), enableAnimation: enableAnimation, loadData: wrappedLoadData, pageSize: pageSize, targetRowHeight: equalHeightConfig.targetRowHeight, sizeRange: equalHeightConfig.sizeRange, maxItemWidth: equalHeightConfig.maxItemWidth, maxStretchRatio: equalHeightConfig.maxStretchRatio, gap: equalHeightConfig.gap, buffer: equalHeightConfig.buffer, mapSize: mapSize }));
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
exports.DynamicMasonryView = DynamicMasonryView;
|
|
676
|
+
exports.FullWidthEqualHeightMasonry = FullWidthEqualHeightMasonry;
|
|
677
|
+
exports.FullWidthEqualHeightMasonryCore = FullWidthEqualHeightMasonryCore;
|
|
678
|
+
exports.VirtualMasonry = VirtualMasonry;
|
|
679
|
+
exports.VirtualMasonryCore = VirtualMasonryCore;
|
|
680
|
+
//# sourceMappingURL=index.js.map
|