diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 5847d72..26ba45e 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -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() { }} >
- {item.cells.map(({ photo, globalIndex }) => ( + {item.cells.map(({ photo }) => ( { - 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)} diff --git a/frontend/src/hooks/useKeyboardShortcuts.ts b/frontend/src/hooks/useKeyboardShortcuts.ts index 88d46a5..818d938 100644 --- a/frontend/src/hooks/useKeyboardShortcuts.ts +++ b/frontend/src/hooks/useKeyboardShortcuts.ts @@ -300,8 +300,13 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) { // Space toggles the preview view (open from grid, close from preview). // Double-click on a thumbnail does the same. const openPreviewFromGrid = () => { - const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null - if (id) openPreview(id) + const state = usePhotoStore.getState() + const id = state.activePhotoId ?? getFirstPhotoId?.() ?? null + if (!id) return + // Pass the current visible sequence explicitly so preview navigation + // walks the order the user actually sees, even if the active photo + // was selected before the publisher caught up. + openPreview(id, state.visiblePhotoIds) } const togglePreview = () => { diff --git a/frontend/src/store/photoStore.ts b/frontend/src/store/photoStore.ts index 1b0bd99..360cff3 100644 --- a/frontend/src/store/photoStore.ts +++ b/frontend/src/store/photoStore.ts @@ -7,20 +7,27 @@ interface PhotoStore { photos: Photo[] selectedPhotos: string[] activePhotoId: string | null - lastSelectedIndex: number | null - rangeStartIndex: number | 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). The - * preview view walks this sequence so arrow nav matches the order - * the user actually sees. Owned by the Timeline component, which - * rewrites it whenever its layout items change. */ + * 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, index: number) => void - togglePhotoSelection: (id: string, index: number) => void - selectRange: (endIndex: number) => 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 @@ -38,47 +45,80 @@ export const usePhotoStore = create((set) => ({ photos: [], selectedPhotos: [], activePhotoId: null, - lastSelectedIndex: null, - rangeStartIndex: null, + rangeStartId: null, viewMode: 'grid', visiblePhotoIds: [], setPhotos: (photos) => set({ photos }), - - selectPhoto: (id, index) => set({ + + selectPhoto: (id) => set({ selectedPhotos: [id], activePhotoId: id, - lastSelectedIndex: index, - rangeStartIndex: index, + rangeStartId: id, }), - - togglePhotoSelection: (id, index) => set((state) => { + + togglePhotoSelection: (id) => set((state) => { const isSelected = state.selectedPhotos.includes(id) return { selectedPhotos: isSelected - ? state.selectedPhotos.filter(photoId => photoId !== id) + ? state.selectedPhotos.filter((photoId) => photoId !== id) : [...state.selectedPhotos, id], - lastSelectedIndex: index, - rangeStartIndex: isSelected ? state.rangeStartIndex : index, + // 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: (endIndex) => { - // Note: The actual range selection logic should be handled in the Timeline component - // which has access to the photos array - set({ - lastSelectedIndex: endIndex, - }) - }, - + + 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() + 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({ + + clearSelection: () => set({ selectedPhotos: [], - lastSelectedIndex: null, - rangeStartIndex: null, + rangeStartId: null, }), setActivePhoto: (id) => set({ activePhotoId: id }),