feat: snappier timeline — instant discard, progressive load, restore preview origin
- Discard yanks photos from the grid optimistically (cache strip + active cursor advance) instead of waiting for the mutation round-trip; wired through the X hotkey, RightSidebar bulk discard, LeftSidebar discard drop, and DiscardActionBar restore/delete. - usePhotosQuery resolves on the first 500-photo page and streams the remaining pages into the cache in the background, so the first thumbnails paint immediately on large libraries. - Closing preview restores the photo it was opened on (snapshot ref in PreviewView, written directly to the store) and Timeline scrolls that row back into view. Escape is handled on the dialog with stopPropagation so Timeline's window-level Esc handler doesn't wipe the restored selection. - Preview overlay bumped to z-[1000] so it covers Leaflet map tiles, and the right sidebar no longer collapses during preview — both fix visible layout shifts on close. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,11 @@ function App() {
|
||||
|
||||
// Right sidebar stays open by default and shows whatever's selected
|
||||
// (or an empty state if nothing is). User can still toggle it manually.
|
||||
const showRightSidebar = rightSidebarOpen && viewMode === 'grid'
|
||||
// Note: deliberately NOT gated on viewMode — the preview overlay sits
|
||||
// on top with z-[1000], so leaving the sidebar mounted underneath
|
||||
// costs nothing visually and avoids the collapse-then-reopen layout
|
||||
// shift the user would otherwise see every time they exit preview.
|
||||
const showRightSidebar = rightSidebarOpen
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg text-text">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RotateCcw, Trash2 } from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { useFilterStore } from '../../store/filterStore'
|
||||
import { usePhotosQuery } from '../../hooks/usePhotosQuery'
|
||||
import { usePhotosQuery, stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
import { discard as discardApi, photos as photosApi } from '../../services/api'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { ConfirmDialog } from '../dialogs/ConfirmDialog'
|
||||
@@ -27,6 +27,14 @@ export function DiscardActionBar() {
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => discardApi.restore(ids),
|
||||
// Pull the restored ids out of the discard view immediately. The
|
||||
// user is sitting on flag=discarded so they should disappear from
|
||||
// sight the moment the click lands; the onSuccess invalidate still
|
||||
// reconciles with server truth shortly after.
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, ids)
|
||||
},
|
||||
onSuccess: (_, ids) => {
|
||||
registerUndoable(
|
||||
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
|
||||
@@ -45,6 +53,10 @@ export function DiscardActionBar() {
|
||||
|
||||
const deleteSelectedMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => discardApi.deletePermanent(ids),
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, ids)
|
||||
},
|
||||
onSuccess: (data: any) => {
|
||||
const count = data?.deleted ?? 0
|
||||
const errors = data?.file_errors ?? 0
|
||||
|
||||
@@ -28,6 +28,8 @@ import { ActiveHeapCard } from '../heaps/ActiveHeapCard'
|
||||
import { PHOTO_DRAG_MIME } from '../timeline/PhotoThumbnail'
|
||||
import { useFolderTreeQuery } from '../../hooks/useFolderTreeQuery'
|
||||
import { useTagsQuery } from '../../hooks/useTagsQuery'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
import {
|
||||
useLibraryStatsQuery,
|
||||
LIBRARY_STATS_QUERY_KEY,
|
||||
@@ -101,6 +103,13 @@ export function LeftSidebar({ onCollapse, onOpenSettings }: LeftSidebarProps) {
|
||||
// Bulk discard mutation for the drag-onto-Discarded interaction.
|
||||
const discardDropMutation = useMutation({
|
||||
mutationFn: (photoIds: string[]) => photosApi.bulkDiscard(photoIds),
|
||||
// Optimistically pull the dropped photos out of the timeline so the
|
||||
// grid reflows the moment the drop lands, instead of waiting for
|
||||
// the network round-trip + invalidation refetch.
|
||||
onMutate: (photoIds) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(photoIds)
|
||||
stripPhotosFromCache(queryClient, photoIds)
|
||||
},
|
||||
onSuccess: (_data, photoIds) => {
|
||||
registerUndoable(
|
||||
`Discarded ${photoIds.length} photo${photoIds.length === 1 ? '' : 's'}`,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useActiveHeapMembers } from '../../hooks/useActiveHeapMembersQuery'
|
||||
import { HEAPS_QUERY_KEY } from '../../hooks/useHeapsQuery'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
import { useTagsQuery, TAGS_QUERY_KEY } from '../../hooks/useTagsQuery'
|
||||
import { stripPhotosFromCache } from '../../hooks/usePhotosQuery'
|
||||
import { toast } from '../ToastContainer'
|
||||
import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels'
|
||||
@@ -48,6 +49,13 @@ export function RightSidebar({ onCollapse }: RightSidebarProps) {
|
||||
})
|
||||
const bulkDiscardMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
|
||||
// Yank the photos from the timeline before the network round-trip
|
||||
// so the grid reflows immediately. Same pattern as the X hotkey
|
||||
// path in useKeyboardShortcuts.
|
||||
onMutate: (ids) => {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, ids)
|
||||
},
|
||||
onSuccess: invalidatePhotoQueries,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,13 +14,38 @@ import { PhotoInfoPanel } from '../sidebar/PhotoInfoPanel'
|
||||
export function PreviewView() {
|
||||
const activePhotoId = usePhotoStore((s) => s.activePhotoId)
|
||||
const setActivePhoto = usePhotoStore((s) => s.setActivePhoto)
|
||||
const closePreview = usePhotoStore((s) => s.closePreview)
|
||||
const visiblePhotoIds = usePhotoStore((s) => s.visiblePhotoIds)
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
|
||||
const [infoPanelOpen, setInfoPanelOpen] = useState(false)
|
||||
|
||||
// Snapshot the photo we opened on, captured once at mount via the
|
||||
// store's getState (which is guaranteed to reflect the value the
|
||||
// openPreview action just wrote, even if the React subscription
|
||||
// hasn't been delivered to this component's first render yet). This
|
||||
// is the id we'll restore on close, no matter how many neighbours
|
||||
// the user arrows through inside the preview.
|
||||
const openOriginRef = useRef<string | null>(
|
||||
activePhotoId ?? usePhotoStore.getState().activePhotoId
|
||||
)
|
||||
const closePreview = useCallback(() => {
|
||||
// Bypass the store action and write the restoration directly so
|
||||
// the snapshot ref is the single source of truth. Falls back to
|
||||
// the live activePhotoId if the ref was somehow never populated
|
||||
// (defensive — openPreview always sets activePhotoId before
|
||||
// PreviewView mounts).
|
||||
const id =
|
||||
openOriginRef.current ?? usePhotoStore.getState().activePhotoId
|
||||
usePhotoStore.setState({
|
||||
viewMode: 'grid',
|
||||
activePhotoId: id,
|
||||
rangeStartId: id,
|
||||
selectedPhotos: id ? [id] : [],
|
||||
previewOriginPhotoId: null,
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Same hook Timeline uses, so we share one cache entry rather than looking
|
||||
// it up by key (which broke when the key gained the filter params).
|
||||
const { data: rawPhotos = [] } = usePhotosQuery()
|
||||
@@ -93,7 +118,16 @@ export function PreviewView() {
|
||||
// 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 })
|
||||
//
|
||||
// Escape is intentionally NOT bound through useHotkeys here. The
|
||||
// grid-level Timeline component listens for Escape on `window` to
|
||||
// clear the current selection, and react-hotkeys-hook binds at
|
||||
// document level — so a single Esc keypress would land in BOTH
|
||||
// handlers. The grid handler would then wipe the selection we just
|
||||
// restored from the preview origin. Instead, escape is handled by
|
||||
// the dialog's onKeyDown below, which runs first (lower in the
|
||||
// bubble chain) and calls stopPropagation so the keypress never
|
||||
// reaches window.
|
||||
useHotkeys('left', goPrev, { preventDefault: true })
|
||||
useHotkeys('right', goNext, { preventDefault: true })
|
||||
useHotkeys('i', () => setInfoPanelOpen((v) => !v), { preventDefault: true })
|
||||
@@ -123,8 +157,17 @@ export function PreviewView() {
|
||||
}, [])
|
||||
|
||||
// Trap Tab inside the dialog so users can't accidentally tab into the
|
||||
// hidden grid behind. Simple cycle implementation.
|
||||
// hidden grid behind. Simple cycle implementation. Also intercepts
|
||||
// Escape and stops propagation before the keypress reaches the
|
||||
// window-level handler in Timeline (which would otherwise wipe the
|
||||
// selection we just restored to the entry photo).
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
closePreview()
|
||||
return
|
||||
}
|
||||
if (e.key !== 'Tab') return
|
||||
const root = containerRef.current
|
||||
if (!root) return
|
||||
@@ -156,7 +199,7 @@ export function PreviewView() {
|
||||
aria-modal="true"
|
||||
aria-label="Photo preview"
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-40 flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
||||
className="fixed inset-0 z-[1000] flex flex-col items-center justify-center bg-black text-text-muted outline-none"
|
||||
>
|
||||
<div>No photo to display</div>
|
||||
<button
|
||||
@@ -177,7 +220,7 @@ export function PreviewView() {
|
||||
aria-label={`Photo preview: ${currentPhoto.filename}`}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed inset-0 z-40 flex bg-black outline-none"
|
||||
className="fixed inset-0 z-[1000] flex bg-black outline-none"
|
||||
>
|
||||
{/* Main column — image + filmstrip */}
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
|
||||
@@ -321,6 +321,11 @@ export function Timeline() {
|
||||
// they share one cache entry, regardless of filter state.
|
||||
const { data: photos = [], isLoading } = usePhotosQuery()
|
||||
|
||||
// Tracks the previous viewMode so the "preview just closed" scroll
|
||||
// effect (defined further down, after photoRows) only fires on the
|
||||
// actual transition rather than every items[] recomputation.
|
||||
const prevViewModeRef = useRef(viewMode)
|
||||
|
||||
// Auto-focus the first photo on initial grid load so arrow-key nav
|
||||
// works immediately without a pre-click. Only fires when there's no
|
||||
// current active photo — we never clobber the user's selection or
|
||||
@@ -459,6 +464,44 @@ export function Timeline() {
|
||||
return map
|
||||
}, [items])
|
||||
|
||||
// After the preview closes, scroll the photo it was originally
|
||||
// opened on back into view. The store's closePreview already
|
||||
// restored activePhotoId to that origin id; we just need to make
|
||||
// sure it's actually visible in the scroll viewport. Guarded by
|
||||
// prevViewModeRef so this only fires on the actual preview→grid
|
||||
// transition, not every time photoRows recomputes.
|
||||
useEffect(() => {
|
||||
const prev = prevViewModeRef.current
|
||||
prevViewModeRef.current = viewMode
|
||||
if (prev !== 'preview' || viewMode !== 'grid') return
|
||||
if (!activePhotoId) return
|
||||
let rowIdx = -1
|
||||
for (let r = 0; r < photoRows.length; r++) {
|
||||
if (photoRows[r].cells.some((c) => c.photo.id === activePhotoId)) {
|
||||
rowIdx = r
|
||||
break
|
||||
}
|
||||
}
|
||||
if (rowIdx < 0) return
|
||||
const itemIdx = photoRowItemIndex[rowIdx]
|
||||
const scrollEl = parentRef.current
|
||||
if (itemIdx === undefined || !scrollEl) return
|
||||
let rowTop = 0
|
||||
for (let i = 0; i < itemIdx; i++) rowTop += items[i].height
|
||||
const rowHeight = items[itemIdx].height
|
||||
const viewTop = scrollEl.scrollTop
|
||||
const viewBottom = viewTop + scrollEl.clientHeight
|
||||
if (rowTop >= viewTop && rowTop + rowHeight <= viewBottom) return
|
||||
// Center the row in the viewport — the user is returning to a
|
||||
// specific photo, not resuming a scroll, so context above and
|
||||
// below is what they want.
|
||||
const target = Math.max(
|
||||
0,
|
||||
rowTop - scrollEl.clientHeight / 2 + rowHeight / 2
|
||||
)
|
||||
scrollEl.scrollTo({ top: target })
|
||||
}, [viewMode, activePhotoId, photoRows, photoRowItemIndex, items])
|
||||
|
||||
// Flat visible-order id sequence — exactly the order the user reads
|
||||
// off the grid (top-to-bottom, left-to-right within each row).
|
||||
// Includes duplicates from tag-grouping; landing on the same photo's
|
||||
|
||||
@@ -6,6 +6,7 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
|
||||
import { toast } from '../components/ToastContainer'
|
||||
import { registerUndoable, useUndoStore } from '../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
|
||||
import { stripPhotosFromCache } from './usePhotosQuery'
|
||||
|
||||
interface KeyboardShortcutsProps {
|
||||
onToggleLeftSidebar: () => void
|
||||
@@ -108,6 +109,17 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
|
||||
const ids = cullTargets()
|
||||
if (ids.length === 0) return
|
||||
|
||||
// Discard is a removal from the timeline view: yank it from the
|
||||
// selection / cache before the network round-trip so the grid
|
||||
// reflows immediately and the next photo takes over the active
|
||||
// cursor. Restore goes through the same removal path because it
|
||||
// only fires from views (discard pile) where restored photos no
|
||||
// longer match the filter.
|
||||
if (data.is_discarded === true || data.is_discarded === false) {
|
||||
usePhotoStore.getState().removePhotosFromTimeline(ids)
|
||||
stripPhotosFromCache(queryClient, ids)
|
||||
}
|
||||
|
||||
if (ids.length === 1) {
|
||||
const id = ids[0]
|
||||
updateMutation.mutate(
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
|
||||
import { useFilterStore, filtersToParams } from '../store/filterStore'
|
||||
import api from '../services/api'
|
||||
import type { Photo } from '../types/photo'
|
||||
|
||||
/**
|
||||
* Optimistically strip the supplied photo ids from every cached
|
||||
* timeline list. Used by discard / delete flows so the photos vanish
|
||||
* from the grid the instant the user acts, without waiting for the
|
||||
* mutation round-trip + invalidation refetch. The subsequent invalidate
|
||||
* still runs and reconciles the cache with server truth.
|
||||
*/
|
||||
export function stripPhotosFromCache(queryClient: QueryClient, ids: string[]) {
|
||||
if (ids.length === 0) return
|
||||
const removed = new Set(ids)
|
||||
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) => {
|
||||
if (!prev) return prev
|
||||
return prev.filter((p) => !removed.has(p.id))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Single source of truth for the timeline photos query. Both Timeline and
|
||||
* PreviewView call this so they share one cache entry — previously
|
||||
@@ -47,39 +63,67 @@ export function usePhotosQuery() {
|
||||
[q, dateFrom, dateTo, mediaTypes, ratingMin, colorLabel, flag, heapId, folderId, tagIds, duplicates, groupBy, sortBy, sortOrder]
|
||||
)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['photos', filterParams],
|
||||
queryFn: async () => {
|
||||
// Goes through the shared axios instance so it inherits the
|
||||
// relative /api/v1 baseURL — same-origin behind the nginx / vite
|
||||
// proxy, no CORS dance required from another machine.
|
||||
//
|
||||
// The Timeline and grid views virtualize, so we load every match
|
||||
// up-front rather than paginating in the UI. Backend caps per_page
|
||||
// at 500, so for libraries / folders with more matches we walk
|
||||
// pages until we have everything. Capped at 200 pages (= 100k
|
||||
// photos) as a sanity bound.
|
||||
queryFn: async ({ signal }) => {
|
||||
// Two-phase fetch so the timeline can paint its first thumbnails
|
||||
// long before the entire library has finished downloading. Phase 1
|
||||
// returns the first page synchronously (which resolves the
|
||||
// useQuery promise so consumers exit their loading state). Phase 2
|
||||
// walks the remaining pages in the background, appending each one
|
||||
// into the cache via setQueryData so the grid grows as data
|
||||
// arrives. The signal from React Query aborts the background
|
||||
// loop if the query is invalidated or unmounts mid-stream.
|
||||
const PER_PAGE = 500
|
||||
const MAX_PAGES = 200
|
||||
const all: Photo[] = []
|
||||
for (let page = 1; page <= MAX_PAGES; page++) {
|
||||
const response = await api.get<{
|
||||
photos: Photo[]
|
||||
total: number
|
||||
pages: number
|
||||
}>('/photos', {
|
||||
params: {
|
||||
page,
|
||||
per_page: PER_PAGE,
|
||||
...filterParams,
|
||||
},
|
||||
})
|
||||
const photos = response.data.photos || []
|
||||
all.push(...photos)
|
||||
const totalPages = response.data.pages ?? 1
|
||||
if (page >= totalPages || photos.length < PER_PAGE) break
|
||||
|
||||
const firstResp = await api.get<{
|
||||
photos: Photo[]
|
||||
total: number
|
||||
pages: number
|
||||
}>('/photos', {
|
||||
params: { page: 1, per_page: PER_PAGE, ...filterParams },
|
||||
signal,
|
||||
})
|
||||
const firstBatch = firstResp.data.photos || []
|
||||
const totalPages = firstResp.data.pages ?? 1
|
||||
|
||||
if (totalPages > 1 && firstBatch.length === PER_PAGE) {
|
||||
// Fire-and-forget background loop. We don't await here — the
|
||||
// first batch is already enough to render. Each subsequent
|
||||
// page lands via setQueryData, which triggers consumers to
|
||||
// re-render with the larger list.
|
||||
void (async () => {
|
||||
for (let page = 2; page <= Math.min(totalPages, MAX_PAGES); page++) {
|
||||
if (signal?.aborted) return
|
||||
try {
|
||||
const resp = await api.get<{
|
||||
photos: Photo[]
|
||||
total: number
|
||||
pages: number
|
||||
}>('/photos', {
|
||||
params: { page, per_page: PER_PAGE, ...filterParams },
|
||||
signal,
|
||||
})
|
||||
if (signal?.aborted) return
|
||||
const more = resp.data.photos || []
|
||||
queryClient.setQueryData<Photo[]>(
|
||||
['photos', filterParams],
|
||||
(prev) => (prev ? [...prev, ...more] : more)
|
||||
)
|
||||
if (more.length < PER_PAGE) return
|
||||
} catch {
|
||||
// Network or abort — give up the background stream. The
|
||||
// next user-triggered refetch will start fresh.
|
||||
return
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
return all
|
||||
|
||||
return firstBatch
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
@@ -20,6 +20,11 @@ interface PhotoStore {
|
||||
* for both preview navigation order and range selection. Owned by
|
||||
* the Timeline component. */
|
||||
visiblePhotoIds: string[]
|
||||
/** The id of the photo the preview was originally opened on. Captured
|
||||
* by openPreview, restored by closePreview, so the user lands back
|
||||
* on the photo they came from instead of whatever neighbour they
|
||||
* arrow-navigated to inside the preview. */
|
||||
previewOriginPhotoId: string | null
|
||||
|
||||
setPhotos: (photos: Photo[]) => void
|
||||
selectPhoto: (id: string) => void
|
||||
@@ -38,7 +43,18 @@ interface PhotoStore {
|
||||
* 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
|
||||
/** Close the preview overlay. The optional restoreId pins the active
|
||||
* photo to a specific id (typically the photo preview was opened on)
|
||||
* so the user lands back on it instead of whatever neighbour they
|
||||
* arrow-navigated to inside the preview. PreviewView passes the
|
||||
* value it captured at mount time, which is the most reliable
|
||||
* source — the in-store previewOriginPhotoId is a fallback. */
|
||||
closePreview: (restoreId?: string | null) => void
|
||||
/** Drop photos that just left the timeline (e.g. discarded). Strips
|
||||
* them from selection / visible sequence and advances activePhotoId
|
||||
* to the next surviving neighbour so the grid still has a target
|
||||
* for arrow keys after the removal. */
|
||||
removePhotosFromTimeline: (ids: string[]) => void
|
||||
}
|
||||
|
||||
export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
@@ -48,6 +64,7 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
rangeStartId: null,
|
||||
viewMode: 'grid',
|
||||
visiblePhotoIds: [],
|
||||
previewOriginPhotoId: null,
|
||||
|
||||
setPhotos: (photos) => set({ photos }),
|
||||
|
||||
@@ -147,6 +164,9 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
set((s) => ({
|
||||
viewMode: 'preview',
|
||||
activePhotoId: id,
|
||||
// Snapshot the entry photo so closing returns the user there even
|
||||
// after they arrow-navigated through several neighbours.
|
||||
previewOriginPhotoId: 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
|
||||
@@ -157,5 +177,62 @@ export const usePhotoStore = create<PhotoStore>((set) => ({
|
||||
: s.visiblePhotoIds,
|
||||
})),
|
||||
|
||||
closePreview: () => set({ viewMode: 'grid' }),
|
||||
closePreview: (restoreId) =>
|
||||
set((s) => {
|
||||
// Prefer the explicit restoreId from PreviewView (captured at
|
||||
// mount time, immune to anything that might have cleared the
|
||||
// in-store origin during the preview session). Fall back to the
|
||||
// store-tracked origin and finally to the current active photo.
|
||||
const finalId =
|
||||
(restoreId ?? s.previewOriginPhotoId ?? s.activePhotoId) || null
|
||||
return {
|
||||
viewMode: 'grid',
|
||||
activePhotoId: finalId,
|
||||
rangeStartId: finalId,
|
||||
selectedPhotos: finalId ? [finalId] : [],
|
||||
previewOriginPhotoId: null,
|
||||
}
|
||||
}),
|
||||
|
||||
removePhotosFromTimeline: (ids) =>
|
||||
set((state) => {
|
||||
if (ids.length === 0) return state
|
||||
const removed = new Set(ids)
|
||||
const newSelected = state.selectedPhotos.filter((id) => !removed.has(id))
|
||||
const newVisible = state.visiblePhotoIds.filter((id) => !removed.has(id))
|
||||
|
||||
// Pick a survivor for activePhotoId so arrow nav still has a
|
||||
// landing spot. Walk forward from the old position first (the
|
||||
// intuitive direction for "delete current, advance to next"),
|
||||
// then fall back to walking backward if we were at the end.
|
||||
let newActive = state.activePhotoId
|
||||
if (newActive && removed.has(newActive)) {
|
||||
const oldIdx = state.visiblePhotoIds.indexOf(newActive)
|
||||
let found: string | null = null
|
||||
if (oldIdx >= 0) {
|
||||
for (let i = oldIdx + 1; i < state.visiblePhotoIds.length; i++) {
|
||||
if (!removed.has(state.visiblePhotoIds[i])) {
|
||||
found = state.visiblePhotoIds[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
for (let i = oldIdx - 1; i >= 0; i--) {
|
||||
if (!removed.has(state.visiblePhotoIds[i])) {
|
||||
found = state.visiblePhotoIds[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newActive = found ?? newVisible[0] ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
selectedPhotos: newSelected,
|
||||
visiblePhotoIds: newVisible,
|
||||
activePhotoId: newActive,
|
||||
rangeStartId: newActive,
|
||||
}
|
||||
}),
|
||||
}))
|
||||
Reference in New Issue
Block a user