@@ -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
+}