mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-11 07:46:37 +08:00
优化
This commit is contained in:
@@ -4,15 +4,24 @@ import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
|
||||
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
|
||||
//
|
||||
// 判定规则(和纵向滚动共存的关键):
|
||||
// - 位移要够长(minDistance),短距离当点击处理,交给下面的分区点击;
|
||||
// - 横向位移要明显大于纵向位移(dominance 倍),否则算纵向滑动/滚动,不翻页;
|
||||
// - 手势时长不能太长(maxDuration),慢慢拖过去不算一次滑动。
|
||||
// - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
|
||||
// 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
|
||||
// - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
|
||||
// 短距离当点击处理,交给下面的分区点击。
|
||||
//
|
||||
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
|
||||
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
|
||||
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
|
||||
// (慢慢拖过去也是拖动)。
|
||||
//
|
||||
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
|
||||
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
|
||||
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
|
||||
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
|
||||
|
||||
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
|
||||
const DIRECTION_SLOP = 12
|
||||
|
||||
interface HorizontalSwipeOptions {
|
||||
/** 从左往右滑(手指向右移)。 */
|
||||
onSwipeRight: () => void
|
||||
@@ -22,12 +31,17 @@ interface HorizontalSwipeOptions {
|
||||
minDistance?: number
|
||||
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
|
||||
dominance?: number
|
||||
/** 手势超过这个时长不算滑动(毫秒)。 */
|
||||
/** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
|
||||
maxDuration?: number
|
||||
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
|
||||
onDrag?: (dx: number) => void
|
||||
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
|
||||
onDragEnd?: (dx: number) => void
|
||||
}
|
||||
|
||||
interface HorizontalSwipeHandlers {
|
||||
onTouchStart: (e: ReactTouchEvent) => void
|
||||
onTouchMove: (e: ReactTouchEvent) => void
|
||||
onTouchEnd: (e: ReactTouchEvent) => void
|
||||
onTouchCancel: () => void
|
||||
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
|
||||
@@ -40,21 +54,62 @@ export function useHorizontalSwipe({
|
||||
minDistance = 45,
|
||||
dominance = 1.2,
|
||||
maxDuration = 800,
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
}: HorizontalSwipeOptions): HorizontalSwipeHandlers {
|
||||
const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
|
||||
const consumedRef = useRef(false)
|
||||
/** 本次手势是否已判定为横滑并在跟手。 */
|
||||
const draggingRef = useRef(false)
|
||||
const lastDxRef = useRef(0)
|
||||
|
||||
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
|
||||
draggingRef.current = false
|
||||
lastDxRef.current = 0
|
||||
}, [])
|
||||
|
||||
const onTouchMove = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
const t = e.touches[0]
|
||||
if (!start || !t) return
|
||||
const dx = t.clientX - start.x
|
||||
const dy = t.clientY - start.y
|
||||
if (!draggingRef.current) {
|
||||
if (!onDrag) return
|
||||
if (Math.abs(dx) < DIRECTION_SLOP && Math.abs(dy) < DIRECTION_SLOP) return
|
||||
// 纵向占优:这是滚动,不是翻页。丢掉起点,之后不再判定滑动也不吞 click。
|
||||
if (Math.abs(dx) < Math.abs(dy) * dominance) {
|
||||
startRef.current = null
|
||||
return
|
||||
}
|
||||
draggingRef.current = true
|
||||
}
|
||||
lastDxRef.current = dx
|
||||
onDrag?.(dx)
|
||||
},
|
||||
[onDrag, dominance],
|
||||
)
|
||||
|
||||
const onTouchEnd = useCallback(
|
||||
(e: ReactTouchEvent) => {
|
||||
const start = startRef.current
|
||||
startRef.current = null
|
||||
if (draggingRef.current) {
|
||||
draggingRef.current = false
|
||||
// 跟手手势一律吞掉随之而来的 click:翻页已经在 onDragEnd 里决定了,
|
||||
// 不吞的话短距离拖动会「回弹 + 再翻一页」。
|
||||
consumedRef.current = true
|
||||
const t = e.changedTouches[0]
|
||||
// 用松手这一刻的位置而不是最后一次 touchmove:手指最后那几像素也要算进去。
|
||||
const dx = start && t ? t.clientX - start.x : lastDxRef.current
|
||||
onDragEnd?.(dx)
|
||||
return
|
||||
}
|
||||
const t = e.changedTouches[0]
|
||||
if (!start || !t) return
|
||||
|
||||
@@ -68,12 +123,17 @@ export function useHorizontalSwipe({
|
||||
if (dx > 0) onSwipeRight()
|
||||
else onSwipeLeft()
|
||||
},
|
||||
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration],
|
||||
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration, onDragEnd],
|
||||
)
|
||||
|
||||
const onTouchCancel = useCallback(() => {
|
||||
startRef.current = null
|
||||
}, [])
|
||||
if (!draggingRef.current) return
|
||||
draggingRef.current = false
|
||||
// 手势被系统打断(来电、边缘返回手势):按「没翻页」处理,让内容回弹。
|
||||
// 传 0 而不是最后的位置——打断不等于用户想翻页,别顺手翻过去。
|
||||
onDragEnd?.(0)
|
||||
}, [onDragEnd])
|
||||
|
||||
const consumeSwipe = useCallback(() => {
|
||||
if (!consumedRef.current) return false
|
||||
@@ -81,5 +141,5 @@ export function useHorizontalSwipe({
|
||||
return true
|
||||
}, [])
|
||||
|
||||
return { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe }
|
||||
return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
|
||||
@@ -27,6 +27,13 @@ interface ReaderComicProps {
|
||||
spread?: number[] | null
|
||||
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
|
||||
imageFit?: ComicImageFit
|
||||
/**
|
||||
* 翻页模式的跟手拖拽:把当前页左右各摆一页到舞台两侧,整条舞台由外层按
|
||||
* stageRef 直接平移(见 ReaderViewPage 的 onPageDrag)。并排铺开两页时关闭。
|
||||
*/
|
||||
draggable?: boolean
|
||||
/** 跟手拖拽要平移的舞台元素(由外层拿着直接写 transform,不走 React state)。 */
|
||||
stageRef?: RefObject<HTMLDivElement>
|
||||
}
|
||||
|
||||
function ComicImage({
|
||||
@@ -36,6 +43,7 @@ function ComicImage({
|
||||
half = false,
|
||||
imageFit = 'default',
|
||||
viewportHeight = 0,
|
||||
eager = false,
|
||||
}: {
|
||||
src: string
|
||||
theme: { bg: string; text: string; accent: string }
|
||||
@@ -47,6 +55,10 @@ function ComicImage({
|
||||
imageFit?: ComicImageFit
|
||||
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
|
||||
viewportHeight?: number
|
||||
// eager:跟手拖拽摆在舞台两侧的那两页。它们被 overflow-hidden 裁在屏幕外,
|
||||
// 浏览器的懒加载认为「离视口还远」而不去取,横滑过去就会看到转圈,
|
||||
// 所以这两页立刻加载(顺带把下一页提前拉好,翻页不用现等)。
|
||||
eager?: boolean
|
||||
}) {
|
||||
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
|
||||
|
||||
@@ -82,7 +94,7 @@ function ComicImage({
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
loading="lazy"
|
||||
loading={eager ? 'eager' : 'lazy'}
|
||||
alt=""
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget
|
||||
@@ -114,7 +126,7 @@ function ComicImage({
|
||||
)
|
||||
}
|
||||
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
|
||||
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default', draggable = false, stageRef }: ReaderComicProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const restoredRef = useRef(false)
|
||||
@@ -174,11 +186,39 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
|
||||
const group = (spread ?? []).filter((i) => i >= 0 && i < images.length)
|
||||
const idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
|
||||
const paired = idxs.length > 1
|
||||
|
||||
if (!draggable) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 跟手拖拽:当前页左右各摆一页,整条舞台由外层直接平移(stageRef),
|
||||
// 于是手指横滑时左右两页就在屏幕边上,滑到一半松手能接着滑完。
|
||||
// 外面再套一层 overflow-hidden:桌面端正文列限宽 900px 居中,两侧那两页要是
|
||||
// 溢出到窗口里会在留白处露出来。
|
||||
//
|
||||
// 单元格按图片序号做 key、位置用 left 百分比铺开,而不是固定三个「上一页/当前/下一页」
|
||||
// 槽位换内容:翻页时变的只是 left 偏移,<img> 的 src 不变,浏览器不会重新解码、
|
||||
// 也不会先闪一下转圈。两侧那两页同时充当下一页/上一页的预取。
|
||||
const cells = [page - 1, page, page + 1].filter((i) => i >= 0 && i < images.length)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
{idxs.map((i) => (
|
||||
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
|
||||
))}
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
<div ref={stageRef} className="relative h-full w-full">
|
||||
{cells.map((i) => (
|
||||
<div
|
||||
key={images[i]}
|
||||
className="absolute inset-y-0 flex items-center justify-center"
|
||||
style={{ left: `${(i - page) * 100}%`, width: '100%' }}
|
||||
>
|
||||
<ComicImage src={images[i]} theme={theme} fit eager={i !== page} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -55,6 +55,11 @@ const COMIC_SAVE_INTERVAL_MS = 2_000
|
||||
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
|
||||
const MENU_SHIFT = 'transition-transform duration-200'
|
||||
|
||||
/** 翻页动画时长。正文列的平移过渡,以及跟手拖拽松手后的回弹/滑完,都用这一条。 */
|
||||
const PAGE_TRANSITION = 'transform 220ms ease'
|
||||
/** 跟手拖拽要翻页所需的最小位移(px)。 */
|
||||
const DRAG_TURN_DISTANCE = 45
|
||||
|
||||
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
|
||||
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
|
||||
const WHEEL_NOTCH = 40
|
||||
@@ -88,6 +93,25 @@ function firstReadableIndex(chapters: ReaderChapter[]): number {
|
||||
return j === -1 ? 0 : j
|
||||
}
|
||||
|
||||
/**
|
||||
* 漫画跟手拖拽松手后「滑完这一页」。
|
||||
*
|
||||
* 舞台两侧各摆着相邻的一页,手指拖到哪儿舞台就平移到哪儿。松手要翻页时,
|
||||
* 先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),
|
||||
* 读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;与此同时页码 +1/-1,
|
||||
* ReaderComic 那边单元格的 left 偏移在同一次提交里跟着换,两者相抵,
|
||||
* 屏幕上看到的就是新页从手指离开的位置平滑归位。
|
||||
*/
|
||||
function settleComicDrag(el: HTMLDivElement, turn: 1 | -1, dx: number, turnPage: () => void): void {
|
||||
const width = el.clientWidth || 1
|
||||
el.style.transition = 'none'
|
||||
el.style.transform = `translateX(${turn > 0 ? width + dx : -width + dx}px)`
|
||||
void el.offsetWidth
|
||||
el.style.transition = PAGE_TRANSITION
|
||||
el.style.transform = 'translateX(0px)'
|
||||
turnPage()
|
||||
}
|
||||
|
||||
export default function ReaderViewPage() {
|
||||
const { bookId = '' } = useParams()
|
||||
const settings = useReaderSettingsStore()
|
||||
@@ -171,6 +195,13 @@ export default function ReaderViewPage() {
|
||||
comicDoublePage && comicSpreads.length > 0
|
||||
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
|
||||
: 0
|
||||
/** 当前这一屏是不是并排两页(桌面端双页铺开)。 */
|
||||
const comicPaired = comicDoublePage && (comicSpreads[comicSpreadIndex]?.length ?? 0) > 1
|
||||
/**
|
||||
* 漫画翻页模式且单页显示时接跟手拖拽:舞台左右各摆一页,手指横滑整条舞台跟着走。
|
||||
* 并排两页时不接——两侧要摆的是「下一屏」而不是下一张,交给原来的滑动判定即可。
|
||||
*/
|
||||
const comicDragSinglePage = contentType === 'image' && settings.pageMode === 'page' && !comicPaired
|
||||
|
||||
// ── 漫画滚动模式的图片显示尺寸 ──
|
||||
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
|
||||
@@ -636,11 +667,66 @@ export default function ReaderViewPage() {
|
||||
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
|
||||
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
|
||||
//
|
||||
// 手机上再补一层横向滑动翻页:从左往右滑=上一页、从右往左滑=下一页。
|
||||
// 滑动的判定与「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里。
|
||||
const { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({
|
||||
// 手机上再补一层横向滑动:从左往右滑=上一页、从右往左滑=下一页,并且跟手——
|
||||
// 滑动途中内容就跟着手指走,松手再决定翻页还是回弹(判定、跟手位移与
|
||||
// 「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里)。
|
||||
//
|
||||
// 跟手拖拽平移的是两个不同的元素:
|
||||
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。分页平移仍旧由 React 写在
|
||||
// 里层,跟手层只管手指位移;松手时两层同时动——跟手位移归零 + 页码变化——
|
||||
// 合起来就是一次连续滑动。分两层而不是直接改里层,是为了避免「跟手位移把 DOM
|
||||
// 改成了 React 不知道的值、React 又认为 transform 没变而不去写」把页面留在拖动位置。
|
||||
// - 漫画:平移 ReaderComic 的舞台(comicStageRef)。舞台两侧摆着上一页/下一页
|
||||
// (见那边的 carousel 分支),翻页时单元格的 left 偏移与舞台位移在同一次提交里
|
||||
// 相抵,画面看不出切换。
|
||||
const pageDragRef = useRef<HTMLDivElement>(null)
|
||||
const comicStageRef = useRef<HTMLDivElement>(null)
|
||||
const pageDragEnabled = settings.pageMode === 'page' && (contentType === 'text' || comicDragSinglePage)
|
||||
|
||||
const onPageDrag = useCallback(
|
||||
(dx: number) => {
|
||||
const el = contentType === 'image' ? comicStageRef.current : pageDragRef.current
|
||||
if (!el) return
|
||||
el.style.transition = 'none'
|
||||
el.style.transform = `translateX(${dx}px)`
|
||||
},
|
||||
[contentType],
|
||||
)
|
||||
|
||||
const onPageDragEnd = useCallback(
|
||||
(dx: number) => {
|
||||
if (contentType === 'image') {
|
||||
const el = comicStageRef.current
|
||||
if (!el) return
|
||||
if (dx <= -DRAG_TURN_DISTANCE) {
|
||||
settleComicDrag(el, 1, dx, goNext)
|
||||
return
|
||||
}
|
||||
if (dx >= DRAG_TURN_DISTANCE) {
|
||||
settleComicDrag(el, -1, dx, goPrev)
|
||||
return
|
||||
}
|
||||
el.style.transition = PAGE_TRANSITION
|
||||
el.style.transform = 'translateX(0px)'
|
||||
return
|
||||
}
|
||||
const el = pageDragRef.current
|
||||
if (!el) return
|
||||
// 跟手层归零与页码平移同时进行,合成一次连续滑动(位移不够时就是回弹)。
|
||||
el.style.transition = PAGE_TRANSITION
|
||||
el.style.transform = 'translateX(0px)'
|
||||
if (dx <= -DRAG_TURN_DISTANCE) goNext()
|
||||
else if (dx >= DRAG_TURN_DISTANCE) goPrev()
|
||||
},
|
||||
[contentType, goNext, goPrev],
|
||||
)
|
||||
|
||||
const { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe } = useHorizontalSwipe({
|
||||
onSwipeRight: goPrev,
|
||||
onSwipeLeft: goNext,
|
||||
minDistance: DRAG_TURN_DISTANCE,
|
||||
onDrag: pageDragEnabled ? onPageDrag : undefined,
|
||||
onDragEnd: pageDragEnabled ? onPageDragEnd : undefined,
|
||||
})
|
||||
const handleZoneTap = useCallback(
|
||||
(clientX: number, rect: DOMRect) => {
|
||||
@@ -909,6 +995,8 @@ export default function ReaderViewPage() {
|
||||
page={comicPage}
|
||||
imageFit={comicScrollFit}
|
||||
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
|
||||
draggable={comicDragSinglePage}
|
||||
stageRef={comicStageRef}
|
||||
onZone={(zone) => {
|
||||
if (zone === 'center') setMenuOpen((v) => !v)
|
||||
}}
|
||||
@@ -934,20 +1022,23 @@ export default function ReaderViewPage() {
|
||||
style={menuShiftStyle}
|
||||
>
|
||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full"
|
||||
style={{
|
||||
columnWidth: `${Math.max(vw, 1)}px`,
|
||||
columnGap: `${COLUMN_GAP}px`,
|
||||
columnFill: 'auto',
|
||||
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
||||
transition: 'transform 220ms ease',
|
||||
fontSize: settings.fontSize,
|
||||
lineHeight: settings.lineHeight,
|
||||
}}
|
||||
>
|
||||
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||
{/* 跟手层:跟手拖拽的位移写在这一层,分页平移仍旧写在里层(React 管)。 */}
|
||||
<div ref={pageDragRef} className="h-full">
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="h-full"
|
||||
style={{
|
||||
columnWidth: `${Math.max(vw, 1)}px`,
|
||||
columnGap: `${COLUMN_GAP}px`,
|
||||
columnFill: 'auto',
|
||||
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
||||
transition: PAGE_TRANSITION,
|
||||
fontSize: settings.fontSize,
|
||||
lineHeight: settings.lineHeight,
|
||||
}}
|
||||
>
|
||||
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1016,6 +1107,7 @@ export default function ReaderViewPage() {
|
||||
<div
|
||||
className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]"
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onTouchCancel={onTouchCancel}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user