Gallery: true justified layout (row-height scaling, no crop, no spacer)
This commit is contained in:
@@ -1,23 +1,66 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { imgUrl } from '../api'
|
||||
|
||||
// Justified gallery: rows are built with flexbox, each tile's flex-grow is
|
||||
// proportional to its aspect ratio so rows fill the container edge to edge.
|
||||
// When `selected`/`onToggleSelect` are provided, tiles get a select checkmark;
|
||||
// selection state lives in the parent so it survives lightbox open/close.
|
||||
// True justified layout: pack photos greedily into rows at their real aspect
|
||||
// ratios, then scale each row's height so it fills the container width
|
||||
// exactly. No cropping, no stretch, and the last row simply renders at the
|
||||
// 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 }) {
|
||||
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
|
||||
const gap = 6
|
||||
const targetHeight = width < 700 ? 170 : 240
|
||||
const rows = width > 0 ? layoutRows(photos, width, targetHeight, gap) : []
|
||||
const selecting = selected && selected.size > 0
|
||||
|
||||
return (
|
||||
<div className={`gallery${selecting ? ' selecting' : ''}`}>
|
||||
{photos.map((p, i) => {
|
||||
const ar = p.width && p.height ? p.width / p.height : 1.5
|
||||
<div ref={containerRef} className={`gallery${selecting ? ' selecting' : ''}`}>
|
||||
{rows.map((row) => (
|
||||
<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
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`g-item${isSelected ? ' selected' : ''}`}
|
||||
style={{ '--ar': ar }}
|
||||
onClick={() => onOpen && onOpen(i)}
|
||||
style={{ width: ar * row.height }}
|
||||
onClick={() => onOpen && onOpen(index)}
|
||||
>
|
||||
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
|
||||
{onToggleSelect && (
|
||||
@@ -36,7 +79,8 @@ export default function Gallery({ photos, onOpen, overlay, selected, onToggleSel
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="g-spacer" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+6
-13
@@ -191,15 +191,17 @@ input:focus {
|
||||
/* justified gallery */
|
||||
.gallery {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.g-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
.g-item {
|
||||
position: relative;
|
||||
height: 240px;
|
||||
flex-grow: calc(var(--ar) * 100);
|
||||
flex-basis: calc(var(--ar) * 240px);
|
||||
flex: none;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
@@ -211,11 +213,6 @@ input:focus {
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.g-spacer {
|
||||
flex-grow: 1000000;
|
||||
flex-basis: 0;
|
||||
height: 0;
|
||||
}
|
||||
.g-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -556,10 +553,6 @@ progress {
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.g-item {
|
||||
height: 160px;
|
||||
flex-basis: calc(var(--ar) * 160px);
|
||||
}
|
||||
.lb-stage {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user