mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-09 06:46:37 +08:00
优化
This commit is contained in:
@@ -96,7 +96,7 @@
|
||||
- 界面面板的字号/行距/段距三个调节组用 `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`),分页平移仍旧由 React 写在里层,跟手层只管手指位移;松手时两层同时动(跟手位移归零 + 页码变化),合起来就是一次连续滑动。分两层是为了避开「跟手位移把 DOM 改成 React 不知道的值、React 又认为 transform 没变而不去写」把页面留在拖动位置上的坑。漫画:平移 `ReaderComic` 的舞台,舞台左右各摆一页(见下一条)。
|
||||
- 跟手拖拽平移的是两个不同的元素。文本:正文列外面单挂一层「跟手层」(`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` 得写死列数字面量,多一个按钮无法复用)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import toast from 'react-hot-toast'
|
||||
import {
|
||||
@@ -85,6 +85,84 @@ 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
|
||||
if (text.startsWith(IMG_MARK)) {
|
||||
return (
|
||||
<p key={key} style={{ marginBottom: 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: 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()
|
||||
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
|
||||
@@ -157,6 +235,13 @@ export default function ReaderViewPage() {
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageCount, setPageCount] = useState(1)
|
||||
const [vw, setVw] = useState(0)
|
||||
// 文本翻页位移走命令式 transform(见 applyTextPageTransform):先改 DOM 再 setPage,
|
||||
// 避免长章重渲染把翻页动画堵在主线程后面。下面几个 ref 用来去重 / 换章时关掉过渡。
|
||||
const vwRef = useRef(0)
|
||||
const appliedTextPageRef = useRef(-1)
|
||||
const appliedTextVwRef = useRef(-1)
|
||||
const snapTextPageRef = useRef(false)
|
||||
vwRef.current = vw
|
||||
// 音频/漫画媒体状态
|
||||
const [media, setMedia] = useState<ReaderChapterContent | null>(null)
|
||||
const [restorePos, setRestorePos] = useState(0) // 音频秒数 / 漫画图片序号
|
||||
@@ -322,6 +407,9 @@ export default function ReaderViewPage() {
|
||||
setContentType(ct.type)
|
||||
setMedia(ct)
|
||||
setContent(ct.content ?? '')
|
||||
// 换章瞬移到目标页,不要带着上一章的翻页过渡滑过去。
|
||||
snapTextPageRef.current = true
|
||||
appliedTextPageRef.current = -1
|
||||
setPage(0)
|
||||
setComicPage(0)
|
||||
setCurrentImage(0)
|
||||
@@ -380,6 +468,18 @@ 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'
|
||||
el.style.transform = `translateX(-${nextPage * stride}px)`
|
||||
appliedTextPageRef.current = nextPage
|
||||
appliedTextVwRef.current = width
|
||||
}, [])
|
||||
|
||||
const relayout = useCallback(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
@@ -388,6 +488,8 @@ export default function ReaderViewPage() {
|
||||
const total = vp.scrollWidth
|
||||
const count = Math.max(1, Math.ceil((total + COLUMN_GAP) / (width + COLUMN_GAP)))
|
||||
setPageCount(count)
|
||||
// 恢复进度 / 夹紧页码属于排版结果,不是用户翻页——关掉过渡,免得整章闪滑。
|
||||
snapTextPageRef.current = true
|
||||
setPage((p) => {
|
||||
if (pendingEndRef.current && count > 0) {
|
||||
pendingEndRef.current = false
|
||||
@@ -406,6 +508,15 @@ export default function ReaderViewPage() {
|
||||
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
|
||||
applyTextPageTransform(page, animate)
|
||||
}, [page, vw, contentType, settings.pageMode, applyTextPageTransform])
|
||||
|
||||
useEffect(() => {
|
||||
const vp = viewportRef.current
|
||||
if (!vp) return
|
||||
@@ -631,9 +742,14 @@ export default function ReaderViewPage() {
|
||||
scrollRef.current?.scrollBy({ top: -window.innerHeight * 0.9, behavior: 'auto' })
|
||||
return
|
||||
}
|
||||
if (page > 0) setPage((p) => p - 1)
|
||||
else goChapter(-1, true)
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
|
||||
// 文本翻页:先命令式挪正文列,再 setPage 更新页脚。顺序不能反——
|
||||
// 否则长章重渲染会把过渡堵到卡顿结束才开始。
|
||||
if (page > 0) {
|
||||
const next = page - 1
|
||||
applyTextPageTransform(next, true)
|
||||
setPage(next)
|
||||
} else goChapter(-1, true)
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter, applyTextPageTransform])
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (contentType === 'audio') {
|
||||
@@ -657,9 +773,12 @@ export default function ReaderViewPage() {
|
||||
else el?.scrollBy({ top: window.innerHeight * 0.9, behavior: 'auto' })
|
||||
return
|
||||
}
|
||||
if (page < pageCount - 1) setPage((p) => p + 1)
|
||||
else goChapter(1)
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
|
||||
if (page < pageCount - 1) {
|
||||
const next = page + 1
|
||||
applyTextPageTransform(next, true)
|
||||
setPage(next)
|
||||
} else goChapter(1)
|
||||
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter, applyTextPageTransform])
|
||||
|
||||
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
|
||||
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
|
||||
@@ -672,10 +791,10 @@ export default function ReaderViewPage() {
|
||||
// 「滑动后浏览器补发的 click」的去重都在 useHorizontalSwipe 里)。
|
||||
//
|
||||
// 跟手拖拽平移的是两个不同的元素:
|
||||
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。分页平移仍旧由 React 写在
|
||||
// 里层,跟手层只管手指位移;松手时两层同时动——跟手位移归零 + 页码变化——
|
||||
// 合起来就是一次连续滑动。分两层而不是直接改里层,是为了避免「跟手位移把 DOM
|
||||
// 改成了 React 不知道的值、React 又认为 transform 没变而不去写」把页面留在拖动位置。
|
||||
// - 文本:正文列外面单独挂一层「跟手层」(pageDragRef)。分页平移由 applyTextPageTransform
|
||||
// 写在里层(contentRef),跟手层只管手指位移;松手时两层同时动——跟手位移归零 +
|
||||
// 页码变化——合起来就是一次连续滑动。分两层是为了避免跟手位移和 React/命令式页码
|
||||
// 位移互相覆盖;页码不再经 React style 下发,长章翻页才不会先卡主线程再开动画。
|
||||
// - 漫画:平移 ReaderComic 的舞台(comicStageRef)。舞台两侧摆着上一页/下一页
|
||||
// (见那边的 carousel 分支),翻页时单元格的 left 偏移与舞台位移在同一次提交里
|
||||
// 相抵,画面看不出切换。
|
||||
@@ -852,8 +971,11 @@ export default function ReaderViewPage() {
|
||||
1000,
|
||||
),
|
||||
onChange: (v: number) => {
|
||||
if (settings.pageMode === 'page') setPage(v - 1)
|
||||
else {
|
||||
if (settings.pageMode === 'page') {
|
||||
const next = v - 1
|
||||
applyTextPageTransform(next, true)
|
||||
setPage(next)
|
||||
} else {
|
||||
const el = scrollRef.current
|
||||
if (el) el.scrollTop = (v / 1000) * (el.scrollHeight - el.clientHeight)
|
||||
}
|
||||
@@ -905,62 +1027,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) {
|
||||
@@ -1022,7 +1095,7 @@ export default function ReaderViewPage() {
|
||||
style={menuShiftStyle}
|
||||
>
|
||||
<div ref={viewportRef} className="relative h-full overflow-hidden">
|
||||
{/* 跟手层:跟手拖拽的位移写在这一层,分页平移仍旧写在里层(React 管)。 */}
|
||||
{/* 跟手层:跟手位移写这里;分页平移由 applyTextPageTransform 写在 contentRef 上。 */}
|
||||
<div ref={pageDragRef} className="h-full">
|
||||
<div
|
||||
ref={contentRef}
|
||||
@@ -1031,13 +1104,17 @@ export default function ReaderViewPage() {
|
||||
columnWidth: `${Math.max(vw, 1)}px`,
|
||||
columnGap: `${COLUMN_GAP}px`,
|
||||
columnFill: 'auto',
|
||||
transform: `translateX(-${page * (vw + COLUMN_GAP)}px)`,
|
||||
transition: PAGE_TRANSITION,
|
||||
fontSize: settings.fontSize,
|
||||
lineHeight: settings.lineHeight,
|
||||
}}
|
||||
>
|
||||
{blocks.map((b, i) => renderParagraph(b, i))}
|
||||
<ReaderTextPageBody
|
||||
blocks={blocks}
|
||||
paragraphSpacing={settings.paragraphSpacing}
|
||||
fontSize={settings.fontSize}
|
||||
accent={theme.accent}
|
||||
onOpenComment={handleOpenComment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1062,7 +1139,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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user