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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user