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:
@@ -17,6 +17,13 @@ const AUTO_RETRY_DELAYS = [1500, 3500, 7000, 12000, 20000]
|
|||||||
interface PhotoThumbnailProps {
|
interface PhotoThumbnailProps {
|
||||||
photo: Photo
|
photo: Photo
|
||||||
size: number
|
size: number
|
||||||
|
/** When true, the cell stretches to fill its parent (100% width +
|
||||||
|
* 100% height) and ignores `size` for the box dimensions. Used by
|
||||||
|
* the Timeline grid where the parent is a CSS grid track of 1fr —
|
||||||
|
* this is what guarantees the row fills the container without any
|
||||||
|
* rounding gap on the right. The heap sidebar leaves this off so
|
||||||
|
* thumbnails stay at the explicit `size`. */
|
||||||
|
fill?: boolean
|
||||||
isSelected: boolean
|
isSelected: boolean
|
||||||
/** True when the photo belongs to the currently active heap. */
|
/** True when the photo belongs to the currently active heap. */
|
||||||
isInActiveHeap?: boolean
|
isInActiveHeap?: boolean
|
||||||
@@ -30,6 +37,7 @@ interface PhotoThumbnailProps {
|
|||||||
export function PhotoThumbnail({
|
export function PhotoThumbnail({
|
||||||
photo,
|
photo,
|
||||||
size,
|
size,
|
||||||
|
fill = false,
|
||||||
isSelected,
|
isSelected,
|
||||||
isInActiveHeap = false,
|
isInActiveHeap = false,
|
||||||
activeHeapName = null,
|
activeHeapName = null,
|
||||||
@@ -51,6 +59,12 @@ export function PhotoThumbnail({
|
|||||||
// overflowed their row because TanStack Virtual estimates row height as a
|
// overflowed their row because TanStack Virtual estimates row height as a
|
||||||
// single fixed value — portraits in a landscape row would overlap the row
|
// single fixed value — portraits in a landscape row would overlap the row
|
||||||
// below. With object-cover the image still fills the cell, just cropped.
|
// below. With object-cover the image still fills the cell, just cropped.
|
||||||
|
//
|
||||||
|
// The cell stretches to whatever width the parent grid track gives it
|
||||||
|
// (via width:100% + aspect-ratio:1) so the timeline's CSS grid can hand
|
||||||
|
// out 1fr columns and we never leave horizontal space unused. `size`
|
||||||
|
// remains the *minimum* track width and the fallback when there's no
|
||||||
|
// parent grid (e.g. heap thumbnails).
|
||||||
const displayHeight = size
|
const displayHeight = size
|
||||||
|
|
||||||
const clearRetryTimer = () => {
|
const clearRetryTimer = () => {
|
||||||
@@ -131,10 +145,11 @@ export function PhotoThumbnail({
|
|||||||
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
|
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
|
||||||
!imageLoaded && 'bg-surface animate-pulse'
|
!imageLoaded && 'bg-surface animate-pulse'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={
|
||||||
width: size,
|
fill
|
||||||
height: displayHeight,
|
? { width: '100%', height: '100%' }
|
||||||
}}
|
: { width: size, height: displayHeight }
|
||||||
|
}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
draggable
|
draggable
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ function buildItems(
|
|||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
const parentRef = useRef<HTMLDivElement>(null)
|
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 [containerWidth, setContainerWidth] = useState(0)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -195,12 +201,18 @@ export function Timeline() {
|
|||||||
if (containerWidth === 0) {
|
if (containerWidth === 0) {
|
||||||
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
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(
|
const cols = Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP))
|
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 }
|
return { columns: cols, cellSize: cell }
|
||||||
}, [containerWidth])
|
}, [containerWidth])
|
||||||
|
|
||||||
@@ -279,11 +291,12 @@ export function Timeline() {
|
|||||||
return current
|
return current
|
||||||
}, [headerOffsets, scrollTop])
|
}, [headerOffsets, scrollTop])
|
||||||
|
|
||||||
// Measure container width on mount, window resize, and any layout
|
// Measure the sentinel's actual rendered width on mount, window
|
||||||
// change driven by the sidebar collapse / right panel toggle. Plain
|
// resize, and any layout change driven by the sidebar collapse /
|
||||||
// window.resize wouldn't catch those — ResizeObserver does.
|
// right panel toggle. ResizeObserver picks up everything window
|
||||||
|
// resize misses (sidebar collapse doesn't fire window resize).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = parentRef.current
|
const el = widthSentinelRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const measure = () => setContainerWidth(el.clientWidth)
|
const measure = () => setContainerWidth(el.clientWidth)
|
||||||
measure()
|
measure()
|
||||||
@@ -485,6 +498,23 @@ export function Timeline() {
|
|||||||
position: 'relative',
|
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) => {
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||||
const item = items[virtualItem.index]
|
const item = items[virtualItem.index]
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
@@ -523,12 +553,27 @@ export function Timeline() {
|
|||||||
transform: `translateY(${virtualItem.start}px)`,
|
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 }) => (
|
{item.cells.map(({ photo }) => (
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
size={cellSize}
|
size={cellSize}
|
||||||
|
fill
|
||||||
isSelected={selectedPhotos.includes(photo.id)}
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
activeHeapName={activeHeapName}
|
activeHeapName={activeHeapName}
|
||||||
|
|||||||
Reference in New Issue
Block a user