This commit is contained in:
truewhile
2026-10-08 17:42:07 +08:00
parent 99916c0b97
commit d7665e4ef0
4 changed files with 226 additions and 32 deletions
+3 -1
View File
@@ -95,7 +95,9 @@
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。 - 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。 - 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。 - 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**。判定要同时满足「水平位移 ≥45px」「水平位移 ≥ 垂直位移的 1.2 倍」「手势时长 ≤800ms」,所以短距离仍按点击分区处理、纵向拖动不会被误判成翻页。滑动后浏览器可能仍补发一次 click 落在原分区按钮上(会「滑一页又点一页」翻两页),用 `consumeSwipe()` 让三个按钮的 `onClick` 先判断这次 click 是否已被滑动吃掉;标记在每次 `touchstart` 重置,所以真正滑动不产生 click 时也不会吞掉下一次点击。滑动本身复用既有的 220ms 平移动画,不额外做跟手拖拽。 - 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移仍旧由 React 写在里层,跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。分两层是为了避开「跟手位移把 DOM 改成 React 不知道的值、React 又认为 transform 没变而不去写」把页面留在拖动位置上的坑。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
- 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。 - 滚动模式的滚轮(`useSmoothWheelScroll`):把滚轮格数累加成一个目标位置,再逐帧向它逼近,滚动连续、松手后自己滑行一段,仿手机上下滑动,而不是浏览器整格跳变。只在文本/漫画的上下滚动模式接管;翻页模式仍走下面的翻页监听器。按真实帧间隔换算逼近比例,120Hz 屏上速度不会翻倍;`ctrl/cmd+滚轮` 保留缩放;手指按下(滑动、拖滚动条、点分区)立刻停掉动画把控制权交还用户。
- 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。 - 漫画双页铺开(`hooks/useComicSpreads.ts` + `utils/comicSpread.ts`,legado 无对应项,是桌面端补充):窗口宽度 ≥900px 且漫画处于翻页模式时,一屏并排显示两页(`settings.comicDoublePage` 为设备级偏好,默认开,可在菜单里关;窗口不够宽时自动退回单页)。分组规则同 Tachiyomi 的 dual page——宽度明显大于高度(比值 ≥1.1)的图视为「跨页宽图」独占一屏,其余连续的竖版页两两配对;列表接口只给 URL,尺寸靠 `new Image()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。 - 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
+67 -7
View File
@@ -4,15 +4,24 @@ import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。 // 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
// //
// 判定规则(和纵向滚动共存的关键): // 判定规则(和纵向滚动共存的关键):
// - 位移要够长(minDistance),短距离当点击处理,交给下面的分区点击; // - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
// - 横向位移要明显大于纵向位移(dominance 倍),否则算纵向滑动/滚动,不翻页; // 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
// - 手势时长不能太长(maxDuration),慢慢拖过去不算一次滑动。 // - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
// 短距离当点击处理,交给下面的分区点击。
//
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
// (慢慢拖过去也是拖动)。
// //
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上), // 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句: // 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记, // 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
// 所以真实滑动不产生 click 时也不会误吞下一次点击。 // 所以真实滑动不产生 click 时也不会误吞下一次点击。
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
const DIRECTION_SLOP = 12
interface HorizontalSwipeOptions { interface HorizontalSwipeOptions {
/** 从左往右滑(手指向右移)。 */ /** 从左往右滑(手指向右移)。 */
onSwipeRight: () => void onSwipeRight: () => void
@@ -22,12 +31,17 @@ interface HorizontalSwipeOptions {
minDistance?: number minDistance?: number
/** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */ /** 水平位移需达到垂直位移的这个倍数才算横滑,避免和纵向滚动打架。 */
dominance?: number dominance?: number
/** 手势超过这个时长不算滑动(毫秒)。 */ /** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */
maxDuration?: number maxDuration?: number
/** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */
onDrag?: (dx: number) => void
/** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */
onDragEnd?: (dx: number) => void
} }
interface HorizontalSwipeHandlers { interface HorizontalSwipeHandlers {
onTouchStart: (e: ReactTouchEvent) => void onTouchStart: (e: ReactTouchEvent) => void
onTouchMove: (e: ReactTouchEvent) => void
onTouchEnd: (e: ReactTouchEvent) => void onTouchEnd: (e: ReactTouchEvent) => void
onTouchCancel: () => void onTouchCancel: () => void
/** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */ /** 点击回调里先调用;返回 true 表示这次点击已被滑动消费,应跳过默认动作。 */
@@ -40,21 +54,62 @@ export function useHorizontalSwipe({
minDistance = 45, minDistance = 45,
dominance = 1.2, dominance = 1.2,
maxDuration = 800, maxDuration = 800,
onDrag,
onDragEnd,
}: HorizontalSwipeOptions): HorizontalSwipeHandlers { }: HorizontalSwipeOptions): HorizontalSwipeHandlers {
const startRef = useRef<{ x: number; y: number; t: number } | null>(null) const startRef = useRef<{ x: number; y: number; t: number } | null>(null)
const consumedRef = useRef(false) const consumedRef = useRef(false)
/** 本次手势是否已判定为横滑并在跟手。 */
const draggingRef = useRef(false)
const lastDxRef = useRef(0)
const onTouchStart = useCallback((e: ReactTouchEvent) => { const onTouchStart = useCallback((e: ReactTouchEvent) => {
const t = e.touches[0] const t = e.touches[0]
if (!t) return if (!t) return
startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() } startRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }
consumedRef.current = false 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( const onTouchEnd = useCallback(
(e: ReactTouchEvent) => { (e: ReactTouchEvent) => {
const start = startRef.current const start = startRef.current
startRef.current = null 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] const t = e.changedTouches[0]
if (!start || !t) return if (!start || !t) return
@@ -68,12 +123,17 @@ export function useHorizontalSwipe({
if (dx > 0) onSwipeRight() if (dx > 0) onSwipeRight()
else onSwipeLeft() else onSwipeLeft()
}, },
[onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration], [onSwipeRight, onSwipeLeft, minDistance, dominance, maxDuration, onDragEnd],
) )
const onTouchCancel = useCallback(() => { const onTouchCancel = useCallback(() => {
startRef.current = null startRef.current = null
}, []) if (!draggingRef.current) return
draggingRef.current = false
// 手势被系统打断(来电、边缘返回手势):按「没翻页」处理,让内容回弹。
// 传 0 而不是最后的位置——打断不等于用户想翻页,别顺手翻过去。
onDragEnd?.(0)
}, [onDragEnd])
const consumeSwipe = useCallback(() => { const consumeSwipe = useCallback(() => {
if (!consumedRef.current) return false if (!consumedRef.current) return false
@@ -81,5 +141,5 @@ export function useHorizontalSwipe({
return true return true
}, []) }, [])
return { onTouchStart, onTouchEnd, onTouchCancel, consumeSwipe } return { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel, consumeSwipe }
} }
+47 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState, type RefObject } from 'react'
import { Loader2 } from 'lucide-react' import { Loader2 } from 'lucide-react'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll' import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
@@ -27,6 +27,13 @@ interface ReaderComicProps {
spread?: number[] | null spread?: number[] | null
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */ /** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
imageFit?: ComicImageFit imageFit?: ComicImageFit
/**
* 翻页模式的跟手拖拽:把当前页左右各摆一页到舞台两侧,整条舞台由外层按
* stageRef 直接平移(见 ReaderViewPage 的 onPageDrag)。并排铺开两页时关闭。
*/
draggable?: boolean
/** 跟手拖拽要平移的舞台元素(由外层拿着直接写 transform,不走 React state)。 */
stageRef?: RefObject<HTMLDivElement>
} }
function ComicImage({ function ComicImage({
@@ -36,6 +43,7 @@ function ComicImage({
half = false, half = false,
imageFit = 'default', imageFit = 'default',
viewportHeight = 0, viewportHeight = 0,
eager = false,
}: { }: {
src: string src: string
theme: { bg: string; text: string; accent: string } theme: { bg: string; text: string; accent: string }
@@ -47,6 +55,10 @@ function ComicImage({
imageFit?: ComicImageFit imageFit?: ComicImageFit
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。 // viewportHeight:滚动容器高度(px),height/long 档位按它换算。
viewportHeight?: number viewportHeight?: number
// eager:跟手拖拽摆在舞台两侧的那两页。它们被 overflow-hidden 裁在屏幕外,
// 浏览器的懒加载认为「离视口还远」而不去取,横滑过去就会看到转圈,
// 所以这两页立刻加载(顺带把下一页提前拉好,翻页不用现等)。
eager?: boolean
}) { }) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading') const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
@@ -82,7 +94,7 @@ function ComicImage({
> >
<img <img
src={src} src={src}
loading="lazy" loading={eager ? 'eager' : 'lazy'}
alt="" alt=""
onLoad={(e) => { onLoad={(e) => {
const el = e.currentTarget 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 scrollRef = useRef<HTMLDivElement>(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([]) const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const restoredRef = useRef(false) 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 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 idxs = group.length > 0 ? group : [Math.min(Math.max(page, 0), images.length - 1)]
const paired = idxs.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 ( return (
<div className="flex h-full items-center justify-center"> <div className="relative h-full w-full overflow-hidden">
{idxs.map((i) => ( <div ref={stageRef} className="relative h-full w-full">
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} /> {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> </div>
) )
} }
+109 -17
View File
@@ -55,6 +55,11 @@ const COMIC_SAVE_INTERVAL_MS = 2_000
/** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */ /** 菜单打开时正文下移过渡(与顶栏动画同节奏)。 */
const MENU_SHIFT = 'transition-transform duration-200' const MENU_SHIFT = 'transition-transform duration-200'
/** 翻页动画时长。正文列的平移过渡,以及跟手拖拽松手后的回弹/滑完,都用这一条。 */
const PAGE_TRANSITION = 'transform 220ms ease'
/** 跟手拖拽要翻页所需的最小位移(px)。 */
const DRAG_TURN_DISTANCE = 45
// 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素) // 滚轮翻页参数(deltaY 已按 deltaMode 归一化成像素)
/** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */ /** 单次 deltaY 达到这个量视为鼠标滚轮的一格(一格一页)。 */
const WHEEL_NOTCH = 40 const WHEEL_NOTCH = 40
@@ -88,6 +93,25 @@ function firstReadableIndex(chapters: ReaderChapter[]): number {
return j === -1 ? 0 : j 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() { export default function ReaderViewPage() {
const { bookId = '' } = useParams() const { bookId = '' } = useParams()
const settings = useReaderSettingsStore() const settings = useReaderSettingsStore()
@@ -171,6 +195,13 @@ export default function ReaderViewPage() {
comicDoublePage && comicSpreads.length > 0 comicDoublePage && comicSpreads.length > 0
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1) ? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 0 : 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 坐标分区: // (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。 // 纵向拖动不会产生 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, onSwipeRight: goPrev,
onSwipeLeft: goNext, onSwipeLeft: goNext,
minDistance: DRAG_TURN_DISTANCE,
onDrag: pageDragEnabled ? onPageDrag : undefined,
onDragEnd: pageDragEnabled ? onPageDragEnd : undefined,
}) })
const handleZoneTap = useCallback( const handleZoneTap = useCallback(
(clientX: number, rect: DOMRect) => { (clientX: number, rect: DOMRect) => {
@@ -909,6 +995,8 @@ export default function ReaderViewPage() {
page={comicPage} page={comicPage}
imageFit={comicScrollFit} imageFit={comicScrollFit}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null} spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
draggable={comicDragSinglePage}
stageRef={comicStageRef}
onZone={(zone) => { onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v) if (zone === 'center') setMenuOpen((v) => !v)
}} }}
@@ -934,20 +1022,23 @@ export default function ReaderViewPage() {
style={menuShiftStyle} style={menuShiftStyle}
> >
<div ref={viewportRef} className="relative h-full overflow-hidden"> <div ref={viewportRef} className="relative h-full overflow-hidden">
<div {/* 跟手层:跟手拖拽的位移写在这一层,分页平移仍旧写在里层(React 管)。 */}
ref={contentRef} <div ref={pageDragRef} className="h-full">
className="h-full" <div
style={{ ref={contentRef}
columnWidth: `${Math.max(vw, 1)}px`, className="h-full"
columnGap: `${COLUMN_GAP}px`, style={{
columnFill: 'auto', columnWidth: `${Math.max(vw, 1)}px`,
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`, columnGap: `${COLUMN_GAP}px`,
transition: 'transform 220ms ease', columnFill: 'auto',
fontSize: settings.fontSize, transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
lineHeight: settings.lineHeight, transition: PAGE_TRANSITION,
}} fontSize: settings.fontSize,
> lineHeight: settings.lineHeight,
{blocks.map((b, i) => renderParagraph(b, i))} }}
>
{blocks.map((b, i) => renderParagraph(b, i))}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1016,6 +1107,7 @@ export default function ReaderViewPage() {
<div <div
className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]" className="absolute inset-0 z-[1] grid grid-cols-[30%_40%_30%]"
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel} onTouchCancel={onTouchCancel}
> >