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:
@@ -1,6 +1,7 @@
|
||||
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import clsx from 'clsx'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { PhotoThumbnail } from './PhotoThumbnail'
|
||||
@@ -204,6 +205,10 @@ export function Timeline() {
|
||||
[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
|
||||
// space, used to drive the sticky-header overlay below.
|
||||
const headerOffsets = useMemo(() => {
|
||||
@@ -231,19 +236,132 @@ export function Timeline() {
|
||||
virtualizer.measure()
|
||||
}, [items, virtualizer])
|
||||
|
||||
// Track scroll position so we can show the current group label as a
|
||||
// pinned overlay at the top of the scroll container. The virtualizer's
|
||||
// Track scroll position so we can (a) show the current group label as
|
||||
// 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
|
||||
// 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.
|
||||
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(() => {
|
||||
const el = parentRef.current
|
||||
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 })
|
||||
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
|
||||
// the group whose natural in-grid header has scrolled out of view —
|
||||
@@ -557,6 +675,22 @@ export function Timeline() {
|
||||
</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
|
||||
ref={parentRef}
|
||||
className="h-full overflow-auto bg-bg"
|
||||
|
||||
Reference in New Issue
Block a user