feat: persistent metadata panel, symmetric sidebar toggles, keyboard nav scroll
- Right sidebar stays open by default and shows an empty state when nothing is selected, instead of auto-hiding on deselect. - Both sidebars now have a collapse button in their header and an expand button in the TopBar that only appears when collapsed, so each panel has a discoverable affordance in either state. - Arrow-key navigation auto-scrolls the destination row into view with a ~35% peek margin, cueing the user that there's more content in the scroll direction. - Fix: the width sentinel's measurement effect never installed its ResizeObserver when Timeline first rendered the loading state (ref was null, empty-dep effect didn't re-run), so containerWidth stuck at 0 and the grid fell back to 4 columns × 200px forever. Switched to a callback ref that attaches the observer the moment the sentinel actually mounts. - KeyboardHints surface the Tab (library) and I (info) shortcuts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect, useMemo, useState } from 'react'
|
||||
import { useRef, useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { format, parseISO } from 'date-fns'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
@@ -167,7 +167,6 @@ export function Timeline() {
|
||||
// because parentRef has padding and we'd otherwise have to subtract
|
||||
// it (and account for any scrollbar) — easy to get wrong by a pixel
|
||||
// and end up with a column count off by one.
|
||||
const widthSentinelRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
const {
|
||||
@@ -295,17 +294,35 @@ export function Timeline() {
|
||||
// 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).
|
||||
useEffect(() => {
|
||||
const el = widthSentinelRef.current
|
||||
//
|
||||
// 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 measureElRef = useRef<HTMLDivElement | null>(null)
|
||||
const widthSentinelRef = useCallback((el: HTMLDivElement | null) => {
|
||||
roRef.current?.disconnect()
|
||||
roRef.current = null
|
||||
measureElRef.current = el
|
||||
if (!el) return
|
||||
const measure = () => setContainerWidth(el.clientWidth)
|
||||
measure()
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
window.addEventListener('resize', measure)
|
||||
roRef.current = ro
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
const el = measureElRef.current
|
||||
if (el) setContainerWidth(el.clientWidth)
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => {
|
||||
ro.disconnect()
|
||||
window.removeEventListener('resize', measure)
|
||||
window.removeEventListener('resize', onResize)
|
||||
roRef.current?.disconnect()
|
||||
roRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -318,6 +335,17 @@ export function Timeline() {
|
||||
[items]
|
||||
)
|
||||
|
||||
// Parallel array: photoRows[i] corresponds to items[photoRowItemIndex[i]].
|
||||
// Lets keyboard nav jump the virtualizer to the destination row even when
|
||||
// it hasn't been rendered yet (beyond the overscan window).
|
||||
const photoRowItemIndex = useMemo(() => {
|
||||
const map: number[] = []
|
||||
items.forEach((it, idx) => {
|
||||
if (it.type === 'row') map.push(idx)
|
||||
})
|
||||
return map
|
||||
}, [items])
|
||||
|
||||
// Flat visible-order id sequence — exactly the order the user reads
|
||||
// off the grid (top-to-bottom, left-to-right within each row).
|
||||
// Includes duplicates from tag-grouping; landing on the same photo's
|
||||
@@ -416,6 +444,35 @@ export function Timeline() {
|
||||
} else {
|
||||
selectPhoto(dest.photo.id)
|
||||
}
|
||||
// Bring the destination row into view if it's off-screen, leaving
|
||||
// a "peek" margin so the next row above/below stays partly visible
|
||||
// — cues the user that there's more content in the scroll direction.
|
||||
// In-viewport moves are a no-op, so same-row arrow presses don't
|
||||
// jitter the scroll position.
|
||||
const itemIdx = photoRowItemIndex[nextRow]
|
||||
const scrollEl = parentRef.current
|
||||
if (itemIdx !== undefined && scrollEl) {
|
||||
// Sum item heights up to itemIdx to get this row's offset in the
|
||||
// virtualizer's coordinate space. Cheap enough at O(items) and
|
||||
// avoids reaching into virtualizer.measurementsCache internals.
|
||||
let rowTop = 0
|
||||
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
|
||||
const rowHeight = items[itemIdx].height
|
||||
const peek = Math.round(cellSize * 0.35)
|
||||
const viewTop = scrollEl.scrollTop
|
||||
const viewBottom = viewTop + scrollEl.clientHeight
|
||||
if (rowTop - peek < viewTop) {
|
||||
// Destination is above (or flush with) the viewport top. Leave
|
||||
// `peek` pixels of the previous row visible above it.
|
||||
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
|
||||
} else if (rowTop + rowHeight + peek > viewBottom) {
|
||||
// Destination is below the viewport bottom. Leave `peek` pixels
|
||||
// of the next row visible below it.
|
||||
scrollEl.scrollTo({
|
||||
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
@@ -455,7 +512,7 @@ export function Timeline() {
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId])
|
||||
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user