smart-masonry-grid 0.1.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 +172 -0
- package/dist/index.cjs +694 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +165 -0
- package/dist/index.d.ts +165 -0
- package/dist/index.js +689 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +616 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +165 -0
- package/dist/react/index.d.ts +165 -0
- package/dist/react/index.js +612 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +86 -0
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useRef, useState, Children, useMemo, useCallback, useEffect } from 'react';
|
|
3
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
// src/layout.ts
|
|
6
|
+
function computeLayout(input) {
|
|
7
|
+
const { items, containerWidth, columnCount, gap } = input;
|
|
8
|
+
if (columnCount <= 0 || containerWidth <= 0 || items.length === 0) {
|
|
9
|
+
return { positions: [], columnHeights: [], totalHeight: 0 };
|
|
10
|
+
}
|
|
11
|
+
const columnWidth = (containerWidth - (columnCount - 1) * gap) / columnCount;
|
|
12
|
+
const columnHeights = new Float64Array(columnCount);
|
|
13
|
+
const positions = new Array(items.length);
|
|
14
|
+
for (let i = 0; i < items.length; i++) {
|
|
15
|
+
const item = items[i];
|
|
16
|
+
let shortestCol = 0;
|
|
17
|
+
let shortestHeight = columnHeights[0];
|
|
18
|
+
for (let c = 1; c < columnCount; c++) {
|
|
19
|
+
if (columnHeights[c] < shortestHeight) {
|
|
20
|
+
shortestHeight = columnHeights[c];
|
|
21
|
+
shortestCol = c;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const left = shortestCol * (columnWidth + gap);
|
|
25
|
+
const top = columnHeights[shortestCol];
|
|
26
|
+
positions[i] = {
|
|
27
|
+
id: item.id,
|
|
28
|
+
index: item.index,
|
|
29
|
+
top,
|
|
30
|
+
left,
|
|
31
|
+
width: columnWidth,
|
|
32
|
+
height: item.height,
|
|
33
|
+
column: shortestCol
|
|
34
|
+
};
|
|
35
|
+
columnHeights[shortestCol] = top + item.height + gap;
|
|
36
|
+
}
|
|
37
|
+
let maxHeight = 0;
|
|
38
|
+
for (let c = 0; c < columnCount; c++) {
|
|
39
|
+
if (columnHeights[c] > maxHeight) {
|
|
40
|
+
maxHeight = columnHeights[c];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const totalHeight = maxHeight > 0 ? maxHeight - gap : 0;
|
|
44
|
+
return {
|
|
45
|
+
positions,
|
|
46
|
+
columnHeights: Array.from(columnHeights),
|
|
47
|
+
totalHeight
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function resolveColumnCount(containerWidth, strategy, gap) {
|
|
51
|
+
if (strategy.type === "fixed") {
|
|
52
|
+
return Math.max(1, strategy.count);
|
|
53
|
+
}
|
|
54
|
+
if (strategy.type === "responsive") {
|
|
55
|
+
return resolveResponsiveColumns(containerWidth, strategy.breakpoints);
|
|
56
|
+
}
|
|
57
|
+
const count = Math.floor((containerWidth + gap) / (strategy.minColumnWidth + gap));
|
|
58
|
+
return Math.max(1, count);
|
|
59
|
+
}
|
|
60
|
+
function resolveResponsiveColumns(containerWidth, breakpoints) {
|
|
61
|
+
const entries = Object.keys(breakpoints).map((w) => [Number(w), breakpoints[Number(w)]]).sort((a, b) => b[0] - a[0]);
|
|
62
|
+
for (const [minWidth, cols] of entries) {
|
|
63
|
+
if (containerWidth >= minWidth) {
|
|
64
|
+
return Math.max(1, cols);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return entries.length > 0 ? Math.max(1, entries[entries.length - 1][1]) : 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// src/react/utils.ts
|
|
71
|
+
var NAMED_BREAKPOINTS = {
|
|
72
|
+
sm: 640,
|
|
73
|
+
md: 768,
|
|
74
|
+
lg: 1024,
|
|
75
|
+
xl: 1280
|
|
76
|
+
};
|
|
77
|
+
function resolveStrategy(columns) {
|
|
78
|
+
if (columns === void 0) return { type: "auto", minColumnWidth: 250 };
|
|
79
|
+
if (typeof columns === "number") return { type: "fixed", count: columns };
|
|
80
|
+
if ("type" in columns) return columns;
|
|
81
|
+
const breakpoints = {};
|
|
82
|
+
for (const [key, value] of Object.entries(columns)) {
|
|
83
|
+
const namedPx = NAMED_BREAKPOINTS[key];
|
|
84
|
+
breakpoints[namedPx ?? Number(key)] = value;
|
|
85
|
+
}
|
|
86
|
+
return { type: "responsive", breakpoints };
|
|
87
|
+
}
|
|
88
|
+
function resolveAnimationConfig(animate) {
|
|
89
|
+
if (!animate) return null;
|
|
90
|
+
if (animate === true) {
|
|
91
|
+
return { duration: 300, easing: "ease-out", offset: 20 };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
duration: animate.duration ?? 300,
|
|
95
|
+
easing: animate.easing ?? "ease-out",
|
|
96
|
+
offset: animate.offset ?? 20
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function Masonry({
|
|
100
|
+
children,
|
|
101
|
+
columns,
|
|
102
|
+
gap = 16,
|
|
103
|
+
className,
|
|
104
|
+
style,
|
|
105
|
+
onLayout,
|
|
106
|
+
animate,
|
|
107
|
+
onReachEnd,
|
|
108
|
+
reachEndThreshold = 200
|
|
109
|
+
}) {
|
|
110
|
+
const containerRef = useRef(null);
|
|
111
|
+
const itemRefs = useRef(/* @__PURE__ */ new Map());
|
|
112
|
+
const [positions, setPositions] = useState([]);
|
|
113
|
+
const [totalHeight, setTotalHeight] = useState(0);
|
|
114
|
+
const animConfig = resolveAnimationConfig(animate);
|
|
115
|
+
const animatedRef = useRef(/* @__PURE__ */ new Set());
|
|
116
|
+
const pendingAnimRef = useRef(/* @__PURE__ */ new Set());
|
|
117
|
+
const [, forceUpdate] = useState(0);
|
|
118
|
+
const sentinelRef = useRef(null);
|
|
119
|
+
const reachEndFiredRef = useRef(false);
|
|
120
|
+
const childArray = Children.toArray(children);
|
|
121
|
+
const childCount = childArray.length;
|
|
122
|
+
const strategy = resolveStrategy(columns);
|
|
123
|
+
const stableStrategy = useMemo(() => strategy, [
|
|
124
|
+
strategy.type,
|
|
125
|
+
strategy.type === "fixed" ? strategy.count : void 0,
|
|
126
|
+
strategy.type === "auto" ? strategy.minColumnWidth : void 0,
|
|
127
|
+
strategy.type === "responsive" ? JSON.stringify(strategy.breakpoints) : void 0
|
|
128
|
+
]);
|
|
129
|
+
const computePositions = useCallback(() => {
|
|
130
|
+
const container = containerRef.current;
|
|
131
|
+
if (!container || childCount === 0) return;
|
|
132
|
+
const containerWidth = container.offsetWidth;
|
|
133
|
+
if (containerWidth <= 0) return;
|
|
134
|
+
const colCount = resolveColumnCount(containerWidth, stableStrategy, gap);
|
|
135
|
+
const items = [];
|
|
136
|
+
for (let i = 0; i < childCount; i++) {
|
|
137
|
+
const el = itemRefs.current.get(i);
|
|
138
|
+
const height = el ? el.offsetHeight : 0;
|
|
139
|
+
items.push({ id: i, index: i, height });
|
|
140
|
+
}
|
|
141
|
+
const result = computeLayout({
|
|
142
|
+
items,
|
|
143
|
+
containerWidth,
|
|
144
|
+
columnCount: colCount,
|
|
145
|
+
gap
|
|
146
|
+
});
|
|
147
|
+
setPositions(result.positions);
|
|
148
|
+
setTotalHeight(result.totalHeight);
|
|
149
|
+
onLayout?.(result);
|
|
150
|
+
}, [childCount, gap, stableStrategy, onLayout]);
|
|
151
|
+
useEffect(() => {
|
|
152
|
+
const container = containerRef.current;
|
|
153
|
+
if (!container) return;
|
|
154
|
+
const ro = new ResizeObserver(() => computePositions());
|
|
155
|
+
ro.observe(container);
|
|
156
|
+
return () => ro.disconnect();
|
|
157
|
+
}, [computePositions]);
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
const items = itemRefs.current;
|
|
160
|
+
if (items.size === 0) return;
|
|
161
|
+
const ro = new ResizeObserver(() => computePositions());
|
|
162
|
+
for (const [, el] of items) {
|
|
163
|
+
ro.observe(el);
|
|
164
|
+
}
|
|
165
|
+
return () => ro.disconnect();
|
|
166
|
+
}, [childCount, computePositions]);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
computePositions();
|
|
169
|
+
}, [computePositions]);
|
|
170
|
+
useEffect(() => {
|
|
171
|
+
if (!animConfig || positions.length === 0) return;
|
|
172
|
+
const newItems = [];
|
|
173
|
+
for (let i = 0; i < positions.length; i++) {
|
|
174
|
+
if (!animatedRef.current.has(i)) {
|
|
175
|
+
animatedRef.current.add(i);
|
|
176
|
+
pendingAnimRef.current.add(i);
|
|
177
|
+
newItems.push(i);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (newItems.length === 0) return;
|
|
181
|
+
forceUpdate((c) => c + 1);
|
|
182
|
+
requestAnimationFrame(() => {
|
|
183
|
+
pendingAnimRef.current.clear();
|
|
184
|
+
forceUpdate((c) => c + 1);
|
|
185
|
+
});
|
|
186
|
+
}, [positions, animConfig]);
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
reachEndFiredRef.current = false;
|
|
189
|
+
}, [childCount]);
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
if (!onReachEnd) return;
|
|
192
|
+
const sentinel = sentinelRef.current;
|
|
193
|
+
if (!sentinel) return;
|
|
194
|
+
const observer = new IntersectionObserver(
|
|
195
|
+
([entry]) => {
|
|
196
|
+
if (entry.isIntersecting && !reachEndFiredRef.current) {
|
|
197
|
+
reachEndFiredRef.current = true;
|
|
198
|
+
onReachEnd();
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
{ rootMargin: `0px 0px ${reachEndThreshold}px 0px` }
|
|
202
|
+
);
|
|
203
|
+
observer.observe(sentinel);
|
|
204
|
+
return () => observer.disconnect();
|
|
205
|
+
}, [onReachEnd, reachEndThreshold, totalHeight]);
|
|
206
|
+
const containerStyle = {
|
|
207
|
+
position: "relative",
|
|
208
|
+
overflow: "hidden",
|
|
209
|
+
height: totalHeight > 0 ? totalHeight : void 0,
|
|
210
|
+
...style
|
|
211
|
+
};
|
|
212
|
+
return /* @__PURE__ */ jsxs("div", { ref: containerRef, className, style: containerStyle, children: [
|
|
213
|
+
childArray.map((child, index) => {
|
|
214
|
+
const pos = positions[index];
|
|
215
|
+
const isPending = animConfig && pendingAnimRef.current.has(index);
|
|
216
|
+
const hasAnimated = animConfig && animatedRef.current.has(index);
|
|
217
|
+
const itemStyle = pos ? {
|
|
218
|
+
position: "absolute",
|
|
219
|
+
top: 0,
|
|
220
|
+
left: 0,
|
|
221
|
+
transform: `translate3d(${pos.left}px, ${pos.top + (isPending ? animConfig.offset ?? 20 : 0)}px, 0)`,
|
|
222
|
+
width: pos.width,
|
|
223
|
+
willChange: "transform",
|
|
224
|
+
opacity: isPending ? 0 : 1,
|
|
225
|
+
transition: hasAnimated ? `opacity ${animConfig.duration}ms ${animConfig.easing}, transform ${animConfig.duration}ms ${animConfig.easing}` : void 0
|
|
226
|
+
} : { visibility: "hidden" };
|
|
227
|
+
return /* @__PURE__ */ jsx(
|
|
228
|
+
"div",
|
|
229
|
+
{
|
|
230
|
+
ref: (el) => {
|
|
231
|
+
if (el) {
|
|
232
|
+
itemRefs.current.set(index, el);
|
|
233
|
+
} else {
|
|
234
|
+
itemRefs.current.delete(index);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
style: itemStyle,
|
|
238
|
+
children: child
|
|
239
|
+
},
|
|
240
|
+
index
|
|
241
|
+
);
|
|
242
|
+
}),
|
|
243
|
+
onReachEnd && /* @__PURE__ */ jsx(
|
|
244
|
+
"div",
|
|
245
|
+
{
|
|
246
|
+
ref: sentinelRef,
|
|
247
|
+
style: {
|
|
248
|
+
position: "absolute",
|
|
249
|
+
bottom: 0,
|
|
250
|
+
height: 1,
|
|
251
|
+
width: "100%",
|
|
252
|
+
pointerEvents: "none"
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
)
|
|
256
|
+
] });
|
|
257
|
+
}
|
|
258
|
+
function findVisibleItems(positions, sortedIndices, scrollTop, viewportHeight, overscan, maxItemHeight) {
|
|
259
|
+
if (sortedIndices.length === 0) return [];
|
|
260
|
+
const rangeTop = scrollTop - overscan;
|
|
261
|
+
const rangeBottom = scrollTop + viewportHeight + overscan;
|
|
262
|
+
const searchTop = rangeTop - maxItemHeight;
|
|
263
|
+
let lo = 0;
|
|
264
|
+
let hi = sortedIndices.length;
|
|
265
|
+
while (lo < hi) {
|
|
266
|
+
const mid = lo + hi >>> 1;
|
|
267
|
+
if (positions[sortedIndices[mid]].top < searchTop) {
|
|
268
|
+
lo = mid + 1;
|
|
269
|
+
} else {
|
|
270
|
+
hi = mid;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const result = [];
|
|
274
|
+
for (let i = lo; i < sortedIndices.length; i++) {
|
|
275
|
+
const idx = sortedIndices[i];
|
|
276
|
+
const pos = positions[idx];
|
|
277
|
+
if (pos.top >= rangeBottom) break;
|
|
278
|
+
if (pos.top + pos.height > rangeTop) {
|
|
279
|
+
result.push(idx);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
function VirtualMasonry({
|
|
285
|
+
totalItems,
|
|
286
|
+
renderItem,
|
|
287
|
+
columns,
|
|
288
|
+
gap = 16,
|
|
289
|
+
height,
|
|
290
|
+
overscan = 600,
|
|
291
|
+
estimatedItemHeight = 300,
|
|
292
|
+
className,
|
|
293
|
+
style,
|
|
294
|
+
onLayout,
|
|
295
|
+
animate,
|
|
296
|
+
onReachEnd,
|
|
297
|
+
reachEndThreshold = 200,
|
|
298
|
+
placeholder
|
|
299
|
+
}) {
|
|
300
|
+
const scrollRef = useRef(null);
|
|
301
|
+
const itemRefs = useRef(/* @__PURE__ */ new Map());
|
|
302
|
+
const [heightMap, setHeightMap] = useState(
|
|
303
|
+
() => /* @__PURE__ */ new Map()
|
|
304
|
+
);
|
|
305
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
306
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
307
|
+
const animConfig = resolveAnimationConfig(animate);
|
|
308
|
+
const animatedRef = useRef(/* @__PURE__ */ new Set());
|
|
309
|
+
const pendingAnimRef = useRef(/* @__PURE__ */ new Set());
|
|
310
|
+
const [, forceUpdate] = useState(0);
|
|
311
|
+
const reachEndFiredRef = useRef(false);
|
|
312
|
+
const prevTotalItemsRef = useRef(totalItems);
|
|
313
|
+
if (prevTotalItemsRef.current !== totalItems) {
|
|
314
|
+
prevTotalItemsRef.current = totalItems;
|
|
315
|
+
setHeightMap(/* @__PURE__ */ new Map());
|
|
316
|
+
animatedRef.current.clear();
|
|
317
|
+
pendingAnimRef.current.clear();
|
|
318
|
+
}
|
|
319
|
+
const strategy = resolveStrategy(columns);
|
|
320
|
+
const stableStrategy = useMemo(() => strategy, [
|
|
321
|
+
strategy.type,
|
|
322
|
+
strategy.type === "fixed" ? strategy.count : void 0,
|
|
323
|
+
strategy.type === "auto" ? strategy.minColumnWidth : void 0,
|
|
324
|
+
strategy.type === "responsive" ? JSON.stringify(strategy.breakpoints) : void 0
|
|
325
|
+
]);
|
|
326
|
+
const columnCount = useMemo(
|
|
327
|
+
() => containerWidth > 0 ? resolveColumnCount(containerWidth, stableStrategy, gap) : 1,
|
|
328
|
+
[containerWidth, stableStrategy, gap]
|
|
329
|
+
);
|
|
330
|
+
const layoutItems = useMemo(() => {
|
|
331
|
+
const items = [];
|
|
332
|
+
for (let i = 0; i < totalItems; i++) {
|
|
333
|
+
items.push({
|
|
334
|
+
id: i,
|
|
335
|
+
index: i,
|
|
336
|
+
height: heightMap.get(i) ?? estimatedItemHeight
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return items;
|
|
340
|
+
}, [totalItems, heightMap, estimatedItemHeight]);
|
|
341
|
+
const layout = useMemo(() => {
|
|
342
|
+
if (containerWidth <= 0) return null;
|
|
343
|
+
const result = computeLayout({
|
|
344
|
+
items: layoutItems,
|
|
345
|
+
containerWidth,
|
|
346
|
+
columnCount,
|
|
347
|
+
gap
|
|
348
|
+
});
|
|
349
|
+
return result;
|
|
350
|
+
}, [layoutItems, containerWidth, columnCount, gap]);
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (layout) onLayout?.(layout);
|
|
353
|
+
}, [layout, onLayout]);
|
|
354
|
+
const sortedIndices = useMemo(() => {
|
|
355
|
+
if (!layout) return [];
|
|
356
|
+
const indices = layout.positions.map((_, i) => i);
|
|
357
|
+
indices.sort((a, b) => layout.positions[a].top - layout.positions[b].top);
|
|
358
|
+
return indices;
|
|
359
|
+
}, [layout]);
|
|
360
|
+
const maxItemHeight = useMemo(() => {
|
|
361
|
+
let max = estimatedItemHeight;
|
|
362
|
+
for (const h of heightMap.values()) {
|
|
363
|
+
if (h > max) max = h;
|
|
364
|
+
}
|
|
365
|
+
return max;
|
|
366
|
+
}, [heightMap, estimatedItemHeight]);
|
|
367
|
+
const visibleIndices = useMemo(() => {
|
|
368
|
+
if (!layout) return [];
|
|
369
|
+
return findVisibleItems(
|
|
370
|
+
layout.positions,
|
|
371
|
+
sortedIndices,
|
|
372
|
+
scrollTop,
|
|
373
|
+
height,
|
|
374
|
+
overscan,
|
|
375
|
+
maxItemHeight
|
|
376
|
+
);
|
|
377
|
+
}, [layout, sortedIndices, scrollTop, height, overscan, maxItemHeight]);
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
const el = scrollRef.current;
|
|
380
|
+
if (!el) return;
|
|
381
|
+
const ro = new ResizeObserver((entries) => {
|
|
382
|
+
for (const entry of entries) {
|
|
383
|
+
const w = entry.borderBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
|
|
384
|
+
setContainerWidth(w);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
ro.observe(el);
|
|
388
|
+
return () => ro.disconnect();
|
|
389
|
+
}, []);
|
|
390
|
+
const itemRORef = useRef(null);
|
|
391
|
+
const observedRef = useRef(/* @__PURE__ */ new Set());
|
|
392
|
+
const measureItems = useCallback(() => {
|
|
393
|
+
const refs = itemRefs.current;
|
|
394
|
+
if (refs.size === 0) return;
|
|
395
|
+
setHeightMap((prev) => {
|
|
396
|
+
let changed = false;
|
|
397
|
+
const updates = new Map(prev);
|
|
398
|
+
for (const [index, el] of refs) {
|
|
399
|
+
const measured = el.offsetHeight;
|
|
400
|
+
if (measured > 0 && measured !== updates.get(index)) {
|
|
401
|
+
updates.set(index, measured);
|
|
402
|
+
changed = true;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return changed ? updates : prev;
|
|
406
|
+
});
|
|
407
|
+
}, []);
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
const ro = new ResizeObserver(() => {
|
|
410
|
+
measureItems();
|
|
411
|
+
});
|
|
412
|
+
itemRORef.current = ro;
|
|
413
|
+
return () => {
|
|
414
|
+
ro.disconnect();
|
|
415
|
+
itemRORef.current = null;
|
|
416
|
+
observedRef.current.clear();
|
|
417
|
+
};
|
|
418
|
+
}, [measureItems]);
|
|
419
|
+
useEffect(() => {
|
|
420
|
+
const ro = itemRORef.current;
|
|
421
|
+
if (!ro) return;
|
|
422
|
+
const refs = itemRefs.current;
|
|
423
|
+
const observed = observedRef.current;
|
|
424
|
+
const currentEls = /* @__PURE__ */ new Set();
|
|
425
|
+
for (const [, el] of refs) {
|
|
426
|
+
currentEls.add(el);
|
|
427
|
+
if (!observed.has(el)) {
|
|
428
|
+
ro.observe(el);
|
|
429
|
+
observed.add(el);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
for (const el of observed) {
|
|
433
|
+
if (!currentEls.has(el)) {
|
|
434
|
+
ro.unobserve(el);
|
|
435
|
+
observed.delete(el);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
useEffect(() => {
|
|
440
|
+
measureItems();
|
|
441
|
+
});
|
|
442
|
+
useEffect(() => {
|
|
443
|
+
reachEndFiredRef.current = false;
|
|
444
|
+
}, [totalItems]);
|
|
445
|
+
const handleScroll = useCallback(() => {
|
|
446
|
+
const el = scrollRef.current;
|
|
447
|
+
if (!el) return;
|
|
448
|
+
const currentScrollTop = el.scrollTop;
|
|
449
|
+
setScrollTop(currentScrollTop);
|
|
450
|
+
if (onReachEnd && !reachEndFiredRef.current && layout) {
|
|
451
|
+
if (currentScrollTop + height + reachEndThreshold >= layout.totalHeight) {
|
|
452
|
+
reachEndFiredRef.current = true;
|
|
453
|
+
onReachEnd();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}, [onReachEnd, reachEndThreshold, height, layout]);
|
|
457
|
+
useEffect(() => {
|
|
458
|
+
if (!animConfig || visibleIndices.length === 0) return;
|
|
459
|
+
const newItems = [];
|
|
460
|
+
for (const idx of visibleIndices) {
|
|
461
|
+
if (!animatedRef.current.has(idx)) {
|
|
462
|
+
animatedRef.current.add(idx);
|
|
463
|
+
pendingAnimRef.current.add(idx);
|
|
464
|
+
newItems.push(idx);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
if (newItems.length === 0) return;
|
|
468
|
+
forceUpdate((c) => c + 1);
|
|
469
|
+
requestAnimationFrame(() => {
|
|
470
|
+
pendingAnimRef.current.clear();
|
|
471
|
+
forceUpdate((c) => c + 1);
|
|
472
|
+
});
|
|
473
|
+
}, [visibleIndices, animConfig]);
|
|
474
|
+
const containerStyle = {
|
|
475
|
+
height,
|
|
476
|
+
overflowY: "auto",
|
|
477
|
+
position: "relative",
|
|
478
|
+
...style
|
|
479
|
+
};
|
|
480
|
+
const innerStyle = {
|
|
481
|
+
position: "relative",
|
|
482
|
+
height: layout?.totalHeight ?? 0,
|
|
483
|
+
width: "100%"
|
|
484
|
+
};
|
|
485
|
+
return /* @__PURE__ */ jsx(
|
|
486
|
+
"div",
|
|
487
|
+
{
|
|
488
|
+
ref: scrollRef,
|
|
489
|
+
className,
|
|
490
|
+
style: containerStyle,
|
|
491
|
+
onScroll: handleScroll,
|
|
492
|
+
children: /* @__PURE__ */ jsx("div", { style: innerStyle, children: visibleIndices.map((index) => {
|
|
493
|
+
const pos = layout?.positions[index];
|
|
494
|
+
if (!pos) return null;
|
|
495
|
+
const isMeasured = heightMap.has(index);
|
|
496
|
+
const isPending = animConfig && pendingAnimRef.current.has(index);
|
|
497
|
+
const hasAnimated = animConfig && animatedRef.current.has(index);
|
|
498
|
+
const itemStyle = {
|
|
499
|
+
position: "absolute",
|
|
500
|
+
top: 0,
|
|
501
|
+
left: 0,
|
|
502
|
+
transform: `translate3d(${pos.left}px, ${pos.top + (isPending ? animConfig.offset ?? 20 : 0)}px, 0)`,
|
|
503
|
+
width: pos.width,
|
|
504
|
+
willChange: "transform",
|
|
505
|
+
opacity: isPending ? 0 : 1,
|
|
506
|
+
transition: hasAnimated ? `opacity ${animConfig.duration}ms ${animConfig.easing}, transform ${animConfig.duration}ms ${animConfig.easing}` : void 0
|
|
507
|
+
};
|
|
508
|
+
return /* @__PURE__ */ jsx(
|
|
509
|
+
"div",
|
|
510
|
+
{
|
|
511
|
+
ref: (el) => {
|
|
512
|
+
if (el) {
|
|
513
|
+
itemRefs.current.set(index, el);
|
|
514
|
+
} else {
|
|
515
|
+
itemRefs.current.delete(index);
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
style: itemStyle,
|
|
519
|
+
children: placeholder && !isMeasured ? placeholder : renderItem(index)
|
|
520
|
+
},
|
|
521
|
+
index
|
|
522
|
+
);
|
|
523
|
+
}) })
|
|
524
|
+
}
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
function useMasonryGrid(options = {}) {
|
|
528
|
+
const { columns, gap = 16, onLayout } = options;
|
|
529
|
+
const strategy = resolveStrategy(columns);
|
|
530
|
+
const containerElRef = useRef(null);
|
|
531
|
+
const itemHeights = useRef(/* @__PURE__ */ new Map());
|
|
532
|
+
const [layout, setLayout] = useState(null);
|
|
533
|
+
const [columnCount, setColumnCount] = useState(0);
|
|
534
|
+
const compute = useCallback(() => {
|
|
535
|
+
const container = containerElRef.current;
|
|
536
|
+
if (!container) return;
|
|
537
|
+
const containerWidth = container.offsetWidth;
|
|
538
|
+
if (containerWidth <= 0) return;
|
|
539
|
+
const colCount = resolveColumnCount(containerWidth, strategy, gap);
|
|
540
|
+
setColumnCount(colCount);
|
|
541
|
+
const children = Array.from(container.children);
|
|
542
|
+
const items = children.map((el, i) => ({
|
|
543
|
+
id: i,
|
|
544
|
+
index: i,
|
|
545
|
+
height: itemHeights.current.get(i) ?? el.offsetHeight
|
|
546
|
+
}));
|
|
547
|
+
if (items.length === 0) return;
|
|
548
|
+
const result = computeLayout({
|
|
549
|
+
items,
|
|
550
|
+
containerWidth,
|
|
551
|
+
columnCount: colCount,
|
|
552
|
+
gap
|
|
553
|
+
});
|
|
554
|
+
children.forEach((el, i) => {
|
|
555
|
+
const h = el.offsetHeight;
|
|
556
|
+
if (h > 0) itemHeights.current.set(i, h);
|
|
557
|
+
});
|
|
558
|
+
setLayout(result);
|
|
559
|
+
onLayout?.(result);
|
|
560
|
+
}, [gap, strategy, onLayout]);
|
|
561
|
+
const containerRef = useCallback(
|
|
562
|
+
(el) => {
|
|
563
|
+
containerElRef.current = el;
|
|
564
|
+
if (!el) return;
|
|
565
|
+
compute();
|
|
566
|
+
const ro = new ResizeObserver(() => compute());
|
|
567
|
+
ro.observe(el);
|
|
568
|
+
return () => ro.disconnect();
|
|
569
|
+
},
|
|
570
|
+
[compute]
|
|
571
|
+
);
|
|
572
|
+
useEffect(() => {
|
|
573
|
+
const container = containerElRef.current;
|
|
574
|
+
if (!container) return;
|
|
575
|
+
const ro = new ResizeObserver(() => compute());
|
|
576
|
+
const children = Array.from(container.children);
|
|
577
|
+
for (const child of children) {
|
|
578
|
+
ro.observe(child);
|
|
579
|
+
}
|
|
580
|
+
return () => ro.disconnect();
|
|
581
|
+
}, [layout?.positions.length, compute]);
|
|
582
|
+
const getItemStyle = useCallback(
|
|
583
|
+
(index) => {
|
|
584
|
+
const pos = layout?.positions[index];
|
|
585
|
+
if (!pos) return { visibility: "hidden" };
|
|
586
|
+
return {
|
|
587
|
+
position: "absolute",
|
|
588
|
+
top: 0,
|
|
589
|
+
left: 0,
|
|
590
|
+
transform: `translate3d(${pos.left}px, ${pos.top}px, 0)`,
|
|
591
|
+
width: pos.width,
|
|
592
|
+
willChange: "transform"
|
|
593
|
+
};
|
|
594
|
+
},
|
|
595
|
+
[layout]
|
|
596
|
+
);
|
|
597
|
+
const refresh = useCallback(() => {
|
|
598
|
+
itemHeights.current.clear();
|
|
599
|
+
compute();
|
|
600
|
+
}, [compute]);
|
|
601
|
+
return {
|
|
602
|
+
containerRef,
|
|
603
|
+
layout,
|
|
604
|
+
columnCount,
|
|
605
|
+
refresh,
|
|
606
|
+
getItemStyle
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export { Masonry, VirtualMasonry, useMasonryGrid };
|
|
611
|
+
//# sourceMappingURL=index.js.map
|
|
612
|
+
//# sourceMappingURL=index.js.map
|