Files
mule-image/frontend/src/store/photoStore.ts
dtoro f4fc15101e feat: loupe view with zoom, pan, video, and filmstrip
Adds the Lightroom-style full-screen single-photo viewer (spec §6.11).
Open with E / Enter / double-click; navigate with arrow keys; Esc returns
to grid. Filmstrip at the bottom auto-scrolls the active cell into view.

Display:
- Stills source from /photos/{id}/proxy so RAW/HEIC are decoded server-
  side; large thumbnail is the onError fallback only.
- Videos render in a <video controls> sourced from /original.
- Continuous wheel zoom (1×–8×, ~15% per tick) with click-drag pan when
  zoomed past fit. Z toggles between fit and natural-resolution
  (computed from naturalWidth / clientWidth); a second Z snaps back.
- Live percentage indicator in the bottom-center while zoomed.

Polish:
- Neighbor preloading via new Image() when currentIndex changes so arrow
  nav feels instant (skips videos).
- Focus trap with role=dialog, aria-modal, focus-on-mount, restore-on-
  unmount, and Tab cycling among focusable children.

Plumbing:
- New canonical Photo TS interface in types/photo.ts; removes the three
  duplicated definitions in PhotoThumbnail/Timeline/photoStore.
- photoStore gains viewMode + openLoupe/closeLoupe.
- useKeyboardShortcuts wires E/Enter/G to open/close, gates rating and
  flag stubs with { enabled: viewMode === 'grid' } so they're inert in
  loupe.
- App.tsx mounts <LoupeView/> as a z-40 overlay covering TopBar, gates
  the auto-open right sidebar effect on viewMode === 'grid' so leaving
  loupe doesn't fight the user's prior sidebar state.
- PhotoThumbnail gains an onDoubleClick prop wired to openLoupe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:39:33 +02:00

79 lines
2.1 KiB
TypeScript

import { create } from 'zustand'
import type { Photo } from '../types/photo'
type ViewMode = 'grid' | 'loupe'
interface PhotoStore {
photos: Photo[]
selectedPhotos: string[]
activePhotoId: string | null
lastSelectedIndex: number | null
rangeStartIndex: number | null
viewMode: ViewMode
setPhotos: (photos: Photo[]) => void
selectPhoto: (id: string, index: number) => void
togglePhotoSelection: (id: string, index: number) => void
selectRange: (endIndex: number) => void
deselectPhoto: (id: string) => void
clearSelection: () => void
setActivePhoto: (id: string | null) => void
setViewMode: (mode: ViewMode) => void
openLoupe: (id: string) => void
closeLoupe: () => void
}
export const usePhotoStore = create<PhotoStore>((set) => ({
photos: [],
selectedPhotos: [],
activePhotoId: null,
lastSelectedIndex: null,
rangeStartIndex: null,
viewMode: 'grid',
setPhotos: (photos) => set({ photos }),
selectPhoto: (id, index) => set({
selectedPhotos: [id],
activePhotoId: id,
lastSelectedIndex: index,
rangeStartIndex: index,
}),
togglePhotoSelection: (id, index) => set((state) => {
const isSelected = state.selectedPhotos.includes(id)
return {
selectedPhotos: isSelected
? state.selectedPhotos.filter(photoId => photoId !== id)
: [...state.selectedPhotos, id],
lastSelectedIndex: index,
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
}
}),
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,
})
},
deselectPhoto: (id) => set((state) => ({
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
})),
clearSelection: () => set({
selectedPhotos: [],
lastSelectedIndex: null,
rangeStartIndex: null,
}),
setActivePhoto: (id) => set({ activePhotoId: id }),
setViewMode: (mode) => set({ viewMode: mode }),
openLoupe: (id) => set({ viewMode: 'loupe', activePhotoId: id }),
closeLoupe: () => set({ viewMode: 'grid' }),
}))