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>
This commit is contained in:
@@ -1,55 +1,96 @@
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { usePhotoStore } from '../store/photoStore'
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
onToggleLeftSidebar: () => void
|
||||
onToggleRightSidebar: () => void
|
||||
/** Returns the first photo id in the current timeline, or null if empty. */
|
||||
getFirstPhotoId?: () => string | null
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
const { onToggleLeftSidebar, onToggleRightSidebar } = props
|
||||
|
||||
// Toggle sidebars
|
||||
const { onToggleLeftSidebar, onToggleRightSidebar, getFirstPhotoId } = props
|
||||
|
||||
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||
const openLoupe = usePhotoStore((s) => s.openLoupe)
|
||||
const closeLoupe = usePhotoStore((s) => s.closeLoupe)
|
||||
|
||||
const isGrid = viewMode === 'grid'
|
||||
const isLoupe = viewMode === 'loupe'
|
||||
|
||||
// Toggle sidebars (allowed in both modes; right sidebar is hidden in loupe
|
||||
// by App-level CSS so toggling it is effectively grid-only.)
|
||||
useHotkeys('tab', (e) => {
|
||||
e.preventDefault()
|
||||
onToggleLeftSidebar()
|
||||
})
|
||||
|
||||
|
||||
useHotkeys('i', (e) => {
|
||||
e.preventDefault()
|
||||
onToggleRightSidebar()
|
||||
})
|
||||
|
||||
// Navigation shortcuts
|
||||
|
||||
// Grid view: G always returns to grid (closes loupe if open).
|
||||
useHotkeys('g', () => {
|
||||
// Go to grid view
|
||||
console.log('Grid view')
|
||||
closeLoupe()
|
||||
})
|
||||
|
||||
useHotkeys('e', () => {
|
||||
// Go to loupe view
|
||||
console.log('Loupe view')
|
||||
})
|
||||
|
||||
// Rating shortcuts
|
||||
useHotkeys('1,2,3,4,5', (_e, handler) => {
|
||||
const rating = parseInt(handler.keys![0])
|
||||
console.log('Set rating:', rating)
|
||||
})
|
||||
|
||||
|
||||
// Loupe view: E toggles loupe (open from grid, close from loupe).
|
||||
// Enter also opens loupe from grid.
|
||||
const openLoupeFromGrid = () => {
|
||||
const id = activePhotoId ?? getFirstPhotoId?.() ?? null
|
||||
if (id) openLoupe(id)
|
||||
}
|
||||
|
||||
useHotkeys(
|
||||
'e',
|
||||
() => {
|
||||
if (isLoupe) {
|
||||
closeLoupe()
|
||||
} else {
|
||||
openLoupeFromGrid()
|
||||
}
|
||||
},
|
||||
[isLoupe, activePhotoId, getFirstPhotoId]
|
||||
)
|
||||
|
||||
useHotkeys(
|
||||
'enter',
|
||||
(e) => {
|
||||
if (isGrid) {
|
||||
e.preventDefault()
|
||||
openLoupeFromGrid()
|
||||
}
|
||||
},
|
||||
{ enabled: isGrid },
|
||||
[isGrid, activePhotoId, getFirstPhotoId]
|
||||
)
|
||||
|
||||
// Rating shortcuts (grid only — stubs from a different phase)
|
||||
useHotkeys(
|
||||
'1,2,3,4,5',
|
||||
(_e, handler) => {
|
||||
const rating = parseInt(handler.keys![0])
|
||||
console.log('Set rating:', rating)
|
||||
},
|
||||
{ enabled: isGrid }
|
||||
)
|
||||
|
||||
useHotkeys('0', () => {
|
||||
console.log('Remove rating')
|
||||
})
|
||||
|
||||
// Flag shortcuts
|
||||
}, { enabled: isGrid })
|
||||
|
||||
// Flag shortcuts (grid only)
|
||||
useHotkeys('p', () => {
|
||||
console.log('Pick photo')
|
||||
})
|
||||
|
||||
}, { enabled: isGrid })
|
||||
|
||||
useHotkeys('x', () => {
|
||||
console.log('Reject photo')
|
||||
})
|
||||
|
||||
}, { enabled: isGrid })
|
||||
|
||||
useHotkeys('u', () => {
|
||||
console.log('Unflag photo')
|
||||
})
|
||||
}
|
||||
}, { enabled: isGrid })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user