grain.social is a photo sharing platform built on atproto.
1type LayoutMode = "justified" | "masonry";
2
3type GalleryItem = HTMLElement & {
4 dataset: {
5 width: string;
6 height: string;
7 [key: string]: string;
8 };
9};
10
11interface GalleryLayoutOptions {
12 containerSelector?: string;
13 layoutMode?: LayoutMode;
14 spacing?: number;
15 masonryBreakpoint?: number;
16}
17
18/**
19 * GalleryLayout class for flexible photo gallery layouts (masonry/justified).
20 *
21 * Example usage with the GalleryLayout compositional component:
22 *
23 * // In your JSX component:
24 * <GalleryLayout
25 * layoutButtons={
26 * <>
27 * <GalleryLayout.ModeButton mode="justified" />
28 * <GalleryLayout.ModeButton mode="masonry" />
29 * </>
30 * }
31 * >
32 * <GalleryLayout.Container>
33 * {photos.map(photo => (
34 * <GalleryLayout.Item key={photo.cid} photo={photo} gallery={gallery} />
35 * ))}
36 * </GalleryLayout.Container>
37 * </GalleryLayout>
38 *
39 * // In your static JS/TS:
40 * import { GalleryLayout } from "../static/gallery_layout.ts";
41 * const galleryLayout = new GalleryLayout({
42 * containerSelector: "#gallery-container",
43 * layoutMode: "justified",
44 * spacing: 8,
45 * masonryBreakpoint: 640,
46 * });
47 * galleryLayout.init();
48 */
49export class GalleryLayout {
50 private observerInitialized = false;
51 private layoutMode: LayoutMode;
52 private containerSelector: string;
53 private spacing: number;
54 private masonryBreakpoint: number;
55
56 constructor(options: GalleryLayoutOptions = {}) {
57 this.layoutMode = options.layoutMode ?? "justified";
58 this.containerSelector = options.containerSelector ?? "#gallery-container";
59 this.spacing = options.spacing ?? 8;
60 this.masonryBreakpoint = options.masonryBreakpoint ?? 640;
61 }
62
63 public setLayoutMode(mode: LayoutMode) {
64 this.layoutMode = mode;
65 this.computeLayout();
66 }
67
68 public computeLayout(): void {
69 if (this.layoutMode === "masonry") {
70 this.computeMasonry();
71 } else {
72 this.computeJustified();
73 }
74 }
75
76 public computeMasonry(): void {
77 const container = document.querySelector<HTMLElement>(
78 this.containerSelector,
79 );
80 if (!container) return;
81
82 const spacing = this.spacing;
83 const containerWidth = container.offsetWidth;
84
85 if (containerWidth === 0) {
86 requestAnimationFrame(() => this.computeMasonry());
87 return;
88 }
89
90 const columns = containerWidth < this.masonryBreakpoint ? 1 : 3;
91 const columnWidth = (containerWidth + spacing) / columns - spacing;
92 const columnHeights: number[] = new Array(columns).fill(0);
93 const tiles = container.querySelectorAll<HTMLElement>(".gallery-item");
94
95 tiles.forEach((tile) => {
96 const imgW = parseFloat((tile as GalleryItem).dataset.width);
97 const imgH = parseFloat((tile as GalleryItem).dataset.height);
98 if (!imgW || !imgH) return;
99
100 const aspectRatio = imgH / imgW;
101 const renderedHeight = aspectRatio * columnWidth;
102
103 let shortestIndex = 0;
104 for (let i = 1; i < columns; i++) {
105 if (columnHeights[i] < columnHeights[shortestIndex]) {
106 shortestIndex = i;
107 }
108 }
109
110 const left = (columnWidth + spacing) * shortestIndex;
111 const top = columnHeights[shortestIndex];
112
113 Object.assign(tile.style, {
114 position: "absolute",
115 width: `${columnWidth}px`,
116 height: `${renderedHeight}px`,
117 left: `${left}px`,
118 top: `${top}px`,
119 });
120
121 columnHeights[shortestIndex] = top + renderedHeight + spacing;
122 });
123
124 container.style.height = `${Math.max(...columnHeights)}px`;
125 }
126
127 public computeJustified(): void {
128 const container = document.querySelector<HTMLElement>(
129 this.containerSelector,
130 );
131 if (!container) return;
132
133 const spacing = this.spacing;
134 const containerWidth = container.offsetWidth;
135
136 if (containerWidth === 0) {
137 requestAnimationFrame(() => this.computeJustified());
138 return;
139 }
140
141 const tiles = Array.from(
142 container.querySelectorAll<HTMLElement>(".gallery-item"),
143 );
144 let currentRow: Array<
145 { tile: HTMLElement; aspectRatio: number; imgW: number; imgH: number }
146 > = [];
147 let rowAspectRatioSum = 0;
148 let yOffset = 0;
149
150 // Clear all styles before layout
151 tiles.forEach((tile) => {
152 Object.assign(tile.style, {
153 position: "absolute",
154 left: "0px",
155 top: "0px",
156 width: "auto",
157 height: "auto",
158 });
159 });
160
161 for (let i = 0; i < tiles.length; i++) {
162 const tile = tiles[i];
163 const imgW = parseFloat((tile as GalleryItem).dataset.width);
164 const imgH = parseFloat((tile as GalleryItem).dataset.height);
165 if (!imgW || !imgH) continue;
166
167 const aspectRatio = imgW / imgH;
168 currentRow.push({ tile, aspectRatio, imgW, imgH });
169 rowAspectRatioSum += aspectRatio;
170
171 // Estimate if row is "full" enough
172 const estimatedRowHeight =
173 (containerWidth - (currentRow.length - 1) * spacing) /
174 rowAspectRatioSum;
175
176 // If height is reasonable or we're at the end, render the row
177 if (estimatedRowHeight < 300 || i === tiles.length - 1) {
178 let xOffset = 0;
179
180 for (const item of currentRow) {
181 const width = estimatedRowHeight * item.aspectRatio;
182 Object.assign(item.tile.style, {
183 position: "absolute",
184 top: `${yOffset}px`,
185 left: `${xOffset}px`,
186 width: `${width}px`,
187 height: `${estimatedRowHeight}px`,
188 });
189 xOffset += width + spacing;
190 }
191
192 yOffset += estimatedRowHeight + spacing;
193 currentRow = [];
194 rowAspectRatioSum = 0;
195 }
196 }
197
198 container.style.position = "relative";
199 container.style.height = `${yOffset}px`;
200 }
201
202 public observe(): void {
203 if (this.observerInitialized) return;
204 this.observerInitialized = true;
205
206 const container = document.querySelector<HTMLElement>(
207 this.containerSelector,
208 );
209 if (!container) return;
210
211 // Observe parent resize
212 if (typeof ResizeObserver !== "undefined") {
213 const resizeObserver = new ResizeObserver(() => this.computeLayout());
214 if (container.parentElement) {
215 resizeObserver.observe(container.parentElement);
216 }
217 }
218
219 // Observe inner content changes (tiles being added/removed)
220 const mutationObserver = new MutationObserver(() => {
221 this.computeLayout();
222 });
223
224 mutationObserver.observe(container, {
225 childList: true,
226 subtree: true,
227 });
228 }
229
230 public init(options: GalleryLayoutOptions = {}): void {
231 document.addEventListener("DOMContentLoaded", () => {
232 const container = document.querySelector(
233 options.containerSelector ?? "#gallery-container",
234 );
235 if (container) {
236 this.computeLayout();
237 this.observe();
238 }
239 });
240 }
241}