feat(preview): fanned deck of cards on multi-select
When `selection.ids.size >= 2`, the inline preview swaps the single- photo view for a SelectionDeck: each selected thumbnail renders as an absolutely-positioned card with a translate + rotate computed from its index in the deck, so the spread reads as a fan. CSS transition-transform handles the reflow as the deck grows or shrinks; `in:fly` lands new cards from above, `out:scale` shrinks removals into the stack. Hash resolution walks the existing TanStack caches (per-photo + photos-infinite envelope) so the deck is side-effect-free — no fetches just to render thumbs. Drop the now-redundant Maximize hover affordance on PhotoTile and the `onOpenPreview` plumbing through PhotoGrid / +page.svelte: single-click already places a tile into the inline preview pane, so the dedicated "open preview" button no longer has a job. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,8 @@
|
|||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import { getPhoto } from '$lib/services/photoprism';
|
import { getPhoto } from '$lib/services/photoprism';
|
||||||
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
import { thumbUrl, videoUrl } from '$lib/stores/session.svelte';
|
||||||
import { setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
import { selection, setAnchor, setFocused } from '$lib/stores/selection.svelte';
|
||||||
|
import SelectionDeck from '$lib/components/preview/SelectionDeck.svelte';
|
||||||
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
import VideoPlayer from '$lib/components/preview/VideoPlayer.svelte';
|
||||||
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
import { isVideo, primaryFile, videoFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
@@ -21,6 +22,14 @@
|
|||||||
}
|
}
|
||||||
let { uid, order }: Props = $props();
|
let { uid, order }: Props = $props();
|
||||||
|
|
||||||
|
// Multi-select swaps the single-photo pane for a fanned deck of the
|
||||||
|
// selected thumbnails. Reads `selection.ids` directly (already in
|
||||||
|
// scope via the import) so the host doesn't need to thread it
|
||||||
|
// through as a prop. Spreading SvelteSet preserves insertion order,
|
||||||
|
// so the most recently picked card sits on top of the fan.
|
||||||
|
const selectedUids = $derived([...selection.ids]);
|
||||||
|
const multiSelect = $derived(selectedUids.length >= 2);
|
||||||
|
|
||||||
const photoQuery = createQuery<PpPhoto>(() => ({
|
const photoQuery = createQuery<PpPhoto>(() => ({
|
||||||
queryKey: ['photo', uid ?? ''],
|
queryKey: ['photo', uid ?? ''],
|
||||||
queryFn: () => getPhoto(uid as string),
|
queryFn: () => getPhoto(uid as string),
|
||||||
@@ -38,7 +47,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative flex h-full w-full items-center justify-center bg-black/30 p-4">
|
<div class="relative flex h-full w-full items-center justify-center bg-black/30 p-4">
|
||||||
{#if uid === null}
|
{#if multiSelect}
|
||||||
|
<SelectionDeck uids={selectedUids} />
|
||||||
|
{:else if uid === null}
|
||||||
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
|
<p class="text-sm text-muted-foreground">Select a photo to preview.</p>
|
||||||
{:else if photoQuery.isPending}
|
{:else if photoQuery.isPending}
|
||||||
<p class="text-sm text-muted-foreground">Loading…</p>
|
<p class="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
|||||||
112
web/src/lib/components/preview/SelectionDeck.svelte
Normal file
112
web/src/lib/components/preview/SelectionDeck.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<!--
|
||||||
|
Multi-select deck. Renders the selected thumbnails as a fanned spread
|
||||||
|
of cards in the inline preview pane. Each new selection flies onto
|
||||||
|
the deck; each removal shrinks out. Layout reflow on add/remove is
|
||||||
|
driven by CSS transforms transitioning between the recomputed fan
|
||||||
|
positions.
|
||||||
|
|
||||||
|
Hashes come from the per-photo TanStack cache (populated by the
|
||||||
|
timeline + the right-sidebar's getPhoto query); we walk the photos-
|
||||||
|
infinite-query cache as a fallback for photos the user selected
|
||||||
|
before the right sidebar had a chance to fetch them. Anything not
|
||||||
|
found is skipped silently — the deck just renders the resolvable
|
||||||
|
subset, which keeps this surface side-effect-free (no fetches just
|
||||||
|
to draw a thumbnail spread).
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
|
import { fly, scale } from 'svelte/transition';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
import { thumbUrl } from '$lib/stores/session.svelte';
|
||||||
|
import { primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
uids: string[];
|
||||||
|
}
|
||||||
|
let { uids }: Props = $props();
|
||||||
|
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
/** Resolve a uid to its primary-file hash by walking TanStack's caches.
|
||||||
|
* Order: per-photo cache first (cheapest), then any infinite-query
|
||||||
|
* entry under `['photos', …]` — both shapes (flat list and Infinite-
|
||||||
|
* Data envelope) are handled because the timeline uses the envelope
|
||||||
|
* while pools/sidebars use the flat list. Mirrors the cachedPhoto
|
||||||
|
* helper in gridKeyNav.ts. */
|
||||||
|
function lookup(uid: string): string | null {
|
||||||
|
const direct = qc.getQueryData<PpPhoto>(['photo', uid]);
|
||||||
|
if (direct) return primaryFile(direct).Hash ?? null;
|
||||||
|
const lists = qc.getQueriesData({ queryKey: ['photos'] });
|
||||||
|
for (const [, data] of lists) {
|
||||||
|
if (!data) continue;
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
const hit = (data as PpPhoto[]).find((p) => p.UID === uid);
|
||||||
|
if (hit) return primaryFile(hit).Hash ?? null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pages = (data as { pages?: PpPhoto[][] }).pages;
|
||||||
|
if (!Array.isArray(pages)) continue;
|
||||||
|
for (const page of pages) {
|
||||||
|
const hit = page?.find?.((p) => p.UID === uid);
|
||||||
|
if (hit) return primaryFile(hit).Hash ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Card {
|
||||||
|
uid: string;
|
||||||
|
hash: string;
|
||||||
|
}
|
||||||
|
const cards = $derived<Card[]>(
|
||||||
|
uids
|
||||||
|
.map((uid) => {
|
||||||
|
const hash = lookup(uid);
|
||||||
|
return hash ? { uid, hash } : null;
|
||||||
|
})
|
||||||
|
.filter((c): c is Card => c !== null)
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Fan geometry. The spread tightens as the deck grows so 20 cards
|
||||||
|
* still fit visually; small selections (2–4) get wide-stance spreads
|
||||||
|
* so each card reads as its own surface. The `mid`-relative offset
|
||||||
|
* keeps the deck centred regardless of count. */
|
||||||
|
function transform(i: number, n: number): string {
|
||||||
|
if (n <= 1) return 'translateX(0) rotate(0deg)';
|
||||||
|
const mid = (n - 1) / 2;
|
||||||
|
const off = i - mid;
|
||||||
|
// Tighter angle + offset as N grows; cap so very large selections
|
||||||
|
// don't degenerate to a single overlapping pile or, conversely,
|
||||||
|
// spread past the pane edges.
|
||||||
|
const angleStep = Math.min(9, 30 / n);
|
||||||
|
const transStep = Math.min(60, 220 / n);
|
||||||
|
const angle = off * angleStep;
|
||||||
|
const tx = off * transStep;
|
||||||
|
return `translateX(${tx}px) rotate(${angle}deg)`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="relative flex h-full w-full items-center justify-center overflow-hidden">
|
||||||
|
{#if cards.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">Selection has no resolvable thumbnails yet.</p>
|
||||||
|
{/if}
|
||||||
|
{#each cards as card, i (card.uid)}
|
||||||
|
<img
|
||||||
|
src={thumbUrl(card.hash, 'fit_720')}
|
||||||
|
alt=""
|
||||||
|
class="absolute max-h-[80%] max-w-[60%] rounded-lg object-contain shadow-2xl ring-1 ring-black/40 transition-transform duration-300 ease-out"
|
||||||
|
style="transform: {transform(i, cards.length)}; z-index: {i};"
|
||||||
|
in:fly={{ y: -180, duration: 320, easing: cubicOut }}
|
||||||
|
out:scale={{ start: 0.5, duration: 220, easing: cubicOut }}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
<!-- Count chip so the user can see at a glance how many photos are
|
||||||
|
in the bulk selection without counting cards. -->
|
||||||
|
{#if cards.length > 0}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute bottom-3 right-3 rounded-full bg-background/85 px-2.5 py-1 text-xs font-medium shadow"
|
||||||
|
>
|
||||||
|
{cards.length} selected
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -81,13 +81,6 @@
|
|||||||
setFocused(uid);
|
setFocused(uid);
|
||||||
setAnchor(uid);
|
setAnchor(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onOpenPreview(uid: string) {
|
|
||||||
selection.ids.clear();
|
|
||||||
selection.ids.add(uid);
|
|
||||||
setFocused(uid);
|
|
||||||
setAnchor(uid);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
|
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
|
||||||
@@ -99,7 +92,6 @@
|
|||||||
selected={sel}
|
selected={sel}
|
||||||
onClick={(e) => onClick(e, photo.UID)}
|
onClick={(e) => onClick(e, photo.UID)}
|
||||||
onDblclick={(e) => onDblclick(e, photo.UID)}
|
onDblclick={(e) => onDblclick(e, photo.UID)}
|
||||||
onOpenPreview={() => onOpenPreview(photo.UID)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -2,20 +2,17 @@
|
|||||||
Single photo tile — shared by the timeline (+page.svelte) and the flat
|
Single photo tile — shared by the timeline (+page.svelte) and the flat
|
||||||
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
|
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
|
||||||
and can't drift between the two. Owns: thumbnail, selection animation,
|
and can't drift between the two. Owns: thumbnail, selection animation,
|
||||||
video badge, and the hover-only "open preview" affordance.
|
and the video badge.
|
||||||
|
|
||||||
Sizing is delegated to the caller — PhotoTile fills its container with
|
Sizing is delegated to the caller — PhotoTile fills its container with
|
||||||
h-full w-full, so the timeline can wrap it in its windowing shell and
|
h-full w-full, so the timeline can wrap it in its windowing shell and
|
||||||
PhotoGrid can wrap it in an aspect-square cell.
|
PhotoGrid can wrap it in an aspect-square cell.
|
||||||
|
|
||||||
Click semantics:
|
Click semantics:
|
||||||
- plain click → onClick (caller decides; both surfaces use it for select-only)
|
- plain click → onClick (caller decides; both surfaces use it for select-only)
|
||||||
- dblclick → onDblclick (caller usually opens preview)
|
- dblclick → onDblclick (caller usually opens preview)
|
||||||
- Maximize ico → onOpenPreview (single-click fallback for the
|
|
||||||
not-yet-discovered dblclick gesture)
|
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Maximize2 } from "lucide-svelte";
|
|
||||||
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
import { thumbSrc, thumbSrcSet, videoUrl } from "$lib/stores/session.svelte";
|
||||||
import { view } from "$lib/stores/view.svelte";
|
import { view } from "$lib/stores/view.svelte";
|
||||||
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
import { isVideo, primaryFile, type PpPhoto } from "$lib/types/photoprism";
|
||||||
@@ -25,9 +22,8 @@
|
|||||||
selected: boolean;
|
selected: boolean;
|
||||||
onClick: (e: MouseEvent) => void;
|
onClick: (e: MouseEvent) => void;
|
||||||
onDblclick: (e: MouseEvent) => void;
|
onDblclick: (e: MouseEvent) => void;
|
||||||
onOpenPreview: () => void;
|
|
||||||
}
|
}
|
||||||
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
|
let { photo, selected, onClick, onDblclick }: Props = $props();
|
||||||
|
|
||||||
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
|
||||||
const video = $derived(isVideo(photo));
|
const video = $derived(isVideo(photo));
|
||||||
@@ -68,10 +64,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Wrapper div carries `group` so the Maximize affordance can hover-reveal
|
Wrapper div carries `group` so the image-scale hover effect can ride off
|
||||||
off the same hover region as the image-scale hover. The Maximize button
|
the same hover region. No nested buttons — the only interactive surface
|
||||||
sits as a sibling of the main tile button (not nested) — nesting <button>
|
is the tile button itself.
|
||||||
in <button> is invalid HTML.
|
|
||||||
-->
|
-->
|
||||||
<div
|
<div
|
||||||
class="group relative h-full w-full"
|
class="group relative h-full w-full"
|
||||||
@@ -150,25 +145,4 @@
|
|||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
<!--
|
|
||||||
Hover-only "open preview" affordance. Single-click on this icon
|
|
||||||
opens the preview directly, giving users a one-click fallback for
|
|
||||||
the dblclick gesture (and a visual cue that previewing is a thing
|
|
||||||
at all). Hidden when the tile is selected — there'd be no preview
|
|
||||||
intent on a tile the user is in the middle of bulk-acting on.
|
|
||||||
-->
|
|
||||||
{#if !selected}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="absolute bottom-1.5 right-1.5 hidden rounded bg-background/80 p-1 text-muted-foreground hover:text-foreground group-hover:block"
|
|
||||||
onclick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenPreview();
|
|
||||||
}}
|
|
||||||
title="Open preview"
|
|
||||||
aria-label="Open preview"
|
|
||||||
>
|
|
||||||
<Maximize2 class="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -665,13 +665,6 @@
|
|||||||
setAnchor(uid);
|
setAnchor(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTileOpenPreview(uid: string) {
|
|
||||||
selection.ids.clear();
|
|
||||||
selection.ids.add(uid);
|
|
||||||
setFocused(uid);
|
|
||||||
setAnchor(uid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
|
// Scroll root for the infinite-scroll IntersectionObserver. Bound by
|
||||||
// the <main> element below; the sentinel's `root` references this so
|
// the <main> element below; the sentinel's `root` references this so
|
||||||
// the observer measures intersections relative to the timeline pane
|
// the observer measures intersections relative to the timeline pane
|
||||||
@@ -905,7 +898,6 @@
|
|||||||
selected={sel}
|
selected={sel}
|
||||||
onClick={(e) => onTileClick(e, photo.UID)}
|
onClick={(e) => onTileClick(e, photo.UID)}
|
||||||
onDblclick={(e) => onTileDblclick(e, photo.UID)}
|
onDblclick={(e) => onTileDblclick(e, photo.UID)}
|
||||||
onOpenPreview={() => onTileOpenPreview(photo.UID)}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user