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:
@@ -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]])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
115
web/src/lib/components/timeline/PhotoTile.svelte
Normal file
115
web/src/lib/components/timeline/PhotoTile.svelte
Normal 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>
|
||||
31
web/src/lib/components/timeline/SkeletonGrid.svelte
Normal file
31
web/src/lib/components/timeline/SkeletonGrid.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user