From 66b3bc5e1f159ecb3d66d86d1e8abfb4a6aeb3e5 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Apr 2026 15:24:32 +0200 Subject: [PATCH] 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) --- frontend/src/App.tsx | 26 +++- .../components/preview/PreviewFilmstrip.tsx | 116 ++++++++++++------ frontend/src/components/timeline/Timeline.tsx | 51 ++++++-- frontend/src/components/ui/sonner.tsx | 14 ++- 4 files changed, 149 insertions(+), 58 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 832a8fc..a212cb4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 && } diff --git a/frontend/src/components/preview/PreviewFilmstrip.tsx b/frontend/src/components/preview/PreviewFilmstrip.tsx index 7a30402..a688f3a 100644 --- a/frontend/src/components/preview/PreviewFilmstrip.tsx +++ b/frontend/src/components/preview/PreviewFilmstrip.tsx @@ -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(null) +/** + * Horizontal virtualised filmstrip. Mounting thousands of ` - ) - })} +
+
+ {virtualizer.getVirtualItems().map((vItem) => { + const photo = photos[vItem.index] + const isActive = vItem.index === currentIndex + const isInActiveHeap = activeHeapMembers.has(photo.id) + return ( + + ) + })} +
) } diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 9e4be4f..6d1cffd 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -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 diff --git a/frontend/src/components/ui/sonner.tsx b/frontend/src/components/ui/sonner.tsx index 2ac5cd3..2cc3bb5 100644 --- a/frontend/src/components/ui/sonner.tsx +++ b/frontend/src/components/ui/sonner.tsx @@ -10,16 +10,20 @@ const Toaster = ({ ...props }: ToasterProps) => (