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:
@@ -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 />}
|
||||
|
||||
@@ -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,36 +12,74 @@ 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',
|
||||
const virtualizer = useVirtualizer({
|
||||
horizontal: true,
|
||||
count: photos.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => CELL_SIZE + GAP,
|
||||
overscan: 10,
|
||||
getItemKey: (idx) => photos[idx].id,
|
||||
})
|
||||
}, [currentIndex])
|
||||
|
||||
// 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
|
||||
<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}
|
||||
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',
|
||||
'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'
|
||||
!isActive &&
|
||||
!isInActiveHeap &&
|
||||
'opacity-60 hover:opacity-100 hover:ring-1 hover:ring-text-muted/70'
|
||||
)}
|
||||
style={{ width: CELL_SIZE, height: CELL_SIZE }}
|
||||
style={{
|
||||
width: CELL_SIZE,
|
||||
height: CELL_SIZE,
|
||||
transform: `translateX(${vItem.start}px)`,
|
||||
}}
|
||||
title={photo.filename}
|
||||
>
|
||||
<FilmstripThumb photo={photo} />
|
||||
@@ -54,6 +93,7 @@ export function PreviewFilmstrip({ photos, currentIndex, onSelect }: PreviewFilm
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user