Files
vfxreview/hooks/use-scroll-memory.ts
twotalesanimation 33bc6a935c
Deploy / deploy (push) Successful in 3m31s
feat(ui): shot status, projects and client portal keep their place
Coming back from a shot left the shot status page at the top with its
sections reset. The scroll restore saved window.scrollY, but dashboard pages
scroll the layout's <main>, so it always saved 0; the Projects page had the
same bug. Only episode expansion was remembered — scene and group sections
came back fully collapsed.

A shared useScrollMemory hook saves the real scroll container's position as
the user scrolls (so any way of leaving keeps it) and restores it once the
content that sets the page height is back. It ignores the scroll the shared
<main> fires when the next page replaces this one, which otherwise
overwrote the saved position.

Shot status now remembers episode, scene and group expansion per project.
The client portal already kept its filters and collapsed episodes but
restored scroll before they were applied; it now uses the hook too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 05:52:13 +02:00

92 lines
3.4 KiB
TypeScript

"use client";
import { useEffect, useRef } from "react";
/**
* Remembers a page's scroll position for the browser session and puts it back
* when the page is shown again — e.g. after opening a shot and coming back.
*
* Dashboard pages do not scroll the window: the layout scrolls its <main>
* (overflow-auto), so window.scrollY is always 0. This finds the nearest
* scrolling ancestor of `anchorRef` and saves that instead, falling back to
* the document for pages that do scroll the window (the client portal).
*
* The position is saved as the user scrolls, not on a particular link's click,
* so any way of leaving the page (another link, the sidebar, a browser
* back/forward) keeps it.
*
* Restores once per `key`, and only when `ready` is true: the caller holds it
* false until the content that sets the page height (expanded sections) is
* back, or the saved offset would be clamped to a shorter page.
*/
export function useScrollMemory(
key: string | null,
anchorRef: React.RefObject<HTMLElement | null>,
ready: boolean
) {
const restoredKey = useRef<string | null>(null);
useEffect(() => {
if (!key || !ready || !anchorRef.current) return;
const anchor = anchorRef.current;
const scroller = findScroller(anchor);
const storageKey = `scroll:${key}`;
if (restoredKey.current !== key) {
restoredKey.current = key;
let saved: number | null = null;
try {
const raw = sessionStorage.getItem(storageKey);
saved = raw ? parseInt(raw, 10) : null;
} catch {}
if (saved && saved > 0) {
// Thumbnails and late rows can still grow the page for a moment, so
// keep trying for a short while until the offset actually fits.
const target = saved;
let frames = 0;
const tryRestore = () => {
scroller.scrollTop = target;
if (Math.abs(scroller.scrollTop - target) > 1 && frames++ < 60) {
requestAnimationFrame(tryRestore);
}
};
requestAnimationFrame(tryRestore);
}
}
let pending = false;
let disposed = false;
const onScroll = () => {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
// The layout's scroller outlives this page. When the next page
// replaces this one, the scroller clamps to the new, shorter content
// and fires a scroll before this listener is removed; recording it
// would overwrite the real position. Once this page's content is out
// of the document, scrolls are not ours.
if (disposed || !anchor.isConnected) return;
try {
sessionStorage.setItem(storageKey, String(Math.round(scroller.scrollTop)));
} catch {}
});
};
// When the document itself scrolls, the event fires on window, not on <html>.
const target: HTMLElement | Window = scroller === document.scrollingElement ? window : scroller;
target.addEventListener("scroll", onScroll, { passive: true });
return () => {
disposed = true;
target.removeEventListener("scroll", onScroll);
};
}, [key, ready, anchorRef]);
}
function findScroller(el: HTMLElement): HTMLElement {
for (let node = el.parentElement; node; node = node.parentElement) {
const overflowY = getComputedStyle(node).overflowY;
if (overflowY === "auto" || overflowY === "scroll") return node;
}
return document.scrollingElement as HTMLElement;
}