"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
* (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, ready: boolean ) { const restoredKey = useRef(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 . 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; }