fix: visible-order range selection + preview-after-Space sequence

Two related bugs around visible vs API order.

1. Multi-select range selection (Shift+Click, Shift+Arrow):
- The previous selectRange walked the API photos array and only
  ADDED to the existing selection, never replacing or shrinking. So
  Shift+clicking to the left often "did nothing" (already-selected
  ids skipped) and the selection never matched the user's intended
  range.
- Replace with a store action that walks visiblePhotoIds (the visual
  row-major sequence Timeline already publishes), de-dupes ids
  (tag-grouped views can repeat photos), and REPLACES the selection.
- Track the range anchor as rangeStartId (a photo id) instead of an
  index so it survives filter changes and works correctly when API
  index != visual position.
- Drop the now-redundant lastSelectedIndex / globalIndex plumbing
  from selectPhoto / togglePhotoSelection — call sites simplify to
  pass just the photo id.

2. Preview navigation after pressing Space:
- The Space hotkey path called openPreview(id) without a sequence
  and relied on the store's fallback to whatever Timeline most
  recently published. Make it explicit: read visiblePhotoIds from
  the store snapshot at fire time and pass it through. Same effect
  in the happy case but eliminates any subtle publisher timing
  question.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-08 22:04:18 +02:00
parent e2ee9b691c
commit 123c60ed2c
3 changed files with 90 additions and 59 deletions

View File

@@ -166,10 +166,9 @@ export function Timeline() {
const {
selectedPhotos,
activePhotoId,
lastSelectedIndex,
rangeStartIndex,
selectPhoto,
togglePhotoSelection,
selectRange,
clearSelection,
openPreview,
} = usePhotoStore()
@@ -220,19 +219,6 @@ export function Timeline() {
return result
}, [items])
// Range-selection helper. Operates on the global photos array, not on
// virtualizer items.
const selectRange = (endIndex: number) => {
const startIndex = rangeStartIndex ?? lastSelectedIndex ?? 0
const minIndex = Math.min(startIndex, endIndex)
const maxIndex = Math.max(startIndex, endIndex)
for (let i = minIndex; i <= maxIndex; i++) {
if (i < photos.length && !selectedPhotos.includes(photos[i].id)) {
togglePhotoSelection(photos[i].id, i)
}
}
}
// Virtual scrolling setup with per-item heights.
const virtualizer = useVirtualizer({
count: items.length,
@@ -384,9 +370,9 @@ export function Timeline() {
const dest = photoRows[nextRow]?.cells[nextCol]
if (!dest) return
if (e.shiftKey) {
selectRange(dest.globalIndex)
selectRange(dest.photo.id)
} else {
selectPhoto(dest.photo.id, dest.globalIndex)
selectPhoto(dest.photo.id)
}
}
@@ -410,9 +396,9 @@ export function Timeline() {
case 'a':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
photos.forEach((photo, index) => {
photos.forEach((photo) => {
if (!selectedPhotos.includes(photo.id)) {
togglePhotoSelection(photo.id, index)
togglePhotoSelection(photo.id)
}
})
}
@@ -509,7 +495,7 @@ export function Timeline() {
}}
>
<div className="flex" style={{ gap: `${GAP}px` }}>
{item.cells.map(({ photo, globalIndex }) => (
{item.cells.map(({ photo }) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
@@ -517,12 +503,12 @@ export function Timeline() {
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
onClick={(e) => {
if (e.shiftKey && lastSelectedIndex !== null) {
selectRange(globalIndex)
if (e.shiftKey) {
selectRange(photo.id)
} else if (e.ctrlKey || e.metaKey) {
togglePhotoSelection(photo.id, globalIndex)
togglePhotoSelection(photo.id)
} else {
selectPhoto(photo.id, globalIndex)
selectPhoto(photo.id)
}
}}
onDoubleClick={() => openPreview(photo.id, visibleSequence)}