Files
mule-image/web/src/lib/components/preview/PreviewModal.svelte
dtoro e1707c314d preview: keep metadata sidebar mounted across photo changes
Previously the modal's aside was gated on `focusedPhotoQuery.data`, so
each arrow-skim unmounted the sidebar until the next photo's metadata
arrived — which reflowed the preview pane sideways. Now the aside is
always mounted while the modal is open; its contents swap between the
metadata panel and a small InlineLoader the same way the timeline's
right-aside does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:28:12 +02:00

179 lines
6.7 KiB
Svelte

<!--
Full-screen preview modal. Mounted once at the layout root so every
route (timeline, /tags, future grids) can open it via Space or a
double-click on a tile. Reads from the global selection store:
- selection.focused → which photo to display
- selection.order → walked left/right + drives PreviewCarousel
Layout: preview pane (left, fills) + RightSidebar (right) + action
toolbar + thumbnail carousel along the bottom. Keyboard nav (←/→/Space/
Esc) is owned here; gridKeyNav bails out of those keys while the modal
is open so we don't double-handle. Action keys (X/S/U/⌘Z/etc.) continue
to flow through gridKeyNav since they target selection state, which
the modal shares.
-->
<script lang="ts">
import { Dialog } from 'bits-ui';
import { createQuery } from '@tanstack/svelte-query';
import { X } from 'lucide-svelte';
import { closePreview, view } from '$lib/stores/view.svelte';
import {
clearBulkToFirst,
clearSelection,
selectRange,
selection,
setAnchor,
setFocused
} from '$lib/stores/selection.svelte';
import { getPhoto } from '$lib/services/photoprism';
import { isAuthenticated } from '$lib/stores/session.svelte';
import type { PpPhoto } from '$lib/types/photoprism';
import PreviewPane from './PreviewPane.svelte';
import PreviewCarousel from './PreviewCarousel.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import { InlineLoader } from '$lib/components/feedback';
const focusedUid = $derived(selection.focused);
// Fetch the focused photo so the RightSidebar always has full
// metadata even when the user jumped here from a list that only
// hydrated thumbnails.
const focusedPhotoQuery = createQuery<PpPhoto>(() => ({
queryKey: ['photo', focusedUid ?? ''],
queryFn: () => getPhoto(focusedUid as string),
enabled: Boolean(focusedUid) && isAuthenticated() && view.previewOpen
}));
function step(delta: number, extending: boolean) {
const order = selection.order;
const cur = focusedUid ? order.indexOf(focusedUid) : -1;
if (cur < 0) return;
const next = order[cur + delta];
if (!next) return;
// Plain arrow drops any prior multi-selection down to the cursor —
// otherwise the previously selected tiles keep their blue ring and
// the carousel reads as two simultaneously selected images (the old
// ones still in ids, plus the freshly focused one). Mirrors
// gridKeyNav.moveFocus's contract.
if (!extending) clearSelection();
setFocused(next);
if (extending) selectRange(next);
else setAnchor(next);
}
function onKeydown(e: KeyboardEvent) {
// Don't steal keys from inputs inside the sidebar (editable
// metadata fields, keyword chips, etc.).
const t = e.target as HTMLElement | null;
const tag = t?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) {
return;
}
// stopImmediatePropagation prevents gridKeyNav's window-level
// handler from firing afterwards — without it, closing on Space
// would set previewOpen=false and then gridKeyNav's Space branch
// would re-open the modal because selection.focused is still set.
if (e.key === 'Escape') {
e.preventDefault();
e.stopImmediatePropagation();
// First Esc collapses a bulk back to single-focus on its first
// member, keeping the modal open so the user can keep working.
// Second Esc (no bulk left) closes the modal.
if (clearBulkToFirst()) return;
closePreview();
return;
}
if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
e.stopImmediatePropagation();
closePreview();
return;
}
if (e.key === 'ArrowLeft') {
e.preventDefault();
e.stopImmediatePropagation();
step(-1, e.shiftKey);
return;
}
if (e.key === 'ArrowRight') {
e.preventDefault();
e.stopImmediatePropagation();
step(1, e.shiftKey);
}
}
// Bind a window-level handler only while the modal is open. The
// `capture` phase lets us pre-empt gridKeyNav's own listener for the
// keys we own (arrows, Esc, Space) without having to coordinate
// listener order.
$effect(() => {
if (!view.previewOpen) return;
const handler = (e: KeyboardEvent) => onKeydown(e);
window.addEventListener('keydown', handler, { capture: true });
return () => window.removeEventListener('keydown', handler, { capture: true } as EventListenerOptions);
});
</script>
<Dialog.Root
open={view.previewOpen}
onOpenChange={(o) => {
if (!o) closePreview();
}}
>
<Dialog.Portal>
<Dialog.Overlay
class="fixed inset-0 z-40 bg-background/95 backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
/>
<Dialog.Content
class="fixed inset-0 z-50 flex flex-col overflow-hidden bg-background outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
>
<Dialog.Title class="sr-only">Photo preview</Dialog.Title>
<Dialog.Description class="sr-only">
Full-screen preview of the focused photo with metadata and a thumbnail carousel.
</Dialog.Description>
<!-- Top row: preview pane (fills) + sidebar (fixed width).
BulkActionBar lives inside the main column — same shape as the
timeline (+page.svelte) so the bar stays bounded by the
column's width and doesn't stretch under the metadata
sidebar. -->
<div class="flex min-h-0 flex-1">
<div class="relative flex min-w-0 flex-1 flex-col">
<button
type="button"
class="absolute right-3 top-3 z-20 inline-flex h-8 w-8 items-center justify-center rounded-full bg-background/80 text-foreground shadow hover:bg-background"
onclick={closePreview}
aria-label="Close preview"
title="Close (Esc)"
>
<X class="h-4 w-4" />
</button>
<div class="flex min-h-0 flex-1">
<PreviewPane uid={focusedUid} order={selection.order} />
</div>
<!-- Action toolbar (acts on selection.ids; falls back to focused). -->
<BulkActionBar />
</div>
<!-- Sidebar stays mounted across photo changes so the preview
pane doesn't reflow on arrow-skim; contents swap between
the metadata panel and a small loader the same way the
timeline's right-aside does. -->
<aside
class="w-[300px] shrink-0 overflow-y-auto border-l border-border bg-card"
>
{#if focusedPhotoQuery.data}
<RightSidebar photo={focusedPhotoQuery.data} />
{:else if focusedPhotoQuery.isFetching}
<InlineLoader size="sm" label="Loading metadata…" />
{/if}
</aside>
</div>
<!-- Bottom filmstrip across selection.order. -->
<PreviewCarousel />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>