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:
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user