feat: full shortcut parity + perf fixes across memories and duplicates

Memories view now supports the same keyboard shortcuts, heap membership,
and optimistic cache updates as the Timeline. Arrow/Ctrl+A/Escape nav is
extracted into a shared useGridKeyNav hook so both views stay in lockstep.
Duplicates view is virtualised with @tanstack/react-virtual and has
stabilised PhotoThumbnail props so React.memo actually elides work when
scrolling or toggling selection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
root
2026-04-15 11:51:13 +02:00
parent 744a7fa0c3
commit d72a218b46
8 changed files with 527 additions and 228 deletions

View File

@@ -1,6 +1,7 @@
import { useMemo, useState, useEffect, useCallback } from 'react'
import { memo, useMemo, useRef, useState, useEffect, useCallback } from 'react'
import { Copy, Layers, Sparkles, Trash2, Loader2, Info, Crown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Button } from '@/components/ui/button'
import { formatApiError } from '../../lib/apiError'
@@ -79,6 +80,10 @@ export function DuplicatesView() {
() => groups.flatMap((g) => g.members.map((m) => m.id)),
[groups]
)
// Ref mirror so the stable keyboard handler can reach into the latest
// groups without re-binding the listener on every render.
const groupsRef = useRef(groups)
groupsRef.current = groups
// Track the rendered column count of the duplicates grid so ↑/↓ can
// skip a row instead of jumping a single cell. The grid uses
@@ -86,7 +91,13 @@ export function DuplicatesView() {
// We measure the FIRST section's grid container — every section uses
// the same auto-fill rule so any one is representative.
const [columns, setColumns] = useState(4)
// With virtualisation the first section can unmount on scroll and
// re-mount on scroll back — track the observer so we can disconnect
// cleanly every time the ref detaches (previously this leaked).
const sampleObserverRef = useRef<ResizeObserver | null>(null)
const sampleGridRef = useCallback((el: HTMLDivElement | null) => {
sampleObserverRef.current?.disconnect()
sampleObserverRef.current = null
if (!el) return
const measure = () => {
const cols = Math.max(1, Math.floor(el.clientWidth / 180))
@@ -95,10 +106,24 @@ export function DuplicatesView() {
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
// Caller doesn't get the cleanup hook but ResizeObserver disconnects
// when the element unmounts, which is fine for our lifecycle.
sampleObserverRef.current = ro
}, [])
// Virtualise the group list. Each group has a variable height
// (header + dynamic row count), so we let TanStack measure rendered
// DOM via `measureElement` and use a conservative estimate for
// unmeasured rows. ~360px handles a 2-row, 180px-cell section + header
// + group padding without underestimating in most cases.
const scrollRef = useRef<HTMLDivElement | null>(null)
const virtualizer = useVirtualizer({
count: groups.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 360,
overscan: 2,
measureElement: (el) => el.getBoundingClientRect().height,
getItemKey: (idx) => groups[idx].group_id,
})
// Window-level keyboard nav. Mirrors Timeline's handler but walks
// `allMemberIds` directly — duplicate groups don't have a uniform row
// grid so we approximate ↑/↓ via the measured `columns` count and
@@ -135,18 +160,25 @@ export function DuplicatesView() {
const nextId = allMemberIds[nextIdx]
if (!nextId) return
selectPhoto(nextId)
// Scroll the now-active cell into view if it's off-screen. The
// PhotoThumbnail wrapper carries data-dup-id so we can find it
// without threading refs through every cell.
const el = document.querySelector<HTMLElement>(
`[data-dup-id="${nextId}"]`
// With the group list virtualised, the destination cell might not
// be mounted yet. Scroll its owning group into view first (cheap
// virtualizer op), then scrollIntoView on the cell once it mounts.
const currentGroups = groupsRef.current
const ownerIdx = currentGroups.findIndex((g) =>
g.members.some((m) => m.id === nextId)
)
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
if (ownerIdx >= 0) virtualizer.scrollToIndex(ownerIdx, { align: 'auto' })
requestAnimationFrame(() => {
const el = document.querySelector<HTMLElement>(
`[data-dup-id="${nextId}"]`
)
el?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
})
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [allMemberIds, activePhotoId, columns, selectPhoto])
}, [allMemberIds, activePhotoId, columns, selectPhoto, virtualizer])
if (isLoading) {
return (
@@ -179,8 +211,27 @@ export function DuplicatesView() {
)
}
// Stable handlers — passed through memoised section + thumbnail so
// React.memo is actually effective. The `discardMutation.mutate` and
// store actions have stable identity already; we wrap them once so the
// closure identity doesn't change per render.
const allMemberIdsRef = useRef(allMemberIds)
allMemberIdsRef.current = allMemberIds
const handleKeepBest = useCallback(
(discardIds: string[]) => discardMutation.mutate(discardIds),
[discardMutation]
)
const handlePreviewMember = useCallback(
(memberId: string) => openPreview(memberId, allMemberIdsRef.current),
[openPreview]
)
const handleSelectMember = useCallback(
(memberId: string) => selectPhoto(memberId),
[selectPhoto]
)
return (
<div className="h-full overflow-auto bg-bg p-4 pb-20">
<div ref={scrollRef} className="h-full overflow-auto bg-bg p-4 pb-20">
<div className="mb-4 flex items-center gap-2 text-xs text-text-muted">
<Info className="h-3.5 w-3.5" />
<span>
@@ -191,22 +242,43 @@ export function DuplicatesView() {
</span>
</div>
<div className="space-y-6">
{groups.map((group, idx) => (
<DuplicateGroupSection
key={group.group_id}
group={group}
onKeepBest={(discardIds) => discardMutation.mutate(discardIds)}
onPreviewMember={(memberId) => openPreview(memberId, allMemberIds)}
onSelectMember={(memberId) => selectPhoto(memberId)}
selectedPhotos={selectedPhotos}
isPending={discardMutation.isPending}
// Hand the column-measurement ref to the first section only
// — every section's grid uses the same auto-fill rule so any
// one is representative of the rendered column count.
gridRef={idx === 0 ? sampleGridRef : undefined}
/>
))}
<div
style={{
height: virtualizer.getTotalSize(),
position: 'relative',
width: '100%',
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const group = groups[vItem.index]
return (
<div
key={group.group_id}
data-index={vItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${vItem.start}px)`,
paddingBottom: 24,
}}
>
<DuplicateGroupSection
group={group}
onKeepBest={handleKeepBest}
onPreviewMember={handlePreviewMember}
onSelectMember={handleSelectMember}
selectedPhotos={selectedPhotos}
isPending={discardMutation.isPending}
// First section feeds the column-count sample ref —
// every section uses the same auto-fill rule.
gridRef={vItem.index === 0 ? sampleGridRef : undefined}
/>
</div>
)
})}
</div>
</div>
)
@@ -225,7 +297,7 @@ interface DuplicateGroupSectionProps {
gridRef?: (el: HTMLDivElement | null) => void
}
function DuplicateGroupSection({
const DuplicateGroupSection = memo(function DuplicateGroupSection({
group,
onKeepBest,
onPreviewMember,
@@ -251,6 +323,25 @@ function DuplicateGroupSection({
const discardCount = group.member_count - 1
const isExact = group.reason === 'exact'
// Adapt members to Photo once per group (group.members reference is
// stable across renders as long as the query data doesn't change),
// so PhotoThumbnail's memo doesn't re-render on parent tick.
const photoByMemberId = useMemo(() => {
const map = new Map<string, Photo>()
for (const m of group.members) map.set(m.id, memberToPhoto(m))
return map
}, [group.members])
const handleClick = useCallback(
(p: Photo) => onSelectMember(p.id),
[onSelectMember]
)
const handleDoubleClick = useCallback(
(p: Photo) => onPreviewMember(p.id),
[onPreviewMember]
)
const selectedSet = useMemo(() => new Set(selectedPhotos), [selectedPhotos])
return (
<section className="rounded-lg border border-border bg-surface">
<header className="flex items-center justify-between gap-3 border-b border-border px-3 py-2">
@@ -307,12 +398,12 @@ function DuplicateGroupSection({
className="group/dup relative"
>
<PhotoThumbnail
photo={memberToPhoto(member)}
photo={photoByMemberId.get(member.id)!}
size={180}
fill
isSelected={selectedPhotos.includes(member.id)}
onClick={(p) => onSelectMember(p.id)}
onDoubleClick={(p) => onPreviewMember(p.id)}
isSelected={selectedSet.has(member.id)}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
/>
{/* BEST pill — top-right, pick-coloured. Composes the same
* THUMB_BADGE_* family used by PhotoThumbnail so the full
@@ -373,7 +464,7 @@ function DuplicateGroupSection({
</div>
</section>
)
}
})
// ── Helpers ──────────────────────────────────────────────────────────────

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import {
@@ -10,10 +10,13 @@ import type { Photo } from '../../types/photo'
import { usePhotoStore } from '../../store/photoStore'
import { useFilterStore } from '../../store/filterStore'
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
const CELL_SIZE = 180
const GAP = 4
/**
* "On this day" view. Same visual + interaction grid as Timeline —
* PhotoThumbnail cells wired up to the shared photo store, so selection,
@@ -46,7 +49,9 @@ export function MemoriesView() {
const togglePhotoSelection = usePhotoStore((s) => s.togglePhotoSelection)
const selectRange = usePhotoStore((s) => s.selectRange)
const openPreview = usePhotoStore((s) => s.openPreview)
const viewMode = usePhotoStore((s) => s.viewMode)
const searchQuery = useFilterStore((s) => s.q)
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
const visibleSequenceRef = useRef<string[]>([])
visibleSequenceRef.current = visibleSequence
@@ -66,6 +71,73 @@ export function MemoriesView() {
[openPreview],
)
// Measure column count from the first grid's actual computed template
// so arrow nav matches what the user sees. Auto-fill re-flows on resize,
// so a ResizeObserver keeps the count current.
const scrollRef = useRef<HTMLDivElement | null>(null)
const gridRefs = useRef<(HTMLDivElement | null)[]>([])
const [columns, setColumns] = useState(1)
useEffect(() => {
const el = gridRefs.current.find((g) => g)
if (!el) return
const measure = () => {
const cols = window
.getComputedStyle(el)
.gridTemplateColumns.split(' ')
.filter(Boolean).length
if (cols > 0) setColumns(cols)
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(el)
return () => ro.disconnect()
}, [memories.length])
// Break each year's photos into rows of `columns` ids — matches the
// shape useGridKeyNav expects. Arrow-nav crosses year boundaries
// naturally because rows flow in visual order across groups.
const gridNavRows = useMemo(() => {
const rows: { cells: { id: string }[] }[] = []
for (const group of memories) {
const cells = group.photos.map((m) => ({ id: m.id }))
for (let i = 0; i < cells.length; i += columns) {
rows.push({ cells: cells.slice(i, i + columns) })
}
}
return rows
}, [memories, columns])
const scrollRowIntoView = useCallback((rowIdx: number) => {
const firstId = gridNavRowsRef.current[rowIdx]?.cells[0]?.id
if (!firstId) return
const scrollEl = scrollRef.current
if (!scrollEl) return
const cellEl = scrollEl.querySelector<HTMLElement>(
`[data-photo-id="${firstId}"]`,
)
if (!cellEl) return
const peek = Math.round(CELL_SIZE * 0.35)
const cellTop = cellEl.offsetTop
const cellBottom = cellTop + cellEl.offsetHeight
const viewTop = scrollEl.scrollTop
const viewBottom = viewTop + scrollEl.clientHeight
if (cellTop - peek < viewTop) {
scrollEl.scrollTo({ top: Math.max(0, cellTop - peek) })
} else if (cellBottom + peek > viewBottom) {
scrollEl.scrollTo({ top: cellBottom + peek - scrollEl.clientHeight })
}
}, [])
// Rows change on resize / data load — read via ref so the callback
// identity stays stable.
const gridNavRowsRef = useRef(gridNavRows)
gridNavRowsRef.current = gridNavRows
useGridKeyNav({
rows: gridNavRows,
enabled: viewMode === 'grid',
scrollRowIntoView,
})
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-text-muted">
@@ -90,6 +162,7 @@ export function MemoriesView() {
return (
<div
ref={scrollRef}
className="h-full overflow-auto bg-bg p-4"
role="grid"
aria-label="Memories"
@@ -99,7 +172,7 @@ export function MemoriesView() {
</h2>
<div className="space-y-6">
{memories.map((group) => (
{memories.map((group, groupIdx) => (
<section key={group.year} className="space-y-2">
<header className="flex items-baseline gap-2">
<h3 className="text-sm font-semibold uppercase tracking-wide text-text">
@@ -110,6 +183,9 @@ export function MemoriesView() {
</span>
</header>
<div
ref={(el) => {
gridRefs.current[groupIdx] = el
}}
className="grid"
style={{
gridTemplateColumns: `repeat(auto-fill, minmax(${CELL_SIZE}px, 1fr))`,
@@ -118,16 +194,18 @@ export function MemoriesView() {
}}
>
{group.photos.map((m) => (
<PhotoThumbnail
key={m.id}
photo={memoryToPhoto(m)}
size={CELL_SIZE}
fill
isSelected={selectedPhotos.includes(m.id)}
searchQuery={searchQuery}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>
<div key={m.id} data-photo-id={m.id}>
<PhotoThumbnail
photo={memoryToPhoto(m)}
size={CELL_SIZE}
fill
isSelected={selectedPhotos.includes(m.id)}
isInActiveHeap={activeHeapMembers.has(m.id)}
searchQuery={searchQuery}
onClick={handleCellClick}
onDoubleClick={handleCellDoubleClick}
/>
</div>
))}
</div>
</section>
@@ -151,7 +229,8 @@ function memoryToPhoto(m: MemoryPhoto): Photo {
height: m.height,
taken_at: m.taken_at,
rating: m.rating,
is_discarded: false,
color_label: m.color_label,
is_discarded: m.is_discarded,
is_duplicate: false,
file_hash: '',
folder_id: null,

View File

@@ -7,6 +7,7 @@ import { useFilterStore, hasActiveFilters } from '../../store/filterStore'
import { PhotoThumbnail } from './PhotoThumbnail'
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
import { useGridKeyNav } from '../../hooks/useGridKeyNav'
import { Button } from '@/components/ui/button'
import { ImageOff } from 'lucide-react'
import type { Photo } from '../../types/photo'
@@ -133,7 +134,6 @@ export function Timeline() {
selectPhoto,
togglePhotoSelection,
selectRange,
clearSelection,
openPreview,
} = usePhotoStore()
// Pulled via a focused selector so the publisher subscription doesn't
@@ -528,189 +528,50 @@ export function Timeline() {
visibleSequenceRef.current = visibleSequence
}, [visibleSequence, setVisiblePhotoIds])
// Locate the active photo in the visual grid. Returns the FIRST
// Handle keyboard shortcuts for photo navigation. Operates on the
// grouped grid the user sees, so a half-full last row of a group
// doesn't make ArrowDown skip into the wrong place.
// Keyboard grid nav (arrows / Ctrl+A / Escape) is shared with
// MemoriesView via useGridKeyNav. Timeline provides its own scroll
// callback because row geometry comes from the TanStack virtualizer's
// item heights; MemoriesView reads DOM offsets instead.
//
// Inert in preview mode PreviewView mounts its own arrow handlers,
// and a window-level grid handler firing alongside them used to race
// against PreviewView's setActivePhoto, landing the user on the wrong
// photo. The grid handler stays attached so it can re-engage the
// moment the user closes preview.
//
// The handler reads its inputs through a ref so the listener can bind
// once per (viewMode, currentSection) — every other dep used to be in
// the array and triggered an unbind/rebind on every state tick (eight
// values, several with new identity each render).
const navStateRef = useRef({
photoRows,
photos,
selectedPhotos,
activePhotoId,
photoRowItemIndex,
items,
cellSize,
// Inert in preview mode (PreviewView has its own arrow handlers) and
// in the duplicates section (DuplicatesView manages its own grouped
// nav against a different snapshot of the visible grid).
const gridNavRows = useMemo(
() =>
photoRows.map((row) => ({
cells: row.cells.map((c) => ({ id: c.photo.id })),
})),
[photoRows]
)
const scrollRowIntoView = useCallback(
(rowIdx: number) => {
const itemIdx = photoRowItemIndex[rowIdx]
const scrollEl = parentRef.current
if (itemIdx === undefined || !scrollEl) return
// 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) {
scrollEl.scrollTo({ top: Math.max(0, rowTop - peek) })
} else if (rowTop + rowHeight + peek > viewBottom) {
scrollEl.scrollTo({
top: rowTop + rowHeight + peek - scrollEl.clientHeight,
})
}
},
[photoRowItemIndex, items, cellSize]
)
useGridKeyNav({
rows: gridNavRows,
enabled: viewMode === 'grid' && currentSection !== 'duplicates',
scrollRowIntoView,
})
navStateRef.current = {
photoRows,
photos,
selectedPhotos,
activePhotoId,
photoRowItemIndex,
items,
cellSize,
}
useEffect(() => {
if (viewMode !== 'grid') return
// The duplicates section mounts its own grouped view (DuplicatesView)
// with its own keyboard nav — bail out so we don't double-handle
// arrow keys and try to navigate against a photoRows snapshot that
// doesn't match what the user actually sees on screen.
if (currentSection === 'duplicates') return
const handleKeyDown = (e: KeyboardEvent) => {
const {
photoRows,
photos,
selectedPhotos,
photoRowItemIndex,
items,
cellSize,
} = navStateRef.current
if (photoRows.length === 0) return
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) {
return
}
const findActive = (): { row: number; col: number } | null => {
const aid = navStateRef.current.activePhotoId
if (!aid) return null
for (let r = 0; r < photoRows.length; r++) {
const c = photoRows[r].cells.findIndex((cell) => cell.photo.id === aid)
if (c >= 0) return { row: r, col: c }
}
return null
}
const move = (dr: number, dc: number) => {
const current = findActive() ?? { row: 0, col: -1 }
let nextRow = current.row
let nextCol = current.col + dc
if (dc !== 0) {
// Wrap left/right across row boundaries.
while (nextCol < 0 && nextRow > 0) {
nextRow -= 1
nextCol = photoRows[nextRow].cells.length - 1
}
while (
nextRow < photoRows.length &&
nextCol >= photoRows[nextRow].cells.length
) {
if (nextRow === photoRows.length - 1) {
nextCol = photoRows[nextRow].cells.length - 1
break
}
nextRow += 1
nextCol = 0
}
if (nextCol < 0) nextCol = 0
}
if (dr !== 0) {
nextRow += dr
if (nextRow < 0) nextRow = 0
if (nextRow >= photoRows.length) nextRow = photoRows.length - 1
// Clamp the column to the destination row's actual width so
// moving down into a half-full row lands on its last cell
// instead of nothing.
const rowLen = photoRows[nextRow].cells.length
if (nextCol >= rowLen) nextCol = rowLen - 1
if (nextCol < 0) nextCol = 0
}
const dest = photoRows[nextRow]?.cells[nextCol]
if (!dest) return
if (e.shiftKey) {
selectRange(dest.photo.id)
} 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) {
case 'ArrowUp':
e.preventDefault()
move(-1, 0)
break
case 'ArrowDown':
e.preventDefault()
move(1, 0)
break
case 'ArrowLeft':
e.preventDefault()
move(0, -1)
break
case 'ArrowRight':
e.preventDefault()
move(0, 1)
break
case 'a':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
photos.forEach((photo) => {
if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id)
}
})
}
break
case 'Escape':
e.preventDefault()
clearSelection()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [
viewMode,
currentSection,
selectRange,
selectPhoto,
togglePhotoSelection,
clearSelection,
])
if (isLoading) {
return (