diff --git a/web/src/lib/actions/visibleRange.ts b/web/src/lib/actions/visibleRange.ts index b080391..81d27ff 100644 --- a/web/src/lib/actions/visibleRange.ts +++ b/web/src/lib/actions/visibleRange.ts @@ -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 }); *
{ 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} - * - * {:else} - *
- * {/if} + *
+ * {#if i >= range.first - BUFFER && i <= range.last + BUFFER} + * + * {/if} + *
* {/each} *
* - * 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(); - const visibleIndices = new Set(); - 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('[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; diff --git a/web/src/lib/components/layout/FolderTree.svelte b/web/src/lib/components/layout/FolderTree.svelte index 3d087e4..e4bf381 100644 --- a/web/src/lib/components/layout/FolderTree.svelte +++ b/web/src/lib/components/layout/FolderTree.svelte @@ -142,25 +142,30 @@ left-align with the Views/Heaps rows. --> {/if} + - {#if counts && counts[node.path] !== undefined} - {@const n = counts[node.path]} - - {n >= 1000 ? '1000+' : n} - - {/if} {#if !readonly} +