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
|
// Track the rendered column count of the duplicates grid so ↑/↓ can
|
||||||
// skip a row instead of jumping a single cell. The grid uses
|
// 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
|
// We measure the FIRST section's grid container — every section uses
|
||||||
// the same auto-fill rule so any one is representative.
|
// the same auto-fill rule so any one is representative.
|
||||||
const [columns, setColumns] = useState(4)
|
const [columns, setColumns] = useState(4)
|
||||||
@@ -100,7 +100,7 @@ export function DuplicatesView() {
|
|||||||
sampleObserverRef.current = null
|
sampleObserverRef.current = null
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
|
const cols = Math.max(1, Math.floor(el.clientWidth / 160))
|
||||||
setColumns(cols)
|
setColumns(cols)
|
||||||
}
|
}
|
||||||
measure()
|
measure()
|
||||||
@@ -386,7 +386,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
|||||||
className="grid gap-1 p-2"
|
className="grid gap-1 p-2"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns:
|
gridTemplateColumns:
|
||||||
'repeat(auto-fill, minmax(180px, 1fr))',
|
'repeat(auto-fill, 160px)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{group.members.map((member) => {
|
{group.members.map((member) => {
|
||||||
@@ -399,7 +399,7 @@ const DuplicateGroupSection = memo(function DuplicateGroupSection({
|
|||||||
>
|
>
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
photo={photoByMemberId.get(member.id)!}
|
photo={photoByMemberId.get(member.id)!}
|
||||||
size={180}
|
size={160}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedSet.has(member.id)}
|
isSelected={selectedSet.has(member.id)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
|||||||
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||||
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
|
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
|
const GAP = 4
|
||||||
|
|
||||||
|
|
||||||
@@ -71,27 +73,41 @@ export function MemoriesView() {
|
|||||||
[openPreview],
|
[openPreview],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Measure column count from the first grid's actual computed template
|
// Measure the scroll container directly and subtract its padding to
|
||||||
// so arrow nav matches what the user sees. Auto-fill re-flows on resize,
|
// get the real grid width. A ResizeObserver on the container itself
|
||||||
// so a ResizeObserver keeps the count current.
|
// always fires on width changes — sentinel elements can miss changes
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null)
|
// in some edge cases (zero-height, detached during loading states).
|
||||||
const gridRefs = useRef<(HTMLDivElement | null)[]>([])
|
const [containerWidth, setContainerWidth] = useState(0)
|
||||||
const [columns, setColumns] = useState(1)
|
const roRef = useRef<ResizeObserver | null>(null)
|
||||||
useEffect(() => {
|
const scrollRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
const el = gridRefs.current.find((g) => g)
|
roRef.current?.disconnect()
|
||||||
|
roRef.current = null
|
||||||
|
scrollElRef.current = el
|
||||||
if (!el) return
|
if (!el) return
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
const cols = window
|
const style = window.getComputedStyle(el)
|
||||||
.getComputedStyle(el)
|
const padX =
|
||||||
.gridTemplateColumns.split(' ')
|
parseFloat(style.paddingLeft) + parseFloat(style.paddingRight)
|
||||||
.filter(Boolean).length
|
setContainerWidth(Math.max(0, el.clientWidth - padX))
|
||||||
if (cols > 0) setColumns(cols)
|
|
||||||
}
|
}
|
||||||
measure()
|
measure()
|
||||||
const ro = new ResizeObserver(measure)
|
const ro = new ResizeObserver(measure)
|
||||||
ro.observe(el)
|
ro.observe(el)
|
||||||
return () => ro.disconnect()
|
roRef.current = ro
|
||||||
}, [memories.length])
|
}, [])
|
||||||
|
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
|
// Break each year's photos into rows of `columns` ids — matches the
|
||||||
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
|
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
|
||||||
@@ -110,13 +126,13 @@ export function MemoriesView() {
|
|||||||
const scrollRowIntoView = useCallback((rowIdx: number) => {
|
const scrollRowIntoView = useCallback((rowIdx: number) => {
|
||||||
const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id
|
const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id
|
||||||
if (!firstId) return
|
if (!firstId) return
|
||||||
const scrollEl = scrollRef.current
|
const scrollEl = scrollElRef.current
|
||||||
if (!scrollEl) return
|
if (!scrollEl) return
|
||||||
const cellEl = scrollEl.querySelector<HTMLElement>(
|
const cellEl = scrollEl.querySelector<HTMLElement>(
|
||||||
`[data-photo-id="${firstId}"]`,
|
`[data-photo-id="${firstId}"]`,
|
||||||
)
|
)
|
||||||
if (!cellEl) return
|
if (!cellEl) return
|
||||||
const peek = Math.round(CELL_SIZE * 0.35)
|
const peek = Math.round(cellSize * 0.35)
|
||||||
const cellTop = cellEl.offsetTop
|
const cellTop = cellEl.offsetTop
|
||||||
const cellBottom = cellTop + cellEl.offsetHeight
|
const cellBottom = cellTop + cellEl.offsetHeight
|
||||||
const viewTop = scrollEl.scrollTop
|
const viewTop = scrollEl.scrollTop
|
||||||
@@ -172,7 +188,7 @@ export function MemoriesView() {
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{memories.map((group, groupIdx) => (
|
{memories.map((group) => (
|
||||||
<section key={group.year} className="space-y-2">
|
<section key={group.year} className="space-y-2">
|
||||||
<header className="flex items-baseline gap-2">
|
<header className="flex items-baseline gap-2">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
||||||
@@ -183,13 +199,10 @@ export function MemoriesView() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
|
||||||
gridRefs.current[groupIdx] = el
|
|
||||||
}}
|
|
||||||
className="grid"
|
className="grid"
|
||||||
style={{
|
style={{
|
||||||
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
|
gridTemplateColumns: `repeat(${columns}, ${cellSize}px)`,
|
||||||
gridAutoRows: `${CELL_SIZE}px`,
|
gridAutoRows: `${cellSize}px`,
|
||||||
gap: `${GAP}px`,
|
gap: `${GAP}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -197,7 +210,7 @@ export function MemoriesView() {
|
|||||||
<div key={m.id} data-photo-id={m.id}>
|
<div key={m.id} data-photo-id={m.id}>
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
photo={memoryToPhoto(m)}
|
photo={memoryToPhoto(m)}
|
||||||
size={CELL_SIZE}
|
size={cellSize}
|
||||||
fill
|
fill
|
||||||
isSelected={selectedPhotos.includes(m.id)}
|
isSelected={selectedPhotos.includes(m.id)}
|
||||||
isInActiveHeap={activeHeapMembers.has(m.id)}
|
isInActiveHeap={activeHeapMembers.has(m.id)}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { ImageOff } from 'lucide-react'
|
|||||||
import type { Photo } from '../../types/photo'
|
import type { Photo } from '../../types/photo'
|
||||||
|
|
||||||
// Layout constants for the grid + grouped headers.
|
// Layout constants for the grid + grouped headers.
|
||||||
const THUMBNAIL_SIZE = 200
|
const THUMBNAIL_SIZE = 160
|
||||||
const GAP = 4
|
const GAP = 4
|
||||||
const PADDING = 16
|
const PADDING = 16
|
||||||
const HEADER_HEIGHT = 36
|
const HEADER_HEIGHT = 36
|
||||||
@@ -121,7 +121,7 @@ function buildItems(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Timeline() {
|
export function Timeline() {
|
||||||
const parentRef = useRef<HTMLDivElement>(null)
|
const parentRef = useRef<HTMLDivElement | null>(null)
|
||||||
// Sentinel placed inside the inner virtualizer wrapper at the exact
|
// Sentinel placed inside the inner virtualizer wrapper at the exact
|
||||||
// position rows will render. We measure THIS instead of parentRef,
|
// position rows will render. We measure THIS instead of parentRef,
|
||||||
// because parentRef has padding and we'd otherwise have to subtract
|
// 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 searchQuery = useFilterStore((s) => s.q)
|
||||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
|
|
||||||
// Calculate number of columns + actual cell size based on container
|
// Number of columns that fit at the FIXED THUMBNAIL_SIZE. Cells no
|
||||||
// width. Treat THUMBNAIL_SIZE as a *minimum* and let cells grow to
|
// longer stretch to absorb the remaining width — we prefer a stable
|
||||||
// fill the remaining space, so we never leave a horizontal gap on
|
// cell size across sidebar-open / sidebar-closed states over a
|
||||||
// the right side of the grid.
|
// perfectly-flush right edge. A small gap may remain on the right
|
||||||
//
|
// when containerWidth isn't an integer multiple of (T+G).
|
||||||
// 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.
|
|
||||||
const { columns, cellSize } = useMemo(() => {
|
const { columns, cellSize } = useMemo(() => {
|
||||||
if (containerWidth === 0) {
|
if (containerWidth === 0) {
|
||||||
return { columns: 4, cellSize: THUMBNAIL_SIZE }
|
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(
|
const cols = Math.max(
|
||||||
1,
|
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
|
return { columns: cols, cellSize: THUMBNAIL_SIZE }
|
||||||
// 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])
|
}, [containerWidth])
|
||||||
|
|
||||||
// Shared photos query — both Timeline and PreviewView use the same hook so
|
// Shared photos query — both Timeline and PreviewView use the same hook so
|
||||||
@@ -438,41 +425,29 @@ export function Timeline() {
|
|||||||
return current
|
return current
|
||||||
}, [headerOffsets, scrollTop])
|
}, [headerOffsets, scrollTop])
|
||||||
|
|
||||||
// Measure the sentinel's actual rendered width on mount, window
|
// Measure the scroll container directly and subtract its padding to
|
||||||
// resize, and any layout change driven by the sidebar collapse /
|
// get the real grid width. Observing the scroll container itself (vs
|
||||||
// right panel toggle. ResizeObserver picks up everything window
|
// a sentinel child) reliably fires on every sidebar toggle and
|
||||||
// resize misses (sidebar collapse doesn't fire window resize).
|
// window resize — no edge cases with zero-height observers or
|
||||||
//
|
// early-returned loading states detaching the sentinel.
|
||||||
// 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.
|
|
||||||
const roRef = useRef<ResizeObserver | null>(null)
|
const roRef = useRef<ResizeObserver | null>(null)
|
||||||
const measureElRef = useRef<HTMLDivElement | null>(null)
|
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||||
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
|
|
||||||
roRef.current?.disconnect()
|
roRef.current?.disconnect()
|
||||||
roRef.current = null
|
roRef.current = null
|
||||||
measureElRef.current = el
|
parentRef.current = el
|
||||||
if (!el) return
|
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()
|
measure()
|
||||||
const ro = new ResizeObserver(measure)
|
const ro = new ResizeObserver(measure)
|
||||||
ro.observe(el)
|
ro.observe(el)
|
||||||
roRef.current = ro
|
roRef.current = ro
|
||||||
}, [])
|
}, [])
|
||||||
useEffect(() => {
|
useEffect(() => () => roRef.current?.disconnect(), [])
|
||||||
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
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Photo rows in visual order — drops the header items so navigation
|
// 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
|
// walks the grid as the user sees it. Each row has cells of length
|
||||||
@@ -647,7 +622,7 @@ export function Timeline() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={parentRef}
|
ref={scrollContainerRef}
|
||||||
className="h-full overflow-auto bg-bg"
|
className="h-full overflow-auto bg-bg"
|
||||||
// Extra bottom padding so the last row clears the floating
|
// Extra bottom padding so the last row clears the floating
|
||||||
// KeyboardHints pill (which sits at bottom-4, ~40px tall).
|
// KeyboardHints pill (which sits at bottom-4, ~40px tall).
|
||||||
@@ -660,23 +635,6 @@ 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
|
||||||
|
|||||||
Reference in New Issue
Block a user