fix(tickets): Scrollposition übersteht Handy-Sperre/Hintergrund (nicht mehr Sprung nach oben)
Bisher wurde die Scrollposition nur einmal beim Mount wiederhergestellt — sperrte das Handy und kam zurück, war die Seite oben. Jetzt: - Position wird beim Ausblenden sicher gesichert (visibilitychange→hidden, pagehide), nicht nur bei Scroll-Events. - Wiederherstellung beim Wiederanzeigen (visibilitychange→visible, pageshow) UND nach dem Laden — mit mehreren rAF-Versuchen gegen nachwachsende Inhalte. - Schutz gegen den Aufwach-Sprung nach oben: während des Wiederherstellens werden Scroll-Speicherungen blockiert, damit ein vom Browser ausgelöster 0-Sprung die gemerkte Position nicht überschreibt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,17 @@
|
|||||||
* 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 { createContext, Fragment, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
import {
|
||||||
|
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'
|
||||||
@@ -262,10 +272,11 @@ 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 das Verlassen/Zurückkehren (z. B. Klick auf einen Tier-Link und dann
|
// Scrollposition über Verlassen/Zurückkehren UND Bildschirm-Sperre/Wiederaufwachen hinweg
|
||||||
// Browser-Zurück) hinweg erhalten. Erst NACH dem Wiederherstellen wieder mitschreiben, damit
|
// erhalten (Handy sperrt → Seite wird eingefroren/verworfen → kommt sonst oben an). Siehe die
|
||||||
// der Sprung an den Seitenanfang beim Re-Mount die gemerkte Position nicht überschreibt.
|
// Scroll-Effekte unten (Speichern beim Ausblenden, Wiederherstellen beim Anzeigen + nach Laden).
|
||||||
const scrollRestoredRef = useRef(false)
|
const lastScrollRef = useRef(0)
|
||||||
|
const restoringRef = useRef(false)
|
||||||
|
|
||||||
async function handleToggleStatus(ticket: FeedbackTicket) {
|
async function handleToggleStatus(ticket: FeedbackTicket) {
|
||||||
try {
|
try {
|
||||||
@@ -437,40 +448,93 @@ export default function TicketsPage() {
|
|||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [focusId, displayed])
|
}, [focusId, displayed])
|
||||||
|
|
||||||
// Scrollposition laufend in der Session merken (für die Zurück-Navigation).
|
// 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 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(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (!scrollRestoredRef.current) return
|
if (document.visibilityState !== 'visible') return
|
||||||
try {
|
saveScroll()
|
||||||
sessionStorage.setItem(SCROLL_KEY, String(window.scrollY))
|
|
||||||
} catch {
|
|
||||||
/* sessionStorage nicht verfügbar — Position dann nicht gemerkt */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
window.addEventListener('scroll', onScroll, { passive: true })
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
return () => window.removeEventListener('scroll', onScroll)
|
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)
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Nach dem Laden der Tickets die gemerkte Scrollposition EINMALIG wiederherstellen — außer es
|
// Gemerkte Position wiederherstellen — robust gegen nachwachsende Inhalte (mehrere Versuche per
|
||||||
// steht ein ?focus=-Sprung an (der hat Vorrang). Erst danach beginnt das Mitschreiben (s. o.).
|
// rAF) und gegen den Aufwach-Sprung nach oben. ?focus= hat Vorrang.
|
||||||
useEffect(() => {
|
const restoreScroll = useCallback(() => {
|
||||||
if (scrollRestoredRef.current || rows === null) return
|
if (focusId) return
|
||||||
if (focusId) {
|
|
||||||
scrollRestoredRef.current = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let y = NaN
|
let y = NaN
|
||||||
try {
|
try {
|
||||||
y = Number(sessionStorage.getItem(SCROLL_KEY))
|
y = Number(sessionStorage.getItem(SCROLL_KEY))
|
||||||
} catch {
|
} catch {
|
||||||
/* ignorieren */
|
/* ignorieren */
|
||||||
}
|
}
|
||||||
scrollRestoredRef.current = true
|
if (!Number.isFinite(y) || y <= 0) return
|
||||||
if (Number.isFinite(y) && y > 0) {
|
restoringRef.current = true
|
||||||
requestAnimationFrame(() => window.scrollTo(0, y))
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [rows, focusId])
|
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