Gallery: true justified layout (row-height scaling, no crop, no spacer)
ci / docker (push) Successful in 15s
ci / docker (push) Successful in 15s
This commit is contained in:
@@ -1,42 +1,86 @@
|
|||||||
|
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 }}>
|
||||||
const isSelected = selected ? selected.has(p.id) : false
|
{row.items.map(({ photo: p, ar, index }) => {
|
||||||
return (
|
const isSelected = selected ? selected.has(p.id) : false
|
||||||
<div
|
return (
|
||||||
key={p.id}
|
<div
|
||||||
className={`g-item${isSelected ? ' selected' : ''}`}
|
key={p.id}
|
||||||
style={{ '--ar': ar }}
|
className={`g-item${isSelected ? ' selected' : ''}`}
|
||||||
onClick={() => onOpen && onOpen(i)}
|
style={{ width: ar * row.height }}
|
||||||
>
|
onClick={() => onOpen && onOpen(index)}
|
||||||
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
|
|
||||||
{onToggleSelect && (
|
|
||||||
<button
|
|
||||||
className="g-check"
|
|
||||||
title={isSelected ? 'Deselect' : 'Select'}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
onToggleSelect(p.id)
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
✓
|
<img src={imgUrl(p, 'thumb')} loading="lazy" alt={p.filename} />
|
||||||
</button>
|
{onToggleSelect && (
|
||||||
)}
|
<button
|
||||||
{overlay && overlay(p)}
|
className="g-check"
|
||||||
</div>
|
title={isSelected ? 'Deselect' : 'Select'}
|
||||||
)
|
onClick={(e) => {
|
||||||
})}
|
e.stopPropagation()
|
||||||
<div className="g-spacer" />
|
onToggleSelect(p.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{overlay && overlay(p)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-13
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user