feat: floating capture-date chip on timeline scrollbar

Tracks the scroll position and shows a small date chip pinned to the
right edge of the timeline, fading in while the user scrolls and out
700ms after they stop. Only active in date-sorted views — other sort
modes hide it since the label would be meaningless. A cached
row-offset/date index keeps the lookup to a single binary search per
scroll frame.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 12:01:52 +02:00
parent dcf6c11a22
commit 64e4ea8083

View File

@@ -1,6 +1,7 @@
import { useRef, useEffect, useMemo, useState, useCallback } from 'react' import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { format, parseISO } from 'date-fns' import { format, parseISO } from 'date-fns'
import clsx from 'clsx'
import { usePhotoStore } from '../../store/photoStore' import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore' import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail' import { PhotoThumbnail } from './PhotoThumbnail'
@@ -204,6 +205,10 @@ export function Timeline() {
[photos, columns, cellSize, sortBy, groupBy] [photos, columns, cellSize, sortBy, groupBy]
) )
// Used by the sticky header, the row-date index, and the floating
// scrollbar chip — all three only make sense in date-sorted views.
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
// Pre-computed offset of every header in the virtualizer's coordinate // Pre-computed offset of every header in the virtualizer's coordinate
// space, used to drive the sticky-header overlay below. // space, used to drive the sticky-header overlay below.
const headerOffsets = useMemo(() => { const headerOffsets = useMemo(() => {
@@ -231,19 +236,132 @@ export function Timeline() {
virtualizer.measure() virtualizer.measure()
}, [items, virtualizer]) }, [items, virtualizer])
// Track scroll position so we can show the current group label as a // Track scroll position so we can (a) show the current group label as
// pinned overlay at the top of the scroll container. The virtualizer's // a pinned overlay at the top of the scroll container and (b) drive
// the floating "date next to the scrollbar" indicator. The virtualizer's
// items use transform translateY (so CSS position: sticky doesn't work // items use transform translateY (so CSS position: sticky doesn't work
// on the inline headers); the overlay sidesteps that by living outside // on the inline headers); the overlays sidestep that by living outside
// the virtualizer's positioned children. // the virtualizer's positioned children.
const [scrollTop, setScrollTop] = useState(0) //
// We capture clientHeight + scrollHeight alongside top so the floating
// indicator can position itself proportionally to scroll progress
// without needing a second observer to react to viewport resizes.
// `isScrolling` is a short-lived flag reset by a debounced timer —
// drives the fade in/out of the date chip so it only appears while
// the user is actively dragging the scrollbar / wheel-scrolling.
const [scrollMetrics, setScrollMetrics] = useState({
top: 0,
clientHeight: 0,
scrollHeight: 0,
})
const [isScrolling, setIsScrolling] = useState(false)
const scrollIdleTimerRef = useRef<number | null>(null)
useEffect(() => { useEffect(() => {
const el = parentRef.current const el = parentRef.current
if (!el) return if (!el) return
const onScroll = () => setScrollTop(el.scrollTop) const capture = () => {
setScrollMetrics({
top: el.scrollTop,
clientHeight: el.clientHeight,
scrollHeight: el.scrollHeight,
})
}
capture()
const onScroll = () => {
capture()
setIsScrolling(true)
if (scrollIdleTimerRef.current !== null) {
window.clearTimeout(scrollIdleTimerRef.current)
}
scrollIdleTimerRef.current = window.setTimeout(() => {
setIsScrolling(false)
scrollIdleTimerRef.current = null
}, 700)
}
el.addEventListener('scroll', onScroll, { passive: true }) el.addEventListener('scroll', onScroll, { passive: true })
return () => el.removeEventListener('scroll', onScroll) return () => {
}, []) el.removeEventListener('scroll', onScroll)
if (scrollIdleTimerRef.current !== null) {
window.clearTimeout(scrollIdleTimerRef.current)
scrollIdleTimerRef.current = null
}
}
// Depend on isLoading + photo presence so the effect re-attaches
// after the early-return loading/empty JSX gives way to the real
// scroll container — on first mount parentRef.current is null and
// an empty-deps effect would never bind the listener.
}, [isLoading, photos.length])
// Alias for the sticky-header math below, which only needs the scroll
// offset. Kept as a local so the existing logic reads the same as
// before the metrics refactor.
const scrollTop = scrollMetrics.top
// Parallel index of every row's (cumulative offset, raw date string),
// built from the same flat items array the virtualizer reads. Used by
// the floating scrollbar-date chip to answer "what date is the user
// currently looking at" in O(log n) without reaching into the
// virtualizer's internals. Rebuilt whenever items or sort field change.
const rowDateIndex = useMemo(() => {
if (!isDateSort) return [] as { offset: number; raw: string | null }[]
const out: { offset: number; raw: string | null }[] = []
let cum = 0
for (const item of items) {
if (item.type === 'row') {
const first = item.cells[0]?.photo
const raw =
sortBy === 'added_at'
? first?.added_at ?? first?.taken_at ?? null
: first?.taken_at ?? null
out.push({ offset: cum, raw })
}
cum += item.height
}
return out
}, [items, sortBy, isDateSort])
// Date shown in the floating chip next to the scrollbar. Finds the
// deepest row whose offset is at/above the viewport top (plus a
// small lookahead so fast flicks feel responsive) and formats its
// photo's capture date. Null when not date-sorted, not scrolling, or
// when the topmost row has no date — we'd rather hide the chip than
// show an unhelpful "Unknown".
const scrollDateLabel = useMemo(() => {
if (!isDateSort || rowDateIndex.length === 0) return null
const target = scrollMetrics.top + 20
let raw: string | null = null
// Binary search: largest offset ≤ target.
let lo = 0
let hi = rowDateIndex.length - 1
while (lo <= hi) {
const mid = (lo + hi) >> 1
if (rowDateIndex[mid].offset <= target) {
raw = rowDateIndex[mid].raw
lo = mid + 1
} else {
hi = mid - 1
}
}
if (!raw) return null
try {
return format(parseISO(raw), 'MMM d, yyyy')
} catch {
return null
}
}, [rowDateIndex, scrollMetrics.top, isDateSort])
// Vertical position of the floating chip in scroll-viewport pixels.
// Tracks the scrollbar thumb's position by mapping scroll progress
// linearly onto the viewport height. Clamped so the chip never
// overflows the top/bottom padding even at scroll extremes.
const scrollIndicatorTop = useMemo(() => {
const { top, clientHeight, scrollHeight } = scrollMetrics
if (scrollHeight <= clientHeight) return 8
const ratio = top / (scrollHeight - clientHeight)
const chipHeight = 28
const usable = clientHeight - chipHeight - 16
return 8 + Math.max(0, Math.min(usable, ratio * usable))
}, [scrollMetrics])
// Find the latest header whose BOTTOM is above the viewport top. That's // Find the latest header whose BOTTOM is above the viewport top. That's
// the group whose natural in-grid header has scrolled out of view — // the group whose natural in-grid header has scrolled out of view —
@@ -557,6 +675,22 @@ export function Timeline() {
</div> </div>
)} )}
{/* Floating scrollbar date chip. Sits just inside the right edge,
* vertically tracking the scrollbar thumb's position, and fades
* out 700ms after the user stops scrolling. Read-only/decorative,
* so pointer-events-none — it must never steal scrollbar clicks. */}
{isDateSort && scrollDateLabel && (
<div
className={clsx(
'pointer-events-none absolute right-4 z-30 rounded-md border border-border bg-surface/95 px-2.5 py-1 text-xs font-semibold text-text shadow-lg backdrop-blur transition-opacity duration-200',
isScrolling ? 'opacity-100' : 'opacity-0'
)}
style={{ top: `${scrollIndicatorTop}px` }}
>
{scrollDateLabel}
</div>
)}
<div <div
ref={parentRef} ref={parentRef}
className="h-full overflow-auto bg-bg" className="h-full overflow-auto bg-bg"