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>
161 lines
5.3 KiB
TypeScript
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' }),
|
|
})) |