perf+ux: cheaper sidebar toggle, virtualised filmstrip, compact toasts

Timeline's items array used to rebuild on every sub-pixel cellSize tick
during the sidebar CSS transition, causing visible jank with thousands
of photos. Row heights now resolve off a ref at virtualizer-measure
time, so items only rebuild when the column count actually changes.
PreviewFilmstrip is horizontally virtualised (~15 cells in the DOM
instead of N), cutting preview open latency on large libraries. Also
honor the user's explicit right-sidebar collapse (don't auto-reopen on
photo selection) and shrink the sonner toasts to a tighter form factor.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-15 15:24:32 +02:00
parent d72a218b46
commit 66b3bc5e1f
4 changed files with 149 additions and 58 deletions

View File

@@ -29,6 +29,11 @@ import { TooltipProvider } from '@/components/ui/tooltip'
function MainApp() {
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true)
const [rightSidebarOpen, setRightSidebarOpen] = useState(true)
// Respect the user's manual collapse of the metadata panel. Once they
// close it explicitly (via `i` hotkey or the sidebar toggle button),
// selecting a new photo should NOT force it back open. Cleared when
// they open it manually again.
const rightCollapsedByUser = useRef(false)
const viewMode = usePhotoStore((state) => state.viewMode)
const activePhotoId = usePhotoStore((state) => state.activePhotoId)
const currentSection = useFilterStore((s) => s.currentSection)
@@ -45,9 +50,24 @@ function MainApp() {
}, [currentSection])
useEffect(() => {
setRightSidebarOpen(!!activePhotoId)
if (!activePhotoId) {
setRightSidebarOpen(false)
return
}
// User explicitly collapsed the panel — don't undo that just because
// they picked a different photo.
if (rightCollapsedByUser.current) return
setRightSidebarOpen(true)
}, [activePhotoId])
const toggleRightSidebar = () => {
setRightSidebarOpen((prev) => {
const next = !prev
rightCollapsedByUser.current = !next
return next
})
}
// Bidirectional sync of filter store with URL query params.
useFilterUrlSync()
@@ -59,7 +79,7 @@ function MainApp() {
// Set up global keyboard shortcuts
useKeyboardShortcuts({
onToggleLeftSidebar: () => setLeftSidebarOpen(!leftSidebarOpen),
onToggleRightSidebar: () => setRightSidebarOpen(!rightSidebarOpen),
onToggleRightSidebar: toggleRightSidebar,
getFirstPhotoId: () => allPhotos?.[0]?.id ?? null,
})
@@ -96,7 +116,7 @@ function MainApp() {
leftSidebarOpen={leftSidebarOpen}
rightSidebarOpen={showRightSidebar}
onToggleLeftSidebar={() => setLeftSidebarOpen(!leftSidebarOpen)}
onToggleRightSidebar={() => setRightSidebarOpen(!rightSidebarOpen)}
onToggleRightSidebar={toggleRightSidebar}
/>
)}
{!isSettings && <DiscardActionBar />}

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { cn } from '@/lib/utils'
import type { Photo } from '../../types/photo'
import { photos as photosApi } from '../../services/api'
@@ -11,48 +12,87 @@ interface PreviewFilmstripProps {
}
const CELL_SIZE = 72
const GAP = 4
export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilmstripProps) {
const activeRef = useRef<HTMLButtonElement>(null)
/**
* Horizontal virtualised filmstrip. Mounting thousands of `<button>`
* nodes on preview open used to dominate the perceived open latency
* for large libraries — with virtualisation only a viewport's worth
* (~15-20 cells) ever hits the DOM.
*/
export function PreviewFilmstrip({
photos,
currentIndex,
onSelect,
}: PreviewFilmstripProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
useEffect(() => {
activeRef.current?.scrollIntoView({
block: 'nearest',
inline: 'center',
behavior: 'smooth',
})
}, [currentIndex])
const virtualizer = useVirtualizer({
horizontal: true,
count: photos.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => CELL_SIZE + GAP,
overscan: 10,
getItemKey: (idx) => photos[idx].id,
})
// Centre the active cell when the index changes. Using layout-effect
// so the scroll happens before paint — avoids a visible jump.
useLayoutEffect(() => {
if (currentIndex < 0 || currentIndex >= photos.length) return
virtualizer.scrollToIndex(currentIndex, { align: 'center' })
}, [currentIndex, photos.length, virtualizer])
return (
<div className="flex h-24 shrink-0 items-center gap-1 overflow-x-auto border-t border-border bg-surface px-2 py-2">
{photos.map((photo, index) => {
const isActive = index === currentIndex
const isInActiveHeap = activeHeapMembers.has(photo.id)
return (
<button
key={photo.id}
ref={isActive ? activeRef : null}
onClick={() => onSelect(photo.id)}
className={cn(
'relative shrink-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
isActive && 'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
!isActive && isInActiveHeap && 'opacity-100',
!isActive && !isInActiveHeap && 'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
)}
style={{ width: CELL_SIZE, height: CELL_SIZE }}
title={photo.filename}
>
<FilmstripThumb photo={photo} />
{isInActiveHeap && (
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
)}
{isActive && (
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
)}
</button>
)
})}
<div
ref={scrollRef}
className="relative h-24 shrink-0 overflow-x-auto overflow-y-hidden border-t border-border bg-surface py-2"
>
<div
style={{
width: virtualizer.getTotalSize(),
height: '100%',
position: 'relative',
paddingLeft: 8,
paddingRight: 8,
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const photo = photos[vItem.index]
const isActive = vItem.index === currentIndex
const isInActiveHeap = activeHeapMembers.has(photo.id)
return (
<button
key={photo.id}
onClick={() => onSelect(photo.id)}
className={cn(
'absolute top-0 overflow-hidden rounded-sm will-change-transform transition-[transform,box-shadow,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]',
isActive &&
'scale-90 opacity-100 ring-2 ring-blue-500 ring-offset-2 ring-offset-surface',
!isActive && isInActiveHeap && 'opacity-100',
!isActive &&
!isInActiveHeap &&
'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
)}
style={{
width: CELL_SIZE,
height: CELL_SIZE,
transform: `translateX(${vItem.start}px)`,
}}
title={photo.filename}
>
<FilmstripThumb photo={photo} />
{isInActiveHeap && (
<div className="pointer-events-none absolute inset-0 bg-emerald-500/40" />
)}
{isActive && (
<div className="pointer-events-none absolute inset-0 bg-blue-500/50" />
)}
</button>
)
})}
</div>
</div>
)
}

View File

@@ -40,7 +40,6 @@ type TimelineItem =
function buildItems(
photos: Photo[],
columns: number,
rowHeight: number,
sortBy: string,
groupBy: string,
): TimelineItem[] {
@@ -48,7 +47,9 @@ function buildItems(
const items: TimelineItem[] = []
// Helper: split a flat array of cells into rows of `columns` cells.
// Row items carry a placeholder `height: 0` — the virtualizer reads
// the live cellSize off a ref at render time (see `estimateSize`),
// so resizing the main column doesn't force items to rebuild.
const pushRowsForGroup = (groupKey: string, cells: PhotoCell[]) => {
for (let i = 0; i < cells.length; i += columns) {
const slice = cells.slice(i, i + columns)
@@ -56,7 +57,7 @@ function buildItems(
type: 'row',
key: `${groupKey}::row::${i}`,
cells: slice,
height: rowHeight + GAP,
height: 0,
})
}
}
@@ -232,11 +233,28 @@ export function Timeline() {
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date').
//
// Notably NOT dependent on `cellSize` — row heights are read off a ref
// at virtualizer-measurement time instead. This stops `items` (and
// every downstream memo) from rebuilding on every sub-pixel tick of
// the sidebar CSS transition, which used to cause visible jank in
// timelines with thousands of photos.
const items = useMemo(
() => buildItems(photos, columns, cellSize, sortBy, groupBy),
[photos, columns, cellSize, sortBy, groupBy]
() => buildItems(photos, columns, sortBy, groupBy),
[photos, columns, sortBy, groupBy]
)
// Live cell size, consumed by the virtualizer's estimateSize so row
// heights stay accurate as the column fluidly resizes.
const cellSizeRef = useRef(cellSize)
cellSizeRef.current = cellSize
// Row items carry a placeholder height of 0 (see buildItems). Any
// code walking `items` for scroll offsets must resolve it against the
// current cellSize; headers carry their fixed height verbatim.
const effectiveHeight = (it: TimelineItem) =>
it.type === 'header' ? it.height : cellSizeRef.current + GAP
// 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'
@@ -259,14 +277,23 @@ export function Timeline() {
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (index) => items[index]?.height ?? cellSize,
estimateSize: (index) => {
const it = items[index]
if (!it) return cellSizeRef.current + GAP
// Row items carry `height: 0` as a placeholder — always resolved
// to the current cellSize via the ref. Headers carry their own
// fixed height.
return it.type === 'header' ? it.height : cellSizeRef.current + GAP
},
overscan: 5,
})
// Re-measure when items change (column count, group structure).
// Re-measure when items change (column count, group structure) or
// cellSize shifts (sidebar collapse, window resize). Cheap — it just
// walks the item list and recomputes heights.
useEffect(() => {
virtualizer.measure()
}, [items, virtualizer])
}, [items, cellSize, virtualizer])
// 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
@@ -490,8 +517,8 @@ export function Timeline() {
const scrollEl = parentRef.current
if (itemIdx === undefined || !scrollEl) return
let rowTop = 0
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
const rowHeight = items[itemIdx].height
for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
const rowHeight = effectiveHeight(items[itemIdx])
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
@@ -552,8 +579,8 @@ export function Timeline() {
// 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
for (let i = 0; i < itemIdx; i++) rowTop += effectiveHeight(items[i])
const rowHeight = effectiveHeight(items[itemIdx])
const peek = Math.round(cellSize * 0.35)
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight

View File

@@ -10,16 +10,20 @@ const Toaster = ({ ...props }: ToasterProps) => (
<Sonner
position="bottom-left"
theme="dark"
// Compact toasts: tight padding, smaller gap, single-line title+desc
// so they don't hog the corner of the screen.
gap={6}
toastOptions={{
classNames: {
toast:
'group toast bg-surface/90 border border-border backdrop-blur-md text-text shadow-lg',
title: 'text-text text-sm font-semibold',
description: 'text-text-muted text-xs',
'group toast !gap-1.5 !p-2 !min-h-0 bg-surface/90 border border-border backdrop-blur-md text-text shadow',
title: 'text-text text-xs font-medium leading-tight',
description: 'text-text-muted text-[11px] leading-tight',
actionButton:
'bg-primary text-bg hover:bg-primary/90 rounded-md px-2 py-1 text-xs font-medium',
'!h-6 bg-primary text-bg hover:bg-primary/90 rounded-md px-1.5 text-[11px] font-medium',
cancelButton:
'bg-surface-2 text-text-muted hover:bg-surface-offset rounded-md px-2 py-1 text-xs',
'!h-6 bg-surface-2 text-text-muted hover:bg-surface-offset rounded-md px-1.5 text-[11px]',
icon: '!size-3.5 !mr-1',
success: 'border-l-2 border-l-pick',
error: 'border-l-2 border-l-reject',
info: 'border-l-2 border-l-primary',