fix: square timeline cells that fully fill each row

The grid was leaving horizontal space unused for two reasons:

1. Width measurement was based on parentRef.clientWidth - PADDING*2,
   which is fragile to padding/box-sizing/scrollbar mismatches and was
   off by enough pixels in practice to drop a column. Replace with a
   1px-tall normal-flow sentinel rendered inside the inner virtualizer
   wrapper at the exact horizontal extent rows render at. ResizeObserver
   on the sentinel gives the authoritative row width — no padding
   subtraction, no scrollbar guesswork.

2. The flex layout with explicit per-cell px widths accumulated floor()
   rounding error and let cells drift away from square. Switch to a CSS
   grid with fixed-px tracks (`repeat(cols, ${cellSize}px)` + matching
   `gridAutoRows`) so every track is exactly cellSize wide AND tall. By
   construction `cols × cellSize + (cols-1) × gap == measured width`,
   so the row fills edge-to-edge with cells that are guaranteed square.

PhotoThumbnail gains an opt-in `fill` prop the timeline uses to switch
its inline width/height to 100%, so the cell stretches to whatever the
parent grid track gives it. Heap sidebar / non-grid callers still get
explicit `size`-by-`size` square thumbnails as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-09 12:19:29 +02:00
parent ce25a4460e
commit a4b1802657
2 changed files with 71 additions and 11 deletions

View File

@@ -162,6 +162,12 @@ function buildItems(
export function Timeline() {
const parentRef = useRef<HTMLDivElement>(null)
// Sentinel placed inside the inner virtualizer wrapper at the exact
// position rows will render. We measure THIS instead of parentRef,
// because parentRef has padding and we'd otherwise have to subtract
// it (and account for any scrollbar) — easy to get wrong by a pixel
// and end up with a column count off by one.
const widthSentinelRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const {
@@ -195,12 +201,18 @@ export function Timeline() {
if (containerWidth === 0) {
return { columns: 4, cellSize: THUMBNAIL_SIZE }
}
const available = containerWidth - PADDING * 2
// containerWidth here is the sentinel's actual rendered width — no
// padding subtraction needed, the sentinel already lives inside the
// padded scroll container.
const available = containerWidth
const cols = Math.max(
1,
Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
)
const cell = Math.floor((available - (cols - 1) * GAP) / cols)
// Exact float — no floor. cellSize × cols + (cols-1) × gap == available
// by construction, so the row fills edge-to-edge without any
// sub-pixel rounding gap.
const cell = (available - (cols - 1) * GAP) / cols
return { columns: cols, cellSize: cell }
}, [containerWidth])
@@ -279,11 +291,12 @@ export function Timeline() {
return current
}, [headerOffsets, scrollTop])
// Measure container width on mount, window resize, and any layout
// change driven by the sidebar collapse / right panel toggle. Plain
// window.resize wouldn't catch those — ResizeObserver does.
// Measure the sentinel's actual rendered width on mount, window
// resize, and any layout change driven by the sidebar collapse /
// right panel toggle. ResizeObserver picks up everything window
// resize misses (sidebar collapse doesn't fire window resize).
useEffect(() => {
const el = parentRef.current
const el = widthSentinelRef.current
if (!el) return
const measure = () => setContainerWidth(el.clientWidth)
measure()
@@ -485,6 +498,23 @@ export function Timeline() {
position: 'relative',
}}
>
{/* Width sentinel — a 1px-tall normal-flow div that takes the
* full width of the inner virtualizer wrapper, which is the
* exact width rows render at. clientWidth on this is what we
* base the column count on, sidestepping any padding /
* scrollbar mismatch the parentRef-based measurement is
* vulnerable to. ResizeObserver doesn't reliably fire on
* zero-area absolute elements, so 1px tall + relative flow. */}
<div
ref={widthSentinelRef}
aria-hidden="true"
style={{
width: '100%',
height: 1,
marginBottom: -1,
pointerEvents: 'none',
}}
/>
{virtualizer.getVirtualItems().map((virtualItem) => {
const item = items[virtualItem.index]
if (!item) return null
@@ -523,12 +553,27 @@ export function Timeline() {
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className="flex" style={{ gap: `${GAP}px` }}>
<div
style={{
// Fixed-size grid: every track is exactly cellSize
// wide and the row is exactly cellSize tall, so
// cells are guaranteed square no matter what CSS
// the cell contents bring along. cellSize was
// already computed from `available / cols` so the
// sum cols*cellSize + (cols-1)*gap equals the
// container width to within sub-pixel rounding.
display: 'grid',
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
gridAutoRows: `${cellSize}px`,
gap: `${GAP}px`,
}}
>
{item.cells.map(({ photo }) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={cellSize}
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
activeHeapName={activeHeapName}