Compare commits
22
Commits
2fccf3f2b3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef57b194f4 | ||
|
|
395be9688d | ||
|
|
28d8587e71 | ||
|
|
4767ee7fec | ||
|
|
8831a9b277 | ||
|
|
272d0bc672 | ||
|
|
1004daf821 | ||
|
|
74e5e9d5fe | ||
|
|
0d7cd5f322 | ||
|
|
174c498757 | ||
|
|
26aafb3f53 | ||
|
|
cea2414371 | ||
|
|
1f9d7e7572 | ||
|
|
996f492083 | ||
|
|
6aefe62a9f | ||
|
|
8283d66db6 | ||
|
|
655b77c2b7 | ||
|
|
64cc293878 | ||
|
|
72cf13c8c3 | ||
|
|
154ef11c90 | ||
|
|
043b7fc480 | ||
|
|
cc18822c97 |
@@ -8,8 +8,28 @@ The format loosely follows Keep a Changelog and can be adapted to the team's hab
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed / 变更
|
||||||
|
|
||||||
|
- Reader UI cleanup: the legacy style helpers (ui.ts / icons.tsx) and all rd-* classes are gone; every reader surface now uses the shared design tokens and components.
|
||||||
|
- 阅读器 UI 清理:旧样式助手(ui.ts / icons.tsx)与全部 rd-* 类删除;所有阅读器表面统一使用共享设计 token 与组件。
|
||||||
|
|
||||||
### Added / 新增
|
### Added / 新增
|
||||||
|
|
||||||
|
- The shelf page gains a reading stats card (this week's time, streak days, expandable 7-day bar chart); reading time is tracked on-device while a book is open.
|
||||||
|
- 书架页新增阅读统计卡(本周时长、连续天数、可展开的近 7 日柱状图);打开书籍期间在设备本地记录阅读时长。
|
||||||
|
|
||||||
|
- EPUB reader gains typography settings (font size, line height, margins) and honors the 纸/米/夜 reading themes including follow-global-theme; its toolbar moves to the unified settings bar.
|
||||||
|
- EPUB 阅读器新增排版设置(字号/行距/边距)并支持纸/米/夜阅读主题(含跟随全局);工具条迁入统一设置栏。
|
||||||
|
|
||||||
|
- Text reader gains full-book search (搜索 tab in the navigation drawer: debounced input, chapter + snippet results, tap to jump) and typography settings (line height ×5, margin width ×3); the reading toolbar is now the unified settings bar with theme swatches and follow-global-theme.
|
||||||
|
- 文本阅读器新增全书搜索(导航抽屉「搜索」选项卡:输入防抖、章节+前后文结果、点击跳转)与排版设置(行距五档、边距三档);阅读工具条换为统一设置栏,含主题卡与跟随全局主题。
|
||||||
|
|
||||||
|
- CBZ reader gains horizontal reading modes — 单页 / 双页 with a 右开本 (RTL) toggle — alongside the existing vertical strip; mode, direction, continuous reading and prefetch now live in a unified settings bar with the 纸/米/夜 theme swatches and follow-global-theme support.
|
||||||
|
- CBZ 阅读器新增横向阅读模式——单页/双页,带右开本(RTL)开关——与现有长卷模式共存;翻页模式、方向、连读与预读收进统一设置栏,含纸/米/夜主题卡与跟随全局主题。
|
||||||
|
|
||||||
|
- Reading preferences are unified into a single store with a new "follow global theme" mode (纸/米/夜 auto-follows light/dark until manually overridden); a reading-time tracker lays the groundwork for the shelf stats card.
|
||||||
|
- 阅读偏好统一为单一存储,新增「跟随全局主题」模式(纸/米/夜随 light/dark 自动切换,手动选择后以手动为准);阅读时长记录层就位,为书架统计卡打底。
|
||||||
|
|
||||||
- Developer tooling: component testing infrastructure (vitest jsdom project + Testing Library) with sample tests for shared UI components; reader component tests are deferred to the reader redesign. Bundle analysis available via `npm run analyze`, findings documented in `docs/bundle-review.md`.
|
- Developer tooling: component testing infrastructure (vitest jsdom project + Testing Library) with sample tests for shared UI components; reader component tests are deferred to the reader redesign. Bundle analysis available via `npm run analyze`, findings documented in `docs/bundle-review.md`.
|
||||||
- 开发工具链:组件测试基建(vitest jsdom project + Testing Library),公共 UI 组件配样板测试;阅读器组件测试留待阅读器改版。`npm run analyze` 可出 bundle 体积报告,结论见 `docs/bundle-review.md`。
|
- 开发工具链:组件测试基建(vitest jsdom project + Testing Library),公共 UI 组件配样板测试;阅读器组件测试留待阅读器改版。`npm run analyze` 可出 bundle 体积报告,结论见 `docs/bundle-review.md`。
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
|||||||
|
# 阅读器改版(chrome 迁移 + 阅读新特性)设计 / Reader revamp design
|
||||||
|
|
||||||
|
日期 2026-09-16。分支 `docs/reader-revamp-spec`(设计);实施分支另建(建议 `feat/reader-revamp`)。状态:已获用户批准(会话内分节确认)。
|
||||||
|
|
||||||
|
本 spec 是项目优化四个子项目中的 **②**(① 后端健壮性、④ 前端工程质量已合入 master;③ 功能增强独立 spec)。边界来源:`2026-09-08-modern-redesign-shadcn-design.md` 的「Phase 2」节;基建依赖 ④ 已交付(eslint/组件测试/e2e/axe 回归网)。
|
||||||
|
|
||||||
|
## 目标 / Goal
|
||||||
|
|
||||||
|
1. 四阅读器(CBZ/Text/EPUB/PDF)chrome(头栏/工具条/目录/书签面板/主题卡)迁入 shadcn token + radix 原语,删除全部 `rd-*` 组件类与裸色类,删除 deprecated 的 `components/ui.ts` 与 `components/icons.tsx`。
|
||||||
|
2. 「纸/米/夜」阅读主题重映射为语义 token,并实现与全局 light/dark 的「默认联动 + 手动覆盖」规则。
|
||||||
|
3. 五个新特性:CBZ 横向翻页模式(长卷/单页/双页 + RTL)、txt/md 书内搜索、统一阅读设置面板(底部抽屉扩展)、阅读统计(localStorage + 可替换接口,书架顶部统计卡)、EPUB 排版设置(epubjs themes)。
|
||||||
|
4. 回收 ④ 留下的收严清单:eslint reader 豁免 override(4 条 jsx-a11y warn)删除回 error、axe `WAIVERS`(reader-chrome color-contrast)清空重扫、TxtView/CbzReader 的行级 disable 重估。
|
||||||
|
5. 顺带修复 ④ 终审遗留的 tsc 盲区(`e2e/`、`*.config.ts` 纳入类型检查)。
|
||||||
|
|
||||||
|
非目标见文末「范围外」。
|
||||||
|
|
||||||
|
## 决策记录 / Decisions
|
||||||
|
|
||||||
|
- 范围 = **chrome 迁移 + 新特性**(用户选定,否决「纯迁移」与「迁移为主+逐个勾选」)。五特性由用户从候选清单多选圈定;快捷键速查面板落 backlog。
|
||||||
|
- 实施策略 = **基建先行 + 按阅读器纵切**(方案 1,用户选定):B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。否决:两段式(迁移批过大、特性二次触碰)、特性先行(旧壳上写特性必返工)。
|
||||||
|
- 主题联动 = **默认联动 + 手动覆盖**(用户选定):`themeMode:"auto"` 时全局 dark→夜、light→纸;手动选卡记住手动值;设置面板提供「跟随全局」重置。否决:完全独立、完全同步。
|
||||||
|
- 统一设置面板形态 = **A 底部抽屉扩展**(用户经视觉伴侣选定):现有沉浸底部 sheet 长大,常驻行 + 按格式条件渲染的「更多设置」展开区。否决:右侧 Sheet、工具条 Popover。
|
||||||
|
- 书内搜索交互 = **导航抽屉第三 tab**(用户选定),否决顶部 overlay 搜索栏。
|
||||||
|
- 阅读统计 = **localStorage 记录 + `StatsStore` 可替换接口**(用户选定,③ 未来可换服务端实现);展示 = **书架顶部可折叠统计卡**(用户选定),书卡不加元素。
|
||||||
|
- CBZ 翻页 = **长卷/单页/双页 + RTL 独立开关**(用户选定);双页为手动配对,**不做自动跨页检测**;RTL 仅对页模式生效。
|
||||||
|
- EPUB 排版 = **字号/行距/边距 + 主题映射**(用户选定),不引入自定义字体族/字体文件。
|
||||||
|
- tsc 盲区修复**纳入 ②**(用户选定)作为 B0 的一个任务。
|
||||||
|
- **无 localStorage 兼容要求**(用户明确裁定):偏好统一收进单一 `reader-prefs` 新结构,旧独立键 `cbz-continuous`/`cbz-prefetch` 直接废弃(不读不写不迁移),老用户偏好重置为新默认值。服务端进度/书签 locator 格式本期不变(page/spread 与 `{page}` 天然兼容)。
|
||||||
|
- 行为保留约束(继承 Phase 2 边界):翻页/翻屏、CBZ 章锁/连读/预读、书签 seek、进度上报的**行为逻辑**原样保留(只换壳 + 明确圈定的新特性);④ 期间的行为等价修复(渲染期重置等)不回退。
|
||||||
|
|
||||||
|
## S0 批次总则 / Batches
|
||||||
|
|
||||||
|
六批依序实施、依序提交:B0 横切基建 → B1 CBZ → B2 Text → B3 EPUB → B4 PDF+统计卡 → B5 清理收严。
|
||||||
|
|
||||||
|
- 每批验收底线:`npm run check` 绿 + `npm run e2e` 绿(既有 spec 不回归)+ 该批新增测试通过。
|
||||||
|
- changelog:用户可见条目记 `docs/CHANGELOG_web.md`(双语同条、条目间空行、新在上);每批随批记。
|
||||||
|
- 新依赖(如有)容器内安装并提交 lockfile;本期预期**零新运行时依赖**(radix/shadcn/lucide 已在),仅可能新增 `@types/node`(dev)。
|
||||||
|
|
||||||
|
## S1 B0 横切基建 / Cross-cutting foundation
|
||||||
|
|
||||||
|
**阅读主题 token 与联动**
|
||||||
|
- 保留 `data-rd={paper|sepia|night}` 驱动机制;`--rd-bg/--rd-fg/--rd-link` 扩展为完整 token 组(补 `--rd-muted/--rd-accent/--rd-border`),供迁移后 chrome 使用。阅读面用 `--rd-*` token,抽屉/弹层容器用 shadcn 语义 token。
|
||||||
|
- `themeMode: "auto" | "paper" | "sepia" | "night"`,默认 `auto`(全局 dark→night、light→paper,实时跟随);手动选主题卡即切换为手动值;设置面板「跟随全局」按钮重置回 auto。
|
||||||
|
|
||||||
|
**readerPrefs 统一结构(无兼容负担)**
|
||||||
|
- 单一 localStorage 键 `reader-prefs`,新结构:`{ themeMode, text:{sizeIdx,lineIdx,marginIdx}, cbz:{mode:"strip"|"page"|"spread", rtl:boolean, continuous:boolean, prefetch:0|1|2|3}, epub:{sizeIdx,lineIdx,marginIdx} }`;宽容解析(缺字段/坏值回默认)。旧 `cbz-continuous`/`cbz-prefetch` 键废弃。
|
||||||
|
- `useReaderPrefs` 重构为该结构的读写 hook(现有消费点随各阅读器批次迁移)。
|
||||||
|
|
||||||
|
**统一设置抽屉(形态 A)**
|
||||||
|
- 新增 shadcn 原语文件 `ui/sheet.tsx`、`ui/tabs.tsx`、`ui/slider.tsx`(基于已装的 `radix-ui` 包,零新 npm 依赖,模式对齐现有 `ui/*.tsx`)。
|
||||||
|
- `components/reader-settings.tsx`:底部 sheet(radix Sheet 原语 + shadcn token),常驻行 = 进度滑条(shadcn Slider)+ 主题卡(含「跟随」态)+ A−/A+;「更多设置」展开区按格式条件渲染:CBZ = 翻页模式/方向/连读/预读;Text = 行距/边距;EPUB = 字号/行距/边距;PDF = 无。
|
||||||
|
- 替代旧 `rd-sheet/rd-btn/rd-range` 与 `rd-slider.tsx`(组件在 B5 删除)。
|
||||||
|
|
||||||
|
**统计记录层**
|
||||||
|
- `lib/readingStats.ts`:`StatsStore` 接口(`record(seconds)` / `weekly()` / `streak()`)+ localStorage 实现(键 `reading-stats`,按日桶 `{ "YYYY-MM-DD": seconds }`)。
|
||||||
|
- 记录时机:Reader 页可见期间 30s 心跳 + 卸载 flush(`visibilitychange`/`pagehide`)。纯函数(日桶聚合/周合计/连续天数)独立导出供单测。
|
||||||
|
|
||||||
|
**tsc 盲区修复**
|
||||||
|
- `frontend/tsconfig.json` include 扩为 `["src", "test", "e2e", "*.config.ts"]` + dev 依赖 `@types/node`;存量 e2e/config 代码先过一遍 `tsc --noEmit` 清零。
|
||||||
|
|
||||||
|
## S2 B1 CBZ(迁移 + 翻页模式 + RTL)
|
||||||
|
|
||||||
|
**chrome 迁移**
|
||||||
|
- `pages/Reader.tsx` 头栏:`stone-950`/`btn`/`btnGhost`/`formatBadge` → shadcn token + `ui/button`(ghost)+ `ui/badge`;`IconArrowLeft` → lucide `ArrowLeft`。
|
||||||
|
- `components/reader-nav.tsx` → radix Sheet(side="left")+ shadcn Tabs 样式;`Bookmarks.tsx` 内容结构不变、`rd-btn` → `ui/button`。
|
||||||
|
- 底部工具条 → S1 的 `reader-settings`。
|
||||||
|
|
||||||
|
**翻页模式 + RTL**
|
||||||
|
- `strip`(长卷)= 现有渲染路径原样保留:章锁/连读/预读/滚动定位/章末卡片零改动。
|
||||||
|
- `page`/`spread` 新渲染路径:按页索引显示当前页;spread 手动配对 `[2i, 2i+1]`;翻页 = 索引步进,复用 `PageImg` 解码缓存预载相邻页;章锁在页模式 = 章末页后显示「本章完」卡片页,点击进下一章(锁章止步);`continuous` 在页模式 = 越过章末卡片自动进下一章;预读三模式通用。
|
||||||
|
- RTL 独立开关:右缘点击 = 上一页、键盘 ←/→ 映射翻转、spread 右页为先;仅 page/spread 生效。
|
||||||
|
- 点击区约定沿用:两侧翻页、中央收放 chrome。模式/方向切换保持当前页;书签 `{page}` locator 与进度上报(页索引→percent)兼容不变。
|
||||||
|
- 双页配对与 RTL 方向映射抽纯函数进 `lib/`(单测)。
|
||||||
|
|
||||||
|
**测试**:配对/方向纯函数单测;模式切换组件测试;e2e 扩展单页模式翻页 + RTL 冒烟。CbzReader 迁移后其 eslint warn 豁免与 axe WAIVERS 应自然消除(B5 验证)。
|
||||||
|
|
||||||
|
## S3 B2 Text(迁移 + 搜索 + 排版)
|
||||||
|
|
||||||
|
**chrome 迁移**:阅读面保留 `rd-surface`/`data-rd` token 机制(用 S1 扩展后变量);顶栏/章节选择器(含卷分组)/底部栏迁 shadcn + radix;主题卡/A−A+ 收进 `reader-settings`;书签抽屉复用 B1 的 `reader-nav`。分章/分卷/编码回退/进度恢复逻辑原样保留;TxtView 的 set-state disable(④ 遗留)迁移时重估:能以渲染期重置修复则修,否则保留注释并登记 B5 清单。
|
||||||
|
|
||||||
|
**书内搜索(导航抽屉第三 tab,仅 Text)**
|
||||||
|
- 输入 250ms 防抖;对内存章节文本线性扫描(大小写不敏感),全局结果上限 500;结果行 = 章节标题 + snippet(命中 ±~20 字符,`<mark>` 高亮)。
|
||||||
|
- 点击结果 → 复用章节跳转定位到章、滚动至命中处短暂高亮;不产生书签;locator 格式不变。
|
||||||
|
- 扫描/snippet/高亮切分抽纯函数进 `lib/search.ts`(单测)。
|
||||||
|
|
||||||
|
**排版设置**:行距五档(1.2/1.4/1.6/1.8/2.0)+ 边距三档(窄/中/宽 → max-width),进 `reader-settings`,持久化 `reader-prefs.text`。
|
||||||
|
|
||||||
|
**测试**:`lib/search.ts` 单测;搜索 tab 组件测试;新增 `e2e/fixtures/e2e-sample.txt` + e2e 搜索冒烟(输入→结果→跳转断言)。
|
||||||
|
|
||||||
|
## S4 B3 EPUB(迁移 + 排版设置)
|
||||||
|
|
||||||
|
- chrome 迁移:顶栏/「更多阅读选项」dropdown → shadcn + radix DropdownMenu/Sheet;书签抽屉复用 `reader-nav`;CFI 进度/书签原样保留。
|
||||||
|
- 排版:epubjs `rendition.themes` API——字号(复用 `FONT_SIZES`)/行距/边距 register 为 overrides;「纸/米/夜」映射 EPUB 背景/文字色,按 `themeMode`(含 auto 联动)`themes.select`;持久化 `reader-prefs.epub`。
|
||||||
|
- **测试限制(如实记录)**:epubjs 渲染在 iframe 内,组件测试不可行;headless 稳定性差,本期不加 EPUB e2e/fixture。验收走手工清单:开书 → 改字号/行距/边距/主题各一次 → 视觉生效 → 进度恢复。
|
||||||
|
|
||||||
|
## S5 B4 PDF(迁移)+ 书架统计卡
|
||||||
|
|
||||||
|
- PDF:pdfjs 渲染/页码定位/书签/进度原样保留;顶栏/工具条 chrome → shadcn + radix;pdf.worker 配置不动;无新增排版设置。
|
||||||
|
- 统计卡:`Shelf.tsx` 筛选栏上方可折叠卡(折叠态记忆 localStorage)——「本周阅读 X 小时 Y 分 · 连续 N 天」,展开显示近 7 日按日柱状图(纯 div 高度百分比,不引图表库);数据来自 `StatsStore.weekly()/streak()`;书卡不加元素。
|
||||||
|
- 测试:统计纯函数单测(B0 已列);统计卡组件测试用 fake `StatsStore` 注入;e2e 仅断言卡片存在(不断言时间敏感数值)。
|
||||||
|
|
||||||
|
## S6 B5 清理与收严 / Cleanup & tightening
|
||||||
|
|
||||||
|
- 删除 `components/ui.ts`(使用点先迁 `ui/button`/`ui/badge`)与 `components/icons.tsx`(→ lucide)。
|
||||||
|
- `index.css` 删除全部 `rd-*` 组件类(rd-btn/rd-sheet/rd-range/rd-row/rd-highlight/rd-divider 等)与阅读器裸色类(stone-* 等);`--rd-*` token 变量与 `data-rd` 机制保留。验收:`grep -rn 'rd-' src/ --include='*.tsx'` 零命中(`--rd-*`/`data-rd` 除外)。
|
||||||
|
- 收严清单回收:① `eslint.config.js` 删除 reader 豁免 override(4 条 jsx-a11y warn 回 error),迁移后代码必须真达标;② `e2e/helpers/axe.ts` 的 `WAIVERS` 清空,reader 相关页重扫零 critical/serious;③ TxtView/CbzReader 行级 disable 重估(修复或留带理由注释,AuthContext 的 disable 与阅读器无关、按 ④ 结论保留)。
|
||||||
|
- 测试收口:设置抽屉组件测试(改字号→断言正文 style);e2e 补「设置面板冒烟」。
|
||||||
|
- changelog:每批用户可见条目已在各批记;B5 补 Changed 条目(旧样式类删除/主题联动)。
|
||||||
|
|
||||||
|
## S7 验收 / Definition of Done
|
||||||
|
|
||||||
|
1. `npm run check` 绿(含 tsc 新覆盖 e2e/config);`npm run e2e` 绿(扩展后全部 spec)。
|
||||||
|
2. 手工走查清单:四阅读器 × 三阅读主题(+auto 联动)× 375/768/1280 三宽 × light/dark;翻页/书签/进度恢复/沉浸收放行为与迁移前一致(新特性除外)。
|
||||||
|
3. `ui.ts`/`icons.tsx` 已删;rd-* 组件类 grep 零命中;eslint reader override 与 axe WAIVERS 已移除。
|
||||||
|
4. changelog(双语)与 lockfile 齐;prod 镜像无新依赖。
|
||||||
|
|
||||||
|
## S8 范围外 / Out of scope
|
||||||
|
|
||||||
|
- EPUB 书内搜索;CBZ 自动跨页检测;快捷键速查面板(backlog);
|
||||||
|
- 阅读统计服务端化(③ 候选,本期仅留 `StatsStore` 接口缝);
|
||||||
|
- PDF 排版设置;自定义字体族/字体文件;i18n;
|
||||||
|
- 后端任何改动(API/迁移/契约零变化);
|
||||||
|
- 书卡元素变更;书架布局改版;
|
||||||
|
- localStorage 旧偏好兼容(用户裁定不需要)。
|
||||||
|
|
||||||
|
## 附录:backlog(本期未选,记录备查)
|
||||||
|
|
||||||
|
- 快捷键速查面板(阅读页 `?` 弹出);
|
||||||
|
- CBZ 自动跨页检测(宽>高单显、相邻竖图配对);
|
||||||
|
- EPUB 书内搜索(epubjs search API);
|
||||||
|
- 阅读统计服务端化 + 跨设备(③ 联动);
|
||||||
|
- ④ 遗留:`--legacy-peer-deps` 已落 `.npmrc`(闭合);vitest `src/**/*.test.ts`(非 tsx)静默缝隙——B0 顺手在 vitest.config 注释或 include 收紧。
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { expect, test, type APIRequestContext } from "@playwright/test";
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
import { adminApi, creds, ensureSampleBook } from "./helpers/api";
|
import { adminApiRetry, creds, ensureSampleBook } from "./helpers/api";
|
||||||
import { scanA11y } from "./helpers/axe";
|
import { scanA11y } from "./helpers/axe";
|
||||||
|
|
||||||
let admin: APIRequestContext;
|
let admin: APIRequestContext;
|
||||||
@@ -7,7 +7,7 @@ let bookId: number;
|
|||||||
let title: string;
|
let title: string;
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
admin = await adminApi();
|
admin = await adminApiRetry();
|
||||||
bookId = await ensureSampleBook(admin, "e2e");
|
bookId = await ensureSampleBook(admin, "e2e");
|
||||||
// 标题以 API 实况为准:后端 titleOf 可能改写文件名(下划线→空格等),书架卡按真实标题找。
|
// 标题以 API 实况为准:后端 titleOf 可能改写文件名(下划线→空格等),书架卡按真实标题找。
|
||||||
title = ((await (await admin.get(`/api/books/${bookId}`)).json()) as { title: string }).title;
|
title = ((await (await admin.get(`/api/books/${bookId}`)).json()) as { title: string }).title;
|
||||||
@@ -32,6 +32,7 @@ test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ pa
|
|||||||
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
|
// 卡片用「结构 + 文本」定位:可访问名匹配会同时命中卡片的《…》操作菜单钮(含书名 aria-label)。
|
||||||
const card = page.locator(".group > button", { hasText: title });
|
const card = page.locator(".group > button", { hasText: title });
|
||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
|
await expect(page.getByRole("region", { name: "阅读统计" })).toBeVisible();
|
||||||
await scanA11y(page, "shelf");
|
await scanA11y(page, "shelf");
|
||||||
await card.click();
|
await card.click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
|
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
|
||||||
@@ -51,13 +52,33 @@ test("登录 → 书架 → CBZ 阅读翻页 → 书签 → 退出", async ({ pa
|
|||||||
await expect(page.getByText("2/3")).toBeVisible();
|
await expect(page.getByText("2/3")).toBeVisible();
|
||||||
}).toPass({ timeout: 20_000 });
|
}).toPass({ timeout: 20_000 });
|
||||||
|
|
||||||
|
// 页模式 + RTL 冒烟:开设置面板 → 单页 → 右缘翻页 → 右开本(RTL) 方向翻转 → 回长卷。
|
||||||
|
await page.getByRole("button", { name: "更多阅读设置" }).click();
|
||||||
|
await page.getByRole("button", { name: "单页" }).click();
|
||||||
|
await expect(page.getByText(/^1\/3 · /)).toBeVisible(); // 模式切换保持当前页:此时在第 2 页
|
||||||
|
// 回到第 1 页:左缘点击
|
||||||
|
const box2 = await reader.boundingBox();
|
||||||
|
if (!box2) throw new Error("阅读器容器无 boundingBox");
|
||||||
|
await page.mouse.click(box2.x + box2.width * 0.1, box2.y + box2.height / 2);
|
||||||
|
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
|
||||||
|
// 右缘点击 → 第 2 页
|
||||||
|
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
|
||||||
|
await expect(page.getByText(/^2\/3 · /)).toBeVisible();
|
||||||
|
// RTL:右缘点击变后退
|
||||||
|
await page.getByRole("button", { name: "右开本(RTL)" }).click();
|
||||||
|
await page.mouse.click(box2.x + box2.width * 0.9, box2.y + box2.height / 2);
|
||||||
|
await expect(page.getByText(/^1\/3 · /)).toBeVisible();
|
||||||
|
// 回长卷
|
||||||
|
await page.getByRole("button", { name: "长卷", exact: true }).click();
|
||||||
|
await expect(page.getByText("1/3")).toBeVisible();
|
||||||
|
|
||||||
// 导航抽屉(本 fixture 无内嵌目录,抽屉直接落在书签面板)→ 加书签 → toast
|
// 导航抽屉(本 fixture 无内嵌目录,抽屉直接落在书签面板)→ 加书签 → toast
|
||||||
await page.getByRole("button", { name: "导航", exact: true }).click();
|
await page.getByRole("button", { name: "导航", exact: true }).click();
|
||||||
const nav = page.locator("#reader-nav");
|
const nav = page.locator("#reader-nav");
|
||||||
await expect(nav).toBeVisible();
|
await expect(nav).toBeVisible();
|
||||||
await nav.getByRole("button", { name: "加书签" }).click();
|
await nav.getByRole("button", { name: "加书签" }).click();
|
||||||
await expect(page.getByText("已加书签")).toBeVisible();
|
await expect(page.getByText("已加书签")).toBeVisible();
|
||||||
await page.getByRole("button", { name: "关闭导航" }).click();
|
await page.getByRole("button", { name: "关闭" }).click();
|
||||||
|
|
||||||
// 回书架 → 退出登录
|
// 回书架 → 退出登录
|
||||||
await page.getByRole("link", { name: "书架" }).click();
|
await page.getByRole("link", { name: "书架" }).click();
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
第一章 样本
|
||||||
|
|
||||||
|
这是第一章的正文,包含标记词 ZEBRA-XC42 用于搜索断言。
|
||||||
|
第一章的正文还有许多话,用来把两个章节的标题拉开足够的距离,
|
||||||
|
因为分章器会丢弃距上一个保留标题不足两百字的行,避免把正文里的提及
|
||||||
|
当成目录条目。下面是凑篇幅的段落:书的特点是按章阅读,每章之间用
|
||||||
|
明显的编号标题分隔;扫描器把文件落盘后由解析器分卷与分章;阅读器按
|
||||||
|
章渲染,长文的滚动与恢复都按章节与章内偏移来记录。ZEBRA-XC42 是
|
||||||
|
搜索断言的标记词,本段只出现一次。再写几行凑足两百字以上:风起云
|
||||||
|
收,纸上得来终觉浅,绝知此事要躬行。如此五章的信息足够编造一份适合
|
||||||
|
端到端测试用的文本样本,昭然有效,勿改内容。
|
||||||
|
|
||||||
|
第二章 落雨
|
||||||
|
|
||||||
|
雨点敲窗。第二章也有 zebra-xc42 的小写变体,用于验证大小写不敏感的
|
||||||
|
命中;这一章的正文同样要足以拉开与第一章标题的距离,避免被分章器
|
||||||
|
当成正文里的目录行丢弃。雨水落在窗上,本章的内容较第一章多几度斜
|
||||||
|
面:先有标记词的小写变体,再有凑篇幅的句子。为了保持两百字的间距,
|
||||||
|
本章正文需要再写几行;夜读时开着夜色主题,正文底色与文字色由阅读
|
||||||
|
主题映射而来;行距与边距档位随时可调,搜索结果由防抖输入驱动,命中
|
||||||
|
列表以章节 + 前后文片段呈现,点击即可跳到命中章的大致位置。落雨行
|
||||||
|
至结尾,此章同样只包含一次标记词,合计两处命中。
|
||||||
|
|
||||||
|
第三章 风止
|
||||||
|
|
||||||
|
wind 结尾章,无标记词。风止一章同样凑足两百字,让分章器认全三章。
|
||||||
|
结尾章的正文不包含任何搜索断言词,用于验证搜索结果不会多算。风过
|
||||||
|
无痕,桌上灯亮;夜读的读者从目录跳章时看到当前章的计数变为 2/3,
|
||||||
|
即第二章标记已存。样本至此结束。
|
||||||
@@ -4,6 +4,7 @@ import { request, type APIRequestContext } from "@playwright/test";
|
|||||||
|
|
||||||
export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173";
|
export const BASE = process.env.E2E_BASE_URL ?? "http://localhost:5173";
|
||||||
export const FIXTURE_CBZ = fileURLToPath(new URL("../fixtures/e2e-sample.cbz", import.meta.url));
|
export const FIXTURE_CBZ = fileURLToPath(new URL("../fixtures/e2e-sample.cbz", import.meta.url));
|
||||||
|
export const FIXTURE_TXT = fileURLToPath(new URL("../fixtures/e2e-sample-txt.txt", import.meta.url));
|
||||||
|
|
||||||
/** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */
|
/** UI 登录与 API fixture 共用同一管理员凭据:E2E_ADMIN_* 优先,回落 deploy/.env 注入的 ADMIN_*。 */
|
||||||
export function creds(): { user: string; pass: string } {
|
export function creds(): { user: string; pass: string } {
|
||||||
@@ -30,6 +31,18 @@ export async function adminApi(): Promise<APIRequestContext> {
|
|||||||
return request.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${token}` } });
|
return request.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${token}` } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** adminApi 登录带 429 退避重试:三 spec 并发 worker 的登录总数可能顶到后端 1min/5 次限流门,等窗口即可;仅消耗等待预算,不改用例语义 */
|
||||||
|
export async function adminApiRetry(): Promise<APIRequestContext> {
|
||||||
|
for (let i = 0; ; i++) {
|
||||||
|
try {
|
||||||
|
return await adminApi();
|
||||||
|
} catch (e) {
|
||||||
|
if (i > 20 || !(e instanceof Error) || !e.message.includes("HTTP 429")) throw e;
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function ensureLibrary(ctx: APIRequestContext, name: string): Promise<number> {
|
export async function ensureLibrary(ctx: APIRequestContext, name: string): Promise<number> {
|
||||||
const libs = (await (await ctx.get("/api/libraries")).json()) as { id: number; name: string }[];
|
const libs = (await (await ctx.get("/api/libraries")).json()) as { id: number; name: string }[];
|
||||||
const hit = libs.find((l) => l.name === name);
|
const hit = libs.find((l) => l.name === name);
|
||||||
@@ -45,14 +58,19 @@ async function findBook(ctx: APIRequestContext, q: string): Promise<{ id: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */
|
/** 幂等:书已存在直接返回 id;否则上传 fixture → 触发扫描 → 轮询等书出现(扫描是 fire-and-forget)。 */
|
||||||
export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
|
export async function ensureBook(
|
||||||
const title = "e2e-sample";
|
ctx: APIRequestContext,
|
||||||
|
libName: string,
|
||||||
|
fileName: string,
|
||||||
|
absPath: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const title = fileName.replace(/\.[a-z]+$/i, "");
|
||||||
const existing = await findBook(ctx, title);
|
const existing = await findBook(ctx, title);
|
||||||
if (existing) return existing.id;
|
if (existing) return existing.id;
|
||||||
const libId = await ensureLibrary(ctx, libName);
|
const libId = await ensureLibrary(ctx, libName);
|
||||||
const up = await ctx.post(`/api/libraries/${libId}/upload`, {
|
const up = await ctx.post(`/api/libraries/${libId}/upload`, {
|
||||||
multipart: {
|
multipart: {
|
||||||
file: { name: "e2e-sample.cbz", mimeType: "application/zip", buffer: readFileSync(FIXTURE_CBZ) },
|
file: { name: fileName, mimeType: "application/octet-stream", buffer: readFileSync(absPath) },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!up.ok()) throw new Error(`上传失败:HTTP ${up.status()} ${await up.text()}`);
|
if (!up.ok()) throw new Error(`上传失败:HTTP ${up.status()} ${await up.text()}`);
|
||||||
@@ -66,3 +84,8 @@ export async function ensureSampleBook(ctx: APIRequestContext, libName: string):
|
|||||||
await new Promise((r) => setTimeout(r, 1000));
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** CBZ 样本书便捷封装(auth-shelf 沿用)。 */
|
||||||
|
export async function ensureSampleBook(ctx: APIRequestContext, libName: string): Promise<number> {
|
||||||
|
return ensureBook(ctx, libName, "e2e-sample.cbz", FIXTURE_CBZ);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,18 +8,10 @@ interface Waiver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* critical/serious 违规的逐条豁免清单(spec S3):
|
* critical/serious 违规的逐条豁免清单(④ 基建):
|
||||||
* 仅允许「属于将被 ② 阅读器改版重写的旧 chrome」的问题进这里,reason 必须写明归属。
|
* ② 阅读器改版后旧 chrome 重写完毕,本期清空;新增豁免必须写明 reason 并控制在一次性条件。
|
||||||
* 例:{ page: "reader-chrome", rule: "color-contrast", reason: "旧 rd-* chrome 配色,② 迁移 shadcn token 后移除本豁免" }
|
|
||||||
*/
|
*/
|
||||||
const WAIVERS: Waiver[] = [
|
const WAIVERS: Waiver[] = [];
|
||||||
{
|
|
||||||
page: "reader-chrome",
|
|
||||||
rule: "color-contrast",
|
|
||||||
reason:
|
|
||||||
"旧 chrome:Reader.tsx 头栏(书架返回钮/书名/库名/进度)用 stone-* 配色,对比不足;② 阅读器改版迁移 shadcn token 后移除本豁免",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export async function scanA11y(page: Page, name: string): Promise<void> {
|
export async function scanA11y(page: Page, name: string): Promise<void> {
|
||||||
// 等 CSS 动画/过渡收尾(如 fx-rise 入场淡入)再扫:动画途中采样会因不透明度混色造成 contrast 误报;2s 上限兜底长动画
|
// 等 CSS 动画/过渡收尾(如 fx-rise 入场淡入)再扫:动画途中采样会因不透明度混色造成 contrast 误报;2s 上限兜底长动画
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, test, type APIRequestContext } from "@playwright/test";
|
||||||
|
import { adminApiRetry, creds, ensureBook, FIXTURE_TXT } from "./helpers/api";
|
||||||
|
import { scanA11y } from "./helpers/axe";
|
||||||
|
|
||||||
|
let admin: APIRequestContext;
|
||||||
|
let bookId: number;
|
||||||
|
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
admin = await adminApiRetry();
|
||||||
|
// 文件名避免与 CBZ 样本书同名:fixture 用 e2e-sample-txt.txt(title=e2e-sample-txt)
|
||||||
|
bookId = await ensureBook(admin, "e2e", "e2e-sample-txt.txt", FIXTURE_TXT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await admin?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("登录 → txt 阅读 → 全书搜索 → 跳转", async ({ page }) => {
|
||||||
|
const { user, pass } = creds();
|
||||||
|
|
||||||
|
await page.goto("/login");
|
||||||
|
await page.fill("#login-user", user);
|
||||||
|
await page.fill("#login-pass", pass);
|
||||||
|
await page.getByRole("button", { name: "登录", exact: true }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
|
||||||
|
const card = page.locator(".group > button", { hasText: "e2e-sample-txt" });
|
||||||
|
await expect(card).toBeVisible();
|
||||||
|
await card.click();
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/book/${bookId}$`));
|
||||||
|
|
||||||
|
// 导航抽屉 → 搜索 tab
|
||||||
|
await page.getByRole("button", { name: "导航", exact: true }).click();
|
||||||
|
const nav = page.locator("#reader-nav");
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
await nav.getByRole("tab", { name: "搜索" }).click();
|
||||||
|
await nav.getByRole("textbox", { name: "搜索全书" }).fill("zebra-xc42");
|
||||||
|
// 大小写不敏感:第一章 2 处 + 第二章 1 处 = 3 行
|
||||||
|
await expect(nav.getByRole("listitem")).toHaveCount(3, { timeout: 10_000 });
|
||||||
|
expect(await nav.locator("mark").count()).toBe(3); // <mark> 高亮
|
||||||
|
|
||||||
|
// 点第二条 → 跳到第一章/第二章(第二条命中)的章,章计数 2/3,抽屉关闭
|
||||||
|
await nav.getByRole("button").filter({ hasText: "第二章" }).first().click();
|
||||||
|
await expect(page.getByText("2/3")).toBeVisible({ timeout: 10_000 });
|
||||||
|
await expect(nav).not.toBeVisible();
|
||||||
|
|
||||||
|
await scanA11y(page, "reader-chrome");
|
||||||
|
|
||||||
|
// 设置面板冒烟:字号/行距即时生效 + 主题卡联动阅读面(data-rd 受控)
|
||||||
|
const pre = page.locator(".rd-surface pre");
|
||||||
|
await expect(pre).toHaveCSS("font-size", "20px");
|
||||||
|
await page.getByRole("button", { name: "更多阅读设置" }).click();
|
||||||
|
await page.getByRole("button", { name: "加大字号" }).click();
|
||||||
|
await expect(pre).toHaveCSS("font-size", "22px");
|
||||||
|
await page.getByRole("button", { name: "1.8", exact: true }).click();
|
||||||
|
await expect(pre).toHaveAttribute("style", /line-height: 1\.8/);
|
||||||
|
await page.getByRole("button", { name: "夜色主题" }).click();
|
||||||
|
await expect(page.locator(".rd-surface").first()).toHaveAttribute("data-rd", "night");
|
||||||
|
|
||||||
|
// 退出
|
||||||
|
await page.getByRole("link", { name: "书架" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/$/);
|
||||||
|
await page.getByRole("button", { name: new RegExp(`账户 ${user}`) }).click();
|
||||||
|
await page.getByText("退出登录").click();
|
||||||
|
await expect(page).toHaveURL(/\/login/);
|
||||||
|
});
|
||||||
@@ -21,25 +21,6 @@ export default tseslint.config(
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// 旧阅读器 chrome 的点击区/交互元素将被 ② 阅读器改版迁入 radix 原语,
|
|
||||||
// 届时收严为 error;本期对存量文件降 warn(spec S1 决策)
|
|
||||||
files: [
|
|
||||||
"src/readers/**/*.tsx",
|
|
||||||
"src/components/Bookmarks.tsx",
|
|
||||||
"src/components/reader-nav.tsx",
|
|
||||||
"src/components/rd-slider.tsx",
|
|
||||||
"src/pages/Reader.tsx",
|
|
||||||
],
|
|
||||||
rules: {
|
|
||||||
"jsx-a11y/click-events-have-key-events": "warn",
|
|
||||||
"jsx-a11y/no-static-element-interactions": "warn",
|
|
||||||
"jsx-a11y/no-noninteractive-element-interactions": "warn",
|
|
||||||
// 键盘焦点容器:CBZ/PDF 阅读器根 div 用 tabIndex={0} 接方向键翻页,
|
|
||||||
// 与上面三条同属 ② 改版范围(radix 原语自带焦点管理),本期随块降 warn
|
|
||||||
"jsx-a11y/no-noninteractive-tabindex": "warn",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
files: ["e2e/**/*.ts"],
|
files: ["e2e/**/*.ts"],
|
||||||
// e2e 是 Playwright 脚本,测试工具链的动态类型用法(如 locator/fixture 包装)允许 any
|
// e2e 是 Playwright 脚本,测试工具链的动态类型用法(如 locator/fixture 包装)允许 any
|
||||||
|
|||||||
Generated
+18
@@ -33,6 +33,7 @@
|
|||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.7",
|
"@testing-library/user-event": "^14.6.7",
|
||||||
|
"@types/node": "^22.20.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.7",
|
"@types/react-dom": "^19.2.7",
|
||||||
"@typescript/native": "npm:typescript@^7.0.2",
|
"@typescript/native": "npm:typescript@^7.0.2",
|
||||||
@@ -5212,6 +5213,16 @@
|
|||||||
"localforage": "*"
|
"localforage": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
|
||||||
|
"integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.18",
|
"version": "19.2.18",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||||
@@ -11421,6 +11432,13 @@
|
|||||||
"node": ">=22.19.0"
|
"node": ">=22.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"@testing-library/jest-dom": "^7.0.1",
|
"@testing-library/jest-dom": "^7.0.1",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@testing-library/user-event": "^14.6.7",
|
"@testing-library/user-event": "^14.6.7",
|
||||||
|
"@types/node": "^22.20.3",
|
||||||
"@types/react": "^19.2.18",
|
"@types/react": "^19.2.18",
|
||||||
"@types/react-dom": "^19.2.7",
|
"@types/react-dom": "^19.2.7",
|
||||||
"@typescript/native": "npm:typescript@^7.0.2",
|
"@typescript/native": "npm:typescript@^7.0.2",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function RequireAuth({ children }: { children: ReactNode }) {
|
|||||||
const { user, ready } = useAuth();
|
const { user, ready } = useAuth();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
if (!getToken()) return <Navigate to="/login" replace state={{ from: loc.pathname }} />;
|
||||||
if (!ready || !user) return <div className="grid h-full place-items-center text-stone-500">加载中…</div>;
|
if (!ready || !user) return <div className="text-muted-foreground grid h-full place-items-center">加载中…</div>;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { Check, Pencil, Plus, X } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Book } from "../api/types";
|
import type { Book } from "../api/types";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { toast } from "./Toaster";
|
import { toast } from "./Toaster";
|
||||||
|
|
||||||
type Locator = Record<string, unknown>;
|
type Locator = Record<string, unknown>;
|
||||||
@@ -55,8 +57,8 @@ export function BookmarksTab({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-2 flex items-center gap-1.5">
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
<input
|
<Input
|
||||||
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100 placeholder:text-stone-500"
|
className="min-w-0 flex-1"
|
||||||
placeholder="备注(可空)"
|
placeholder="备注(可空)"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
value={note}
|
value={note}
|
||||||
@@ -65,26 +67,28 @@ export function BookmarksTab({
|
|||||||
if (e.key === "Enter" && !add.isPending) add.mutate();
|
if (e.key === "Enter" && !add.isPending) add.mutate();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn inline-flex shrink-0 items-center gap-1"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0"
|
||||||
aria-label="加书签"
|
aria-label="加书签"
|
||||||
disabled={add.isPending}
|
disabled={add.isPending}
|
||||||
onClick={() => add.mutate()}
|
onClick={() => add.mutate()}
|
||||||
>
|
>
|
||||||
<Plus size={14} /> 加
|
<Plus size={14} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{q.isPending && <li className="p-2 text-xs text-stone-500">加载中…</li>}
|
{q.isPending && <li className="p-2 text-xs text-muted-foreground">加载中…</li>}
|
||||||
{!q.isPending && items.length === 0 && (
|
{!q.isPending && items.length === 0 && (
|
||||||
<li className="p-2 text-xs text-stone-500">还没有书签,读到想记的位置点「加」</li>
|
<li className="p-2 text-xs text-muted-foreground">还没有书签,读到想记的位置点「加」</li>
|
||||||
)}
|
)}
|
||||||
{items.map((b) => (
|
{items.map((b) => (
|
||||||
<li key={b.id}>
|
<li key={b.id}>
|
||||||
{editId === b.id ? (
|
{editId === b.id ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<input
|
<Input
|
||||||
className="min-w-0 flex-1 rounded-md border border-stone-700 bg-stone-900 px-2 py-1 text-sm text-stone-100"
|
className="min-w-0 flex-1"
|
||||||
maxLength={500}
|
maxLength={500}
|
||||||
value={editVal}
|
value={editVal}
|
||||||
onChange={(e) => setEditVal(e.target.value)}
|
onChange={(e) => setEditVal(e.target.value)}
|
||||||
@@ -92,34 +96,40 @@ export function BookmarksTab({
|
|||||||
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
if (e.key === "Enter" && !patch.isPending) patch.mutate({ id: b.id, note: editVal.trim() });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn inline-flex shrink-0 items-center"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0"
|
||||||
aria-label="保存备注"
|
aria-label="保存备注"
|
||||||
disabled={patch.isPending}
|
disabled={patch.isPending}
|
||||||
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
onClick={() => patch.mutate({ id: b.id, note: editVal.trim() })}
|
||||||
>
|
>
|
||||||
<Check size={14} />
|
<Check size={14} />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn inline-flex shrink-0 items-center"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="shrink-0"
|
||||||
aria-label="取消编辑"
|
aria-label="取消编辑"
|
||||||
onClick={() => setEditId(null)}
|
onClick={() => setEditId(null)}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="group flex items-center">
|
<div className="group flex items-center">
|
||||||
<button
|
<button
|
||||||
className="rd-row min-w-0 flex-1 truncate text-left"
|
className="min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
title={b.note || undefined}
|
title={b.note || undefined}
|
||||||
onClick={() => seek(b.locator)}
|
onClick={() => seek(b.locator)}
|
||||||
>
|
>
|
||||||
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
|
<span className="tabular-nums opacity-70">{Math.round(b.percent * 100)}%</span>{" "}
|
||||||
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
|
{b.note || <span className="opacity-50">{b.created_at.slice(0, 10)}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
aria-label="改备注"
|
aria-label="改备注"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditId(b.id);
|
setEditId(b.id);
|
||||||
@@ -127,14 +137,16 @@ export function BookmarksTab({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Pencil size={14} />
|
<Pencil size={14} />
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
className="rd-btn m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="m-0.5 shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
|
||||||
aria-label="删除书签"
|
aria-label="删除书签"
|
||||||
onClick={() => del.mutate(b.id)}
|
onClick={() => del.mutate(b.id)}
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={14} />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import type { Book } from "../api/types";
|
import type { Book } from "../api/types";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
import { btn, formatBadge } from "./ui";
|
import { FormatBadge } from "./format-badge";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
import { TriangleAlert } from "lucide-react";
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
export function useAuthedImage(url: string | undefined): { src: string; failed: boolean; retry: () => void } {
|
||||||
@@ -44,12 +45,17 @@ export function Cover({ book, className }: { book: Book; className?: string }) {
|
|||||||
<span className="select-none font-serif text-4xl text-muted-foreground">
|
<span className="select-none font-serif text-4xl text-muted-foreground">
|
||||||
{Array.from(book.title)[0] ?? "?"}
|
{Array.from(book.title)[0] ?? "?"}
|
||||||
</span>
|
</span>
|
||||||
<span className={formatBadge(book.format, "absolute bottom-1.5 right-1.5")}>{book.format}</span>
|
<FormatBadge fmt={book.format} className="absolute right-1.5 bottom-1.5" />
|
||||||
{failed && (
|
{failed && (
|
||||||
<button className={btn + " absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2"} onClick={retry}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="xs"
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 gap-1"
|
||||||
|
onClick={retry}
|
||||||
|
>
|
||||||
<TriangleAlert className="size-4" />
|
<TriangleAlert className="size-4" />
|
||||||
封面重试
|
封面重试
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { err: Err
|
|||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="grid h-full place-items-center p-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
<p className="mb-2 text-lg text-red-400">页面出错了</p>
|
||||||
<p className="mb-4 max-w-md text-sm break-all text-stone-500">{String(this.state.err)}</p>
|
<p className="mb-4 max-w-md text-sm break-all text-muted-foreground">{String(this.state.err)}</p>
|
||||||
<button
|
<button
|
||||||
className="rounded-md bg-stone-800 px-3 py-1.5 text-sm"
|
className="rounded-md bg-secondary px-3 py-1.5 text-sm"
|
||||||
onClick={() => this.setState({ err: null })}
|
onClick={() => this.setState({ err: null })}
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import type { Format } from "@/api/types";
|
||||||
|
|
||||||
|
const CLASSES: Record<Format, string> = {
|
||||||
|
cbz: "border-transparent bg-orange-500/15 text-orange-900 ring-0 dark:text-orange-300",
|
||||||
|
pdf: "border-transparent bg-rose-500/15 text-rose-900 ring-0 dark:text-rose-300",
|
||||||
|
epub: "border-transparent bg-sky-500/15 text-sky-900 ring-0 dark:text-sky-300",
|
||||||
|
txt: "border-transparent bg-emerald-500/15 text-emerald-900 ring-0 dark:text-emerald-300",
|
||||||
|
md: "border-transparent bg-teal-500/15 text-teal-900 ring-0 dark:text-teal-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义(承接旧 ui.ts formatBadge)。 */
|
||||||
|
export function FormatBadge({ fmt, className }: { fmt: Format; className?: string }) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[10px] font-semibold uppercase tracking-wide ${CLASSES[fmt]} ${className ?? ""}`}
|
||||||
|
>
|
||||||
|
{fmt}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
function Svg({ children, ...p }: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.7}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
aria-hidden="true"
|
|
||||||
{...p}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IconArrowLeft = (p: SVGProps<SVGSVGElement>) => (
|
|
||||||
<Svg {...p}>
|
|
||||||
<path d="M20 12H4m0 0 6-6m-6 6 6 6" />
|
|
||||||
</Svg>
|
|
||||||
);
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
/** 阅读器主题内的滑条配置项:说明 + 整数档位滑条 + 锚点刻度. */
|
|
||||||
export function RdSlider({
|
|
||||||
label,
|
|
||||||
ariaLabel,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
ariaLabel: string;
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
value: number;
|
|
||||||
onChange: (v: number) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="px-2.5 pb-1.5 pt-2">
|
|
||||||
<div className="mb-1 text-sm">{label}</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={min}
|
|
||||||
max={max}
|
|
||||||
step={1}
|
|
||||||
value={value}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="rd-range"
|
|
||||||
onChange={(e) => onChange(Number(e.target.value))}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between px-1 text-[10px] tabular-nums opacity-50" aria-hidden="true">
|
|
||||||
{Array.from({ length: max - min + 1 }, (_, i) => (
|
|
||||||
<span key={i}>{min + i}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,66 +1,51 @@
|
|||||||
import { Bookmark, List, X } from "lucide-react";
|
import { Bookmark, List, Search } from "lucide-react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
|
||||||
export type NavTab = "toc" | "bm";
|
export type NavTab = "toc" | "bm" | "search";
|
||||||
|
|
||||||
/** 阅读器左侧导航抽屉:目录/书签选项卡;不传 toc 则退化为纯书签面板. */
|
/** 阅读器导航抽屉(radix Sheet, side=left):目录/书签/搜索选项卡;按传入内容决定可见 tab。 */
|
||||||
export function ReaderNav({
|
export function ReaderNav({
|
||||||
tab,
|
tab,
|
||||||
onTab,
|
onTab,
|
||||||
onClose,
|
onClose,
|
||||||
toc,
|
toc,
|
||||||
bookmarks,
|
bookmarks,
|
||||||
|
search,
|
||||||
}: {
|
}: {
|
||||||
tab: NavTab;
|
tab: NavTab;
|
||||||
onTab?: (t: NavTab) => void;
|
onTab?: (t: NavTab) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
toc?: ReactNode;
|
toc?: ReactNode;
|
||||||
bookmarks: ReactNode;
|
bookmarks: ReactNode;
|
||||||
|
search?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const tabs: { id: NavTab; label: string; icon: typeof List; node: ReactNode }[] = [
|
||||||
|
...(toc != null ? [{ id: "toc" as const, label: "目录", icon: List, node: toc }] : []),
|
||||||
|
{ id: "bm" as const, label: "书签", icon: Bookmark, node: bookmarks },
|
||||||
|
...(search != null ? [{ id: "search" as const, label: "搜索", icon: Search, node: search }] : []),
|
||||||
|
];
|
||||||
|
const active = tabs.find((t) => t.id === tab) ?? tabs[0];
|
||||||
return (
|
return (
|
||||||
<>
|
<Sheet open onOpenChange={(o) => !o && onClose()}>
|
||||||
<div className="fixed inset-0 z-30" aria-hidden="true" onClick={onClose} />
|
<SheetContent side="left" className="w-72 p-0" aria-describedby={undefined}>
|
||||||
<nav
|
<SheetHeader className="sr-only">
|
||||||
id="reader-nav"
|
<SheetTitle>导航</SheetTitle>
|
||||||
aria-label="导航"
|
</SheetHeader>
|
||||||
className="rd-divider absolute inset-y-0 left-0 z-40 flex w-72 flex-col border-r bg-[var(--rd-bg,#171512)] shadow-2xl"
|
<div id="reader-nav" className="flex h-full flex-col">
|
||||||
style={{ color: "var(--rd-fg,#c6c0b6)" }}
|
<Tabs value={active.id} onValueChange={(v) => onTab?.(v as NavTab)} className="flex min-h-0 flex-1 flex-col">
|
||||||
>
|
<TabsList className="m-2 shrink-0 self-start">
|
||||||
<div
|
{tabs.map(({ id, label, icon: Icon }) => (
|
||||||
role={toc != null ? "tablist" : undefined}
|
<TabsTrigger key={id} value={id} className="gap-1.5">
|
||||||
aria-label="导航选项卡"
|
<Icon size={14} /> {label}
|
||||||
className="rd-divider flex shrink-0 items-center gap-1 border-b p-2"
|
</TabsTrigger>
|
||||||
>
|
))}
|
||||||
{toc != null ? (
|
</TabsList>
|
||||||
<>
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">{active.node}</div>
|
||||||
<button
|
</Tabs>
|
||||||
role="tab"
|
|
||||||
aria-selected={tab === "toc"}
|
|
||||||
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "toc" ? " rd-btn-on" : "")}
|
|
||||||
onClick={() => onTab?.("toc")}
|
|
||||||
>
|
|
||||||
<List size={14} /> 目录
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
role="tab"
|
|
||||||
aria-selected={tab === "bm"}
|
|
||||||
className={"rd-btn inline-flex items-center gap-1.5" + (tab === "bm" ? " rd-btn-on" : "")}
|
|
||||||
onClick={() => onTab?.("bm")}
|
|
||||||
>
|
|
||||||
<Bookmark size={14} /> 书签
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="rd-btn rd-btn-on inline-flex items-center gap-1.5">
|
|
||||||
<Bookmark size={14} /> 书签
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<button className="rd-btn ml-auto" aria-label="关闭导航" onClick={onClose}>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tab === "toc" ? toc : bookmarks}</div>
|
</SheetContent>
|
||||||
</nav>
|
</Sheet>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import { beforeEach, describe, it } from "vitest";
|
||||||
|
import { ReaderSettings } from "@/components/reader-settings";
|
||||||
|
import { ThemeProvider } from "@/components/theme";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSettings(props = {}) {
|
||||||
|
return render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<ReaderSettings
|
||||||
|
slider={{ value: 2, max: 10, onChange: () => {}, ariaLabel: "页码跳转" }}
|
||||||
|
position="3/12 · 25%"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReaderSettings", () => {
|
||||||
|
it("常驻行渲染:滑条 aria-label 与位置文本", () => {
|
||||||
|
renderSettings();
|
||||||
|
expect(screen.getByRole("slider", { name: "页码跳转" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("3/12 · 25%")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("主题卡点击 → localStorage 的 themeMode 变手动值", () => {
|
||||||
|
renderSettings();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "夜色主题" }));
|
||||||
|
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
|
||||||
|
expect(stored.themeMode).toBe("night");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("「跟随全局」按钮存在且点击回 auto", () => {
|
||||||
|
renderSettings();
|
||||||
|
const follow = screen.getByRole("button", { name: "跟随全局" });
|
||||||
|
expect(follow).toBeInTheDocument();
|
||||||
|
expect(follow).toHaveAttribute("aria-pressed", "true");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "米色主题" }));
|
||||||
|
expect(screen.getByRole("button", { name: "跟随全局" })).toHaveAttribute("aria-pressed", "false");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "跟随全局" }));
|
||||||
|
const stored = JSON.parse(localStorage.getItem("reader-prefs") ?? "{}");
|
||||||
|
expect(stored.themeMode).toBe("auto");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showFont=false 时无 A−/A+", () => {
|
||||||
|
renderSettings({ showFont: false, onFont: () => {} });
|
||||||
|
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "加大字号" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未传 onFont 时 A−/A+ 隐藏(showFont 也无字号钮)", () => {
|
||||||
|
renderSettings({ showFont: true });
|
||||||
|
expect(screen.queryByRole("button", { name: "减小字号" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("「更多设置」展开/收起(aria-expanded 翻转、extra 可见性)", () => {
|
||||||
|
renderSettings({ extra: <div data-testid="extra">额外面板</div> });
|
||||||
|
const toggle = screen.getByRole("button", { name: "更多阅读设置" });
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
|
expect(screen.queryByTestId("extra")).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(toggle);
|
||||||
|
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||||
|
expect(screen.getByTestId("extra")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("showFont + onFont 时 A+ 点击 → 回调收到 +1", () => {
|
||||||
|
let got = 0;
|
||||||
|
renderSettings({ showFont: true, onFont: (d: number) => (got = d) });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "加大字号" }));
|
||||||
|
expect(got).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { useGlobalDark } from "@/components/theme";
|
||||||
|
import {
|
||||||
|
RD_THEMES,
|
||||||
|
RD_THEME_LABEL,
|
||||||
|
FONT_SIZES,
|
||||||
|
resolveRdTheme,
|
||||||
|
useReaderPrefs,
|
||||||
|
type RdTheme,
|
||||||
|
type ReaderPrefs,
|
||||||
|
} from "@/lib/readerPrefs";
|
||||||
|
|
||||||
|
export interface ReaderSettingsProps {
|
||||||
|
/** 进度滑条:value/max 页或千分比由调用方定,onChange 收整数;EPUB 无全局分数故可省 */
|
||||||
|
slider?: { value: number; max: number; onChange: (v: number) => void; ariaLabel: string };
|
||||||
|
/** 位置文本,如 "3/12 · 25%" */
|
||||||
|
position?: string;
|
||||||
|
/** 左半区按钮(导航/上一章/下一章等),调用方给 */
|
||||||
|
left?: ReactNode;
|
||||||
|
/** 是否显示 A−/A+ 与字号(text/epub true;cbz/pdf false) */
|
||||||
|
showFont?: boolean;
|
||||||
|
/** 字号调节走哪一档(text=bumpText / epub=bumpEpub);未传时不渲染 A−/A+ */
|
||||||
|
onFont?: (d: number) => void;
|
||||||
|
/** 当前字号档位(用于 A−/A+ 边界禁用);不传按文本框位 */
|
||||||
|
fontIdx?: number;
|
||||||
|
/** 受控主题:传入时用调用方状态,保证阅读面 data-rd 与设置栏同步(不传则组件内自持) */
|
||||||
|
themeMode?: ReaderPrefs["themeMode"];
|
||||||
|
onThemeMode?: (m: ReaderPrefs["themeMode"]) => void;
|
||||||
|
/** 「更多设置」展开区内容(按格式给:CBZ 翻页/RTL/连读/预读;Text 行距/边距;EPUB 字号/行距/边距) */
|
||||||
|
extra?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题色卡样式(纸 #faf8f4 / 米 #f0e6d2 / 夜 #171512,与 --rd-bg 三值一致) */
|
||||||
|
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
||||||
|
|
||||||
|
/** 统一阅读设置栏:常驻行 = 进度滑条 + 位置 + 左区 + 主题卡(含跟随全局)+ A−/A+;「更多设置」展开区按格式给。 */
|
||||||
|
export function ReaderSettings({
|
||||||
|
slider,
|
||||||
|
position,
|
||||||
|
left,
|
||||||
|
showFont = false,
|
||||||
|
onFont,
|
||||||
|
fontIdx,
|
||||||
|
themeMode,
|
||||||
|
onThemeMode,
|
||||||
|
extra,
|
||||||
|
}: ReaderSettingsProps) {
|
||||||
|
const internal = useReaderPrefs();
|
||||||
|
// 主题受控:调用方给状态时用调用方(阅读面 data-rd 联动);否则退回组件内自持(独立使用/测试)
|
||||||
|
const mode = themeMode ?? internal.prefs.themeMode;
|
||||||
|
const setMode = onThemeMode ?? internal.setThemeMode;
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(mode, globalDark);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="border-t bg-background/95 text-foreground backdrop-blur pt-1.5 pb-[max(0.5rem,env(safe-area-inset-bottom))]"
|
||||||
|
data-rd={rd}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 px-2.5">
|
||||||
|
{left && <div className="flex items-center gap-1.5">{left}</div>}
|
||||||
|
{position && (
|
||||||
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground" aria-live="polite">
|
||||||
|
{position}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 whitespace-nowrap" role="group" aria-label="阅读主题">
|
||||||
|
{RD_THEMES.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
aria-label={`${RD_THEME_LABEL[t]}色主题`}
|
||||||
|
aria-pressed={mode === t}
|
||||||
|
onClick={() => setMode(t)}
|
||||||
|
className="h-5 w-5 shrink-0 rounded-full transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
background: SWATCH[t],
|
||||||
|
boxShadow:
|
||||||
|
rd === t
|
||||||
|
? "0 0 0 2px var(--rd-accent)"
|
||||||
|
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
aria-pressed={mode === "auto"}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => setMode("auto")}
|
||||||
|
>
|
||||||
|
跟随全局
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||||
|
{showFont && onFont && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="减小字号"
|
||||||
|
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === 0}
|
||||||
|
onClick={() => onFont(-1)}
|
||||||
|
>
|
||||||
|
A−
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="加大字号"
|
||||||
|
disabled={(fontIdx ?? internal.prefs.text.sizeIdx) === FONT_SIZES.length - 1}
|
||||||
|
onClick={() => onFont(1)}
|
||||||
|
>
|
||||||
|
A+
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{extra && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
aria-label="更多阅读设置"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
{open ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{slider && (
|
||||||
|
<Slider
|
||||||
|
className="mx-2.5 mt-2 w-[calc(100%-1.25rem)]"
|
||||||
|
value={[slider.value]}
|
||||||
|
max={slider.max}
|
||||||
|
onValueChange={([v]) => slider.onChange(v)}
|
||||||
|
aria-label={slider.ariaLabel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{extra && open && <div className="px-2.5 pt-2">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置行通用骨架:label + 右侧控件组。 */
|
||||||
|
export function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-3 px-1 py-1.5 text-sm">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="flex items-center gap-1">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { expect } from "vitest";
|
||||||
|
import { beforeEach, describe, it } from "vitest";
|
||||||
|
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
||||||
|
import type { StatsStore } from "@/lib/readingStats";
|
||||||
|
|
||||||
|
function fakeStore(): StatsStore {
|
||||||
|
const today = new Date();
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
const key = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
const y = new Date(today);
|
||||||
|
y.setDate(y.getDate() - 1);
|
||||||
|
const buckets: Record<string, number> = { [key(today)]: 900, [key(y)]: 300 };
|
||||||
|
return {
|
||||||
|
record() {},
|
||||||
|
weekly() {
|
||||||
|
const perDay = Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - (6 - i));
|
||||||
|
const k = key(d);
|
||||||
|
return { day: k, seconds: buckets[k] ?? 0 };
|
||||||
|
});
|
||||||
|
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
|
||||||
|
},
|
||||||
|
streak() {
|
||||||
|
return 2;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
describe("ReadingStatsCard", () => {
|
||||||
|
it("本周文案含时长与连续天数", () => {
|
||||||
|
render(<ReadingStatsCard store={fakeStore()} />);
|
||||||
|
expect(screen.getByText(/本周阅读/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/20 分钟/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/连续 2 天/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("展开/收起:aria-label 翻转 + 柱状图可见性 + localStorage 记忆", () => {
|
||||||
|
render(<ReadingStatsCard store={fakeStore()} />);
|
||||||
|
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
|
||||||
|
const btn = screen.getByRole("button", { name: "折叠统计" });
|
||||||
|
expect(screen.queryAllByRole("img").length).toBeGreaterThan(0);
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(screen.getByRole("button", { name: "展开统计" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("img", { name: "近 7 日阅读时长柱状图" })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "展开统计" }));
|
||||||
|
expect(screen.getByRole("img", { name: "近 7 日阅读时长柱状图" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("注入的 store 无连续记录时不显示连续段", () => {
|
||||||
|
const s = fakeStore();
|
||||||
|
render(<ReadingStatsCard store={{ ...s, streak: () => 0 }} />);
|
||||||
|
expect(screen.queryByText(/连续/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("7 根柱(含 0 值日)", () => {
|
||||||
|
render(<ReadingStatsCard store={fakeStore()} />);
|
||||||
|
const chart = screen.getByRole("img", { name: "近 7 日阅读时长柱状图" });
|
||||||
|
expect(chart.children.length).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { BarChart3, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { createLocalStats, type StatsStore } from "@/lib/readingStats";
|
||||||
|
|
||||||
|
const FOLD_KEY = "stats-card-folded";
|
||||||
|
|
||||||
|
function fmtDur(s: number): string {
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.round((s % 3600) / 60);
|
||||||
|
return h > 0 ? `${h} 小时 ${m} 分` : `${m} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 书架顶部阅读统计卡:本周时长 + 连续天数,展开显示近 7 日柱状图。store 可注入(测试/③ 服务端实现)。 */
|
||||||
|
export function ReadingStatsCard({ store }: { store?: StatsStore }) {
|
||||||
|
const stats = useMemo(() => store ?? createLocalStats(), [store]);
|
||||||
|
const [folded, setFolded] = useState(() => localStorage.getItem(FOLD_KEY) === "1");
|
||||||
|
const { perDay, total } = stats.weekly();
|
||||||
|
const streak = stats.streak();
|
||||||
|
const max = Math.max(1, ...perDay.map((d) => d.seconds));
|
||||||
|
return (
|
||||||
|
<section aria-label="阅读统计" className="bg-card text-card-foreground px-4 py-3 text-sm rounded-lg border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="text-muted-foreground size-4" />
|
||||||
|
<span>
|
||||||
|
本周阅读 <strong className="tabular-nums">{fmtDur(total)}</strong>
|
||||||
|
{streak > 0 && <span className="text-muted-foreground"> · 连续 {streak} 天</span>}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
className="ml-auto"
|
||||||
|
aria-label={folded ? "展开统计" : "折叠统计"}
|
||||||
|
onClick={() => {
|
||||||
|
const v = !folded;
|
||||||
|
setFolded(v);
|
||||||
|
localStorage.setItem(FOLD_KEY, v ? "1" : "0");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{folded ? <ChevronDown className="size-4" /> : <ChevronUp className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!folded && (
|
||||||
|
<div className="mt-2 flex h-16 items-end gap-1.5" role="img" aria-label="近 7 日阅读时长柱状图">
|
||||||
|
{perDay.map((d) => (
|
||||||
|
<div key={d.day} className="flex flex-1 flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className="bg-primary/70 w-full rounded-sm"
|
||||||
|
style={{ height: `${Math.max(2, (d.seconds / max) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] text-muted-foreground tabular-nums">{d.day.slice(8)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,3 +45,19 @@ export function useTheme() {
|
|||||||
if (!v) throw new Error("useTheme outside ThemeProvider");
|
if (!v) throw new Error("useTheme outside ThemeProvider");
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MQ = "(prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
/** 全局主题的有效暗色态:dark→true;system→跟随媒体查询;light→false。供阅读主题 auto 联动。 */
|
||||||
|
export function useGlobalDark(): boolean {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const [sysDark, setSysDark] = useState(() => typeof matchMedia === "function" && matchMedia(MQ).matches);
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== "system") return;
|
||||||
|
const mq = matchMedia(MQ);
|
||||||
|
const on = () => setSysDark(mq.matches);
|
||||||
|
mq.addEventListener("change", on);
|
||||||
|
return () => mq.removeEventListener("change", on);
|
||||||
|
}, [theme]);
|
||||||
|
return theme === "dark" || (theme === "system" && sysDark);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { Format } from "../api/types";
|
|
||||||
|
|
||||||
export const btn =
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg bg-white/[0.06] px-3 py-1.5 text-sm ring-1 ring-white/10 transition-colors hover:bg-white/[0.11] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500 active:bg-white/[0.15] disabled:cursor-not-allowed disabled:opacity-50";
|
|
||||||
export const btnGhost =
|
|
||||||
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-stone-400 transition-colors hover:bg-white/[0.07] hover:text-stone-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-500";
|
|
||||||
|
|
||||||
// 格式徽章:按类型着色,色彩之外始终伴随文字,不依赖颜色单独传义
|
|
||||||
export const FORMAT_BADGE: Record<Format, string> = {
|
|
||||||
cbz: "bg-orange-500/15 text-orange-300 ring-orange-500/25",
|
|
||||||
pdf: "bg-rose-500/15 text-rose-300 ring-rose-500/25",
|
|
||||||
epub: "bg-sky-500/15 text-sky-300 ring-sky-500/25",
|
|
||||||
txt: "bg-emerald-500/15 text-emerald-300 ring-emerald-500/25",
|
|
||||||
md: "bg-teal-500/15 text-teal-300 ring-teal-500/25",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function formatBadge(fmt: Format, extra = ""): string {
|
|
||||||
return `rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide ring-1 ${FORMAT_BADGE[fmt]} ${extra}`;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDE_CLASSES = {
|
||||||
|
top: "inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||||
|
bottom:
|
||||||
|
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||||
|
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||||
|
right:
|
||||||
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal data-slot="sheet-portal">
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-2 bg-background p-4 shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||||
|
SIDE_CLASSES[side],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && side !== "bottom" && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
aria-label="关闭"
|
||||||
|
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("font-semibold text-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetPortal,
|
||||||
|
SheetTitle,
|
||||||
|
SheetTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Slider as SliderPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
function Slider({
|
||||||
|
className,
|
||||||
|
defaultValue,
|
||||||
|
value,
|
||||||
|
min = 0,
|
||||||
|
max = 100,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||||
|
const thumbs = React.useMemo(
|
||||||
|
() => (Array.isArray(value) ? value : Array.isArray(defaultValue) ? defaultValue : [min, max]),
|
||||||
|
[value, defaultValue, min, max],
|
||||||
|
);
|
||||||
|
// radix 的可访问 slider 角色在 Thumb 上,aria-label 提到 Root 传不进去——改下沉到每个 Thumb。
|
||||||
|
const { "aria-label": ariaLabel, ...rootProps } = props;
|
||||||
|
return (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
data-slot="slider"
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
value={value}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...rootProps}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track
|
||||||
|
data-slot="slider-track"
|
||||||
|
className="relative w-full grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Range
|
||||||
|
data-slot="slider-range"
|
||||||
|
className="absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=horizontal]:top-0 data-[orientation=vertical]:w-full"
|
||||||
|
/>
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
{thumbs.map((_, i) => (
|
||||||
|
<SliderPrimitive.Thumb
|
||||||
|
key={i}
|
||||||
|
data-slot="slider-thumb"
|
||||||
|
aria-label={Array.isArray(ariaLabel) ? ariaLabel[i] : ariaLabel}
|
||||||
|
className="block size-4 shrink-0 rounded-full border border-primary/50 bg-background shadow transition-[color,box-shadow] hover:ring-4 hover:ring-ring/30 focus-visible:ring-4 focus-visible:ring-ring/50 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Tabs as TabsPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
function Tabs({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return <TabsPrimitive.Root data-slot="tabs" className={cn("flex flex-col gap-2", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn("inline-flex w-fit items-center justify-center rounded-lg bg-muted p-1-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1 text-foreground text-sm font-medium whitespace-nowrap transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return <TabsPrimitive.Content data-slot="tabs-content" className={cn("flex-1 outline-none", className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||||
+9
-66
@@ -121,16 +121,25 @@ body {
|
|||||||
--rd-bg: #faf8f4;
|
--rd-bg: #faf8f4;
|
||||||
--rd-fg: #292524;
|
--rd-fg: #292524;
|
||||||
--rd-link: #9a3412;
|
--rd-link: #9a3412;
|
||||||
|
--rd-muted: #8a8378;
|
||||||
|
--rd-accent: #b45309;
|
||||||
|
--rd-border: #e4ded2;
|
||||||
}
|
}
|
||||||
[data-rd="sepia"] {
|
[data-rd="sepia"] {
|
||||||
--rd-bg: #f0e6d2;
|
--rd-bg: #f0e6d2;
|
||||||
--rd-fg: #433a29;
|
--rd-fg: #433a29;
|
||||||
--rd-link: #92400e;
|
--rd-link: #92400e;
|
||||||
|
--rd-muted: #857a63;
|
||||||
|
--rd-accent: #92400e;
|
||||||
|
--rd-border: #ddd0b4;
|
||||||
}
|
}
|
||||||
[data-rd="night"] {
|
[data-rd="night"] {
|
||||||
--rd-bg: #171512;
|
--rd-bg: #171512;
|
||||||
--rd-fg: #c6c0b6;
|
--rd-fg: #c6c0b6;
|
||||||
--rd-link: #e0a458;
|
--rd-link: #e0a458;
|
||||||
|
--rd-muted: #8b8579;
|
||||||
|
--rd-accent: #e0a458;
|
||||||
|
--rd-border: #322e27;
|
||||||
}
|
}
|
||||||
.rd-surface {
|
.rd-surface {
|
||||||
background: var(--rd-bg);
|
background: var(--rd-bg);
|
||||||
@@ -140,74 +149,8 @@ body {
|
|||||||
.rd-surface ::selection {
|
.rd-surface ::selection {
|
||||||
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
|
background: color-mix(in oklab, var(--rd-link) 30%, transparent);
|
||||||
}
|
}
|
||||||
/* 主题内的次级控件(章节条/翻页键),跟随当前阅读主题 */
|
|
||||||
.rd-btn {
|
|
||||||
@apply rounded-md px-2.5 py-1 text-sm transition-colors;
|
|
||||||
background: color-mix(in oklab, currentColor 8%, transparent);
|
|
||||||
}
|
|
||||||
.rd-btn:hover {
|
|
||||||
background: color-mix(in oklab, currentColor 16%, transparent);
|
|
||||||
}
|
|
||||||
.rd-btn:disabled {
|
|
||||||
@apply cursor-not-allowed opacity-40;
|
|
||||||
}
|
|
||||||
/* 目录侧边栏行 */
|
|
||||||
.rd-row {
|
|
||||||
@apply block w-full truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors;
|
|
||||||
}
|
|
||||||
.rd-row:hover {
|
|
||||||
background: color-mix(in oklab, currentColor 12%, transparent);
|
|
||||||
}
|
|
||||||
.rd-btn-on {
|
|
||||||
@apply font-semibold;
|
|
||||||
background: color-mix(in oklab, currentColor 20%, transparent);
|
|
||||||
}
|
|
||||||
/* Radix 菜单项高亮(指针/键盘) */
|
|
||||||
.rd-highlight {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
.rd-highlight[data-highlighted] {
|
|
||||||
background: color-mix(in oklab, currentColor 16%, transparent);
|
|
||||||
}
|
|
||||||
.rd-divider {
|
|
||||||
border-color: color-mix(in oklab, currentColor 14%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 进度/页码滑条:主题色轨道 + 强调色拇指,触摸友好 */
|
|
||||||
.rd-range {
|
|
||||||
@apply h-1.5 w-full cursor-pointer appearance-none rounded-full;
|
|
||||||
background: color-mix(in oklab, currentColor 18%, transparent);
|
|
||||||
}
|
|
||||||
.rd-range::-webkit-slider-thumb {
|
|
||||||
@apply h-4 w-4 appearance-none rounded-full ring-2 ring-black/10;
|
|
||||||
background: var(--rd-link, #f59e0b);
|
|
||||||
}
|
|
||||||
.rd-range::-moz-range-thumb {
|
|
||||||
@apply h-4 w-4 rounded-full border-0;
|
|
||||||
background: var(--rd-link, #f59e0b);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 沉浸态底部工具抽屉的滑入动效 */
|
|
||||||
@keyframes rd-rise {
|
|
||||||
from {
|
|
||||||
transform: translateY(100%);
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.rd-sheet {
|
|
||||||
animation: rd-rise 0.2s ease-out;
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.rd-sheet {
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
.rd-rise-anim {
|
|
||||||
transition: none !important;
|
|
||||||
}
|
|
||||||
.fx-rise {
|
.fx-rise {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type PageMode = "page" | "spread";
|
||||||
|
|
||||||
|
export function viewCount(count: number, mode: PageMode): number {
|
||||||
|
return mode === "page" ? count : Math.ceil(count / 2);
|
||||||
|
}
|
||||||
|
export function pageIndexToView(i: number, mode: PageMode): number {
|
||||||
|
return mode === "page" ? i : Math.floor(i / 2);
|
||||||
|
}
|
||||||
|
export function viewToPageIndex(v: number, mode: PageMode): number {
|
||||||
|
return mode === "page" ? v : v * 2;
|
||||||
|
}
|
||||||
|
/** 一个 spread 视图要渲染的页,按显示顺序(LTR=[左,右];RTL=[右,左] 即先读页在前)。 */
|
||||||
|
export function spreadPages(first: number, count: number, rtl: boolean): number[] {
|
||||||
|
const pages = [first, ...(first + 1 < count ? [first + 1] : [])];
|
||||||
|
return rtl ? [...pages].reverse() : pages;
|
||||||
|
}
|
||||||
|
/** 翻一屏/页;rtl 时方向翻转;越界钳制。 */
|
||||||
|
export function turnView(v: number, d: number, rtl: boolean, views: number): number {
|
||||||
|
const step = rtl ? -d : d;
|
||||||
|
return Math.max(0, Math.min(views - 1, v + step));
|
||||||
|
}
|
||||||
@@ -1,35 +1,108 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
export const RD_THEMES = ["paper", "sepia", "night"] as const;
|
export const RD_THEMES = ["paper", "sepia", "night"] as const;
|
||||||
export type RdTheme = (typeof RD_THEMES)[number];
|
export type RdTheme = (typeof RD_THEMES)[number];
|
||||||
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
|
export const RD_THEME_LABEL: Record<RdTheme, string> = { paper: "纸", sepia: "米", night: "夜" };
|
||||||
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
|
export const FONT_SIZES = [16, 18, 20, 22, 25, 28];
|
||||||
|
export const LINE_HEIGHTS = [1.2, 1.4, 1.6, 1.8, 2.0];
|
||||||
|
export const MARGINS = ["narrow", "medium", "wide"] as const;
|
||||||
|
export type Margin = (typeof MARGINS)[number];
|
||||||
|
export const MARGIN_LABEL: Record<Margin, string> = { narrow: "窄", medium: "中", wide: "宽" };
|
||||||
|
/** 正文限宽档位:窄=宽度量、宽=窄度量(阅读习惯:边距越大行宽越窄) */
|
||||||
|
export const MARGIN_MAXW: Record<Margin, string> = { narrow: "42em", medium: "34em", wide: "26em" };
|
||||||
|
export const CBZ_MODES = ["strip", "page", "spread"] as const;
|
||||||
|
export type CbzMode = (typeof CBZ_MODES)[number];
|
||||||
|
export const CBZ_MODE_LABEL: Record<CbzMode, string> = { strip: "长卷", page: "单页", spread: "双页" };
|
||||||
|
|
||||||
const KEY = "reader-prefs";
|
const KEY = "reader-prefs";
|
||||||
|
|
||||||
export interface ReaderPrefs {
|
export interface ReaderPrefs {
|
||||||
theme: RdTheme;
|
themeMode: "auto" | RdTheme;
|
||||||
sizeIdx: number;
|
text: { sizeIdx: number; lineIdx: number; margin: Margin };
|
||||||
|
cbz: { mode: CbzMode; rtl: boolean; continuous: boolean; prefetch: number };
|
||||||
|
epub: { sizeIdx: number; lineIdx: number; marginIdx: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PREFS: ReaderPrefs = {
|
||||||
|
themeMode: "auto",
|
||||||
|
text: { sizeIdx: 2, lineIdx: 2, margin: "medium" },
|
||||||
|
cbz: { mode: "strip", rtl: false, continuous: false, prefetch: 2 },
|
||||||
|
epub: { sizeIdx: 2, lineIdx: 2, marginIdx: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
function idx(v: unknown, len: number, def: number): number {
|
||||||
|
return Number.isInteger(v) && (v as number) >= 0 && (v as number) < len ? (v as number) : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 宽容解析:逐字段校验,缺失/坏值回默认(新结构,无旧键迁移——spec 决策)。 */
|
||||||
|
export function parsePrefs(raw: string | null): ReaderPrefs {
|
||||||
|
let o: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw ?? "");
|
||||||
|
if (parsed && typeof parsed === "object") o = parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
/* 首次使用或坏值 */
|
||||||
|
}
|
||||||
|
const t = (o.text ?? {}) as Record<string, unknown>;
|
||||||
|
const c = (o.cbz ?? {}) as Record<string, unknown>;
|
||||||
|
const e = (o.epub ?? {}) as Record<string, unknown>;
|
||||||
|
const mode = o.themeMode;
|
||||||
|
return {
|
||||||
|
themeMode:
|
||||||
|
mode === "auto" || RD_THEMES.includes(mode as RdTheme)
|
||||||
|
? (mode as ReaderPrefs["themeMode"])
|
||||||
|
: DEFAULT_PREFS.themeMode,
|
||||||
|
text: {
|
||||||
|
sizeIdx: idx(t.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.text.sizeIdx),
|
||||||
|
lineIdx: idx(t.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.text.lineIdx),
|
||||||
|
margin: MARGINS.includes(t.margin as Margin) ? (t.margin as Margin) : DEFAULT_PREFS.text.margin,
|
||||||
|
},
|
||||||
|
cbz: {
|
||||||
|
mode: CBZ_MODES.includes(c.mode as CbzMode) ? (c.mode as CbzMode) : DEFAULT_PREFS.cbz.mode,
|
||||||
|
rtl: typeof c.rtl === "boolean" ? c.rtl : DEFAULT_PREFS.cbz.rtl,
|
||||||
|
continuous: typeof c.continuous === "boolean" ? c.continuous : DEFAULT_PREFS.cbz.continuous,
|
||||||
|
prefetch:
|
||||||
|
Number.isInteger(c.prefetch) && (c.prefetch as number) >= 0 && (c.prefetch as number) <= 3
|
||||||
|
? (c.prefetch as number)
|
||||||
|
: DEFAULT_PREFS.cbz.prefetch,
|
||||||
|
},
|
||||||
|
epub: {
|
||||||
|
sizeIdx: idx(e.sizeIdx, FONT_SIZES.length, DEFAULT_PREFS.epub.sizeIdx),
|
||||||
|
lineIdx: idx(e.lineIdx, LINE_HEIGHTS.length, DEFAULT_PREFS.epub.lineIdx),
|
||||||
|
marginIdx: idx(e.marginIdx, MARGINS.length, DEFAULT_PREFS.epub.marginIdx),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** auto = 全局 dark→夜、light→纸;手动值直通(spec S1 联动规则)。 */
|
||||||
|
export function resolveRdTheme(mode: ReaderPrefs["themeMode"], globalDark: boolean): RdTheme {
|
||||||
|
if (mode !== "auto") return mode;
|
||||||
|
return globalDark ? "night" : "paper";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReaderPrefs() {
|
export function useReaderPrefs() {
|
||||||
const [prefs, setPrefs] = useState<ReaderPrefs>(() => {
|
const [prefs, setPrefs] = useState<ReaderPrefs>(() => parsePrefs(localStorage.getItem(KEY)));
|
||||||
try {
|
|
||||||
const p = JSON.parse(localStorage.getItem(KEY) ?? "") as ReaderPrefs;
|
|
||||||
if (RD_THEMES.includes(p.theme) && Number.isInteger(p.sizeIdx) && p.sizeIdx >= 0 && p.sizeIdx < FONT_SIZES.length)
|
|
||||||
return p;
|
|
||||||
} catch {
|
|
||||||
/* 首次使用或存了坏值 */
|
|
||||||
}
|
|
||||||
return { theme: "sepia", sizeIdx: 2 };
|
|
||||||
});
|
|
||||||
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
|
useEffect(() => localStorage.setItem(KEY, JSON.stringify(prefs)), [prefs]);
|
||||||
|
const update = useCallback((fn: (p: ReaderPrefs) => ReaderPrefs) => setPrefs(fn), []);
|
||||||
return {
|
return {
|
||||||
prefs,
|
prefs,
|
||||||
fontSize: FONT_SIZES[prefs.sizeIdx],
|
update,
|
||||||
setTheme: (theme: RdTheme) => setPrefs((p) => ({ ...p, theme })),
|
fontSize: FONT_SIZES[prefs.text.sizeIdx],
|
||||||
bump: (d: number) =>
|
lineHeight: LINE_HEIGHTS[prefs.text.lineIdx],
|
||||||
setPrefs((p) => ({ ...p, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.sizeIdx + d)) })),
|
marginMaxW: MARGIN_MAXW[prefs.text.margin],
|
||||||
|
epubFontSize: FONT_SIZES[prefs.epub.sizeIdx],
|
||||||
|
setThemeMode: (themeMode: ReaderPrefs["themeMode"]) => update((p) => ({ ...p, themeMode })),
|
||||||
|
bumpText: (d: number) =>
|
||||||
|
update((p) => ({
|
||||||
|
...p,
|
||||||
|
text: { ...p.text, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.text.sizeIdx + d)) },
|
||||||
|
})),
|
||||||
|
bumpEpub: (d: number) =>
|
||||||
|
update((p) => ({
|
||||||
|
...p,
|
||||||
|
epub: { ...p.epub, sizeIdx: Math.min(FONT_SIZES.length - 1, Math.max(0, p.epub.sizeIdx + d)) },
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ReaderPrefsApi = ReturnType<typeof useReaderPrefs>;
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface DayBucket {
|
||||||
|
day: string;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
export interface StatsStore {
|
||||||
|
record(seconds: number): void;
|
||||||
|
weekly(): { perDay: DayBucket[]; total: number };
|
||||||
|
streak(): number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY = "reading-stats";
|
||||||
|
export type Buckets = Record<string, number>;
|
||||||
|
|
||||||
|
export function dayKey(d: Date): string {
|
||||||
|
const p = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
}
|
||||||
|
export function recordInto(b: Buckets, day: string, seconds: number): Buckets {
|
||||||
|
return { ...b, [day]: Math.max(0, Math.round((b[day] ?? 0) + seconds)) };
|
||||||
|
}
|
||||||
|
export function weeklySum(b: Buckets, today: Date): { perDay: DayBucket[]; total: number } {
|
||||||
|
const perDay: DayBucket[] = [];
|
||||||
|
for (let i = 6; i >= 0; i--) {
|
||||||
|
const d = new Date(today);
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
const day = dayKey(d);
|
||||||
|
perDay.push({ day, seconds: b[day] ?? 0 });
|
||||||
|
}
|
||||||
|
return { perDay, total: perDay.reduce((s, x) => s + x.seconds, 0) };
|
||||||
|
}
|
||||||
|
export function streakOf(b: Buckets, today: Date): number {
|
||||||
|
let n = 0;
|
||||||
|
const d = new Date(today);
|
||||||
|
if (!(b[dayKey(d)] > 0)) d.setDate(d.getDate() - 1); // 今天还没读不算断
|
||||||
|
while (b[dayKey(d)] > 0) {
|
||||||
|
n++;
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function load(): Buckets {
|
||||||
|
try {
|
||||||
|
const o: unknown = JSON.parse(localStorage.getItem(KEY) ?? "{}");
|
||||||
|
return o && typeof o === "object" ? (o as Buckets) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** localStorage 实现;③ 可换服务端实现(spec:可替换接口)。 */
|
||||||
|
export function createLocalStats(): StatsStore {
|
||||||
|
return {
|
||||||
|
record(seconds) {
|
||||||
|
if (seconds <= 0) return;
|
||||||
|
localStorage.setItem(KEY, JSON.stringify(recordInto(load(), dayKey(new Date()), seconds)));
|
||||||
|
},
|
||||||
|
weekly() {
|
||||||
|
return weeklySum(load(), new Date());
|
||||||
|
},
|
||||||
|
streak() {
|
||||||
|
return streakOf(load(), new Date());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { chapterText, type TxtChapter } from "./chapters";
|
||||||
|
|
||||||
|
export interface SearchHit {
|
||||||
|
ch: number;
|
||||||
|
/** 命中在章内文本的起点 */
|
||||||
|
pos: number;
|
||||||
|
snippet: string;
|
||||||
|
matchStart: number; // snippet 内
|
||||||
|
matchLen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CTX = 20; // snippet 前后文
|
||||||
|
|
||||||
|
/** 全书线性扫描(文本已分章在内存,量级足够;大小写不敏感,不折叠全半角)。 */
|
||||||
|
export function searchChapters(text: string, chapters: TxtChapter[], q: string, limit = 500): SearchHit[] {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
if (!needle) return [];
|
||||||
|
const bodies = chapters.map((_, ci) => chapterText(text, chapters, ci).toLowerCase());
|
||||||
|
const hits: SearchHit[] = [];
|
||||||
|
for (let ci = 0; ci < chapters.length && hits.length < limit; ci++) {
|
||||||
|
const body = bodies[ci];
|
||||||
|
let from = 0;
|
||||||
|
for (;;) {
|
||||||
|
const at = body.indexOf(needle, from);
|
||||||
|
if (at < 0 || hits.length >= limit) break;
|
||||||
|
const raw = chapterText(text, chapters, ci);
|
||||||
|
const s = Math.max(0, at - CTX);
|
||||||
|
const e = Math.min(raw.length, at + needle.length + CTX);
|
||||||
|
hits.push({
|
||||||
|
ch: ci,
|
||||||
|
pos: at,
|
||||||
|
snippet: (s > 0 ? "…" : "") + raw.slice(s, e) + (e < raw.length ? "…" : ""),
|
||||||
|
matchStart: at - s + (s > 0 ? 1 : 0),
|
||||||
|
matchLen: needle.length,
|
||||||
|
});
|
||||||
|
from = at + needle.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function splitHighlight(snippet: string, matchStart: number, matchLen: number) {
|
||||||
|
return {
|
||||||
|
before: snippet.slice(0, matchStart),
|
||||||
|
match: snippet.slice(matchStart, matchStart + matchLen),
|
||||||
|
after: snippet.slice(matchStart + matchLen),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { createLocalStats, type StatsStore } from "./readingStats";
|
||||||
|
|
||||||
|
const TICK = 30; // 秒:可见期间心跳粒度
|
||||||
|
|
||||||
|
/** Reader 页挂一次:可见期间每 30s 落一笔,隐藏/卸载即 flush。 */
|
||||||
|
export function useReadingSession(store: StatsStore = createLocalStats()) {
|
||||||
|
const acc = useRef(0);
|
||||||
|
const last = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
last.current = Date.now(); // 渲染期不取时钟,effect 内初始化起算点
|
||||||
|
const flush = () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (document.visibilityState === "visible") acc.current += (now - last.current) / 1000;
|
||||||
|
last.current = now;
|
||||||
|
if (acc.current >= 1) {
|
||||||
|
store.record(acc.current);
|
||||||
|
acc.current = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const t = setInterval(flush, TICK * 1000);
|
||||||
|
const onVis = () => flush();
|
||||||
|
document.addEventListener("visibilitychange", onVis);
|
||||||
|
globalThis.addEventListener?.("pagehide", onVis);
|
||||||
|
return () => {
|
||||||
|
clearInterval(t);
|
||||||
|
document.removeEventListener("visibilitychange", onVis);
|
||||||
|
globalThis.removeEventListener?.("pagehide", onVis);
|
||||||
|
flush();
|
||||||
|
};
|
||||||
|
}, [store]);
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
|
import { Suspense, lazy, useCallback, useMemo, useState, type ComponentType, type LazyExoticComponent } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import type { Format } from "../api/types";
|
import type { Format } from "../api/types";
|
||||||
import { IconArrowLeft } from "../components/icons";
|
import { FormatBadge } from "../components/format-badge";
|
||||||
import { btn, btnGhost, formatBadge } from "../components/ui";
|
import { Button } from "../components/ui/button";
|
||||||
import type { ReaderProps } from "../lib/useProgress";
|
import type { ReaderProps } from "../lib/useProgress";
|
||||||
|
import { createLocalStats } from "../lib/readingStats";
|
||||||
|
import { useReadingSession } from "../lib/useReadingSession";
|
||||||
|
|
||||||
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
const READERS: Partial<Record<Format, LazyExoticComponent<ComponentType<ReaderProps>>>> = {
|
||||||
cbz: lazy(() => import("../readers/CbzReader")),
|
cbz: lazy(() => import("../readers/CbzReader")),
|
||||||
@@ -26,6 +29,8 @@ export default function Reader() {
|
|||||||
|
|
||||||
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
const bookQ = useQuery({ queryKey: ["book", id], queryFn: () => api.getBook(id), enabled: Number.isFinite(id) });
|
||||||
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
const progQ = useQuery({ queryKey: ["progress"], queryFn: api.listProgress });
|
||||||
|
const stats = useMemo(() => createLocalStats(), []);
|
||||||
|
useReadingSession(stats);
|
||||||
|
|
||||||
const book = bookQ.data;
|
const book = bookQ.data;
|
||||||
const body = (() => {
|
const body = (() => {
|
||||||
@@ -51,19 +56,21 @@ export default function Reader() {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header
|
<header
|
||||||
className={
|
className={
|
||||||
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-stone-800/80 bg-stone-950/80 px-4 text-sm backdrop-blur transition-all duration-300 " +
|
"flex shrink-0 items-center gap-3 overflow-hidden border-b border-border bg-background/90 px-4 text-sm text-foreground backdrop-blur transition-all duration-300 " +
|
||||||
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
|
(chromeOn ? "h-11 py-2 opacity-100" : "h-0 border-b-0 py-0 opacity-0")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link to="/" className={btnGhost + " shrink-0"}>
|
<Button variant="ghost" size="sm" asChild className="shrink-0">
|
||||||
<IconArrowLeft className="size-4" />
|
<Link to="/">
|
||||||
书架
|
<ArrowLeft className="size-4" />
|
||||||
</Link>
|
书架
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
<span className="truncate font-medium">{book?.title ?? "…"}</span>
|
||||||
{book && (
|
{book && (
|
||||||
<span className="hidden shrink-0 items-center gap-2 text-xs text-stone-500 sm:flex">
|
<span className="hidden shrink-0 items-center gap-2 text-xs text-muted-foreground sm:flex">
|
||||||
{book.library && <span>{book.library}</span>}
|
{book.library && <span>{book.library}</span>}
|
||||||
<span className={formatBadge(book.format)}>{book.format}</span>
|
<FormatBadge fmt={book.format} />
|
||||||
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
|
<span className="tabular-nums">{Math.round(book.percent * 100)}%</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -77,13 +84,13 @@ export default function Reader() {
|
|||||||
|
|
||||||
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
function Msg({ text, retry }: { text: string; retry?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3">{text}</p>
|
<p className="mb-3">{text}</p>
|
||||||
{retry && (
|
{retry && (
|
||||||
<button className={btn} onClick={retry}>
|
<Button variant="secondary" onClick={retry}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { useAuth } from "@/auth/AuthContext";
|
|||||||
import { Cover } from "@/components/Cover";
|
import { Cover } from "@/components/Cover";
|
||||||
import { toast } from "@/components/Toaster";
|
import { toast } from "@/components/Toaster";
|
||||||
import { UploadButton } from "@/components/upload-button";
|
import { UploadButton } from "@/components/upload-button";
|
||||||
import { formatBadge } from "@/components/ui";
|
import { ReadingStatsCard } from "@/components/reading-stats-card";
|
||||||
|
import { FormatBadge } from "@/components/format-badge";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -67,7 +68,7 @@ function BookCard({
|
|||||||
>
|
>
|
||||||
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
<span className="relative block aspect-[3/4] overflow-hidden rounded-md border bg-muted shadow-sm transition group-hover:shadow-md group-hover:ring-2 group-hover:ring-accent/50">
|
||||||
<Cover book={book} className="size-full" />
|
<Cover book={book} className="size-full" />
|
||||||
<span className={formatBadge(book.format, "absolute left-1.5 top-1.5")}>{book.format}</span>
|
<FormatBadge fmt={book.format} className="absolute left-1.5 top-1.5" />
|
||||||
{book.state === "error" && (
|
{book.state === "error" && (
|
||||||
<span
|
<span
|
||||||
className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground"
|
className="absolute right-1.5 top-1.5 flex items-center gap-1 rounded bg-destructive px-1.5 py-0.5 text-[10px] font-semibold text-destructive-foreground"
|
||||||
@@ -280,6 +281,8 @@ export default function Shelf() {
|
|||||||
|
|
||||||
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
{lib === LIB0 && <p className="sr-only" aria-live="polite">{`当前筛选:${libName}`}</p>}
|
||||||
|
|
||||||
|
<ReadingStatsCard />
|
||||||
|
|
||||||
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
{(libsQ.data ?? []).length === 0 && !libsQ.isPending && !searching && (
|
||||||
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent-strong">
|
<p className="mb-6 flex items-center gap-2 rounded-md border border-accent/30 bg-accent/10 px-3 py-2 text-sm text-accent-strong">
|
||||||
<LibraryBig className="size-4 shrink-0" />
|
<LibraryBig className="size-4 shrink-0" />
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ThemeProvider } from "@/components/theme";
|
||||||
|
import CbzReader from "@/readers/CbzReader";
|
||||||
|
|
||||||
|
const okResponse = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
vi.restoreAllMocks?.();
|
||||||
|
// 全局网络打桩:页数接口 + 图片对象 URL + 进度上报(页图数据解不真实也无需:PageImg 有占位态)
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||||
|
const url = String(input instanceof Request ? input.url : input);
|
||||||
|
if (url.includes("/pages")) return okResponse({ count: 3, chapters: null });
|
||||||
|
if (url.includes("/progress")) return okResponse(null);
|
||||||
|
return new Response("", { status: 200 }); // 页图 blob 等
|
||||||
|
});
|
||||||
|
Object.defineProperty(URL, "createObjectURL", { value: () => "blob:x", configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const book = {
|
||||||
|
id: 1,
|
||||||
|
library_id: 1,
|
||||||
|
path: "p",
|
||||||
|
title: "t",
|
||||||
|
format: "cbz",
|
||||||
|
state: "ok",
|
||||||
|
percent: 0,
|
||||||
|
pages_url: "/pages",
|
||||||
|
page_url_fmt: "/page/{}",
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
describe("CbzReader 页模式设置区", () => {
|
||||||
|
it("切「单页」后位置读数 1/3,切「双页」首页不变,切回「长卷」按钮仍可用", async () => {
|
||||||
|
render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryClientProvider client={new QueryClient()}>
|
||||||
|
<CbzReader book={book} chrome={{ on: true, toggle: () => {}, show: () => {} }} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
await screen.findByLabelText("漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏");
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "更多阅读设置" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "单页" }));
|
||||||
|
expect(screen.getByText(/^1\/3 · /)).toBeInTheDocument();
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "双页" }));
|
||||||
|
expect(screen.getByText(/^1\/3 · /)).toBeInTheDocument();
|
||||||
|
// 模式按钮组仍在设置面板内(长卷收进同一展开区)
|
||||||
|
expect(screen.getByRole("button", { name: "长卷" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
+410
-178
@@ -1,39 +1,22 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { BookOpenCheck, Check, ChevronLeft, ChevronRight, PanelLeft, Settings2 } from "lucide-react";
|
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
|
||||||
import { DropdownMenu } from "radix-ui";
|
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
|
||||||
import { api, formatPageUrl } from "../api/client";
|
import { api, formatPageUrl } from "../api/client";
|
||||||
import { btn } from "../components/ui";
|
|
||||||
import { BookmarksTab } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
import { RdSlider } from "../components/rd-slider";
|
|
||||||
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
||||||
|
import { ReaderSettings, SettingRow } from "../components/reader-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { fetchObjectUrl } from "../lib/authImage";
|
import { fetchObjectUrl } from "../lib/authImage";
|
||||||
|
import { pageIndexToView, spreadPages, turnView, viewCount, viewToPageIndex, type PageMode } from "../lib/cbzPages";
|
||||||
|
import { CBZ_MODES, CBZ_MODE_LABEL, resolveRdTheme, useReaderPrefs, type ReaderPrefs } from "../lib/readerPrefs";
|
||||||
|
import { useGlobalDark } from "../components/theme";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
|
import { PageHeights, chapterIndexAt, chapterWin } from "../lib/virt";
|
||||||
|
|
||||||
const MAX_W = 720;
|
const MAX_W = 720;
|
||||||
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
|
const EST_RATIO = 1.4; // 估高:宽 × 1.4(漫画常见竖幅)
|
||||||
|
|
||||||
function useStoredBool(key: string, def: boolean) {
|
|
||||||
const [v, setV] = useState(() => {
|
|
||||||
const s = localStorage.getItem(key);
|
|
||||||
return s === null ? def : s === "1";
|
|
||||||
});
|
|
||||||
useEffect(() => localStorage.setItem(key, v ? "1" : "0"), [key, v]);
|
|
||||||
return [v, setV] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useStoredPrefetch() {
|
|
||||||
const [n, setN] = useState(() => {
|
|
||||||
const s = localStorage.getItem("cbz-prefetch");
|
|
||||||
if (s === null || s === "") return 2; // 键缺失/空串 ≠ 0:新用户默认 2
|
|
||||||
const v = Number(s);
|
|
||||||
return Number.isInteger(v) && v >= 0 && v <= 3 ? v : 2;
|
|
||||||
});
|
|
||||||
useEffect(() => localStorage.setItem("cbz-prefetch", String(n)), [n]);
|
|
||||||
return [n, setN] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
|
function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoaded: (h: number) => void }) {
|
||||||
const [src, setSrc] = useState("");
|
const [src, setSrc] = useState("");
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
@@ -58,14 +41,14 @@ function PageImg({ url, width, onLoaded }: { url: string; width: number; onLoade
|
|||||||
}, [url, nonce]);
|
}, [url, nonce]);
|
||||||
if (failed)
|
if (failed)
|
||||||
return (
|
return (
|
||||||
<div className="grid place-items-center gap-2 bg-stone-900 py-8 text-sm text-stone-500" style={{ width }}>
|
<div className="bg-muted text-muted-foreground grid place-items-center gap-2 py-8 text-sm" style={{ width }}>
|
||||||
本页加载失败
|
本页加载失败
|
||||||
<button className={btn} onClick={() => setNonce((n) => n + 1)}>
|
<Button variant="secondary" onClick={() => setNonce((n) => n + 1)}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (!src) return <div className="animate-pulse bg-stone-800" style={{ width, height: width * EST_RATIO }} />;
|
if (!src) return <div className="animate-pulse bg-muted" style={{ width, height: width * EST_RATIO }} />;
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -107,10 +90,23 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
const dragging = useRef(false); // 拖滚动条中:scrollTop 归浏览器管,JS 只补几何不抢方向盘
|
const dragging = useRef(false); // 拖滚动条中:scrollTop 归浏览器管,JS 只补几何不抢方向盘
|
||||||
const estLearned = useRef(false); // 估高只学一次(见 measure)
|
const estLearned = useRef(false); // 估高只学一次(见 measure)
|
||||||
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
|
const ratio = useRef(EST_RATIO); // 学到的页高比(同开本漫画一页量准,全程几何稳定)
|
||||||
const [continuous, setContinuous] = useStoredBool("cbz-continuous", false); // 连读:滑动可跨章
|
const pr = useReaderPrefs();
|
||||||
const [prefetch, setPrefetch] = useStoredPrefetch(); // 锁章预读章数 0–3
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
|
const mode = pr.prefs.cbz.mode; // strip | page | spread;双向阅读方向独立的 rtl 开关(仅页模式生效)
|
||||||
|
const patchCbz = useCallback(
|
||||||
|
(part: Partial<ReaderPrefs["cbz"]>) => pr.update((p) => ({ ...p, cbz: { ...p.cbz, ...part } })),
|
||||||
|
[pr.update],
|
||||||
|
);
|
||||||
|
const continuous = pr.prefs.cbz.continuous; // 连读:滑动可跨章
|
||||||
|
const prefetch = pr.prefs.cbz.prefetch; // 锁章预读章数 0–3
|
||||||
|
const rtl = pr.prefs.cbz.rtl;
|
||||||
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
|
const [ci, setCi] = useState(0); // 当前章窗口;锁章时只由显式跳页改,连读时随滚动同步
|
||||||
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
|
const pending = useRef<number | null>(null); // 跨章跳页:切窗后下一帧落位
|
||||||
|
const [pvCur, setPvCur] = useState(0); // 页模式当前页(PageView 上报,供滑条/HUD/位置文本)
|
||||||
|
const [pvSeek, setPvSeek] = useState<{ page: number; n: number } | null>(null); // 页模式外部跳页(书签/目录/滑动条/恢复进度)
|
||||||
|
const pvSeekN = useRef(0);
|
||||||
|
const pvTurn = useRef<((d: number) => void) | null>(null); // 页模式翻页函数(容器键盘转发用)
|
||||||
|
|
||||||
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
|
const ph = useMemo(() => new PageHeights(count, width * ratio.current), [count, width]);
|
||||||
const w = useMemo(
|
const w = useMemo(
|
||||||
@@ -138,11 +134,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
|
|
||||||
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
|
// 首次拿到 count 后恢复进度页;目标页可能在他章 → goPage 走显式切章路径
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restored.current || !count || !boxRef.current) return;
|
if (restored.current || !count || (mode === "strip" && !boxRef.current)) return;
|
||||||
restored.current = true;
|
restored.current = true;
|
||||||
const p = Number(initialLocator?.page);
|
const p = Number(initialLocator?.page);
|
||||||
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false);
|
if (Number.isInteger(p) && p > 0 && p < count) goPage(p, false, false);
|
||||||
}, [count, initialLocator, ph]);
|
}, [count, initialLocator, ph, mode]);
|
||||||
|
|
||||||
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
|
// 跨章跳页:新窗口几何渲染后才能真正滚动(旧窗口 contentH 会把 scrollTop 夹断),故挂 pending 等 [ci] 生效
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -209,7 +205,25 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
}
|
}
|
||||||
|
|
||||||
const curRaw = ph.pageAt(top + anchor + vh / 2);
|
const curRaw = ph.pageAt(top + anchor + vh / 2);
|
||||||
const cur = locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
|
// 页模式读数 = PageView 上报的全局页;长卷维持滚动换算
|
||||||
|
const cur = mode !== "strip" ? pvCur : locked ? Math.min(curRaw, w.end - 1) : curRaw; // 锁章:视口落在章末卡片上时读数不越章(HUD/滑条/进度)
|
||||||
|
|
||||||
|
// 页模式 → 长卷切换后的落位:容器重挂且几何渲染完成后滚动
|
||||||
|
const [stripSeek, setStripSeek] = useState<{ page: number; n: number } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stripSeek || mode !== "strip" || !boxRef.current || !count) return;
|
||||||
|
const t = stripSeek.page;
|
||||||
|
setStripSeek(null);
|
||||||
|
if (locked && (t < w.start || t >= w.end)) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- 回长卷的跨章落位须等容器重挂后才能滚动;setCi+pending 与长卷既有「跨章跳页」路径同一实现
|
||||||
|
setCi(chapterIndexAt(chapters, t));
|
||||||
|
pending.current = t;
|
||||||
|
} else {
|
||||||
|
scrollToPage(t, false);
|
||||||
|
}
|
||||||
|
// 容器重挂后 scrollTop 归 0 且无 scroll 事件可刷 → 手动同步换算用的 top,避免沿用切走前的旧读数
|
||||||
|
setTop(boxRef.current?.scrollTop ?? 0);
|
||||||
|
}, [stripSeek, mode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
||||||
@@ -228,6 +242,11 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
function goPage(i: number, smooth = true, show = true) {
|
function goPage(i: number, smooth = true, show = true) {
|
||||||
const t = Math.max(0, Math.min(count - 1, i));
|
const t = Math.max(0, Math.min(count - 1, i));
|
||||||
if (show) chrome.show();
|
if (show) chrome.show();
|
||||||
|
// 页模式:交给 PageView 统一落位(含跨章)
|
||||||
|
if (mode !== "strip") {
|
||||||
|
setPvSeek({ page: t, n: ++pvSeekN.current });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (locked && (t < w.start || t >= w.end)) {
|
if (locked && (t < w.start || t >= w.end)) {
|
||||||
setCi(chapterIndexAt(chapters, t));
|
setCi(chapterIndexAt(chapters, t));
|
||||||
pending.current = t;
|
pending.current = t;
|
||||||
@@ -236,6 +255,19 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
scrollToPage(t, smooth);
|
scrollToPage(t, smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 模式切换保持当前页:页↔页换算视图起点,回长卷用现有滚动定位落位
|
||||||
|
function setMode(m: ReaderPrefs["cbz"]["mode"]) {
|
||||||
|
if (m === mode) return;
|
||||||
|
const t = scrub ?? pvCur;
|
||||||
|
if (m === "strip") {
|
||||||
|
patchCbz({ mode: m });
|
||||||
|
setStripSeek({ page: t, n: ++pvSeekN.current }); // 长卷容器重挂后由 clip effect 滚动落位
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPvSeek({ page: t, n: ++pvSeekN.current });
|
||||||
|
patchCbz({ mode: m });
|
||||||
|
}
|
||||||
|
|
||||||
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
|
// 翻一屏;锁章时滚动高度天然止于章末卡片,滚不过去
|
||||||
function turnScreen(d: number) {
|
function turnScreen(d: number) {
|
||||||
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
|
boxRef.current?.scrollBy({ top: d * vh, behavior: "smooth" });
|
||||||
@@ -254,10 +286,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
|
|
||||||
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
|
// pages_url 缺失时 query 被 disabled 永久 pending → 只有真正发起了请求才显示加载态
|
||||||
if (countQ.isPending && !!book.pages_url)
|
if (countQ.isPending && !!book.pages_url)
|
||||||
return <div className="grid h-full place-items-center text-stone-500">页索引加载中…</div>;
|
return <div className="text-muted-foreground grid h-full place-items-center">页索引加载中…</div>;
|
||||||
if (countQ.isError || !book.page_url_fmt)
|
if (countQ.isError || !book.page_url_fmt)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center text-stone-500">
|
<div className="text-muted-foreground grid h-full place-items-center p-8 text-center">
|
||||||
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
无法解析页索引:{countQ.error instanceof Error ? countQ.error.message : "缺 page_url_fmt"}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -299,165 +331,196 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- 根容器仅接键盘翻页(含 RTL 方向翻转);交互语义由 aria-label 说明
|
||||||
<div
|
<div
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 根容器聚焦以接方向键翻页;「滚动区可聚焦」通行做法
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
|
aria-label="漫画阅读器,点按左右两侧翻屏,点按中间显示工具栏"
|
||||||
className="relative h-full"
|
className="relative h-full"
|
||||||
|
data-rd={rd}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
|
if (mode !== "strip") {
|
||||||
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
|
if (e.key === "ArrowRight" || e.key === "PageDown") pvTurn.current?.(1);
|
||||||
else return;
|
else if (e.key === "ArrowLeft" || e.key === "PageUp") pvTurn.current?.(-1);
|
||||||
|
else return;
|
||||||
|
} else {
|
||||||
|
if (e.key === "ArrowRight" || e.key === "PageDown") turnScreen(1);
|
||||||
|
else if (e.key === "ArrowLeft" || e.key === "PageUp") turnScreen(-1);
|
||||||
|
else return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
{mode !== "strip" ? (
|
||||||
ref={attachBox}
|
<PageView
|
||||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);div 无交互语义属滚动区可聚焦的通行做法
|
count={count}
|
||||||
tabIndex={0}
|
chapters={chapters}
|
||||||
onScroll={onScroll}
|
locked={locked}
|
||||||
onClick={onZoneClick}
|
ci={ci}
|
||||||
onPointerDown={(e) => {
|
setCi={setCi}
|
||||||
const el = e.currentTarget;
|
continuous={continuous}
|
||||||
const r = el.getBoundingClientRect();
|
fmt={fmt}
|
||||||
if (e.clientX - r.left >= el.clientWidth || e.clientY - r.top >= el.clientHeight) dragging.current = true; // 按在滚动条上
|
rtl={rtl}
|
||||||
}}
|
mode={mode}
|
||||||
className="h-full overflow-y-auto"
|
saver={saver}
|
||||||
>
|
chrome={chrome}
|
||||||
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
|
pvSeek={pvSeek}
|
||||||
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
|
onSeekDone={() => setPvSeek(null)}
|
||||||
const i = a + k;
|
onCur={setPvCur}
|
||||||
return (
|
onTurnHook={pvTurn}
|
||||||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
/>
|
||||||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
) : (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 手势区(两侧翻屏/中间收放 chrome);键盘翻页由根容器统一提供
|
||||||
|
<div
|
||||||
|
ref={attachBox}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 滚动容器须可聚焦(axe scrollable-region-focusable,键盘滚动可达);「滚动区可聚焦」通行做法
|
||||||
|
tabIndex={0}
|
||||||
|
onScroll={onScroll}
|
||||||
|
onClick={onZoneClick}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
if (e.clientX - r.left >= el.clientWidth || e.clientY - r.top >= el.clientHeight) dragging.current = true; // 按在滚动条上
|
||||||
|
}}
|
||||||
|
className="h-full overflow-y-auto"
|
||||||
|
>
|
||||||
|
<div className="relative mx-auto" style={{ height: contentH, maxWidth: MAX_W }}>
|
||||||
|
{Array.from({ length: Math.max(0, b - a) }, (_, k) => {
|
||||||
|
const i = a + k;
|
||||||
|
return (
|
||||||
|
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
||||||
|
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{locked && (
|
||||||
|
<div
|
||||||
|
className="absolute left-0 grid w-full place-items-center"
|
||||||
|
style={{ top: ph.offset(w.end) - anchor, height: vh }}
|
||||||
|
>
|
||||||
|
{w.end >= count ? (
|
||||||
|
<span className="text-muted-foreground text-sm">— 全书完 —</span>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" onClick={() => goPage(chapters![ci + 1].start)}>
|
||||||
|
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})}
|
{locked && w.mountEnd > w.end && (
|
||||||
{locked && (
|
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
|
||||||
<div
|
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
|
||||||
className="absolute left-0 grid w-full place-items-center"
|
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
|
||||||
style={{ top: ph.offset(w.end) - anchor, height: vh }}
|
const i = w.end + k;
|
||||||
>
|
return (
|
||||||
{w.end >= count ? (
|
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
||||||
<span className="text-sm text-stone-500">— 全书完 —</span>
|
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
||||||
) : (
|
</div>
|
||||||
<button className={btn} onClick={() => goPage(chapters![ci + 1].start)}>
|
);
|
||||||
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
})}
|
||||||
</button>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
{locked && w.mountEnd > w.end && (
|
|
||||||
// 预读层:挂载+解码但不参与滚动几何(height:0 + overflow:hidden 裁掉全部子元素)
|
|
||||||
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-0 overflow-hidden">
|
|
||||||
{Array.from({ length: w.mountEnd - w.end }, (_, k) => {
|
|
||||||
const i = w.end + k;
|
|
||||||
return (
|
|
||||||
<div key={i} className="absolute left-0 w-full" style={{ top: ph.offset(i) - anchor }}>
|
|
||||||
<PageImg url={formatPageUrl(fmt, i)} width={width} onLoaded={(h) => measure(i, h)} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{chrome.on ? (
|
{chrome.on ? (
|
||||||
<div className="rd-sheet absolute inset-x-0 bottom-0 z-20 border-t border-stone-700/60 bg-stone-900/85 px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2 text-stone-200 backdrop-blur">
|
<div className="absolute inset-x-0 bottom-0 z-20">
|
||||||
<div className="flex items-center gap-3">
|
<ReaderSettings
|
||||||
<input
|
themeMode={pr.prefs.themeMode}
|
||||||
type="range"
|
onThemeMode={pr.setThemeMode}
|
||||||
min={0}
|
slider={{
|
||||||
max={Math.max(0, count - 1)}
|
value: scrub ?? cur,
|
||||||
value={scrub ?? cur}
|
max: Math.max(0, count - 1),
|
||||||
onChange={(e) => {
|
onChange: (v) => {
|
||||||
const v = Number(e.target.value);
|
|
||||||
setScrub(v);
|
setScrub(v);
|
||||||
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
goPage(v, false); // 拖拽中瞬移:平滑动画会被下一格打断,手感像卡顿
|
||||||
}}
|
},
|
||||||
onPointerUp={() => setScrub(null)}
|
ariaLabel: "页码跳转",
|
||||||
onPointerCancel={() => setScrub(null)}
|
}}
|
||||||
onBlur={() => setScrub(null)}
|
position={`${(scrub ?? cur) + 1}/${count} · ${Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%`}
|
||||||
className="rd-range"
|
left={
|
||||||
aria-label="页码跳转"
|
|
||||||
/>
|
|
||||||
<span className="w-20 shrink-0 text-right text-xs tabular-nums opacity-70">
|
|
||||||
{(scrub ?? cur) + 1}/{count} · {Math.round((((scrub ?? cur) + 1) / Math.max(1, count)) * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2 pb-1 text-sm">
|
|
||||||
<button
|
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
|
||||||
aria-expanded={!!nav}
|
|
||||||
aria-controls="reader-nav"
|
|
||||||
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
|
|
||||||
>
|
|
||||||
<PanelLeft size={14} /> 导航
|
|
||||||
</button>
|
|
||||||
{chapters && (
|
|
||||||
<>
|
<>
|
||||||
<span className="text-xs tabular-nums opacity-60">
|
<Button
|
||||||
{ci + 1}/{chapters.length}
|
variant="ghost"
|
||||||
</span>
|
size="sm"
|
||||||
<button
|
className="gap-1.5"
|
||||||
className="rd-btn inline-flex items-center gap-0.5"
|
aria-expanded={!!nav}
|
||||||
disabled={ci === 0}
|
aria-controls="reader-nav"
|
||||||
onClick={() => goPage(chapters[ci - 1].start)}
|
onClick={() => setNav(nav ? null : chapters ? "toc" : "bm")}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14} /> 上一章
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</Button>
|
||||||
<button
|
{chapters && (
|
||||||
className="rd-btn inline-flex items-center gap-0.5"
|
<>
|
||||||
disabled={ci === chapters.length - 1}
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
onClick={() => goPage(chapters[ci + 1].start)}
|
{ci + 1}/{chapters.length}
|
||||||
>
|
</span>
|
||||||
下一章 <ChevronRight size={14} />
|
<Button
|
||||||
</button>
|
variant="ghost"
|
||||||
</>
|
size="sm"
|
||||||
)}
|
disabled={ci === 0}
|
||||||
{chapters && chapters.length >= 2 && (
|
onClick={() => goPage(chapters[ci - 1].start)}
|
||||||
<DropdownMenu.Root>
|
|
||||||
<DropdownMenu.Trigger
|
|
||||||
className="rd-btn ml-auto inline-flex items-center gap-1.5"
|
|
||||||
aria-label="更多阅读选项"
|
|
||||||
>
|
|
||||||
<Settings2 size={14} /> 更多
|
|
||||||
</DropdownMenu.Trigger>
|
|
||||||
<DropdownMenu.Portal>
|
|
||||||
<DropdownMenu.Content
|
|
||||||
align="end"
|
|
||||||
side="top"
|
|
||||||
className="rd-divider z-50 min-w-44 rounded-md border bg-[var(--rd-bg,#171512)] p-1 shadow-2xl"
|
|
||||||
style={{ color: "var(--rd-fg,#c6c0b6)" }}
|
|
||||||
>
|
|
||||||
<DropdownMenu.CheckboxItem
|
|
||||||
className="rd-row rd-highlight cursor-pointer"
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
|
||||||
checked={continuous}
|
|
||||||
onCheckedChange={(v) => setContinuous(!!v)}
|
|
||||||
>
|
>
|
||||||
<BookOpenCheck size={14} /> 连读(滑动跨章)
|
<ChevronLeft size={14} /> 上一章
|
||||||
<DropdownMenu.ItemIndicator style={{ marginLeft: "auto" }}>
|
</Button>
|
||||||
<Check size={14} />
|
<Button
|
||||||
</DropdownMenu.ItemIndicator>
|
variant="ghost"
|
||||||
</DropdownMenu.CheckboxItem>
|
size="sm"
|
||||||
{locked && (
|
disabled={ci === chapters.length - 1}
|
||||||
<RdSlider
|
onClick={() => goPage(chapters[ci + 1].start)}
|
||||||
label="预读:锁章末尾时提前挂载其后 N 章"
|
>
|
||||||
ariaLabel="预读章数"
|
下一章 <ChevronRight size={14} />
|
||||||
min={0}
|
</Button>
|
||||||
max={3}
|
</>
|
||||||
value={prefetch}
|
)}
|
||||||
onChange={setPrefetch}
|
</>
|
||||||
/>
|
}
|
||||||
)}
|
extra={
|
||||||
</DropdownMenu.Content>
|
<>
|
||||||
</DropdownMenu.Portal>
|
<SettingRow label="翻页模式">
|
||||||
</DropdownMenu.Root>
|
{CBZ_MODES.map((m) => (
|
||||||
)}
|
<Button key={m} variant={mode === m ? "secondary" : "ghost"} size="xs" onClick={() => setMode(m)}>
|
||||||
</div>
|
{CBZ_MODE_LABEL[m]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
{mode !== "strip" && (
|
||||||
|
<SettingRow label="阅读方向">
|
||||||
|
<Button variant={rtl ? "secondary" : "ghost"} size="xs" onClick={() => patchCbz({ rtl: !rtl })}>
|
||||||
|
右开本(RTL)
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
{chapters && chapters.length >= 2 && (
|
||||||
|
<SettingRow label="连读(跨章)">
|
||||||
|
<Button
|
||||||
|
variant={continuous ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => patchCbz({ continuous: !continuous })}
|
||||||
|
>
|
||||||
|
{continuous ? "开" : "关"}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
{locked && (
|
||||||
|
<SettingRow label="预读章数">
|
||||||
|
<Slider
|
||||||
|
className="w-32"
|
||||||
|
min={0}
|
||||||
|
max={3}
|
||||||
|
step={1}
|
||||||
|
value={[prefetch]}
|
||||||
|
onValueChange={([v]) => patchCbz({ prefetch: v })}
|
||||||
|
aria-label="预读章数"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-stone-900/85 px-3 py-1.5 text-xs tabular-nums text-stone-200 shadow-lg ring-1 ring-stone-700/60 backdrop-blur">
|
<div className="pointer-events-none absolute bottom-3 right-3 flex items-center gap-2 rounded-full bg-background/85 px-3 py-1.5 text-xs tabular-nums text-foreground shadow-lg ring-1 ring-border backdrop-blur">
|
||||||
<span>
|
<span>
|
||||||
{cur + 1}/{count}
|
{cur + 1}/{count}
|
||||||
</span>
|
</span>
|
||||||
@@ -486,7 +549,10 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
<li key={i}>
|
<li key={i}>
|
||||||
<button
|
<button
|
||||||
ref={i === ci ? activeRow : undefined}
|
ref={i === ci ? activeRow : undefined}
|
||||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
className={
|
||||||
|
"min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" +
|
||||||
|
(i === ci ? " bg-accent text-accent-foreground" : "")
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
goPage(c.start);
|
goPage(c.start);
|
||||||
setNav(null);
|
setNav(null);
|
||||||
@@ -504,3 +570,169 @@ export default function CbzReader({ book, initialLocator, initialPercent, chrome
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Chrome = { on: boolean; toggle: () => void; show: () => void };
|
||||||
|
|
||||||
|
/** 页/双页横向阅读:章锁=章末卡片止步,连读=自动进下一章;RTL 翻转翻页方向;视图配对手动按 [2i,2i+1]。 */
|
||||||
|
function PageView({
|
||||||
|
count,
|
||||||
|
chapters,
|
||||||
|
locked,
|
||||||
|
ci,
|
||||||
|
setCi,
|
||||||
|
continuous,
|
||||||
|
fmt,
|
||||||
|
rtl,
|
||||||
|
mode,
|
||||||
|
saver,
|
||||||
|
chrome,
|
||||||
|
pvSeek,
|
||||||
|
onSeekDone,
|
||||||
|
onCur,
|
||||||
|
onTurnHook,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
chapters: { title: string; start: number }[] | null;
|
||||||
|
locked: boolean;
|
||||||
|
ci: number;
|
||||||
|
setCi: (i: number) => void;
|
||||||
|
continuous: boolean;
|
||||||
|
fmt: string;
|
||||||
|
rtl: boolean;
|
||||||
|
mode: PageMode;
|
||||||
|
saver: ReturnType<typeof useProgressSaver>;
|
||||||
|
chrome: Chrome;
|
||||||
|
pvSeek: { page: number; n: number } | null;
|
||||||
|
onSeekDone: () => void;
|
||||||
|
onCur: (p: number) => void;
|
||||||
|
onTurnHook: { current: ((d: number) => void) | null };
|
||||||
|
}) {
|
||||||
|
// 章窗口:locked 时视图范围 = 本章页;continuous/扁平 = 全书
|
||||||
|
const win =
|
||||||
|
locked && chapters
|
||||||
|
? { start: chapters[ci].start, end: ci + 1 < chapters.length ? chapters[ci + 1].start : count }
|
||||||
|
: { start: 0, end: count };
|
||||||
|
const winViews = viewCount(Math.max(0, win.end - win.start), mode);
|
||||||
|
const [v, setV] = useState(0);
|
||||||
|
const [showEndCard, setShowEndCard] = useState(false);
|
||||||
|
const pend = useRef<number | null>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [cw, setCw] = useState(0);
|
||||||
|
|
||||||
|
// 容器宽度(单页=宽度全用,双页=对半)
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(() => setCw(el.clientWidth));
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
const pageW = mode === "spread" ? Math.min(Math.floor(cw / 2), MAX_W) : Math.min(cw, MAX_W);
|
||||||
|
|
||||||
|
// 外部跳页(pvSeek):锁章时切章,等 ci 生效再落位(pending ref 模式,与 strip 路径同一惯性)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pvSeek == null) return;
|
||||||
|
pend.current = pvSeek.page;
|
||||||
|
if (locked && chapters) setCi(chapterIndexAt(chapters, pvSeek.page));
|
||||||
|
onSeekDone();
|
||||||
|
}, [pvSeek, locked, chapters, setCi, onSeekDone]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pend.current === null || winViews === 0) return;
|
||||||
|
const t = pend.current;
|
||||||
|
pend.current = null;
|
||||||
|
setShowEndCard(false);
|
||||||
|
const at = Math.max(win.start, Math.min(win.end - 1, t));
|
||||||
|
setV(pageIndexToView(at - win.start, mode));
|
||||||
|
}, [pvSeek, ci, winViews, win.start, win.end, mode]);
|
||||||
|
|
||||||
|
const page0 = win.start + viewToPageIndex(v, mode);
|
||||||
|
// 当前页由 PageView 上报(供滑条/HUD/位置文本);进度随页落(与长卷路径同一 report 语义)
|
||||||
|
useEffect(() => {
|
||||||
|
onCur(page0);
|
||||||
|
saver.report({ page: page0 }, (page0 + 1) / count);
|
||||||
|
}, [page0, onCur]); // saver/report 每页一次,依赖从简(同 fetchObjectUrl 前载语义)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 相邻页预载(±2)
|
||||||
|
for (const p of [page0 - 2, page0 + 2])
|
||||||
|
if (p >= 0 && p < count) fetchObjectUrl(formatPageUrl(fmt, p)).catch(() => {});
|
||||||
|
}, [page0, fmt, count]);
|
||||||
|
|
||||||
|
function turn(d: number) {
|
||||||
|
chrome.show();
|
||||||
|
const fwd = rtl ? d < 0 : d > 0;
|
||||||
|
if (showEndCard) {
|
||||||
|
if (fwd) advanceChapter();
|
||||||
|
else {
|
||||||
|
setShowEndCard(false);
|
||||||
|
setV(winViews - 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nv = turnView(v, d, rtl, winViews);
|
||||||
|
if (nv !== v) {
|
||||||
|
setV(nv);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fwd && v >= winViews - 1) {
|
||||||
|
// 视图尽头:章末卡片(锁章止步/全书完)或跨章
|
||||||
|
if (win.end >= count) setShowEndCard(true);
|
||||||
|
else if (locked && !continuous) setShowEndCard(true);
|
||||||
|
else advanceChapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function advanceChapter() {
|
||||||
|
if (!chapters || ci + 1 >= chapters.length) return; // 全书完:停在末页
|
||||||
|
setCi(ci + 1);
|
||||||
|
setShowEndCard(false);
|
||||||
|
setV(0);
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
onTurnHook.current = turn;
|
||||||
|
return () => {
|
||||||
|
onTurnHook.current = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function onZone(e: MouseEvent<HTMLDivElement>) {
|
||||||
|
if ((e.target as HTMLElement).closest("button,input,nav,a")) return;
|
||||||
|
if (window.getSelection()?.toString()) return;
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = (e.clientX - r.left) / r.width;
|
||||||
|
if (x < 0.3) turn(-1);
|
||||||
|
else if (x > 0.7) turn(1);
|
||||||
|
else chrome.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = spreadPages(page0, win.end, rtl);
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 手势区(两侧翻页/中间收放 chrome);键盘翻页由外层根容器统一提供
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 容器可聚焦以接键盘翻页;与长卷滚动容器同一「滚动区可聚焦」理由
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label="漫画页阅读视图"
|
||||||
|
className="relative h-full cursor-pointer select-none overflow-hidden"
|
||||||
|
style={{ background: "var(--rd-bg)", color: "var(--rd-fg)" }}
|
||||||
|
onClick={onZone}
|
||||||
|
>
|
||||||
|
{count < 1 || winViews < 1 ? null : showEndCard ? (
|
||||||
|
<div className="grid h-full w-full place-items-center">
|
||||||
|
{win.end >= count ? (
|
||||||
|
<span className="text-sm text-muted-foreground">— 全书完 —</span>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" onClick={advanceChapter}>
|
||||||
|
本章完 · 下一章 <ChevronRight size={14} className="inline" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`flex h-full w-full items-center justify-center ${mode === "spread" ? "gap-1" : ""}`}>
|
||||||
|
{pages.map((p) => (
|
||||||
|
<PageImg key={p} url={formatPageUrl(fmt, p)} width={pageW} onLoaded={() => {}} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { apiRaw } from "../api/client";
|
import { apiRaw } from "../api/client";
|
||||||
import { BookmarksTab } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
import { ReaderNav } from "../components/reader-nav";
|
import { ReaderNav } from "../components/reader-nav";
|
||||||
import { btn } from "../components/ui";
|
import { ReaderSettings, SettingRow } from "../components/reader-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "../components/Toaster";
|
import { toast } from "../components/Toaster";
|
||||||
|
import { useGlobalDark } from "../components/theme";
|
||||||
|
import { FONT_SIZES, LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
// 仅声明用到的最小子集,避免依赖 epubjs 自带类型的完整性
|
||||||
@@ -16,6 +19,10 @@ interface EpubRendition {
|
|||||||
next(): unknown;
|
next(): unknown;
|
||||||
prev(): unknown;
|
prev(): unknown;
|
||||||
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
|
on(ev: "relocated", cb: (loc: EpubLocation) => void): void;
|
||||||
|
themes: {
|
||||||
|
register(name: string, rules: Record<string, unknown>): void;
|
||||||
|
select(name: string): void;
|
||||||
|
};
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
}
|
}
|
||||||
interface EpubBookLike {
|
interface EpubBookLike {
|
||||||
@@ -30,10 +37,20 @@ async function loadEpub(): Promise<(data: Uint8Array) => EpubBookLike> {
|
|||||||
return m.default as (data: Uint8Array) => EpubBookLike;
|
return m.default as (data: Uint8Array) => EpubBookLike;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EpubReader({ book, initialLocator, initialPercent }: ReaderProps) {
|
/** epubjs iframe 取不到宿主 CSS 变量 → 三主题 bg/fg 用 index.css 的具体色值常量。 */
|
||||||
|
const THEME_CSS = {
|
||||||
|
paper: { bg: "#faf8f4", fg: "#292524" },
|
||||||
|
sepia: { bg: "#f0e6d2", fg: "#433a29" },
|
||||||
|
night: { bg: "#171512", fg: "#c6c0b6" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function EpubReader({ book, initialLocator, initialPercent }: Omit<ReaderProps, "chrome">) {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const rendRef = useRef<EpubRendition | null>(null);
|
const rendRef = useRef<EpubRendition | null>(null);
|
||||||
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
const saver = useProgressSaver(book.id, { locator: initialLocator, percent: initialPercent });
|
||||||
|
const pr = useReaderPrefs();
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
const [nav, setNav] = useState(false);
|
const [nav, setNav] = useState(false);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [nonce, setNonce] = useState(0);
|
const [nonce, setNonce] = useState(0);
|
||||||
@@ -89,42 +106,77 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
|||||||
};
|
};
|
||||||
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [book.file_url, nonce]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 排版/主题映射:rd 或 prefs.epub 变化时重新 register+select(iframe 内须用具体色值)
|
||||||
|
useEffect(() => {
|
||||||
|
const r = rendRef.current;
|
||||||
|
if (!r || !ready) return;
|
||||||
|
const { bg, fg } = THEME_CSS[rd];
|
||||||
|
r.themes.register("booklib", {
|
||||||
|
body: {
|
||||||
|
background: bg,
|
||||||
|
color: fg,
|
||||||
|
"line-height": String(LINE_HEIGHTS[pr.prefs.epub.lineIdx]),
|
||||||
|
"font-size": `${FONT_SIZES[pr.prefs.epub.sizeIdx]}px`,
|
||||||
|
padding: `${[0.5, 1, 1.75][pr.prefs.epub.marginIdx]}rem 0`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
r.themes.select("booklib");
|
||||||
|
}, [rd, pr.prefs.epub, ready]);
|
||||||
|
|
||||||
if (err)
|
if (err)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="bg-muted grid h-full place-items-center p-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-red-400">{err}</p>
|
<p className="text-destructive mb-3">{err}</p>
|
||||||
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
|
<Button variant="secondary" onClick={() => setNonce((x) => x + 1)}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative grid h-full grid-rows-[1fr_auto]">
|
<div className="relative grid h-full grid-rows-[1fr_auto] bg-background text-foreground" data-rd={rd}>
|
||||||
<div className="relative mx-auto min-h-0 w-full max-w-4xl bg-white shadow-2xl ring-1 ring-stone-800">
|
<div className="bg-background ring-border relative mx-auto min-h-0 w-full max-w-4xl shadow-2xl ring-1">
|
||||||
<div ref={hostRef} className="absolute inset-0" />
|
<div ref={hostRef} className="absolute inset-0" style={{ background: "var(--rd-bg)" }} />
|
||||||
{!ready && !err && (
|
{!ready && !err && (
|
||||||
<div className="grid h-full place-items-center bg-stone-950 text-stone-500">EPUB 加载中…</div>
|
<div className="bg-muted text-muted-foreground grid h-full place-items-center">EPUB 加载中…</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-center gap-3 border-t border-stone-800 px-4 py-2 text-sm">
|
<ReaderSettings
|
||||||
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.prev()}>
|
themeMode={pr.prefs.themeMode}
|
||||||
<ChevronLeft size={14} /> 上一页
|
onThemeMode={pr.setThemeMode}
|
||||||
</button>
|
left={
|
||||||
<button className={btn + " inline-flex items-center gap-0.5"} onClick={() => rendRef.current?.next()}>
|
<>
|
||||||
下一页 <ChevronRight size={14} />
|
<Button variant="ghost" size="sm" className="gap-0.5" onClick={() => rendRef.current?.prev()}>
|
||||||
</button>
|
<ChevronLeft size={14} /> 上一页
|
||||||
<button
|
</Button>
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
<Button variant="ghost" size="sm" className="gap-0.5" onClick={() => rendRef.current?.next()}>
|
||||||
aria-expanded={nav}
|
下一页 <ChevronRight size={14} />
|
||||||
aria-controls="reader-nav"
|
</Button>
|
||||||
onClick={() => setNav((v) => !v)}
|
<Button
|
||||||
>
|
variant="ghost"
|
||||||
<PanelLeft size={14} /> 导航
|
size="sm"
|
||||||
</button>
|
className="gap-1.5"
|
||||||
</div>
|
aria-expanded={nav}
|
||||||
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav((v) => !v)}
|
||||||
|
>
|
||||||
|
<PanelLeft size={14} /> 导航
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
showFont
|
||||||
|
onFont={pr.bumpEpub}
|
||||||
|
fontIdx={pr.prefs.epub.sizeIdx}
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<FontSizeRow pr={pr} />
|
||||||
|
<LineHeightRow pr={pr} />
|
||||||
|
<MarginRow pr={pr} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{nav && (
|
{nav && (
|
||||||
<ReaderNav
|
<ReaderNav
|
||||||
tab="bm"
|
tab="bm"
|
||||||
@@ -146,3 +198,55 @@ export default function EpubReader({ book, initialLocator, initialPercent }: Rea
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 字号档位按钮组(EPUB 独立档位,存 prefs.epub.sizeIdx)。 */
|
||||||
|
function FontSizeRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
|
||||||
|
return (
|
||||||
|
<SettingRow label="字号">
|
||||||
|
{FONT_SIZES.map((fs, i) => (
|
||||||
|
<Button
|
||||||
|
key={fs}
|
||||||
|
variant={pr.prefs.epub.sizeIdx === i ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, sizeIdx: i } }))}
|
||||||
|
>
|
||||||
|
{fs}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** EPUB 行距/边距档位(存 prefs.epub)。 */
|
||||||
|
function LineHeightRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
|
||||||
|
return (
|
||||||
|
<SettingRow label="行距">
|
||||||
|
{LINE_HEIGHTS.map((lh, i) => (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
variant={pr.prefs.epub.lineIdx === i ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, lineIdx: i } }))}
|
||||||
|
>
|
||||||
|
{lh}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function MarginRow({ pr }: { pr: ReturnType<typeof useReaderPrefs> }) {
|
||||||
|
return (
|
||||||
|
<SettingRow label="边距">
|
||||||
|
{MARGINS.map((m, i) => (
|
||||||
|
<Button
|
||||||
|
key={m}
|
||||||
|
variant={pr.prefs.epub.marginIdx === i ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => pr.update((p) => ({ ...p, epub: { ...p.epub, marginIdx: i } }))}
|
||||||
|
>
|
||||||
|
{MARGIN_LABEL[m]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import * as pdfjs from "pdfjs-dist";
|
|||||||
import { apiRaw } from "../api/client";
|
import { apiRaw } from "../api/client";
|
||||||
import { BookmarksTab } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
import { ReaderNav } from "../components/reader-nav";
|
import { ReaderNav } from "../components/reader-nav";
|
||||||
import { btn } from "../components/ui";
|
import { ReaderSettings } from "../components/reader-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
// Vite 原生 worker 语法;pdfjs v5 要求 module worker
|
// Vite 原生 worker 语法;pdfjs v5 要求 module worker
|
||||||
@@ -124,70 +125,86 @@ export default function PdfReader({ book, initialLocator, initialPercent }: Read
|
|||||||
|
|
||||||
if (err)
|
if (err)
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="bg-muted grid h-full place-items-center p-8 text-center">
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-red-400">{err}</p>
|
<p className="text-destructive mb-3">{err}</p>
|
||||||
<button className={btn} onClick={() => setNonce((x) => x + 1)}>
|
<Button variant="secondary" onClick={() => setNonce((x) => x + 1)}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
if (num === 0) return <div className="grid h-full place-items-center text-stone-500">PDF 加载中…</div>;
|
if (num === 0) return <div className="text-muted-foreground grid h-full place-items-center">PDF 加载中…</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- 根容器接键盘翻页;交互语义由 aria-label 说明
|
||||||
<div
|
<div
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- 根容器聚焦以接方向键翻页;「滚动区可聚焦」通行做法
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={onKey}
|
onKeyDown={onKey}
|
||||||
aria-label="PDF 阅读器,可用左右方向键翻页"
|
aria-label="PDF 阅读器,可用左右方向键翻页"
|
||||||
className="relative grid h-full grid-rows-[1fr_auto] focus:outline-none"
|
className="relative grid h-full grid-rows-[1fr_auto] bg-background text-foreground focus:outline-none focus-visible:outline-hidden"
|
||||||
>
|
>
|
||||||
<div ref={wrapRef} className="flex min-h-0 items-start justify-center overflow-y-auto bg-stone-900/70 p-4">
|
<div ref={wrapRef} className="bg-muted/50 flex min-h-0 items-start justify-center overflow-y-auto p-4">
|
||||||
<canvas ref={canvasRef} className="max-w-3xl rounded-md shadow-2xl ring-1 ring-stone-700/50" />
|
<canvas ref={canvasRef} className="ring-border max-w-3xl rounded-md shadow-2xl ring-1" />
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-center gap-2 border-t border-stone-800/80 px-4 py-2 text-sm">
|
|
||||||
<button
|
|
||||||
className={btn + " inline-flex items-center gap-0.5"}
|
|
||||||
disabled={page === 0}
|
|
||||||
onClick={() => setPage(0)}
|
|
||||||
aria-label="第一页"
|
|
||||||
>
|
|
||||||
<ChevronsLeft size={14} /> 首页
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={btn + " inline-flex items-center gap-0.5"}
|
|
||||||
disabled={page === 0}
|
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={14} /> 上一页
|
|
||||||
</button>
|
|
||||||
<span className="min-w-16 text-center tabular-nums text-stone-400">
|
|
||||||
{page + 1}/{num}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className={btn + " inline-flex items-center gap-0.5"}
|
|
||||||
disabled={page >= num - 1}
|
|
||||||
onClick={() => setPage((p) => Math.min(num - 1, p + 1))}
|
|
||||||
>
|
|
||||||
下一页 <ChevronRight size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className={btn + " inline-flex items-center gap-0.5"}
|
|
||||||
disabled={page >= num - 1}
|
|
||||||
onClick={() => setPage(num - 1)}
|
|
||||||
aria-label="最后一页"
|
|
||||||
>
|
|
||||||
<ChevronsRight size={14} /> 末页
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
|
||||||
aria-expanded={nav}
|
|
||||||
aria-controls="reader-nav"
|
|
||||||
onClick={() => setNav((v) => !v)}
|
|
||||||
>
|
|
||||||
<PanelLeft size={14} /> 导航
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ReaderSettings
|
||||||
|
left={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-0.5"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage(0)}
|
||||||
|
aria-label="第一页"
|
||||||
|
>
|
||||||
|
<ChevronsLeft size={14} /> 首页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-0.5"
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={14} /> 上一页
|
||||||
|
</Button>
|
||||||
|
<span className="min-w-16 text-center text-muted-foreground tabular-nums">
|
||||||
|
{page + 1}/{num}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-0.5"
|
||||||
|
disabled={page >= num - 1}
|
||||||
|
onClick={() => setPage((p) => Math.min(num - 1, p + 1))}
|
||||||
|
>
|
||||||
|
下一页 <ChevronRight size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-0.5"
|
||||||
|
disabled={page >= num - 1}
|
||||||
|
onClick={() => setPage(num - 1)}
|
||||||
|
aria-label="最后一页"
|
||||||
|
>
|
||||||
|
<ChevronsRight size={14} /> 末页
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
aria-expanded={nav}
|
||||||
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav((v) => !v)}
|
||||||
|
>
|
||||||
|
<PanelLeft size={14} /> 导航
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{nav && (
|
{nav && (
|
||||||
<ReaderNav
|
<ReaderNav
|
||||||
tab="bm"
|
tab="bm"
|
||||||
|
|||||||
+242
-130
@@ -1,20 +1,20 @@
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
|
import { ChevronLeft, ChevronRight, PanelLeft } from "lucide-react";
|
||||||
import { useEffect, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
||||||
import { apiRaw } from "../api/client";
|
import { apiRaw } from "../api/client";
|
||||||
import { BookmarksTab } from "../components/Bookmarks";
|
import { BookmarksTab } from "../components/Bookmarks";
|
||||||
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
import { ReaderNav, type NavTab } from "../components/reader-nav";
|
||||||
import { btn } from "../components/ui";
|
import { ReaderSettings, SettingRow } from "../components/reader-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { useGlobalDark } from "../components/theme";
|
||||||
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
import { chapterText, seekChapter, splitChapters } from "../lib/chapters";
|
||||||
import { decodeText } from "../lib/encoding";
|
import { decodeText } from "../lib/encoding";
|
||||||
import { FONT_SIZES, RD_THEMES, RD_THEME_LABEL, useReaderPrefs, type RdTheme } from "../lib/readerPrefs";
|
import { LINE_HEIGHTS, MARGINS, MARGIN_LABEL, resolveRdTheme, useReaderPrefs } from "../lib/readerPrefs";
|
||||||
|
import { searchChapters, splitHighlight, type SearchHit } from "../lib/search";
|
||||||
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
import { useProgressSaver, type ReaderProps } from "../lib/useProgress";
|
||||||
|
|
||||||
type Prefs = ReturnType<typeof useReaderPrefs>;
|
|
||||||
|
|
||||||
const SWATCH: Record<RdTheme, string> = { paper: "#faf8f4", sepia: "#f0e6d2", night: "#171512" };
|
|
||||||
|
|
||||||
function useFileText(url: string) {
|
function useFileText(url: string) {
|
||||||
const [text, setText] = useState<string | null>(null);
|
const [text, setText] = useState<string | null>(null);
|
||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
@@ -31,9 +31,22 @@ function useFileText(url: string) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let dead = false;
|
let dead = false;
|
||||||
apiRaw(url)
|
apiRaw(url)
|
||||||
.then((r) => r.arrayBuffer())
|
.then((r) => {
|
||||||
.then((b) => !dead && setText(decodeText(b)))
|
if (dead) return;
|
||||||
.catch((e) => !dead && setErr(e instanceof Error ? e.message : "读取失败"));
|
if (!r.ok) {
|
||||||
|
r.text().then((t) => {
|
||||||
|
if (!dead) setErr(t || `HTTP ${r.status}`);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
r.arrayBuffer().then((buf) => {
|
||||||
|
if (dead) return;
|
||||||
|
setText(decodeText(buf));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (!dead) setErr(e instanceof Error ? e.message : "加载失败");
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
dead = true;
|
dead = true;
|
||||||
};
|
};
|
||||||
@@ -43,89 +56,19 @@ function useFileText(url: string) {
|
|||||||
|
|
||||||
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
function LoadState({ text, onRetry }: { text: string; onRetry?: () => void }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full place-items-center p-8 text-center">
|
<div className="grid h-full place-items-center p-8 text-center text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
{onRetry && <p className="mb-3 text-red-400">{text}</p>}
|
<p className="mb-3">{text}</p>
|
||||||
{!onRetry && <p className="text-stone-500">{text}</p>}
|
|
||||||
{onRetry && (
|
{onRetry && (
|
||||||
<button className={btn} onClick={onRetry}>
|
<Button variant="secondary" onClick={onRetry}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 沉浸态底栏:进度滑条 + 主题色卡 + 字号,主题内配色。children 放左半区按钮(目录/翻页)。 */
|
|
||||||
function ReaderSheet({
|
|
||||||
pr,
|
|
||||||
frac,
|
|
||||||
onSeek,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
pr: Prefs;
|
|
||||||
frac: number;
|
|
||||||
onSeek: (f: number) => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rd-sheet rd-divider relative z-20 shrink-0 border-t px-4 pb-[max(0.5rem,env(safe-area-inset-bottom))] pt-2">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={1000}
|
|
||||||
value={Math.round(frac * 1000)}
|
|
||||||
onChange={(e) => onSeek(Number(e.target.value) / 1000)}
|
|
||||||
className="rd-range"
|
|
||||||
aria-label="阅读进度"
|
|
||||||
/>
|
|
||||||
<span className="w-10 shrink-0 text-right text-xs tabular-nums opacity-70">{Math.round(frac * 100)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-1 flex items-center gap-2">
|
|
||||||
{children}
|
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
|
||||||
<div className="flex items-center gap-1.5" role="group" aria-label="阅读主题">
|
|
||||||
{RD_THEMES.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
aria-label={`${RD_THEME_LABEL[t]}色主题`}
|
|
||||||
aria-pressed={pr.prefs.theme === t}
|
|
||||||
onClick={() => pr.setTheme(t)}
|
|
||||||
className="h-5 w-5 rounded-full transition-transform hover:scale-110"
|
|
||||||
style={{
|
|
||||||
background: SWATCH[t],
|
|
||||||
boxShadow:
|
|
||||||
pr.prefs.theme === t
|
|
||||||
? "0 0 0 2px var(--rd-link)"
|
|
||||||
: "inset 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="rd-btn"
|
|
||||||
onClick={() => pr.bump(-1)}
|
|
||||||
disabled={pr.prefs.sizeIdx === 0}
|
|
||||||
aria-label="减小字号"
|
|
||||||
>
|
|
||||||
A−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="rd-btn"
|
|
||||||
onClick={() => pr.bump(1)}
|
|
||||||
disabled={pr.prefs.sizeIdx === FONT_SIZES.length - 1}
|
|
||||||
aria-label="加大字号"
|
|
||||||
>
|
|
||||||
A+
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
|
/** 点正文唤出/收起工具栏;拖选文本与控件点击不触发。 */
|
||||||
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
|
function bodyTogglesChrome(e: MouseEvent<HTMLElement>, toggle: () => void) {
|
||||||
if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
|
if ((e.target as HTMLElement).closest("a,button,input,nav")) return;
|
||||||
@@ -143,6 +86,8 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
const [nav, setNav] = useState(false);
|
const [nav, setNav] = useState(false);
|
||||||
const restored = useRef(false);
|
const restored = useRef(false);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
const [frac, setFrac] = useState(0);
|
const [frac, setFrac] = useState(0);
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
|
|
||||||
@@ -176,7 +121,8 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
if (err) return <LoadState text={err} onRetry={retry} />;
|
if (err) return <LoadState text={err} onRetry={retry} />;
|
||||||
if (text == null) return <LoadState text="正文加载中…" />;
|
if (text == null) return <LoadState text="正文加载中…" />;
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;正文滚轮/拖选不涉及键盘操作 */}
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
@@ -184,22 +130,44 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
className="min-h-0 flex-1 overflow-y-auto px-6 py-6"
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
className="md-body mx-auto max-w-[46rem] leading-[1.85]"
|
className="md-body mx-auto max-w-[46rem]"
|
||||||
style={{ fontSize: pr.fontSize }}
|
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight }}
|
||||||
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
dangerouslySetInnerHTML={{ __html: html ?? "" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{chrome.on && (
|
{chrome.on && (
|
||||||
<ReaderSheet pr={pr} frac={frac} onSeek={seek}>
|
<ReaderSettings
|
||||||
<button
|
themeMode={pr.prefs.themeMode}
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
onThemeMode={pr.setThemeMode}
|
||||||
aria-expanded={nav}
|
slider={{
|
||||||
aria-controls="reader-nav"
|
value: Math.round(frac * 1000),
|
||||||
onClick={() => setNav((v) => !v)}
|
max: 1000,
|
||||||
>
|
onChange: (v) => seek(v / 1000),
|
||||||
<PanelLeft size={14} /> 导航
|
ariaLabel: "阅读进度",
|
||||||
</button>
|
}}
|
||||||
</ReaderSheet>
|
position={`${Math.round(frac * 100)}%`}
|
||||||
|
left={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1.5"
|
||||||
|
aria-expanded={nav}
|
||||||
|
aria-controls="reader-nav"
|
||||||
|
onClick={() => setNav((v) => !v)}
|
||||||
|
>
|
||||||
|
<PanelLeft size={14} /> 导航
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
showFont
|
||||||
|
onFont={pr.bumpText}
|
||||||
|
fontIdx={pr.prefs.text.sizeIdx}
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<LineHeightRow pr={pr} />
|
||||||
|
<MarginRow pr={pr} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{nav && (
|
{nav && (
|
||||||
<ReaderNav
|
<ReaderNav
|
||||||
@@ -221,6 +189,40 @@ function MdView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 行距五档按钮组(text/epub 各自消费 lineIdx)。 */
|
||||||
|
function LineHeightRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; epub?: boolean }) {
|
||||||
|
const cur = epub ? pr.prefs.epub.lineIdx : pr.prefs.text.lineIdx;
|
||||||
|
const patch = (i: number) =>
|
||||||
|
pr.update((p) => (epub ? { ...p, epub: { ...p.epub, lineIdx: i } } : { ...p, text: { ...p.text, lineIdx: i } }));
|
||||||
|
return (
|
||||||
|
<SettingRow label="行距">
|
||||||
|
{LINE_HEIGHTS.map((lh, i) => (
|
||||||
|
<Button key={i} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
|
||||||
|
{lh}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 边距三档按钮组(text/epub 各自消费 margin/marginIdx)。 */
|
||||||
|
function MarginRow({ pr, epub = false }: { pr: ReturnType<typeof useReaderPrefs>; epub?: boolean }) {
|
||||||
|
const cur = epub ? pr.prefs.epub.marginIdx : MARGINS.indexOf(pr.prefs.text.margin);
|
||||||
|
const patch = (i: number) =>
|
||||||
|
pr.update((p) =>
|
||||||
|
epub ? { ...p, epub: { ...p.epub, marginIdx: i } } : { ...p, text: { ...p.text, margin: MARGINS[i] } },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<SettingRow label="边距">
|
||||||
|
{MARGINS.map((m, i) => (
|
||||||
|
<Button key={m} variant={cur === i ? "secondary" : "ghost"} size="xs" onClick={() => patch(i)}>
|
||||||
|
{MARGIN_LABEL[m]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</SettingRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
// 整本塞一个 <pre> 会冻死浏览器:按章渲染,一章一个滚动块。
|
||||||
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps) {
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -232,7 +234,11 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
const [ci, setCi] = useState(0);
|
const [ci, setCi] = useState(0);
|
||||||
const [frac, setFrac] = useState(0);
|
const [frac, setFrac] = useState(0);
|
||||||
const [nav, setNav] = useState<null | NavTab>(null);
|
const [nav, setNav] = useState<null | NavTab>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||||
const pr = useReaderPrefs();
|
const pr = useReaderPrefs();
|
||||||
|
const globalDark = useGlobalDark();
|
||||||
|
const rd = resolveRdTheme(pr.prefs.themeMode, globalDark);
|
||||||
const { text, err, retry } = useFileText(book.file_url ?? "");
|
const { text, err, retry } = useFileText(book.file_url ?? "");
|
||||||
|
|
||||||
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
const chapters = useMemo(() => (text == null ? null : splitChapters(text)), [text]);
|
||||||
@@ -245,7 +251,7 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
const rawCh = Number(initialLocator?.ch);
|
const rawCh = Number(initialLocator?.ch);
|
||||||
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
|
if (Number.isFinite(rawCh) && rawCh >= 0 && rawCh < chapters.length) {
|
||||||
targetCi.current = Math.floor(rawCh);
|
targetCi.current = Math.floor(rawCh);
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- 首章渲染后按缓存 locator 落位阅读进度;ci 与下方滚动定位 effect/refs 协作,改渲染期重置属高风险重构,保守保留
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- 首章渲染后按缓存 locator 落位阅读进度:ci 落位与下方滚动定位 effect/refs 协作,生命周期与恢复进度语义同源,保留带理由
|
||||||
setCi(Math.floor(rawCh));
|
setCi(Math.floor(rawCh));
|
||||||
if (hasF) restoreFrac.current = f;
|
if (hasF) restoreFrac.current = f;
|
||||||
} else if (hasF) {
|
} else if (hasF) {
|
||||||
@@ -269,6 +275,15 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
if (nav === "toc") activeRow.current?.scrollIntoView({ block: "center" });
|
||||||
}, [nav]);
|
}, [nav]);
|
||||||
|
|
||||||
|
// 全书搜索:250ms 防抖,空 query 在同一 timer 内清空结果(避免 effect 内同步 setState)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!chapters || !text) return;
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
setHits(query.trim() ? searchChapters(text, chapters, query) : null);
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [query, chapters, text]);
|
||||||
|
|
||||||
function goChapter(i: number, intra = 0) {
|
function goChapter(i: number, intra = 0) {
|
||||||
if (!chapters) return;
|
if (!chapters) return;
|
||||||
restoreFrac.current = intra > 0 ? intra : null;
|
restoreFrac.current = intra > 0 ? intra : null;
|
||||||
@@ -299,7 +314,8 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
const total = chapters.length > 1 ? Math.min(1, (ci + frac) / chapters.length) : frac;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rd-surface relative flex h-full flex-col" data-rd={pr.prefs.theme}>
|
<div className="rd-surface relative flex h-full flex-col" data-rd={rd}>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- 点正文唤出/收起工具栏的手势区;点按是唯一唤出途径,键盘用户由常驻工具条提供全部控制 */}
|
||||||
<div
|
<div
|
||||||
ref={boxRef}
|
ref={boxRef}
|
||||||
onScroll={onScroll}
|
onScroll={onScroll}
|
||||||
@@ -307,44 +323,65 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
className="min-h-0 flex-1 overflow-y-auto px-5 py-6 sm:px-6"
|
||||||
>
|
>
|
||||||
<pre
|
<pre
|
||||||
className="mx-auto max-w-[34em] break-words whitespace-pre-wrap"
|
className="mx-auto break-words whitespace-pre-wrap"
|
||||||
style={{ fontSize: pr.fontSize, lineHeight: 1.9, textAlign: "justify" }}
|
style={{ fontSize: pr.fontSize, lineHeight: pr.lineHeight, textAlign: "justify", maxWidth: pr.marginMaxW }}
|
||||||
>
|
>
|
||||||
{chapterText(text, chapters, ci)}
|
{chapterText(text, chapters, ci)}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
{chrome.on && (
|
{chrome.on && (
|
||||||
<ReaderSheet pr={pr} frac={total} onSeek={seek}>
|
<ReaderSettings
|
||||||
<button
|
themeMode={pr.prefs.themeMode}
|
||||||
className="rd-btn inline-flex items-center gap-1.5"
|
onThemeMode={pr.setThemeMode}
|
||||||
aria-expanded={!!nav}
|
slider={{
|
||||||
aria-controls="reader-nav"
|
value: Math.round(total * 1000),
|
||||||
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
|
max: 1000,
|
||||||
>
|
onChange: (v) => seek(v / 1000),
|
||||||
<PanelLeft size={14} /> 导航
|
ariaLabel: "阅读进度",
|
||||||
</button>
|
}}
|
||||||
{chapters.length > 1 && (
|
position={`${Math.round(total * 100)}%`}
|
||||||
|
left={
|
||||||
<>
|
<>
|
||||||
<span className="text-xs opacity-60 tabular-nums">
|
<Button
|
||||||
{ci + 1}/{chapters.length}
|
variant="ghost"
|
||||||
</span>
|
size="sm"
|
||||||
<button
|
className="gap-1.5"
|
||||||
className="rd-btn inline-flex items-center gap-0.5"
|
aria-expanded={!!nav}
|
||||||
disabled={ci === 0}
|
aria-controls="reader-nav"
|
||||||
onClick={() => goChapter(ci - 1)}
|
onClick={() => setNav(nav ? null : chapters.length > 1 ? "toc" : "bm")}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={14} /> 上一章
|
<PanelLeft size={14} /> 导航
|
||||||
</button>
|
</Button>
|
||||||
<button
|
{chapters.length > 1 && (
|
||||||
className="rd-btn inline-flex items-center gap-0.5"
|
<>
|
||||||
disabled={ci === chapters.length - 1}
|
<span className="text-xs tabular-nums text-muted-foreground">
|
||||||
onClick={() => goChapter(ci + 1)}
|
{ci + 1}/{chapters.length}
|
||||||
>
|
</span>
|
||||||
下一章 <ChevronRight size={14} />
|
<Button variant="ghost" size="sm" disabled={ci === 0} onClick={() => goChapter(ci - 1)}>
|
||||||
</button>
|
<ChevronLeft size={14} /> 上一章
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={ci === chapters.length - 1}
|
||||||
|
onClick={() => goChapter(ci + 1)}
|
||||||
|
>
|
||||||
|
下一章 <ChevronRight size={14} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
}
|
||||||
</ReaderSheet>
|
showFont
|
||||||
|
onFont={pr.bumpText}
|
||||||
|
fontIdx={pr.prefs.text.sizeIdx}
|
||||||
|
extra={
|
||||||
|
<>
|
||||||
|
<LineHeightRow pr={pr} />
|
||||||
|
<MarginRow pr={pr} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{nav && (
|
{nav && (
|
||||||
<ReaderNav
|
<ReaderNav
|
||||||
@@ -361,6 +398,19 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
search={
|
||||||
|
<SearchPanel
|
||||||
|
query={query}
|
||||||
|
hits={hits}
|
||||||
|
chapters={chapters}
|
||||||
|
onQuery={setQuery}
|
||||||
|
onJump={(h) => {
|
||||||
|
const len = chapterText(text, chapters, h.ch).length;
|
||||||
|
goChapter(h.ch, Math.min(1, Math.max(0, h.pos / Math.max(1, len))));
|
||||||
|
setNav(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
toc={
|
toc={
|
||||||
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
|
/* ponytail: 目录一次渲染全部章节,数千章级书若卡顿再上虚拟列表 */
|
||||||
chapters.length > 1 && (
|
chapters.length > 1 && (
|
||||||
@@ -369,7 +419,10 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
<li key={i}>
|
<li key={i}>
|
||||||
<button
|
<button
|
||||||
ref={i === ci ? activeRow : undefined}
|
ref={i === ci ? activeRow : undefined}
|
||||||
className={"rd-row" + (i === ci ? " rd-btn-on" : "")}
|
className={
|
||||||
|
"min-w-0 flex-1 truncate rounded-md px-2.5 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground" +
|
||||||
|
(i === ci ? " bg-accent text-accent-foreground" : "")
|
||||||
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
goChapter(i);
|
goChapter(i);
|
||||||
setNav(null);
|
setNav(null);
|
||||||
@@ -387,3 +440,62 @@ function TxtView({ book, initialLocator, initialPercent, chrome }: ReaderProps)
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 搜索面板(导航抽屉「搜索」选项卡):防抖输入 + 章节/片段结果,点击跳到命中章的大致位置。 */
|
||||||
|
function SearchPanel({
|
||||||
|
query,
|
||||||
|
hits,
|
||||||
|
chapters,
|
||||||
|
onQuery,
|
||||||
|
onJump,
|
||||||
|
}: {
|
||||||
|
query: string;
|
||||||
|
hits: SearchHit[] | null;
|
||||||
|
chapters: { title: string; start: number }[];
|
||||||
|
onQuery: (q: string) => void;
|
||||||
|
onJump: (h: SearchHit) => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []); // autofocus 以 effect 实现,规避 jsx-a11y/no-autofocus 与打断首帧渲染
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder="搜索全书"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => onQuery(e.target.value)}
|
||||||
|
aria-label="搜索全书"
|
||||||
|
/>
|
||||||
|
<div className="mt-2 min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{hits == null && <p className="p-2 text-xs text-muted-foreground">输入关键词检索全书</p>}
|
||||||
|
{hits != null && hits.length === 0 && <p className="p-2 text-xs text-muted-foreground">无结果</p>}
|
||||||
|
{hits != null && hits.length > 0 && (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{hits.map((h, i) => {
|
||||||
|
const { before, match, after } = splitHighlight(h.snippet, h.matchStart, h.matchLen);
|
||||||
|
return (
|
||||||
|
<li key={`${h.ch}-${h.pos}-${i}`}>
|
||||||
|
<button
|
||||||
|
className="w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
|
onClick={() => onJump(h)}
|
||||||
|
>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{i + 1}. {chapters[h.ch].title}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate">
|
||||||
|
{before}
|
||||||
|
<mark className="rounded px-0.5 bg-accent text-accent-foreground">{match}</mark>
|
||||||
|
{after}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { spreadPages, pageIndexToView, turnView, viewCount, viewToPageIndex } from "../src/lib/cbzPages";
|
||||||
|
|
||||||
|
describe("cbzPages", () => {
|
||||||
|
it("page mode: one page per view", () => {
|
||||||
|
expect(viewCount(5, "page")).toBe(5);
|
||||||
|
expect(viewToPageIndex(2, "page")).toBe(2);
|
||||||
|
expect(pageIndexToView(2, "page")).toBe(2);
|
||||||
|
});
|
||||||
|
it("spread mode: pairs [0,1] [2,3] [4]", () => {
|
||||||
|
expect(viewCount(5, "spread")).toBe(3);
|
||||||
|
expect(pageIndexToView(3, "spread")).toBe(1);
|
||||||
|
expect(viewToPageIndex(1, "spread")).toBe(2);
|
||||||
|
expect(spreadPages(2, 5, false)).toEqual([2, 3]);
|
||||||
|
expect(spreadPages(4, 5, false)).toEqual([4]); // 落单页
|
||||||
|
expect(spreadPages(2, 5, true)).toEqual([3, 2]); // RTL 右页为先
|
||||||
|
});
|
||||||
|
it("turnView clamps and RTL flips direction", () => {
|
||||||
|
expect(turnView(0, 1, false, 3)).toBe(1);
|
||||||
|
expect(turnView(2, 1, false, 3)).toBe(2); // 右边界钳制
|
||||||
|
expect(turnView(0, -1, false, 3)).toBe(0); // 左边界钳制
|
||||||
|
expect(turnView(1, 1, true, 3)).toBe(0); // RTL:d=+1 变后退
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEFAULT_PREFS, parsePrefs, resolveRdTheme } from "../src/lib/readerPrefs";
|
||||||
|
|
||||||
|
describe("parsePrefs", () => {
|
||||||
|
it("returns defaults for null/garbage", () => {
|
||||||
|
expect(parsePrefs(null)).toEqual(DEFAULT_PREFS);
|
||||||
|
expect(parsePrefs("{oops")).toEqual(DEFAULT_PREFS);
|
||||||
|
});
|
||||||
|
it("merges partial legacy-free structures with defaults", () => {
|
||||||
|
const p = parsePrefs(JSON.stringify({ themeMode: "night", cbz: { mode: "page" } }));
|
||||||
|
expect(p.themeMode).toBe("night");
|
||||||
|
expect(p.cbz).toEqual({ ...DEFAULT_PREFS.cbz, mode: "page" });
|
||||||
|
expect(p.text).toEqual(DEFAULT_PREFS.text);
|
||||||
|
});
|
||||||
|
it("rejects out-of-range values back to defaults", () => {
|
||||||
|
const p = parsePrefs(
|
||||||
|
JSON.stringify({
|
||||||
|
text: { sizeIdx: 99, lineIdx: -1, margin: "huge" },
|
||||||
|
cbz: { prefetch: 7 },
|
||||||
|
epub: { sizeIdx: 2, lineIdx: 0, marginIdx: 9 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(p.text).toEqual(DEFAULT_PREFS.text);
|
||||||
|
expect(p.cbz.prefetch).toBe(DEFAULT_PREFS.cbz.prefetch);
|
||||||
|
expect(p.epub.marginIdx).toBe(DEFAULT_PREFS.epub.marginIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveRdTheme", () => {
|
||||||
|
it("auto follows global dark/light", () => {
|
||||||
|
expect(resolveRdTheme("auto", true)).toBe("night");
|
||||||
|
expect(resolveRdTheme("auto", false)).toBe("paper");
|
||||||
|
});
|
||||||
|
it("manual overrides global", () => {
|
||||||
|
expect(resolveRdTheme("sepia", true)).toBe("sepia");
|
||||||
|
expect(resolveRdTheme("night", false)).toBe("night");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createLocalStats, dayKey, recordInto, streakOf, weeklySum, type Buckets } from "../src/lib/readingStats";
|
||||||
|
|
||||||
|
describe("dayKey", () => {
|
||||||
|
it("本地时区 YYYY-MM-DD", () => {
|
||||||
|
expect(dayKey(new Date(2026, 0, 5))).toBe("2026-01-05");
|
||||||
|
expect(dayKey(new Date(2026, 10, 23))).toBe("2026-11-23");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recordInto", () => {
|
||||||
|
it("不可变累加", () => {
|
||||||
|
const b: Buckets = { "2026-01-01": 60 };
|
||||||
|
const nb = recordInto(b, "2026-01-01", 30);
|
||||||
|
expect(nb["2026-01-01"]).toBe(90);
|
||||||
|
expect(b["2026-01-01"]).toBe(60); // 原桶不变
|
||||||
|
expect(recordInto(nb, "2026-01-02", 5)["2026-01-02"]).toBe(5);
|
||||||
|
expect(recordInto(nb, "2026-01-03", -5)).toEqual({ "2026-01-01": 90, "2026-01-03": 0 }); // 负值钳 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("weeklySum", () => {
|
||||||
|
it("近 7 天含今天、缺日补 0、合计", () => {
|
||||||
|
const today = new Date(2026, 8, 16); // 2026-09-16(周三)
|
||||||
|
const b: Buckets = { "2026-09-16": 120, "2026-09-12": 300 };
|
||||||
|
const w = weeklySum(b, today);
|
||||||
|
expect(w.perDay).toHaveLength(7);
|
||||||
|
expect(w.perDay[6]).toEqual({ day: "2026-09-16", seconds: 120 });
|
||||||
|
expect(w.perDay[2]).toEqual({ day: "2026-09-12", seconds: 300 });
|
||||||
|
expect(w.perDay[0].seconds).toBe(0);
|
||||||
|
expect(w.total).toBe(420);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("streakOf", () => {
|
||||||
|
it("今天有记录 → 连续天数", () => {
|
||||||
|
const today = new Date(2026, 8, 16);
|
||||||
|
const b: Buckets = { "2026-09-16": 10, "2026-09-15": 10, "2026-09-14": 10 };
|
||||||
|
expect(streakOf(b, today)).toBe(3);
|
||||||
|
});
|
||||||
|
it("今天无但昨天有 → 连续天数(含昨天)", () => {
|
||||||
|
const today = new Date(2026, 8, 16);
|
||||||
|
const b: Buckets = { "2026-09-15": 10, "2026-09-14": 10 };
|
||||||
|
expect(streakOf(b, today)).toBe(2);
|
||||||
|
});
|
||||||
|
it("断档即止", () => {
|
||||||
|
const today = new Date(2026, 8, 16);
|
||||||
|
const b: Buckets = { "2026-09-15": 10, "2026-09-13": 10 };
|
||||||
|
expect(streakOf(b, today)).toBe(1);
|
||||||
|
});
|
||||||
|
it("空 → 0", () => {
|
||||||
|
const today = new Date(2026, 8, 16);
|
||||||
|
expect(streakOf({}, today)).toBe(0);
|
||||||
|
expect(streakOf({ "2026-09-14": 10 }, today)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createLocalStats", () => {
|
||||||
|
it("record→weekly/streak 走 localStorage(内存桩)", () => {
|
||||||
|
const mem: Record<string, string> = {};
|
||||||
|
globalThis.localStorage ??= {
|
||||||
|
getItem: (k: string) => (k in mem ? mem[k] : null),
|
||||||
|
setItem: (k: string, v: string) => (mem[k] = String(v)),
|
||||||
|
clear: () => Object.keys(mem).forEach((k) => delete mem[k]),
|
||||||
|
} as unknown as typeof localStorage;
|
||||||
|
globalThis.localStorage.clear();
|
||||||
|
const s = createLocalStats();
|
||||||
|
s.record(30);
|
||||||
|
s.record(20);
|
||||||
|
const w = s.weekly();
|
||||||
|
expect(w.perDay.at(-1)?.seconds).toBe(50);
|
||||||
|
expect(s.streak()).toBe(1);
|
||||||
|
s.record(0); // 无时间不落盘
|
||||||
|
expect(s.weekly().total).toBe(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { searchChapters, splitHighlight } from "../src/lib/search";
|
||||||
|
import { splitChapters } from "../src/lib/chapters";
|
||||||
|
|
||||||
|
const TEXT = ["第一章 起风", "风来了又走。", "第二章 落雨", "雨点敲窗,风声相伴。", "风止"].join("\n");
|
||||||
|
|
||||||
|
describe("searchChapters", () => {
|
||||||
|
const chs = splitChapters(TEXT);
|
||||||
|
it("finds case-insensitive hits with chapter index and position", () => {
|
||||||
|
const hits = searchChapters(TEXT, chs, "风");
|
||||||
|
expect(hits.length).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(hits[0].ch).toBe(0);
|
||||||
|
expect(hits.every((h) => h.snippet.includes("风"))).toBe(true);
|
||||||
|
});
|
||||||
|
it("returns empty for no match / empty query", () => {
|
||||||
|
expect(searchChapters(TEXT, chs, "不存在")).toEqual([]);
|
||||||
|
expect(searchChapters(TEXT, chs, " ")).toEqual([]);
|
||||||
|
});
|
||||||
|
it("caps at limit", () => {
|
||||||
|
expect(searchChapters(TEXT, chs, "风", 2)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitHighlight", () => {
|
||||||
|
it("splits snippet into before/match/after", () => {
|
||||||
|
expect(splitHighlight("abc风def", 3, 1)).toEqual({ before: "abc", match: "风", after: "def" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"paths": { "@/*": ["./src/*"] },
|
"paths": { "@/*": ["./src/*"] },
|
||||||
"types": ["vite/client", "vite-plugin-pwa/client"]
|
"types": ["vite/client", "vite-plugin-pwa/client", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src", "test"]
|
"include": ["src", "test", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
// 组件测试一律 colocate 在 src/ 组件目录(*.test.tsx);放 test/ 下的 .test.tsx 不会被任何 project 捕获
|
// 组件测试一律 colocate 在 src/ 组件目录(*.test.tsx / *.test.ts);放 test/ 下的 .test.tsx 不会被任何 project 捕获
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
resolve: { alias: { "@": new URL("./src", import.meta.url).pathname } },
|
resolve: { alias: { "@": new URL("./src", import.meta.url).pathname } },
|
||||||
test: {
|
test: {
|
||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
extends: true,
|
extends: true,
|
||||||
test: {
|
test: {
|
||||||
name: "components",
|
name: "components",
|
||||||
include: ["src/**/*.test.tsx"],
|
include: ["src/**/*.test.{ts,tsx}"],
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: ["test/setup.ts"],
|
setupFiles: ["test/setup.ts"],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user