Files
jellytau/src/lib/utils/scrollContainer.ts
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

91 lines
3.3 KiB
TypeScript

/**
* Wires a persistent scroll container to the per-route scroll memory.
*
* The decision logic is pure and lives in `scrollRestore.ts`; this is the thin
* DOM/SvelteKit half. Call it once at component init (SvelteKit's navigation
* hooks must be registered during initialisation, not from `onMount`), passing
* a getter for the element — the element itself is bound later, so a getter is
* the only way to hand it over from the top of `<script>`.
*
* let scroller: HTMLElement | undefined = $state();
* useScrollRestore(() => scroller, "library");
* …
* <div bind:this={scroller} class="flex-1 overflow-y-auto">
*
* Memories are keyed by container id and held at module scope, not per call.
* Two containers must never share one (the root, home and library scrollers
* hold different content for the same URL, so a shared map would restore one
* into another) — but a container that *remounts* has to find its offsets again
* when it comes back. The home scroller is destroyed on every navigation away,
* so a memory owned by the component instance would be empty on return and Back
* could only ever land at the top.
*
* TRACES: UR-072 | DR-156
*/
import { beforeNavigate, afterNavigate } from "$app/navigation";
import { tick } from "svelte";
import { ScrollMemory, classifyNavigation, scrollKey } from "./scrollRestore";
/** Container id → its offsets. Outlives the components that mount them. */
const memories = new Map<string, ScrollMemory>();
function memoryFor(containerId: string): ScrollMemory {
let memory = memories.get(containerId);
if (!memory) {
memory = new ScrollMemory();
memories.set(containerId, memory);
}
return memory;
}
/** Forget every container's offsets. For sign-out and tests. */
export function clearScrollMemories(): void {
memories.clear();
}
export function useScrollRestore(
getElement: () => HTMLElement | null | undefined,
containerId: string,
): void {
const memory = memoryFor(containerId);
// Record where we were before the route changes. `nav.from` is absent on the
// very first navigation, which is exactly when there is nothing to save.
beforeNavigate((nav) => {
const element = getElement();
if (!element || !nav.from) return;
memory.save(scrollKey(nav.from.url), element.scrollTop);
});
afterNavigate(async (nav) => {
const target = nav.to;
if (!target) return;
const action = memory.decide(scrollKey(target.url), classifyNavigation(nav));
if (action.kind === "none") return;
const top = action.kind === "restore" ? action.top : 0;
// Wait for the new route's markup to be in the DOM before moving the
// scroller — setting scrollTop past the current content height is clamped,
// and a reset applied too early is undone by the incoming render.
await tick();
const element = getElement();
if (!element) return;
element.scrollTop = top;
// A restore often targets content that is still loading (a library grid
// fetches after mount), so the offset would clamp to a short page. Re-apply
// on the next frame, once, which is enough for the common case without
// fighting a user who has already started scrolling.
if (action.kind === "restore" && top > 0) {
requestAnimationFrame(() => {
const el = getElement();
if (el && el.scrollTop < top) el.scrollTop = top;
});
}
});
}