From 34e61a59945df9c04ab36811152d306f43f69fb6 Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:48:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/hooks/useScrollMemory.test.ts | 59 ++++++++++ web/src/hooks/useScrollMemory.ts | 142 +++++++++++++++++++++---- web/src/pages/LibraryMediaSections.tsx | 63 +++++++---- web/src/pages/LibraryPage.tsx | 1 + 4 files changed, 226 insertions(+), 39 deletions(-) diff --git a/web/src/hooks/useScrollMemory.test.ts b/web/src/hooks/useScrollMemory.test.ts index e24d8ff..eed38ff 100644 --- a/web/src/hooks/useScrollMemory.test.ts +++ b/web/src/hooks/useScrollMemory.test.ts @@ -1,6 +1,7 @@ import { isJumpIntentKey, isScrollIntentKey, + shouldReapplyGuardedScroll, shouldPersistClampedSample, shouldPersistScrollSample, shouldRememberScroll, @@ -100,4 +101,62 @@ check('home allows a long jump', isJumpIntentKey('Home')) check('arrow key keeps smooth-only intent', !isJumpIntentKey('ArrowDown')) check('space keeps smooth-only intent', !isJumpIntentKey(' ')) +// 恢复完成后的看守期:列表重排(普通网格换成虚拟列表)会把容器高度压塌, +// scrollTop 被浏览器夹小;没有用户滚动意图时要把记忆位置贴回去。 +check( + 'collapsed position inside the guard window is re-applied', + shouldReapplyGuardedScroll({ + current: 0, + target: 2600, + maxScroll: 30000, + userDriven: false, + now: 1000, + guardUntil: 3000, + }), +) +check( + 'user scroll during the guard window wins', + !shouldReapplyGuardedScroll({ + current: 0, + target: 2600, + maxScroll: 30000, + userDriven: true, + now: 1000, + guardUntil: 3000, + }), +) +check( + 'no re-apply after the guard window', + !shouldReapplyGuardedScroll({ + current: 0, + target: 2600, + maxScroll: 30000, + userDriven: false, + now: 4000, + guardUntil: 3000, + }), +) +check( + 'no re-apply while the content is still too short', + !shouldReapplyGuardedScroll({ + current: 0, + target: 0, + maxScroll: 0, + userDriven: false, + now: 1000, + guardUntil: 3000, + }), +) +check( + 'position already at the target is left alone', + !shouldReapplyGuardedScroll({ + current: 2600, + target: 2600, + maxScroll: 30000, + userDriven: false, + now: 1000, + guardUntil: 3000, + }), +) + console.log('useScrollMemory.test.ts ok') diff --git a/web/src/hooks/useScrollMemory.ts b/web/src/hooks/useScrollMemory.ts index 5b4d8ac..76e4054 100644 --- a/web/src/hooks/useScrollMemory.ts +++ b/web/src/hooks/useScrollMemory.ts @@ -109,6 +109,35 @@ export function shouldPersistClampedSample(input: { return true } +/** + * 恢复完成后的看守时长:列表在这一小段时间里仍可能重排(分页补页让普通网格 + * 切换成虚拟列表、海报量高……),内容高度瞬间被压塌时浏览器会把 scrollTop + * 夹小,表现就是“返回列表后跳到顶部”。这段时间内没有用户滚动意图就把记忆 + * 位置贴回去。 + */ +const RESTORE_GUARD_MS = 3000 + +/** + * 看守期里哪些 scrollTop 跌落要贴回记忆位置。 + * + * 只处理“内容还够长、用户没有滚动意图、位置却掉到记忆位置上方”的跌落; + * 用户自己滚上去(有滚动意图)不干预,内容还没长回原来的高度也不强行回填。 + */ +export function shouldReapplyGuardedScroll(input: { + current: number + target: number + maxScroll: number + userDriven: boolean + now: number + guardUntil: number +}): boolean { + const { current, target, maxScroll, userDriven, now, guardUntil } = input + if (now > guardUntil) return false + if (userDriven) return false + if (maxScroll <= 0 || target <= 0) return false + return current < target - 1 +} + /** * 记住列表页的滚动位置。页面内容会异步长高,因此恢复期间会监听内容高度, * 直到目标位置可达;期间用户主动滚动会立即接管,避免和恢复逻辑抢滚动条。 @@ -141,8 +170,15 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void { let restoreTimer: number | null = null let restoreFrame = 0 let restorePumpUntil = 0 + // 恢复完成后的看守截止时间:见 RESTORE_GUARD_MS。 + let guardUntil = 0 + // 看守期里被“用户正在滚动”挡下的那次回填,等滚动意图过期后补做一次。 + let guardRetryTimer: number | null = null let lastSaved = saved let lastHeight = el.scrollHeight + // 内容容器(`
` 里那层宽度约束 div):分页补页、列表切换渲染方式都会 + // 改变它的高度,恢复回填与看守都靠它驱动。 + const content = el.firstElementChild instanceof HTMLElement ? el.firstElementChild : null // 最近一次“用户主动滚动”的时间戳与类型(滚轮 / 触摸 / 翻页键 / 拖滚动条)。 // 只有用户自己滚出来的位置才写回存储:恢复逻辑、虚拟列表挂载量高、浏览器 // 钳制造成的 scrollTop 变化都不代表用户想要的位置,写进去就会把记忆抹掉。 @@ -183,25 +219,91 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void { window.clearTimeout(restoreTimer) restoreTimer = null } + if (guardRetryTimer != null) { + window.clearTimeout(guardRetryTimer) + guardRetryTimer = null + } observer?.disconnect() observer = null + guardUntil = 0 + } + + /** 恢复完成后继续看守一小段时间,见 RESTORE_GUARD_MS。 */ + const startGuard = (target: number) => { + if (target <= 0) return + guardUntil = performance.now() + RESTORE_GUARD_MS + if (restoreFrame) { + window.cancelAnimationFrame(restoreFrame) + restoreFrame = 0 + } + if (restoreTimer != null) window.clearTimeout(restoreTimer) + restoreTimer = window.setTimeout(() => { + restoreTimer = null + stopRestore() + }, RESTORE_GUARD_MS) + } + + /** + * 看守期里的塌陷回填:列表重排(普通网格换成虚拟列表、海报重新量高)会让 + * 内容瞬间变矮,浏览器随之把 scrollTop 夹小,表现就是“返回列表后跳到顶部”。 + * 用户没有滚动意图时把记忆位置贴回去。 + */ + const reapplyGuardedScroll = () => { + if (restoring) return + const max = currentMaxScroll() + const target = Math.min(lastSaved, max) + if ( + shouldReapplyGuardedScroll({ + current: el.scrollTop, + target, + maxScroll: max, + userDriven: userScrolling(), + now: performance.now(), + guardUntil, + }) + ) { + el.scrollTop = target + return + } + // 这次塌陷正好撞上用户还在滚动:等滚动意图过期后再补做一次,别把它漏掉 + // (否则用户滚完停手,列表就停在被夹出来的顶部不动了)。 + if (userScrolling() && performance.now() <= guardUntil && guardRetryTimer == null) { + guardRetryTimer = window.setTimeout(() => { + guardRetryTimer = null + reapplyGuardedScroll() + }, USER_SCROLL_INTENT_TTL_MS) + } + } + + const onContentSizeChange = () => { + if (restoring) { + tryRestore() + return + } + reapplyGuardedScroll() } const finishRestore = () => { if (!restoring) return restoring = false - stopRestore() const current = Math.round(el.scrollTop) - if (!sampleIsTrustworthy(current)) { - // 目标一直不可达(内容仍比记忆位置矮 / 还在加载):保留原记忆值, - // 不要把钳制出来的位置写回去。 + // 恢复了半天位置却掉到记忆位置上方很远,而且不是用户自己滚上去的: + // 这是浏览器把 scrollTop 夹出来的值(内容被压塌、还在加载占位)。 + // 记忆值必须保留,否则下次进来就真的从顶部开始了。 + const droppedFar = + saved - current > maxSmoothJumpUp(el.clientHeight) && !userScrolling() + if (droppedFar || !sampleIsTrustworthy(current)) { + stopRestore() return } adoptScrollPosition(current) + startGuard(current) } const tryRestore = () => { if (!restoring) return + // 用户正在滚动:本帧不要回填,把控制权交给他(交给 saveNow 的接管分支)。 + if (userScrolling()) return const max = currentMaxScroll() const target = Math.min(saved, max) if (target > 0 && Math.abs(el.scrollTop - target) > 1) { @@ -230,19 +332,20 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void { // 内容还没长回来时读到的 scrollTop 不是用户选的位置,既不能写存储, // 也不能当成“用户接管”。 if (!sampleIsTrustworthy(current)) return - // 一瞬间大幅向上跳(恢复回填、虚拟列表挂载量高、浏览器钳制)只有按键翻页 / - // 拖滚动条这类意图才可能是用户行为,否则直接丢弃。 - const upJump = lastSaved > current ? lastSaved - current : 0 - if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) { + if (restoring) { + // 恢复途中用户自己滚了:一律以用户为准(哪怕一次跳得很远,也是他真实的 + // 滚轮 / 触摸 / 翻页操作)。位置仍要看住:列表随后切换渲染方式时容器 + // 高度还是会塌,塌了要贴回“用户自己选的位置”。 + if (!userDriven || Math.abs(current - saved) <= 1) return + restoring = false + adoptScrollPosition(current) + startGuard(current) return } - if (restoring) { - if (!userDriven || Math.abs(current - saved) <= 1) return - // 恢复途中用户真的滚到了别处:交还控制权并记录这个位置,避免恢复 - // 逻辑继续和用户抢滚动条。 - restoring = false - stopRestore() - adoptScrollPosition(current) + // 非恢复状态下,一瞬间大幅向上跳(虚拟列表挂载量高、浏览器钳制)只有按键 + // 翻页 / 拖滚动条这类意图才可能是用户行为,否则直接丢弃。 + const upJump = lastSaved > current ? lastSaved - current : 0 + if (upJump > maxSmoothJumpUp(el.clientHeight) && !(userDriven && userScrollAllowsJump)) { return } // 非用户滚动(恢复逻辑回填、虚拟列表挂载/量高、浏览器钳制)不写存储。 @@ -276,13 +379,12 @@ export function useScrollMemory(routeKey: string, userKey = 'anonymous'): void { if (saved <= 0) { el.scrollTop = 0 } else { + if (typeof ResizeObserver !== 'undefined' && content) { + observer = new ResizeObserver(onContentSizeChange) + observer.observe(content) + } tryRestore() if (restoring) { - const content = el.firstElementChild - if (typeof ResizeObserver !== 'undefined' && content instanceof HTMLElement) { - observer = new ResizeObserver(() => tryRestore()) - observer.observe(content) - } restorePumpUntil = performance.now() + 1000 restoreFrame = window.requestAnimationFrame(pumpRestore) restoreTimer = window.setTimeout(finishRestore, 10000) diff --git a/web/src/pages/LibraryMediaSections.tsx b/web/src/pages/LibraryMediaSections.tsx index 9f4b68b..87e06fc 100644 --- a/web/src/pages/LibraryMediaSections.tsx +++ b/web/src/pages/LibraryMediaSections.tsx @@ -8,10 +8,15 @@ import type { Media } from '../types' import type { SeriesCard } from '../utils/groupSeries' // 超过该数量后切换为虚拟滚动:DOM 只挂载视口内的卡片,大库不再线性劣化。 +// 判定必须用“库的总量”而不是“已加载条数”:分页补页会把已加载条数推过阈值, +// 若此时才切换渲染方式,整块网格的 DOM 子树会被替换、容器高度瞬间塌陷, +// 浏览器随之把 scrollTop 夹回顶部,滚动记忆恢复得再好也会当场失效。 const VIRTUALIZE_THRESHOLD = 200 type LibraryMediaSectionsProps = { isSeries: boolean + /** 当前列表的总条数(媒体库总量),用于稳定地决定是否走虚拟滚动。 */ + total: number items: Media[] seriesCards: SeriesCard[] selectedSeries: SeriesCard | null @@ -25,6 +30,7 @@ type LibraryMediaSectionsProps = { export function LibraryMediaSections({ isSeries, + total, items, seriesCards, selectedSeries, @@ -35,15 +41,22 @@ export function LibraryMediaSections({ cardActions, onSeriesClick, }: LibraryMediaSectionsProps) { + // 渲染方式在整次浏览里保持不变:总量超过阈值就一开始就用虚拟列表。 + const virtualizeByTotal = total > VIRTUALIZE_THRESHOLD + return ( <> {!isSeries && items.length > 0 && ( - { - const media = items[index] - // renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮, - // 配合 memo,父级无关状态变化不再级联重渲染全部卡片。 - return - }} /> + { + const media = items[index] + // renderActions 传函数引用:MediaCard 重渲染时才构建操作按钮, + // 配合 memo,父级无关状态变化不再级联重渲染全部卡片。 + return + }} + /> )} {!isSeries && items.length === 0 && ( @@ -51,17 +64,21 @@ export function LibraryMediaSections({ )} {isSeries && seriesCards.length > 0 && !selectedSeries && ( - { - const series = seriesCards[index] - return ( - - ) - }} /> + { + const series = seriesCards[index] + return ( + + ) + }} + /> )} {isSeries && seriesCards.length === 0 && !loading && ( @@ -94,8 +111,16 @@ const SeriesCardItem = memo(function SeriesCardItem({ ) }) -function MediaGrid({ count, renderItem }: { count: number; renderItem: (index: number) => ReactNode }) { - if (count <= VIRTUALIZE_THRESHOLD) { +function MediaGrid({ + count, + virtualize, + renderItem, +}: { + count: number + virtualize: boolean + renderItem: (index: number) => ReactNode +}) { + if (!virtualize) { return
{Array.from({ length: count }, (_, index) => renderItem(index))}
} return diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx index 62ccccd..90c3239 100644 --- a/web/src/pages/LibraryPage.tsx +++ b/web/src/pages/LibraryPage.tsx @@ -332,6 +332,7 @@ export function LibraryPage() {