This commit is contained in:
truewhile
2026-10-08 13:48:06 +08:00
parent 6adc3cb3b2
commit 99916c0b97
3 changed files with 122 additions and 4 deletions
+85
View File
@@ -0,0 +1,85 @@
import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
// 横向滑动手势:给「左右翻页」的阅读区补上手机上的滑动手势。
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
//
// 判定规则(和纵向滚动共存的关键):
// - 位移要够长(minDistance),短距离当点击处理,交给下面的分区点击;
// - 横向位移要明显大于纵向位移(dominance 倍),否则算纵向滑动/滚动,不翻页;
// - 手势时长不能太长(maxDuration),慢慢拖过去不算一次滑动。
//
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
interface HorizontalSwipeOptions {
/** 从左往右滑(手指向右移)。 */
onSwipeRight: () => void
/** 从右往左滑(手指向左移)。 */
onSwipeLeft: () => void
/** 触发翻页所需的最小水平位移(px)。 */
minDistance?: number
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
dominance?: number
/** 手势超过这个时长不算滑动(毫秒)。 */
maxDuration?: number
}
interface HorizontalSwipeHandlers {
onTouchStart: (e: ReactTouchEvent) => void
onTouchEnd: (e: ReactTouchEvent) => void
onTouchCancel: () => void
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
consumeSwipe: () => boolean
}
export function useHorizontalSwipe({
onSwipeRight,
onSwipeLeft,
minDistance = 45,
dominance = 1.2,
maxDuration = 800,
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
const consumedRef = useRef(false)
const onTouchStart = useCallback((e: ReactTouchEvent) => {
const t = e.touches[0]
if (!t) return
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
consumedRef.current = false
}, [])
const onTouchEnd = useCallback(
(e: ReactTouchEvent) => {
const start = startRef.current
startRef.current = null
const t = e.changedTouches[0]
if (!start || !t) return
const dx = t.clientX - start.x
const dy = t.clientY - start.y
if (Date.now() - start.t > maxDuration) return
if (Math.abs(dx) < minDistance) return
if (Math.abs(dx) < Math.abs(dy) * dominance) return
consumedRef.current = true
if (dx > 0) onSwipeRight()
else onSwipeLeft()
},
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration],
)
const onTouchCancel = useCallback(() => {
startRef.current = null
}, [])
const consumeSwipe = useCallback(() => {
if (!consumedRef.current) return false
consumedRef.current = false
return true
}, [])
return { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe }
}
+36 -4
View File
@@ -28,6 +28,7 @@ import {
type ReaderSearchOrigin,
} from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useHorizontalSwipe } from '../../hooks/useHorizontalSwipe'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
@@ -634,6 +635,13 @@ export default function ReaderViewPage() {
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
//
// 手机上再补一层横向滑动翻页:从左往右滑=上一页、从右往左滑=下一页。
// 滑动的判定与「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里。
const { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({
onSwipeRight: goPrev,
onSwipeLeft: goNext,
})
const handleZoneTap = useCallback(
(clientX: number, rect: DOMRect) => {
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
@@ -1005,15 +1013,39 @@ export default function ReaderViewPage() {
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
<div className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<div
className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]"
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
>
<button
type="button"
aria-label="上一页"
onClick={() => {
if (consumeSwipe()) return
goPrev()
}}
className="cursor-w-resize"
/>
<button
type="button"
aria-label="菜单"
onClick={() => setMenuOpen((v) => !v)}
onClick={() => {
if (consumeSwipe()) return
setMenuOpen((v) => !v)
}}
className="cursor-default"
/>
<button type="button" aria-label="下一页" onClick={goNext} className="cursor-e-resize" />
<button
type="button"
aria-label="下一页"
onClick={() => {
if (consumeSwipe()) return
goNext()
}}
className="cursor-e-resize"
/>
</div>
)}
</div>