Compare commits
5 Commits
26424469db
...
eb16564b84
| Author | SHA1 | Date | |
|---|---|---|---|
| eb16564b84 | |||
| 7bb03be51a | |||
| 123c60ed2c | |||
| e2ee9b691c | |||
| 57f510ad3d |
@@ -30,13 +30,15 @@ export function KeyboardHints() {
|
|||||||
// inside the main column in App.tsx so it isn't offset by the
|
// inside the main column in App.tsx so it isn't offset by the
|
||||||
// sidebar widths.
|
// sidebar widths.
|
||||||
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2">
|
<div className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2">
|
||||||
<div className="pointer-events-auto flex items-center gap-3 rounded-full border border-border/60 bg-surface/40 px-4 py-1.5 shadow-lg ring-1 ring-white/5 backdrop-blur-md">
|
<div className="pointer-events-auto flex items-center gap-3 whitespace-nowrap rounded-full border border-border/60 bg-surface/40 px-4 py-1.5 shadow-lg ring-1 ring-white/5 backdrop-blur-md">
|
||||||
{hints.map((hint, i) => (
|
{hints.map((hint, i) => (
|
||||||
<div key={i} className="flex items-center gap-1.5">
|
<div key={i} className="flex items-center gap-1.5">
|
||||||
<kbd className="rounded bg-surface-offset/80 px-1.5 py-0.5 text-[11px] font-medium text-text">
|
<kbd className="rounded bg-surface-offset/80 px-1.5 py-0.5 text-[11px] font-medium text-text">
|
||||||
{hint.key}
|
{hint.key}
|
||||||
</kbd>
|
</kbd>
|
||||||
<span className="text-xs text-text-muted">{hint.action}</span>
|
<span className="whitespace-nowrap text-xs text-text-muted">
|
||||||
|
{hint.action}
|
||||||
|
</span>
|
||||||
{i < hints.length - 1 && (
|
{i < hints.length - 1 && (
|
||||||
<span className="ml-1 text-text-faint">•</span>
|
<span className="ml-1 text-text-faint">•</span>
|
||||||
)}
|
)}
|
||||||
@@ -45,7 +47,7 @@ export function KeyboardHints() {
|
|||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-text-faint">•</span>
|
<span className="text-text-faint">•</span>
|
||||||
<span className="text-xs font-medium text-primary">
|
<span className="whitespace-nowrap text-xs font-medium text-primary">
|
||||||
{selectedCount} selected
|
{selectedCount} selected
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -45,22 +45,38 @@ export function PreviewView() {
|
|||||||
const safeIndex = currentIndex < 0 ? 0 : currentIndex
|
const safeIndex = currentIndex < 0 ? 0 : currentIndex
|
||||||
const currentPhoto: Photo | undefined = photos[safeIndex]
|
const currentPhoto: Photo | undefined = photos[safeIndex]
|
||||||
|
|
||||||
|
// Keep the latest photos array + active id in a ref so the keyboard
|
||||||
|
// handlers ALWAYS read the freshest state. Without this, react-hotkeys-
|
||||||
|
// hook can fire a closure that captured an older photos array (e.g.
|
||||||
|
// the empty initial render before visiblePhotoIds was applied) and
|
||||||
|
// arrow nav lands on the wrong photo or no-ops.
|
||||||
|
const navRef = useRef({ photos, activePhotoId })
|
||||||
|
navRef.current = { photos, activePhotoId }
|
||||||
|
|
||||||
const goPrev = useCallback(() => {
|
const goPrev = useCallback(() => {
|
||||||
if (photos.length === 0) return
|
const { photos: ps, activePhotoId: aid } = navRef.current
|
||||||
const next = Math.max(0, safeIndex - 1)
|
if (ps.length === 0) return
|
||||||
setActivePhoto(photos[next].id)
|
const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
|
||||||
}, [photos, safeIndex, setActivePhoto])
|
const safe = idx < 0 ? 0 : idx
|
||||||
|
const next = Math.max(0, safe - 1)
|
||||||
|
setActivePhoto(ps[next].id)
|
||||||
|
}, [setActivePhoto])
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
const goNext = useCallback(() => {
|
||||||
if (photos.length === 0) return
|
const { photos: ps, activePhotoId: aid } = navRef.current
|
||||||
const next = Math.min(photos.length - 1, safeIndex + 1)
|
if (ps.length === 0) return
|
||||||
setActivePhoto(photos[next].id)
|
const idx = aid ? ps.findIndex((p) => p.id === aid) : 0
|
||||||
}, [photos, safeIndex, setActivePhoto])
|
const safe = idx < 0 ? 0 : idx
|
||||||
|
const next = Math.min(ps.length - 1, safe + 1)
|
||||||
|
setActivePhoto(ps[next].id)
|
||||||
|
}, [setActivePhoto])
|
||||||
|
|
||||||
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
|
// Preview-scoped hotkeys: only mounted while PreviewView is rendered.
|
||||||
|
// The handlers themselves are stable (refs internally) so the deps
|
||||||
|
// array stays empty — useHotkeys won't have to re-bind on every render.
|
||||||
useHotkeys('escape', closePreview, { preventDefault: true })
|
useHotkeys('escape', closePreview, { preventDefault: true })
|
||||||
useHotkeys('left', goPrev, { preventDefault: true }, [goPrev])
|
useHotkeys('left', goPrev, { preventDefault: true })
|
||||||
useHotkeys('right', goNext, { preventDefault: true }, [goNext])
|
useHotkeys('right', goNext, { preventDefault: true })
|
||||||
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
||||||
|
|
||||||
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
|
// Preload the immediate neighbors so arrow nav feels instant. Skip videos
|
||||||
|
|||||||
@@ -166,10 +166,9 @@ export function Timeline() {
|
|||||||
const {
|
const {
|
||||||
selectedPhotos,
|
selectedPhotos,
|
||||||
activePhotoId,
|
activePhotoId,
|
||||||
lastSelectedIndex,
|
|
||||||
rangeStartIndex,
|
|
||||||
selectPhoto,
|
selectPhoto,
|
||||||
togglePhotoSelection,
|
togglePhotoSelection,
|
||||||
|
selectRange,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
openPreview,
|
openPreview,
|
||||||
} = usePhotoStore()
|
} = usePhotoStore()
|
||||||
@@ -179,6 +178,7 @@ export function Timeline() {
|
|||||||
|
|
||||||
const sortBy = useFilterStore((s) => s.sortBy)
|
const sortBy = useFilterStore((s) => s.sortBy)
|
||||||
const groupBy = useFilterStore((s) => s.groupBy)
|
const groupBy = useFilterStore((s) => s.groupBy)
|
||||||
|
const viewMode = usePhotoStore((s) => s.viewMode)
|
||||||
|
|
||||||
// Calculate number of columns based on container width.
|
// Calculate number of columns based on container width.
|
||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
@@ -220,19 +220,6 @@ export function Timeline() {
|
|||||||
return result
|
return result
|
||||||
}, [items])
|
}, [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.
|
// Virtual scrolling setup with per-item heights.
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
@@ -297,20 +284,27 @@ export function Timeline() {
|
|||||||
[items]
|
[items]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Publish the flat visible-order id sequence to the photo store so
|
// Flat visible-order id sequence — exactly the order the user reads
|
||||||
// PreviewView arrow nav (and the filmstrip) walks the same order the
|
// off the grid (top-to-bottom, left-to-right within each row).
|
||||||
// user sees in the grid. Includes duplicates from tag grouping —
|
// Includes duplicates from tag-grouping; landing on the same photo's
|
||||||
// landing on the same photo's "second" appearance in the next tag
|
// "second" appearance in the next tag bucket is the right behavior
|
||||||
// bucket is the right behavior in tag mode.
|
// in tag mode.
|
||||||
useEffect(() => {
|
const visibleSequence = useMemo(() => {
|
||||||
const ids: string[] = []
|
const ids: string[] = []
|
||||||
for (const row of photoRows) {
|
for (const row of photoRows) {
|
||||||
for (const cell of row.cells) {
|
for (const cell of row.cells) {
|
||||||
ids.push(cell.photo.id)
|
ids.push(cell.photo.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setVisiblePhotoIds(ids)
|
return ids
|
||||||
}, [photoRows, setVisiblePhotoIds])
|
}, [photoRows])
|
||||||
|
|
||||||
|
// Publish to the photo store so PreviewView's arrow nav and filmstrip
|
||||||
|
// can walk the same order even when opened from a non-click path
|
||||||
|
// (e.g. the global Space hotkey).
|
||||||
|
useEffect(() => {
|
||||||
|
setVisiblePhotoIds(visibleSequence)
|
||||||
|
}, [visibleSequence, setVisiblePhotoIds])
|
||||||
|
|
||||||
// Locate the active photo in the visual grid. Returns the FIRST
|
// Locate the active photo in the visual grid. Returns the FIRST
|
||||||
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
|
// (rowIndex, colIndex) where its id appears, since a tag-grouped view
|
||||||
@@ -329,7 +323,14 @@ export function Timeline() {
|
|||||||
// Handle keyboard shortcuts for photo navigation. Operates on the
|
// Handle keyboard shortcuts for photo navigation. Operates on the
|
||||||
// grouped grid the user sees, so a half-full last row of a group
|
// grouped grid the user sees, so a half-full last row of a group
|
||||||
// doesn't make ArrowDown skip into the wrong place.
|
// doesn't make ArrowDown skip into the wrong place.
|
||||||
|
//
|
||||||
|
// Inert in preview mode — PreviewView mounts its own arrow handlers,
|
||||||
|
// and a window-level grid handler firing alongside them used to race
|
||||||
|
// against PreviewView's setActivePhoto, landing the user on the wrong
|
||||||
|
// photo. The grid handler stays attached so it can re-engage the
|
||||||
|
// moment the user closes preview.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (viewMode !== 'grid') return
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (photoRows.length === 0) return
|
if (photoRows.length === 0) return
|
||||||
const target = e.target as HTMLElement | null
|
const target = e.target as HTMLElement | null
|
||||||
@@ -377,9 +378,9 @@ export function Timeline() {
|
|||||||
const dest = photoRows[nextRow]?.cells[nextCol]
|
const dest = photoRows[nextRow]?.cells[nextCol]
|
||||||
if (!dest) return
|
if (!dest) return
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
selectRange(dest.globalIndex)
|
selectRange(dest.photo.id)
|
||||||
} else {
|
} else {
|
||||||
selectPhoto(dest.photo.id, dest.globalIndex)
|
selectPhoto(dest.photo.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,9 +404,9 @@ export function Timeline() {
|
|||||||
case 'a':
|
case 'a':
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
photos.forEach((photo, index) => {
|
photos.forEach((photo) => {
|
||||||
if (!selectedPhotos.includes(photo.id)) {
|
if (!selectedPhotos.includes(photo.id)) {
|
||||||
togglePhotoSelection(photo.id, index)
|
togglePhotoSelection(photo.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -420,7 +421,7 @@ export function Timeline() {
|
|||||||
window.addEventListener('keydown', handleKeyDown)
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [photoRows, photos, selectedPhotos, activePhotoId])
|
}, [viewMode, photoRows, photos, selectedPhotos, activePhotoId])
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -502,7 +503,7 @@ export function Timeline() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex" style={{ gap: `${GAP}px` }}>
|
<div className="flex" style={{ gap: `${GAP}px` }}>
|
||||||
{item.cells.map(({ photo, globalIndex }) => (
|
{item.cells.map(({ photo }) => (
|
||||||
<PhotoThumbnail
|
<PhotoThumbnail
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
photo={photo}
|
photo={photo}
|
||||||
@@ -510,15 +511,15 @@ export function Timeline() {
|
|||||||
isSelected={selectedPhotos.includes(photo.id)}
|
isSelected={selectedPhotos.includes(photo.id)}
|
||||||
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
isInActiveHeap={activeHeapMembers.has(photo.id)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.shiftKey && lastSelectedIndex !== null) {
|
if (e.shiftKey) {
|
||||||
selectRange(globalIndex)
|
selectRange(photo.id)
|
||||||
} else if (e.ctrlKey || e.metaKey) {
|
} else if (e.ctrlKey || e.metaKey) {
|
||||||
togglePhotoSelection(photo.id, globalIndex)
|
togglePhotoSelection(photo.id)
|
||||||
} else {
|
} else {
|
||||||
selectPhoto(photo.id, globalIndex)
|
selectPhoto(photo.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => openPreview(photo.id)}
|
onDoubleClick={() => openPreview(photo.id, visibleSequence)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -300,8 +300,13 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
|||||||
// Space toggles the preview view (open from grid, close from preview).
|
// Space toggles the preview view (open from grid, close from preview).
|
||||||
// Double-click on a thumbnail does the same.
|
// Double-click on a thumbnail does the same.
|
||||||
const openPreviewFromGrid = () => {
|
const openPreviewFromGrid = () => {
|
||||||
const id = usePhotoStore.getState().activePhotoId ?? getFirstPhotoId?.() ?? null
|
const state = usePhotoStore.getState()
|
||||||
if (id) openPreview(id)
|
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 = () => {
|
const togglePreview = () => {
|
||||||
|
|||||||
@@ -7,26 +7,37 @@ interface PhotoStore {
|
|||||||
photos: Photo[]
|
photos: Photo[]
|
||||||
selectedPhotos: string[]
|
selectedPhotos: string[]
|
||||||
activePhotoId: string | null
|
activePhotoId: string | null
|
||||||
lastSelectedIndex: number | null
|
/** The id of the photo where the current range-selection anchor lives.
|
||||||
rangeStartIndex: number | null
|
* 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
|
viewMode: ViewMode
|
||||||
/** Flat sequence of photo ids in the order they currently appear in
|
/** Flat sequence of photo ids in the order they currently appear in
|
||||||
* the timeline grid (including duplicates from tag-grouping). The
|
* the timeline grid (including duplicates from tag-grouping). Used
|
||||||
* preview view walks this sequence so arrow nav matches the order
|
* for both preview navigation order and range selection. Owned by
|
||||||
* the user actually sees. Owned by the Timeline component, which
|
* the Timeline component. */
|
||||||
* rewrites it whenever its layout items change. */
|
|
||||||
visiblePhotoIds: string[]
|
visiblePhotoIds: string[]
|
||||||
|
|
||||||
setPhotos: (photos: Photo[]) => void
|
setPhotos: (photos: Photo[]) => void
|
||||||
selectPhoto: (id: string, index: number) => void
|
selectPhoto: (id: string) => void
|
||||||
togglePhotoSelection: (id: string, index: number) => void
|
togglePhotoSelection: (id: string) => void
|
||||||
selectRange: (endIndex: number) => 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
|
deselectPhoto: (id: string) => void
|
||||||
clearSelection: () => void
|
clearSelection: () => void
|
||||||
setActivePhoto: (id: string | null) => void
|
setActivePhoto: (id: string | null) => void
|
||||||
setViewMode: (mode: ViewMode) => void
|
setViewMode: (mode: ViewMode) => void
|
||||||
setVisiblePhotoIds: (ids: string[]) => void
|
setVisiblePhotoIds: (ids: string[]) => void
|
||||||
openPreview: (id: 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
|
closePreview: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,47 +45,80 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
photos: [],
|
photos: [],
|
||||||
selectedPhotos: [],
|
selectedPhotos: [],
|
||||||
activePhotoId: null,
|
activePhotoId: null,
|
||||||
lastSelectedIndex: null,
|
rangeStartId: null,
|
||||||
rangeStartIndex: null,
|
|
||||||
viewMode: 'grid',
|
viewMode: 'grid',
|
||||||
visiblePhotoIds: [],
|
visiblePhotoIds: [],
|
||||||
|
|
||||||
setPhotos: (photos) => set({ photos }),
|
setPhotos: (photos) => set({ photos }),
|
||||||
|
|
||||||
selectPhoto: (id, index) => set({
|
selectPhoto: (id) => set({
|
||||||
selectedPhotos: [id],
|
selectedPhotos: [id],
|
||||||
activePhotoId: id,
|
activePhotoId: id,
|
||||||
lastSelectedIndex: index,
|
rangeStartId: id,
|
||||||
rangeStartIndex: index,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
togglePhotoSelection: (id, index) => set((state) => {
|
togglePhotoSelection: (id) => set((state) => {
|
||||||
const isSelected = state.selectedPhotos.includes(id)
|
const isSelected = state.selectedPhotos.includes(id)
|
||||||
return {
|
return {
|
||||||
selectedPhotos: isSelected
|
selectedPhotos: isSelected
|
||||||
? state.selectedPhotos.filter(photoId => photoId !== id)
|
? state.selectedPhotos.filter((photoId) => photoId !== id)
|
||||||
: [...state.selectedPhotos, id],
|
: [...state.selectedPhotos, id],
|
||||||
lastSelectedIndex: index,
|
// A toggle-add anchors the range here so a subsequent Shift+click
|
||||||
rangeStartIndex: isSelected ? state.rangeStartIndex : index,
|
// extends from this id. A toggle-remove leaves the anchor alone.
|
||||||
|
rangeStartId: isSelected ? state.rangeStartId : id,
|
||||||
|
activePhotoId: id,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
||||||
selectRange: (endIndex) => {
|
selectRange: (endId) =>
|
||||||
// Note: The actual range selection logic should be handled in the Timeline component
|
set((state) => {
|
||||||
// which has access to the photos array
|
const ids = state.visiblePhotoIds
|
||||||
set({
|
// No published sequence yet → just behave like a single-pick.
|
||||||
lastSelectedIndex: endIndex,
|
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) => ({
|
deselectPhoto: (id) => set((state) => ({
|
||||||
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
|
selectedPhotos: state.selectedPhotos.filter(photoId => photoId !== id)
|
||||||
})),
|
})),
|
||||||
|
|
||||||
clearSelection: () => set({
|
clearSelection: () => set({
|
||||||
selectedPhotos: [],
|
selectedPhotos: [],
|
||||||
lastSelectedIndex: null,
|
rangeStartId: null,
|
||||||
rangeStartIndex: null,
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
setActivePhoto: (id) => set({ activePhotoId: id }),
|
setActivePhoto: (id) => set({ activePhotoId: id }),
|
||||||
@@ -99,7 +143,19 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
|||||||
return { visiblePhotoIds }
|
return { visiblePhotoIds }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
openPreview: (id) => set({ viewMode: 'preview', activePhotoId: id }),
|
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' }),
|
closePreview: () => set({ viewMode: 'grid' }),
|
||||||
}))
|
}))
|
||||||
Reference in New Issue
Block a user