feat(ux): globale Scroll-Wiederherstellung für ALLE Listen/Seiten
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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { NavLink, Outlet } from 'react-router-dom'
|
|||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import GerbilIcon from './GerbilIcon'
|
import GerbilIcon from './GerbilIcon'
|
||||||
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
import { BreederSuffixProvider } from './BreederSuffixProvider'
|
||||||
|
import { useScrollRestoration } from '../hooks/useScrollRestoration'
|
||||||
import './appShell.css'
|
import './appShell.css'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -52,6 +53,9 @@ const linkClass = ({ isActive }: { isActive: boolean }) =>
|
|||||||
*/
|
*/
|
||||||
export default function AppShell() {
|
export default function AppShell() {
|
||||||
const [moreOpen, setMoreOpen] = useState(false)
|
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 (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
|
|||||||
129
gerbil-manager-web/src/hooks/useScrollRestoration.ts
Normal file
129
gerbil-manager-web/src/hooks/useScrollRestoration.ts
Normal file
@@ -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<number | null>(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])
|
||||||
|
}
|
||||||
@@ -11,17 +11,7 @@
|
|||||||
* Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote).
|
* Rückfragen kommen hierher zurück), „Geschlossen" (Resolved, zeigt den Changelog/fixNote).
|
||||||
* Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS.
|
* Der Frage/Antwort-Verlauf (thread) wird angezeigt; das interne `agentContext` NIEMALS.
|
||||||
*/
|
*/
|
||||||
import {
|
import { createContext, Fragment, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||||
createContext,
|
|
||||||
Fragment,
|
|
||||||
useCallback,
|
|
||||||
useContext,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
} from 'react'
|
|
||||||
import { Link, useSearchParams } from 'react-router-dom'
|
import { Link, useSearchParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { ApiError } from '../api/client'
|
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.
|
// selbst gewählt hat (userPicked), darf ein ?focus=-Sprung die Ansicht bestimmen.
|
||||||
const [view, setView] = useState<TicketView>('open')
|
const [view, setView] = useState<TicketView>('open')
|
||||||
const [userPicked, setUserPicked] = useState(false)
|
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) {
|
async function handleToggleStatus(ticket: FeedbackTicket) {
|
||||||
try {
|
try {
|
||||||
@@ -448,93 +433,8 @@ export default function TicketsPage() {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [focusId, displayed])
|
}, [focusId, displayed])
|
||||||
|
|
||||||
// Scrollposition laufend merken (außer während wir gerade selbst wiederherstellen, damit ein
|
// (Scroll-Wiederherstellung läuft global über useScrollRestoration im AppShell — gilt für alle
|
||||||
// vom Browser beim Aufwachen ausgelöster Sprung nach oben die gemerkte Position nicht killt).
|
// Listen, inkl. Handy-Sperre/Hintergrund. ?focus= unten hat hier Vorrang.)
|
||||||
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])
|
|
||||||
|
|
||||||
// Nackte Tier-IDs in den sichtbaren Tickets sammeln und ihre Namen einmalig auflösen,
|
// 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).
|
// damit die KI im Text einfach eine Tier-ID hinterlegen kann (→ Name + Link zur Akte).
|
||||||
|
|||||||
Reference in New Issue
Block a user