From ebc70cc0b867877d37b6404f278c220b8ae7fd29 Mon Sep 17 00:00:00 2001 From: Gulum Date: Tue, 23 Jun 2026 13:48:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(ux):=20globale=20Scroll-Wiederherstellung?= =?UTF-8?q?=20f=C3=BCr=20ALLE=20Listen/Seiten?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer Hook useScrollRestoration (einmal im AppShell gemountet) erhält die Scrollposition je Route über: - Zurück/Vorwärts-Navigation (POP restauriert, PUSH startet oben), - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) — damit das Handy nach dem Entsperren nicht mehr oben landet. Robust gegen nachladende Listen (rAF-Nachjustierung bis ~2,5 s, während die Liste wächst), bricht bei eigener Scroll-Eingabe ab, Deep-Links (?focus=, #anchor) haben Vorrang. Die bisher nur in der Tickets-Seite eingebaute Logik wurde durch den globalen Hook ersetzt. tsc/eslint/vitest grün; e2e (navigation/listen/tickets, 42) grün. Co-Authored-By: Claude Opus 4.8 --- .../src/components/AppShell.tsx | 4 + .../src/hooks/useScrollRestoration.ts | 129 ++++++++++++++++++ gerbil-manager-web/src/pages/TicketsPage.tsx | 106 +------------- 3 files changed, 136 insertions(+), 103 deletions(-) create mode 100644 gerbil-manager-web/src/hooks/useScrollRestoration.ts diff --git a/gerbil-manager-web/src/components/AppShell.tsx b/gerbil-manager-web/src/components/AppShell.tsx index 380abd8..63ec19a 100644 --- a/gerbil-manager-web/src/components/AppShell.tsx +++ b/gerbil-manager-web/src/components/AppShell.tsx @@ -4,6 +4,7 @@ import { NavLink, Outlet } from 'react-router-dom' import { de } from '../strings/de' import GerbilIcon from './GerbilIcon' import { BreederSuffixProvider } from './BreederSuffixProvider' +import { useScrollRestoration } from '../hooks/useScrollRestoration' import './appShell.css' interface NavItem { @@ -52,6 +53,9 @@ const linkClass = ({ isActive }: { isActive: boolean }) => */ export default function AppShell() { const [moreOpen, setMoreOpen] = useState(false) + // Scrollposition je Seite erhalten (Zurück-Navigation + Handy-Sperre/Hintergrund) — gilt für + // ALLE scrollbaren Listen der App. + useScrollRestoration() return (
diff --git a/gerbil-manager-web/src/hooks/useScrollRestoration.ts b/gerbil-manager-web/src/hooks/useScrollRestoration.ts new file mode 100644 index 0000000..cc8d5d4 --- /dev/null +++ b/gerbil-manager-web/src/hooks/useScrollRestoration.ts @@ -0,0 +1,129 @@ +/** + * Globale Scroll-Wiederherstellung (einmal im AppShell gemountet, gilt für ALLE Seiten/Listen). + * + * Erhält die Fenster-Scrollposition je Route über: + * - Zurück/Vorwärts-Navigation (POP) → gemerkte Position wiederherstellen, + * - Bildschirm-Sperre / App-Hintergrund (visibilitychange, pagehide/pageshow) → Position + * sicher festhalten und beim Zurückkommen wiederherstellen (sonst springt das Handy oben). + * Neue Navigation (PUSH/REPLACE) startet wie gewohnt oben. + * + * Robust gegen nachladende Listen: nach dem Wiederherstellen wird mehrfach (per rAF, bis ~2.5 s) + * nachjustiert, während die Liste in die Höhe wächst — bricht aber ab, sobald die Nutzerin selbst + * scrollt (damit wir nie gegen sie ankämpfen). Deep-Links (?focus=, #anchor) haben Vorrang. + */ +import { useCallback, useEffect, useRef } from 'react' +import { useLocation, useNavigationType } from 'react-router-dom' + +const PREFIX = 'scroll:' +const RESTORE_DEADLINE_MS = 2500 + +export function useScrollRestoration() { + const { pathname } = useLocation() + const navType = useNavigationType() + const pathRef = useRef(pathname) + const restoringRef = useRef(false) + const rafSaveRef = useRef(null) + + const keyFor = (p: string) => PREFIX + p + + const persist = useCallback(() => { + try { + sessionStorage.setItem(keyFor(pathRef.current), String(window.scrollY)) + } catch { + /* sessionStorage nicht verfügbar */ + } + }, []) + + const restore = useCallback((path: string, respectDeepLink: boolean) => { + if (respectDeepLink) { + // Ein Deep-Link (Sprung zu einem Element / Anker) hat Vorrang vor der gemerkten Position. + if (window.location.hash) return + try { + if (new URLSearchParams(window.location.search).has('focus')) return + } catch { + /* ignorieren */ + } + } + let y = NaN + try { + y = Number(sessionStorage.getItem(keyFor(path))) + } catch { + /* ignorieren */ + } + if (!Number.isFinite(y) || y <= 0) return + + restoringRef.current = true + const deadline = performance.now() + RESTORE_DEADLINE_MS + let aborted = false + const onUser = () => { + aborted = true + } + const passive = { passive: true } as AddEventListenerOptions + window.addEventListener('wheel', onUser, passive) + window.addEventListener('touchmove', onUser, passive) + window.addEventListener('keydown', onUser) + const stop = () => { + restoringRef.current = false + window.removeEventListener('wheel', onUser) + window.removeEventListener('touchmove', onUser) + window.removeEventListener('keydown', onUser) + } + const tick = () => { + if (aborted) { + stop() + return + } + window.scrollTo(0, y) + if (Math.abs(window.scrollY - y) <= 2 || performance.now() > deadline) { + stop() + return + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + }, []) + + // Auf Navigation reagieren: POP = wiederherstellen, sonst neue Seite oben starten. + useEffect(() => { + pathRef.current = pathname + if (navType === 'POP') { + restore(pathname, true) + } else { + restoringRef.current = true + requestAnimationFrame(() => { + window.scrollTo(0, 0) + restoringRef.current = false + }) + } + }, [pathname, navType, restore]) + + // Laufendes Mitschreiben (rAF-gedrosselt) + Sichern beim Ausblenden + Wiederherstellen beim + // Wiederanzeigen (Handy entsperrt / aus dem Hintergrund). + useEffect(() => { + const onScroll = () => { + if (restoringRef.current || document.visibilityState !== 'visible') return + if (rafSaveRef.current != null) return + rafSaveRef.current = requestAnimationFrame(() => { + rafSaveRef.current = null + persist() + }) + } + const onVisibility = () => { + if (document.visibilityState === 'hidden') persist() + else restore(pathRef.current, false) + } + const onPageShow = () => restore(pathRef.current, false) + + window.addEventListener('scroll', onScroll, { passive: true }) + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('pagehide', persist) + window.addEventListener('pageshow', onPageShow) + return () => { + window.removeEventListener('scroll', onScroll) + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('pagehide', persist) + window.removeEventListener('pageshow', onPageShow) + if (rafSaveRef.current != null) cancelAnimationFrame(rafSaveRef.current) + } + }, [persist, restore]) +} diff --git a/gerbil-manager-web/src/pages/TicketsPage.tsx b/gerbil-manager-web/src/pages/TicketsPage.tsx index d12df39..95a854f 100644 --- a/gerbil-manager-web/src/pages/TicketsPage.tsx +++ b/gerbil-manager-web/src/pages/TicketsPage.tsx @@ -11,17 +11,7 @@ * Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote). * Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS. */ -import { - createContext, - Fragment, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, - type ReactNode, -} from 'react' +import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { de } from '../strings/de' import { ApiError } from '../api/client' @@ -272,11 +262,6 @@ export default function TicketsPage() { // selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen. const [view, setView] = useState('open') const [userPicked, setUserPicked] = useState(false) - // Scrollposition über Verlassen/Zurückkehren UND Bildschirm-Sperre/Wiederaufwachen hinweg - // erhalten (Handy sperrt → Seite wird eingefroren/verworfen → kommt sonst oben an). Siehe die - // Scroll-Effekte unten (Speichern beim Ausblenden, Wiederherstellen beim Anzeigen + nach Laden). - const lastScrollRef = useRef(0) - const restoringRef = useRef(false) async function handleToggleStatus(ticket: FeedbackTicket) { try { @@ -448,93 +433,8 @@ export default function TicketsPage() { return () => clearTimeout(timer) }, [focusId, displayed]) - // Scrollposition laufend merken (außer während wir gerade selbst wiederherstellen, damit ein - // vom Browser beim Aufwachen ausgelöster Sprung nach oben die gemerkte Position nicht killt). - const SCROLL_KEY = 'tickets-scroll-y' - const saveScroll = useCallback(() => { - if (restoringRef.current) return - lastScrollRef.current = window.scrollY - try { - sessionStorage.setItem(SCROLL_KEY, String(window.scrollY)) - } catch { - /* sessionStorage nicht verfügbar */ - } - }, []) - useEffect(() => { - const onScroll = () => { - if (document.visibilityState !== 'visible') return - saveScroll() - } - window.addEventListener('scroll', onScroll, { passive: true }) - return () => window.removeEventListener('scroll', onScroll) - }, [saveScroll]) - - // Position SICHER festhalten, kurz bevor die Seite eingefroren/verworfen wird (Handy-Sperre, - // App-Wechsel, Tab schließen) — da feuert evtl. kein Scroll-Event mehr. - useEffect(() => { - const persist = () => { - try { - sessionStorage.setItem(SCROLL_KEY, String(lastScrollRef.current || window.scrollY)) - } catch { - /* ignorieren */ - } - } - const onVisibility = () => { - if (document.visibilityState === 'hidden') persist() - } - document.addEventListener('visibilitychange', onVisibility) - window.addEventListener('pagehide', persist) - return () => { - document.removeEventListener('visibilitychange', onVisibility) - window.removeEventListener('pagehide', persist) - } - }, []) - - // Gemerkte Position wiederherstellen — robust gegen nachwachsende Inhalte (mehrere Versuche per - // rAF) und gegen den Aufwach-Sprung nach oben. ?focus= hat Vorrang. - const restoreScroll = useCallback(() => { - if (focusId) return - let y = NaN - try { - y = Number(sessionStorage.getItem(SCROLL_KEY)) - } catch { - /* ignorieren */ - } - if (!Number.isFinite(y) || y <= 0) return - restoringRef.current = true - let tries = 0 - const tick = () => { - window.scrollTo(0, y) - tries += 1 - const close = Math.abs(window.scrollY - y) <= 2 - const cannotReach = document.documentElement.scrollHeight <= y + window.innerHeight - if (!close && !cannotReach && tries < 30) { - requestAnimationFrame(tick) - } else { - restoringRef.current = false - } - } - requestAnimationFrame(tick) - }, [focusId]) - - // Wiederherstellen nach dem Laden der Tickets (frischer Mount / Reload nach Tab-Verwerfung). - useEffect(() => { - if (rows) restoreScroll() - }, [rows, restoreScroll]) - - // … und beim Wiederanzeigen der Seite (Handy entsperrt / aus dem Hintergrund) OHNE Reload. - useEffect(() => { - const onVisible = () => { - if (document.visibilityState === 'visible') restoreScroll() - } - const onPageShow = () => restoreScroll() - document.addEventListener('visibilitychange', onVisible) - window.addEventListener('pageshow', onPageShow) - return () => { - document.removeEventListener('visibilitychange', onVisible) - window.removeEventListener('pageshow', onPageShow) - } - }, [restoreScroll]) + // (Scroll-Wiederherstellung läuft global über useScrollRestoration im AppShell — gilt für alle + // Listen, inkl. Handy-Sperre/Hintergrund. ?focus= unten hat hier Vorrang.) // Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen, // damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).