feat: unify reader chrome state management
- Add RDEPUBReaderUIState struct for centralized UI state model - Refactor RDEPUBReaderChromeCoordinator with makeUIState() and applyUIState() - Remove direct UI updates from RDEPUBReaderAnnotationCoordinator - Consolidate updateBookmarkChrome() into updateReaderChrome() - Update RDEPUBReaderContext, Runtime, and LocationCoordinator - Add reader problem fix development checklist documentation Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
d15187b730
commit
ed390da147
@ -1,6 +1,6 @@
|
|||||||
# ReadViewSDK 文档索引
|
# ReadViewSDK 文档索引
|
||||||
|
|
||||||
> 最后更新:2026-06-09
|
> 最后更新:2026-06-12
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
|
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划:10 个任务、修改位置、实施步骤、风险点与验收标准 |
|
||||||
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
|
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
|
||||||
|
|
||||||
## 项目信息
|
## 项目信息
|
||||||
|
|||||||
757
Doc/当前阅读器问题修复开发清单.md
Normal file
757
Doc/当前阅读器问题修复开发清单.md
Normal file
@ -0,0 +1,757 @@
|
|||||||
|
# 当前阅读器问题修复开发清单
|
||||||
|
|
||||||
|
> 最后更新:2026-06-12
|
||||||
|
> 依据说明:本清单仅基于当前仓库代码实现整理,不以现有说明文档是否准确为前提。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
本文将“当前阅读器还存在的问题”展开为可执行的逐任务开发清单,目标是把工作从“方向建议”落到“具体改哪里、怎么改、如何验收”。
|
||||||
|
|
||||||
|
本文聚焦以下四类问题:
|
||||||
|
|
||||||
|
1. 核心交互链路不稳定:选区、高亮、书签、工具栏状态分散。
|
||||||
|
2. 默认安全策略偏弱:外链、解压、日志、持久化默认行为需要收口。
|
||||||
|
3. 资源与分页稳定性不足:大资源内存峰值、离屏分页 WebView 边界控制不足。
|
||||||
|
4. SDK 契约与回归手段不足:默认 no-op 容易误用,自动化观测点不够结构化。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 总体实施顺序
|
||||||
|
|
||||||
|
建议按以下顺序执行,减少返工:
|
||||||
|
|
||||||
|
1. `bookmark-chrome-state-unification`
|
||||||
|
2. `selection-state-refactor`
|
||||||
|
3. `external-link-policy-hardening`
|
||||||
|
4. `epub-archive-path-validation`
|
||||||
|
5. `webview-debug-sanitization`
|
||||||
|
6. `persistence-defaults-hardening`
|
||||||
|
7. `resource-scheme-streaming-and-limits`
|
||||||
|
8. `pagination-webview-hardening`
|
||||||
|
9. `cache-hardening`
|
||||||
|
10. `sdk-contract-and-test-hardening`
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 任务 2 是已确认的重复 UI 状态写入问题,收益最高、风险相对低,应优先处理。
|
||||||
|
- 任务 1 更偏“状态模型收口优化”,问题真实存在,但严重度低于任务 2。
|
||||||
|
- 第 3-6 项收口默认安全行为,改动局部、收益高。
|
||||||
|
- 第 7-9 项涉及资源与分页底层,副作用更大,放在前面稳定后更容易验证。
|
||||||
|
- 最后一项负责把前面改动固化为稳定契约和测试基线。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 任务清单
|
||||||
|
|
||||||
|
## 任务 1:`selection-state-refactor`
|
||||||
|
|
||||||
|
### 3.1 目标
|
||||||
|
|
||||||
|
在不破坏当前清晰分层的前提下,收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态所带来的维护成本和时序问题。
|
||||||
|
|
||||||
|
### 3.2 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||||
|
|
||||||
|
### 3.3 修改方案
|
||||||
|
|
||||||
|
当前代码并不是“混乱散写”,而是相对清晰的分层流:
|
||||||
|
|
||||||
|
- gesture
|
||||||
|
- view
|
||||||
|
- coordinator
|
||||||
|
- context/controller
|
||||||
|
|
||||||
|
其中真正值得优化的点主要有两个:
|
||||||
|
|
||||||
|
1. view 层和 controller 层各自持有一份 `currentSelection`
|
||||||
|
2. `menuSelection` 是为 `UIMenuController` 时序保底引入的 workaround,属于合理存在,但可以尝试被更明确的状态模型替代
|
||||||
|
|
||||||
|
新增一个统一的选区状态模型仍然是可行方案,例如:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum RDEPUBSelectionState: Equatable {
|
||||||
|
case idle
|
||||||
|
case selecting(anchor: Int)
|
||||||
|
case selected(RDEPUBSelection)
|
||||||
|
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议新增文件:
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBSelectionState.swift`
|
||||||
|
|
||||||
|
建议在 `RDEPUBReaderAnnotationCoordinator` 内新增或抽出一个单一入口:
|
||||||
|
|
||||||
|
- `applySelectionState(_ state: RDEPUBSelectionState)`
|
||||||
|
- `currentSelectionState`
|
||||||
|
|
||||||
|
具体修改:
|
||||||
|
|
||||||
|
1. `RDEPUBTextSelectionController`
|
||||||
|
- 移除“自己就是最终状态源”的职责。
|
||||||
|
- 保留字符范围计算与 `RDEPUBSelection?` 构建。
|
||||||
|
- `isSelecting` 可保留为手势内部临时状态,但不再作为 UI 的最终依据。
|
||||||
|
|
||||||
|
2. `RDEPUBTextContentView`
|
||||||
|
- 不再直接通过 `currentSelection != nil` 作为所有 UI 行为的唯一判断条件。
|
||||||
|
- 长按、拖拽、结束只上报“开始选择 / 更新选择 / 结束选择 / 取消选择”事件。
|
||||||
|
- `menuSelection` 不建议直接删除。
|
||||||
|
- 第一版应先保留 `menuSelection`,并把它明确标注为菜单时序缓冲状态。
|
||||||
|
- 待状态模型稳定后,再评估是否能安全移除。
|
||||||
|
|
||||||
|
3. `RDEPUBReaderAnnotationCoordinator.updateCurrentSelection(_:)`
|
||||||
|
- 改造成 `applySelectionState(_:)`。
|
||||||
|
- 在 `.selected` 时统一:
|
||||||
|
- 更新 `controller.currentSelection`
|
||||||
|
- 决定是否展示工具栏
|
||||||
|
- 更新底部高亮按钮可用性
|
||||||
|
- 通知 delegate
|
||||||
|
- 在 `.idle` 时统一清理:
|
||||||
|
- 清空 `controller.currentSelection`
|
||||||
|
- 关闭菜单
|
||||||
|
- 刷新按钮状态
|
||||||
|
|
||||||
|
4. `RDEPUBReaderController`
|
||||||
|
- `currentSelection` 继续保留为公开只读语义,但底层来源改为 `selectionState` 推导,而不是多处直接赋值。
|
||||||
|
|
||||||
|
### 3.4 实施步骤
|
||||||
|
|
||||||
|
1. 新增 `RDEPUBSelectionState.swift`
|
||||||
|
2. 在 `RDEPUBReaderAnnotationCoordinator` 中接管选区状态
|
||||||
|
3. 重写 `RDEPUBTextContentView` 中选区相关回调
|
||||||
|
4. 先收口零散的 `currentSelection != nil` 控制逻辑
|
||||||
|
5. 保留 `menuSelection`,待验证不再需要后再移除
|
||||||
|
6. 统一 delegate 通知路径
|
||||||
|
|
||||||
|
### 3.5 风险点
|
||||||
|
|
||||||
|
- 选区菜单弹出时机可能变化。
|
||||||
|
- `menuSelection` 是现有时序补丁,若贸然删除,菜单动作可能拿不到正确 selection。
|
||||||
|
- Web 路径和 Native Text 路径都要走统一状态流,避免只修一条。
|
||||||
|
|
||||||
|
### 3.6 验收标准
|
||||||
|
|
||||||
|
- 长按后必然进入“选区中”状态。
|
||||||
|
- 松手后有有效文本时必然进入“已选中”状态。
|
||||||
|
- 复制/高亮/批注后必然回到空闲状态。
|
||||||
|
- `selection=1` 的 Demo 状态与按钮启用状态始终一致。
|
||||||
|
|
||||||
|
### 3.7 优先级修正
|
||||||
|
|
||||||
|
本任务应视为“结构优化 + 降低后续维护成本”,不是当前代码库里最严重的缺陷。建议优先级下调到与任务 2 同级,排在任务 2 之后实施。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 2:`bookmark-chrome-state-unification`
|
||||||
|
|
||||||
|
### 3.8 目标
|
||||||
|
|
||||||
|
统一顶部书签按钮、底部书签按钮、高亮按钮、添加高亮按钮的可用状态计算,避免不同协调器各自改一部分 UI。
|
||||||
|
|
||||||
|
### 3.9 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderChromeCoordinator.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderTopToolView.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderBottomToolView.swift`
|
||||||
|
|
||||||
|
### 3.10 修改方案
|
||||||
|
|
||||||
|
新增统一 UI 状态模型:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
struct RDEPUBReaderUIState {
|
||||||
|
let canToggleBookmark: Bool
|
||||||
|
let hasBookmarkAtCurrentLocation: Bool
|
||||||
|
let canShowBookmarks: Bool
|
||||||
|
let canAddHighlight: Bool
|
||||||
|
let canShowHighlights: Bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
建议新增文件:
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderUIState.swift`
|
||||||
|
|
||||||
|
具体修改:
|
||||||
|
|
||||||
|
1. `RDEPUBReaderChromeCoordinator`
|
||||||
|
- 新增 `makeUIState()` 和 `applyUIState(_:)`
|
||||||
|
- `updateReaderChrome()` 内不再直接散写多个 `setXxxEnabled`
|
||||||
|
|
||||||
|
2. `RDEPUBReaderAnnotationCoordinator`
|
||||||
|
- `updateBookmarkChrome()` 不再直接写 view,改为触发 `context.updateReaderChrome()`
|
||||||
|
|
||||||
|
3. `RDEPUBReaderTopToolView` / `RDEPUBReaderBottomToolView`
|
||||||
|
- 保持 dumb view,只接收状态,不参与规则判断
|
||||||
|
|
||||||
|
### 3.11 实施步骤
|
||||||
|
|
||||||
|
1. 新增 `RDEPUBReaderUIState.swift`
|
||||||
|
2. 改造 `updateReaderChrome()`
|
||||||
|
3. 不要简单删除 `AnnotationCoordinator` 内的按钮状态刷新
|
||||||
|
4. 区分两类触发时机:
|
||||||
|
- `ChromeCoordinator` 负责整页/整轮 chrome 同步
|
||||||
|
- `AnnotationCoordinator` 负责用户操作后的即时反馈触发
|
||||||
|
5. 将二者统一收口到同一个状态计算入口,例如 `context.updateReaderChrome()`
|
||||||
|
6. 调整书签新增/删除/跳转后的刷新入口为统一 `updateReaderChrome()`
|
||||||
|
|
||||||
|
### 3.12 风险点
|
||||||
|
|
||||||
|
- 如果简单删除 `AnnotationCoordinator` 内的调用,可能丢失“用户操作后立即反馈”的刷新时机。
|
||||||
|
- 如果某些按钮当前依赖副作用显示,需要顺便梳理显示时机。
|
||||||
|
|
||||||
|
### 3.13 验收标准
|
||||||
|
|
||||||
|
- 添加第一个书签后,底部书签列表按钮立即可用。
|
||||||
|
- 删除最后一个书签后,底部书签列表按钮立即禁用。
|
||||||
|
- 当前位置已加书签时,顶部书签按钮始终是选中态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 3:`external-link-policy-hardening`
|
||||||
|
|
||||||
|
### 3.14 目标
|
||||||
|
|
||||||
|
把“外链点击后直接打开系统应用”改成受控策略,避免 SDK 默认行为过于激进。
|
||||||
|
|
||||||
|
### 3.15 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+JavaScriptBridge.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift`
|
||||||
|
|
||||||
|
### 3.16 修改方案
|
||||||
|
|
||||||
|
在 `RDEPUBReaderConfiguration` 新增:
|
||||||
|
|
||||||
|
- `allowedExternalURLSchemes: Set<String>`
|
||||||
|
- `requiresExternalLinkConfirmation: Bool`
|
||||||
|
- 可选:`allowedExternalURLHosts: Set<String>?`
|
||||||
|
|
||||||
|
默认值建议:
|
||||||
|
|
||||||
|
- scheme 仅允许 `https`
|
||||||
|
- 需要确认框
|
||||||
|
|
||||||
|
具体修改:
|
||||||
|
|
||||||
|
1. `RDEPUBWebView+JavaScriptBridge.swift`
|
||||||
|
- 继续识别外链,但不再表示“允许直接打开”。
|
||||||
|
- 只负责把外链事件上抛。
|
||||||
|
|
||||||
|
2. `RDEPUBReaderController+ContentDelegates.swift`
|
||||||
|
- 新增私有方法:
|
||||||
|
- `shouldAllowExternalURL(_:)`
|
||||||
|
- `presentExternalLinkConfirmation(for:)`
|
||||||
|
- `openExternalURLIfAllowed(_:)`
|
||||||
|
- `didActivateExternalLink` 改为:
|
||||||
|
- 先 delegate 回调
|
||||||
|
- 再走配置校验
|
||||||
|
- 再弹确认框
|
||||||
|
- 最后调用 `UIApplication.shared.open`
|
||||||
|
|
||||||
|
3. 可选扩展 `RDEPUBReaderDelegate`
|
||||||
|
- 增加可选拦截钩子:
|
||||||
|
- `epubReader(_:shouldOpenExternalURL:) -> Bool`
|
||||||
|
|
||||||
|
### 3.17 实施步骤
|
||||||
|
|
||||||
|
1. 改配置模型与默认值
|
||||||
|
2. 改控制器外链处理逻辑
|
||||||
|
3. 增加确认对话框
|
||||||
|
4. 增加测试用 mock URL 覆盖 `https`、`mailto`、未知 scheme`
|
||||||
|
|
||||||
|
### 3.18 风险点
|
||||||
|
|
||||||
|
- 某些 Demo 书可能依赖 `mailto` 或 `tel`,要明确它们现在默认不再直接打开。
|
||||||
|
|
||||||
|
### 3.19 验收标准
|
||||||
|
|
||||||
|
- `https` 外链在确认后打开。
|
||||||
|
- 默认配置下 `mailto` 和 `tel` 不直接打开。
|
||||||
|
- 未知 scheme 一律拒绝。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 4:`epub-archive-path-validation`
|
||||||
|
|
||||||
|
### 3.20 目标
|
||||||
|
|
||||||
|
为 EPUB 解压路径增加显式安全校验,防止恶意压缩包通过相对路径写出解压根目录。
|
||||||
|
|
||||||
|
### 3.21 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBCore/RDEPUBParser+Archive.swift`
|
||||||
|
|
||||||
|
### 3.22 修改方案
|
||||||
|
|
||||||
|
新增私有方法:
|
||||||
|
|
||||||
|
- `validatedExtractionDestination(for:extractionRoot:) -> URL?`
|
||||||
|
|
||||||
|
校验规则:
|
||||||
|
|
||||||
|
1. 拒绝绝对路径。
|
||||||
|
2. 拒绝包含 `..` 的路径段。
|
||||||
|
3. `standardizedFileURL.path` 必须位于 `extractionRoot.standardizedFileURL.path` 下。
|
||||||
|
|
||||||
|
具体修改:
|
||||||
|
|
||||||
|
1. `extractArchiveIfNeeded(epubURL:)`
|
||||||
|
- 在循环内先调用 `validatedExtractionDestination`
|
||||||
|
- 无效 entry 可:
|
||||||
|
- 直接抛错终止解析,或
|
||||||
|
- 记日志并跳过
|
||||||
|
- 建议优先抛错,行为更清晰
|
||||||
|
|
||||||
|
2. 增加自定义错误:
|
||||||
|
- 若当前 `RDEPUBParserError` 里没有合适 case,可新增 `invalidArchiveEntryPath(String)`
|
||||||
|
|
||||||
|
### 3.23 实施步骤
|
||||||
|
|
||||||
|
1. 增加路径校验方法
|
||||||
|
2. 替换原始 `destinationURL` 生成逻辑
|
||||||
|
3. 增加针对恶意 entry path 的单元测试
|
||||||
|
|
||||||
|
### 3.24 风险点
|
||||||
|
|
||||||
|
- 少数历史 EPUB 可能带奇怪路径分隔符,需要兼容性验证。
|
||||||
|
|
||||||
|
### 3.25 验收标准
|
||||||
|
|
||||||
|
- 正常 EPUB 仍可正常解压。
|
||||||
|
- 带 `../` 的恶意 entry 会被拒绝。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 5:`webview-debug-sanitization`
|
||||||
|
|
||||||
|
### 3.26 目标
|
||||||
|
|
||||||
|
减少默认调试日志泄露阅读内容的风险,并关闭不必要的 inspectable 默认值。
|
||||||
|
|
||||||
|
### 3.27 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBCore/RDEPUBWebViewDebug.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBCore/RDEPUBWebView+Configuration.swift`
|
||||||
|
|
||||||
|
### 3.28 修改方案
|
||||||
|
|
||||||
|
1. `RDEPUBWebViewDebug`
|
||||||
|
- `logMessage` 改为只打印:
|
||||||
|
- message name
|
||||||
|
- 字段名列表
|
||||||
|
- 文本长度
|
||||||
|
- URL 摘要
|
||||||
|
- 不再打印完整 message body
|
||||||
|
|
||||||
|
2. 新增更细粒度配置
|
||||||
|
- `RDEPUBWebViewDebugEnabled`
|
||||||
|
- `RDEPUBVerboseWebViewDebugEnabled`
|
||||||
|
|
||||||
|
3. `RDEPUBPaginator` 与正常阅读 WebView
|
||||||
|
- `isInspectable` 改为受配置控制
|
||||||
|
- 默认关闭
|
||||||
|
|
||||||
|
4. `RDEPUBReaderConfiguration`
|
||||||
|
- 增加:
|
||||||
|
- `allowsInspectableWebViews`
|
||||||
|
- `enablesVerboseWebViewLogging`
|
||||||
|
|
||||||
|
### 3.29 实施步骤
|
||||||
|
|
||||||
|
1. 改日志输出格式
|
||||||
|
2. 改 inspectable 的默认逻辑
|
||||||
|
3. 为 verbose 模式保留调试后门
|
||||||
|
|
||||||
|
### 3.30 风险点
|
||||||
|
|
||||||
|
- 调试某些 JS 问题时信息会变少,因此要保留显式 verbose 开关。
|
||||||
|
|
||||||
|
### 3.31 验收标准
|
||||||
|
|
||||||
|
- 默认 DEBUG 包不输出完整选中文本。
|
||||||
|
- 默认分页 WebView 不可 inspect。
|
||||||
|
- 打开 verbose 开关后仍能深度调试。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 6:`persistence-defaults-hardening`
|
||||||
|
|
||||||
|
### 3.32 目标
|
||||||
|
|
||||||
|
降低默认 `UserDefaults` 持久化的误用风险和数据膨胀风险,同时不破坏当前 API 可用性。
|
||||||
|
|
||||||
|
### 3.33 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||||
|
|
||||||
|
### 3.34 修改方案
|
||||||
|
|
||||||
|
短期目标:
|
||||||
|
|
||||||
|
1. 明确 `RDEPUBUserDefaultsPersistence` 是轻量实现。
|
||||||
|
2. 高亮持久化优先依赖:
|
||||||
|
- `location`
|
||||||
|
- `rangeInfo`
|
||||||
|
- `style`
|
||||||
|
- `note`
|
||||||
|
3. `text` 字段作为冗余缓存,而不是唯一恢复依据。
|
||||||
|
|
||||||
|
中期可选目标:
|
||||||
|
|
||||||
|
新增文件持久化实现:
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBFilePersistence.swift`
|
||||||
|
|
||||||
|
### 3.35 具体修改
|
||||||
|
|
||||||
|
1. `RDEPUBReaderPersistence.swift`
|
||||||
|
- 为默认实现增加 DEBUG 警告,提示 bookmarks/settings 默认实现是 no-op
|
||||||
|
|
||||||
|
2. `RDEPUBUserDefaultsPersistence`
|
||||||
|
- 增加大小保护,例如当高亮集合序列化后超阈值时打印告警
|
||||||
|
- 可选增加版本字段,便于后续迁移
|
||||||
|
|
||||||
|
3. `RDEPUBReaderController`
|
||||||
|
- 初始化默认 persistence 时,在注释与命名上明确这是默认轻量实现
|
||||||
|
|
||||||
|
### 3.36 实施步骤
|
||||||
|
|
||||||
|
1. 增加 warning/assert 机制
|
||||||
|
2. 优化高亮持久化字段策略
|
||||||
|
3. 视时间增加 `RDEPUBFilePersistence`
|
||||||
|
|
||||||
|
### 3.37 风险点
|
||||||
|
|
||||||
|
- 如果外部代码依赖 `text` 永远存在,需要做好兼容。
|
||||||
|
|
||||||
|
### 3.38 验收标准
|
||||||
|
|
||||||
|
- 未实现书签持久化时,DEBUG 下能看到明确提示。
|
||||||
|
- 高亮即使 `text` 丢失,也可基于范围信息恢复定位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 7:`resource-scheme-streaming-and-limits`
|
||||||
|
|
||||||
|
### 3.39 目标
|
||||||
|
|
||||||
|
降低 `WKURLSchemeHandler` 对大资源的内存冲击,避免每次都整块 `Data(contentsOf:)` 读入。
|
||||||
|
|
||||||
|
### 3.40 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBCore/RDEPUBResourceURLSchemeHandler.swift`
|
||||||
|
|
||||||
|
### 3.41 修改方案
|
||||||
|
|
||||||
|
分两步做:
|
||||||
|
|
||||||
|
第一步,先加大小阈值和告警:
|
||||||
|
|
||||||
|
- 读取文件属性获取大小
|
||||||
|
- 超过阈值时走分块发送
|
||||||
|
|
||||||
|
第二步,再补流式发送:
|
||||||
|
|
||||||
|
- 使用 `FileHandle` 或 `InputStream`
|
||||||
|
- 按固定 chunk size 反复 `didReceive(data)`
|
||||||
|
|
||||||
|
建议新增私有方法:
|
||||||
|
|
||||||
|
- `resourceMetadata(for:)`
|
||||||
|
- `respondWithStreaming(fileURL:requestURL:taskID:urlSchemeTask:)`
|
||||||
|
- `respondWithInMemoryData(...)`
|
||||||
|
|
||||||
|
### 3.42 实施步骤
|
||||||
|
|
||||||
|
1. 先加文件大小检测
|
||||||
|
2. 小文件保留内存直读
|
||||||
|
3. 大文件切换到分块发送
|
||||||
|
4. 增加取消任务时的中断判断
|
||||||
|
|
||||||
|
### 3.43 风险点
|
||||||
|
|
||||||
|
- `WKURLSchemeHandler` 分块发送要注意 stop 之后不继续回调。
|
||||||
|
|
||||||
|
### 3.44 验收标准
|
||||||
|
|
||||||
|
- 大图片/字体加载时内存峰值下降。
|
||||||
|
- 取消任务不会继续发送数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 8:`pagination-webview-hardening`
|
||||||
|
|
||||||
|
### 3.45 目标
|
||||||
|
|
||||||
|
加强离屏分页 WebView 的边界控制,减少外部资源波动和调试副作用。
|
||||||
|
|
||||||
|
### 3.46 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBCore/RDEPUBPaginator.swift`
|
||||||
|
|
||||||
|
### 3.47 修改方案
|
||||||
|
|
||||||
|
1. 为分页 WebView 增加严格导航限制
|
||||||
|
- 仅允许 `file://`
|
||||||
|
- 仅允许 `ss-reader://`
|
||||||
|
- 拒绝 `http/https`
|
||||||
|
|
||||||
|
2. 将 `isInspectable` 受配置控制
|
||||||
|
|
||||||
|
3. 增加测量稳定性日志
|
||||||
|
- 记录三轮测量结果
|
||||||
|
- 当三轮差异过大时告警
|
||||||
|
|
||||||
|
建议新增私有方法:
|
||||||
|
|
||||||
|
- `shouldAllowPaginatorNavigation(url:)`
|
||||||
|
- `recordMeasurement(pass:value:)`
|
||||||
|
|
||||||
|
### 3.48 实施步骤
|
||||||
|
|
||||||
|
1. 扩展 `WKNavigationDelegate`
|
||||||
|
2. 加入导航 allowlist
|
||||||
|
3. 记录多轮测量差异
|
||||||
|
|
||||||
|
### 3.49 风险点
|
||||||
|
|
||||||
|
- 某些 EPUB 样式若依赖外链资源,分页结果可能与当前行为不同,但这本身就是更安全的策略。
|
||||||
|
|
||||||
|
### 3.50 验收标准
|
||||||
|
|
||||||
|
- 分页 WebView 不发起外部导航。
|
||||||
|
- 三轮测量波动可观测。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 9:`cache-hardening`
|
||||||
|
|
||||||
|
### 3.51 目标
|
||||||
|
|
||||||
|
提升章节摘要磁盘缓存的健壮性、可观测性和可清理能力。
|
||||||
|
|
||||||
|
### 3.52 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift`
|
||||||
|
|
||||||
|
### 3.53 修改方案
|
||||||
|
|
||||||
|
1. 原子写入
|
||||||
|
- 先写 `.tmp`
|
||||||
|
- 再 replace 到正式文件
|
||||||
|
|
||||||
|
2. 读失败分类
|
||||||
|
- 文件不存在
|
||||||
|
- 读取失败
|
||||||
|
- 解码失败
|
||||||
|
|
||||||
|
3. 精细化清理
|
||||||
|
- `removeAll()`
|
||||||
|
- `removeAll(forBookID:)`
|
||||||
|
- `removeAll(forRenderSignature:)`
|
||||||
|
|
||||||
|
4. 增加统计接口
|
||||||
|
- 缓存文件数
|
||||||
|
- 总大小
|
||||||
|
|
||||||
|
### 3.54 实施步骤
|
||||||
|
|
||||||
|
1. 改写入策略
|
||||||
|
2. 增加读失败日志
|
||||||
|
3. 增加清理接口
|
||||||
|
4. 补测试覆盖损坏文件、半写文件
|
||||||
|
|
||||||
|
### 3.55 风险点
|
||||||
|
|
||||||
|
- 清理策略接口若公开,需要评估是否作为 public API。
|
||||||
|
|
||||||
|
### 3.56 验收标准
|
||||||
|
|
||||||
|
- 缓存文件损坏不会影响整本书重新打开。
|
||||||
|
- 能按需清理缓存,而不是只能全删。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 任务 10:`sdk-contract-and-test-hardening`
|
||||||
|
|
||||||
|
### 3.57 目标
|
||||||
|
|
||||||
|
把前面所有修复固化为更清晰的 SDK 契约和更稳定的测试观测点。
|
||||||
|
|
||||||
|
### 3.58 主要问题代码
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderPersistence.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBUI/RDURLReaderController.swift`
|
||||||
|
- `ReadViewDemo/ReadViewDemoUITests/`
|
||||||
|
|
||||||
|
### 3.59 修改方案
|
||||||
|
|
||||||
|
1. 强化 persistence 契约
|
||||||
|
- 继续保留默认实现,但在 DEBUG 下对 no-op 行为给出明确信号
|
||||||
|
|
||||||
|
2. Demo 状态能力修正
|
||||||
|
- 当前仓库并非完全没有结构化状态。
|
||||||
|
- `ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift` 已经提供了状态解析器和 `waitForDemoReaderState(...)` 结构化等待能力。
|
||||||
|
- 真正需要推进的不是再造一个 `DemoReaderSnapshot`,而是把仍然大量存在的 `waitForReaderState(containing:)` 迁移到结构化断言。
|
||||||
|
|
||||||
|
建议方向:
|
||||||
|
|
||||||
|
- 继续保留隐藏 label 输出
|
||||||
|
- 保持 `key=value` 格式
|
||||||
|
- 统一测试侧只通过 `DemoReaderState` 解析和断言
|
||||||
|
- 逐步淘汰 `containing:` 子串匹配,避免 `highlights=1` 命中 `highlights=10` 之类误判
|
||||||
|
|
||||||
|
3. 补测试
|
||||||
|
- 单元测试:
|
||||||
|
- `RDEPUBParserArchiveSafetyTests`
|
||||||
|
- `RDEPUBExternalLinkPolicyTests`
|
||||||
|
- `RDEPUBChapterSummaryDiskCacheTests`
|
||||||
|
- UI 测试:
|
||||||
|
- 选区状态流
|
||||||
|
- 书签启用/禁用
|
||||||
|
- 外链确认框
|
||||||
|
|
||||||
|
### 3.60 实施步骤
|
||||||
|
|
||||||
|
1. 改 Demo 状态串生成逻辑
|
||||||
|
2. 优先将 `waitForReaderState(containing:)` 迁移为 `waitForDemoReaderState(...)`
|
||||||
|
3. 调整 UI 测试断言
|
||||||
|
4. 增加单元测试 target 或现有测试目录内的新文件
|
||||||
|
|
||||||
|
### 3.61 风险点
|
||||||
|
|
||||||
|
- 现有 UI 测试如果强依赖旧状态串,需要同步迁移。
|
||||||
|
|
||||||
|
### 3.62 验收标准
|
||||||
|
|
||||||
|
- UI 测试失败时能明确知道是选区状态、书签状态还是导航状态失配。
|
||||||
|
- persistence 的默认 no-op 行为在开发期不再“静默”。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 补充清理项
|
||||||
|
|
||||||
|
以下问题已在代码中可见,但未纳入前 10 个主任务,建议作为补充任务或穿插清理项处理。
|
||||||
|
|
||||||
|
### 4.1 `pageBreakBefore/pageBreakAfter` 相关死代码审计
|
||||||
|
|
||||||
|
涉及代码:
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/Typesetter/RDEPUBSemanticMarkerInjector.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBPageBreakPolicy.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
|
||||||
|
|
||||||
|
建议动作:
|
||||||
|
|
||||||
|
- 审计 `pageBreakBefore/pageBreakAfter` 从注入到消费的完整链路
|
||||||
|
- 判断是否有“已定义、已注入、但实际无有效触发”的死路径
|
||||||
|
- 若确认无效,删除或补测试锁定其真实行为
|
||||||
|
|
||||||
|
### 4.2 `PAGINATION-DEBUG` 输出清理
|
||||||
|
|
||||||
|
涉及代码:
|
||||||
|
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBCoreTextPageFrameFactory.swift`
|
||||||
|
- `Sources/RDReaderView/EPUBTextRendering/Pagination/RDEPUBChapterPageCounter.swift`
|
||||||
|
|
||||||
|
建议动作:
|
||||||
|
|
||||||
|
- 将裸 `print` 改为受控调试开关
|
||||||
|
- 默认关闭
|
||||||
|
- 避免分页细节和正文片段直接进入控制台
|
||||||
|
|
||||||
|
### 4.3 `ChapterRuntime` 旧代码与遗留路径清理
|
||||||
|
|
||||||
|
建议动作:
|
||||||
|
|
||||||
|
- 梳理 `ChapterRuntime` 下是否存在已不再被主流程使用的辅助类型或过渡实现
|
||||||
|
- 对“仍被引用但语义已经过时”的代码先补注释标记
|
||||||
|
- 对“完全无调用”的代码建立删除候选清单
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 推荐里程碑切分
|
||||||
|
|
||||||
|
### 里程碑 A:核心交互稳定
|
||||||
|
|
||||||
|
包含任务:
|
||||||
|
|
||||||
|
- `selection-state-refactor`
|
||||||
|
- `bookmark-chrome-state-unification`
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 选区、高亮、书签相关 UI 测试恢复稳定
|
||||||
|
|
||||||
|
### 里程碑 B:默认安全行为收口
|
||||||
|
|
||||||
|
包含任务:
|
||||||
|
|
||||||
|
- `external-link-policy-hardening`
|
||||||
|
- `epub-archive-path-validation`
|
||||||
|
- `webview-debug-sanitization`
|
||||||
|
- `persistence-defaults-hardening`
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 默认配置下不再直接打开高风险外链
|
||||||
|
- 解压路径具备显式校验
|
||||||
|
- 默认调试日志不泄露完整阅读内容
|
||||||
|
|
||||||
|
### 里程碑 C:底层稳定性优化
|
||||||
|
|
||||||
|
包含任务:
|
||||||
|
|
||||||
|
- `resource-scheme-streaming-and-limits`
|
||||||
|
- `pagination-webview-hardening`
|
||||||
|
- `cache-hardening`
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- 大资源加载和分页更稳定
|
||||||
|
- 缓存更健壮、可清理
|
||||||
|
|
||||||
|
### 里程碑 D:契约与测试固化
|
||||||
|
|
||||||
|
包含任务:
|
||||||
|
|
||||||
|
- `sdk-contract-and-test-hardening`
|
||||||
|
|
||||||
|
完成标准:
|
||||||
|
|
||||||
|
- SDK 默认行为更清晰
|
||||||
|
- 回归测试对关键状态具备稳定观测能力
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 建议的开发节奏
|
||||||
|
|
||||||
|
若按 2 周一个小迭代,可参考:
|
||||||
|
|
||||||
|
1. 第 1 周:`bookmark-chrome-state-unification` + `selection-state-refactor`
|
||||||
|
2. 第 2 周:`external-link-policy-hardening` + `epub-archive-path-validation` + `webview-debug-sanitization`
|
||||||
|
3. 第 3 周:`persistence-defaults-hardening` + `resource-scheme-streaming-and-limits` + `pagination-webview-hardening`
|
||||||
|
4. 第 4 周:`cache-hardening` + `sdk-contract-and-test-hardening` 与回归收口
|
||||||
|
|
||||||
|
如果只想先做最值的部分,建议最小交付集合为:
|
||||||
|
|
||||||
|
1. `bookmark-chrome-state-unification`
|
||||||
|
2. `selection-state-refactor`
|
||||||
|
3. `external-link-policy-hardening`
|
||||||
|
4. `epub-archive-path-validation`
|
||||||
|
|
||||||
|
这四项完成后,阅读器的“主观可用性”和“默认安全性”会先提升一个台阶。
|
||||||
@ -31,7 +31,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
highlights: textHighlights(for: resolvedPage.page),
|
highlights: textHighlights(for: resolvedPage.page),
|
||||||
searchState: searchState
|
searchState: searchState(for: resolvedPage.page)
|
||||||
)
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
@ -46,7 +46,7 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
totalPages: textBook.pages.count,
|
totalPages: textBook.pages.count,
|
||||||
configuration: configuration,
|
configuration: configuration,
|
||||||
highlights: textHighlights(for: page),
|
highlights: textHighlights(for: page),
|
||||||
searchState: searchState
|
searchState: searchState(for: page)
|
||||||
)
|
)
|
||||||
return contentView
|
return contentView
|
||||||
}
|
}
|
||||||
@ -91,6 +91,57 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 返回当前文本页需要展示的搜索状态。
|
||||||
|
/// 这里会先按页过滤命中结果,并把 currentMatchIndex 重映射到当前页内的局部索引,
|
||||||
|
/// 这样渲染层不需要再关心 href 规范化差异。
|
||||||
|
private func searchState(for page: RDEPUBTextPage) -> RDEPUBSearchState? {
|
||||||
|
guard let globalSearchState = searchState else { return nil }
|
||||||
|
|
||||||
|
let matches = globalSearchState.matches.filter { searchMatch in
|
||||||
|
searchMatchBelongsToPage(searchMatch, page: page)
|
||||||
|
}
|
||||||
|
guard !matches.isEmpty || globalSearchState.currentMatch != nil else {
|
||||||
|
return globalSearchState.matches.isEmpty ? globalSearchState : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentMatchIndex = globalSearchState.currentMatch.flatMap { currentMatch in
|
||||||
|
matches.firstIndex(of: currentMatch)
|
||||||
|
}
|
||||||
|
return RDEPUBSearchState(
|
||||||
|
keyword: globalSearchState.keyword,
|
||||||
|
matches: matches,
|
||||||
|
currentMatchIndex: currentMatchIndex
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func searchMatchBelongsToPage(_ searchMatch: RDEPUBSearchMatch, page: RDEPUBTextPage) -> Bool {
|
||||||
|
if let textBook,
|
||||||
|
let chapterData = textBook.chapterData(for: page.href),
|
||||||
|
let range = chapterData.absoluteRange(for: searchMatch) {
|
||||||
|
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageHref = normalizedPageHref(for: page)
|
||||||
|
let matchHref = normalizedSearchHref(searchMatch.href)
|
||||||
|
guard pageHref == matchHref else { return false }
|
||||||
|
|
||||||
|
guard let rangeLocation = searchMatch.rangeLocation else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
let range = NSRange(location: rangeLocation, length: searchMatch.rangeLength)
|
||||||
|
return NSIntersectionRange(range, page.contentRange).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedPageHref(for page: RDEPUBTextPage) -> String {
|
||||||
|
guard let publication else { return page.href }
|
||||||
|
return publication.resourceResolver.normalizedHref(page.href) ?? page.href
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalizedSearchHref(_ href: String) -> String {
|
||||||
|
guard let publication else { return href }
|
||||||
|
return publication.resourceResolver.normalizedHref(href) ?? href
|
||||||
|
}
|
||||||
|
|
||||||
/// 根据规范化 href 获取文本章节数据
|
/// 根据规范化 href 获取文本章节数据
|
||||||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||||||
readerContext.textChapterData(forNormalizedHref: href)
|
readerContext.textChapterData(forNormalizedHref: href)
|
||||||
|
|||||||
@ -138,11 +138,6 @@ extension RDEPUBReaderController {
|
|||||||
runtime.updateReaderChrome()
|
runtime.updateReaderChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新书签按钮状态
|
|
||||||
func updateBookmarkChrome() {
|
|
||||||
runtime.updateBookmarkChrome()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 展示书签管理界面
|
/// 展示书签管理界面
|
||||||
func presentBookmarksManager() {
|
func presentBookmarksManager() {
|
||||||
runtime.presentBookmarksManager()
|
runtime.presentBookmarksManager()
|
||||||
|
|||||||
@ -38,9 +38,7 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
controller.readerView.isShowToolView == false {
|
controller.readerView.isShowToolView == false {
|
||||||
controller.readerView.tapCenter()
|
controller.readerView.tapCenter()
|
||||||
}
|
}
|
||||||
controller.bottomToolView.setAddHighlightEnabled(
|
controller.updateReaderChrome()
|
||||||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
|
||||||
)
|
|
||||||
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
controller.delegate?.epubReader(controller, didChangeSelection: controller.currentSelection)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,13 +319,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
return controller.restoreReadingLocation(bookmark.location, animated: animated)
|
return controller.restoreReadingLocation(bookmark.location, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步顶部书签按钮和底部书签列表按钮的 UI 状态。
|
|
||||||
func updateBookmarkChrome() {
|
|
||||||
guard let controller else { return }
|
|
||||||
controller.topToolView.setBookmarkSelected(currentBookmark() != nil)
|
|
||||||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 弹出书签管理器,支持查看、跳转和删除书签。
|
/// 弹出书签管理器,支持查看、跳转和删除书签。
|
||||||
func presentBookmarksManager() {
|
func presentBookmarksManager() {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
@ -468,10 +459,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
|||||||
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
guard let currentBookIdentifier = controller.currentBookIdentifier else { return }
|
||||||
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
|
controller.persistence?.saveBookmarks(controller.activeBookmarks, for: currentBookIdentifier)
|
||||||
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
|
controller.delegate?.epubReader(controller, didUpdateBookmarks: controller.activeBookmarks)
|
||||||
updateBookmarkChrome()
|
controller.updateReaderChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func currentBookmark() -> RDEPUBBookmark? {
|
func currentBookmark() -> RDEPUBBookmark? {
|
||||||
guard let controller else { return nil }
|
guard let controller else { return nil }
|
||||||
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
guard let location = scopedBookmarkLocation(controller.currentVisibleLocation()) else {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -57,6 +57,29 @@ final class RDEPUBReaderChromeCoordinator {
|
|||||||
|
|
||||||
/// 同步更新顶部和底部工具栏的主题、标题、按钮可用性等状态。
|
/// 同步更新顶部和底部工具栏的主题、标题、按钮可用性等状态。
|
||||||
func updateReaderChrome() {
|
func updateReaderChrome() {
|
||||||
|
guard let controller else { return }
|
||||||
|
let uiState = makeUIState()
|
||||||
|
applyUIState(uiState)
|
||||||
|
updateSearchBar()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构建当前 UI 状态快照
|
||||||
|
func makeUIState() -> RDEPUBReaderUIState {
|
||||||
|
guard let controller else { return .empty }
|
||||||
|
return RDEPUBReaderUIState(
|
||||||
|
canToggleBookmark: controller.currentBookIdentifier != nil,
|
||||||
|
hasBookmarkAtCurrentLocation: hasBookmarkAtCurrentLocation(),
|
||||||
|
canShowBookmarks: !controller.activeBookmarks.isEmpty,
|
||||||
|
canAddHighlight: controller.configuration.allowsHighlights && controller.currentSelection != nil,
|
||||||
|
canShowHighlights: controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty,
|
||||||
|
showsTableOfContents: controller.configuration.showsTableOfContents,
|
||||||
|
allowsHighlights: controller.configuration.allowsHighlights,
|
||||||
|
showsSettingsPanel: controller.configuration.showsSettingsPanel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将 UI 状态应用到顶部和底部工具栏
|
||||||
|
func applyUIState(_ state: RDEPUBReaderUIState) {
|
||||||
guard let controller else { return }
|
guard let controller else { return }
|
||||||
controller.topToolView.apply(theme: controller.configuration.theme)
|
controller.topToolView.apply(theme: controller.configuration.theme)
|
||||||
controller.topToolView.setTitle(
|
controller.topToolView.setTitle(
|
||||||
@ -64,22 +87,23 @@ final class RDEPUBReaderChromeCoordinator {
|
|||||||
?? controller.parser?.metadata.title
|
?? controller.parser?.metadata.title
|
||||||
?? controller.epubURL.deletingPathExtension().lastPathComponent
|
?? controller.epubURL.deletingPathExtension().lastPathComponent
|
||||||
)
|
)
|
||||||
controller.topToolView.setBookmarkEnabled(controller.currentBookIdentifier != nil)
|
controller.topToolView.setBookmarkEnabled(state.canToggleBookmark)
|
||||||
|
controller.topToolView.setBookmarkSelected(state.hasBookmarkAtCurrentLocation)
|
||||||
controller.bottomToolView.apply(theme: controller.configuration.theme)
|
controller.bottomToolView.apply(theme: controller.configuration.theme)
|
||||||
controller.bottomToolView.updateVisibility(
|
controller.bottomToolView.updateVisibility(
|
||||||
showsTableOfContents: controller.configuration.showsTableOfContents,
|
showsTableOfContents: state.showsTableOfContents,
|
||||||
allowsHighlights: controller.configuration.allowsHighlights,
|
allowsHighlights: state.allowsHighlights,
|
||||||
showsSettingsPanel: controller.configuration.showsSettingsPanel
|
showsSettingsPanel: state.showsSettingsPanel
|
||||||
)
|
)
|
||||||
controller.bottomToolView.setBookmarksEnabled(!controller.activeBookmarks.isEmpty)
|
controller.bottomToolView.setBookmarksEnabled(state.canShowBookmarks)
|
||||||
controller.bottomToolView.setAddHighlightEnabled(
|
controller.bottomToolView.setAddHighlightEnabled(state.canAddHighlight)
|
||||||
controller.configuration.allowsHighlights && controller.currentSelection != nil
|
controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights)
|
||||||
)
|
}
|
||||||
controller.bottomToolView.setHighlightsEnabled(
|
|
||||||
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
|
/// 判断当前位置是否有书签
|
||||||
)
|
private func hasBookmarkAtCurrentLocation() -> Bool {
|
||||||
controller.updateBookmarkChrome()
|
guard let controller else { return false }
|
||||||
updateSearchBar()
|
return context.runtime?.annotationCoordinator.currentBookmark() != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
||||||
|
|||||||
@ -369,9 +369,4 @@ final class RDEPUBReaderContext {
|
|||||||
func applyReaderViewConfiguration() {
|
func applyReaderViewConfiguration() {
|
||||||
controller?.applyReaderViewConfiguration()
|
controller?.applyReaderViewConfiguration()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步书签按钮的 UI 状态。
|
|
||||||
func updateBookmarkChrome() {
|
|
||||||
controller?.updateBookmarkChrome()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,6 +87,6 @@ final class RDEPUBReaderLocationCoordinator {
|
|||||||
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
|
controller.persistence?.saveLocation(location, for: currentBookIdentifier)
|
||||||
controller.delegate?.epubReader(controller, didUpdateLocation: location)
|
controller.delegate?.epubReader(controller, didUpdateLocation: location)
|
||||||
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
|
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
|
||||||
controller.updateBookmarkChrome()
|
controller.updateReaderChrome()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -193,10 +193,6 @@ final class RDEPUBReaderRuntime {
|
|||||||
annotationCoordinator.go(toBookmarkID: id, animated: animated)
|
annotationCoordinator.go(toBookmarkID: id, animated: animated)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateBookmarkChrome() {
|
|
||||||
annotationCoordinator.updateBookmarkChrome()
|
|
||||||
}
|
|
||||||
|
|
||||||
func presentBookmarksManager() {
|
func presentBookmarksManager() {
|
||||||
annotationCoordinator.presentBookmarksManager()
|
annotationCoordinator.presentBookmarksManager()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 阅读器 UI 状态模型:统一管理顶部/底部工具栏按钮的可用性和显示状态。
|
||||||
|
///
|
||||||
|
/// 解决问题:之前书签、高亮等按钮的状态分散在 ChromeCoordinator 和 AnnotationCoordinator 中,
|
||||||
|
/// 导致状态更新入口不一致,容易出现不同步的情况。
|
||||||
|
struct RDEPUBReaderUIState {
|
||||||
|
/// 顶部书签按钮是否可用(当前有书籍标识时可用)
|
||||||
|
let canToggleBookmark: Bool
|
||||||
|
/// 顶部书签按钮是否选中(当前位置已加书签时选中)
|
||||||
|
let hasBookmarkAtCurrentLocation: Bool
|
||||||
|
/// 底部书签列表按钮是否可用(有书签数据时可用)
|
||||||
|
let canShowBookmarks: Bool
|
||||||
|
/// 底部新建标注按钮是否可用(有选中文本时可用)
|
||||||
|
let canAddHighlight: Bool
|
||||||
|
/// 底部高亮列表按钮是否可用(有高亮数据时可用)
|
||||||
|
let canShowHighlights: Bool
|
||||||
|
/// 是否显示目录按钮
|
||||||
|
let showsTableOfContents: Bool
|
||||||
|
/// 是否显示高亮相关按钮(新建标注和高亮列表)
|
||||||
|
let allowsHighlights: Bool
|
||||||
|
/// 是否显示设置按钮
|
||||||
|
let showsSettingsPanel: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RDEPUBReaderUIState {
|
||||||
|
/// 默认的空状态
|
||||||
|
static let empty = RDEPUBReaderUIState(
|
||||||
|
canToggleBookmark: false,
|
||||||
|
hasBookmarkAtCurrentLocation: false,
|
||||||
|
canShowBookmarks: false,
|
||||||
|
canAddHighlight: false,
|
||||||
|
canShowHighlights: false,
|
||||||
|
showsTableOfContents: true,
|
||||||
|
allowsHighlights: true,
|
||||||
|
showsSettingsPanel: true
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -64,7 +64,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
|||||||
let pageStart = pageRange.lowerBound
|
let pageStart = pageRange.lowerBound
|
||||||
let pageEndExclusive = pageRange.upperBound
|
let pageEndExclusive = pageRange.upperBound
|
||||||
|
|
||||||
for match in searchState.matches where match.href == page.href {
|
for match in searchState.matches {
|
||||||
guard let matchStart = match.rangeLocation else { continue }
|
guard let matchStart = match.rangeLocation else { continue }
|
||||||
let matchEnd = matchStart + match.rangeLength
|
let matchEnd = matchStart + match.rangeLength
|
||||||
let overlapStart = max(matchStart, pageStart)
|
let overlapStart = max(matchStart, pageStart)
|
||||||
@ -100,7 +100,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
|||||||
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
let normalColor = UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.55)
|
||||||
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
let activeColor = UIColor(red: 255 / 255, green: 159 / 255, blue: 67 / 255, alpha: 0.75)
|
||||||
|
|
||||||
for match in searchState.matches where match.href == page.href {
|
for match in searchState.matches {
|
||||||
guard let matchStart = match.rangeLocation else { continue }
|
guard let matchStart = match.rangeLocation else { continue }
|
||||||
let matchEnd = matchStart + match.rangeLength
|
let matchEnd = matchStart + match.rangeLength
|
||||||
let overlapStart = max(matchStart, pageStart)
|
let overlapStart = max(matchStart, pageStart)
|
||||||
|
|||||||
@ -40,6 +40,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
let view = RDEPUBTextPageRenderView()
|
let view = RDEPUBTextPageRenderView()
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
view.isOpaque = false
|
view.isOpaque = false
|
||||||
|
view.isAccessibilityElement = true
|
||||||
|
view.accessibilityTraits = .staticText
|
||||||
return view
|
return view
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -118,6 +120,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
} else {
|
} else {
|
||||||
self.hideSelectionMenu()
|
self.hideSelectionMenu()
|
||||||
}
|
}
|
||||||
|
self.updateAccessibilityDecorationSummary()
|
||||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||||
}
|
}
|
||||||
selectionController.pageProvider = { [weak self] in self?.currentPage }
|
selectionController.pageProvider = { [weak self] in self?.currentPage }
|
||||||
@ -250,6 +253,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||||
overlayView.applyDecorations(fgDecorations)
|
overlayView.applyDecorations(fgDecorations)
|
||||||
#endif
|
#endif
|
||||||
|
updateAccessibilityDecorationSummary()
|
||||||
|
|
||||||
delegate?.textContentView(self, didChangeSelection: nil)
|
delegate?.textContentView(self, didChangeSelection: nil)
|
||||||
setNeedsLayout()
|
setNeedsLayout()
|
||||||
@ -262,6 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||||
overlayView.clearSelection()
|
overlayView.clearSelection()
|
||||||
backgroundOverlayView.clearSelection()
|
backgroundOverlayView.clearSelection()
|
||||||
|
updateAccessibilityDecorationSummary()
|
||||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,13 +575,21 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func updateAccessibilityDecorationSummary() {
|
||||||
|
#if canImport(DTCoreText)
|
||||||
|
coreTextContentView.accessibilityValue = [
|
||||||
|
backgroundOverlayView.decorationSummary(),
|
||||||
|
overlayView.decorationSummary()
|
||||||
|
].joined(separator: " | ")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
|
||||||
guard let page = currentPage else { return nil }
|
guard let page = currentPage else { return nil }
|
||||||
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
|
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
|
||||||
guard let absoluteRange else { return nil }
|
guard let absoluteRange else { return nil }
|
||||||
let matches = currentHighlights.filter { highlight in
|
let matches = currentHighlights.filter { highlight in
|
||||||
guard highlight.location.href == page.href,
|
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||||||
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return NSIntersectionRange(range, absoluteRange).length > 0
|
return NSIntersectionRange(range, absoluteRange).length > 0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user