Gallery: true justified layout (row-height scaling, no crop, no spacer)

This commit is contained in:
2026-07-17 14:12:57 +02:00
parent 16d2a56a78
commit a6819809a7
2 changed files with 82 additions and 45 deletions
+54 -10
View File
@@ -1,23 +1,66 @@
import { useEffect, useRef, useState } from 'react'
import { imgUrl } from '../api' import { imgUrl } from '../api'
// Justified gallery: rows are built with flexbox, each tile's flex-grow is // True justified layout: pack photos greedily into rows at their real aspect
// proportional to its aspect ratio so rows fill the container edge to edge. // ratios, then scale each row's height so it fills the container width
// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark; // exactly. No cropping, no stretch, and the last row simply renders at the
// selection state lives in the parent so it survives lightbox open/close. // target height instead of being padded by a spacer.
function layoutRows(photos, containerWidth, targetHeight, gap) {
const rows = []
let row = []
let arSum = 0
let index = 0
for (const photo of photos) {
const ar = photo.width && photo.height ? photo.width / photo.height : 1.5
row.push({ photo, ar, index: index++ })
arSum += ar
const gaps = (row.length - 1) * gap
if (arSum * targetHeight + gaps >= containerWidth) {
rows.push({ items: row, height: (containerWidth - gaps) / arSum })
row = []
arSum = 0
}
}
if (row.length > 0) {
const gaps = (row.length - 1) * gap
rows.push({
items: row,
height: Math.min(targetHeight, (containerWidth - gaps) / arSum),
})
}
return rows
}
export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) { export default function Gallery({ photos, onOpen, overlay, selected, onToggleSelect }) {
const containerRef = useRef(null)
const [width, setWidth] = useState(0)
useEffect(() => {
const el = containerRef.current
if (!el) return
const observer = new ResizeObserver((entries) => setWidth(entries[0].contentRect.width))
observer.observe(el)
return () => observer.disconnect()
}, [])
if (photos.length === 0) return null if (photos.length === 0) return null
const gap = 6
const targetHeight = width < 700 ? 170 : 240
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
const selecting = selected && selected.size > 0 const selecting = selected && selected.size > 0
return ( return (
<div className={`gallery${selecting ? ' selecting' : ''}`}> <div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
{photos.map((p, i) => { {rows.map((row) => (
const ar = p.width && p.height ? p.width / p.height : 1.5 <div key={row.items[0].photo.id} className="g-row" style={{ height: row.height }}>
{row.items.map(({ photo: p, ar, index }) => {
const isSelected = selected ? selected.has(p.id) : false const isSelected = selected ? selected.has(p.id) : false
return ( return (
<div <div
key={p.id} key={p.id}
className={`g-item${isSelected ? ' selected' : ''}`} className={`g-item${isSelected ? ' selected' : ''}`}
style={{ '--ar': ar }} style={{ width: ar * row.height }}
onClick={() => onOpen && onOpen(i)} onClick={() => onOpen && onOpen(index)}
> >
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} /> <img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
{onToggleSelect && ( {onToggleSelect && (
@@ -36,7 +79,8 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
</div> </div>
) )
})} })}
<div className="g-spacer" /> </div>
))}
</div> </div>
) )
} }
+6 -13
View File
@@ -191,15 +191,17 @@ input:focus {
/* justified gallery */ /* justified gallery */
.gallery { .gallery {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 6px; gap: 6px;
margin: 1rem 0; margin: 1rem 0;
} }
.g-row {
display: flex;
gap: 6px;
}
.g-item { .g-item {
position: relative; position: relative;
height: 240px; flex: none;
flex-grow: calc(var(--ar) * 100);
flex-basis: calc(var(--ar) * 240px);
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
@@ -211,11 +213,6 @@ input:focus {
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.g-spacer {
flex-grow: 1000000;
flex-basis: 0;
height: 0;
}
.g-overlay { .g-overlay {
position: absolute; position: absolute;
bottom: 0; bottom: 0;
@@ -556,10 +553,6 @@ progress {
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.g-item {
height: 160px;
flex-basis: calc(var(--ar) * 160px);
}
.lb-stage { .lb-stage {
padding: 0 0.5rem; padding: 0 0.5rem;
} }