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.
@@ -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