fix(timeline): scroll-driven visibility scan, factor PhotoTile + SkeletonGrid

- visibleRange action rewritten to scan [data-uid-shell] divs on each
  rAF-throttled scroll instead of attaching an IntersectionObserver to
  sample tiles. The observer approach broke on return from /inbox: with
  cached photo data, shells mounted in the same Svelte pass as the
  scroll root and tileRegister fired before any __visibleRange stash
  was in place, so registrations dropped silently. Fast scrolling could
  also strand the observer in a dead zone when every sample tile left
  the viewport before the next was mounted. Shells are always rendered,
  so a DOM scan always finds a true first/last.
- Extract PhotoTile + SkeletonGrid so the timeline and the drill-in
  PhotoGrid share one tile chrome (selection animation, badges,
  hover-only "open preview" affordance).
- FolderTree count badge moves inside the row's button so the badge
  area becomes part of the click target instead of a dead zone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 21:33:03 +02:00
parent d35de8a2a9
commit 84e433ff63
8 changed files with 362 additions and 288 deletions

View File

@@ -1,59 +1,57 @@
/**
* Tracks the first/last visible tile indices inside the attached scroll
* container so the host can render only `[first - BUFFER, last + BUFFER]`
* and leave the rest unmounted. Mirrors PhotoPrism's pattern in
* `frontend/src/component/photo/view/cards.vue`:
* and leave the rest unmounted.
*
* - One IntersectionObserver on the scroll root.
* - Observes every Nth tile (sample, not every tile, to keep observer
* overhead flat as the order grows).
* - The host registers nodes as they mount via `register(el, index)`
* and unregisters via `unregister(el)`.
* - The action calls `onChange(first, last)` whenever the visible
* window updates.
* Implementation: rAF-throttled scroll listener that scans the shell
* elements (every photo renders a `[data-uid-shell]` div regardless of
* the windowing window) and reports the first/last shell whose bounding
* rect intersects the scroll root.
*
* Why not an IntersectionObserver? Two real-world breakages:
*
* 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:
*
* const range = $state({ first: 0, last: 0 });
* <main use:visibleRange={{
* onChange: (f, l) => { range.first = f; range.last = l; },
* sampleEvery: 5,
* onChange: (f, l) => { range.first = f; range.last = l; }
* }}>
* {#each photos as p, i}
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
* <Tile {p} use:registerTile={i} />
* {:else}
* <div style="height: {tileHeight}px"></div>
* {/if}
* <div data-uid-shell={p.UID}>
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
* <Tile {p} />
* {/if}
* </div>
* {/each}
* </main>
*
* The returned controller exposes `register`/`unregister`/`expand` so the
* host can plumb them through.
* The `register`/`unregister` handle is kept for backwards compatibility
* with the existing host but is now a no-op — shell-based scanning
* doesn't need per-tile enrolment.
*/
export interface VisibleRangeParams {
onChange: (first: number, last: number) => void;
/** Sample 1 in N tiles. Higher numbers reduce observer overhead at
* the cost of resolution. PhotoPrism uses 5. */
/** Retained for backwards compatibility — shell-scan ignores it. */
sampleEvery?: number;
/** Optional override: trigger zone in px around the scroll root.
* Defaults to 0 (only counts the visible viewport). */
/** CSS margin string for legacy callers; shell-scan ignores it. */
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 {
register(el: HTMLElement, index: number): void;
unregister(el: HTMLElement): void;
@@ -61,112 +59,78 @@ export interface VisibleRangeHandle {
export function visibleRange(node: HTMLElement, params: VisibleRangeParams) {
let current = params;
const indexByEl = new WeakMap<Element, number>();
const visibleIndices = new Set<number>();
let observer: IntersectionObserver | null = null;
let lastFirst = -1;
let lastLast = -1;
let rafId: number | null = null;
function rebuild() {
observer?.disconnect();
observer = new IntersectionObserver(
(entries) => {
let dirty = false;
for (const e of entries) {
const i = indexByEl.get(e.target);
if (i === undefined) continue;
const wasIn = visibleIndices.has(i);
if (e.isIntersecting && !wasIn) {
visibleIndices.add(i);
dirty = true;
} else if (!e.isIntersecting && wasIn) {
visibleIndices.delete(i);
dirty = true;
}
}
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;
function compute() {
rafId = null;
const shells = node.querySelectorAll<HTMLElement>('[data-uid-shell]');
if (shells.length === 0) return;
const rootRect = node.getBoundingClientRect();
// Sweep through shells (rendered in document order = photo order)
// and find the first/last whose rect crosses the viewport. Bail
// out the moment we pass the bottom edge — shells past the
// viewport can't intersect, no point measuring them.
let first = -1;
let last = -1;
for (let i = 0; i < shells.length; i++) {
const r = shells[i].getBoundingClientRect();
if (r.bottom < rootRect.top) continue;
if (r.top > rootRect.bottom) break;
if (first === -1) first = i;
last = i;
}
if (first === -1 || last === -1) return;
if (first === lastFirst && last === lastLast) return;
lastFirst = first;
lastLast = last;
current.onChange(first, last);
}
rebuild();
function schedule() {
if (rafId !== null) return;
rafId = requestAnimationFrame(compute);
}
// Initial measurement. Two rAFs because the first runs *during* the
// current frame's mount cycle — shells may not have computed layout
// 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(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();
}
register() {},
unregister() {}
};
// Stash the handle on the node so the host can grab it via the
// action's return. Svelte's action API only returns update/destroy,
// so we expose `getHandle` through a one-shot accessor on the host.
(node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange = handle;
return {
update(next: VisibleRangeParams) {
const sampleChanged = (next.sampleEvery ?? 5) !== (current.sampleEvery ?? 5);
const marginChanged = next.rootMargin !== current.rootMargin;
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() {
observer?.disconnect();
delete (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange;
if (rafId !== null) cancelAnimationFrame(rafId);
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
* the host's per-tile register/unregister calls. */
/** Read the handle the action stashed on the scroll-root node. Kept for
* 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 {
if (!node) return null;
return (node as HTMLElement & { __visibleRange?: VisibleRangeHandle }).__visibleRange ?? null;

View File

@@ -142,25 +142,30 @@
left-align with the Views/Heaps rows. -->
<span class="inline-block h-[18px] w-4" aria-hidden="true"></span>
{/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
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}
onclick={() => onPick(node.path)}
ondblclick={readonly ? undefined : () => onRename?.(node.path)}
title={node.path}
>
<span class="truncate">{node.name}</span>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
</button>
{#if counts && counts[node.path] !== undefined}
{@const n = counts[node.path]}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {active
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{n >= 1000 ? '1000+' : n}
</span>
{/if}
{#if !readonly}
<!-- Hover-revealed kebab. `display: none` until row hover
(or while the menu is open via has-[[data-state=open]])

View File

@@ -424,7 +424,17 @@
{/snippet}
<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
default landing view (see filters store init), making it the
primary navigation surface. Root-folder row + subfolder tree;
@@ -478,24 +488,29 @@
{rootExpanded ? '▾' : '▸'}
</button>
{/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
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}
onclick={() => pickFolder('/')}
title="Photos directly under originals/"
>
<span class="truncate">/</span>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
</button>
{#if configQuery.data}
<span
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{rootCount >= 1000 ? '1000+' : rootCount}
</span>
{/if}
<!-- Root-row kebab. Only "New subfolder" applies — root itself
can't be renamed or deleted, so those entries are omitted
entirely rather than greyed out. Hidden until row hover (or
@@ -600,12 +615,11 @@
{#each heapsQuery.data ?? [] as heap (heap.UID)}
{@const active = isActive('heap', heap.UID)}
<!--
Count + kebab share the right edge: count is the
resting state, kebab swaps in on hover (or while the
menu is open). Moving the count out of the inner
button is what lets it reach the row's right edge
the way Views rows do — and the inner button still
owns the navigate-on-click area.
Count badge lives INSIDE the button (along with the
title) so clicking the badge navigates to the heap —
previously the badge was a dead zone. Kebab stays a
sibling and swaps in on hover (or while the menu is
open), pushing the button slightly left.
-->
<li
class="group flex h-[24px] items-center rounded pr-2 text-[12px] leading-tight hover:bg-accent"
@@ -614,20 +628,20 @@
class:hover:bg-primary={active}
>
<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)}
ondblclick={() => onRenameHeap(heap)}
title={`${heap.Title} (${heap.PhotoCount ?? 0})`}
>
<span class="truncate">{heap.Title}</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
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
</button>
<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
? 'bg-primary-foreground/15 text-primary-foreground'
: 'bg-secondary text-muted-foreground'}"
>
{heap.PhotoCount ?? 0}
</span>
<div class="ml-1 hidden group-hover:block has-[[data-state=open]]:block">
<KebabMenu label="Heap actions">
<Item

View File

@@ -1,14 +1,16 @@
<!--
Flat photo grid for views that don't need infinite-scroll windowing or
month headers — the drill-in screens in /colors, /tags, /ratings. Wears
the same tile look + click semantics as the timeline so the user gets
selection rings, single-click select, dblclick preview, and arrow-key
nav (via `gridKeyNav` on the scroll-root) without per-route plumbing.
month headers — the drill-in screens in /tags. Wears the same tile look
+ click semantics as the timeline via the shared PhotoTile so the user
gets selection rings, single-click select, dblclick preview, hover-only
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
column count, and each tile carries `data-tile`+`data-uid` so the
action's document-level click handler can pick up shift/cmd/ctrl
modifiers and route them through the shared selection helpers.
The grid carries `data-photo-grid` so gridKeyNav can measure its column
count, and each tile (rendered by PhotoTile) carries `data-tile`+
`data-uid` so the action's document-level click handler can pick up
shift/cmd/ctrl modifiers and route them through the shared selection
helpers.
-->
<script lang="ts">
import {
@@ -19,9 +21,9 @@
setOrder
} from '$lib/stores/selection.svelte';
import { openPreview } from '$lib/stores/preview.svelte';
import { thumbUrl } from '$lib/stores/session.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 {
photos: PpPhoto[];
@@ -56,51 +58,23 @@
e.preventDefault();
openPreview(uid, order);
}
function onOpenPreview(uid: string) {
openPreview(uid, order);
}
</script>
<div data-photo-grid class="grid gap-2" style="grid-template-columns: {tracks};">
{#each photos as photo (photo.UID)}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<button
type="button"
data-tile
data-uid={photo.UID}
onclick={(e) => onClick(e, photo.UID)}
ondblclick={(e) => onDblclick(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 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}
<div class="aspect-square">
<PhotoTile
{photo}
selected={sel}
onClick={(e) => onClick(e, photo.UID)}
onDblclick={(e) => onDblclick(e, photo.UID)}
onOpenPreview={() => onOpenPreview(photo.UID)}
/>
{#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>
</div>
{/each}
</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,
setSection
} 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 {
isSelected,
@@ -41,16 +41,14 @@
import { resizable } from '$lib/actions/resizable';
import { gridKeyNav, type ArrowKey } from '$lib/actions/gridKeyNav';
import { nearBottom } from '$lib/actions/nearBottom';
import {
visibleRange,
getVisibleRangeHandle,
type VisibleRangeHandle
} from '$lib/actions/visibleRange';
import { visibleRange, getVisibleRangeHandle } from '$lib/actions/visibleRange';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.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 SkeletonGrid from '$lib/components/timeline/SkeletonGrid.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 ──────────────────────────────────────────────
// 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
* the tile shell into the visibility observer when it mounts and
* un-hooks it when it unmounts (or when `i` changes because the
* photos array shifted). Using a `use:` action (not `{@attach}`)
* keeps the registration stable across re-renders; `{@attach}` would
* 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) {
let current = index;
visHandle?.register(node, current);
const handle = scrollRoot ? getVisibleRangeHandle(scrollRoot) : null;
handle?.register(node, current);
return {
update(next: number) {
if (next === current) return;
visHandle?.unregister(node);
handle?.unregister(node);
current = next;
visHandle?.register(node, current);
handle?.register(node, current);
},
destroy() {
visHandle?.unregister(node);
handle?.unregister(node);
}
};
}
@@ -633,6 +637,13 @@
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
// the <main> element below; the sentinel's `root` references this so
// 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">
{sectionLabel}
</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}
<button
type="button"
@@ -739,7 +762,7 @@
>
<div class="p-6 pb-24">
{#if photosQuery.isPending}
<p class="text-sm text-muted-foreground">Loading photos…</p>
<SkeletonGrid />
{:else if photosQuery.isError}
<p class="text-sm text-destructive">
Failed to load photos: {photosQuery.error instanceof Error
@@ -801,61 +824,14 @@
use:tileRegister={i}
>
{#if inWindow}
{@const hash = photo.Hash ?? primaryFile(photo).Hash}
{@const sel = isSelected(photo.UID) || selection.focused === photo.UID}
<!-- Selection animation ported from mule-image's PhotoThumbnail:
scale to 90% + blue ring with offset + blue tint overlay, all
driven by a springy `cubic-bezier(0.34, 1.56, 0.64, 1)` over
300ms. Crucially, the transition class is ONLY applied when
selected — dropping it on deselect snaps the photo back to
full size instantly instead of crawling back.
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>
<PhotoTile
{photo}
selected={sel}
onClick={(e) => onTileClick(e, photo.UID)}
onDblclick={(e) => onTileDblclick(e, photo.UID)}
onOpenPreview={() => onTileOpenPreview(photo.UID)}
/>
{/if}
</div>
{/if}

View File

@@ -17,6 +17,7 @@
import { gridKeyNav } from '$lib/actions/gridKeyNav';
import BulkActionBar from '$lib/components/timeline/BulkActionBar.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';
// 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
marks pool already in cache. -->
{#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}
<p class="text-sm text-destructive">Failed to load photos.</p>
{:else if drillPhotos.length === 0}
@@ -339,7 +340,7 @@
{/if}
{:else if activeTab === 'labels'}
{#if labelsQuery.isPending}
<p class="text-sm text-muted-foreground">Loading labels…</p>
<SkeletonGrid />
{:else if labelsQuery.isError}
<p class="text-sm text-destructive">Failed to load labels.</p>
{:else if labelsSorted.length === 0}
@@ -379,13 +380,7 @@
{/if}
{:else if activeTab === 'keywords'}
{#if keywordsQuery.isPending}
<p class="text-sm text-muted-foreground">
Loading keywords…
<br />
<span class="text-[11px]">
This walks every photo's metadata once — the result is cached after the first load.
</span>
</p>
<SkeletonGrid />
{:else if keywordsQuery.isError}
<p class="text-sm text-destructive">Failed to load keywords.</p>
{:else if keywordsSorted.length === 0}
@@ -422,7 +417,7 @@
{/if}
{:else if activeTab === 'ratings'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading ratings…</p>
<SkeletonGrid count={5} />
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load ratings.</p>
{:else if ratingGroups.length === 0}
@@ -463,7 +458,7 @@
{/if}
{:else if activeTab === 'colors'}
{#if marksQuery.isPending || marksPoolQuery.isPending}
<p class="text-sm text-muted-foreground">Loading colors…</p>
<SkeletonGrid count={4} />
{:else if marksQuery.isError || marksPoolQuery.isError}
<p class="text-sm text-destructive">Failed to load colors.</p>
{:else if colorGroups.length === 0}