diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md index e3a0564..fb68601 100644 --- a/docs/reader-ui-spec.md +++ b/docs/reader-ui-spec.md @@ -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()` 探测(`` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。 +- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 ``,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`
`/`
`)与空行当作无内容忽略,绝不吞掉可读文字。 +- 鼠标滚轮翻页(仅翻页模式):向上滚=上一页,向下滚=下一页;菜单打开时也不翻页。鼠标滚轮一格一页(间隔至少 220ms,与翻页动画对齐),触控板小步长累计到阈值翻一页且一次手势只翻一页(避免惯性连翻)。`ctrl/cmd+滚轮` 保留浏览器缩放。 - 目录:整屏面板(顶部返回 + 书名 + 章数,Virtuoso 虚拟列表,定位并高亮当前章,点章跳转)。**必须渲染在底部菜单之外**:菜单带 `backdrop-blur`,会成为 `fixed` 后代的包含块,放里面 `h-full` 只能拿到菜单高度;历史上它写的是 `top-0 + bottom-full`,两者同时存在时高度被算成 0,整块目录完全看不见。 **本地书籍(对应 legado 本地 TXT / EPUB)** diff --git a/internal/service/reader/local.go b/internal/service/reader/local.go index c5d81ed..1510d26 100644 --- a/internal/service/reader/local.go +++ b/internal/service/reader/local.go @@ -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 } diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go index 155e2df..1212c9b 100644 --- a/internal/service/reader/reader.go +++ b/internal/service/reader/reader.go @@ -1696,6 +1696,63 @@ func rewriteContentImageMarkers(content, baseURL string, proxy func(string) stri return strings.Join(out, "\n") } +// imageMarkersOnly 判断一章正文是不是「整章都是图片」:非空行全部是 [img] 标记行。 +// +// 文本型漫画源(拷贝漫画等,bookSourceType=0 但正文规则给 ,见 +// 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 标签(
/
之类,正文规则给 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 > 这类手写残渣)一并忽略。 +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 } diff --git a/internal/service/reader/reader_helpers_test.go b/internal/service/reader/reader_helpers_test.go index 248d032..2923943 100644 --- a/internal/service/reader/reader_helpers_test.go +++ b/internal/service/reader/reader_helpers_test.go @@ -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
\n
\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) { diff --git a/web/src/hooks/useComicSpreads.ts b/web/src/hooks/useComicSpreads.ts new file mode 100644 index 0000000..4ea857e --- /dev/null +++ b/web/src/hooks/useComicSpreads.ts @@ -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 } +} diff --git a/web/src/hooks/useSmoothWheelScroll.ts b/web/src/hooks/useSmoothWheelScroll.ts new file mode 100644 index 0000000..4133d8a --- /dev/null +++ b/web/src/hooks/useSmoothWheelScroll.ts @@ -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, enabled: boolean): void { + const targetRef = useRef(null) + const rafRef = useRef(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]) +} diff --git a/web/src/pages/reader/ReaderComic.tsx b/web/src/pages/reader/ReaderComic.tsx index b686258..195ae75 100644 --- a/web/src/pages/reader/ReaderComic.tsx +++ b/web/src/pages/reader/ReaderComic.tsx @@ -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 (
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(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 (
@@ -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 (
- + {idxs.map((i) => ( + + ))}
) } diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx index 8de9a1a..94fa7d4 100644 --- a/web/src/pages/reader/ReaderViewPage.tsx +++ b/web/src/pages/reader/ReaderViewPage.tsx @@ -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() {
{/* 正文视口 */}
-
+
{contentType === 'audio' ? ( media && media.tracks && media.tracks.length > 0 ? ( { if (zone === 'center') setMenuOpen((v) => !v) }} @@ -711,6 +784,7 @@ export default function ReaderViewPage() { ) : (
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 }} >
@@ -763,7 +837,10 @@ export default function ReaderViewPage() { )}
- {(contentType === 'text' || (contentType === 'image' && settings.pageMode === 'page')) && ( + {/* 点击分区覆盖层:只在翻页模式用。滚动模式用覆盖层会挡住正文的 + 原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点), + 所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */} + {settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
- {/* 动作行(目录 / 界面 / 夜间 / 模式 / 换源) */} -
+ {/* 动作行(目录 / 界面 / 夜间 / 模式 / 双页 / 换源)。 + 用 flex 等分而不是 grid-cols-N:动作数量随内容类型和书源能力变化, + grid 列数得写死成字面量类名,多一个按钮就要再加一档。 */} +
{([ { icon: , label: '目录', action: () => openPanel('toc') }, { icon: , 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: , + 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} {item.label} diff --git a/web/src/stores/readerSettings.ts b/web/src/stores/readerSettings.ts index e409a55..7acdfac 100644 --- a/web/src/stores/readerSettings.ts +++ b/web/src/stores/readerSettings.ts @@ -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()( 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()( * 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。 * * 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。 - * 首页的「影视 / 阅读」模式(homeMode)属于设备偏好,故意不参与同步。 + * 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好, + * 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。 */ export interface ReaderSettingsProfile { theme_id: string diff --git a/web/src/utils/comicSpread.ts b/web/src/utils/comicSpread.ts new file mode 100644 index 0000000..00903fd --- /dev/null +++ b/web/src/utils/comicSpread.ts @@ -0,0 +1,108 @@ +// 漫画「双页铺开」的纯逻辑与图片尺寸探测。 +// +// 桌面端一屏能并排放下两页,漫画按「左右两页」成对观看才接近纸质阅读体验 +// (尤其日漫的跨页)。麻烦在于章节里常混着「本来就横跨两页」的宽图(跨页扉页、 +// 彩页):这类图必须独占一屏,否则会被硬塞进一对里,从它开始整章都错位。 +// +// 所以分组规则与 Tachiyomi 的 dual page 一致:宽度明显大于高度的图独占一组, +// 其余连续的竖版页按顺序两两成对。判断需要原始宽高,而列表接口只给 URL, +// 这里就自己探测并缓存( 渲染时也会顺带回填,能省掉一次请求)。 + +/** 图片原始像素尺寸。 */ +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() +/** 正在探测的图片,避免同一 URL 并发发起多次请求。 */ +const inflight = new Map>() + +/** 渲染中的 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 { + 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((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(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 +}