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

@@ -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>