@slithy/react-tessera-gallery 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.md +21 -0
- package/README.md +111 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +251 -0
- package/package.json +50 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Campagna
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# @slithy/react-tessera-gallery
|
|
2
|
+
|
|
3
|
+
React photo gallery with optimal justified layout. Uses a Knuth-Plass dynamic programming algorithm to break items into rows that minimize deviation from a target row height. Supports incremental loading, unknown aspect ratios, and append-only rendering to prevent layout jumps as new images load.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @slithy/react-tessera-gallery
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
**Peer dependencies:** `react@^17 || ^18 || ^19`
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## `<TesseraGallery>`
|
|
16
|
+
|
|
17
|
+
The main component. Accepts a list of items and a `renderItem` function; handles all layout and loading state internally.
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { TesseraGallery } from '@slithy/react-tessera-gallery'
|
|
21
|
+
|
|
22
|
+
<TesseraGallery
|
|
23
|
+
items={photos}
|
|
24
|
+
rowHeight={200}
|
|
25
|
+
gap={4}
|
|
26
|
+
renderItem={(item, { width, height, loaded }, handlers) => (
|
|
27
|
+
<img
|
|
28
|
+
key={item.key}
|
|
29
|
+
src={item.src}
|
|
30
|
+
width={width}
|
|
31
|
+
height={height}
|
|
32
|
+
onLoad={handlers.onLoad}
|
|
33
|
+
style={{ opacity: loaded ? 1 : 0 }}
|
|
34
|
+
/>
|
|
35
|
+
)}
|
|
36
|
+
/>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Props:**
|
|
40
|
+
|
|
41
|
+
| Prop | Type | Default | Description |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| `items` | `GalleryItem<T>[]` | — | Items to display. Each must have a `key`. `aspectRatio` is optional — see below. |
|
|
44
|
+
| `renderItem` | `(item, layout, handlers) => ReactNode` | — | Render function called for each item |
|
|
45
|
+
| `rowHeight` | `number` | — | Target row height in pixels |
|
|
46
|
+
| `gap` | `number` | `0` | Gap between items and rows in pixels |
|
|
47
|
+
| `lastRow` | `'left' \| 'center' \| 'right' \| 'justify' \| 'hide'` | `'left'` | Alignment of the last (partial) row |
|
|
48
|
+
| `maxShrink` | `number` | `0.75` | Minimum row height as a fraction of `rowHeight` |
|
|
49
|
+
| `maxStretch` | `number` | `1.5` | Maximum row height as a multiple of `rowHeight` |
|
|
50
|
+
| `justifyThreshold` | `number` | `1` | Justify the last row if its natural fill ratio meets this threshold (0–1) |
|
|
51
|
+
|
|
52
|
+
**`renderItem` arguments:**
|
|
53
|
+
|
|
54
|
+
| Argument | Type | Description |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `item` | `GalleryItem<T>` | The original item |
|
|
57
|
+
| `layout.width` | `number` | Computed pixel width for this item |
|
|
58
|
+
| `layout.height` | `number` | Computed pixel height for this item |
|
|
59
|
+
| `layout.loaded` | `boolean` | Whether the browser has confirmed this image loaded via `handlers.onLoad` |
|
|
60
|
+
| `handlers.onLoad` | `ReactEventHandler<HTMLImageElement>` | Pass to `<img onLoad={...}>` to track load state |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## `GalleryItem<T>`
|
|
65
|
+
|
|
66
|
+
Items passed to `TesseraGallery` must satisfy `GalleryItem<T>`:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
type GalleryItem<T> = T & {
|
|
70
|
+
key: string | number
|
|
71
|
+
aspectRatio?: number // optional — discovered via onLoad if omitted
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Items with a known `aspectRatio` are laid out immediately. Items without one are held out of the layout until `handlers.onLoad` fires, at which point their aspect ratio is derived from `naturalWidth / naturalHeight` and they enter the layout with `loaded: true`.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## `useTesseraGallery`
|
|
80
|
+
|
|
81
|
+
The hook underlying `<TesseraGallery>`. Use this directly for custom rendering or when you need lower-level control.
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { useTesseraGallery } from '@slithy/react-tessera-gallery'
|
|
85
|
+
|
|
86
|
+
const { containerRef, rows, onLoad } = useTesseraGallery(items, options)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Returns:**
|
|
90
|
+
|
|
91
|
+
| Property | Type | Description |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| `containerRef` | `RefObject<HTMLDivElement \| null>` | Attach to your container element to observe its width |
|
|
94
|
+
| `rows` | `ResolvedRow<T>[]` | Computed layout rows, each with `height` and `items` |
|
|
95
|
+
| `onLoad` | `(key, naturalWidth, naturalHeight) => void` | Call when an image loads |
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## `computeTesseraLayout`
|
|
100
|
+
|
|
101
|
+
The pure layout function. Takes items with known aspect ratios, a container width, and options; returns row data with pixel dimensions. No React dependency.
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
import { computeTesseraLayout } from '@slithy/react-tessera-gallery'
|
|
105
|
+
|
|
106
|
+
const rows = computeTesseraLayout(
|
|
107
|
+
[{ aspectRatio: 1.5 }, { aspectRatio: 1 }, { aspectRatio: 2 }],
|
|
108
|
+
600,
|
|
109
|
+
{ rowHeight: 200, gap: 4 },
|
|
110
|
+
)
|
|
111
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { RefObject, ReactEventHandler, ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
type LayoutOptions = {
|
|
4
|
+
rowHeight: number;
|
|
5
|
+
gap?: number;
|
|
6
|
+
lastRow?: 'justify' | 'left' | 'center' | 'right' | 'hide';
|
|
7
|
+
maxShrink?: number;
|
|
8
|
+
maxStretch?: number;
|
|
9
|
+
justifyThreshold?: number;
|
|
10
|
+
};
|
|
11
|
+
type LayoutRow = {
|
|
12
|
+
items: Array<{
|
|
13
|
+
aspectRatio: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}>;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
type GalleryItem<T> = T & {
|
|
20
|
+
key: string | number;
|
|
21
|
+
aspectRatio?: number;
|
|
22
|
+
};
|
|
23
|
+
type ResolvedRow<T> = {
|
|
24
|
+
items: Array<{
|
|
25
|
+
item: GalleryItem<T>;
|
|
26
|
+
width: number;
|
|
27
|
+
height: number;
|
|
28
|
+
loaded: boolean;
|
|
29
|
+
}>;
|
|
30
|
+
height: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
declare function computeTesseraLayout(items: {
|
|
34
|
+
aspectRatio: number;
|
|
35
|
+
}[], containerWidth: number, options: LayoutOptions): LayoutRow[];
|
|
36
|
+
|
|
37
|
+
declare function useTesseraGallery<T>(items: GalleryItem<T>[], options: LayoutOptions): {
|
|
38
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
39
|
+
rows: ResolvedRow<T>[];
|
|
40
|
+
onLoad: (key: string | number, naturalWidth: number, naturalHeight: number) => void;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type Props<T> = {
|
|
44
|
+
items: GalleryItem<T>[];
|
|
45
|
+
renderItem: (item: GalleryItem<T>, layout: {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
loaded: boolean;
|
|
49
|
+
}, handlers: {
|
|
50
|
+
onLoad: ReactEventHandler<HTMLImageElement>;
|
|
51
|
+
}) => ReactNode;
|
|
52
|
+
} & LayoutOptions;
|
|
53
|
+
declare function TesseraGallery<T>({ items, renderItem, ...options }: Props<T>): ReactNode;
|
|
54
|
+
|
|
55
|
+
export { type GalleryItem, type LayoutOptions, type LayoutRow, type ResolvedRow, TesseraGallery, computeTesseraLayout, useTesseraGallery };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// src/computeTesseraLayout.ts
|
|
2
|
+
var BADNESS_POWER = 3;
|
|
3
|
+
function computeTesseraLayout(items, containerWidth, options) {
|
|
4
|
+
const {
|
|
5
|
+
rowHeight: idealHeight,
|
|
6
|
+
gap = 0,
|
|
7
|
+
lastRow = "left",
|
|
8
|
+
maxShrink = 0.75,
|
|
9
|
+
maxStretch = 1.5,
|
|
10
|
+
justifyThreshold = 1
|
|
11
|
+
} = options;
|
|
12
|
+
const n = items.length;
|
|
13
|
+
if (n === 0 || containerWidth <= 0) return [];
|
|
14
|
+
const minHeight = idealHeight * maxShrink;
|
|
15
|
+
const maxHeight = idealHeight * maxStretch;
|
|
16
|
+
const prefixAR = new Array(n + 1);
|
|
17
|
+
prefixAR[0] = 0;
|
|
18
|
+
for (let i = 0; i < n; i++) {
|
|
19
|
+
prefixAR[i + 1] = prefixAR[i] + items[i].aspectRatio;
|
|
20
|
+
}
|
|
21
|
+
function rowHeightFor(start2, end) {
|
|
22
|
+
const totalAR = prefixAR[end] - prefixAR[start2];
|
|
23
|
+
const numGaps = end - start2 - 1;
|
|
24
|
+
return (containerWidth - gap * numGaps) / totalAR;
|
|
25
|
+
}
|
|
26
|
+
function badness(h) {
|
|
27
|
+
if (h >= idealHeight) {
|
|
28
|
+
const range2 = maxHeight - idealHeight;
|
|
29
|
+
return range2 === 0 ? Infinity : (h - idealHeight) ** BADNESS_POWER / range2 ** BADNESS_POWER;
|
|
30
|
+
}
|
|
31
|
+
const range = idealHeight - minHeight;
|
|
32
|
+
return range === 0 ? Infinity : (idealHeight - h) ** BADNESS_POWER / range ** BADNESS_POWER;
|
|
33
|
+
}
|
|
34
|
+
const dp = new Array(n + 1).fill(Infinity);
|
|
35
|
+
const pred = new Array(n + 1).fill(-1);
|
|
36
|
+
dp[0] = 0;
|
|
37
|
+
for (let i = 0; i < n; i++) {
|
|
38
|
+
if (dp[i] === Infinity) continue;
|
|
39
|
+
for (let j = i + 1; j <= n; j++) {
|
|
40
|
+
const h = rowHeightFor(i, j);
|
|
41
|
+
if (h > maxHeight) continue;
|
|
42
|
+
if (h < minHeight) break;
|
|
43
|
+
const cost = dp[i] + badness(h);
|
|
44
|
+
if (cost < dp[j]) {
|
|
45
|
+
dp[j] = cost;
|
|
46
|
+
pred[j] = i;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const breaks = [];
|
|
51
|
+
if (dp[n] < Infinity) {
|
|
52
|
+
let cur = n;
|
|
53
|
+
while (cur > 0) {
|
|
54
|
+
breaks.unshift(cur);
|
|
55
|
+
cur = pred[cur];
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
let last = n - 1;
|
|
59
|
+
while (last > 0 && dp[last] === Infinity) last--;
|
|
60
|
+
let cur = last;
|
|
61
|
+
while (cur > 0) {
|
|
62
|
+
breaks.unshift(cur);
|
|
63
|
+
cur = pred[cur];
|
|
64
|
+
}
|
|
65
|
+
if (last < n) breaks.push(n);
|
|
66
|
+
}
|
|
67
|
+
const rows = [];
|
|
68
|
+
let start = 0;
|
|
69
|
+
for (let r = 0; r < breaks.length; r++) {
|
|
70
|
+
const end = breaks[r];
|
|
71
|
+
const isLastRow = r === breaks.length - 1;
|
|
72
|
+
const numGaps = end - start - 1;
|
|
73
|
+
const totalAR = prefixAR[end] - prefixAR[start];
|
|
74
|
+
let actualHeight;
|
|
75
|
+
let justify;
|
|
76
|
+
if (isLastRow) {
|
|
77
|
+
if (lastRow === "hide") {
|
|
78
|
+
start = end;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const naturalWidth = totalAR * idealHeight + gap * numGaps;
|
|
82
|
+
const fillRatio = naturalWidth / containerWidth;
|
|
83
|
+
const shouldJustify = lastRow === "justify" || fillRatio >= justifyThreshold;
|
|
84
|
+
if (shouldJustify) {
|
|
85
|
+
actualHeight = rowHeightFor(start, end);
|
|
86
|
+
justify = true;
|
|
87
|
+
} else {
|
|
88
|
+
actualHeight = idealHeight;
|
|
89
|
+
justify = false;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
actualHeight = rowHeightFor(start, end);
|
|
93
|
+
justify = true;
|
|
94
|
+
}
|
|
95
|
+
rows.push(buildRow(items, start, end, actualHeight, justify, containerWidth, gap));
|
|
96
|
+
start = end;
|
|
97
|
+
}
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
function buildRow(items, start, end, height, justify, containerWidth, gap) {
|
|
101
|
+
const count = end - start;
|
|
102
|
+
const numGaps = count - 1;
|
|
103
|
+
if (!justify) {
|
|
104
|
+
return {
|
|
105
|
+
height,
|
|
106
|
+
items: items.slice(start, end).map((item) => ({
|
|
107
|
+
aspectRatio: item.aspectRatio,
|
|
108
|
+
width: item.aspectRatio * height,
|
|
109
|
+
height
|
|
110
|
+
}))
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const targetTotal = containerWidth - gap * numGaps;
|
|
114
|
+
const naturalWidths = items.slice(start, end).map((item) => item.aspectRatio * height);
|
|
115
|
+
const rawTotal = naturalWidths.reduce((s, w) => s + w, 0);
|
|
116
|
+
const scale = targetTotal / rawTotal;
|
|
117
|
+
const scaled = naturalWidths.map((w) => w * scale);
|
|
118
|
+
const floored = scaled.map(Math.floor);
|
|
119
|
+
const remainder = Math.round(targetTotal - floored.reduce((s, w) => s + w, 0));
|
|
120
|
+
const order = scaled.map((w, i) => ({ i, frac: w - Math.floor(w) })).sort((a, b) => b.frac - a.frac);
|
|
121
|
+
const finalWidths = [...floored];
|
|
122
|
+
for (let k = 0; k < remainder; k++) finalWidths[order[k].i]++;
|
|
123
|
+
return {
|
|
124
|
+
height,
|
|
125
|
+
items: items.slice(start, end).map((item, idx) => ({
|
|
126
|
+
aspectRatio: item.aspectRatio,
|
|
127
|
+
width: finalWidths[idx],
|
|
128
|
+
height
|
|
129
|
+
}))
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/useTesseraGallery.ts
|
|
134
|
+
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
|
|
135
|
+
function toResolvedRow(row, loadedSet) {
|
|
136
|
+
return {
|
|
137
|
+
height: row.height,
|
|
138
|
+
items: row.items.map(({ item, width, height }) => ({
|
|
139
|
+
item,
|
|
140
|
+
width,
|
|
141
|
+
height,
|
|
142
|
+
loaded: loadedSet.has(item.key)
|
|
143
|
+
}))
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function useTesseraGallery(items, options) {
|
|
147
|
+
const containerRef = useRef(null);
|
|
148
|
+
const [containerWidth, setContainerWidth] = useState(0);
|
|
149
|
+
const aspectRatioCache = useRef(/* @__PURE__ */ new Map());
|
|
150
|
+
const loadedSet = useRef(/* @__PURE__ */ new Set());
|
|
151
|
+
const [, rerender] = useReducer((n) => n + 1, 0);
|
|
152
|
+
const committedRowsRef = useRef([]);
|
|
153
|
+
const committedItemCountRef = useRef(0);
|
|
154
|
+
const committedContainerWidthRef = useRef(0);
|
|
155
|
+
const committedOptionsKeyRef = useRef("");
|
|
156
|
+
for (const item of items) {
|
|
157
|
+
if (item.aspectRatio !== void 0) {
|
|
158
|
+
aspectRatioCache.current.set(item.key, item.aspectRatio);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
const observer = new ResizeObserver((entries) => {
|
|
163
|
+
const width = entries[0]?.contentRect.width ?? 0;
|
|
164
|
+
setContainerWidth(width);
|
|
165
|
+
});
|
|
166
|
+
const el = containerRef.current;
|
|
167
|
+
if (el) observer.observe(el);
|
|
168
|
+
return () => observer.disconnect();
|
|
169
|
+
}, []);
|
|
170
|
+
const onLoad = useCallback(
|
|
171
|
+
(key, naturalWidth, naturalHeight) => {
|
|
172
|
+
if (naturalWidth <= 0 || naturalHeight <= 0) return;
|
|
173
|
+
let changed = false;
|
|
174
|
+
if (!aspectRatioCache.current.has(key)) {
|
|
175
|
+
aspectRatioCache.current.set(key, naturalWidth / naturalHeight);
|
|
176
|
+
changed = true;
|
|
177
|
+
}
|
|
178
|
+
if (!loadedSet.current.has(key)) {
|
|
179
|
+
loadedSet.current.add(key);
|
|
180
|
+
changed = true;
|
|
181
|
+
}
|
|
182
|
+
if (changed) rerender();
|
|
183
|
+
},
|
|
184
|
+
[]
|
|
185
|
+
);
|
|
186
|
+
const resolvedItems = items.filter((item) => aspectRatioCache.current.has(item.key));
|
|
187
|
+
const optionsKey = `${options.rowHeight}|${options.gap ?? 0}|${options.maxShrink ?? 0.75}|${options.maxStretch ?? 1.5}`;
|
|
188
|
+
if (containerWidth !== committedContainerWidthRef.current || optionsKey !== committedOptionsKeyRef.current || resolvedItems.length < committedItemCountRef.current) {
|
|
189
|
+
committedRowsRef.current = [];
|
|
190
|
+
committedItemCountRef.current = 0;
|
|
191
|
+
committedContainerWidthRef.current = containerWidth;
|
|
192
|
+
committedOptionsKeyRef.current = optionsKey;
|
|
193
|
+
}
|
|
194
|
+
const frontierItems = resolvedItems.slice(committedItemCountRef.current);
|
|
195
|
+
const frontierLayout = containerWidth > 0 && frontierItems.length > 0 ? computeTesseraLayout(
|
|
196
|
+
frontierItems.map((item) => ({
|
|
197
|
+
aspectRatio: aspectRatioCache.current.get(item.key)
|
|
198
|
+
})),
|
|
199
|
+
containerWidth,
|
|
200
|
+
options
|
|
201
|
+
) : [];
|
|
202
|
+
const frontierRows = [];
|
|
203
|
+
let itemIdx = 0;
|
|
204
|
+
for (const layoutRow of frontierLayout) {
|
|
205
|
+
frontierRows.push({
|
|
206
|
+
height: layoutRow.height,
|
|
207
|
+
items: layoutRow.items.map((layoutItem) => ({
|
|
208
|
+
item: frontierItems[itemIdx++],
|
|
209
|
+
width: layoutItem.width,
|
|
210
|
+
height: layoutItem.height
|
|
211
|
+
}))
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (frontierRows.length > 1) {
|
|
215
|
+
for (let i = 0; i < frontierRows.length - 1; i++) {
|
|
216
|
+
committedRowsRef.current.push(frontierRows[i]);
|
|
217
|
+
committedItemCountRef.current += frontierRows[i].items.length;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const rows = committedRowsRef.current.map((row) => toResolvedRow(row, loadedSet.current));
|
|
221
|
+
const lastFrontierRow = frontierRows[frontierRows.length - 1];
|
|
222
|
+
if (lastFrontierRow) {
|
|
223
|
+
rows.push(toResolvedRow(lastFrontierRow, loadedSet.current));
|
|
224
|
+
}
|
|
225
|
+
return { containerRef, rows, onLoad };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// src/TesseraGallery.tsx
|
|
229
|
+
import { jsx } from "react/jsx-runtime";
|
|
230
|
+
function TesseraGallery({ items, renderItem, ...options }) {
|
|
231
|
+
const { containerRef, rows, onLoad } = useTesseraGallery(items, options);
|
|
232
|
+
const { gap = 0, lastRow = "left" } = options;
|
|
233
|
+
return /* @__PURE__ */ jsx("div", { ref: containerRef, style: { display: "flex", flexDirection: "column", gap: `${gap}px` }, children: rows.map((row, rowIndex) => {
|
|
234
|
+
const isLastRow = rowIndex === rows.length - 1;
|
|
235
|
+
const justifyContent = isLastRow && lastRow === "center" ? "center" : isLastRow && lastRow === "right" ? "flex-end" : "flex-start";
|
|
236
|
+
return /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: `${gap}px`, justifyContent }, children: row.items.map(
|
|
237
|
+
({ item, width, height, loaded }) => renderItem(
|
|
238
|
+
item,
|
|
239
|
+
{ width, height, loaded },
|
|
240
|
+
{
|
|
241
|
+
onLoad: (e) => onLoad(item.key, e.currentTarget.naturalWidth, e.currentTarget.naturalHeight)
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
) }, rowIndex);
|
|
245
|
+
}) });
|
|
246
|
+
}
|
|
247
|
+
export {
|
|
248
|
+
TesseraGallery,
|
|
249
|
+
computeTesseraLayout,
|
|
250
|
+
useTesseraGallery
|
|
251
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slithy/react-tessera-gallery",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React justified gallery with optimal line-breaking layout.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"react": "^17 || ^18 || ^19"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@testing-library/jest-dom": "^6",
|
|
21
|
+
"@testing-library/react": "^16",
|
|
22
|
+
"@types/react": "^19",
|
|
23
|
+
"@vitejs/plugin-react": "^6",
|
|
24
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
25
|
+
"jsdom": "^29.0.1",
|
|
26
|
+
"react": "^19",
|
|
27
|
+
"react-dom": "^19",
|
|
28
|
+
"tsup": "^8",
|
|
29
|
+
"typescript": "^5",
|
|
30
|
+
"vitest": "^4.1.2",
|
|
31
|
+
"@slithy/tsconfig": "0.0.0",
|
|
32
|
+
"@slithy/eslint-config": "0.0.0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/mjcampagna/react-tessera-gallery"
|
|
37
|
+
},
|
|
38
|
+
"author": "mjcampagna",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"scripts": {
|
|
41
|
+
"sync": "rsync -a --delete ../../../react-tessera-gallery/src/ ./src/",
|
|
42
|
+
"clean": "rm -rf dist",
|
|
43
|
+
"build": "rm -rf dist && tsup src/index.ts --format esm --dts",
|
|
44
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"lint": "eslint .",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"test:watch": "vitest"
|
|
49
|
+
}
|
|
50
|
+
}
|