mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 13:26:38 +08:00
bug处理
This commit is contained in:
@@ -89,6 +89,7 @@ export function Layout() {
|
|||||||
)}
|
)}
|
||||||
<LayoutWorkspace
|
<LayoutWorkspace
|
||||||
routeKey={location.pathname}
|
routeKey={location.pathname}
|
||||||
|
scrollKey={`${location.pathname}${location.search}`}
|
||||||
userKey={user?.id}
|
userKey={user?.id}
|
||||||
showMobileBottomNav={showMobileBottomNav}
|
showMobileBottomNav={showMobileBottomNav}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type LayoutSidebarsProps = Omit<
|
|||||||
|
|
||||||
type LayoutWorkspaceProps = {
|
type LayoutWorkspaceProps = {
|
||||||
routeKey: string
|
routeKey: string
|
||||||
|
/** 滚动记忆的 key:含 query,让同一路径下的不同视图(如剧集面板)各记一份位置。 */
|
||||||
|
scrollKey?: string
|
||||||
userKey?: string
|
userKey?: string
|
||||||
showMobileBottomNav?: boolean
|
showMobileBottomNav?: boolean
|
||||||
}
|
}
|
||||||
@@ -113,10 +115,11 @@ export function LayoutSidebars({
|
|||||||
|
|
||||||
export function LayoutWorkspace({
|
export function LayoutWorkspace({
|
||||||
routeKey,
|
routeKey,
|
||||||
|
scrollKey,
|
||||||
userKey = 'anonymous',
|
userKey = 'anonymous',
|
||||||
showMobileBottomNav = false,
|
showMobileBottomNav = false,
|
||||||
}: LayoutWorkspaceProps) {
|
}: LayoutWorkspaceProps) {
|
||||||
useScrollMemory(routeKey, userKey)
|
useScrollMemory(scrollKey ?? routeKey, userKey)
|
||||||
|
|
||||||
const bottomPad = showMobileBottomNav
|
const bottomPad = showMobileBottomNav
|
||||||
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
? 'pb-[calc(3.75rem+env(safe-area-inset-bottom,0px))] lg:pb-10'
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
|
isJumpIntentKey,
|
||||||
|
isScrollIntentKey,
|
||||||
shouldPersistClampedSample,
|
shouldPersistClampedSample,
|
||||||
shouldPersistScrollSample,
|
shouldPersistScrollSample,
|
||||||
shouldRememberScroll,
|
shouldRememberScroll,
|
||||||
@@ -11,6 +13,7 @@ function check(name: string, condition: boolean) {
|
|||||||
check('home remembers scroll', shouldRememberScroll('/'))
|
check('home remembers scroll', shouldRememberScroll('/'))
|
||||||
check('library list remembers scroll', shouldRememberScroll('/libraries'))
|
check('library list remembers scroll', shouldRememberScroll('/libraries'))
|
||||||
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
|
check('library detail remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||||
|
check('library series panel remembers scroll', shouldRememberScroll('/library/lib-1'))
|
||||||
check('media detail remembers scroll', shouldRememberScroll('/media/media-1'))
|
check('media detail remembers scroll', shouldRememberScroll('/media/media-1'))
|
||||||
check('settings does not remember scroll', !shouldRememberScroll('/settings'))
|
check('settings does not remember scroll', !shouldRememberScroll('/settings'))
|
||||||
check('player does not remember scroll', !shouldRememberScroll('/play/media-1'))
|
check('player does not remember scroll', !shouldRememberScroll('/play/media-1'))
|
||||||
@@ -87,4 +90,13 @@ check(
|
|||||||
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 0 }),
|
!shouldPersistClampedSample({ current: 0, maxScroll: 0, saved: 0 }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
check('page down counts as scroll intent', isScrollIntentKey('PageDown'))
|
||||||
|
check('space counts as scroll intent', isScrollIntentKey(' '))
|
||||||
|
check('typing does not count as scroll intent', !isScrollIntentKey('a'))
|
||||||
|
check('shortcut does not count as scroll intent', !isScrollIntentKey('Meta'))
|
||||||
|
check('page down allows a long jump', isJumpIntentKey('PageDown'))
|
||||||
|
check('home allows a long jump', isJumpIntentKey('Home'))
|
||||||
|
check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown'))
|
||||||
|
check('space keeps smooth-only intent', !isJumpIntentKey(' '))
|
||||||
|
|
||||||
console.log('useScrollMemory.test.ts ok')
|
console.log('useScrollMemory.test.ts ok')
|
||||||
|
|||||||
@@ -3,6 +3,36 @@ import { useLayoutEffect } from 'react'
|
|||||||
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
|
const SCROLL_STORAGE_PREFIX = 'mebox.scroll.'
|
||||||
const scrollPositions = new Map<string, number>()
|
const scrollPositions = new Map<string, number>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户滚动意图的有效期:滚轮 / 触摸 / 翻页键之后这段时间内的 scroll 事件才算
|
||||||
|
* “用户自己滚出来的位置”。连续滚动(含惯性)期间每个 scroll 事件都会续期,
|
||||||
|
* 所以正常滑动不会中途过期,而恢复回填 / 虚拟列表量高这类程序化位移会被排除。
|
||||||
|
*/
|
||||||
|
const USER_SCROLL_INTENT_TTL_MS = 800
|
||||||
|
|
||||||
|
/** 平滑滚动(滚轮/触摸/方向键)单次采样允许的最大“向上跳”距离。 */
|
||||||
|
function maxSmoothJumpUp(viewportHeight: number): number {
|
||||||
|
return Math.max(viewportHeight * 2, 1200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 会滚动容器的按键。 */
|
||||||
|
export function isScrollIntentKey(key: string): boolean {
|
||||||
|
return (
|
||||||
|
key === 'PageDown' ||
|
||||||
|
key === 'PageUp' ||
|
||||||
|
key === 'Home' ||
|
||||||
|
key === 'End' ||
|
||||||
|
key === 'ArrowDown' ||
|
||||||
|
key === 'ArrowUp' ||
|
||||||
|
key === ' '
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一次按键可能直接跳很远(首页/末页/翻页),这些不算异常向上跳。 */
|
||||||
|
export function isJumpIntentKey(key: string): boolean {
|
||||||
|
return key === 'PageDown' || key === 'PageUp' || key === 'Home' || key === 'End'
|
||||||
|
}
|
||||||
|
|
||||||
function readScrollPosition(key: string): number {
|
function readScrollPosition(key: string): number {
|
||||||
const cached = scrollPositions.get(key)
|
const cached = scrollPositions.get(key)
|
||||||
if (cached !== undefined) return cached
|
if (cached !== undefined) return cached
|
||||||
@@ -85,18 +115,25 @@ export function shouldPersistClampedSample(input: {
|
|||||||
* 恢复还没完成时,容器里的 scrollTop 往往是浏览器钳制出来的值(内容还在加载
|
* 恢复还没完成时,容器里的 scrollTop 往往是浏览器钳制出来的值(内容还在加载
|
||||||
* 占位,或被钳在变矮内容的底部)。这种采样既不写回存储,也不当成用户“接管”,
|
* 占位,或被钳在变矮内容的底部)。这种采样既不写回存储,也不当成用户“接管”,
|
||||||
* 否则一次误触的滚轮 / 触摸就会把记忆位置清成 0。
|
* 否则一次误触的滚轮 / 触摸就会把记忆位置清成 0。
|
||||||
|
*
|
||||||
|
* 只有带“用户滚动意图”(滚轮 / 触摸 / 翻页键 / 拖滚动条)的 scroll 事件才会写
|
||||||
|
* 存储:恢复回填、虚拟列表挂载量高、浏览器钳制造成的位移都不算用户选择的位置。
|
||||||
|
*
|
||||||
|
* routeKey 允许包含 query:媒体库详情页的剧集面板(`/library/:id?series=...`)
|
||||||
|
* 与网格视图是两块不同内容,各自记一份位置,互不覆盖。
|
||||||
*/
|
*/
|
||||||
export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void {
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const el = document.getElementById('app-main-scroll')
|
const el = document.getElementById('app-main-scroll')
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
|
const pathname = routeKey.split('?')[0]
|
||||||
if (!shouldRememberScroll(pathname)) {
|
if (!shouldRememberScroll(pathname)) {
|
||||||
el.scrollTop = 0
|
el.scrollTop = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${userKey}:${pathname}`
|
const key = `${userKey}:${routeKey}`
|
||||||
const saved = readScrollPosition(key)
|
const saved = readScrollPosition(key)
|
||||||
let restoring = saved > 0
|
let restoring = saved > 0
|
||||||
let observer: ResizeObserver | null = null
|
let observer: ResizeObserver | null = null
|
||||||
@@ -105,6 +142,23 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
|||||||
let restorePumpUntil = 0
|
let restorePumpUntil = 0
|
||||||
let lastSaved = saved
|
let lastSaved = saved
|
||||||
let lastHeight = el.scrollHeight
|
let lastHeight = el.scrollHeight
|
||||||
|
// 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。
|
||||||
|
// 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器
|
||||||
|
// 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。
|
||||||
|
let userScrollUntil = 0
|
||||||
|
let userScrollAllowsJump = false
|
||||||
|
const markUserScroll = (allowsJump = false) => {
|
||||||
|
const now = performance.now()
|
||||||
|
// 上一段意图已过期:新的一段从“只允许小幅向上跳”开始。
|
||||||
|
if (userScrollUntil <= now) {
|
||||||
|
userScrollAllowsJump = false
|
||||||
|
}
|
||||||
|
userScrollUntil = now + USER_SCROLL_INTENT_TTL_MS
|
||||||
|
if (allowsJump) {
|
||||||
|
userScrollAllowsJump = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const userScrolling = () => userScrollUntil > performance.now()
|
||||||
|
|
||||||
const currentMaxScroll = () => Math.max(0, el.scrollHeight - el.clientHeight)
|
const currentMaxScroll = () => Math.max(0, el.scrollHeight - el.clientHeight)
|
||||||
|
|
||||||
@@ -169,11 +223,20 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
|||||||
|
|
||||||
const saveNow = () => {
|
const saveNow = () => {
|
||||||
const current = Math.round(el.scrollTop)
|
const current = Math.round(el.scrollTop)
|
||||||
|
const userDriven = userScrolling()
|
||||||
|
// 惯性/连续滚动期间持续续期,避免长距离滑动中途被判定为“非用户滚动”。
|
||||||
|
if (userDriven) markUserScroll(userScrollAllowsJump)
|
||||||
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
|
// 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储,
|
||||||
// 也不能当成“用户接管”。
|
// 也不能当成“用户接管”。
|
||||||
if (!sampleIsTrustworthy(current)) return
|
if (!sampleIsTrustworthy(current)) return
|
||||||
|
// 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 /
|
||||||
|
// 拖滚动条这类意图才可能是用户行为,否则直接丢弃。
|
||||||
|
const upJump = lastSaved > current ? lastSaved - current : 0
|
||||||
|
if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (restoring) {
|
if (restoring) {
|
||||||
if (Math.abs(current - saved) <= 1) return
|
if (!userDriven || Math.abs(current - saved) <= 1) return
|
||||||
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
|
// 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复
|
||||||
// 逻辑继续和用户抢滚动条。
|
// 逻辑继续和用户抢滚动条。
|
||||||
restoring = false
|
restoring = false
|
||||||
@@ -181,6 +244,8 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
|||||||
adoptScrollPosition(current)
|
adoptScrollPosition(current)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。
|
||||||
|
if (!userDriven) return
|
||||||
const height = el.scrollHeight
|
const height = el.scrollHeight
|
||||||
if (
|
if (
|
||||||
!shouldPersistScrollSample({
|
!shouldPersistScrollSample({
|
||||||
@@ -190,7 +255,6 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
|||||||
lastHeight,
|
lastHeight,
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
lastHeight = height
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastHeight = height
|
lastHeight = height
|
||||||
@@ -199,15 +263,13 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
|||||||
writeScrollPosition(key, current)
|
writeScrollPosition(key, current)
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancelRestore = () => {
|
// 用户滚动意图:滚轮 / 触摸 = 平滑滚动;翻页键 / 拖滚动条 = 允许直接跳很远。
|
||||||
if (!restoring) return
|
const onScrollIntent = () => {
|
||||||
const current = Math.round(el.scrollTop)
|
markUserScroll(false)
|
||||||
// 恢复期间用户滚轮 / 触摸 / 按键只是误触或惯性(内容还没长高时他也确实
|
}
|
||||||
// 滚不动):继续完成恢复,并保留原来的记忆值。
|
|
||||||
if (!sampleIsTrustworthy(current)) return
|
const onJumpIntent = () => {
|
||||||
restoring = false
|
markUserScroll(true)
|
||||||
stopRestore()
|
|
||||||
adoptScrollPosition(current)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (saved <= 0) {
|
if (saved <= 0) {
|
||||||
@@ -227,42 +289,37 @@ export function useScrollMemory(pathname: string, userKey = 'anonymous'): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onPointerDown = (event: PointerEvent) => {
|
const onPointerDown = (event: PointerEvent) => {
|
||||||
|
// 点在容器本身 = 拖滚动条或在空白处按下:允许大幅跳转。
|
||||||
if (event.target === el) {
|
if (event.target === el) {
|
||||||
cancelRestore()
|
onJumpIntent()
|
||||||
}
|
}
|
||||||
saveNow()
|
saveNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (
|
if (isJumpIntentKey(event.key)) {
|
||||||
event.key === 'PageDown' ||
|
onJumpIntent()
|
||||||
event.key === 'PageUp' ||
|
} else if (isScrollIntentKey(event.key)) {
|
||||||
event.key === 'Home' ||
|
onScrollIntent()
|
||||||
event.key === 'End' ||
|
|
||||||
event.key === 'ArrowDown' ||
|
|
||||||
event.key === 'ArrowUp' ||
|
|
||||||
event.key === ' '
|
|
||||||
) {
|
|
||||||
cancelRestore()
|
|
||||||
}
|
}
|
||||||
saveNow()
|
saveNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
el.addEventListener('scroll', saveNow, { passive: true })
|
el.addEventListener('scroll', saveNow, { passive: true })
|
||||||
el.addEventListener('pointerdown', onPointerDown, true)
|
el.addEventListener('pointerdown', onPointerDown, true)
|
||||||
window.addEventListener('wheel', cancelRestore, { passive: true, capture: true })
|
window.addEventListener('wheel', onScrollIntent, { passive: true, capture: true })
|
||||||
window.addEventListener('touchstart', cancelRestore, { passive: true, capture: true })
|
window.addEventListener('touchstart', onScrollIntent, { passive: true, capture: true })
|
||||||
window.addEventListener('keydown', onKeyDown, true)
|
window.addEventListener('keydown', onKeyDown, true)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
el.removeEventListener('scroll', saveNow)
|
el.removeEventListener('scroll', saveNow)
|
||||||
el.removeEventListener('pointerdown', onPointerDown, true)
|
el.removeEventListener('pointerdown', onPointerDown, true)
|
||||||
window.removeEventListener('wheel', cancelRestore, true)
|
window.removeEventListener('wheel', onScrollIntent, true)
|
||||||
window.removeEventListener('touchstart', cancelRestore, true)
|
window.removeEventListener('touchstart', onScrollIntent, true)
|
||||||
window.removeEventListener('keydown', onKeyDown, true)
|
window.removeEventListener('keydown', onKeyDown, true)
|
||||||
stopRestore()
|
stopRestore()
|
||||||
// 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。
|
// 清理时写入最后一次有效位置,避免依赖已被钳制的 el.scrollTop。
|
||||||
writeScrollPosition(key, lastSaved)
|
writeScrollPosition(key, lastSaved)
|
||||||
}
|
}
|
||||||
}, [pathname, userKey])
|
}, [routeKey, userKey])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user