mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 05:46:38 +08:00
优化
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import {
|
||||
buildSpreadIndexOf,
|
||||
buildSpreads,
|
||||
hasImageSize,
|
||||
loadImageSize,
|
||||
type ComicSpread,
|
||||
} from '../utils/comicSpread'
|
||||
|
||||
// 漫画双页铺开的分组 + 尺寸探测。
|
||||
//
|
||||
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
|
||||
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
|
||||
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
|
||||
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
|
||||
|
||||
/** 当前页往后探测几张。 */
|
||||
const PROBE_AHEAD = 8
|
||||
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
|
||||
const PROBE_BEHIND = 1
|
||||
|
||||
export interface ComicSpreads {
|
||||
/** 每屏要显示的图片序号。 */
|
||||
spreads: ComicSpread[]
|
||||
/** 图片序号 → 所在屏序号。 */
|
||||
spreadIndexOf: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @param srcs 整章图片地址。
|
||||
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
|
||||
* @param center 当前图片序号,决定探测窗口。
|
||||
*/
|
||||
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
|
||||
const [revision, setRevision] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || srcs.length === 0) return
|
||||
let cancelled = false
|
||||
const from = Math.max(0, center - PROBE_BEHIND)
|
||||
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
|
||||
const targets: string[] = []
|
||||
for (let i = from; i <= to; i += 1) {
|
||||
const src = srcs[i]
|
||||
if (src && !hasImageSize(src)) targets.push(src)
|
||||
}
|
||||
if (targets.length === 0) return
|
||||
for (const src of targets) {
|
||||
loadImageSize(src)
|
||||
.then(() => {
|
||||
if (!cancelled) setRevision((v) => v + 1)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [srcs, enabled, center])
|
||||
|
||||
const spreads = useMemo(() => {
|
||||
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
|
||||
void revision
|
||||
return buildSpreads(srcs)
|
||||
}, [srcs, revision])
|
||||
|
||||
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
|
||||
|
||||
return { spreads, spreadIndexOf }
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react'
|
||||
|
||||
/**
|
||||
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
|
||||
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
|
||||
* 而不是浏览器的整格跳变。
|
||||
*
|
||||
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
|
||||
*/
|
||||
const EASE_PER_FRAME = 0.2
|
||||
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
|
||||
const FRAME_MS = 1000 / 60
|
||||
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
|
||||
const EPSILON = 0.5
|
||||
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
|
||||
const MAX_FRAME_MS = 64
|
||||
|
||||
/**
|
||||
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
|
||||
*
|
||||
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
|
||||
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
|
||||
* - 容器不可滚动时不做 `preventDefault`。
|
||||
*
|
||||
* @param ref 滚动容器(`overflow-y: auto`)。
|
||||
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
|
||||
*/
|
||||
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
|
||||
const targetRef = useRef<number | null>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
const lastFrameRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el || !enabled) return
|
||||
|
||||
const stop = () => {
|
||||
if (rafRef.current !== null) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
rafRef.current = null
|
||||
}
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
}
|
||||
|
||||
const step = (now: number) => {
|
||||
rafRef.current = null
|
||||
const target = targetRef.current
|
||||
const node = ref.current
|
||||
if (target === null || !node) return
|
||||
|
||||
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
|
||||
lastFrameRef.current = now
|
||||
|
||||
const diff = target - node.scrollTop
|
||||
if (Math.abs(diff) < EPSILON) {
|
||||
node.scrollTop = target
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
const before = node.scrollTop
|
||||
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
|
||||
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
|
||||
// 再继续逼近就是 rAF 空转,直接收手。
|
||||
if (node.scrollTop === before) {
|
||||
targetRef.current = null
|
||||
lastFrameRef.current = 0
|
||||
return
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// ctrl/cmd+滚轮是浏览器缩放,别抢
|
||||
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
|
||||
const max = el.scrollHeight - el.clientHeight
|
||||
if (max <= 0) return
|
||||
e.preventDefault()
|
||||
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
|
||||
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
|
||||
const base = targetRef.current ?? el.scrollTop
|
||||
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
|
||||
if (rafRef.current === null) {
|
||||
lastFrameRef.current = 0
|
||||
rafRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
}
|
||||
|
||||
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
|
||||
const onTakeover = () => stop()
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: false })
|
||||
el.addEventListener('pointerdown', onTakeover)
|
||||
el.addEventListener('touchstart', onTakeover, { passive: true })
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel)
|
||||
el.removeEventListener('pointerdown', onTakeover)
|
||||
el.removeEventListener('touchstart', onTakeover)
|
||||
stop()
|
||||
}
|
||||
}, [ref, enabled])
|
||||
}
|
||||
Reference in New Issue
Block a user