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 至今不支持元素全屏(只有