Compare commits

...

2 Commits

Author SHA1 Message Date
truewhile 2494f0c0cd 优化 2026-10-02 23:09:35 +08:00
truewhile c4e9926352 优化 2026-10-02 18:44:07 +08:00
14 changed files with 740 additions and 35 deletions
+1
View File
@@ -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()` 探测(`<img>` 渲染时顺带回填),只探测当前页往后 8 张的窗口,避免整章预载。进度仍按图片序号记,翻页时换算成「第几屏」,所以关掉再开双页进度不会错位。菜单动作行改为 `flex` 等分,容纳「双页/单页」入口(`grid-cols-N` 得写死列数字面量,多一个按钮无法复用)。
- 漫画图片显示尺寸(`utils/comicImageFit.ts`,仅上下滚动模式):legado 的漫画阅读有「缩放」,Web 版原先只有一种写死的宽度(正文列封顶 900px),桌面端放不大也缩不小,所以补上一组档位,在界面面板的「图片尺寸」一行切换。`default` 就是老样子(900px 居中),`width` 适应宽度(正文列不再封顶、铺满窗口),`height` 适应高度(一屏一页),`long` 适应长边(整页完整可见),`original` 原图(原始像素 1:1,超出部分横向滚动)。`default`/`width` 由外层列宽决定,其余三个由 `<img>` 自身约束,**所以后三种必须放开 900px 列宽上限**,否则会被列再压回去。`height`/`long` 要按「一屏多高」换算,所以量的是滚动容器本身而不是 `window.innerHeight`(正文区是 root 里的 `flex-1`,另挂上下条之后就不等于窗口高度了),并用 `ResizeObserver` 跟随;只在启用了自尺寸档位时才挂观察器,默认档位不白白重渲染整章。`settings.comicImageFit` 是设备级偏好(不同屏幕和源分辨率合适的尺寸不同),和 `comicDoublePage` 一样只存本机、不参与账号同步。翻页模式是整页缩放进视口,本来就不存在「太大/太小」,档位对它不生效、也不显示入口。`ctrl/cmd+滚轮` 依然留给浏览器缩放,本功能只用档位做粗调。
- **文本型漫画源按图片下发**(`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,整块目录完全看不见。
+3 -1
View File
@@ -333,13 +333,15 @@ func readerSetSourceLoginInfoHandler(svc *service.Container) gin.HandlerFunc {
func readerSearchHandler(svc *service.Container) gin.HandlerFunc {
var body struct {
Key string `json:"key" binding:"required"`
// SourceIDs 搜索范围:空表示全部启用书源(默认),非空则只搜这些书源。
SourceIDs []string `json:"source_ids"`
}
return func(c *gin.Context) {
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key)
books, skipped, err := svc.Reader.Search(c.Request.Context(), body.Key, body.SourceIDs)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+5 -5
View File
@@ -151,7 +151,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
sourceID := prepareLoginSource(t, svc, loginTestSourceJSON(t, srv.URL))
// ── 登录前:未鉴权,站点返回未登录页 → 搜不到书 ──
if books, _, err := svc.Search(ctx, "会员"); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("未登录时不应搜到结果: %+v", books)
@@ -188,7 +188,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
}
// ── 登录后搜索:应携带 Cookie 并成功 ──
books, skipped, err := svc.Search(ctx, "会员")
books, skipped, err := svc.Search(ctx, "会员", nil)
if err != nil {
t.Fatal(err)
}
@@ -210,7 +210,7 @@ func TestSourceLoginEndToEnd(t *testing.T) {
if info.LoggedIn || len(info.Cookies) != 0 {
t.Fatalf("登出后不应残留登录态: %+v", info)
}
if books, _, err := svc.Search(ctx, "会员"); err != nil {
if books, _, err := svc.Search(ctx, "会员", nil); err != nil {
t.Fatal(err)
} else if len(books) != 0 {
t.Fatalf("登出后不应还能搜到结果: %+v", books)
@@ -367,7 +367,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
}`
sourceID := prepareLoginSource(t, svc, srcJSON)
if _, _, err := svc.Search(ctx, "任意"); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
t.Fatal(err)
}
// 自动捕获被关闭:不应出现 auto=from-response
@@ -389,7 +389,7 @@ func TestEnabledCookieJarGating(t *testing.T) {
if _, err := svc.ImportSources(ctx, srcJSONOn); err != nil {
t.Fatal(err)
}
if _, _, err := svc.Search(ctx, "任意"); err != nil {
if _, _, err := svc.Search(ctx, "任意", nil); err != nil {
t.Fatal(err)
}
if got := svc.newSourceState(ctx, srv2.URL).GetCookie(srv2.URL); !strings.Contains(got, "auto=from-response") {
+35 -7
View File
@@ -737,17 +737,17 @@ type searchHit struct {
}
// Search 多源聚合搜索(同步返回,P1 改为 WS 流式推送)。
func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, []SearchSkipped, error) {
//
// sourceIDs 是本次的搜索范围(对应 legado SearchScope.getBookSourceParts):
// 空表示「全部书源」——所有已启用书源;非空则只搜其中仍存在、仍启用的书源。
// 范围内一个可搜书源都不剩时退回全部启用(对应 legado 范围失效时的兜底),
// 这样删源/停源后不会因为残留的旧选择把搜索变成「什么都搜不到」。
func (s *ReaderService) Search(ctx context.Context, key string, sourceIDs []string) ([]SearchBook, []SearchSkipped, error) {
sources, err := s.repo.ListSources(ctx)
if err != nil {
return nil, nil, err
}
var enabled []model.ReaderBookSource
for _, src := range sources {
if src.Enabled {
enabled = append(enabled, src)
}
}
enabled := enabledSourcesForScope(sources, sourceIDs)
if len(enabled) == 0 {
return nil, nil, fmt.Errorf("没有已启用的书源")
}
@@ -783,6 +783,34 @@ func (s *ReaderService) Search(ctx context.Context, key string) ([]SearchBook, [
return mergeSearchResults(hits, key), skipped, nil
}
// enabledSourcesForScope 按搜索范围挑出可搜的书源(保持 ListSources 的 customOrder)。
// 空范围 = 全部启用;范围里的书源不存在或已停用时忽略;全被忽略则退回全部启用。
func enabledSourcesForScope(sources []model.ReaderBookSource, sourceIDs []string) []model.ReaderBookSource {
enabled := make([]model.ReaderBookSource, 0, len(sources))
for _, src := range sources {
if src.Enabled {
enabled = append(enabled, src)
}
}
if len(sourceIDs) == 0 {
return enabled
}
want := make(map[string]struct{}, len(sourceIDs))
for _, id := range sourceIDs {
want[id] = struct{}{}
}
scoped := make([]model.ReaderBookSource, 0, len(enabled))
for _, src := range enabled {
if _, ok := want[src.ID]; ok {
scoped = append(scoped, src)
}
}
if len(scoped) == 0 {
return enabled
}
return scoped
}
// SearchSkipped 搜索失败的书源与原因(书源管理调试用)。
type SearchSkipped struct {
SourceID string `json:"source_id"`
+3 -3
View File
@@ -46,7 +46,7 @@ func TestSearchSendsQueryString(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅")
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -87,7 +87,7 @@ func TestSearchResponseUsesEmptyArrays(t *testing.T) {
svc, _ := newLoginTestService(t)
_ = prepareLoginSource(t, svc, queryEchoSource(srv.URL))
books, skipped, err := svc.Search(t.Context(), "宠魅")
books, skipped, err := svc.Search(t.Context(), "宠魅", nil)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
@@ -130,7 +130,7 @@ func TestSearchPreservesExistingPercentEncoding(t *testing.T) {
out, _ := json.Marshal(src)
_ = prepareLoginSource(t, svc, string(out))
if _, _, err := svc.Search(t.Context(), "宠魅"); err != nil {
if _, _, err := svc.Search(t.Context(), "宠魅", nil); err != nil {
t.Fatalf("搜索失败: %v", err)
}
if gotQuery != "宠魅" {
@@ -0,0 +1,106 @@
package reader
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// 本文件:搜索范围的过滤逻辑(对应 legado SearchScope.getBookSourceParts)。
// 覆盖「只搜指定源」「空范围=全部」「范围失效退回全部」三种情形。
func scopeEchoSourceJSON(t *testing.T, name, serverURL string) string {
t.Helper()
src := map[string]any{
"bookSourceUrl": serverURL,
"bookSourceName": name,
"searchUrl": serverURL + "/search?q={{key}}",
"ruleSearch": map[string]any{
"bookList": "$.data",
"name": "$.book_name",
"author": "$.author",
"bookUrl": "$.book_url",
},
}
out, _ := json.Marshal(src)
return string(out)
}
func scopeEchoServer(t *testing.T, bookName string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
payload := map[string]any{
"data": []map[string]string{{
"book_name": bookName,
"author": "作者",
"book_url": "/book/1",
}},
}
_ = json.NewEncoder(w).Encode(payload)
}))
}
func TestSearchRespectsSourceScope(t *testing.T) {
srvA := scopeEchoServer(t, "甲书")
defer srvA.Close()
srvB := scopeEchoServer(t, "乙书")
defer srvB.Close()
svc, _ := newLoginTestService(t)
for _, raw := range []string{
scopeEchoSourceJSON(t, "源A", srvA.URL),
scopeEchoSourceJSON(t, "源B", srvB.URL),
} {
if _, err := svc.ImportSources(t.Context(), raw); err != nil {
t.Fatal(err)
}
}
sources, err := svc.ListSources(t.Context())
if err != nil {
t.Fatal(err)
}
idByName := map[string]string{}
for _, s := range sources {
idByName[s.Name] = s.ID
}
names := func(books []SearchBook) map[string]bool {
out := map[string]bool{}
for _, b := range books {
out[b.Name] = true
}
return out
}
// 指定单个源:只应搜到该源的结果
books, skipped, err := svc.Search(t.Context(), "书", []string{idByName["源A"]})
if err != nil {
t.Fatal(err)
}
if len(skipped) > 0 {
t.Fatalf("源A 不应失败: %+v", skipped)
}
if got := names(books); len(got) != 1 || !got["甲书"] {
t.Fatalf("只搜源A 应只返回甲书,实际 %+v", books)
}
// 空范围:默认全部启用书源,两个源都应命中
books, _, err = svc.Search(t.Context(), "书", nil)
if err != nil {
t.Fatal(err)
}
if got := names(books); len(got) != 2 || !got["甲书"] || !got["乙书"] {
t.Fatalf("空范围应搜到两个源的结果,实际 %+v", books)
}
// 范围里的源已不存在(删源/换设备)时退回全部启用,而不是搜不到
books, _, err = svc.Search(t.Context(), "书", []string{"not-a-real-source"})
if err != nil {
t.Fatal(err)
}
if got := names(books); len(got) != 2 {
t.Fatalf("失效范围应退回全部启用,实际 %+v", books)
}
}
+4 -2
View File
@@ -245,11 +245,13 @@ export const readerAPI = {
setSourceVariable: (id: string, variable: string) => api.put(`/reader/sources/${id}/variable`, { variable }),
// ── 搜索 / 详情 / 目录 / 正文 ──
search: (key: string) =>
// 多源聚合搜索。sourceIds 为空表示搜索范围=全部启用书源(默认);
// 非空则只搜这些书源(后端会忽略其中已删除/停用的,全部失效时退回全部启用)。
search: (key: string, sourceIds: string[] = []) =>
api
.post<{ books: ReaderSearchBook[] | null; skipped: ReaderSearchSkipped[] | null }>(
'/reader/search',
{ key },
{ key, source_ids: sourceIds },
longOpts,
)
.then((r) => r.data),
+42 -5
View File
@@ -3,12 +3,15 @@ import { Loader2 } from 'lucide-react'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { rememberImageSize } from '../../utils/comicSpread'
import { comicImageSizing, isSelfSizedComicFit } from '../../utils/comicImageFit'
import type { ComicImageFit } from '../../stores/readerSettings'
// 漫画/图片阅读器(仿 legado MangaMenu 能力面):
// 上下滚动(默认)/ 左右单页 / 左右双页三种呈现;点击分区翻页/呼出菜单;图片懒加载。
//
// 双页铺开由外层算好「这一屏显示哪几张」传进来(见 utils/comicSpread.ts),
// 这里只负责把它们并排摆好、各自撑满视口高度。
// 滚动模式的显示尺寸由 imageFit 档位决定(见 utils/comicImageFit.ts)。
interface ReaderComicProps {
images: string[]
@@ -22,6 +25,8 @@ interface ReaderComicProps {
onScrolled: () => void
/** 双页模式:本屏要并排显示的图片序号(1 张=独占的宽图/单页,2 张=左右一对)。 */
spread?: number[] | null
/** 滚动模式的图片显示尺寸档位(翻页模式不适用,传 default 即可)。 */
imageFit?: ComicImageFit
}
function ComicImage({
@@ -29,13 +34,19 @@ function ComicImage({
theme,
fit = false,
half = false,
imageFit = 'default',
viewportHeight = 0,
}: {
src: string
theme: { bg: string; text: string; accent: string }
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉。
// fit:整页缩放至视口内,长图不再被 overflow-hidden 的容器裁掉(翻页模式)。
fit?: boolean
// half:双页并排时占位不超过半屏;容器收窄到图片本身宽度,两页之间不留缝。
half?: boolean
// imageFit:滚动模式的显示尺寸档位。
imageFit?: ComicImageFit
// viewportHeight:滚动容器高度(px),height/long 档位按它换算。
viewportHeight?: number
}) {
const [state, setState] = useState<'loading' | 'ok' | 'error'>('loading')
@@ -45,6 +56,15 @@ function ComicImage({
setState('loading')
}, [src])
const loaded = state === 'ok'
// 翻页模式:整页缩放进视口。滚动模式:按用户选的尺寸档位摆(外层列宽已放开)。
const sizing = fit
? {
className: 'block max-h-full w-auto max-w-full object-contain',
style: loaded ? {} : { minHeight: '10rem' },
}
: comicImageSizing(imageFit, viewportHeight, loaded)
// <img> 必须始终留在渲染树里(不能 display:none):浏览器不会去拉取
// display:none 的 loading="lazy" 图片,onLoad 就永远不会触发,于是更没机会
// 脱离 loading —— 之前用 hidden 藏图就死在这里,漫画只剩一个转圈。
@@ -71,8 +91,8 @@ function ComicImage({
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' }}
className={sizing.className}
style={sizing.style}
/>
{state === 'loading' && (
<div
@@ -94,10 +114,27 @@ function ComicImage({
)
}
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread }: ReaderComicProps) {
export function ReaderComic({ images, theme, mode, page, onZone, initialImage, onProgress, scrollTo, onScrolled, spread, imageFit = 'default' }: ReaderComicProps) {
const scrollRef = useRef<HTMLDivElement>(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
}
}}
>
<ComicImage src={src} theme={theme} />
<ComicImage src={src} theme={theme} imageFit={imageFit} viewportHeight={viewportHeight} />
<p className="pb-1 text-center text-2xs opacity-40" style={{ color: theme.text }}>
{i + 1} / {images.length}
</p>
+118 -7
View File
@@ -1,14 +1,32 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import toast from 'react-hot-toast'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, Loader2, Plus, Search } from 'lucide-react'
import { AlertTriangle, ArrowLeft, ChevronDown, ChevronRight, ListFilter, Loader2, Plus, Search } from 'lucide-react'
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped } from '../../api/reader'
import { readerAPI, type ReaderSearchBook, type ReaderSearchSkipped, type ReaderSource } from '../../api/reader'
import ReaderBookCover from '../../components/ReaderBookCover'
import { useReaderSettingsStore } from '../../stores/readerSettings'
import { splitKindTags } from '../../utils/kindTags'
import { SearchScopeDialog } from './SearchScopeDialog'
import { SourcePickerDialog } from './SourcePickerDialog'
// 多源聚合搜索页(仿 legado SearchActivity:结果流 + 失败书源列表)。
// 搜索前可按书源收敛范围(对应 legado 的搜索范围):默认全选已启用书源。
// 搜索结果会剔除已在书架里的书——这里的搜索是用来发现新书的。
/** 书架命中键:与搜索结果一致按「书名 + 作者」聚合;作者缺省时只用书名(同 legado isInBookShelf)。 */
function shelfBookKey(name: string, author: string): string {
const n = name.trim()
const a = author.trim()
return a ? `${n}|${a}` : n
}
/** 这本书是否已在书架:优先按书名+作者,其次按书本身/任一命中书源的地址(换源后地址会变)。 */
function isOnShelf(book: ReaderSearchBook, shelf: Set<string>): boolean {
if (shelf.has(shelfBookKey(book.name, book.author))) return true
if (book.book_url && shelf.has(book.book_url)) return true
return book.origins.some((o) => !!o.book_url && shelf.has(o.book_url))
}
export default function ReaderSearchPage() {
const navigate = useNavigate()
@@ -23,13 +41,60 @@ export default function ReaderSearchPage() {
const [adding, setAdding] = useState('')
const inputRef = useRef<HTMLInputElement>(null)
// 搜索范围(设备级持久化,见 readerSettings store):空数组 = 全部启用书源。
const scopeIds = useReaderSettingsStore((s) => s.searchScopeIds)
const setSearchScopeIds = useReaderSettingsStore((s) => s.setSearchScopeIds)
const [sources, setSources] = useState<ReaderSource[]>([])
const [showScope, setShowScope] = useState(false)
const enabledSources = useMemo(() => sources.filter((s) => s.enabled), [sources])
// 已保存范围里仍有效的书源(删源/停源后自动剔除,全失效时等同于全部)。
const activeScopeIds = useMemo(
() => scopeIds.filter((id) => enabledSources.some((s) => s.id === id)),
[scopeIds, enabledSources],
)
// 书源列表还没加载完(sources 为空)时不能判断有效性,先按已存 ID 数显示,
// 避免已保存子集在加载瞬间被错标成「全部书源」。
const scopeAll = scopeIds.length === 0 || (sources.length > 0 && activeScopeIds.length === 0)
const scopeLabel = scopeAll ? '全部书源' : `已选 ${activeScopeIds.length || scopeIds.length} 个书源`
useEffect(() => {
readerAPI.listSources().then(setSources).catch(() => undefined)
}, [])
// 书架命中集合:已在书架的书不参与结果展示(这里只找新书)。
const [shelfKeys, setShelfKeys] = useState<Set<string>>(() => new Set())
useEffect(() => {
readerAPI
.listBooks()
.then((books) => {
const keys = new Set<string>()
for (const b of books) {
keys.add(shelfBookKey(b.name, b.author))
if (b.book_url) keys.add(b.book_url)
}
setShelfKeys(keys)
})
.catch(() => undefined)
}, [])
// 展示用结果 = 原始结果剔除已在书架的书。用派生值而不是在 doSearch 里过滤:
// 书架是异步加载的,派生能保证「书架先到还是结果先到」都能正确隐藏,
// 且刚加进书架的书也会立刻从列表消失。
const visibleBooks = useMemo(
() => (books === null ? null : books.filter((b) => !isOnShelf(b, shelfKeys))),
[books, shelfKeys],
)
const hiddenOnShelf = books === null ? 0 : books.length - (visibleBooks?.length ?? 0)
const doSearch = async () => {
const kw = key.trim()
if (!kw || searching) return
setSearching(true)
setShowSkipped(false)
try {
const res = await readerAPI.search(kw)
// 直接把已保存范围交给后端:它只搜其中仍启用的书源,全部失效时退回全部启用,
// 因此这里不必等书源列表加载完,也能正确处理「换设备后书源尚未同步」的情况。
const res = await readerAPI.search(kw, scopeIds)
setBooks(res.books ?? [])
// 后端在「没有书源失败」时会把空列表编码成 null,这里兜底成数组,
// 否则下面 skipped.length 会直接抛 TypeError 把整页打崩。
@@ -52,12 +117,20 @@ export default function ReaderSearchPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const addToShelf = async (book: ReaderSearchBook) => { const origin = book.origins[0]
const addToShelf = async (book: ReaderSearchBook) => {
const origin = book.origins[0]
if (!origin) return
setAdding(book.book_url)
try {
await readerAPI.addBook({ origin, name: book.name, author: book.author, cover_url: book.cover_url })
toast.success(`《${book.name}》已加入书架`)
// 立刻并入书架命中集合:这本「刚加的书」应从「搜索新书」的结果里消失。
setShelfKeys((prev) => {
const next = new Set(prev)
next.add(shelfBookKey(book.name, book.author))
if (origin.book_url) next.add(origin.book_url)
return next
})
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '加入书架失败'
toast.error(msg)
@@ -113,6 +186,22 @@ export default function ReaderSearchPage() {
</button>
</div>
{/* 搜索范围:默认「全部书源」(全选已启用),点开可收敛到指定书源 */}
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => setShowScope(true)}
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--app-border)] bg-[var(--app-panel)] px-3 py-1.5 text-2xs font-bold text-[var(--app-muted)] transition hover:border-brand-500/40 hover:text-brand-600"
>
<ListFilter size={13} />
搜索范围
<span className="text-brand-600">{scopeLabel}</span>
</button>
{scopeAll && enabledSources.length > 0 && (
<span className="text-2xs text-[var(--app-subtle)]">共 {enabledSources.length} 个启用书源</span>
)}
</div>
{searching && (
<div className="flex flex-col items-center gap-2 py-24 text-[var(--app-muted)]">
<Loader2 className="animate-spin" size={22} />
@@ -120,9 +209,19 @@ export default function ReaderSearchPage() {
</div>
)}
{!searching && books !== null && (
{!searching && visibleBooks !== null && (
<div className="mt-6 space-y-3">
{books.map((book) => (
{hiddenOnShelf > 0 && visibleBooks.length > 0 && (
<p className="rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-2 text-2xs text-[var(--app-muted)]">
已隐藏 {hiddenOnShelf} 本已在书架里的书
</p>
)}
{visibleBooks.length === 0 && (
<p className="py-16 text-center text-xs text-[var(--app-muted)]">
{hiddenOnShelf > 0 ? '搜到的书都已经在书架里了' : '没有找到结果'}
</p>
)}
{visibleBooks.map((book) => (
<div
key={`${book.name}|${book.author}`}
className="flex gap-4 rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-4"
@@ -225,6 +324,18 @@ export default function ReaderSearchPage() {
onClose={() => setPicker(null)}
/>
)}
{showScope && (
<SearchScopeDialog
sources={sources}
selectedIds={scopeIds}
onConfirm={(ids) => {
setSearchScopeIds(ids)
setShowScope(false)
}}
onClose={() => setShowScope(false)}
/>
)}
</div>
)
}
+41 -3
View File
@@ -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() {
<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 ${contentType === 'image' && comicDoublePage ? '' : 'max-w-[900px]'}`}>
<div className={`mx-auto h-full w-full ${comicFullWidth ? '' : 'max-w-[900px]'}`}>
{contentType === 'audio' ? (
media && media.tracks && media.tracks.length > 0 ? (
<ReaderAudioPanel
@@ -746,6 +759,7 @@ export default function ReaderViewPage() {
theme={theme}
mode={settings.pageMode}
page={comicPage}
imageFit={comicScrollFit}
spread={comicDoublePage ? (comicSpreads[comicSpreadIndex] ?? null) : null}
onZone={(zone) => {
if (zone === 'center') setMenuOpen((v) => !v)
@@ -975,7 +989,7 @@ export default function ReaderViewPage() {
))}
</div>
{/* 界面设置面板(主题 / 字号 / 行距 / 段距) */}
{/* 界面设置面板(主题 / 字号 / 行距 / 段距 / 漫画图片尺寸) */}
{panel === 'style' && (
<div
className="absolute bottom-full inset-x-0 border-t px-4 py-4"
@@ -1029,6 +1043,30 @@ export default function ReaderViewPage() {
</button>
</div>
</div>
{/* 漫画滚动模式的图片显示尺寸。默认档就是老样子(900px 居中),
「适应宽度」铺满窗口,「原图」按原始像素 1:1 看细节。 */}
{showComicImageFit && (
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs">
<span className="opacity-70">图片尺寸</span>
{COMIC_IMAGE_FITS.map((item) => {
const active = settings.comicImageFit === item.id
return (
<button
key={item.id}
type="button"
onClick={() => settings.setComicImageFit(item.id)}
className={`rounded-lg border px-2 py-0.5 ${active ? 'font-bold' : 'opacity-70'}`}
style={{
borderColor: active ? theme.accent : theme.text + '44',
color: active ? theme.accent : theme.text,
}}
>
{item.label}
</button>
)
})}
</div>
)}
</div>
)}
</div>
+219
View File
@@ -0,0 +1,219 @@
import { useMemo, useState } from 'react'
import { Check, Minus, Search, X } from 'lucide-react'
import type { ReaderSource } from '../../api/reader'
/**
* SearchScopeDialog 搜索范围弹窗(对应 legado 搜索页的 SearchScopeDialog)。
*
* 只列出书源维度:默认全选所有「已启用」书源(对应 legado SearchScope("") 的
* 「全部书源」语义),取消勾选即可把本次搜索收敛到指定书源。已停用的书源会显示
* 但不可勾选,让用户知道它们不会被搜索。分组标题支持整组勾选/取消(kind 批量选择)。
*
* 确认时若「全部启用书源」都被选中,回传空数组表示「全部」——与后端
* enabledSourcesForScope 的空范围语义一致,也让后续新增的书源自动纳入搜索。
*/
export function SearchScopeDialog({
sources,
selectedIds,
onConfirm,
onClose,
}: {
sources: ReaderSource[]
/** 已保存的搜索范围;空数组 = 全部启用书源(默认全选)。 */
selectedIds: string[]
onConfirm: (ids: string[]) => void
onClose: () => void
}) {
const enabled = useMemo(() => sources.filter((s) => s.enabled), [sources])
const enabledIds = useMemo(() => enabled.map((s) => s.id), [enabled])
// 初始选中:保存过范围就用它(过滤掉已删除/停用的),否则默认全选已启用。
const [selected, setSelected] = useState<Set<string>>(() => {
const valid = selectedIds.filter((id) => enabledIds.includes(id))
return new Set(valid.length > 0 ? valid : enabledIds)
})
const [filter, setFilter] = useState('')
const keyword = filter.trim().toLowerCase()
const visible = useMemo(
() =>
sources.filter(
(s) => !keyword || s.name.toLowerCase().includes(keyword) || (s.group ?? '').toLowerCase().includes(keyword),
),
[sources, keyword],
)
// 按分组聚合并保持书源原顺序(ListSources 已按 customOrder 排好)。
const groups = useMemo(() => {
const map = new Map<string, ReaderSource[]>()
for (const s of visible) {
const key = (s.group ?? '').trim() || '未分组'
const list = map.get(key)
if (list) list.push(s)
else map.set(key, [s])
}
return [...map.entries()].map(([name, items]) => ({ name, items }))
}, [visible])
const pickedCount = enabledIds.filter((id) => selected.has(id)).length
const allSelected = pickedCount === enabledIds.length && enabledIds.length > 0
const toggle = (id: string) =>
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
const toggleGroup = (ids: string[]) =>
setSelected((prev) => {
const next = new Set(prev)
const allIn = ids.every((id) => next.has(id))
ids.forEach((id) => (allIn ? next.delete(id) : next.add(id)))
return next
})
// 全选/清空只作用于当前可见(可能被搜索词过滤)的已启用书源。
const visibleEnabledIds = visible.filter((s) => s.enabled).map((s) => s.id)
const selectVisible = () => setSelected((prev) => new Set([...prev, ...visibleEnabledIds]))
const clearVisible = () =>
setSelected((prev) => {
const next = new Set(prev)
visibleEnabledIds.forEach((id) => next.delete(id))
return next
})
const confirm = () => {
const picked = enabledIds.filter((id) => selected.has(id))
// 全选(含边界:启用源为空)→ 空数组 = 全部,新增书源自动纳入。
onConfirm(picked.length === enabledIds.length ? [] : picked)
}
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/35 p-4 backdrop-blur-sm"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="flex max-h-[80vh] w-full max-w-lg flex-col overflow-hidden rounded-3xl border border-[var(--app-border)] bg-[var(--app-panel)] shadow-2xl"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between border-b border-[var(--app-border)] px-5 py-4">
<div className="min-w-0">
<h3 className="text-sm font-bold text-[var(--app-text)]">搜索范围</h3>
<p className="mt-0.5 text-2xs text-[var(--app-muted)]">
默认搜索全部启用书源 · 已启用 {enabledIds.length} 个
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-xl p-1.5 text-[var(--app-muted)] hover:bg-[var(--app-hover)] hover:text-[var(--app-text)]"
title="关闭"
>
<X size={18} />
</button>
</div>
<div className="flex items-center gap-2 border-b border-[var(--app-border)] px-4 py-3">
<div className="flex flex-1 items-center gap-2 rounded-xl border border-[var(--app-border)] bg-[var(--app-panel-soft)] px-3 py-1.5">
<Search size={13} className="shrink-0 text-[var(--app-muted)]" />
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="过滤书源名 / 分组"
className="w-full bg-transparent text-xs text-[var(--app-text)] outline-none placeholder:text-[var(--app-muted)]"
/>
</div>
<button type="button" onClick={selectVisible} className="btn-outline text-2xs">
全选
</button>
<button type="button" onClick={clearVisible} className="btn-outline text-2xs">
清空
</button>
</div>
<div className="min-h-[160px] flex-1 overflow-y-auto p-2">
{sources.length === 0 ? (
<p className="py-12 text-center text-xs text-[var(--app-muted)]">还没有书源,先去书源管理导入</p>
) : groups.length === 0 ? (
<p className="py-12 text-center text-xs text-[var(--app-muted)]">没有匹配「{filter.trim()}」的书源</p>
) : (
<div className="space-y-2">
{groups.map((group) => {
const groupEnabledIds = group.items.filter((s) => s.enabled).map((s) => s.id)
const groupChecked = groupEnabledIds.length > 0 && groupEnabledIds.every((id) => selected.has(id))
const groupPartial = !groupChecked && groupEnabledIds.some((id) => selected.has(id))
return (
<div key={group.name}>
<button
type="button"
disabled={groupEnabledIds.length === 0}
onClick={() => toggleGroup(groupEnabledIds)}
className="flex w-full items-center gap-2 rounded-lg px-3 py-1.5 text-left hover:bg-[var(--app-hover)] disabled:cursor-default"
>
<CheckMark checked={groupChecked} partial={groupPartial} disabled={groupEnabledIds.length === 0} />
<span className="min-w-0 flex-1 truncate text-2xs font-bold uppercase tracking-wide text-[var(--app-muted)]">
{group.name}
</span>
<span className="shrink-0 text-2xs text-[var(--app-subtle)]">{group.items.length}</span>
</button>
<div className="mt-0.5 space-y-0.5">
{group.items.map((src) => (
<button
key={src.id}
type="button"
disabled={!src.enabled}
onClick={() => toggle(src.id)}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2 text-left transition hover:bg-[var(--app-hover)] disabled:cursor-default disabled:opacity-50"
>
<CheckMark checked={src.enabled && selected.has(src.id)} disabled={!src.enabled} />
<span className="min-w-0 flex-1 truncate text-xs text-[var(--app-text)]">{src.name}</span>
{!src.enabled && (
<span className="shrink-0 rounded bg-[var(--app-hover)] px-1.5 py-0.5 text-2xs font-bold text-[var(--app-muted)]">
已禁用
</span>
)}
</button>
))}
</div>
</div>
)
})}
</div>
)}
</div>
<div className="flex items-center gap-3 border-t border-[var(--app-border)] px-5 py-3">
<span className="flex-1 text-2xs text-[var(--app-muted)]">
{allSelected ? '全部书源' : `已选 ${pickedCount} / ${enabledIds.length} 个书源`}
</span>
<button type="button" onClick={onClose} className="btn-outline text-xs">
取消
</button>
<button type="button" onClick={confirm} disabled={pickedCount === 0} className="btn-primary text-xs disabled:opacity-50">
确定
</button>
</div>
</div>
</div>
)
}
/** 勾选标记(纯展示,点击由外层按钮承载,避免交互元素嵌套)。 */
function CheckMark({ checked, partial = false, disabled = false }: { checked: boolean; partial?: boolean; disabled?: boolean }) {
return (
<span
aria-hidden
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border transition ${
checked || partial ? 'border-brand-500 bg-brand-500 text-white' : 'border-[var(--app-border)] bg-[var(--app-panel-soft)]'
} ${disabled ? 'opacity-40' : ''}`}
>
{checked && <Check size={11} strokeWidth={3} />}
{!checked && partial && <Minus size={11} strokeWidth={3} />}
</span>
)
}
+49 -2
View File
@@ -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,19 @@ interface ReaderSettingsState {
comicDoublePage: boolean
setComicDoublePage: (on: boolean) => void
/** 漫画图片显示尺寸档位(设备级偏好,见 ComicImageFit)。 */
comicImageFit: ComicImageFit
setComicImageFit: (fit: ComicImageFit) => void
// ── 搜索范围(对应 legado 搜索页的 SearchScopeDialog / AppConfig.searchScope)──
/**
* 已选书源 ID;空数组表示「全部启用书源」(默认)。只影响书籍搜索的并发范围。
* 和 comicDoublePage 一样是设备级偏好:刻意不参与账号同步(legado 也把
* searchScope 存在本机 AppConfig),换设备重新默认全选即可。
*/
searchScopeIds: string[]
setSearchScopeIds: (ids: string[]) => void
fontSize: number
setFontSize: (size: number) => void
@@ -147,6 +184,15 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
comicDoublePage: true,
setComicDoublePage: (comicDoublePage) => set({ comicDoublePage }),
// 默认保持老样子(正文列 900px 居中):放大到铺满屏幕是「想要更大」时才做的事,
// 不该在升级后突然改变所有人已经习惯的宽度。想放大点「适应宽度」,想 1:1 看细节
// 点「原图」。
comicImageFit: 'default',
setComicImageFit: (comicImageFit) => set({ comicImageFit }),
searchScopeIds: [],
setSearchScopeIds: (ids) => set({ searchScopeIds: [...new Set(ids)] }),
fontSize: 20,
setFontSize: (fontSize) => set({ fontSize: Math.min(32, Math.max(14, fontSize)) }),
@@ -173,8 +219,9 @@ export const useReaderSettingsStore = create<ReaderSettingsState>()(
* 与账号同步的阅读器偏好(对应后端 GET/PUT /reader/profile 的载荷)。
*
* 这些设置原先是设备级的(只存 localStorage);现在按用户落库,换设备也能保持一致。
* 首页的「影视 / 阅读」模式(homeMode)与漫画双页(comicDoublePage)属于设备偏好,
* 故意不参与同步——前者是入口选择,后者是否合适取决于屏幕宽度。
* 首页的「影视 / 阅读」模式(homeMode)、漫画双页(comicDoublePage)与漫画图片尺寸
* (comicImageFit)属于设备偏好,故意不参与同步——前者是入口选择,后两者是否合适
* 取决于屏幕宽度和分辨率。
*/
export interface ReaderSettingsProfile {
theme_id: string
+55
View File
@@ -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)
+59
View File
@@ -0,0 +1,59 @@
import type { CSSProperties } from 'react'
import type { ComicImageFit } from '../stores/readerSettings'
// 漫画图片的显示尺寸:把「档位」翻译成 <img> 的 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
}
/** 该档位是否由 <img> 自身约束尺寸(决定外层列宽要不要放开)。 */
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 }
}
}