ui: uniform 160px thumbnail cells across all grid views
Timeline, Memories, and Duplicates now share a single fixed cell size (THUMBNAIL_SIZE=160) with no 1fr stretching — cells stay exactly 160px regardless of sidebar state, at the cost of a small right-edge strip when the container width isn't a multiple of (160+gap). Width is measured on the scroll container itself with padding subtracted so sidebar expand/collapse reliably reflows the grid. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -87,7 +87,7 @@ export function DuplicatesView() {
|
||||
|
||||
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||
// skip a row instead of jumping a single cell. The grid uses
|
||||
// `repeat(auto-fill, minmax(180px, 1fr))` so columns = floor(width/180).
|
||||
// `repeat(auto-fill, minmax(160px, 1fr))` so columns = floor(width/160).
|
||||
// We measure the FIRST section's grid container — every section uses
|
||||
// the same auto-fill rule so any one is representative.
|
||||
const [columns, setColumns] = useState(4)
|
||||
@@ -100,7 +100,7 @@ export function DuplicatesView() {
|
||||
sampleObserverRef.current = null
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
|
||||
const cols = Math.max(1, Math.floor(el.clientWidth / 160))
|
||||
setColumns(cols)
|
||||
}
|
||||
measure()
|
||||
@@ -386,7 +386,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
||||
className="grid gap-1 p-2"
|
||||
style={{
|
||||
gridTemplateColumns:
|
||||
'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
'repeat(auto-fill, 160px)',
|
||||
}}
|
||||
>
|
||||
{group.members.map((member) => {
|
||||
@@ -399,7 +399,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
||||
>
|
||||
<PhotoThumbnail
|
||||
photo={photoByMemberId.get(member.id)!}
|
||||
size={180}
|
||||
size={160}
|
||||
fill
|
||||
isSelected={selectedSet.has(member.id)}
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
||||
|
||||
const CELL_SIZE = 180
|
||||
// Match Timeline's THUMBNAIL_SIZE / GAP so memories cells line up
|
||||
// visually with what the user sees in the main grid.
|
||||
const THUMBNAIL_SIZE = 160
|
||||
const GAP = 4
|
||||
|
||||
|
||||
@@ -71,27 +73,41 @@ export function MemoriesView() {
|
||||
[openPreview],
|
||||
)
|
||||
|
||||
// Measure column count from the first grid's actual computed template
|
||||
// so arrow nav matches what the user sees. Auto-fill re-flows on resize,
|
||||
// so a ResizeObserver keeps the count current.
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const gridRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const [columns, setColumns] = useState(1)
|
||||
useEffect(() => {
|
||||
const el = gridRefs.current.find((g) => g)
|
||||
// Measure the scroll container directly and subtract its padding to
|
||||
// get the real grid width. A ResizeObserver on the container itself
|
||||
// always fires on width changes — sentinel elements can miss changes
|
||||
// in some edge cases (zero-height, detached during loading states).
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const roRef = useRef<ResizeObserver | null>(null)
|
||||
const scrollRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect()
|
||||
roRef.current = null
|
||||
scrollElRef.current = el
|
||||
if (!el) return
|
||||
const measure = () => {
|
||||
const cols = window
|
||||
.getComputedStyle(el)
|
||||
.gridTemplateColumns.split(' ')
|
||||
.filter(Boolean).length
|
||||
if (cols > 0) setColumns(cols)
|
||||
const style = window.getComputedStyle(el)
|
||||
const padX =
|
||||
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||
setContainerWidth(Math.max(0, el.clientWidth - padX))
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [memories.length])
|
||||
roRef.current = ro
|
||||
}, [])
|
||||
const scrollElRef = useRef<HTMLDivElement | null>(null)
|
||||
useEffect(() => () => roRef.current?.disconnect(), [])
|
||||
// Fixed-size cells — always exactly THUMBNAIL_SIZE px, regardless of
|
||||
// container width. See Timeline's column-math comment for the
|
||||
// tradeoff (may leave a small gap on the right edge).
|
||||
const { columns, cellSize } = useMemo(() => {
|
||||
if (containerWidth === 0) return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
||||
const cols = Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP)),
|
||||
)
|
||||
return { columns: cols, cellSize: THUMBNAIL_SIZE }
|
||||
}, [containerWidth])
|
||||
|
||||
// Break each year's photos into rows of `columns` ids — matches the
|
||||
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
|
||||
@@ -110,13 +126,13 @@ export function MemoriesView() {
|
||||
const scrollRowIntoView = useCallback((rowIdx: number) => {
|
||||
const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id
|
||||
if (!firstId) return
|
||||
const scrollEl = scrollRef.current
|
||||
const scrollEl = scrollElRef.current
|
||||
if (!scrollEl) return
|
||||
const cellEl = scrollEl.querySelector<HTMLElement>(
|
||||
`[data-photo-id="${firstId}"]`,
|
||||
)
|
||||
if (!cellEl) return
|
||||
const peek = Math.round(CELL_SIZE * 0.35)
|
||||
const peek = Math.round(cellSize * 0.35)
|
||||
const cellTop = cellEl.offsetTop
|
||||
const cellBottom = cellTop + cellEl.offsetHeight
|
||||
const viewTop = scrollEl.scrollTop
|
||||
@@ -172,7 +188,7 @@ export function MemoriesView() {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{memories.map((group, groupIdx) => (
|
||||
{memories.map((group) => (
|
||||
<section key={group.year} className="space-y-2">
|
||||
<header className="flex items-baseline gap-2">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||
@@ -183,13 +199,10 @@ export function MemoriesView() {
|
||||
</span>
|
||||
</header>
|
||||
<div
|
||||
ref={(el) => {
|
||||
gridRefs.current[groupIdx] = el
|
||||
}}
|
||||
className="grid"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
|
||||
gridAutoRows: `${CELL_SIZE}px`,
|
||||
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
|
||||
gridAutoRows: `${cellSize}px`,
|
||||
gap: `${GAP}px`,
|
||||
}}
|
||||
>
|
||||
@@ -197,7 +210,7 @@ export function MemoriesView() {
|
||||
<div key={m.id} data-photo-id={m.id}>
|
||||
<PhotoThumbnail
|
||||
photo={memoryToPhoto(m)}
|
||||
size={CELL_SIZE}
|
||||
size={cellSize}
|
||||
fill
|
||||
isSelected={selectedPhotos.includes(m.id)}
|
||||
isInActiveHeap={activeHeapMembers.has(m.id)}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ImageOff } from 'lucide-react'
|
||||
import type { Photo } from '../../types/photo'
|
||||
|
||||
// Layout constants for the grid + grouped headers.
|
||||
const THUMBNAIL_SIZE = 200
|
||||
const THUMBNAIL_SIZE = 160
|
||||
const GAP = 4
|
||||
const PADDING = 16
|
||||
const HEADER_HEIGHT = 36
|
||||
@@ -121,7 +121,7 @@ function buildItems(
|
||||
}
|
||||
|
||||
export function Timeline() {
|
||||
const parentRef = useRef<HTMLDivElement>(null)
|
||||
const parentRef = useRef<HTMLDivElement | null>(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
|
||||
@@ -150,33 +150,20 @@ export function Timeline() {
|
||||
const searchQuery = useFilterStore((s) => s.q)
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
|
||||
// Calculate number of columns + actual cell size based on container
|
||||
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
|
||||
// fill the remaining space, so we never leave a horizontal gap on
|
||||
// the right side of the grid.
|
||||
//
|
||||
// Column math: with N columns there are N-1 inter-cell gaps, so the
|
||||
// width needed is N*T + (N-1)*G. Solving for the largest N that fits
|
||||
// in the available width gives N = floor((available + G) / (T + G)).
|
||||
// The previous formula floor((available) / (T + G)) was off-by-one
|
||||
// and lost a whole column whenever the remainder almost fit.
|
||||
// Number of columns that fit at the FIXED THUMBNAIL_SIZE. Cells no
|
||||
// longer stretch to absorb the remaining width — we prefer a stable
|
||||
// cell size across sidebar-open / sidebar-closed states over a
|
||||
// perfectly-flush right edge. A small gap may remain on the right
|
||||
// when containerWidth isn't an integer multiple of (T+G).
|
||||
const { columns, cellSize } = useMemo(() => {
|
||||
if (containerWidth === 0) {
|
||||
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
||||
}
|
||||
// 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))
|
||||
Math.floor((containerWidth + GAP) / (THUMBNAIL_SIZE + GAP))
|
||||
)
|
||||
// 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: THUMBNAIL_SIZE }
|
||||
}, [containerWidth])
|
||||
|
||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||
@@ -438,41 +425,29 @@ export function Timeline() {
|
||||
return current
|
||||
}, [headerOffsets, scrollTop])
|
||||
|
||||
// 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).
|
||||
//
|
||||
// Uses a callback ref (not useRef + useEffect) because Timeline
|
||||
// early-returns a loading/empty state before the sentinel exists,
|
||||
// so a mount-only effect would see a null ref and never install
|
||||
// the observer. The callback ref fires whenever the sentinel
|
||||
// actually attaches, which is the moment we can measure it.
|
||||
// Measure the scroll container directly and subtract its padding to
|
||||
// get the real grid width. Observing the scroll container itself (vs
|
||||
// a sentinel child) reliably fires on every sidebar toggle and
|
||||
// window resize — no edge cases with zero-height observers or
|
||||
// early-returned loading states detaching the sentinel.
|
||||
const roRef = useRef<ResizeObserver | null>(null)
|
||||
const measureElRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
|
||||
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect()
|
||||
roRef.current = null
|
||||
measureElRef.current = el
|
||||
parentRef.current = el
|
||||
if (!el) return
|
||||
const measure = () => setContainerWidth(el.clientWidth)
|
||||
const measure = () => {
|
||||
const style = window.getComputedStyle(el)
|
||||
const padX =
|
||||
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||
setContainerWidth(Math.max(0, el.clientWidth - padX))
|
||||
}
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
roRef.current = ro
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const el = measureElRef.current
|
||||
if (el) setContainerWidth(el.clientWidth)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize)
|
||||
roRef.current?.disconnect()
|
||||
roRef.current = null
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => () => roRef.current?.disconnect(), [])
|
||||
|
||||
// Photo rows in visual order — drops the header items so navigation
|
||||
// walks the grid as the user sees it. Each row has cells of length
|
||||
@@ -647,7 +622,7 @@ export function Timeline() {
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={parentRef}
|
||||
ref={scrollContainerRef}
|
||||
className="h-full overflow-auto bg-bg"
|
||||
// Extra bottom padding so the last row clears the floating
|
||||
// KeyboardHints pill (which sits at bottom-4, ~40px tall).
|
||||
@@ -660,23 +635,6 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user