grain.social is a photo sharing platform built on atproto.
at main 6.8 kB view raw
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}