From b50cb7c31e9f4f5cdb4d612a164ee84a0611db1f Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:43:28 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/hooks/useHorizontalSwipe.ts | 10 +- web/src/pages/reader/ReaderViewPage.tsx | 271 +++++++++++++++++++----- 2 files changed, 231 insertions(+), 50 deletions(-) diff --git a/web/src/hooks/useHorizontalSwipe.ts b/web/src/hooks/useHorizontalSwipe.ts index 7342680..7f00490 100644 --- a/web/src/hooks/useHorizontalSwipe.ts +++ b/web/src/hooks/useHorizontalSwipe.ts @@ -33,6 +33,12 @@ interface HorizontalSwipeOptions { dominance?: number /** 手势超过这个时长不算滑动(毫秒)。只对非跟手的滑动判定生效。 */ maxDuration?: number + /** + * 跟手拖拽开始(方向判定通过、第一次 onDrag 之前)调一次。 + * 宿主在这里记下跟手层当前的实际位移:上一次翻页的归位动画可能还没滑完, + * 新手势要接着那点残余位移继续,否则中途接管会把残余一笔抹掉,画面跳一格。 + */ + onDragStart?: () => void /** 跟手拖拽中的位移(px,向右为正)。传了才启用跟手。 */ onDrag?: (dx: number) => void /** 跟手松手:宿主在这里回弹或翻页(随后浏览器补发的 click 会被吞掉)。 */ @@ -54,6 +60,7 @@ export function useHorizontalSwipe({ minDistance = 45, dominance = 1.2, maxDuration = 800, + onDragStart, onDrag, onDragEnd, }: HorizontalSwipeOptions): HorizontalSwipeHandlers { @@ -88,11 +95,12 @@ export function useHorizontalSwipe({ return } draggingRef.current = true + onDragStart?.() } lastDxRef.current = dx onDrag?.(dx) }, - [onDrag, dominance], + [onDragStart, onDrag, dominance], ) const onTouchEnd = useCallback( diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx index 2c09599..c5a7057 100644 --- a/web/src/pages/reader/ReaderViewPage.tsx +++ b/web/src/pages/reader/ReaderViewPage.tsx @@ -44,6 +44,19 @@ import { getReaderAudioEngine } from './readerAudioEngine' 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]' @@ -109,8 +122,10 @@ const ReaderTextPageBody = memo(function ReaderTextPageBody({ <> {blocks.map((block, key) => { const { text, comments } = block - // content-visibility:跳过视口外段落的绘制。长章翻页时否则每次 transform - // 都要重绘整章(实测可到数百毫秒)。auto 尺寸会记住上次渲染高度,减少分页抖动。 + // content-visibility:跳过视口外段落的绘制,否则每次 transform 都要重绘整章 + // (实测可到数百毫秒)。默认全部按需跳过;当前页附近的段落由 + // applyParagraphWarmWindow 改成 visible 并固定成一个窗口——被跳过的段落 + // 会在每帧重新判定可见性,导致动画期间反复重排(见 TEXT_WARM_PAGES)。 const skipPaint: CSSProperties = { contentVisibility: 'auto', containIntrinsicSize: 'auto 4em', @@ -186,26 +201,53 @@ function firstReadableIndex(chapters: ReaderChapter[]): number { * 读一次布局把这一帧定成过渡起点,再打开过渡滑回 0;与此同时页码 +1/-1, * ReaderComic 那边单元格的 left 偏移在同一次提交里跟着换,两者相抵, * 屏幕上看到的就是新页从手指离开的位置平滑归位。 + * + * base 是手势开始时舞台已经带着的残余位移(上一次归位动画没滑完就又被接管)。 */ -function settleComicDrag(el: HTMLDivElement, turn: 1 | -1, dx: number, turnPage: () => void): void { +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(${turn > 0 ? width + dx : -width + dx}px)` + 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): void { - const width = dragEl.clientWidth || 1 +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(${turn > 0 ? width + dx : -width + dx}px,0,0)` + 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)' @@ -274,6 +316,13 @@ export default function ReaderViewPage() { 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 // 音频/漫画媒体状态 @@ -504,19 +553,29 @@ 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 textPageStride = useCallback(() => { + const width = vwRef.current > 0 + ? vwRef.current + : Math.round(viewportRef.current?.getBoundingClientRect().width ?? 0) + return Math.max(width, 1) + COLUMN_GAP }, []) + /** 把正文列平移到指定页。animate=true 时走 220ms 过渡;换章/重排传 false。 */ + const applyTextPageTransform = useCallback( + (nextPage: number, animate: boolean) => { + const el = contentRef.current + if (!el) return + const stride = textPageStride() + el.style.transition = animate ? PAGE_TRANSITION : 'none' + // 正文多栏可能极宽,不要 translate3d / will-change,免得整章被提成巨大合成层。 + el.style.transform = `translateX(-${nextPage * stride}px)` + appliedTextPageRef.current = nextPage + appliedTextVwRef.current = vwRef.current > 0 ? vwRef.current : (viewportRef.current?.clientWidth ?? 0) + }, + [textPageStride], + ) + const updateTextPageLabel = useCallback((nextPage: number) => { const label = pageLabelRef.current if (!label) return @@ -524,6 +583,72 @@ export default function ReaderViewPage() { label.textContent = title ? `${nextPage + 1} / ${pageCountRef.current} · ${title}` : `${nextPage + 1} / ${pageCountRef.current}` }, []) + /** + * 重算「段落 → 页号」映射。正文是一条超长多栏流,段落落在哪一栏只能从布局读。 + * 2500 段读一遍 offsetLeft 约 2ms(布局干净时),比让浏览器每帧自己重算便宜得多。 + */ + const refreshParagraphColumns = useCallback(() => { + const el = contentRef.current + if (!el) return + const ps = el.querySelectorAll('p') + const stride = textPageStride() + const cols = new Int32Array(ps.length) + for (let i = 0; i < ps.length; i++) cols[i] = Math.floor(ps[i].offsetLeft / stride) + paragraphsRef.current = ps + paragraphColsRef.current = cols + // 映射变了,缓存的「谁在渲染」标记不能沿用:下次应用时以 DOM 现状为基准重算。 + paragraphWarmRef.current = new Uint8Array(ps.length) + warmCenterRef.current = -1 + }, [textPageStride]) + + /** + * 把当前页附近的段落固定成「真实渲染」,其余继续按需跳过。 + * 只有分类真的变了的段落才写 DOM,所以窗口不动时这个函数是空转。 + */ + const applyParagraphWarmWindow = useCallback( + (nextPage: number) => { + if (settings.pageMode !== 'page' || contentType !== 'text') return + const cols = paragraphColsRef.current + const ps = paragraphsRef.current + const warm = paragraphWarmRef.current + if (!cols || !ps || !warm || cols.length !== ps.length) return + const center = warmCenterRef.current + if (center >= 0 && Math.abs(nextPage - center) <= TEXT_WARM_SHIFT) return + if (center < 0) { + for (let i = 0; i < ps.length; i++) warm[i] = ps[i].style.contentVisibility === 'visible' ? 1 : 0 + } + warmCenterRef.current = nextPage + const lo = nextPage - TEXT_WARM_PAGES + const hi = nextPage + TEXT_WARM_PAGES + let flipped = false + for (let i = 0; i < ps.length; i++) { + const want = cols[i] >= lo && cols[i] <= hi ? 1 : 0 + if (warm[i] === want) continue + warm[i] = want + flipped = true + // 放进窗口 = visible(真实渲染),移出窗口 = auto(按需跳过,高度用记住的值)。 + ps[i].style.setProperty('content-visibility', want ? 'visible' : 'auto') + } + // 可见性一变,段落高度就从估算值换成真实值,后面所有段落都会挪位, + // 缓存的映射随即过期。等下一帧布局落定再重算,别卡在这一帧里。 + if (flipped) requestAnimationFrame(() => refreshParagraphColumns()) + }, + [settings.pageMode, contentType, refreshParagraphColumns], + ) + + /** 退出翻页模式时把整章段落放回按需跳过,别留着上一次的渲染窗口。 */ + const resetParagraphWarmWindow = useCallback(() => { + const ps = paragraphsRef.current + const warm = paragraphWarmRef.current + if (!ps || !warm) return + for (let i = 0; i < ps.length; i++) { + if (!warm[i]) continue + warm[i] = 0 + ps[i].style.setProperty('content-visibility', 'auto') + } + warmCenterRef.current = -1 + }, []) + /** * 把 pageRef 写回 React。长章每次 setPage 都会让整页重渲染卡住 1s+, * 所以阅读翻页热路径绝不调用;只在打开菜单 / 换章 / 卸载时同步。 @@ -544,7 +669,7 @@ export default function ReaderViewPage() { * dragDx 有值表示来自滑动松手。 */ const commitTextPage = useCallback( - (nextPage: number, opts?: { dragDx?: number }) => { + (nextPage: number, opts?: { dragDx?: number; dragBase?: number }) => { const from = pageRef.current const dragDx = opts?.dragDx const fromSwipe = typeof dragDx === 'number' @@ -553,58 +678,79 @@ export default function ReaderViewPage() { pageRef.current = nextPage applyTextPageTransform(nextPage, false) updateTextPageLabel(nextPage) + applyParagraphWarmWindow(nextPage) const dragEl = pageDragRef.current if (fromSwipe && dragEl && nextPage !== from) { - settleTextDrag(dragEl, turn, dragDx) + settleTextDrag(dragEl, turn, dragDx, textPageStride(), opts?.dragBase ?? 0) } else if (dragEl) { - dragEl.style.transition = 'none' + // 点按翻页也可能打断上一次的归位动画。这里不能用 transition:'none' 直接抹掉残余: + // 那是硬跳。带上过渡滑回 0,起点就是当前的实际位置,画面才是连续的。 + dragEl.style.transition = PAGE_TRANSITION dragEl.style.transform = 'translate3d(0,0,0)' } }, - [applyTextPageTransform, updateTextPageLabel], + [applyTextPageTransform, updateTextPageLabel, applyParagraphWarmWindow, textPageStride], ) const relayout = useCallback(() => { const vp = viewportRef.current - if (!vp) return - const width = vp.clientWidth + const el = contentRef.current + if (!vp || !el) return + // 列宽必须是整数、而且要和位移用的步长完全一致。 + // clientWidth / state 是取整的,而实际布局宽度可能是小数(缩放、非整数 DPI 很常见, + // 比如 382.4)。容器宽 382.4、column-width 写 382 时,浏览器按容器宽排出 382.4 的列, + // 真实列距就变成 430.4,而位移按 430 算——每页差 0.4px,误差乘以页码: + // 第 339 页就偏了 135px,整页文字从句子中间切开。所以这里把容器宽钉成同一个整数, + // 真实列距就等于步长,页码再大也不会漂。 + const width = Math.max(1, Math.round(vp.getBoundingClientRect().width)) + const widthPx = `${width}px` + // 宽度和列宽都用命令式写:React 那边用的是 vw state,晚一帧才跟上, + // 这一帧里算出来的 scrollWidth / 页数就会是错的。 + if (el.style.width !== widthPx) el.style.width = widthPx + if (el.style.columnWidth !== widthPx) el.style.columnWidth = widthPx // 用正文列自己的 scrollWidth;视口在子元素 transform 时 scrollWidth 会抖, // 导致翻一页就重新 setPageCount → 整页重渲染卡 1s+。 - const total = contentRef.current?.scrollWidth ?? vp.scrollWidth + const total = el.scrollWidth ?? vp.scrollWidth const count = Math.max(1, Math.ceil((total + COLUMN_GAP) / (width + COLUMN_GAP))) - const pendingEnd = pendingEndRef.current && count > 0 + const widthChanged = width !== vwRef.current + if (widthChanged) pageCountLockedRef.current = false + // content-visibility 只给视口附近的段落算真尺寸,远端的段落按估算高度参与分页, + // 所以 scrollWidth 会随阅读单向变大。锁定期内只接受变大(页数更准,且增长是单向 + // 的,不会来回抖);不接受变小——正文一跳回估算值就会把页码夹回上一页, + // 表现出来就是「翻到下一页又被拉回上一页」。 + const effectiveCount = pageCountLockedRef.current ? Math.max(count, pageCountRef.current) : count + const countGrew = effectiveCount !== pageCountRef.current + + const pendingEnd = pendingEndRef.current && effectiveCount > 0 const pendingPos = pendingPosRef.current > 0 - const clamped = Math.min(pageRef.current, Math.max(0, count - 1)) + const clamped = Math.min(pageRef.current, Math.max(0, effectiveCount - 1)) const nextPage = pendingEnd - ? count - 1 + ? effectiveCount - 1 : pendingPos - ? Math.min(pendingPosRef.current, count - 1) + ? Math.min(pendingPosRef.current, effectiveCount - 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 (!widthChanged && !countGrew && !pageChanged && !pendingEnd && !pendingPos) { if (count > 1) pageCountLockedRef.current = true return } vwRef.current = width - if (!pageCountLockedRef.current) pageCountRef.current = count + pageCountRef.current = effectiveCount pageRef.current = nextPage + applyParagraphWarmWindow(nextPage) if (widthChanged) setVw(width) - if (countChanged) { - setPageCount(count) - if (count > 1) pageCountLockedRef.current = true + if (countGrew) { + setPageCount(effectiveCount) + if (effectiveCount > 1) pageCountLockedRef.current = true } if (pageChanged || pendingEnd || pendingPos) { snapTextPageRef.current = true @@ -612,11 +758,26 @@ export default function ReaderViewPage() { updateTextPageLabel(nextPage) applyTextPageTransform(nextPage, false) } - }, [applyTextPageTransform, updateTextPageLabel]) + }, [applyTextPageTransform, updateTextPageLabel, applyParagraphWarmWindow]) useLayoutEffect(() => { relayout() - }, [relayout, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, vw]) + // 正文/字号/列宽变了:段落落位全变了,重新算映射并重建渲染窗口。 + if (settings.pageMode === 'page' && contentType === 'text') { + refreshParagraphColumns() + applyParagraphWarmWindow(pageRef.current) + } else { + resetParagraphWarmWindow() + } + }, [relayout, refreshParagraphColumns, applyParagraphWarmWindow, resetParagraphWarmWindow, content, settings.fontSize, settings.lineHeight, settings.paragraphSpacing, settings.pageMode, contentType, vw]) + + // 页脚页码的文字只由 DOM 直接写(翻页热路径不 setState),React 那边不渲染子节点。 + // 两边都写的话,React 重渲染时会拿滞后的 page 去覆盖,覆盖失败时页面会闪一下旧页码。 + // 这里在换章 / 页数变化时补写一次,保证页脚不为空。 + useLayoutEffect(() => { + if (settings.pageMode !== 'page' || contentType !== 'text') return + updateTextPageLabel(pageRef.current) + }, [content, pageCount, chapterIndex, contentType, settings.pageMode, updateTextPageLabel]) // 列宽变化或换章恢复时对齐位移。 // 阅读热路径以 pageRef 为权威页码(不走 setPage);这里绝不能用滞后的 React page @@ -942,28 +1103,40 @@ export default function ReaderViewPage() { // 相抵,画面看不出切换。 const comicStageRef = useRef(null) const pageDragEnabled = settings.pageMode === 'page' && (contentType === 'text' || comicDragSinglePage) + /** + * 本次跟手手势开始时,跟手层/舞台已经带着的残余位移。 + * 上一次翻页的归位动画没滑完就又滑了一次,新位移必须接着它算,否则画面跳一格。 + */ + const dragBaseRef = useRef(0) + + const onPageDragStart = useCallback(() => { + const el = contentType === 'image' ? comicStageRef.current : pageDragRef.current + dragBaseRef.current = el ? readTranslateX(el) : 0 + }, [contentType]) 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)` + el.style.transform = `translate3d(${dragBaseRef.current + dx}px,0,0)` }, [contentType], ) const onPageDragEnd = useCallback( (dx: number) => { + const base = dragBaseRef.current + dragBaseRef.current = 0 if (contentType === 'image') { const el = comicStageRef.current if (!el) return if (dx <= -DRAG_TURN_DISTANCE) { - settleComicDrag(el, 1, dx, goNext) + settleComicDrag(el, 1, dx, base, goNext) return } if (dx >= DRAG_TURN_DISTANCE) { - settleComicDrag(el, -1, dx, goPrev) + settleComicDrag(el, -1, dx, base, goPrev) return } el.style.transition = PAGE_TRANSITION @@ -974,7 +1147,7 @@ export default function ReaderViewPage() { if (!el) return if (dx <= -DRAG_TURN_DISTANCE) { if (pageRef.current < pageCountRef.current - 1) { - commitTextPage(pageRef.current + 1, { dragDx: dx }) + commitTextPage(pageRef.current + 1, { dragDx: dx, dragBase: base }) } else { el.style.transition = PAGE_TRANSITION el.style.transform = 'translate3d(0,0,0)' @@ -984,7 +1157,7 @@ export default function ReaderViewPage() { } if (dx >= DRAG_TURN_DISTANCE) { if (pageRef.current > 0) { - commitTextPage(pageRef.current - 1, { dragDx: dx }) + commitTextPage(pageRef.current - 1, { dragDx: dx, dragBase: base }) } else { el.style.transition = PAGE_TRANSITION el.style.transform = 'translate3d(0,0,0)' @@ -1003,6 +1176,7 @@ export default function ReaderViewPage() { onSwipeRight: goPrev, onSwipeLeft: goNext, minDistance: DRAG_TURN_DISTANCE, + onDragStart: pageDragEnabled ? onPageDragStart : undefined, onDrag: pageDragEnabled ? onPageDrag : undefined, onDragEnd: pageDragEnabled ? onPageDragEnd : undefined, }) @@ -1385,15 +1559,14 @@ export default function ReaderViewPage() { )} - {/* 页脚页码(翻页模式) */} + {/* 页脚页码(翻页模式)。文字由 updateTextPageLabel 直接写 DOM, + React 不渲染子节点,避免重渲染时用滞后的页码覆盖。 */} {settings.pageMode === 'page' && contentType === 'text' && content !== null && (
- {page + 1} / {pageCount} · {currentChapter?.title ?? ''} -
+ /> )} {/* 主菜单(仿 legado ReadMenu) */}