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}
+ *
* {/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}
+