This commit is contained in:
truewhile
2026-10-02 17:56:16 +08:00
parent 2c6646beea
commit f3b88fad7c
10 changed files with 581 additions and 22 deletions
+5 -1
View File
@@ -94,7 +94,11 @@
- 正文留边:左右 16px、上下 8px(对齐 legado 默认左右16/上下6),分页列宽按留边后的视口宽计算,正文不贴屏幕边。
- 菜单打开时正文整体下移一个顶栏高度(用 `transform`,不改视口高度、不触发重新分页),顶栏不再压住开头 1–2 行。
- 界面面板的字号/行距/段距三个调节组用 `flex-wrap`,窄屏自动折行,不会把「段距」挤出屏幕。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;滚动模式保留浏览器原生滚动不接管,菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- 点击分区:翻页模式渲染一层 `absolute inset-0` 的三分区按钮(左 30% 上一页 / 中 40% 呼出菜单 / 右 30% 下一页);**滚动模式不能渲染这层覆盖层**——它不是滚动容器的子节点,手机上手指落在覆盖层按钮上时浏览器找不到可滚动的祖先,纵向滑动完全失效、只剩点左右能翻屏。滚动模式改为在滚动容器自身的 `onClick` 上按 x 坐标分区(`handleZoneTap`),手指拖动不会产生 click,原生纵向滚动照常。
- 滚动模式的滚轮(`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` 得写死列数字面量,多一个按钮无法复用)。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `<img>`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`<div>`/`</div>`)与空行当作无内容忽略,绝不吞掉可读文字。
- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。
- 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。
**本地书籍(对应 legado 本地 TXT / EPUB)**
+5
View File
@@ -501,6 +501,11 @@ func (s *ReaderService) LocalChapterContent(
// EPUB 里的图片换成签名地址(前端按 [img] 标记渲染)
text = s.rewriteLocalImages(book.ID, text)
text = s.applyUserReplaceRules(ctx, userID, book.Name, text)
// 图片版 EPUB(整章都是 [img] 标记)同样是漫画:按图片类型下发,
// 前端走漫画阅读器,才有整页缩放与双页铺开。
if imgs, ok := imageMarkersOnly(text); ok {
return &ChapterContent{Type: "image", Images: imgs, declaredType: -1}, nil
}
return &ChapterContent{Type: "text", Content: text, declaredType: -1}, nil
}
+66
View File
@@ -1696,6 +1696,63 @@ func rewriteContentImageMarkers(content, baseURL string, proxy func(string) stri
return strings.Join(out, "\n")
}
// imageMarkersOnly 判断一章正文是不是「整章都是图片」:非空行全部是 [img] 标记行。
//
// 文本型漫画源(拷贝漫画等,bookSourceType=0 但正文规则给 <img>,见
// rewriteContentImageMarkers)和图片版 EPUB 的正文就是这样一连串的标记行。
// 这类章节本质上就是漫画,必须按漫画渲染:文本阅读器的分页把每张图当成一列,
// 一屏只看得到一张,桌面端也没法两页并排——正是「漫画没法双页铺开」的根因。
// 所以这里识别出来之后由调用方把类型改成 image,交给漫画阅读器。
//
// 只认「非空行全是标记」:混了正文的章节一律保持 text,绝不能把文字吃掉。
// 返回的地址已经是签名代理地址(调用点在此之前刚做过改写)。
func imageMarkersOnly(content string) ([]string, bool) {
var images []string
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
// 空行和一个孤立的 HTML 标签(<div>/</div> 之类,正文规则给 outerHTML 时很常见)
// 都没有可读文字,不影响判断。
if trimmed == "" || isHTMLTagOnly(trimmed) {
continue
}
if !strings.HasPrefix(trimmed, imgMarkerPrefix) {
return nil, false
}
addr := strings.TrimSpace(strings.TrimPrefix(trimmed, imgMarkerPrefix))
if addr == "" {
return nil, false
}
images = append(images, addr)
}
return images, len(images) > 0
}
// isHTMLTagOnly 判断整行是不是一个孤立的 HTML 标签(<div>、</div>、<br> 之类)。
// 即「<」开头、「>」结尾,且尖括号内是标签名的形状(字母开头,后面可带属性)。
// 标签名两侧的空白(源码里常见的 < div > 这类手写残渣)一并忽略。
func isHTMLTagOnly(line string) bool {
if len(line) < 3 || line[0] != '<' || line[len(line)-1] != '>' {
return false
}
inner := strings.TrimSpace(strings.TrimPrefix(line[1:len(line)-1], "/"))
if inner == "" {
return false
}
for i, r := range inner {
switch {
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z'):
continue
case i == 0:
// 首字符不是字母:<3、<!-- 注释 --> 之类,一律不当标签
return false
default:
// 标签名之后(属性、空白、自闭合斜杠)都算标签
return true
}
}
return true
}
// imageRefsInLine 取出一行正文里的图片地址。
//
// - 正文规则给的就是地址 → 原样返回;
@@ -2190,6 +2247,15 @@ func (s *ReaderService) GetContentForBook(ctx context.Context, userID, bookID st
return s.ProxyURL(book.ID, u)
})
}
// 整章都是 [img] 标记 → 这就是一章漫画,改成图片类型交给漫画阅读器。
// 若还按文本下发,前端会把它当文字按列分页,一屏只有一张图(双页铺开也无从谈起)。
if out.Type == "text" {
if imgs, ok := imageMarkersOnly(out.Content); ok {
out.Type = "image"
out.Images = imgs
out.Content = ""
}
}
return out, nil
}
@@ -57,6 +57,76 @@ func TestRewriteContentImageMarkersWithoutProxy(t *testing.T) {
}
}
// TestImageMarkersOnly 整章都是 [img] 标记时判定为图片章(前端才能走漫画阅读器、
// 双页铺开);只要掺了能读的正文就必须保持文本,不能把文字吃掉。
func TestImageMarkersOnly(t *testing.T) {
cases := []struct {
name string
content string
wantOK bool
wantURLs []string
}{
{
name: "整章都是标记",
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + "/b.jpg\n",
wantOK: true,
wantURLs: []string{"/a.jpg", "/b.jpg"},
},
{
name: "夹着空行 / 孤立 html 标签 / 首尾空白",
content: "\n" + imgMarkerPrefix + "/a.jpg\n \n<div>\n</div>\n< br >\n" +
" " + imgMarkerPrefix + " /b.jpg \n",
wantOK: true,
wantURLs: []string{"/a.jpg", "/b.jpg"},
},
{
name: "掺了正文就保持文本(图 + 长段落)",
content: imgMarkerPrefix + "/a.jpg\n" + "第一句话。\n" + imgMarkerPrefix + "/b.jpg\n",
wantOK: false,
},
{
name: "纯文本",
content: "第一章\n正文正文\n",
wantOK: false,
},
{
name: "空正文",
content: "\n\n \n",
wantOK: false,
},
{
name: "标记地址为空",
content: imgMarkerPrefix + "/a.jpg\n" + imgMarkerPrefix + " \n",
wantOK: false,
},
{
// 「<3」这类不是标签,是有内容的一行,不能当成空白忽略
name: "非标签的尖括号行",
content: imgMarkerPrefix + "/a.jpg\n3 < 5\n",
wantOK: false,
},
}
for _, c := range cases {
got, ok := imageMarkersOnly(c.content)
if ok != c.wantOK {
t.Errorf("%s: ok = %v,期望 %v(urls=%v)", c.name, ok, c.wantOK, got)
continue
}
if !ok {
continue
}
if len(got) != len(c.wantURLs) {
t.Errorf("%s: urls = %v,期望 %v", c.name, got, c.wantURLs)
continue
}
for i := range got {
if got[i] != c.wantURLs[i] {
t.Errorf("%s: urls[%d] = %q,期望 %q", c.name, i, got[i], c.wantURLs[i])
}
}
}
}
// TestSearchCheckKeyWord 校验关键字的取值规则(对应 legado getCheckKeyword):
// 含 http/::/++/-- 的值是地址或扩展标记,不当作关键字。
func TestSearchCheckKeyWord(t *testing.T) {
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useMemo, useState } from 'react'
import {
buildSpreadIndexOf,
buildSpreads,
hasImageSize,
loadImageSize,
type ComicSpread,
} from '../utils/comicSpread'
// 漫画双页铺开的分组 + 尺寸探测。
//
// 分组结果依赖每张图是否「横跨两页」,而这要等图片尺寸探测出来才能判断,
// 于是探测每完成一张就 bump 一次版本号、重新分组。为避免把整章图片都拉一遍,
// 只探测当前页附近的一小段窗口:向前看 8 张,翻到下一屏时它的尺寸已经就绪,
// 不会出现「先按竖版配好、渲染完再重排」的跳动。
/** 当前页往后探测几张。 */
const PROBE_AHEAD = 8
/** 当前页往前回看几张(滑块往回拖时补上断档)。 */
const PROBE_BEHIND = 1
export interface ComicSpreads {
/** 每屏要显示的图片序号。 */
spreads: ComicSpread[]
/** 图片序号 → 所在屏序号。 */
spreadIndexOf: number[]
}
/**
* @param srcs 整章图片地址。
* @param enabled 只在真的会用到双页时探测,单页/滚动模式不额外发请求。
* @param center 当前图片序号,决定探测窗口。
*/
export function useComicSpreads(srcs: string[], enabled: boolean, center: number): ComicSpreads {
const [revision, setRevision] = useState(0)
useEffect(() => {
if (!enabled || srcs.length === 0) return
let cancelled = false
const from = Math.max(0, center - PROBE_BEHIND)
const to = Math.min(srcs.length - 1, center + PROBE_AHEAD)
const targets: string[] = []
for (let i = from; i <= to; i += 1) {
const src = srcs[i]
if (src && !hasImageSize(src)) targets.push(src)
}
if (targets.length === 0) return
for (const src of targets) {
loadImageSize(src)
.then(() => {
if (!cancelled) setRevision((v) => v + 1)
})
.catch(() => undefined)
}
return () => {
cancelled = true
}
}, [srcs, enabled, center])
const spreads = useMemo(() => {
// revision 参与依赖:尺寸探测每完成一张就重新分组,宽图才能正确独占一屏
void revision
return buildSpreads(srcs)
}, [srcs, revision])
const spreadIndexOf = useMemo(() => buildSpreadIndexOf(spreads, srcs.length), [spreads, srcs.length])
return { spreads, spreadIndexOf }
}
+104
View File
@@ -0,0 +1,104 @@
import { useEffect, useRef, type RefObject } from 'react'
/**
* 滚轮平滑滚动:把离散的滚轮格数累加成一个「目标位置」,
* 再让 scrollTop 每帧向它逼近,于是滚动像手机上下滑动一样连续、带减速惯性,
* 而不是浏览器的整格跳变。
*
* 参考帧间隔(60Hz)下的逼近比例:越大越跟手,越小越飘。
*/
const EASE_PER_FRAME = 0.2
/** 每帧的基准毫秒数。按真实帧间隔换算,120Hz 屏上速度才不会翻倍。 */
const FRAME_MS = 1000 / 60
/** 与目标相差小于这个像素直接落位,省掉最后一帧帧逼近。 */
const EPSILON = 0.5
/** 单帧最大推进间隔,避免切标签页回来后一步跳很远。 */
const MAX_FRAME_MS = 64
/**
* 给一个纵向滚动容器接上「类手机滑动」的滚轮滚动。
*
* - 只用滚轮事件驱动,触摸/拖动滚动条时立刻交还控制权,不和手指打架。
* - `ctrl/cmd + 滚轮`(浏览器缩放)不接管。
* - 容器不可滚动时不做 `preventDefault`。
*
* @param ref 滚动容器(`overflow-y: auto`)。
* @param enabled 是否接管;翻页模式等由别处处理滚轮的场景传 `false`。
*/
export function useSmoothWheelScroll(ref: RefObject<HTMLElement>, enabled: boolean): void {
const targetRef = useRef<number | null>(null)
const rafRef = useRef<number | null>(null)
const lastFrameRef = useRef(0)
useEffect(() => {
const el = ref.current
if (!el || !enabled) return
const stop = () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
targetRef.current = null
lastFrameRef.current = 0
}
const step = (now: number) => {
rafRef.current = null
const target = targetRef.current
const node = ref.current
if (target === null || !node) return
const dt = lastFrameRef.current === 0 ? FRAME_MS : Math.min(MAX_FRAME_MS, now - lastFrameRef.current)
lastFrameRef.current = now
const diff = target - node.scrollTop
if (Math.abs(diff) < EPSILON) {
node.scrollTop = target
targetRef.current = null
lastFrameRef.current = 0
return
}
const before = node.scrollTop
node.scrollTop = before + diff * (1 - Math.pow(1 - EASE_PER_FRAME, dt / FRAME_MS))
// 到了滚动边界(或内容变短被浏览器夹住)时 scrollTop 不动了,
// 再继续逼近就是 rAF 空转,直接收手。
if (node.scrollTop === before) {
targetRef.current = null
lastFrameRef.current = 0
return
}
rafRef.current = requestAnimationFrame(step)
}
const onWheel = (e: WheelEvent) => {
// ctrl/cmd+滚轮是浏览器缩放,别抢
if (e.ctrlKey || e.metaKey || e.deltaY === 0) return
const max = el.scrollHeight - el.clientHeight
if (max <= 0) return
e.preventDefault()
// deltaMode: 0=像素 1=行 2=页,统一折算成像素
const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientHeight : 1
const base = targetRef.current ?? el.scrollTop
targetRef.current = Math.min(max, Math.max(0, base + e.deltaY * unit))
if (rafRef.current === null) {
lastFrameRef.current = 0
rafRef.current = requestAnimationFrame(step)
}
}
// 手指按下(移动端滑动、拖滚动条、点分区)立刻停掉动画,把控制权交还用户
const onTakeover = () => stop()
el.addEventListener('wheel', onWheel, { passive: false })
el.addEventListener('pointerdown', onTakeover)
el.addEventListener('touchstart', onTakeover, { passive: true })
return () => {
el.removeEventListener('wheel', onWheel)
el.removeEventListener('pointerdown', onTakeover)
el.removeEventListener('touchstart', onTakeover)
stop()
}
}, [ref, enabled])
}
+39 -9
View File
@@ -1,8 +1,14 @@
import { useEffect, useRef, useState } from 'react'
import { Loader2 } from 'lucide-react'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { rememberImageSize } from '../../utils/comicSpread'
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
// 上下滚动(默认)/ 左右单页两种模式;点击分区翻页/呼出菜单;图片懒加载。
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
//
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
// 这里只负责把它们并排摆好、各自撑满视口高度。
interface ReaderComicProps {
images: string[]
@@ -14,17 +20,22 @@ interface ReaderComicProps {
onProgress: (imageIndex: number) => void
scrollTo: number | null // 滚动模式:外部要求滚动到的图片序号
onScrolled: () => void
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
spread?: number[] | null
}
function ComicImage({
src,
theme,
fit = false,
half = false,
}: {
src: string
theme: { bg: string; text: string; accent: string }
// fit:单页模式用。整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
fit?: boolean
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
half?: boolean
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
@@ -40,14 +51,25 @@ function ComicImage({
// 改成图片正常参与布局,未加载时用 minHeight 占位,转圈/失败信息盖在上层。
return (
<div
className={`relative flex w-full items-center justify-center ${fit ? 'h-full' : ''}`}
style={{ backgroundColor: theme.bg }}
className={`relative flex items-center justify-center ${fit ? 'h-full' : ''} ${half ? '' : 'w-full'}`}
style={{
backgroundColor: theme.bg,
// 半屏占位:宽度取「图片自然宽度」与「半屏」的较小值。容器是 flex item,
// 百分比 max-width 相对这一行的宽度解析,所以图片不会撑破半屏;
// 图片比半屏窄时容器跟着收窄,左右两页自然贴在一起。
...(half ? { maxWidth: '50%', minWidth: 0 } : null),
}}
>
<img
src={src}
loading="lazy"
alt=""
onLoad={() => setState('ok')}
onLoad={(e) => {
const el = e.currentTarget
// 顺带回填原始尺寸:双页分组要用它判断这张图是否横跨两页
rememberImageSize(src, el.naturalWidth, el.naturalHeight)
setState('ok')
}}
onError={() => setState('error')}
className={fit ? 'block max-h-full w-auto max-w-full object-contain' : 'block w-full'}
style={state === 'ok' ? undefined : { minHeight: '10rem' }}
@@ -72,7 +94,7 @@ function ComicImage({
)
}
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled }: ReaderComicProps) {
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread }: ReaderComicProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const restoredRef = useRef(false)
@@ -98,6 +120,9 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
restoredRef.current = false
}, [mode])
// 上下滚动模式的滚轮同样走「类手机滑动」的平滑惯性滚动
useSmoothWheelScroll(scrollRef, mode === 'scroll')
if (images.length === 0) {
return (
<div className="flex h-full items-center justify-center text-sm opacity-60" style={{ color: theme.text }}>
@@ -107,11 +132,16 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
}
if (mode === 'page') {
// 单页模式:翻页由外层点击区驱动(page 语义 = 图片序号)
const idx = Math.min(Math.max(page, 0), images.length - 1)
// 翻页模式:翻页由外层点击区驱动(page 语义 = 图片序号)。
// 双页铺开时外层按「屏」给好成员(spread),这里只负责并排摆开。
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
return (
<div className="flex h-full items-center justify-center">
<ComicImage key={images[idx]} src={images[idx]} theme={theme} fit />
{idxs.map((i) => (
<ComicImage key={images[i]} src={images[i]} theme={theme} fit half={paired} />
))}
</div>
)
}
+101 -11
View File
@@ -5,6 +5,7 @@ import {
ArrowLeft,
ArrowLeftRight,
BookOpen,
Columns2,
LayoutList,
ListEnd,
Loader2,
@@ -17,6 +18,8 @@ import {
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { ReaderAudioPanel } from './ReaderAudioPanel'
@@ -49,6 +52,15 @@ const WHEEL_TURN_COOLDOWN = 220
/** 滚轮事件间隔超过这个毫秒数算新手势,重新累计(用于判断触控板一次滑动结束)。 */
const WHEEL_GESTURE_GAP = 180
/**
* 漫画双页铺开的最小窗口宽度。比这窄时并排两页每页只剩一条竖条,
* 反而比单页更难看清,所以渲染层按窗口宽度自动退回单页(偏好设置不动)。
*/
const COMIC_SPREAD_MIN_WIDTH = 900
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
const NO_IMAGES: string[] = []
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -109,6 +121,34 @@ export default function ReaderViewPage() {
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)
}, [])
const comicImages = contentType === 'image' && media?.images ? media.images : NO_IMAGES
const comicDoublePage =
contentType === 'image' &&
settings.pageMode === 'page' &&
settings.comicDoublePage &&
windowWidth >= COMIC_SPREAD_MIN_WIDTH
// 分组依赖每张图的原始宽高(横跨两页的宽图要独占一屏),探测窗口跟着当前页走
const { spreads: comicSpreads, spreadIndexOf: comicSpreadIndexOf } = useComicSpreads(
comicImages,
comicDoublePage,
comicPage,
)
/** 当前是第几「屏」。进度仍按图片序号记,屏序号只用于翻页与渲染。 */
const comicSpreadIndex =
comicDoublePage && comicSpreads.length > 0
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 0
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
@@ -259,6 +299,11 @@ export default function ReaderViewPage() {
}
}, [content, settings.pageMode])
// 滚动模式的滚轮改成「类手机滑动」:把滚轮格数累加成目标位置再逐帧逼近,
// 滚动连续、松手后自己滑行一段,而不是浏览器整格跳变。
// 翻页模式的滚轮由下面的翻页监听器接管,这里必须关掉。
useSmoothWheelScroll(scrollRef, settings.pageMode === 'scroll' && contentType === 'text')
// ── 进度保存(翻页 / 滚动 / 听书 / 漫画) ──
// pos 对文本是页码、漫画是图片序号、听书是秒;audio.currentTime 带小数,
// 统一取整后再上报,和 dur_chapter_pos 的 int 语义对齐。
@@ -446,6 +491,12 @@ export default function ReaderViewPage() {
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
// 双页:一次退一屏;进度落在该屏的首张图上
if (comicDoublePage && comicSpreads.length > 0) {
if (comicSpreadIndex > 0) setComicPage(comicSpreads[comicSpreadIndex - 1][0])
else goChapter(-1, true)
return
}
if (comicPage > 0) setComicPage((p) => p - 1)
else goChapter(-1, true)
return
@@ -456,7 +507,7 @@ export default function ReaderViewPage() {
}
if (page > 0) setPage((p) => p - 1)
else goChapter(-1, true)
}, [contentType, settings.pageMode, comicPage, page, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, comicPage, page, goChapter])
const goNext = useCallback(() => {
if (contentType === 'audio') {
@@ -464,6 +515,11 @@ export default function ReaderViewPage() {
return
}
if (contentType === 'image' && settings.pageMode === 'page') {
if (comicDoublePage && comicSpreads.length > 0) {
if (comicSpreadIndex < comicSpreads.length - 1) setComicPage(comicSpreads[comicSpreadIndex + 1][0])
else goChapter(1)
return
}
const n = media?.images?.length ?? 0
if (comicPage < n - 1) setComicPage((p) => p + 1)
else goChapter(1)
@@ -477,7 +533,22 @@ export default function ReaderViewPage() {
}
if (page < pageCount - 1) setPage((p) => p + 1)
else goChapter(1)
}, [contentType, settings.pageMode, media, comicPage, page, pageCount, goChapter])
}, [contentType, settings.pageMode, comicDoublePage, comicSpreads, comicSpreadIndex, media, comicPage, page, pageCount, goChapter])
// ── 点击分区(左 30% 上一页 / 中间呼出菜单 / 右 30% 下一页) ──
// 翻页模式用覆盖层上的三个按钮驱动;滚动模式不能再用覆盖层——
// 覆盖层不是滚动容器的子节点,手机上手指落在它上面会让纵向滑动失效
// (只能点左右),所以滚动模式改在滚动容器自身的 onClick 上按 x 坐标分区:
// 纵向拖动不会产生 click,浏览器原生滚动照常工作。
const handleZoneTap = useCallback(
(clientX: number, rect: DOMRect) => {
const x = rect.width > 0 ? (clientX - rect.left) / rect.width : 0.5
if (x < 0.3) goPrev()
else if (x > 0.7) goNext()
else setMenuOpen((v) => !v)
},
[goPrev, goNext],
)
// ── 键盘(桌面端) ──
useEffect(() => {
@@ -499,12 +570,13 @@ export default function ReaderViewPage() {
}, [goPrev, goNext, menuOpen, panel])
// ── 鼠标滚轮翻页(仅翻页模式) ──
// 往上滚=上一页,往下滚=下一页。滚动模式保持浏览器原生滚动,不做接管。
// 往上滚=上一页,往下滚=下一页。滚动模式的滚轮不在这里处理,
// 由 useSmoothWheelScroll 接管做平滑惯性滚动。
// 滚轮必须用原生监听器:React 的 onWheel 是 passive 的,调不了 preventDefault。
useEffect(() => {
const el = readerRef.current
if (!el) return
// 滚动模式/听书面板/漫画滚动交给浏览器自己处理;菜单打开时不翻页
// 滚动模式/听书面板/漫画滚动交给各自的滚动逻辑;菜单打开时不翻页
if (settings.pageMode !== 'page' || menuOpen) return
if (contentType !== 'text' && contentType !== 'image') return
@@ -639,7 +711,7 @@ export default function ReaderViewPage() {
<div ref={readerRef} className="fixed inset-0 z-40 flex flex-col" style={{ backgroundColor: theme.bg, color: theme.text }}>
{/* 正文视口 */}
<div className="relative flex-1 overflow-hidden">
<div className="mx-auto h-full w-full max-w-[900px]">
<div className={`mx-auto h-full w-full ${contentType === 'image' && comicDoublePage ? '' : 'max-w-[900px]'}`}>
{contentType === 'audio' ? (
media && media.tracks && media.tracks.length > 0 ? (
<ReaderAudioPanel
@@ -670,10 +742,11 @@ export default function ReaderViewPage() {
)
) : contentType === 'image' ? (
<ReaderComic
images={media?.images ?? []}
images={comicImages}
theme={theme}
mode={settings.pageMode}
page={comicPage}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
}}
@@ -711,6 +784,7 @@ export default function ReaderViewPage() {
) : (
<div
ref={scrollRef}
onClick={(e) => handleZoneTap(e.clientX, e.currentTarget.getBoundingClientRect())}
onScroll={(e) => {
const el = e.currentTarget
if (chapterIndex === null) return
@@ -723,7 +797,7 @@ export default function ReaderViewPage() {
}
}
}}
className={`h-full overflow-y-auto px-4 ${MENU_SHIFT}`}
className={`h-full overflow-y-auto overscroll-contain px-4 ${MENU_SHIFT}`}
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
>
<div className="py-4">
@@ -763,7 +837,10 @@ export default function ReaderViewPage() {
)}
</div>
{(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && (
{/* 点击分区覆盖层:只在翻页模式用。滚动模式用覆盖层会挡住正文的
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
<div className="absolute inset-0 grid grid-cols-[30%_40%_30%]">
<button type="button" aria-label="上一页" onClick={goPrev} className="cursor-w-resize" />
<button
@@ -846,8 +923,10 @@ export default function ReaderViewPage() {
</button>
</div>
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */}
<div className={`${canSwitchSource ? 'grid-cols-5' : 'grid-cols-4'} grid pt-1`} style={{ color: theme.text }}>
{/* 动作行(目录 / 界面 / 夜间 / 模式 / 双页 / 换源)。
用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化,
grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */}
<div className="flex pt-1" style={{ color: theme.text }}>
{([
{ icon: <LayoutList size={18} />, label: '目录', action: () => openPanel('toc') },
{ icon: <BookOpen size={18} />, label: '界面', action: () => openPanel('style') },
@@ -861,6 +940,17 @@ export default function ReaderViewPage() {
label: settings.pageMode === 'page' ? '滚动' : '翻页',
action: () => settings.setPageMode(settings.pageMode === 'page' ? 'scroll' : 'page'),
},
// 双页铺开只对漫画的翻页模式有意义;窗口太窄时并排两页没法看,
// 干脆不显示入口(不是灰掉,避免用户以为坏了)。
...(contentType === 'image' && settings.pageMode === 'page' && windowWidth >= COMIC_SPREAD_MIN_WIDTH
? [
{
icon: <Columns2 size={18} />,
label: settings.comicDoublePage ? '单页' : '双页',
action: () => settings.setComicDoublePage(!settings.comicDoublePage),
},
]
: []),
...(canSwitchSource
? [
{
@@ -877,7 +967,7 @@ export default function ReaderViewPage() {
key={item.label}
type="button"
onClick={item.action}
className="flex flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
className="flex flex-1 flex-col items-center gap-1 py-1 opacity-80 hover:opacity-100"
>
{item.icon}
<span className="text-2xs">{item.label}</span>
+13 -1
View File
@@ -77,6 +77,10 @@ interface ReaderSettingsState {
pageMode: ReaderPageMode
setPageMode: (mode: ReaderPageMode) => void
/** 漫画左右「双页铺开」偏好(legado 无对应项,是 Web 端桌面化补充)。 */
comicDoublePage: boolean
setComicDoublePage: (on: boolean) => void
fontSize: number
setFontSize: (size: number) => void
@@ -136,6 +140,13 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
pageMode: 'page',
setPageMode: (pageMode) => set({ pageMode }),
// 默认开启:桌面端(窗口够宽)漫画自动两页铺开,窄屏(手机/窗口拉窄)
// 由渲染层按窗口宽度自动退回单页,所以这里开着不会影响手机阅读。
// 刻意不做跨设备同步:双页是否合适取决于屏幕宽度,属于设备级偏好,
// 和 homeMode 同理(见 utils/readerSettingsSync.ts 的载荷字段)。
comicDoublePage: true,
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
fontSize: 20,
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
@@ -162,7 +173,8 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。
* 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好,
* 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。
*/
export interface ReaderSettingsProfile {
theme_id: string
+108
View File
@@ -0,0 +1,108 @@
// 漫画「双页铺开」的纯逻辑与图片尺寸探测。
//
// 桌面端一屏能并排放下两页,漫画按「左右两页」成对观看才接近纸质阅读体验
// (尤其日漫的跨页)。麻烦在于章节里常混着「本来就横跨两页」的宽图(跨页扉页、
// 彩页):这类图必须独占一屏,否则会被硬塞进一对里,从它开始整章都错位。
//
// 所以分组规则与 Tachiyomi 的 dual page 一致:宽度明显大于高度的图独占一组,
// 其余连续的竖版页按顺序两两成对。判断需要原始宽高,而列表接口只给 URL,
// 这里就自己探测并缓存(<img> 渲染时也会顺带回填,能省掉一次请求)。
/** 图片原始像素尺寸。 */
export interface ImageSize {
width: number
height: number
}
/** 宽高比达到这个值即视为「跨页宽图」。普通页约 0.6~0.7,跨页图约 1.3~1.5。 */
const WIDE_RATIO = 1.1
/** 已探测到的尺寸,按 URL 缓存(null = 探测失败,当竖版处理),跨章节复用。 */
const sizeCache = new Map<string, ImageSize | null>()
/** 正在探测的图片,避免同一 URL 并发发起多次请求。 */
const inflight = new Map<string, Promise<ImageSize | null>>()
/** 渲染中的 <img> onLoad 顺带回填尺寸,省掉一次探测请求。 */
export function rememberImageSize(src: string, width: number, height: number): void {
if (!src || !width || !height) return
sizeCache.set(src, { width, height })
}
/** 该 URL 是否已经探测过(成功的记尺寸,失败的记 null)。 */
export function hasImageSize(src: string): boolean {
return sizeCache.has(src)
}
/** 探测一张图片的原始尺寸;失败(404 / 被拦 / 解码失败)返回 null。 */
export function loadImageSize(src: string): Promise<ImageSize | null> {
const cached = sizeCache.get(src)
if (cached !== undefined) return Promise.resolve(cached)
const running = inflight.get(src)
if (running) return running
const task = new Promise<ImageSize | null>((resolve) => {
const img = new Image()
const settle = (size: ImageSize | null) => {
sizeCache.set(src, size)
inflight.delete(src)
resolve(size)
}
img.onload = () =>
settle(
img.naturalWidth > 0 && img.naturalHeight > 0
? { width: img.naturalWidth, height: img.naturalHeight }
: null,
)
img.onerror = () => settle(null)
img.src = src
})
inflight.set(src, task)
return task
}
/** 该图是否横跨两页(宽明显大于高)。尺寸未知时按竖版处理,宁可先配对。 */
export function isWideImage(src: string): boolean {
const size = sizeCache.get(src)
if (!size) return false
return size.width / size.height >= WIDE_RATIO
}
/** 一「屏」要显示的图片序号:一张=独占的宽图/单页,两张=左右并排的一对。 */
export type ComicSpread = number[]
/**
* 把整章图片按双页铺开分组:连续的竖版页两两成对,宽图独占一组并打断前后配对。
* 尺寸还没探测出来的图先按竖版参与配对,探测完成后再重新分组(见 useComicSpreads)。
*/
export function buildSpreads(srcs: string[]): ComicSpread[] {
const spreads: ComicSpread[] = []
let i = 0
while (i < srcs.length) {
if (isWideImage(srcs[i])) {
spreads.push([i])
i += 1
continue
}
// 收集一段连续的竖版页,两两成对;落单的最后一张自己一屏(常见于章尾)
const run: number[] = []
while (i < srcs.length && !isWideImage(srcs[i])) {
run.push(i)
i += 1
}
for (let k = 0; k < run.length; k += 2) spreads.push(run.slice(k, k + 2))
}
return spreads
}
/**
* 图片序号 → 所在屏序号。进度是按图片序号记的,翻页要换算成「当前在第几屏」,
* 并且不管展开/收起双页都稳定(只由图片序号推出来,不额外存一屏序号)。
*/
export function buildSpreadIndexOf(spreads: ComicSpread[], total: number): number[] {
const map = new Array<number>(Math.max(0, total)).fill(0)
spreads.forEach((spread, si) => {
for (const idx of spread) {
if (idx >= 0 && idx < map.length) map[idx] = si
}
})
return map
}