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:
@@ -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}
|
||||
* <div data-uid-shell={p.UID}>
|
||||
* {#if i >= range.first - BUFFER && i <= range.last + BUFFER}
|
||||
* <Tile {p} use:registerTile={i} />
|
||||
* {:else}
|
||||
* <div style="height: {tileHeight}px"></div>
|
||||
* <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();
|
||||
|
||||
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();
|
||||
function schedule() {
|
||||
if (rafId !== null) return;
|
||||
rafId = requestAnimationFrame(compute);
|
||||
}
|
||||
};
|
||||
|
||||
// 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.
|
||||
// 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() {},
|
||||
unregister() {}
|
||||
};
|
||||
(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;
|
||||
|
||||
@@ -142,15 +142,19 @@
|
||||
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>
|
||||
</button>
|
||||
{#if counts && counts[node.path] !== undefined}
|
||||
{@const n = counts[node.path]}
|
||||
<span
|
||||
@@ -161,6 +165,7 @@
|
||||
{n >= 1000 ? '1000+' : n}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#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,15 +488,19 @@
|
||||
{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>
|
||||
</button>
|
||||
{#if configQuery.data}
|
||||
<span
|
||||
class="ml-auto shrink-0 rounded px-1 text-[10px] tabular-nums {rootActive
|
||||
@@ -496,6 +510,7 @@
|
||||
{rootCount >= 1000 ? '1000+' : rootCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<!-- 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,13 +628,12 @@
|
||||
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>
|
||||
</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'
|
||||
@@ -628,6 +641,7 @@
|
||||
>
|
||||
{heap.PhotoCount ?? 0}
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
@@ -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}
|
||||
<PhotoTile
|
||||
{photo}
|
||||
selected={sel}
|
||||
onClick={(e) => onTileClick(e, photo.UID)}
|
||||
onDblclick={(e) => onTileDblclick(e, photo.UID)}
|
||||
onOpenPreview={() => onTileOpenPreview(photo.UID)}
|
||||
/>
|
||||
<!-- 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}
|
||||
</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