Mulimage 2.0 #1

Merged
dtoro merged 64 commits from new into main 2026-05-21 22:48:55 +02:00
8 changed files with 362 additions and 288 deletions
Showing only changes of commit 84e433ff63 - Show all commits

View File

@@ -1,59 +1,57 @@
/** /**
* Tracks the first/last visible tile indices inside the attached scroll * Tracks the first/last visible tile indices inside the attached scroll
* container so the host can render only `[first - BUFFER, last + BUFFER]` * container so the host can render only `[first - BUFFER, last + BUFFER]`
* and leave the rest unmounted. Mirrors PhotoPrism's pattern in * and leave the rest unmounted.
* `frontend/src/component/photo/view/cards.vue`:
* *
* - One IntersectionObserver on the scroll root. * Implementation: rAF-throttled scroll listener that scans the shell
* - Observes every Nth tile (sample, not every tile, to keep observer * elements (every photo renders a `[data-uid-shell]` div regardless of
* overhead flat as the order grows). * the windowing window) and reports the first/last shell whose bounding
* - The host registers nodes as they mount via `register(el, index)` * rect intersects the scroll root.
* and unregisters via `unregister(el)`. *
* - The action calls `onChange(first, last)` whenever the visible * Why not an IntersectionObserver? Two real-world breakages:
* window updates. *
* 1. Late tile registration on remount. With cached photo data, the
* host's child shells mount in the same pass as the scroll root,
* racing the observer setup → registrations silently drop, observer
* sees nothing, the window never updates.
* 2. Observer dead zone on fast scroll. The observer fires only when a
* sample tile crosses the root boundary. If the user flicks the
* scroll faster than the host's buffer can extend the mounted set,
* every sample tile leaves the viewport before the next one is
* mounted, the host's `onChange` stops firing, and the timeline
* goes blank.
*
* Querying shells directly sidesteps both: shells are always mounted,
* the scan happens on every scroll tick, and the result is the true
* first/last regardless of how fast the user dragged.
* *
* Use it like: * Use it like:
* *
* const range = $state({ first: 0, last: 0 });
* <main use:visibleRange={{ * <main use:visibleRange={{
* onChange: (f, l) => { range.first = f; range.last = l; }, * onChange: (f, l) => { range.first = f; range.last = l; }
* sampleEvery: 5,
* }}> * }}>
* {#each photos as p, i} * {#each photos as p, i}
* <div data-uid-shell={p.UID}>
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER} * {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
* <Tile {p} use:registerTile={i} /> * <Tile {p} />
* {:else}
* <div style="height: {tileHeight}px"></div>
* {/if} * {/if}
* </div>
* {/each} * {/each}
* </main> * </main>
* *
* The returned controller exposes `register`/`unregister`/`expand` so the * The `register`/`unregister` handle is kept for backwards compatibility
* host can plumb them through. * with the existing host but is now a no-op — shell-based scanning
* doesn't need per-tile enrolment.
*/ */
export interface VisibleRangeParams { export interface VisibleRangeParams {
onChange: (first: number, last: number) => void; onChange: (first: number, last: number) => void;
/** Sample 1 in N tiles. Higher numbers reduce observer overhead at /** Retained for backwards compatibility — shell-scan ignores it. */
* the cost of resolution. PhotoPrism uses 5. */
sampleEvery?: number; sampleEvery?: number;
/** Optional override: trigger zone in px around the scroll root. /** CSS margin string for legacy callers; shell-scan ignores it. */
* Defaults to 0 (only counts the visible viewport). */
rootMargin?: string; rootMargin?: string;
} }
export interface VisibleRangeController {
register(el: HTMLElement, index: number): void;
unregister(el: HTMLElement): void;
/** Force the window to include `index` (used by keyboard navigation
* before scrollIntoView so the target tile actually exists). The
* host should expand its own `first`/`last` reactive state — the
* action only tracks observed intersections. */
}
/** Public handle the host attaches to each tile to enrol it in the
* visibility observer. Returned by setup() rather than created here so
* it closes over the active observer instance. */
export interface VisibleRangeHandle { export interface VisibleRangeHandle {
register(el: HTMLElement, index: number): void; register(el: HTMLElement, index: number): void;
unregister(el: HTMLElement): void; unregister(el: HTMLElement): void;
@@ -61,112 +59,78 @@ export interface VisibleRangeHandle {
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) { export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
let current = params; let current = params;
const indexByEl = new WeakMap<Element, number>();
const visibleIndices = new Set<number>();
let observer: IntersectionObserver | null = null;
let lastFirst = -1; let lastFirst = -1;
let lastLast = -1; let lastLast = -1;
let rafId: number | null = null;
function rebuild() { function compute() {
observer?.disconnect(); rafId = null;
observer = new IntersectionObserver( const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
(entries) => { if (shells.length === 0) return;
let dirty = false; const rootRect = node.getBoundingClientRect();
for (const e of entries) { // Sweep through shells (rendered in document order = photo order)
const i = indexByEl.get(e.target); // and find the first/last whose rect crosses the viewport. Bail
if (i === undefined) continue; // out the moment we pass the bottom edge — shells past the
const wasIn = visibleIndices.has(i); // viewport can't intersect, no point measuring them.
if (e.isIntersecting && !wasIn) { let first = -1;
visibleIndices.add(i); let last = -1;
dirty = true; for (let i = 0; i < shells.length; i++) {
} else if (!e.isIntersecting && wasIn) { const r = shells[i].getBoundingClientRect();
visibleIndices.delete(i); if (r.bottom < rootRect.top) continue;
dirty = true; if (r.top > rootRect.bottom) break;
} if (first === -1) first = i;
} last = i;
if (!dirty) return;
emit();
},
{
root: node,
rootMargin: current.rootMargin ?? '0px'
}
);
}
function emit() {
if (visibleIndices.size === 0) {
// Don't emit (0, 0) — the host's last known window stays valid
// and the user is likely between layout passes. Once a sample
// tile re-enters view, the next intersection fires and we
// update for real.
return;
}
let first = Number.POSITIVE_INFINITY;
let last = Number.NEGATIVE_INFINITY;
for (const i of visibleIndices) {
if (i < first) first = i;
if (i > last) last = i;
} }
if (first === -1 || last === -1) return;
if (first === lastFirst && last === lastLast) return; if (first === lastFirst && last === lastLast) return;
lastFirst = first; lastFirst = first;
lastLast = last; lastLast = last;
current.onChange(first, last); current.onChange(first, last);
} }
rebuild(); function schedule() {
if (rafId !== null) return;
const handle: VisibleRangeHandle = { rafId = requestAnimationFrame(compute);
register(el, index) {
const every = current.sampleEvery ?? 5;
// Sample 1-in-N tiles. The host blindly calls register for
// every mounted tile; we only attach the observer to the
// sample subset to keep observer load O(n/N).
if (index % every !== 0) return;
indexByEl.set(el, index);
observer?.observe(el);
},
unregister(el) {
if (!indexByEl.has(el)) return;
const i = indexByEl.get(el);
if (i !== undefined) visibleIndices.delete(i);
indexByEl.delete(el);
observer?.unobserve(el);
emit();
} }
};
// Stash the handle on the node so the host can grab it via the // Initial measurement. Two rAFs because the first runs *during* the
// action's return. Svelte's action API only returns update/destroy, // current frame's mount cycle — shells may not have computed layout
// so we expose `getHandle` through a one-shot accessor on the host. // yet, so `getBoundingClientRect` returns zeros. Bouncing once more
// lets the browser finish layout before we measure.
requestAnimationFrame(() => requestAnimationFrame(compute));
node.addEventListener('scroll', schedule, { passive: true });
// Resize / content changes (new pages loaded, sidebar toggled,
// thumbnail size flipped) also shift the visible band — recompute.
const ro = new ResizeObserver(schedule);
ro.observe(node);
// No-op handle preserved so host code (`tileRegister`) doesn't need
// to change shape. Shell-scan reads geometry directly; per-tile
// registration isn't needed.
const handle: VisibleRangeHandle = {
register() {},
unregister() {}
};
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle; (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
return { return {
update(next: VisibleRangeParams) { update(next: VisibleRangeParams) {
const sampleChanged = (next.sampleEvery ?? 5) !== (current.sampleEvery ?? 5);
const marginChanged = next.rootMargin !== current.rootMargin;
current = next; current = next;
if (sampleChanged || marginChanged) {
// Re-observe everything under the new config. Cheapest is
// to disconnect; the host's tile-mount effects will
// re-register on next paint when they read sampleEvery.
observer?.disconnect();
indexByEl as unknown; // no-op; entries stay valid for the rebuild
visibleIndices.clear();
lastFirst = -1;
lastLast = -1;
rebuild();
}
}, },
destroy() { destroy() {
observer?.disconnect(); if (rafId !== null) cancelAnimationFrame(rafId);
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange; node.removeEventListener('scroll', schedule);
ro.disconnect();
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle })
.__visibleRange;
} }
}; };
} }
/** Read the handle the action stashed on the scroll-root node. Used by /** Read the handle the action stashed on the scroll-root node. Kept for
* the host's per-tile register/unregister calls. */ * callers that still want the (now-no-op) register/unregister surface;
* new callers can ignore this entirely. */
export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null { export function getVisibleRangeHandle(node: HTMLElement | undefined): VisibleRangeHandle | null {
if (!node) return null; if (!node) return null;
return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null; return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;

View File

@@ -142,15 +142,19 @@
left-align with the Views/Heaps rows. --> left-align with the Views/Heaps rows. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span> <span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/if} {/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge was previously a dead
zone right where the user's eye lands.
-->
<button <button
class="flex min-w-0 flex-1 items-center truncate text-left" class="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasChildren || depth > 0} class:px-1={hasChildren || depth > 0}
onclick={() => onPick(node.path)} onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)} ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path} title={node.path}
> >
<span class="truncate">{node.name}</span> <span class="truncate">{node.name}</span>
</button>
{#if counts && counts[node.path] !== undefined} {#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]} {@const n = counts[node.path]}
<span <span
@@ -161,6 +165,7 @@
{n >= 1000 ? '1000+' : n} {n >= 1000 ? '1000+' : n}
</span> </span>
{/if} {/if}
</button>
{#if !readonly} {#if !readonly}
<!-- Hover-revealed kebab. `display: none` until row hover <!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]]) (or while the menu is open via has-[[data-state=open]])

View File

@@ -424,7 +424,17 @@
{/snippet} {/snippet}
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
<nav class="flex-1 space-y-3 overflow-y-auto p-3"> <!--
Soft fade at the bottom of the scrolling nav so users with hidden
scrollbars (default on macOS) get a visual cue that there's more
content below the fold — common when the Heaps list grows long.
No JS / scroll listener; the trade-off is the last ~16px is always
slightly faded even at scroll-bottom.
-->
<nav
class="flex-1 space-y-3 overflow-y-auto p-3"
style="mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent); -webkit-mask-image: linear-gradient(to bottom, black calc(100% - 16px), transparent);"
>
<!-- Folders — top of the sidebar because the root folder is the <!-- Folders — top of the sidebar because the root folder is the
default landing view (see filters store init), making it the default landing view (see filters store init), making it the
primary navigation surface. Root-folder row + subfolder tree; primary navigation surface. Root-folder row + subfolder tree;
@@ -478,15 +488,19 @@
{rootExpanded ? '▾' : '▸'} {rootExpanded ? '▾' : '▸'}
</button> </button>
{/if} {/if}
<!--
Count badge lives INSIDE the button so the entire row (label
+ badge) is one hit target — the badge is the most visually
prominent element on the row and was previously a dead zone.
-->
<button <button
type="button" type="button"
class="flex min-w-0 flex-1 items-center truncate text-left" class="flex min-w-0 flex-1 items-center text-left"
class:px-1={hasSubfolders} class:px-1={hasSubfolders}
onclick={() => pickFolder('/')} onclick={() => pickFolder('/')}
title="Photos directly under originals/" title="Photos directly under originals/"
> >
<span class="truncate">/</span> <span class="truncate">/</span>
</button>
{#if configQuery.data} {#if configQuery.data}
<span <span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
@@ -496,6 +510,7 @@
{rootCount >= 1000 ? '1000+' : rootCount} {rootCount >= 1000 ? '1000+' : rootCount}
</span> </span>
{/if} {/if}
</button>
<!-- Root-row kebab. Only "New subfolder" applies — root itself <!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted can't be renamed or deleted, so those entries are omitted
entirely rather than greyed out. Hidden until row hover (or entirely rather than greyed out. Hidden until row hover (or
@@ -600,12 +615,11 @@
{#each heapsQuery.data ?? [] as heap (heap.UID)} {#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)} {@const active = isActive('heap', heap.UID)}
<!-- <!--
Count + kebab share the right edge: count is the Count badge lives INSIDE the button (along with the
resting state, kebab swaps in on hover (or while the title) so clicking the badge navigates to the heap —
menu is open). Moving the count out of the inner previously the badge was a dead zone. Kebab stays a
button is what lets it reach the row's right edge sibling and swaps in on hover (or while the menu is
the way Views rows do — and the inner button still open), pushing the button slightly left.
owns the navigate-on-click area.
--> -->
<li <li
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent" class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
@@ -614,13 +628,12 @@
class:hover:bg-primary={active} class:hover:bg-primary={active}
> >
<button <button
class="flex min-w-0 flex-1 items-center gap-2 truncate px-2 text-left" class="flex min-w-0 flex-1 items-center gap-2 px-2 text-left"
onclick={() => navigateTo('heap', heap.UID)} onclick={() => navigateTo('heap', heap.UID)}
ondblclick={() => onRenameHeap(heap)} ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`} title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
> >
<span class="truncate">{heap.Title}</span> <span class="truncate">{heap.Title}</span>
</button>
<span <span
class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active class="ml-auto flex h-4 min-w-[20px] flex-shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground' ? 'bg-primary-foreground/15 text-primary-foreground'
@@ -628,6 +641,7 @@
> >
{heap.PhotoCount ?? 0} {heap.PhotoCount ?? 0}
</span> </span>
</button>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block"> <div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions"> <KebabMenu label="Heap actions">
<Item <Item

View File

@@ -1,14 +1,16 @@
<!-- <!--
Flat photo grid for views that don't need infinite-scroll windowing or Flat photo grid for views that don't need infinite-scroll windowing or
month headers — the drill-in screens in /colors, /tags, /ratings. Wears month headers — the drill-in screens in /tags. Wears the same tile look
the same tile look + click semantics as the timeline so the user gets + click semantics as the timeline via the shared PhotoTile so the user
selection rings, single-click select, dblclick preview, and arrow-key gets selection rings, single-click select, dblclick preview, hover-only
nav (via `gridKeyNav` on the scroll-root) without per-route plumbing. open affordance, and arrow-key nav (via `gridKeyNav` on the scroll-root)
without per-route plumbing.
The grid carries `data-photo-grid` so gridKeyNav can measure its The grid carries `data-photo-grid` so gridKeyNav can measure its column
column count, and each tile carries `data-tile`+`data-uid` so the count, and each tile (rendered by PhotoTile) carries `data-tile`+
action's document-level click handler can pick up shift/cmd/ctrl `data-uid` so the action's document-level click handler can pick up
modifiers and route them through the shared selection helpers. shift/cmd/ctrl modifiers and route them through the shared selection
helpers.
--> -->
<script lang="ts"> <script lang="ts">
import { import {
@@ -19,9 +21,9 @@
setOrder setOrder
} from '$lib/stores/selection.svelte'; } from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte'; import { openPreview } from '$lib/stores/preview.svelte';
import { thumbUrl } 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 { type PpPhoto } from '$lib/types/photoprism';
import PhotoTile from './PhotoTile.svelte';
interface Props { interface Props {
photos: PpPhoto[]; photos: PpPhoto[];
@@ -56,51 +58,23 @@
e.preventDefault(); e.preventDefault();
openPreview(uid, order); openPreview(uid, order);
} }
function onOpenPreview(uid: string) {
openPreview(uid, order);
}
</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};">
{#each photos as photo (photo.UID)} {#each photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID} {@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<button <div class="aspect-square">
type="button" <PhotoTile
data-tile {photo}
data-uid={photo.UID} 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)}
class:scale-90={sel} onOpenPreview={() => onOpenPreview(photo.UID)}
class:ring-2={sel}
class:ring-blue-500={sel}
class:ring-offset-2={sel}
class:ring-offset-background={sel}
class:transition-[transform,box-shadow]={sel}
class:duration-300={sel}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
class="group relative aspect-square overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!sel}
class:group-hover:scale-105={!sel}
/> />
{#if sel} </div>
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/each} {/each}
</div> </div>

View File

@@ -0,0 +1,115 @@
<!--
Single photo tile — shared by the timeline (+page.svelte) and the flat
drill-in grids (PhotoGrid.svelte) so the tile chrome is single-sourced
and can't drift between the two. Owns: thumbnail, selection animation,
favorite/video badges, and the hover-only "open preview" affordance.
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
PhotoGrid can wrap it in an aspect-square cell.
Click semantics:
- plain click → onClick (caller decides; both surfaces use it for select-only)
- dblclick → onDblclick (caller usually opens preview)
- Maximize ico → onOpenPreview (single-click fallback for the
not-yet-discovered dblclick gesture)
-->
<script lang="ts">
import { Maximize2 } from 'lucide-svelte';
import { thumbUrl } from '$lib/stores/session.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism';
interface Props {
photo: PpPhoto;
selected: boolean;
onClick: (e: MouseEvent) => void;
onDblclick: (e: MouseEvent) => void;
onOpenPreview: () => void;
}
let { photo, selected, onClick, onDblclick, onOpenPreview }: Props = $props();
const hash = $derived(photo.Hash ?? primaryFile(photo).Hash);
</script>
<!--
Wrapper div carries `group` so the Maximize affordance can hover-reveal
off the same hover region as the image-scale hover. The Maximize button
sits as a sibling of the main tile button (not nested) — nesting <button>
in <button> is invalid HTML.
-->
<div class="group relative h-full w-full">
<!--
Selection animation: scale to 90% + blue ring + blue tint overlay,
driven by a springy `cubic-bezier(0.34, 1.3, 0.64, 1)` over 300ms.
The overshoot is intentionally modest — on a *shrink* a larger
overshoot reads as the tile dipping below 90% before settling, which
is disorienting when many tiles transition at once (range-select).
The transition class is only applied while selected so deselect
snaps back instantly instead of crawling.
The keyboard-focused tile gets the same treatment as a selected one,
so the arrow-key cursor reads as a "selection of one".
-->
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={onClick}
ondblclick={onDblclick}
title="Click to select · Double-click to open"
class:scale-90={selected}
class:ring-2={selected}
class:ring-blue-500={selected}
class:ring-offset-2={selected}
class:ring-offset-background={selected}
class:transition-[transform,box-shadow]={selected}
class:duration-300={selected}
class:ease-[cubic-bezier(0.34,1.3,0.64,1)]={selected}
class="relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!selected}
class:group-hover:scale-105={!selected}
/>
{#if selected}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</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>

View File

@@ -0,0 +1,31 @@
<!--
Loading skeleton for photo grids. Renders a small fixed batch of
pulsing tiles in the same column track as the real grid so the layout
doesn't shift when data arrives. Used on initial load only — the
infinite-scroll "Loading more…" sentinel at the bottom of the timeline
is a different signal and stays as text.
-->
<script lang="ts">
import { view } from '$lib/stores/view.svelte';
interface Props {
/** Number of skeleton tiles. Default ~first-viewport-worth. */
count?: number;
/** Override the column template (matches the real grid's CSS). */
columns?: string;
}
let { count = 20, columns }: Props = $props();
const tracks = $derived(
columns ?? `repeat(auto-fill, minmax(${view.thumbnailSize}px, 1fr))`
);
</script>
<div
aria-hidden="true"
class="grid gap-2"
style="grid-template-columns: {tracks};"
>
{#each Array.from({ length: count }) as _, i (i)}
<div class="aspect-square animate-pulse rounded-md bg-secondary"></div>
{/each}
</div>

View File

@@ -19,7 +19,7 @@
setSearch, setSearch,
setSection setSection
} from '$lib/stores/filters.svelte'; } from '$lib/stores/filters.svelte';
import { isAuthenticated, thumbUrl } from '$lib/stores/session.svelte'; import { isAuthenticated } from '$lib/stores/session.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { import {
isSelected, isSelected,
@@ -41,16 +41,14 @@
import { resizable } from '$lib/actions/resizable'; import { resizable } from '$lib/actions/resizable';
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav'; import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav';
import { nearBottom } from '$lib/actions/nearBottom'; import { nearBottom } from '$lib/actions/nearBottom';
import { import { visibleRange, getVisibleRangeHandle } from '$lib/actions/visibleRange';
visibleRange,
getVisibleRangeHandle,
type VisibleRangeHandle
} from '$lib/actions/visibleRange';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte'; import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
import PhotoTile from '$lib/components/timeline/PhotoTile.svelte';
import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte'; import RightSidebar from '$lib/components/sidebar/RightSidebar.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import Toolbar from '$lib/components/layout/Toolbar.svelte';
import { isVideo, primaryFile, type PpPhoto } from '$lib/types/photoprism'; import { type PpPhoto } from '$lib/types/photoprism';
// ── URL ↔ filter store sync ────────────────────────────────────────────── // ── URL ↔ filter store sync ──────────────────────────────────────────────
// On nav (back/forward, deep link), reflect the URL into the store. // On nav (back/forward, deep link), reflect the URL into the store.
@@ -299,32 +297,38 @@
}); });
}); });
// Per-tile register handle exposed by the visibleRange action. The
// host pulls it off the scroll-root node once after mount.
let visHandle: VisibleRangeHandle | null = $state(null);
$effect(() => {
if (scrollRoot) visHandle = getVisibleRangeHandle(scrollRoot);
});
/** `use:tileRegister={i}` — stable-identity Svelte action that hooks /** `use:tileRegister={i}` — stable-identity Svelte action that hooks
* the tile shell into the visibility observer when it mounts and * the tile shell into the visibility observer when it mounts and
* un-hooks it when it unmounts (or when `i` changes because the * un-hooks it when it unmounts (or when `i` changes because the
* photos array shifted). Using a `use:` action (not `{@attach}`) * photos array shifted). Using a `use:` action (not `{@attach}`)
* keeps the registration stable across re-renders; `{@attach}` would * keeps the registration stable across re-renders; `{@attach}` would
* rebuild on every render because the inline arrow has fresh * rebuild on every render because the inline arrow has fresh
* identity each time. */ * identity each time.
*
* Resolve the handle lazily off the scroll root each call instead
* of stashing it in a `$state` populated by `$effect`. The effect
* runs *after* the DOM update flush, but on a remount with cached
* photo data the tiles render in the same pass as the scroll root,
* so a `$state`-backed handle is still `null` when tileRegister
* first fires — and the tile never enrols in the observer. Symptom
* was a blank grid on return-trip to the timeline (the bug this
* comment exists for). The visibleRange action sets
* `__visibleRange` on the scroll-root node during its setup phase,
* which runs before any child action, so a synchronous lookup
* always succeeds. */
function tileRegister(node: HTMLElement, index: number) { function tileRegister(node: HTMLElement, index: number) {
let current = index; let current = index;
visHandle?.register(node, current); const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
handle?.register(node, current);
return { return {
update(next: number) { update(next: number) {
if (next === current) return; if (next === current) return;
visHandle?.unregister(node); handle?.unregister(node);
current = next; current = next;
visHandle?.register(node, current); handle?.register(node, current);
}, },
destroy() { destroy() {
visHandle?.unregister(node); handle?.unregister(node);
} }
}; };
} }
@@ -633,6 +637,13 @@
openPreview(uid, photos.map((p) => p.UID)); openPreview(uid, photos.map((p) => p.UID));
} }
// Single-click fallback for the dblclick preview gesture. Wired to the
// hover-only Maximize icon in PhotoTile so users who haven't discovered
// dblclick can still get to the preview.
function onTileOpenPreview(uid: string) {
openPreview(uid, photos.map((p) => p.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
@@ -653,6 +664,18 @@
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground"> <span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
{sectionLabel} {sectionLabel}
</span> </span>
<!--
Persistent gesture hint. The new click-semantics (single = select,
double = open) aren't intuitive for users arriving from Google
Photos / Apple Photos, so surface them in plain text where the eye
can see them without hover. Hidden below sm: so the search bar
still gets room on narrow viewports.
-->
{#if photos.length > 0}
<span class="hidden text-[10px] text-muted-foreground lg:inline">
click select · ⇧ range · ⌘ toggle · dblclick open
</span>
{/if}
{#if filters.section === 'archive' && photos.length > 0} {#if filters.section === 'archive' && photos.length > 0}
<button <button
type="button" type="button"
@@ -739,7 +762,7 @@
> >
<div class="p-6 pb-24"> <div class="p-6 pb-24">
{#if photosQuery.isPending} {#if photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p> <SkeletonGrid />
{:else if photosQuery.isError} {:else if photosQuery.isError}
<p class="text-sm text-destructive"> <p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error Failed to load photos: {photosQuery.error instanceof Error
@@ -801,61 +824,14 @@
use:tileRegister={i} use:tileRegister={i}
> >
{#if inWindow} {#if inWindow}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID} {@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<!-- Selection animation ported from mule-image's PhotoThumbnail: <PhotoTile
scale to 90% + blue ring with offset + blue tint overlay, all {photo}
driven by a springy `cubic-bezier(0.34, 1.56, 0.64, 1)` over selected={sel}
300ms. Crucially, the transition class is ONLY applied when onClick={(e) => onTileClick(e, photo.UID)}
selected — dropping it on deselect snaps the photo back to onDblclick={(e) => onTileDblclick(e, photo.UID)}
full size instantly instead of crawling back. onOpenPreview={() => onTileOpenPreview(photo.UID)}
The keyboard-focused photo gets the same treatment, so the
arrow-key cursor reads as a "selection of one" (matches
mule-image, where focused == singular selection). -->
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={(e) => onTileClick(e, photo.UID)}
ondblclick={(e) => onTileDblclick(e, photo.UID)}
class:scale-90={sel}
class:ring-2={sel}
class:ring-blue-500={sel}
class:ring-offset-2={sel}
class:ring-offset-background={sel}
class:transition-[transform,box-shadow]={sel}
class:duration-300={sel}
class:ease-[cubic-bezier(0.34,1.56,0.64,1)]={sel}
class="group relative h-full w-full overflow-hidden rounded-md border border-border bg-secondary p-0 outline-none focus:outline-none"
>
<img
src={thumbUrl(hash, 'tile_500')}
alt={photo.OriginalName ?? photo.FileName ?? photo.Name ?? 'Photo'}
loading="lazy"
class="h-full w-full object-cover"
class:transition={!sel}
class:group-hover:scale-105={!sel}
/> />
<!-- Blue tint overlay (mule-image's primary selection
signal): pointer-events-none so clicks still hit the
button beneath. Rendered after the image so it composites
on top; before the badges so a star/heart still reads. -->
{#if sel}
<div class="pointer-events-none absolute inset-0 bg-blue-500/40"></div>
{/if}
{#if photo.Favorite}
<span
class="absolute right-1.5 top-1.5 rounded bg-background/80 px-1 text-xs text-red-500"
></span
>
{/if}
{#if isVideo(photo)}
<span
class="absolute left-1.5 top-1.5 rounded bg-background/80 px-1 text-[10px] font-medium text-foreground"
>VIDEO</span
>
{/if}
</button>
{/if} {/if}
</div> </div>
{/if} {/if}

View File

@@ -17,6 +17,7 @@
import { gridKeyNav } from '$lib/actions/gridKeyNav'; import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte'; import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte'; import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
import SkeletonGrid from '$lib/components/timeline/SkeletonGrid.svelte';
import Toolbar from '$lib/components/layout/Toolbar.svelte'; import Toolbar from '$lib/components/layout/Toolbar.svelte';
// Tag-flavoured surfaces, all under one route so the user can swap // Tag-flavoured surfaces, all under one route so the user can swap
@@ -329,7 +330,7 @@
labels/keywords while ratings/colors resolve locally from the labels/keywords while ratings/colors resolve locally from the
marks pool already in cache. --> marks pool already in cache. -->
{#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending} {#if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p> <SkeletonGrid />
{:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError} {:else if activeTab !== 'ratings' && activeTab !== 'colors' && drillPhotosQuery.isError}
<p class="text-sm text-destructive">Failed to load photos.</p> <p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0} {:else if drillPhotos.length === 0}
@@ -339,7 +340,7 @@
{/if} {/if}
{:else if activeTab === 'labels'} {:else if activeTab === 'labels'}
{#if labelsQuery.isPending} {#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p> <SkeletonGrid />
{:else if labelsQuery.isError} {:else if labelsQuery.isError}
<p class="text-sm text-destructive">Failed to load labels.</p> <p class="text-sm text-destructive">Failed to load labels.</p>
{:else if labelsSorted.length === 0} {:else if labelsSorted.length === 0}
@@ -379,13 +380,7 @@
{/if} {/if}
{:else if activeTab === 'keywords'} {:else if activeTab === 'keywords'}
{#if keywordsQuery.isPending} {#if keywordsQuery.isPending}
<p class="text-sm text-muted-foreground"> <SkeletonGrid />
Loading keywords…
<br />
<span class="text-[11px]">
This walks every photo's metadata once — the result is cached after the first load.
</span>
</p>
{:else if keywordsQuery.isError} {:else if keywordsQuery.isError}
<p class="text-sm text-destructive">Failed to load keywords.</p> <p class="text-sm text-destructive">Failed to load keywords.</p>
{:else if keywordsSorted.length === 0} {:else if keywordsSorted.length === 0}
@@ -422,7 +417,7 @@
{/if} {/if}
{:else if activeTab === 'ratings'} {:else if activeTab === 'ratings'}
{#if marksQuery.isPending || marksPoolQuery.isPending} {#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading ratings…</p> <SkeletonGrid count={5} />
{:else if marksQuery.isError || marksPoolQuery.isError} {:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load ratings.</p> <p class="text-sm text-destructive">Failed to load ratings.</p>
{:else if ratingGroups.length === 0} {:else if ratingGroups.length === 0}
@@ -463,7 +458,7 @@
{/if} {/if}
{:else if activeTab === 'colors'} {:else if activeTab === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending} {#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading colors…</p> <SkeletonGrid count={4} />
{:else if marksQuery.isError || marksPoolQuery.isError} {:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load colors.</p> <p class="text-sm text-destructive">Failed to load colors.</p>
{:else if colorGroups.length === 0} {:else if colorGroups.length === 0}