Files
mule-image/frontend/src/store/photoStore.ts
dtoro 123c60ed2c 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>
2026-04-08 22:04:18 +02:00

161 lines
5.3 KiB
TypeScript

import { create } from 'zustand'
import type { Photo } from '../types/photo'
type ViewMode = 'grid' | 'preview'
interface PhotoStore {
photos: Photo[]
selectedPhotos: string[]
activePhotoId: string | null
/** The id of the photo where the current range-selection anchor lives.
* Set when the user clicks (without modifiers) or arrow-navigates,
* read by selectRange to figure out the start of a Shift+Click /
* Shift+Arrow range. Tracked as an id (not an index) so it survives
* filter changes and works correctly in tag-grouped mode where
* visual indices != API indices. */
rangeStartId: string | null
viewMode: ViewMode
/** Flat sequence of photo ids in the order they currently appear in
* the timeline grid (including duplicates from tag-grouping). Used
* for both preview navigation order and range selection. Owned by
* the Timeline component. */
visiblePhotoIds: string[]
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string) => void
togglePhotoSelection: (id: string) => void
/** Replace the selection with every photo between the current
* rangeStartId and the supplied endId, walking visiblePhotoIds in
* visual order. If there's no anchor yet, anchors at endId. */
selectRange: (endId: string) => void
deselectPhoto: (id: string) => void
clearSelection: () => void
setActivePhoto: (id: string | null) => void
setViewMode: (mode: ViewMode) => void
setVisiblePhotoIds: (ids: string[]) => void
/** Open preview on a specific photo. The visibleSequence (optional)
* is the ordered list of photo ids the user currently sees in the
* timeline; passing it from the click site avoids a race where the
* passive Timeline publisher hasn't updated yet. */
openPreview: (id: string, visibleSequence?: string[]) => void
closePreview: () => void
}
export const usePhotoStore = create<PhotoStore>((set) => ({
photos: [],
selectedPhotos: [],
activePhotoId: null,
rangeStartId: null,
viewMode: 'grid',
visiblePhotoIds: [],
setPhotos: (photos) => set({ photos }),
selectPhoto: (id) => set({
selectedPhotos: [id],
activePhotoId: id,
rangeStartId: id,
}),
togglePhotoSelection: (id) => set((state) => {
const isSelected = state.selectedPhotos.includes(id)
return {
selectedPhotos: isSelected
? state.selectedPhotos.filter((photoId) => photoId !== id)
: [...state.selectedPhotos, id],
// A toggle-add anchors the range here so a subsequent Shift+click
// extends from this id. A toggle-remove leaves the anchor alone.
rangeStartId: isSelected ? state.rangeStartId : id,
activePhotoId: id,
}
}),
selectRange: (endId) =>
set((state) => {
const ids = state.visiblePhotoIds
// No published sequence yet → just behave like a single-pick.
if (ids.length === 0) {
return {
selectedPhotos: [endId],
activePhotoId: endId,
rangeStartId: endId,
}
}
const anchorId = state.rangeStartId ?? state.activePhotoId ?? endId
const startIdx = ids.indexOf(anchorId)
const endIdx = ids.indexOf(endId)
if (startIdx < 0 || endIdx < 0) {
return {
selectedPhotos: [endId],
activePhotoId: endId,
rangeStartId: endId,
}
}
const min = Math.min(startIdx, endIdx)
const max = Math.max(startIdx, endIdx)
// De-dupe because the visible sequence can repeat photos in
// tag-grouped mode.
const seen = new Set<string>()
const next: string[] = []
for (let i = min; i <= max; i++) {
const id = ids[i]
if (!seen.has(id)) {
seen.add(id)
next.push(id)
}
}
return {
selectedPhotos: next,
activePhotoId: endId,
// Anchor stays put — the user can keep extending from the
// original click point, matching Lightroom / Finder behavior.
}
}),
deselectPhoto: (id) => set((state) => ({
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
})),
clearSelection: () => set({
selectedPhotos: [],
rangeStartId: null,
}),
setActivePhoto: (id) => set({ activePhotoId: id }),
setViewMode: (mode) => set({ viewMode: mode }),
// No-op when the content is identical so callers can fire from an
// effect without risking a re-render loop.
setVisiblePhotoIds: (visiblePhotoIds) =>
set((s) => {
const prev = s.visiblePhotoIds
if (prev.length === visiblePhotoIds.length) {
let same = true
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== visiblePhotoIds[i]) {
same = false
break
}
}
if (same) return s
}
return { visiblePhotoIds }
}),
openPreview: (id, visibleSequence) =>
set((s) => ({
viewMode: 'preview',
activePhotoId: id,
// Adopt the caller-provided sequence when they pass one. Falls
// back to whatever Timeline most recently published, which is
// correct for paths like the global Space hotkey that don't have
// a click site to compute the sequence from.
visiblePhotoIds:
visibleSequence && visibleSequence.length > 0
? visibleSequence
: s.visiblePhotoIds,
})),
closePreview: () => set({ viewMode: 'grid' }),
}))