feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { getSyncedTimestamp } from '~/composables/useSyncedTime'
|
||||
|
||||
const STORAGE_KEY = 'voicehub_scroll_positions'
|
||||
/** 恢复等待上限:列表数据异步渲染后高度才达标,轮询至到位或超时放弃 */
|
||||
const RESTORE_MAX_WAIT_MS = 3000
|
||||
/** 认为已恢复到目标位置的容差 */
|
||||
const RESTORE_TOLERANCE_PX = 8
|
||||
/** 低于该值的保存位置不占用存储 */
|
||||
const MIN_SAVE_PX = 8
|
||||
/** 相邻保存最小间隔 */
|
||||
const SAVE_INTERVAL_MS = 300
|
||||
|
||||
type ScrollMap = Record<string, number>
|
||||
|
||||
const readMap = (): ScrollMap => {
|
||||
if (!import.meta.client) return {}
|
||||
try {
|
||||
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/** 以重建方式写入,过滤掉当前键与低于阈值的历史记录 */
|
||||
const writeTop = (key: string, top: number): void => {
|
||||
if (!import.meta.client || !key) return
|
||||
try {
|
||||
const next: ScrollMap = {}
|
||||
for (const [k, v] of Object.entries(readMap())) {
|
||||
if (k !== key && v >= MIN_SAVE_PX) next[k] = v
|
||||
}
|
||||
if (top >= MIN_SAVE_PX) next[key] = Math.floor(top)
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// 存储不可用(隐私模式等)时静默降级
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面滚动位置记忆:window 滚动距离按「路由 fullPath + 子键」存取。
|
||||
* 子键用于区分同一页面内部 Tab(如首页排期/歌曲/投稿/通知、控制台各面板),
|
||||
* 各 Tab 独立记忆并在切回时自动恢复;页面返回时等内容渲染到足够高度再定位,
|
||||
* 避免被异步加载打断
|
||||
*/
|
||||
export const useScrollMemory = (getSubKey?: () => string): void => {
|
||||
const route = useRoute()
|
||||
|
||||
let lastSavedAt = 0
|
||||
// 恢复期间程序化滚动会触发 scroll 事件,此标记避免把中间值当成用户位置写回
|
||||
let restoring = false
|
||||
/** 代次令牌:新恢复任务或卸载后使仍在进行的轮询失效 */
|
||||
let restoreToken = 0
|
||||
|
||||
const keyOf = (sub?: string): string => (sub ? `${route.fullPath}#${sub}` : route.fullPath)
|
||||
|
||||
const saveNow = (): void => {
|
||||
lastSavedAt = getSyncedTimestamp()
|
||||
writeTop(keyOf(getSubKey?.()), window.scrollY)
|
||||
}
|
||||
|
||||
const onScroll = (): void => {
|
||||
if (restoring) return
|
||||
if (getSyncedTimestamp() - lastSavedAt < SAVE_INTERVAL_MS) return
|
||||
saveNow()
|
||||
}
|
||||
|
||||
// 恢复等待期间尚未到达目标,保留原记录避免被中间值覆盖
|
||||
const safeSaveNow = (): void => {
|
||||
if (restoring) return
|
||||
saveNow()
|
||||
}
|
||||
|
||||
const startRestore = (key: string): void => {
|
||||
const target = readMap()[key]
|
||||
if (!target || target <= RESTORE_TOLERANCE_PX) return
|
||||
restoring = true
|
||||
const token = ++restoreToken
|
||||
const startedAt = getSyncedTimestamp()
|
||||
requestAnimationFrame(function tryRestore() {
|
||||
if (token !== restoreToken) return
|
||||
if (getSyncedTimestamp() - startedAt > RESTORE_MAX_WAIT_MS) {
|
||||
restoring = false
|
||||
return
|
||||
}
|
||||
const maxY = document.documentElement.scrollHeight - window.innerHeight
|
||||
if (maxY >= target - RESTORE_TOLERANCE_PX) {
|
||||
window.scrollTo(0, target)
|
||||
restoring = false
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(tryRestore)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startRestore(keyOf(getSubKey?.()))
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
window.addEventListener('beforeunload', safeSaveNow)
|
||||
})
|
||||
|
||||
if (getSubKey) {
|
||||
watch(getSubKey, (next, prev) => {
|
||||
// 切换瞬间视口位置仍属于旧 Tab,先归档;新 Tab 内容渲染后再找回归属位置
|
||||
writeTop(keyOf(prev), window.scrollY)
|
||||
startRestore(keyOf(next))
|
||||
})
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
restoreToken++
|
||||
restoring = false
|
||||
safeSaveNow()
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
window.removeEventListener('beforeunload', safeSaveNow)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user