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 { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Timeline } from './components/timeline/Timeline'
|
import { Timeline } from './components/timeline/Timeline'
|
||||||
import { LeftSidebar } from './components/layout/LeftSidebar'
|
import { LeftSidebar } from './components/layout/LeftSidebar'
|
||||||
import { RightSidebar } from './components/layout/RightSidebar'
|
import { RightSidebar } from './components/layout/RightSidebar'
|
||||||
@@ -18,9 +18,8 @@ import { usePhotosQuery } from './hooks/usePhotosQuery'
|
|||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false)
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
const selectedPhotos = usePhotoStore((state) => state.selectedPhotos)
|
|
||||||
const viewMode = usePhotoStore((state) => state.viewMode)
|
const viewMode = usePhotoStore((state) => state.viewMode)
|
||||||
|
|
||||||
// Bidirectional sync of filter store with URL query params.
|
// Bidirectional sync of filter store with URL query params.
|
||||||
@@ -38,24 +37,19 @@ function App() {
|
|||||||
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
|
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auto-show right sidebar when photos are selected — but only in grid mode,
|
// Right sidebar stays open by default and shows whatever's selected
|
||||||
// so leaving the preview doesn't fight the user's prior sidebar state.
|
// (or an empty state if nothing is). User can still toggle it manually.
|
||||||
// Lives in an effect (not the render body) to avoid setState-during-render
|
|
||||||
// and the cascading re-renders the audit caught.
|
|
||||||
useEffect(() => {
|
|
||||||
if (viewMode !== 'grid') return
|
|
||||||
if (selectedPhotos.length > 0 && !rightSidebarOpen) {
|
|
||||||
setRightSidebarOpen(true)
|
|
||||||
} else if (selectedPhotos.length === 0 && rightSidebarOpen) {
|
|
||||||
setRightSidebarOpen(false)
|
|
||||||
}
|
|
||||||
}, [viewMode, selectedPhotos.length, rightSidebarOpen])
|
|
||||||
|
|
||||||
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-bg text-text">
|
<div className="flex flex-col h-screen bg-bg text-text">
|
||||||
<TopBar onOpenSettings={() => setSettingsOpen(true)} />
|
<TopBar
|
||||||
|
onOpenSettings={() => setSettingsOpen(true)}
|
||||||
|
leftSidebarOpen={leftSidebarOpen}
|
||||||
|
rightSidebarOpen={showRightSidebar}
|
||||||
|
onExpandLeft={() => setLeftSidebarOpen(true)}
|
||||||
|
onExpandRight={() => setRightSidebarOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
@@ -64,7 +58,7 @@ function App() {
|
|||||||
leftSidebarOpen ? 'w-64' : 'w-0'
|
leftSidebarOpen ? 'w-64' : 'w-0'
|
||||||
} overflow-hidden border-r border-border bg-surface`}
|
} overflow-hidden border-r border-border bg-surface`}
|
||||||
>
|
>
|
||||||
<LeftSidebar />
|
<LeftSidebar onCollapse={() => setLeftSidebarOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
{/* Main column — filter bar, discard bar, timeline. Lives to the
|
||||||
@@ -90,7 +84,7 @@ function App() {
|
|||||||
showRightSidebar ? 'w-80' : 'w-0'
|
showRightSidebar ? 'w-80' : 'w-0'
|
||||||
} overflow-hidden border-l border-border bg-surface`}
|
} overflow-hidden border-l border-border bg-surface`}
|
||||||
>
|
>
|
||||||
<RightSidebar />
|
<RightSidebar onCollapse={() => setRightSidebarOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ export function KeyboardHints() {
|
|||||||
{ key: 'P', action: 'Pick → heap' },
|
{ key: 'P', action: 'Pick → heap' },
|
||||||
{ key: 'X', action: 'Discard' },
|
{ key: 'X', action: 'Discard' },
|
||||||
{ key: 'Space', action: 'Preview' },
|
{ key: 'Space', action: 'Preview' },
|
||||||
|
{ key: 'I', action: 'Info panel' },
|
||||||
{ key: 'Esc', action: 'Deselect' },
|
{ key: 'Esc', action: 'Deselect' },
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ key: '↑↓←→', action: 'Navigate' },
|
{ key: '↑↓←→', action: 'Navigate' },
|
||||||
{ key: 'Click', action: 'Select' },
|
|
||||||
{ key: 'Shift+Click', action: 'Range' },
|
|
||||||
{ key: 'Space', action: 'Preview' },
|
{ key: 'Space', action: 'Preview' },
|
||||||
|
{ key: 'Tab', action: 'Library panel' },
|
||||||
|
{ key: 'I', action: 'Info panel' },
|
||||||
{ key: '/', action: 'Search' },
|
{ key: '/', action: 'Search' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Layers2,
|
Layers2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
PanelLeftClose,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
import { sourceFolders, library, photos as photosApi, type FolderTreeNode } from '../../services/api'
|
||||||
@@ -41,7 +42,11 @@ interface TreeItem {
|
|||||||
type?: 'folder' | 'heap' | 'special'
|
type?: 'folder' | 'heap' | 'special'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeftSidebar() {
|
interface LeftSidebarProps {
|
||||||
|
onCollapse: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeftSidebar({ onCollapse }: LeftSidebarProps) {
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set(['library', 'folders', 'heaps']))
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
// Inline rename state for source-root rows. Stores the id being edited
|
// Inline rename state for source-root rows. Stores the id being edited
|
||||||
@@ -674,6 +679,19 @@ export function LeftSidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
|
{/* Header with collapse button. Matches the right sidebar header
|
||||||
|
* so both panels have symmetric affordances. */}
|
||||||
|
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text">Library</h2>
|
||||||
|
<button
|
||||||
|
onClick={onCollapse}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Collapse panel (Tab)"
|
||||||
|
aria-label="Collapse panel"
|
||||||
|
>
|
||||||
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{/* Tree View */}
|
{/* Tree View */}
|
||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{libraryTree.map((item) => renderTreeItem(item))}
|
{libraryTree.map((item) => renderTreeItem(item))}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { X, Star, Info, ShoppingBasket, Trash2, Plus } from 'lucide-react'
|
import { X, Star, Info, ShoppingBasket, Trash2, Plus, PanelRightClose } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
@@ -21,7 +21,11 @@ import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
|||||||
* - 2+ photos selected → renders a slim bulk-action panel that fans out
|
* - 2+ photos selected → renders a slim bulk-action panel that fans out
|
||||||
* rating / color / discard / pick across the entire selection.
|
* rating / color / discard / pick across the entire selection.
|
||||||
*/
|
*/
|
||||||
export function RightSidebar() {
|
interface RightSidebarProps {
|
||||||
|
onCollapse: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
||||||
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
const { selectedPhotos, activePhotoId, clearSelection } = usePhotoStore()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
@@ -134,12 +138,51 @@ export function RightSidebar() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Unified header rendered in every branch so the collapse button is
|
||||||
|
// always reachable regardless of selection state. Title and the
|
||||||
|
// clear-selection X adapt to what's selected.
|
||||||
|
const headerTitle =
|
||||||
|
selectedPhotos.length === 0
|
||||||
|
? 'Metadata'
|
||||||
|
: selectedPhotos.length === 1
|
||||||
|
? 'Metadata'
|
||||||
|
: `${selectedPhotos.length} Photos Selected`
|
||||||
|
|
||||||
|
const Header = () => (
|
||||||
|
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
||||||
|
<h2 className="text-sm font-semibold text-text">{headerTitle}</h2>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{selectedPhotos.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Clear selection (Esc)"
|
||||||
|
aria-label="Clear selection"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onCollapse}
|
||||||
|
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Collapse panel (I)"
|
||||||
|
aria-label="Collapse panel"
|
||||||
|
>
|
||||||
|
<PanelRightClose className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
if (selectedPhotos.length === 0) {
|
if (selectedPhotos.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
<div className="text-text-muted">
|
<Header />
|
||||||
<Info className="mx-auto mb-2 h-8 w-8" />
|
<div className="flex flex-1 items-center justify-center p-4 text-center">
|
||||||
<p className="text-sm">Select photos to view details</p>
|
<div className="text-text-muted">
|
||||||
|
<Info className="mx-auto mb-2 h-8 w-8" />
|
||||||
|
<p className="text-sm">Select photos to view details</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -150,17 +193,7 @@ export function RightSidebar() {
|
|||||||
const id = activePhotoId ?? selectedPhotos[0]
|
const id = activePhotoId ?? selectedPhotos[0]
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
<div className="flex h-11 flex-shrink-0 items-center justify-between border-b border-border px-4">
|
<Header />
|
||||||
<h2 className="text-sm font-semibold text-text">Metadata</h2>
|
|
||||||
<button
|
|
||||||
onClick={clearSelection}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Clear selection"
|
|
||||||
aria-label="Clear selection"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<PhotoInfoPanel photoId={id} />
|
<PhotoInfoPanel photoId={id} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -171,18 +204,7 @@ export function RightSidebar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-surface">
|
<div className="flex h-full flex-col bg-surface">
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
<Header />
|
||||||
<h2 className="text-sm font-semibold text-text">
|
|
||||||
{selectedPhotos.length} Photos Selected
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
onClick={clearSelection}
|
|
||||||
className="rounded p-1 text-text-muted hover:bg-surface-2 hover:text-text"
|
|
||||||
title="Clear selection"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 border-b border-border p-4">
|
<div className="space-y-3 border-b border-border p-4">
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
|
|||||||
@@ -1,23 +1,59 @@
|
|||||||
import { Settings } from 'lucide-react'
|
import { Settings, PanelLeftOpen, PanelRightOpen } from 'lucide-react'
|
||||||
import muliLogo from '../../assets/muli-logo.png'
|
import muliLogo from '../../assets/muli-logo.png'
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
onOpenSettings: () => void
|
onOpenSettings: () => void
|
||||||
|
leftSidebarOpen: boolean
|
||||||
|
rightSidebarOpen: boolean
|
||||||
|
onExpandLeft: () => void
|
||||||
|
onExpandRight: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Slim top bar — logo on the left, settings gear on the right. The
|
* Slim top bar — logo on the left, settings gear on the right. The
|
||||||
* active heap badge moved into the Heaps panel in the left sidebar
|
* active heap badge moved into the Heaps panel in the left sidebar
|
||||||
* (where it actually relates to the heap rows the user navigates to).
|
* (where it actually relates to the heap rows the user navigates to).
|
||||||
|
*
|
||||||
|
* Also hosts the "expand sidebar" affordances: when a side panel is
|
||||||
|
* collapsed, a small panel-open icon appears on the corresponding edge
|
||||||
|
* so the user has a way to bring it back without hunting for the
|
||||||
|
* keyboard shortcut. When the panel is open, the button hides — its
|
||||||
|
* collapse twin lives in the panel's own header.
|
||||||
*/
|
*/
|
||||||
export function TopBar({ onOpenSettings }: TopBarProps) {
|
export function TopBar({
|
||||||
|
onOpenSettings,
|
||||||
|
leftSidebarOpen,
|
||||||
|
rightSidebarOpen,
|
||||||
|
onExpandLeft,
|
||||||
|
onExpandRight,
|
||||||
|
}: TopBarProps) {
|
||||||
return (
|
return (
|
||||||
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
<header className="flex h-12 items-center justify-between border-b border-border bg-surface px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!leftSidebarOpen && (
|
||||||
|
<button
|
||||||
|
onClick={onExpandLeft}
|
||||||
|
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Expand panel (Tab)"
|
||||||
|
aria-label="Expand left panel"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
<img src={muliLogo} alt="Mulimago" className="h-7 w-7 object-contain" />
|
||||||
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
<h1 className="text-lg font-semibold text-text">Mulimago</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!rightSidebarOpen && (
|
||||||
|
<button
|
||||||
|
onClick={onExpandRight}
|
||||||
|
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||||
|
title="Expand panel (I)"
|
||||||
|
aria-label="Expand right panel"
|
||||||
|
>
|
||||||
|
<PanelRightOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onOpenSettings}
|
onClick={onOpenSettings}
|
||||||
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
className="rounded p-1.5 text-text-muted transition-colors hover:bg-surface-2 hover:text-text"
|
||||||
|
|||||||
@@ -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 { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { format, parseISO } from 'date-fns'
|
import { format, parseISO } from 'date-fns'
|
||||||
import { usePhotoStore } from '../../store/photoStore'
|
import { usePhotoStore } from '../../store/photoStore'
|
||||||
@@ -167,7 +167,6 @@ export function Timeline() {
|
|||||||
// because parentRef has padding and we'd otherwise have to subtract
|
// because parentRef has padding and we'd otherwise have to subtract
|
||||||
// it (and account for any scrollbar) — easy to get wrong by a pixel
|
// it (and account for any scrollbar) — easy to get wrong by a pixel
|
||||||
// and end up with a column count off by one.
|
// and end up with a column count off by one.
|
||||||
const widthSentinelRef = useRef<HTMLDivElement>(null)
|
|
||||||
const [containerWidth, setContainerWidth] = useState(0)
|
const [containerWidth, setContainerWidth] = useState(0)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -295,17 +294,35 @@ export function Timeline() {
|
|||||||
// resize, and any layout change driven by the sidebar collapse /
|
// resize, and any layout change driven by the sidebar collapse /
|
||||||
// right panel toggle. ResizeObserver picks up everything window
|
// right panel toggle. ResizeObserver picks up everything window
|
||||||
// resize misses (sidebar collapse doesn't fire window resize).
|
// 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
|
if (!el) return
|
||||||
const measure = () => setContainerWidth(el.clientWidth)
|
const measure = () => setContainerWidth(el.clientWidth)
|
||||||
measure()
|
measure()
|
||||||
const ro = new ResizeObserver(measure)
|
const ro = new ResizeObserver(measure)
|
||||||
ro.observe(el)
|
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 () => {
|
return () => {
|
||||||
ro.disconnect()
|
window.removeEventListener('resize', onResize)
|
||||||
window.removeEventListener('resize', measure)
|
roRef.current?.disconnect()
|
||||||
|
roRef.current = null
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -318,6 +335,17 @@ export function Timeline() {
|
|||||||
[items]
|
[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
|
// Flat visible-order id sequence — exactly the order the user reads
|
||||||
// off the grid (top-to-bottom, left-to-right within each row).
|
// off the grid (top-to-bottom, left-to-right within each row).
|
||||||
// Includes duplicates from tag-grouping; landing on the same photo's
|
// Includes duplicates from tag-grouping; landing on the same photo's
|
||||||
@@ -416,6 +444,35 @@ export function Timeline() {
|
|||||||
} else {
|
} else {
|
||||||
selectPhoto(dest.photo.id)
|
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) {
|
switch (e.key) {
|
||||||
@@ -455,7 +512,7 @@ export function Timeline() {
|
|||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId])
|
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId, photoRowItemIndex, items, cellSize])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user