The inline date headers can't use CSS position: sticky because TanStack Virtual positions every item with transform translateY, which removes them from the document flow. Workaround: render a separate overlay above the scroll container that's absolutely positioned (left/right/top: 0) and updates its label as the user scrolls. The current label is computed from a pre-built headerOffsets array (cumulative sum of item heights up to each header) — find the latest header whose offset <= scrollTop, and that's the group containing whatever's at the top of the view. The overlay sits at z-20 above the photos with bg-bg/90 + backdrop-blur and pointer-events-none so it doesn't intercept clicks. Inline headers still render so the visual flow at group boundaries is smooth — the overlay is the persistent label. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import { useRef, useEffect, useMemo, useState } from 'react'
|
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
|
import { format, parseISO } from 'date-fns'
|
|
import { usePhotoStore } from '../../store/photoStore'
|
|
import { useFilterStore } from '../../store/filterStore'
|
|
import { PhotoThumbnail } from './PhotoThumbnail'
|
|
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
|
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
|
import type { Photo } from '../../types/photo'
|
|
|
|
// Layout constants for the grid + grouped headers.
|
|
const THUMBNAIL_SIZE = 200
|
|
const GAP = 4
|
|
const PADDING = 16
|
|
const HEADER_HEIGHT = 36
|
|
|
|
interface PhotoCell {
|
|
photo: Photo
|
|
globalIndex: number
|
|
}
|
|
|
|
type TimelineItem =
|
|
| { type: 'header'; key: string; label: string; height: number }
|
|
| { type: 'row'; key: string; cells: PhotoCell[]; height: number }
|
|
|
|
/**
|
|
* Build groups by month label when sorted by a date field. For non-temporal
|
|
* sorts (filename / file_size / rating) we return a single un-headered group.
|
|
*/
|
|
function buildItems(
|
|
photos: Photo[],
|
|
columns: number,
|
|
sortBy: string
|
|
): TimelineItem[] {
|
|
if (photos.length === 0) return []
|
|
|
|
const isDateSort = sortBy === 'taken_at' || sortBy === 'added_at'
|
|
const items: TimelineItem[] = []
|
|
|
|
// Helper: split a flat array of cells into rows of `columns` cells.
|
|
const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
|
|
for (let i = 0; i < cells.length; i += columns) {
|
|
const slice = cells.slice(i, i + columns)
|
|
items.push({
|
|
type: 'row',
|
|
key: `${groupKey}::row::${i}`,
|
|
cells: slice,
|
|
height: THUMBNAIL_SIZE + GAP,
|
|
})
|
|
}
|
|
}
|
|
|
|
if (!isDateSort) {
|
|
// No grouping — one row stream.
|
|
const cells: PhotoCell[] = photos.map((photo, globalIndex) => ({
|
|
photo,
|
|
globalIndex,
|
|
}))
|
|
pushRowsForGroup('all', cells)
|
|
return items
|
|
}
|
|
|
|
// Walk photos in order, breaking into groups whenever the month label changes.
|
|
let currentLabel: string | null = null
|
|
let bucket: PhotoCell[] = []
|
|
let bucketIndex = 0
|
|
|
|
const flushBucket = () => {
|
|
if (bucket.length === 0 || currentLabel === null) return
|
|
items.push({
|
|
type: 'header',
|
|
key: `header::${bucketIndex}::${currentLabel}`,
|
|
label: currentLabel,
|
|
height: HEADER_HEIGHT,
|
|
})
|
|
pushRowsForGroup(`${bucketIndex}::${currentLabel}`, bucket)
|
|
bucketIndex++
|
|
bucket = []
|
|
}
|
|
|
|
photos.forEach((photo, globalIndex) => {
|
|
const dateStr =
|
|
sortBy === 'taken_at'
|
|
? photo.taken_at
|
|
: (photo as any).added_at ?? photo.taken_at
|
|
let label: string
|
|
if (dateStr) {
|
|
try {
|
|
label = format(parseISO(dateStr), 'MMMM yyyy')
|
|
} catch {
|
|
label = 'Unknown date'
|
|
}
|
|
} else {
|
|
label = 'Unknown date'
|
|
}
|
|
if (label !== currentLabel) {
|
|
flushBucket()
|
|
currentLabel = label
|
|
}
|
|
bucket.push({ photo, globalIndex })
|
|
})
|
|
flushBucket()
|
|
|
|
return items
|
|
}
|
|
|
|
export function Timeline() {
|
|
const parentRef = useRef<HTMLDivElement>(null)
|
|
const [containerWidth, setContainerWidth] = useState(0)
|
|
|
|
const {
|
|
selectedPhotos,
|
|
lastSelectedIndex,
|
|
rangeStartIndex,
|
|
selectPhoto,
|
|
togglePhotoSelection,
|
|
clearSelection,
|
|
openPreview,
|
|
} = usePhotoStore()
|
|
|
|
const sortBy = useFilterStore((s) => s.sortBy)
|
|
|
|
// Calculate number of columns based on container width.
|
|
const columns = useMemo(() => {
|
|
if (containerWidth === 0) return 4
|
|
return Math.max(
|
|
1,
|
|
Math.floor((containerWidth - PADDING * 2) / (THUMBNAIL_SIZE + GAP))
|
|
)
|
|
}, [containerWidth])
|
|
|
|
// Shared photos query — both Timeline and PreviewView use the same hook so
|
|
// they share one cache entry, regardless of filter state.
|
|
const { data: photos = [], isLoading } = usePhotosQuery()
|
|
|
|
// Membership in the active heap (for the basket affordance). Subscribed
|
|
// once at this level so we don't have hundreds of thumbnails each
|
|
// subscribing to the same query.
|
|
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
|
|
|
|
// Build the flat virtualizer items: a mix of date-group headers and rows
|
|
// of photos. Headers only appear when sorted by a date field.
|
|
const items = useMemo(
|
|
() => buildItems(photos, columns, sortBy),
|
|
[photos, columns, sortBy]
|
|
)
|
|
|
|
// Pre-computed offset of every header in the virtualizer's coordinate
|
|
// space, used to drive the sticky-header overlay below.
|
|
const headerOffsets = useMemo(() => {
|
|
const result: { offset: number; label: string }[] = []
|
|
let cumulative = 0
|
|
for (const item of items) {
|
|
if (item.type === 'header') {
|
|
result.push({ offset: cumulative, label: item.label })
|
|
}
|
|
cumulative += item.height
|
|
}
|
|
return result
|
|
}, [items])
|
|
|
|
// Range-selection helper. Operates on the global photos array, not on
|
|
// virtualizer items.
|
|
const selectRange = (endIndex: number) => {
|
|
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
|
|
const minIndex = Math.min(startIndex, endIndex)
|
|
const maxIndex = Math.max(startIndex, endIndex)
|
|
for (let i = minIndex; i <= maxIndex; i++) {
|
|
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
|
|
togglePhotoSelection(photos[i].id, i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Virtual scrolling setup with per-item heights.
|
|
const virtualizer = useVirtualizer({
|
|
count: items.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize: (index) => items[index]?.height ?? THUMBNAIL_SIZE,
|
|
overscan: 5,
|
|
})
|
|
|
|
// Re-measure when items change (column count, group structure).
|
|
useEffect(() => {
|
|
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
|
|
// items use transform translateY (so CSS position: sticky doesn't work
|
|
// on the inline headers); the overlay sidesteps that by living outside
|
|
// the virtualizer's positioned children.
|
|
const [scrollTop, setScrollTop] = useState(0)
|
|
useEffect(() => {
|
|
const el = parentRef.current
|
|
if (!el) return
|
|
const onScroll = () => setScrollTop(el.scrollTop)
|
|
el.addEventListener('scroll', onScroll, { passive: true })
|
|
return () => el.removeEventListener('scroll', onScroll)
|
|
}, [])
|
|
|
|
// Find the latest header whose start <= scrollTop. That's the label of
|
|
// the group containing whatever is currently at the top of the viewport.
|
|
const stickyLabel = useMemo(() => {
|
|
if (headerOffsets.length === 0) return null
|
|
let current: string | null = null
|
|
for (const h of headerOffsets) {
|
|
if (h.offset <= scrollTop) current = h.label
|
|
else break
|
|
}
|
|
return current
|
|
}, [headerOffsets, scrollTop])
|
|
|
|
// Measure container width on mount and resize.
|
|
useEffect(() => {
|
|
const measureWidth = () => {
|
|
if (parentRef.current) {
|
|
setContainerWidth(parentRef.current.clientWidth)
|
|
}
|
|
}
|
|
measureWidth()
|
|
window.addEventListener('resize', measureWidth)
|
|
return () => window.removeEventListener('resize', measureWidth)
|
|
}, [])
|
|
|
|
// Handle keyboard shortcuts for photo navigation. Operates on the flat
|
|
// photos array, so it ignores grouping.
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (photos.length === 0) return
|
|
const target = e.target as HTMLElement | null
|
|
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
|
|
return
|
|
}
|
|
|
|
const currentIndex = lastSelectedIndex ?? -1
|
|
|
|
switch (e.key) {
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
if (currentIndex > columns - 1) {
|
|
const newIndex = currentIndex - columns
|
|
if (e.shiftKey) selectRange(newIndex)
|
|
else selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
break
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
if (currentIndex < photos.length - columns) {
|
|
const newIndex = Math.min(currentIndex + columns, photos.length - 1)
|
|
if (e.shiftKey) selectRange(newIndex)
|
|
else selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
break
|
|
case 'ArrowLeft':
|
|
e.preventDefault()
|
|
if (currentIndex > 0) {
|
|
const newIndex = currentIndex - 1
|
|
if (e.shiftKey) selectRange(newIndex)
|
|
else selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
break
|
|
case 'ArrowRight':
|
|
e.preventDefault()
|
|
if (currentIndex < photos.length - 1) {
|
|
const newIndex = currentIndex + 1
|
|
if (e.shiftKey) selectRange(newIndex)
|
|
else selectPhoto(photos[newIndex].id, newIndex)
|
|
}
|
|
break
|
|
case 'a':
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault()
|
|
photos.forEach((photo, index) => {
|
|
if (!selectedPhotos.includes(photo.id)) {
|
|
togglePhotoSelection(photo.id, index)
|
|
}
|
|
})
|
|
}
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
clearSelection()
|
|
break
|
|
}
|
|
}
|
|
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [photos, selectedPhotos, lastSelectedIndex, columns])
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-text-muted">Loading photos...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (photos.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div className="text-text-muted">No photos found. Add a source folder to get started.</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="relative h-full">
|
|
{/* Sticky group-header overlay. Lives outside the virtualizer's
|
|
* positioned children so it isn't affected by translateY transforms.
|
|
* Updates as the user scrolls past month boundaries. */}
|
|
{stickyLabel && (
|
|
<div className="pointer-events-none absolute left-0 right-0 top-0 z-20 border-b border-border bg-bg/90 px-4 py-1 backdrop-blur-sm">
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
|
|
{stickyLabel}
|
|
</h3>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={parentRef}
|
|
className="h-full overflow-auto bg-bg"
|
|
style={{ padding: `${PADDING}px` }}
|
|
>
|
|
<div
|
|
style={{
|
|
height: `${virtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
{virtualizer.getVirtualItems().map((virtualItem) => {
|
|
const item = items[virtualItem.index]
|
|
if (!item) return null
|
|
|
|
if (item.type === 'header') {
|
|
return (
|
|
<div
|
|
key={virtualItem.key}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: `${virtualItem.size}px`,
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
}}
|
|
className="flex items-end pb-1"
|
|
>
|
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-text-muted">
|
|
{item.label}
|
|
</h3>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// row
|
|
return (
|
|
<div
|
|
key={virtualItem.key}
|
|
style={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
height: `${virtualItem.size}px`,
|
|
transform: `translateY(${virtualItem.start}px)`,
|
|
}}
|
|
>
|
|
<div className="flex" style={{ gap: `${GAP}px` }}>
|
|
{item.cells.map(({ photo, globalIndex }) => (
|
|
<PhotoThumbnail
|
|
key={photo.id}
|
|
photo={photo}
|
|
size={THUMBNAIL_SIZE}
|
|
isSelected={selectedPhotos.includes(photo.id)}
|
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
|
onClick={(e) => {
|
|
if (e.shiftKey && lastSelectedIndex !== null) {
|
|
selectRange(globalIndex)
|
|
} else if (e.ctrlKey || e.metaKey) {
|
|
togglePhotoSelection(photo.id, globalIndex)
|
|
} else {
|
|
selectPhoto(photo.id, globalIndex)
|
|
}
|
|
}}
|
|
onDoubleClick={() => openPreview(photo.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|