diff --git a/docs/reader-ui-spec.md b/docs/reader-ui-spec.md
index fb68601..f963950 100644
--- a/docs/reader-ui-spec.md
+++ b/docs/reader-ui-spec.md
@@ -97,6 +97,7 @@
- 点击分区:翻页模式渲染一层 `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` 得写死列数字面量,多一个按钮无法复用)。
+- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `
` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`internal/service/reader` 的 `imageMarkersOnly`):拷贝漫画这类书源 `bookSourceType=0`(文本),正文规则直接给 `
`,服务端把整行标签转成 `[img]<签名地址>` 标记行后类型仍是 `text`,前端就会走文本阅读器——分页把每张图当成一列,一屏只看得到一张,桌面端也谈不上双页铺开。现在 `GetContentForBook` / `LocalChapterContent` 在标记化之后判断「非空行全部是 `[img]` 标记」,是则把 `type` 改成 `image` 并给出 `images`,交由漫画阅读器渲染(整页缩放、双页铺开、进度按图片序号)。判定只认「全是标记」:混了正文的章节一律保持 `text`,孤立的 HTML 标签行(`
(null)
const imgRefs = useRef<(HTMLDivElement | null)[]>([])
const restoredRef = useRef(false)
+ // 滚动容器的高度:「一屏多高」以这个容器为准,而不是 window.innerHeight——正文区是
+ // root(fixed inset-0)里的 flex-1,将来上下再挂条之类的布局变化就不等于窗口高度了。
+ // 用 ResizeObserver 跟着它,改窗口大小时 height/long 档位自动重算。
+ const [viewportHeight, setViewportHeight] = useState(0)
+
+ const selfSized = isSelfSizedComicFit(imageFit)
+ useEffect(() => {
+ // 只有 height/long/original 用得到,默认档位不挂观察器,免得每次改窗口都重渲染整章。
+ if (mode !== 'scroll' || !selfSized) return
+ const el = scrollRef.current
+ if (!el) return
+ const update = () => setViewportHeight(el.clientHeight)
+ update()
+ const observer = new ResizeObserver(update)
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [mode, selfSized])
// 滚动模式:恢复进度(图片序号)并上报当前图
useEffect(() => {
@@ -183,7 +220,7 @@ export function ReaderComic({ images, theme, mode, page, onZone, initialImage, o
}
}}
>
-
+
{i + 1} / {images.length}
diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx
index 94fa7d4..96df606 100644
--- a/web/src/pages/reader/ReaderViewPage.tsx
+++ b/web/src/pages/reader/ReaderViewPage.tsx
@@ -20,7 +20,7 @@ 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 { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
@@ -149,6 +149,19 @@ export default function ReaderViewPage() {
? Math.min(comicSpreadIndexOf[comicPage] ?? 0, comicSpreads.length - 1)
: 0
+ // ── 漫画滚动模式的图片显示尺寸 ──
+ // 档位只在上下滚动模式生效:翻页模式是整页缩放进视口,没有「太大/太小」的问题,
+ // 掺进来反而会和双页铺开的排版打架。所以翻页模式一律按 default 处理。
+ const comicScrollFit = contentType === 'image' && settings.pageMode === 'scroll' ? settings.comicImageFit : 'default'
+ /** 图片尺寸档位只在漫画的滚动模式下起作用,别的场景不显示这一行免得点了没反应。 */
+ const showComicImageFit = contentType === 'image' && settings.pageMode === 'scroll'
+ /**
+ * 正文列是否放开 900px 上限。两种情况:漫画双页铺开要吃满窗口宽度;
+ * 或者用户把图片尺寸调成了「适应宽度/高度/长边/原图」——这些档位由图片自己定尺寸,
+ * 900px 的列会把它们再压回去。
+ */
+ const comicFullWidth = contentType === 'image' && (comicDoublePage || comicScrollFit !== 'default')
+
// ── 加载书籍与章节 ──
useEffect(() => {
let cancelled = false
@@ -711,7 +724,7 @@ export default function ReaderViewPage() {
{/* 正文视口 */}
-
+
{contentType === 'audio' ? (
media && media.tracks && media.tracks.length > 0 ? (
{
if (zone === 'center') setMenuOpen((v) => !v)
@@ -975,7 +989,7 @@ export default function ReaderViewPage() {
))}
- {/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
+ {/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
{panel === 'style' && (
+ {/* 漫画滚动模式的图片显示尺寸。默认档就是老样子(900px 居中),
+ 「适应宽度」铺满窗口,「原图」按原始像素 1:1 看细节。 */}
+ {showComicImageFit && (
+
+ 图片尺寸
+ {COMIC_IMAGE_FITS.map((item) => {
+ const active = settings.comicImageFit === item.id
+ return (
+
+ )
+ })}
+
+ )}
)}
diff --git a/web/src/stores/readerSettings.ts b/web/src/stores/readerSettings.ts
index e1de165..018b4bf 100644
--- a/web/src/stores/readerSettings.ts
+++ b/web/src/stores/readerSettings.ts
@@ -45,6 +45,30 @@ export type ReaderShelfSort = 'recent' | 'update' | 'name' | 'author' | 'mixed'
/** 书架网格列数:0 表示按屏幕自适应(legado 是固定 2–6 列)。 */
export const SHELF_GRID_COLUMNS = [0, 2, 3, 4, 5, 6] as const
+/**
+ * 漫画图片的显示尺寸档位(上下滚动模式用)。
+ *
+ * legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),
+ * 桌面端看不到也放不大,所以补上这一组档位。屏幕越宽越该放大,属于设备级偏好,
+ * 和 comicDoublePage 一样只存本机、不参与账号同步。
+ *
+ * - `default` 现状:正文列封顶 900px 居中,图片铺满该列
+ * - `width` 适应宽度:正文列不再封顶,图片铺满窗口宽度
+ * - `height` 适应高度:每页高度贴合一屏(一屏一页),宽度按原始比例
+ * - `long` 适应长边:整页完整可见,宽高都不超出视口
+ * - `original` 原始像素 1:1:既不放大也不缩小,超出部分横向滚动
+ */
+export type ComicImageFit = 'default' | 'width' | 'height' | 'long' | 'original'
+
+/** 漫画图片尺寸档位的展示顺序与标签(界面面板按此渲染)。 */
+export const COMIC_IMAGE_FITS: { id: ComicImageFit; label: string }[] = [
+ { id: 'default', label: '默认' },
+ { id: 'width', label: '适应宽度' },
+ { id: 'height', label: '适应高度' },
+ { id: 'long', label: '适应长边' },
+ { id: 'original', label: '原图' },
+]
+
interface ReaderSettingsState {
// 首页模式切换(影视 / 阅读)
homeMode: ReaderHomeMode
@@ -81,6 +105,10 @@ interface ReaderSettingsState {
comicDoublePage: boolean
setComicDoublePage: (on: boolean) => void
+ /** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
+ comicImageFit: ComicImageFit
+ setComicImageFit: (fit: ComicImageFit) => void
+
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
/**
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
@@ -156,6 +184,12 @@ export const useReaderSettingsStore = create
()(
comicDoublePage: true,
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
+ // 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
+ // 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
+ // 点「原图」。
+ comicImageFit: 'default',
+ setComicImageFit: (comicImageFit) => set({ comicImageFit }),
+
searchScopeIds: [],
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
@@ -185,8 +219,9 @@ export const useReaderSettingsStore = create()(
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
- * 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好,
- * 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。
+ * 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
+ * (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
+ * 取决于屏幕宽度和分辨率。
*/
export interface ReaderSettingsProfile {
theme_id: string
diff --git a/web/src/utils/comicImageFit.test.ts b/web/src/utils/comicImageFit.test.ts
new file mode 100644
index 0000000..42b48ab
--- /dev/null
+++ b/web/src/utils/comicImageFit.test.ts
@@ -0,0 +1,55 @@
+import { comicImageSizing, isSelfSizedComicFit } from './comicImageFit.ts'
+import type { ComicImageFit } from '../stores/readerSettings'
+
+// 漫画图片显示尺寸档位的纯逻辑回归测试(对齐 utils/coverUrl.test.ts 的写法)。
+
+function check(name: string, condition: boolean) {
+ if (!condition) throw new Error(`comicImageFit: ${name}`)
+}
+
+// ── 是否由图片自身约束尺寸 ──
+const ALL_FITS: ComicImageFit[] = ['default', 'width', 'height', 'long', 'original']
+check('default 不是自尺寸档位', !isSelfSizedComicFit('default'))
+check('width 不是自尺寸档位', !isSelfSizedComicFit('width'))
+check('height 是自尺寸档位', isSelfSizedComicFit('height'))
+check('long 是自尺寸档位', isSelfSizedComicFit('long'))
+check('original 是自尺寸档位', isSelfSizedComicFit('original'))
+check(
+ '自尺寸档位与列宽档位互斥且覆盖全部取值',
+ ALL_FITS.every((fit) => isSelfSizedComicFit(fit) === (fit === 'height' || fit === 'long' || fit === 'original')),
+)
+
+// ── default / width:宽度交给外层列宽,图片只负责铺满 ──
+for (const fit of ['default', 'width'] as ComicImageFit[]) {
+ const loaded = comicImageSizing(fit, 800, true)
+ check(`${fit} 加载完成时不留占位高度`, loaded.style.minHeight === undefined)
+ check(`${fit} 铺满外层列宽`, loaded.className === 'block w-full')
+ check(`${fit} 不自己定高`, loaded.style.height === undefined && loaded.style.maxHeight === undefined)
+ check(`${fit} 不改 maxWidth`, loaded.style.maxWidth === undefined)
+ check(`${fit} 加载中给占位高度`, comicImageSizing(fit, 800, false).style.minHeight === '10rem')
+}
+
+// ── height:一屏一页 ──
+check('height 用滚动容器的实测高度', comicImageSizing('height', 592, true).style.height === '592px')
+check('height 宽度按原始比例', comicImageSizing('height', 592, true).style.width === 'auto')
+check('height 防超宽页横向溢出', comicImageSizing('height', 592, true).style.maxWidth === '100vw')
+check('height 不额外设 maxHeight', comicImageSizing('height', 592, true).style.maxHeight === undefined)
+check('height 类名不带 w-full(w-full 会顶掉高度约束)', comicImageSizing('height', 592, true).className === 'block')
+
+// ── long:整页完整可见 ──
+check('long 用容器高度封顶', comicImageSizing('long', 592, true).style.maxHeight === '592px')
+check('long 不写死高度', comicImageSizing('long', 592, true).style.height === undefined)
+check('long 同时约束宽度', comicImageSizing('long', 592, true).style.maxWidth === '100vw')
+check('long 加载中给占位高度', comicImageSizing('long', 592, false).style.minHeight === '10rem')
+
+// ── original:原始像素 1:1 ──
+check('original 不限制宽度', comicImageSizing('original', 592, true).style.maxWidth === 'none')
+check('original 不限制高度', comicImageSizing('original', 592, true).style.maxHeight === undefined)
+check('original 按原始像素渲染', comicImageSizing('original', 592, true).style.width === 'auto')
+
+// ── 视口高度还没量出来时退回视口单位,首帧不塌 ──
+for (const fit of ['height', 'long'] as ComicImageFit[]) {
+ check(`${fit} 未量到高度时退回 100dvh`, comicImageSizing(fit, 0, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
+ check(`${fit} 高度为负同样退回 100dvh`, comicImageSizing(fit, -10, true).style[fit === 'height' ? 'height' : 'maxHeight'] === '100dvh')
+}
+check('default 与视口高度无关', comicImageSizing('default', 0, true).style.height === undefined)
diff --git a/web/src/utils/comicImageFit.ts b/web/src/utils/comicImageFit.ts
new file mode 100644
index 0000000..608d83b
--- /dev/null
+++ b/web/src/utils/comicImageFit.ts
@@ -0,0 +1,59 @@
+import type { CSSProperties } from 'react'
+
+import type { ComicImageFit } from '../stores/readerSettings'
+
+// 漫画图片的显示尺寸:把「档位」翻译成
的 class/style(纯逻辑,便于单测)。
+//
+// 只有上下滚动模式用得到:翻页模式是整页缩放进视口(object-contain),本来就没有
+// 「太大/太小」的问题,一个档位反而会和双页铺开的排版打架。
+//
+// 两种控制方式:
+// - default / width 由外层列宽决定(列封顶 900px 或铺满窗口),图片本身 w-full;
+// - height / long / original 由图片自身约束,所以外层必须放开列宽上限
+// (见 ReaderViewPage 的 comicFullWidth),否则 900px 的列会把它们再压回去。
+
+/** 图片还没加载出来时的占位高度,避免懒加载期间整屏塌成 0 高。 */
+const PLACEHOLDER_MIN_HEIGHT = '10rem'
+
+export interface ComicImageSizing {
+ className: string
+ style: CSSProperties
+}
+
+/** 该档位是否由
自身约束尺寸(决定外层列宽要不要放开)。 */
+export function isSelfSizedComicFit(fit: ComicImageFit): boolean {
+ return fit === 'height' || fit === 'long' || fit === 'original'
+}
+
+/**
+ * 滚动模式下漫画图片的尺寸样式。
+ *
+ * @param fit 显示尺寸档位。
+ * @param viewportHeight 滚动容器的高度(px)。height/long 按它换算;还没量出来
+ * (0)时退回 `100dvh`,首帧不会因为 0 而塌掉。
+ * @param loaded 图片是否已加载完成,未完成时给个占位高度。
+ */
+export function comicImageSizing(
+ fit: ComicImageFit,
+ viewportHeight: number,
+ loaded: boolean,
+): ComicImageSizing {
+ const vh = viewportHeight > 0 ? `${viewportHeight}px` : '100dvh'
+ // height 档位自带高度,占位高度没有意义也不冲突,统一先铺再被档位覆盖。
+ const placeholder: CSSProperties = loaded ? {} : { minHeight: PLACEHOLDER_MIN_HEIGHT }
+
+ switch (fit) {
+ case 'height':
+ // 一屏一页:高度贴合视口,宽度按原始比例(maxWidth 防止超宽页横向溢出)
+ return { className: 'block', style: { ...placeholder, height: vh, width: 'auto', maxWidth: '100vw' } }
+ case 'long':
+ // 整页完整可见:长边贴合视口,宽高都不超出
+ return { className: 'block', style: { ...placeholder, maxHeight: vh, maxWidth: '100vw', width: 'auto' } }
+ case 'original':
+ // 原始像素 1:1:不放大也不缩小;比窗口宽时由滚动容器横向滚动
+ return { className: 'block', style: { ...placeholder, width: 'auto', maxWidth: 'none' } }
+ default:
+ // default / width:宽度交给外层列(900px 封顶或铺满窗口),这里只管铺满
+ return { className: 'block w-full', style: placeholder }
+ }
+}