diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 25db66a830..b06520e325 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -29,6 +29,7 @@ import { MainNavbar } from "./components/MainNavbar"; import { PageNotFound } from "./components/PageNotFound"; import * as GQL from "./core/generated-graphql"; import { makeTitleProps } from "./hooks/title"; +import { useScrollRestoration } from "./hooks/scrollRestoration"; import { LoadingIndicator } from "./components/Shared/LoadingIndicator"; import { @@ -206,6 +207,8 @@ export const App: React.FC = () => { const history = useHistory(); const setupMatch = useRouteMatch(["/setup", "/migrate", "/welcome"]); + useScrollRestoration(); + // dispatch event when location changes useEffect(() => { Event.dispatch("location", "", { location }); diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 60a5b6f367..fce838f223 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -8,6 +8,7 @@ import { IHasID } from "src/utils/data"; import { useConfigurationContext } from "src/hooks/Config"; import { View } from "./views"; import { usePrevious } from "src/hooks/state"; +import { isRestoringScroll } from "src/hooks/scrollRestoration"; import * as GQL from "src/core/generated-graphql"; import { DisplayMode } from "src/models/list-filter/types"; import { Criterion } from "src/models/list-filter/criteria/criterion"; @@ -607,6 +608,12 @@ export function useScrollToTopOnPageChange( return; } + // don't interfere with the scroll position being restored when navigating + // back to a list that was showing a different page + if (isRestoringScroll()) { + return; + } + // if the current page has a detail-header, then // scroll up relative to that rather than 0, 0 const detailHeader = document.querySelector(".detail-header"); diff --git a/ui/v2.5/src/hooks/scrollRestoration.ts b/ui/v2.5/src/hooks/scrollRestoration.ts new file mode 100644 index 0000000000..8b62252de4 --- /dev/null +++ b/ui/v2.5/src/hooks/scrollRestoration.ts @@ -0,0 +1,202 @@ +import { useEffect, useLayoutEffect, useRef } from "react"; +import { useHistory, useLocation } from "react-router-dom"; + +// the initial history entry has no key +const initialKey = "@@initial"; + +// scroll positions for each history entry, keyed by location key. Kept in +// session storage so that the positions survive a page reload - the history +// entries do. +const storageKey = "stash-scroll-positions"; + +function loadPositions() { + try { + const stored = sessionStorage.getItem(storageKey); + if (stored) return new Map(JSON.parse(stored)); + } catch { + // ignore - unparseable or unavailable storage + } + return new Map(); +} + +const positions = loadPositions(); + +// the number of history entries to remember positions for +const maxPositions = 50; + +function setPosition(key: string, position: number) { + positions.set(key, position); + + while (positions.size > maxPositions) { + positions.delete(positions.keys().next().value!); + } +} + +// positions are updated as the user scrolls, so they are only written out when +// navigating or leaving the page +function savePositions() { + try { + sessionStorage.setItem(storageKey, JSON.stringify([...positions])); + } catch { + // ignore - storage may be unavailable or full + } +} + +// how long to keep trying to restore the scroll position while the page +// content is still loading in +const restoreTimeout = 3000; + +// how long to keep the restored position applied - the list components scroll +// the page around as their contents settle +const settleTime = 750; + +let restoreCount = 0; + +// true while a scroll position is being restored. Automatic scrolling (such as +// scrolling to the top of a list when its page changes) should be suppressed +// while this is the case. +export function isRestoringScroll() { + return restoreCount > 0; +} + +// Scrolls to the given position, keeping it applied while the page settles. +// +// The position can't be applied while the document is still too short - which +// is the case while the page content is being fetched and rendered - and once +// it can be applied, the content rendering in may scroll it away again. Keep +// it applied until the page has settled, the user scrolls themselves, or we +// give up. +// +// Returns a function to abort the restoration. +export function scrollToWhenReady(target: number) { + const start = Date.now(); + let reached: number | undefined; + let frame = 0; + let stopped = false; + + restoreCount++; + + function stop() { + if (stopped) return; + stopped = true; + restoreCount--; + cancelAnimationFrame(frame); + window.removeEventListener("wheel", stop); + window.removeEventListener("touchstart", stop); + window.removeEventListener("keydown", stop); + } + + function attempt() { + const now = Date.now(); + + if (Math.abs(window.scrollY - target) > 1) { + window.scrollTo(0, target); + } + + if (Math.abs(window.scrollY - target) <= 1) { + // applied - keep it that way until the page has settled + if (reached === undefined) reached = now; + if (now - reached > settleTime) { + stop(); + return; + } + } else if (now - start > restoreTimeout) { + // the page never got tall enough - give up + stop(); + return; + } + + frame = requestAnimationFrame(attempt); + } + + // abandon the restoration if the user scrolls in the meantime + window.addEventListener("wheel", stop, { passive: true }); + window.addEventListener("touchstart", stop, { passive: true }); + window.addEventListener("keydown", stop); + + attempt(); + + return stop; +} + +// Restores the scroll position when navigating back or forward. +// +// The browser's native restoration doesn't work for the list views: at the +// point the history entry is popped, the page content hasn't been fetched and +// rendered yet, so the document is too short and the position is clamped to +// the top. Instead we record the position for each history entry ourselves and +// re-apply it once the page is able to scroll there. +export function useScrollRestoration() { + const location = useLocation(); + const history = useHistory(); + + const key = location.key ?? initialKey; + const keyRef = useRef(key); + const abortRestore = useRef<() => void>(); + + // take over scroll restoration from the browser + useEffect(() => { + if (!("scrollRestoration" in window.history)) return; + + const original = window.history.scrollRestoration; + window.history.scrollRestoration = "manual"; + return () => { + window.history.scrollRestoration = original; + }; + }, []); + + // record the scroll position of the current history entry + useEffect(() => { + function onScroll() { + // ignore the scroll events generated while restoring - they would + // overwrite the position being restored to + if (isRestoringScroll()) return; + + setPosition(keyRef.current, window.scrollY); + } + + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("pagehide", savePositions); + return () => { + window.removeEventListener("scroll", onScroll); + window.removeEventListener("pagehide", savePositions); + }; + }, []); + + useEffect(() => { + return () => abortRestore.current?.(); + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only running when the history entry changes + useLayoutEffect(() => { + const prevKey = keyRef.current; + keyRef.current = key; + + if (history.action === "REPLACE") { + // the same history entry under a new key - the lists replace the URL to + // keep it in sync with their filter, and do so while the position is + // still being restored. Carry the position over and leave the + // restoration running. + const position = positions.get(prevKey); + if (position !== undefined) { + setPosition(key, position); + } + savePositions(); + return; + } + + abortRestore.current?.(); + savePositions(); + + if (history.action !== "POP") { + // new entry - it starts wherever the browser leaves us + setPosition(key, window.scrollY); + return; + } + + const target = positions.get(key) ?? 0; + if (target === 0 && window.scrollY === 0) return; + + abortRestore.current = scrollToWhenReady(target); + }, [key]); +}