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