grain.social is a photo sharing platform built on atproto. grain.social
atproto photography appview
50
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 179c3fef61b30dac3efd4df5624211052c66fb04 241 lines 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}