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 {
ArrowLeft,
ArrowLeftRight,
BookOpen,
Columns2,
LayoutList,
ListEnd,
Loader2,
Maximize,
MessageSquare,
Minimize,
Minus,
Moon,
Plus,
ScrollText,
Sun,
} from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import {
readerAPI,
type ReaderBook,
type ReaderBrowserPage,
type ReaderChapter,
type ReaderChapterContent,
type ReaderContentComment,
type ReaderSearchOrigin,
} from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useDoubleTap } from '../../hooks/useDoubleTap'
import { useHorizontalSwipe } from '../../hooks/useHorizontalSwipe'
import { useIsTouchDevice } from '../../hooks/useIsTouchDevice'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { formatScrollPct, shouldSaveScrollProgress } from '../../utils/readerScrollProgress'
import BrowserPanel from './BrowserPanel'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
import { getReaderAudioEngine } from './readerAudioEngine'
// 文本阅读器(仿 legado ReadBookActivity:主题配色、点击区域、上下章、
// 进度记忆、翻页/滚动双模式;桌面端限宽居中,支持键盘翻页)。
const COLUMN_GAP = 48
/**
* 正文「保持真实渲染」的页窗口:当前页 ± 这么多页的段落不做 content-visibility 跳过。
*
* 为什么要有这个窗口:被跳过的段落不能每帧重算可见性。翻页/拖动时内容每移动一点,
* 进入或离开视口的段落都要重新判定,而每次判定变化都会重排整条多栏流——实测一次
* 滑动触发 32 次 layout(20 次滑动主线程 10.6s,JS 只占 0.014s),帧根本画不完,
* 表现出来就是两页之间抖。窗口固定住以后,动画期间不再有可见性变化,帧就稳了;
* 只有读完窗口(每 TEXT_WARM_SHIFT 页)才付一次重排。
*/
const TEXT_WARM_PAGES = 12
/** 页码漂出这个范围才移动渲染窗口,避免每翻一页都改可见性触发重排。 */
const TEXT_WARM_SHIFT = 6
/** 正文里的图片占位行前缀(本地 EPUB 的图片,服务端已换成签名地址)。 */
const IMG_MARK = '[img]'
/** 听书进度上报节流(毫秒):按秒记忆,退出最多丢 5 秒。 */
const AUDIO_SAVE_INTERVAL_MS = 5_000
/**
* 正文窗口预取章数:当前章之后预取 3 章。
* 单章串行预取在高延迟书源上常常赶不上连续翻章;批量接口在服务端并发抓取并落
* 持久缓存(对应 legado CacheBook),翻到后续章时直接命中。
*/
const PREFETCH_WINDOW = 3
/** 漫画进度上报节流(毫秒):按图片序号记忆。 */
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
/** 触控板小步长累计到这个量翻一页。 */
const WHEEL_SWIPE_THRESHOLD = 60
/** 两次翻页的最小间隔,与 220ms 翻页动画对齐。 */
const WHEEL_TURN_COOLDOWN = 220
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
const WHEEL_GESTURE_GAP = 180
/**
* 漫画双页铺开的最小窗口宽度。比这窄时并排两页每页只剩一条竖条,
* 反而比单页更难看清,所以渲染层按窗口宽度自动退回单页(偏好设置不动)。
*/
const COMIC_SPREAD_MIN_WIDTH = 900
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
const NO_IMAGES: string[] = []
/** 一个渲染单元:一段正文文字 + 挂在这一行上的段评气泡。 */
interface ReaderParagraphBlock {
text: string
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 都要重绘整章
// (实测可到数百毫秒)。默认全部按需跳过;当前页附近的段落由
// applyParagraphWarmWindow 改成 visible 并固定成一个窗口——被跳过的段落
// 会在每帧重新判定可见性,导致动画期间反复重排(见 TEXT_WARM_PAGES)。
const skipPaint: CSSProperties = {
contentVisibility: 'auto',
containIntrinsicSize: 'auto 4em',
}
if (text.startsWith(IMG_MARK)) {
return (
)
}
const standalone = text === '' && comments.length > 0
return (
c.block) ? 'center' : undefined,
...skipPaint,
}}
>
{text}
{comments.map((c, ci) => (
))}
)
})}
>
)
})
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
// 本地导入的章节没有 url,退回第一个非卷名章
const j = chapters.findIndex((c) => !c.is_volume)
return j === -1 ? 0 : j
}
/**
* 漫画跟手拖拽松手后「滑完这一页」。
*
* 舞台两侧各摆着相邻的一页,手指拖到哪儿舞台就平移到哪儿。松手要翻页时,
* 先把新页瞬移到手指离开的位置(这一步必须关掉过渡,否则会先反着滑一段),
* 读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;与此同时页码 +1/-1,
* ReaderComic 那边单元格的 left 偏移在同一次提交里跟着换,两者相抵,
* 屏幕上看到的就是新页从手指离开的位置平滑归位。
*
* base 是手势开始时舞台已经带着的残余位移(上一次归位动画没滑完就又被接管)。
*/
function settleComicDrag(el: HTMLDivElement, turn: 1 | -1, dx: number, base: number, turnPage: () => void): void {
const width = el.clientWidth || 1
el.style.transition = 'none'
el.style.transform = `translateX(${base + dx + turn * width}px)`
void el.offsetWidth
el.style.transition = PAGE_TRANSITION
el.style.transform = 'translateX(0px)'
turnPage()
}
/**
* 读元素当前的实际横向位移。
*
* 跟手层/漫画舞台的归位是 CSS 过渡,一次手势可能在过渡中途就被下一次手势接管。
* 那时元素的 transform 停在中间值,必须把它读出来当作新手势的起点,
* 否则新手势从 dx 重新开始,残余位移被抹掉,画面就会跳一格——快速连续翻页时
* 最明显,看起来就是两页之间来回抖。
*/
function readTranslateX(el: HTMLElement): number {
const t = getComputedStyle(el).transform
if (!t || t === 'none') return 0
const nums = t.match(/-?[\d.]+(?:e[-+]?\d+)?/gi)
if (!nums) return 0
// translate3d 在 Chrome 里可能给 matrix(a,b,c,d,tx,ty) 也可能给 matrix3d(...),
// 两种都要取横向平移分量。
const v = nums.length >= 16 ? Number(nums[12]) : nums.length >= 6 ? Number(nums[4]) : 0
return Number.isFinite(v) ? v : 0
}
/**
* 文本滑动松手后的跟手层归位。正文多栏已瞬移,这里只把跟手层从「画面连续」的
* 位移滑回 0。不用 void offsetWidth 强刷布局——长章多栏一强制回流就要几百毫秒。
* turn=1 下一页,turn=-1 上一页;dx 为松手时的跟手位移。
*
* 起点必须是 base + dx + turn*stride:
* - stride 要和正文那一跳用的步长完全一致(列宽 + 列间距)。用列宽代替步长的话,
* 松手瞬间跟手层会少补一个列间距,画面先往回弹 48px 再往前滑。
* - base 是手势开始时跟手层已经带着的残余位移:上一次归位动画没滑完就又滑了一次时,
* 不带上它就等于把这段残余抹掉,画面跳一格。快速连续翻页时最明显。
*/
function settleTextDrag(dragEl: HTMLDivElement, turn: 1 | -1, dx: number, stride: number, base: number): void {
const step = stride > 0 ? stride : dragEl.clientWidth || 1
dragEl.style.transition = 'none'
dragEl.style.transform = `translate3d(${base + dx + turn * step}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()
const theme = getReaderTheme(settings.themeId, settings.night)
const [book, setBook] = useState(null)
const [chapters, setChapters] = useState([])
const [chapterIndex, setChapterIndex] = useState(null)
const [content, setContent] = useState(null)
const [contentType, setContentType] = useState<'text' | 'audio' | 'image'>('text')
const [loadingStage, setLoadingStage] = useState<'book' | 'content' | null>('book')
const [error, setError] = useState('')
const [menuOpen, setMenuOpen] = useState(false)
const [panel, setPanel] = useState<'none' | 'toc' | 'style'>('none')
// 浏览器全屏(沉浸式):把阅读层整个铺满屏幕,连浏览器的地址栏/状态栏一起让出来。
// 页面本身是 fixed inset-0,所以这里要解决的是浏览器自身的界面,而不是页面内的留白。
const [fullscreen, setFullscreen] = useState(false)
/** 目录面板顶部「区间下拉」选中的组号,随列表滚动同步。 */
const [tocGroupIndex, setTocGroupIndex] = useState(0)
// 换源:候选源来自按书名重新搜索的结果;reloadKey 变化时整本书重新加载
const [switchOpen, setSwitchOpen] = useState(false)
const [switchLoading, setSwitchLoading] = useState(false)
const [switchCandidates, setSwitchCandidates] = useState([])
const [reloadKey, setReloadKey] = useState(0)
// 正文重载计数:错误态的「重试」只重取本章正文,不重载整本书(换源用 reloadKey)。
const [contentReloadKey, setContentReloadKey] = useState(0)
/** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
const [browserPage, setBrowserPage] = useState(null)
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
const topBarRef = useRef(null)
const [menuInset, setMenuInset] = useState(0)
// 滚轮翻页的累计量 / 冷却 / 一次手势只翻一页的锁
const readerRef = useRef(null)
const wheelAccumRef = useRef(0)
const wheelLastEventRef = useRef(0)
const wheelLastTurnRef = useRef(0)
const wheelSwipeLockedRef = useRef(false)
/** 目录面板虚拟列表句柄:区间下拉靠它整段跳转。 */
const tocRef = useRef(null)
// 分页状态
const viewportRef = useRef(null)
const scrollRef = useRef(null)
const contentRef = useRef(null)
/** 文本跟手层:滑动动画也写在这一层,避免给整章多栏做 transition。 */
const pageDragRef = useRef(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(null)
const appliedTextPageRef = useRef(-1)
const appliedTextVwRef = useRef(-1)
const snapTextPageRef = useRef(false)
/** 把 pageRef 同步回 React state 的防抖定时器;翻页热路径上绝不同步 setPage。 */
const textPageSyncTimerRef = useRef | null>(null)
/** 首次分页完成后锁住 pageCount,避免 content-visibility 改变 scrollWidth 后反复 setPageCount。 */
const pageCountLockedRef = useRef(false)
/** 正文段落节点与它们各自落在的页号,用来算「当前页附近的渲染窗口」。 */
const paragraphsRef = useRef | null>(null)
const paragraphColsRef = useRef(null)
/** 每个段落当前是不是「真实渲染」(1=visible,0=按需跳过),只在分类变化时写 DOM。 */
const paragraphWarmRef = useRef(null)
/** 当前窗口中心页;-1 表示窗口失效(映射刚重算过),下次应用时重建。 */
const warmCenterRef = useRef(-1)
vwRef.current = vw
pageCountRef.current = pageCount
// 音频/漫画媒体状态
const [media, setMedia] = useState(null)
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
const [comicPage, setComicPage] = useState(0)
const [currentImage, setCurrentImage] = useState(0)
const [scrollToImage, setScrollToImage] = useState(null)
const lastMediaSaveRef = useRef(0)
const contentCache = useRef(new Map())
const pendingPosRef = useRef(0)
const pendingEndRef = useRef(false)
// ── 漫画双页铺开(桌面端) ──
// 窗口宽度决定双页是否可用:偏好在 settings 里(用户可关),但窗口不够宽时
// 即使开着也退回单页,所以这里额外跟一个窗口宽度。
const [windowWidth, setWindowWidth] = useState(() => window.innerWidth)
useEffect(() => {
const onResize = () => setWindowWidth(window.innerWidth)
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])
// ── 浏览器全屏(沉浸式) ──
// 只认标准 API:iOS 上的 Safari 至今不支持元素全屏(只有