Compare commits

..

4 Commits

Author SHA1 Message Date
truewhile 2bf1b27e23 优化翻页 2026-10-08 20:53:39 +08:00
truewhile 48a1c36a7a 优化 2026-10-08 20:13:46 +08:00
truewhile d7665e4ef0 优化 2026-10-08 17:42:07 +08:00
truewhile 99916c0b97 优化 2026-10-08 13:48:06 +08:00
4 changed files with 657 additions and 112 deletions
+3
View File
@@ -95,6 +95,9 @@
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
- 手机横向滑动翻页(`hooks/useHorizontalSwipe.ts`):翻页模式(文本/漫画)在三分区覆盖层上再接一层触摸手势——**从左往右滑=上一页、从右往左滑=下一页**,并且**跟手**:位移超过 12px 且横向明显大于纵向(1.2 倍)之后,内容就跟着手指走,松手再按「位移 ≥45px」决定翻页还是回弹(跟手手势不再看手势时长,慢慢拖过去同样是拖动)。跟手手势一律吞掉随之而来的 click,否则短距离拖动会「回弹 + 再翻一页」;没走跟手的手势仍按原来的「≥45px + 时长 ≤800ms」判定一次滑动,短距离当点击交给分区按钮。`consumeSwipe()` 让三个分区按钮的 `onClick` 先判断这次 click 是否已被手势吃掉,标记在每次 `touchstart` 重置。
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`pageDragRef`),分页平移由 `applyTextPageTransform` 命令式写在里层(`contentRef`),跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。正文用 `ReaderTextPageBody` memo,避免长章每次 `setPage` 重渲染整棵段落树(手机会先卡一下再翻页)。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
- 漫画单页翻页的跟手轨道与预取(`ReaderComic` 的 `draggable` 分支):翻页模式且没有并排铺开两页时,当前页左右各摆一页,手指横滑整条舞台跟着走。单元格按图片序号做 key、位置用 `left` 百分比铺开(不是固定三个槽位换内容),所以翻页时变的只是 `left`,`<img>` 的 src 不变,浏览器不会重新解码、也不会先闪一下转圈。松手要翻页时先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;同时页码 +1/-1,单元格的 `left` 在同一次提交里跟着换,两边相抵,屏幕上看不出切换。**两侧那两页同时就是下一页/上一页的预取**(带 `eager`:它们被 `overflow-hidden` 裁在屏幕外,懒加载会认为「离视口还远」而不去取,横滑过去就会看到转圈)——这正是「手机上翻一页要等一秒、电脑上(双页开着会顺带往后探测 8 张)不卡」的根因。
- 滚动模式的滚轮(`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` 得写死列数字面量,多一个按钮无法复用)。
- 漫画图片显示尺寸(`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+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useRef, type TouchEvent as ReactTouchEvent } from 'react'
// 横向滑动手势:给「左右翻页」的阅读区补上手机上的滑动手势。
// 原来只能点左 30% / 右 30% 分区翻页,手指横滑没有任何反应,翻页很别扭。
//
// 判定规则(和纵向滚动共存的关键):
// - 方向要先判定出来:位移超过 slop 后,横向位移要明显大于纵向位移(dominance 倍)
// 才算横滑,否则算纵向滑动/滚动,本次手势彻底放弃(连 click 也不吞);
// - 没走跟手(点按、或没传 onDrag)时,仍按 minDistance + maxDuration 判定一次滑动,
// 短距离当点击处理,交给下面的分区点击。
//
// 跟手拖拽:传了 onDrag 之后,手指横向移动会实时回调位移,松手再回调 onDragEnd,
// 由宿主把内容平移到位(正文列 / 漫画舞台)并决定翻页还是回弹。滑动途中内容就跟着
// 手指走,不再等到松手才动一下。跟手手势一律按「拖动」处理,不再走 maxDuration 判定
// (慢慢拖过去也是拖动)。
//
// 一次滑动之后浏览器仍可能补一个 click(落在原来的分区按钮上),
// 于是会「滑一页又点一页」翻两页。用 consumeSwipe() 让点击回调先问一句:
// 这次 click 是不是刚被滑动吃掉,是则跳过。每次 touchstart 会重置这个标记,
// 所以真实滑动不产生 click 时也不会误吞下一次点击。
/** 判定手势方向的位移阈值(px):比这还小就还在点按的抖动范围内。 */
const DIRECTION_SLOP = 12
interface HorizontalSwipeOptions {
/** 从左往右滑(手指向右移)。 */
onSwipeRight: () => void
/** 从右往左滑(手指向左移)。 */
onSwipeLeft: () => void
/** 触发翻页所需的最小水平位移(px)。 */
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 表示这次点击已被滑动消费,应跳过默认动作。 */
consumeSwipe: () => boolean
}
export function useHorizontalSwipe({
onSwipeRight,
onSwipeLeft,
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
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, 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
consumedRef.current = false
return true
}, [])
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 { 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>
)
}
+462 -105
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import { useParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import {
@@ -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'
@@ -54,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
@@ -79,6 +85,91 @@ interface ReaderParagraphBlock {
comments: ReaderContentComment[]
}
/**
* 翻页模式正文列:与页码 state 解耦。
*
* 长章(几百页 CSS 多栏)里,若每次 setPage 都把整棵段落树再调和一遍,
* 手机主线程会卡数百毫秒,表现为「松手停一下才翻到下一页」。
* 分页位移改走 contentRef 命令式写入;这里用 memo,页码变时跳过段落重渲染。
*/
const ReaderTextPageBody = memo(function ReaderTextPageBody({
blocks,
paragraphSpacing,
fontSize,
accent,
onOpenComment,
}: {
blocks: ReaderParagraphBlock[]
paragraphSpacing: number
fontSize: number
accent: string
onOpenComment: (comment: ReaderContentComment) => void
}) {
return (
<>
{blocks.map((block, key) => {
const { text, comments } = block
// content-visibility:跳过视口外段落的绘制。长章翻页时否则每次 transform
// 都要重绘整章(实测可到数百毫秒)。auto 尺寸会记住上次渲染高度,减少分页抖动。
const skipPaint: CSSProperties = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 4em',
}
if (text.startsWith(IMG_MARK)) {
return (
<p key={key} style={{ marginBottom: paragraphSpacing, textAlign: 'center', ...skipPaint }}>
<img
src={text.slice(IMG_MARK.length)}
alt=""
referrerPolicy="no-referrer"
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
/>
</p>
)
}
const standalone = text === '' && comments.length > 0
return (
<p
key={key}
style={{
textIndent: text ? '2em' : undefined,
marginBottom: paragraphSpacing,
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
...skipPaint,
}}
>
{text}
{comments.map((c, ci) => (
<button
key={`${c.line}-${ci}`}
type="button"
onClick={(e) => {
e.stopPropagation()
onOpenComment(c)
}}
title={c.label || '段评'}
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
style={{
borderColor: accent,
color: accent,
fontSize: Math.max(10, fontSize - 4),
lineHeight: 1,
padding: '2px 6px',
verticalAlign: 'middle',
pointerEvents: 'auto',
}}
>
<MessageSquare size={11} />
{c.count > 0 && <span>{c.count}</span>}
</button>
))}
</p>
)
})}
</>
)
})
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -87,6 +178,40 @@ 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()
}
/**
* 文本滑动松手后的跟手层归位。正文多栏已瞬移,这里只把跟手层从「画面连续」的
* 位移滑回 0。不用 void offsetWidth 强刷布局——长章多栏一强制回流就要几百毫秒。
* turn=1 下一页,turn=-1 上一页;dx 为松手时的跟手位移。
*/
function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number): void {
const width = dragEl.clientWidth || 1
dragEl.style.transition = 'none'
dragEl.style.transform = `translate3d(${turn > 0 ? width + dx : -width + dx}px,0,0)`
requestAnimationFrame(() => {
dragEl.style.transition = 'transform 140ms ease-out'
dragEl.style.transform = 'translate3d(0,0,0)'
})
}
export default function ReaderViewPage() {
const { bookId = '' } = useParams()
const settings = useReaderSettingsStore()
@@ -129,9 +254,28 @@ export default function ReaderViewPage() {
const viewportRef = useRef<HTMLDivElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
/** 文本跟手层:滑动动画也写在这一层,避免给整章多栏做 transition。 */
const pageDragRef = useRef<HTMLDivElement>(null)
const [page, setPage] = useState(0)
const [pageCount, setPageCount] = useState(1)
const [vw, setVw] = useState(0)
// 文本翻页:位移与页脚先走 DOM,React 的 page state 等到过渡结束后再同步。
// 若在同一次点击/触摸里同步 setPage,整页重渲染会堵住下一次绘制,
// 命令式 transform 也要等卡完才开始——点按和滑动都会顿一下。
const vwRef = useRef(0)
const pageRef = useRef(0)
const pageCountRef = useRef(1)
const chapterTitleRef = useRef('')
const pageLabelRef = useRef<HTMLDivElement>(null)
const appliedTextPageRef = useRef(-1)
const appliedTextVwRef = useRef(-1)
const snapTextPageRef = useRef(false)
/** 把 pageRef 同步回 React state 的防抖定时器;翻页热路径上绝不同步 setPage。 */
const textPageSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
/** 首次分页完成后锁住 pageCount,避免 content-visibility 改变 scrollWidth 后反复 setPageCount。 */
const pageCountLockedRef = useRef(false)
vwRef.current = vw
pageCountRef.current = pageCount
// 音频/漫画媒体状态
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
@@ -170,6 +314,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
// ── 漫画滚动模式的图片显示尺寸 ──
// 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
@@ -290,6 +441,11 @@ export default function ReaderViewPage() {
setContentType(ct.type)
setMedia(ct)
setContent(ct.content ?? '')
// 换章瞬移到目标页,不要带着上一章的翻页过渡滑过去。
snapTextPageRef.current = true
appliedTextPageRef.current = -1
pageCountLockedRef.current = false
pageRef.current = 0
setPage(0)
setComicPage(0)
setCurrentImage(0)
@@ -348,40 +504,155 @@ export default function ReaderViewPage() {
}, [chapterIndex, book, chapters, contentCacheKey, contentReloadKey])
// ── 分页排版(CSS 多栏 + 平移) ──
/** 把正文列平移到指定页。animate=true 时走 220ms 过渡;换章/重排传 false。 */
const applyTextPageTransform = useCallback((nextPage: number, animate: boolean) => {
const el = contentRef.current
if (!el) return
const width = vwRef.current > 0 ? vwRef.current : (viewportRef.current?.clientWidth ?? 0)
const stride = Math.max(width, 1) + COLUMN_GAP
el.style.transition = animate ? PAGE_TRANSITION : 'none'
// 正文多栏可能极宽,不要 translate3d / will-change,免得整章被提成巨大合成层。
el.style.transform = `translateX(-${nextPage * stride}px)`
appliedTextPageRef.current = nextPage
appliedTextVwRef.current = width
}, [])
const updateTextPageLabel = useCallback((nextPage: number) => {
const label = pageLabelRef.current
if (!label) return
const title = chapterTitleRef.current
label.textContent = title ? `${nextPage + 1} / ${pageCountRef.current} · ${title}` : `${nextPage + 1} / ${pageCountRef.current}`
}, [])
/**
* 把 pageRef 写回 React。长章每次 setPage 都会让整页重渲染卡住 1s+,
* 所以阅读翻页热路径绝不调用;只在打开菜单 / 换章 / 卸载时同步。
*/
const flushTextPageReactSync = useCallback(() => {
if (textPageSyncTimerRef.current !== null) {
clearTimeout(textPageSyncTimerRef.current)
textPageSyncTimerRef.current = null
}
setPage((p) => (p === pageRef.current ? p : pageRef.current))
}, [])
/**
* 文本翻到指定页(热路径零 setState)。
* - 正文多栏瞬移 + 页脚 DOM 更新
* - 点按/滚轮:无动画
* - 滑动松手:只动画跟手层
* dragDx 有值表示来自滑动松手。
*/
const commitTextPage = useCallback(
(nextPage: number, opts?: { dragDx?: number }) => {
const from = pageRef.current
const dragDx = opts?.dragDx
const fromSwipe = typeof dragDx === 'number'
const turn: 1 | -1 = nextPage >= from ? 1 : -1
pageRef.current = nextPage
applyTextPageTransform(nextPage, false)
updateTextPageLabel(nextPage)
const dragEl = pageDragRef.current
if (fromSwipe && dragEl && nextPage !== from) {
settleTextDrag(dragEl, turn, dragDx)
} else if (dragEl) {
dragEl.style.transition = 'none'
dragEl.style.transform = 'translate3d(0,0,0)'
}
},
[applyTextPageTransform, updateTextPageLabel],
)
const relayout = useCallback(() => {
const vp = viewportRef.current
if (!vp) return
const width = vp.clientWidth
setVw(width)
const total = vp.scrollWidth
// 用正文列自己的 scrollWidth;视口在子元素 transform 时 scrollWidth 会抖,
// 导致翻一页就重新 setPageCount → 整页重渲染卡 1s+。
const total = contentRef.current?.scrollWidth ?? vp.scrollWidth
const count = Math.max(1, Math.ceil((total + COLUMN_GAP) / (width + COLUMN_GAP)))
setPageCount(count)
setPage((p) => {
if (pendingEndRef.current && count > 0) {
pendingEndRef.current = false
return count - 1
}
if (pendingPosRef.current > 0) {
const pos = Math.min(pendingPosRef.current, count - 1)
pendingPosRef.current = 0
return pos
}
return Math.min(p, count - 1)
})
}, [])
const pendingEnd = pendingEndRef.current && count > 0
const pendingPos = pendingPosRef.current > 0
const clamped = Math.min(pageRef.current, Math.max(0, count - 1))
const nextPage = pendingEnd
? count - 1
: pendingPos
? Math.min(pendingPosRef.current, count - 1)
: clamped
if (pendingEnd) pendingEndRef.current = false
if (pendingPos) pendingPosRef.current = 0
const widthChanged = width !== vwRef.current
// 窗口宽度变了要重新分页;锁住后忽略 scrollWidth 抖动(content-visibility 估算高度)。
if (widthChanged) pageCountLockedRef.current = false
const countChanged = !pageCountLockedRef.current && count !== pageCountRef.current
const pageChanged = nextPage !== pageRef.current
// 热路径上尺寸没变就别 setState——否则每次翻页都会重渲染 2000+ 段正文。
if (!widthChanged && !countChanged && !pageChanged && !pendingEnd && !pendingPos) {
if (count > 1) pageCountLockedRef.current = true
return
}
vwRef.current = width
if (!pageCountLockedRef.current) pageCountRef.current = count
pageRef.current = nextPage
if (widthChanged) setVw(width)
if (countChanged) {
setPageCount(count)
if (count > 1) pageCountLockedRef.current = true
}
if (pageChanged || pendingEnd || pendingPos) {
snapTextPageRef.current = true
setPage(nextPage)
updateTextPageLabel(nextPage)
applyTextPageTransform(nextPage, false)
}
}, [applyTextPageTransform, updateTextPageLabel])
useLayoutEffect(() => {
relayout()
}, [relayout, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, vw])
// 页码或列宽变了:若翻页回调里已经写过 transform 则跳过,避免把正在播的过渡重开一遍。
useLayoutEffect(() => {
if (contentType !== 'text' || settings.pageMode !== 'page') return
if (appliedTextPageRef.current === page && appliedTextVwRef.current === vw) return
const animate = !snapTextPageRef.current && appliedTextPageRef.current !== page
snapTextPageRef.current = false
pageRef.current = page
applyTextPageTransform(page, animate)
updateTextPageLabel(page)
}, [page, vw, contentType, settings.pageMode, applyTextPageTransform, updateTextPageLabel])
useEffect(() => {
const vp = viewportRef.current
if (!vp) return
const ro = new ResizeObserver(() => relayout())
// 合并同一帧里多次 resize,避免字体/滚动条抖动连带 relayout→setState。
let raf = 0
const ro = new ResizeObserver(() => {
if (raf) cancelAnimationFrame(raf)
raf = requestAnimationFrame(() => {
raf = 0
relayout()
})
})
ro.observe(vp)
return () => ro.disconnect()
return () => {
if (raf) cancelAnimationFrame(raf)
ro.disconnect()
}
}, [relayout, content, settings.pageMode])
// 打开菜单时把 pageRef 写回 state,进度条才不会停在旧页。
useEffect(() => {
if (menuOpen) flushTextPageReactSync()
}, [menuOpen, flushTextPageReactSync])
// 滚动模式恢复进度
useEffect(() => {
if (settings.pageMode !== 'scroll' || content === null) return
@@ -415,13 +686,22 @@ export default function ReaderViewPage() {
[book, chapterIndex, chapters],
)
// 文本翻页进度:不依赖 React page(翻页热路径不 setPage),定时读 pageRef。
useEffect(() => {
// 只处理文本:听书/漫画的正文为空串(不是 null),不能落到页码保存逻辑里,
// 否则打开音频章 1.5 秒后就会把页码 0 写成进度,覆盖掉刚才恢复的秒数。
if (contentType !== 'text' || settings.pageMode !== 'page' || content === null || chapterIndex === null) return
const t = setTimeout(() => savePos(page), 1500)
return () => clearTimeout(t)
}, [page, content, chapterIndex, settings.pageMode, contentType, savePos])
let last = -1
const tick = () => {
const pos = pageRef.current
if (pos === last) return
last = pos
savePos(pos)
}
const t = setInterval(tick, 2000)
return () => {
clearInterval(t)
tick()
}
}, [content, chapterIndex, settings.pageMode, contentType, savePos])
// 漫画单页进度保存
useEffect(() => {
@@ -599,9 +879,10 @@ export default function ReaderViewPage() {
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page > 0) setPage((p) => p - 1)
// 文本翻页读 pageRef:动画期间 React page 可能还没跟上,连点不能被旧 state 卡住。
if (pageRef.current > 0) commitTextPage(pageRef.current - 1)
else goChapter(-1, true)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, goChapter, commitTextPage])
const goNext = useCallback(() => {
if (contentType === 'audio') {
@@ -625,15 +906,92 @@ export default function ReaderViewPage() {
else el?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
return
}
if (page < pageCount - 1) setPage((p) => p + 1)
if (pageRef.current < pageCountRef.current - 1) commitTextPage(pageRef.current + 1)
else goChapter(1)
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, goChapter, commitTextPage])
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
//
// 手机上再补一层横向滑动:从左往右滑=上一页、从右往左滑=下一页,并且跟手——
// 滑动途中内容就跟着手指走,松手再决定翻页还是回弹(判定、跟手位移与
// 「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里)。
//
// 跟手拖拽平移的是两个不同的元素:
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。正文多栏只瞬移,
// 滑页动画全在跟手层(settleTextDrag);长章不能给多栏做 transition。
// - 漫画:平移 ReaderComic 的舞台(comicStageRef)。舞台两侧摆着上一页/下一页
// (见那边的 carousel 分支),翻页时单元格的 left 偏移与舞台位移在同一次提交里
// 相抵,画面看不出切换。
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 = `translate3d(${dx}px,0,0)`
},
[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
if (dx <= -DRAG_TURN_DISTANCE) {
if (pageRef.current < pageCountRef.current - 1) {
commitTextPage(pageRef.current + 1, { dragDx: dx })
} else {
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
goChapter(1)
}
return
}
if (dx >= DRAG_TURN_DISTANCE) {
if (pageRef.current > 0) {
commitTextPage(pageRef.current - 1, { dragDx: dx })
} else {
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
goChapter(-1, true)
}
return
}
// 位移不够:回弹,不翻页。
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translate3d(0,0,0)'
},
[contentType, goNext, goPrev, commitTextPage, goChapter],
)
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) => {
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
@@ -712,6 +1070,7 @@ export default function ReaderViewPage() {
}, [settings.pageMode, menuOpen, contentType, goPrev, goNext])
const currentChapter = chapterIndex !== null ? chapters[chapterIndex] : null
chapterTitleRef.current = currentChapter?.title ?? ''
// 顶栏高度量一次:菜单打开时正文下移,开头几行不被顶栏压住。
// 用 transform 而不是 padding,避免改变视口高度触发重新分页。
@@ -758,7 +1117,10 @@ export default function ReaderViewPage() {
1000,
),
onChange: (v: number) => {
if (settings.pageMode === 'page') setPage(v - 1)
if (settings.pageMode === 'page') {
commitTextPage(v - 1)
flushTextPageReactSync()
}
else {
const el = scrollRef.current
if (el) el.scrollTop = (v / 1000) * (el.scrollHeight - el.clientHeight)
@@ -811,62 +1173,13 @@ export default function ReaderViewPage() {
[book],
)
// 正文段落块:普通段落按缩进排版,[img] 行渲染成居中图片,段评在段末挂气泡
const renderParagraph = (block: ReaderParagraphBlock, key: number) => {
const { text, comments } = block
if (text.startsWith(IMG_MARK)) {
return (
<p key={key} style={{ marginBottom: settings.paragraphSpacing, textAlign: 'center' }}>
<img
src={text.slice(IMG_MARK.length)}
alt=""
referrerPolicy="no-referrer"
style={{ maxWidth: '100%', maxHeight: '70vh', margin: '0 auto', objectFit: 'contain' }}
/>
</p>
)
}
const standalone = text === '' && comments.length > 0
return (
<p
key={key}
style={{
textIndent: text ? '2em' : undefined,
marginBottom: settings.paragraphSpacing,
textAlign: standalone && comments.every((c) => c.block) ? 'center' : undefined,
}}
>
{text}
{comments.map((c, ci) => (
<button
key={`${c.line}-${ci}`}
type="button"
onClick={(e) => {
// 滚动容器的分区点击与翻页覆盖层都在上层,不拦住冒泡,
// 点气泡会顺带翻页或呼出菜单。
e.stopPropagation()
void openComment(c)
}}
title={c.label || '段评'}
// 外层正文档关掉了指针事件(见上面的层叠说明),气泡自己开关。
className="relative z-10 mx-1 inline-flex items-center gap-0.5 rounded-full border align-middle"
style={{
borderColor: theme.accent,
color: theme.accent,
fontSize: Math.max(10, settings.fontSize - 4),
lineHeight: 1,
padding: '2px 6px',
verticalAlign: 'middle',
pointerEvents: 'auto',
}}
>
<MessageSquare size={11} />
{c.count > 0 && <span>{c.count}</span>}
</button>
))}
</p>
)
}
// 段评点击:ReaderTextPageBody 要稳定回调,否则 memo 每次都失效。
const handleOpenComment = useCallback(
(comment: ReaderContentComment) => {
void openComment(comment)
},
[openComment],
)
// ── 渲染 ──
if (error && !book) {
@@ -901,6 +1214,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)
}}
@@ -926,20 +1241,27 @@ 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))}
{/* 跟手层:跟手位移写这里;分页平移由 applyTextPageTransform 写在 contentRef 上。 */}
<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',
fontSize: settings.fontSize,
lineHeight: settings.lineHeight,
}}
>
<ReaderTextPageBody
blocks={blocks}
paragraphSpacing={settings.paragraphSpacing}
fontSize={settings.fontSize}
accent={theme.accent}
onOpenComment={handleOpenComment}
/>
</div>
</div>
</div>
</div>
@@ -963,7 +1285,13 @@ export default function ReaderViewPage() {
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
>
<div className="py-4">
{blocks.map((b, i) => renderParagraph(b, i))}
<ReaderTextPageBody
blocks={blocks}
paragraphSpacing={settings.paragraphSpacing}
fontSize={settings.fontSize}
accent={theme.accent}
onOpenComment={handleOpenComment}
/>
</div>
</div>
)}
@@ -1005,22 +1333,51 @@ 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}
onTouchMove={onTouchMove}
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>
{/* 页脚页码(翻页模式) */}
{settings.pageMode === 'page' && contentType === 'text' && content !== null && (
<div className="pointer-events-none pb-2 text-center text-2xs opacity-50" style={{ color: theme.text }}>
<div
ref={pageLabelRef}
className="pointer-events-none pb-2 text-center text-2xs opacity-50"
style={{ color: theme.text }}
>
{page + 1} / {pageCount} · {currentChapter?.title ?? ''}
</div>
)}