feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip) - 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示 - 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配 - 新增 RDEPUBSelectionLoupeView 选区放大镜 - 新增 MetadataParseWorker/CancellationController 元数据解析取消机制 - 重构 PresentationRuntime/PaginationCoordinator 精简职责 - 优化 ChapterLoader/WarmupOrchestrator 异步章节加载 - CFI 模块微调与 NoteModels 更新 - 清理冗余文档,更新架构/UML/业务逻辑文档 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,265 +0,0 @@
|
||||
# Reflowable EPUB 使用 WXRead 风格原生渲染:详细设计
|
||||
|
||||
> 文档目的:讨论并固化“将 ReadViewSDK 的 reflowable EPUB 渲染/排版/分页改为参考 Doc/WXRead 的读书(WXRead)原生渲染方式”的可落地设计,供后续开发与回归使用。
|
||||
> 版本:v0(设计草案)
|
||||
> 日期:2026-05-21
|
||||
|
||||
## 0. 背景与结论(先说人话)
|
||||
|
||||
ReadViewSDK 当前对 EPUB 有三类渲染路径:
|
||||
|
||||
- `RDEPUBReadingProfile.webFixedLayout`:Fixed Layout EPUB → `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.webInteractive`:交互式 EPUB(JS/音视频/表单/iframe/外链/bridge)→ `WKWebView`(保持不变)
|
||||
- `RDEPUBReadingProfile.textReflowable`:普通 reflowable EPUB → **当前走 DTCoreText → NSAttributedString → CoreText 分页 → 原生文本视图**(这是我们要“升级成 WXRead 风格”的主战场)
|
||||
|
||||
本次改造的最小可落地方向是:**保留三分流策略不变**,只增强 `.textReflowable` 分支,使其在“CSS 分层、样式一致性、资源解析、分页稳定性”等方面更接近 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 所描述的 WXRead 管线,而不是引入新的 WebView 渲染。
|
||||
|
||||
## 1. 目标 / 非目标
|
||||
|
||||
### 1.1 目标(In Scope)
|
||||
|
||||
- G1:reflowable EPUB 的正文渲染/排版/分页改为“WXRead 风格原生渲染管线”:
|
||||
- XHTML/HTML →(CSS 分层 + 解析 + 后处理)→ `NSAttributedString`
|
||||
- `NSAttributedString` →(CoreText 分页)→ 单页内容
|
||||
- 单页内容 → 原生绘制/展示
|
||||
- G2:保持 Fixed Layout 与交互式 EPUB 的 `WKWebView` 路径不回归。
|
||||
- G3:不破坏 `RDURLReaderController` 打开 `.epub` / `.txt` 的主流程。
|
||||
- G4:Demo 可用于验证:至少 2-3 本典型 reflowable EPUB 在段落/标题/图片/链接等常见内容下可稳定阅读。
|
||||
|
||||
### 1.2 非目标(Out of Scope)
|
||||
|
||||
- N1:Fixed Layout EPUB 切换为原生渲染(明确不做)。
|
||||
- N2:交互式 EPUB 切换为原生渲染(明确不做)。
|
||||
- N3:一次性复刻 WXRead 对 DTCoreText 的所有深度魔改(例如自定义 CSS 属性体系、复杂后处理、分页避断规则等)——本次先按“问题驱动”逐步对齐。
|
||||
|
||||
## 2. 关键事实核验(当前代码真实路径)
|
||||
|
||||
> 纠偏说明:项目初始化时曾把”reflowable EPUB 当前路径”概括为偏 `WKWebView` 的历史性表述。经本次代码核验,当前真实主路径是 `.textReflowable` → `RDEPUBTextBookBuilder` → `RDEPUBDTCoreTextRenderer` → CoreText 分页 → `RDEPUBTextContentView`。本设计以代码事实为准,并默认后续实现都按此理解推进。
|
||||
|
||||
### 2.1 渲染路径分流(已存在)
|
||||
|
||||
- 判定在 `Sources/RDReaderView/EPUBCore/RDEPUBParser+ReadingProfile.swift`:
|
||||
- `metadata.layout == .fixed` → `.webFixedLayout`
|
||||
- `hasInteractiveContent() == true` → `.webInteractive`
|
||||
- 否则 → `.textReflowable`
|
||||
|
||||
### 2.2 `.textReflowable` 当前实现(已存在,且是正确切入点)
|
||||
|
||||
`Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`:
|
||||
|
||||
- `publication.readingProfile == .textReflowable` 时:
|
||||
- 使用 `RDEPUBTextBookBuilder(renderer: resolvedTextRenderer())`
|
||||
- 默认 renderer 是 `RDEPUBDTCoreTextRenderer`(`#if canImport(DTCoreText)`)
|
||||
- 分页使用 `NSAttributedString.ss_pageRanges(size:)`(`CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`)
|
||||
- UI 展示使用 `RDEPUBTextContentView`
|
||||
|
||||
结论:我们不需要“新起一个阅读器”,只要把 `.textReflowable` 的 **渲染(typesetter)层**与部分 **分页策略**升级即可。
|
||||
|
||||
## 3. WXRead 参考模型(我们要对齐的最小子集)
|
||||
|
||||
来自 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 的管线:
|
||||
|
||||
1) `WREpubParser`:解析 EPUB 结构(spine、manifest、resourceMap)
|
||||
2) `WREpubTypesetter`:XHTML → `NSAttributedString`(CSS 级联 + HTML 解析 + 后处理)
|
||||
3) `WRCoreTextLayouter`:`NSAttributedString` → 分页布局(`CTTypesetter` + 分页算法)
|
||||
4) `WRCoreTextLayoutFrame`:单页 layout frame
|
||||
5) `WRPageView`:绘制到屏幕
|
||||
|
||||
本次设计对齐重点(最小集合):
|
||||
|
||||
- A:**CSS 分层与合成**(default/replace/dark/epub/user)并注入到渲染输入
|
||||
- B:**资源解析**(图片/CSS 的相对路径 baseURL)与稳定性保障
|
||||
- C:在现有分页基础上逐步迭代(先可用,后对齐“避免断页”等高级策略)
|
||||
|
||||
### 3.1 第一阶段实施假设(必须遵守)
|
||||
|
||||
- H1:**第一期只对齐“管线形态”和“CSS 分层策略”**,即把现有 `.textReflowable` renderer 增强为更接近 WXRead 的 typesetter 输入与样式组织方式。
|
||||
- H2:**第一期不实现 WXRead 对 DTCoreText 的深度魔改**,包括但不限于自定义 CSS 属性体系、复杂附件布局规则、完整的 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame` 等价分页器。
|
||||
- H3:当开发过程中遇到图片断页、复杂样式缺失、附件布局异常等问题时,默认先作为“第二阶段问题清单”记录;只有在它阻塞 `REND-01` / `STAB-02` 的最小验收时,才允许做局部补丁,而不是扩展为全面重写分页引擎。
|
||||
|
||||
## 4. 是否能直接使用 Doc/WXRead 中的 JS/CSS?
|
||||
|
||||
结论:**不建议、也不应该直接把“来自读书 App bundle 的私有 JS/CSS”拷贝进 SDK 作为产品代码**;但可以按以下原则“选择性使用”:
|
||||
|
||||
### 4.1 可以使用的情况(需满足其一)
|
||||
|
||||
- 文件本身带有明确开源许可证声明,且我们按许可证要求引入(保留 license、署名、NOTICE 等),并建议从官方 upstream 获取:
|
||||
- 例如 `Doc/WXRead/resources/js/rangy-core.js` 明确标注 MIT
|
||||
- 例如 `Doc/WXRead/resources/js/Readability.js` 明确标注 Apache-2.0
|
||||
|
||||
> 建议:即便文件里有 license 头,也优先从其原始开源仓库拉取对应版本,而不是从逆向提取的副本直接入库,以降低合规风险。
|
||||
|
||||
### 4.2 不建议/不能直接使用的情况
|
||||
|
||||
- 无明确许可证头、看起来是读书私有逻辑/样式(例如 `weread-highlighter.js`、`MediaPlatform.js`、`replace.css` 等):默认视为私有作品,不应直接拷贝使用。
|
||||
- 即便是“Safari 默认样式”类文件(例如 `default.css` 的注释提到 Safari),也不建议直接照搬;我们可以根据需求写一份“SDK 自己的 default.css / replace.css”,只实现必要规则。
|
||||
|
||||
### 4.3 对本次需求的实际影响
|
||||
|
||||
本次 reflowable EPUB 走原生渲染,不依赖 WebView,因此 **JS 不是本次必需**。
|
||||
CSS 方面我们需要的是“分层策略”和一小部分通用排版规则,可在 SDK 内重写为“WXRead 风格的默认样式集合”。
|
||||
|
||||
## 5. 详细设计(核心)
|
||||
|
||||
### 5.1 总体架构:在现有 `.textReflowable` 上增量替换 renderer
|
||||
|
||||
新增一个 renderer(实现 `RDEPUBTextRenderer`):
|
||||
|
||||
- `RDEPUBWXReadTextRenderer`(新)
|
||||
- 输入:`html: String`, `baseURL: URL?`, `style: RDEPUBTextRenderStyle`
|
||||
- 输出:`RDEPUBRenderedChapterContent`(`NSAttributedString` + `fragmentOffsets`)
|
||||
- 内部职责:
|
||||
1) 读取/生成 CSS 各层(default/replace/dark/user)
|
||||
2) 与 EPUB 自带 CSS 共同作用(通过 HTML 注入 + baseURL)
|
||||
3) 调用 DTCoreText builder 生成 attributedString
|
||||
4) 做最小后处理(段落间距/字体/颜色标准化、fragment marker 提取等)
|
||||
|
||||
切换点:
|
||||
|
||||
- 在 `RDEPUBReaderController.resolvedTextRenderer()`(或其配置位置)增加策略:当开关启用时选择 `RDEPUBWXReadTextRenderer()`,否则沿用 `RDEPUBDTCoreTextRenderer()`。
|
||||
- 建议默认先提供“实验开关”(仅 Demo / debug 可见),降低回归风险。
|
||||
|
||||
### 5.2 CSS 分层策略(WXRead 风格)
|
||||
|
||||
我们在 SDK 内实现与 `Doc/WXRead/analysis/EPUB渲染管线详解.md` 一致的分层概念,但不直接照搬其私有样式文件:
|
||||
|
||||
- Layer 1:`default.css`(SDK 自己维护的基础排版规则)
|
||||
- Layer 2:`replace.css`(SDK 自己维护的替换/增强规则:标题、代码块、图片最大宽度等)
|
||||
- Layer 3:`dark.css`(暗色主题覆盖,仅在暗色主题启用)
|
||||
- Layer 4:EPUB 嵌入 CSS(书籍自带,DTCoreText 解析 HTML 时自然生效;相对路径靠 baseURL)
|
||||
- Layer 5:用户设置 CSS(由 `RDEPUBTextRenderStyle` 动态生成:字体、字号、行高、背景色、文字色等)
|
||||
|
||||
实现方式(建议):
|
||||
|
||||
1) 新增 `RDEPUBWXReadStyleSheetBuilder`:
|
||||
- `func makeDefaultCSS() -> String`
|
||||
- `func makeReplaceCSS() -> String`
|
||||
- `func makeDarkCSS(theme: RDEPUBTheme) -> String?`
|
||||
- `func makeUserCSS(style: RDEPUBTextRenderStyle, theme: RDEPUBTheme) -> String`
|
||||
- `func composeCSS(...) -> String`(按层拼接,后层覆盖前层)
|
||||
|
||||
2) 在 renderer 中将合成后的 CSS 注入到 HTML:
|
||||
- 若存在 `<head>`:插入 `<style id="rd-wxread-layered-style">...`
|
||||
- 若不存在:在 `<html>` 后插入 `<head>...`
|
||||
- 保持原 HTML 内容尽量不改动(外部脚本/交互内容已被 readingProfile 判定剔除到 web 分支)
|
||||
|
||||
### 5.3 baseURL 与资源解析
|
||||
|
||||
目前 `RDEPUBTextBookBuilder` 传入:
|
||||
|
||||
- `baseURL: parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()`
|
||||
|
||||
原则:
|
||||
|
||||
- baseURL 必须是“章节文件所在目录”,以确保:
|
||||
- `<img src="...">` 相对路径可解析
|
||||
- `<link href="...">` CSS 相对路径可解析(如果 DTCoreText 支持)
|
||||
|
||||
待核验点(实现时做小实验):
|
||||
|
||||
- DTCoreText 对 `<link rel="stylesheet">` 的解析策略是否完整;若不完整:
|
||||
- 兜底策略:在渲染前解析 HTML 中的 `<link rel="stylesheet">`,读取 CSS 内容并内联到 `<style>`(仅限 `file://` 且位于 EPUB 解压目录内)。
|
||||
|
||||
### 5.4 分页策略(阶段性)
|
||||
|
||||
现状:
|
||||
|
||||
- `NSAttributedString.ss_pageRanges(size:)` 使用 `CTFramesetterCreateFrame` + `CTFrameGetVisibleStringRange`,属于“最小可用分页”。
|
||||
|
||||
WXRead 的更高阶策略(参考 `Doc/WXRead/analysis/DTCoreText自定义修改分析.md`)可能包含:
|
||||
|
||||
- 避免孤行/断页
|
||||
- 图片/附件的分页边界处理
|
||||
- 特定块元素的分页规则
|
||||
|
||||
本次建议:
|
||||
|
||||
- Phase 2:先保持现有分页算法,只要渲染输入(CSS 分层 + 后处理)到位,就能显著改善一致性。
|
||||
- Phase 3:针对真实书籍出现的问题,逐条补齐分页规则(问题驱动),避免一开始就引入复杂分页器导致风险扩大。
|
||||
|
||||
> 范围约束:如果某个分页问题需要引入“新的复杂分页器”或大规模模拟 `WRCoreTextLayouter` / `WRCoreTextLayoutFrame`,应先暂停并回到方案讨论,不默认并入第一期实现。
|
||||
|
||||
### 5.5 与现有高亮/搜索/位置映射的兼容
|
||||
|
||||
当前 `.textReflowable` 路径:
|
||||
|
||||
- 高亮/搜索依赖 `RDEPUBTextBook` 的 `fragmentOffsets` 与 `location/progression` 映射(见 `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift` 与 `RDEPUBTextBook` 的 `pageNumber(for:)` / `location(forPageNumber:)`)。
|
||||
|
||||
兼容策略:
|
||||
|
||||
- 继续使用现有的 fragment marker 注入与提取:
|
||||
- `RDEPUBTextRendererSupport.injectFragmentMarkers(...)`
|
||||
- `RDEPUBTextRendererSupport.extractFragmentOffsets(...)`
|
||||
- renderer 只改变“CSS 注入与 DTCoreText options/后处理”,不改变 marker 体系与 `RDEPUBTextBook` 数据结构,以降低 UI 层回归。
|
||||
|
||||
## 6. 开发落点(文件 / 类型 / 目录)
|
||||
|
||||
### 6.1 新增文件(建议位置)
|
||||
|
||||
放在 `Sources/RDReaderView/EPUBTextRendering/`(因为它是 textReflowable 的渲染与分页域):
|
||||
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadTextRenderer.swift`(新)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadStyleSheetBuilder.swift`(新)
|
||||
- (可选)`Sources/RDReaderView/EPUBTextRendering/RDEPUBWXReadHTMLPreprocessor.swift`(新:仅当需要内联 `<link>` CSS 时)
|
||||
|
||||
资源文件(建议):
|
||||
|
||||
- `Sources/RDReaderView/Resources/WXRead/default.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/replace.css`(新,SDK 自己写)
|
||||
- `Sources/RDReaderView/Resources/WXRead/dark.css`(新,SDK 自己写)
|
||||
|
||||
> 注意:这些资源需被 `RDReaderView.podspec` 的 resource bundle 覆盖到(当前资源 bundle 为 `RDReaderViewAssets`,来源 `Sources/RDReaderView/Resources/**`)。
|
||||
|
||||
### 6.2 改动文件(建议最小改动)
|
||||
|
||||
- `Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift`
|
||||
- 在 `resolvedTextRenderer()` 或相邻配置处增加选择逻辑(开关 / 版本策略)
|
||||
- `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift`
|
||||
- 如需内联 link CSS:在获取 `rawHTML` 后做预处理(保持接口不变)
|
||||
|
||||
## 7. 验收标准与验证方式(对应 REQUIREMENTS)
|
||||
|
||||
### 对应 REND-01 / REND-03
|
||||
|
||||
- 在 Demo 中打开 reflowable EPUB:
|
||||
- 正文渲染不依赖 `WKWebView`(可通过日志/断点确认不走 `RDEPUBWebContentView`)
|
||||
- CSS 分层生效:默认样式可控、主题/字号/行高变化可控
|
||||
- 图片/链接至少可正确显示/响应(链接行为按现有 text 内容策略)
|
||||
|
||||
### 对应 REND-02
|
||||
|
||||
- Fixed Layout EPUB:仍走 `.webFixedLayout` → `WKWebView` 路径
|
||||
- 交互式 EPUB:仍走 `.webInteractive` → `WKWebView` 路径(桥接与外链不回归)
|
||||
|
||||
### 对应 STAB-01 / STAB-02
|
||||
|
||||
- `RDURLReaderController` 打开 `.epub` / `.txt` 主流程不回归
|
||||
- 至少使用以下 3 类 reflowable EPUB 样本进行回归:
|
||||
- 样本 A:纯文本/小说类章节为主,验证基础段落、标题、分页与阅读位置恢复
|
||||
- 样本 B:包含内嵌图片与多段样式的章节,验证图片显示、图片前后分页、基础 CSS 生效
|
||||
- 样本 C:包含外链与多个 CSS 文件引用的章节,验证 baseURL、样式解析与链接呈现稳定性
|
||||
- 对以上样本的共同要求:不崩溃、不白屏、不无限加载;分页/翻页可用
|
||||
|
||||
## 8. 风险清单与降级策略
|
||||
|
||||
### 8.1 主要风险
|
||||
|
||||
- R1:DTCoreText 对 EPUB 内嵌 CSS / `<link>` CSS 支持不足,导致样式缺失
|
||||
- R2:分页质量不足(断页不美观、图片分页异常)
|
||||
- R3:`hasInteractiveContent()` 判定过宽,导致大量书被误判为 `.webInteractive`,覆盖率不足
|
||||
|
||||
### 8.2 降级/灰度(建议)
|
||||
|
||||
- D1:增加一个“渲染引擎开关”(仅 debug 或 demo 可配置),可在出现严重问题时快速回退到现有 `RDEPUBDTCoreTextRenderer`
|
||||
- D2:对 `hasInteractiveContent()` 的判定提供可配置白名单/黑名单(例如按 manifest properties、按 tag 命中级别)
|
||||
|
||||
## 9. 下一步(交接到开发)
|
||||
|
||||
建议按 `Doc/ARCHITECTURE-CONTEXT.md` 中的架构决策逐步推进:
|
||||
|
||||
- 先基于 `Doc/WXRead/analysis/*` 提炼“我们要实现的 CSS 分层最小集合”
|
||||
- 再在 `.textReflowable` renderer 里实现“分层 CSS 注入 + baseURL/资源解析兜底”
|
||||
- 最后用 Demo 书籍做回归,按问题驱动补齐分页/样式细节
|
||||
|
||||
---
|
||||
*Last updated: 2026-05-21 after discuss-feature-solution*
|
||||
@@ -1,365 +0,0 @@
|
||||
# 将高亮选区实现 1:1 复刻为 WXRead 架构
|
||||
|
||||
## Context
|
||||
|
||||
当前 ReadViewSDK 的高亮选区实现与 WXRead 存在根本性架构差异。需要将选区系统从"UITextView 透明代理 + 独立 overlay 层"迁移到 WXRead 的"自定义手势 + CoreText 直接命中测试 + 统一 drawRect 绘制"架构。
|
||||
|
||||
## 核心差异对比
|
||||
|
||||
| 维度 | 当前 ReadViewSDK | WXRead |
|
||||
|------|-----------------|--------|
|
||||
| 选择触发 | UITextView 原生长按(透明文本) | 自定义 long-press(0.5s) + pan 手势 |
|
||||
| 命中测试 | DTCoreText `stringIndex(forPosition:)` | `CTLineGetStringIndexForPosition` + 坐标翻转 |
|
||||
| 选区绘制 | 独立 `RDEPUBSelectionOverlayView` overlay 层 | 同一 `drawRect:` 内绘制(文字+高亮+选区) |
|
||||
| 高亮绘制 | overlay 层计算 rect 后 CG 填充 | `com.weread.highlight` 自定义属性注入 NSAttributedString,在 `drawInContext:` 中读取绘制 |
|
||||
| 菜单系统 | 自定义 `selectionActionBar` UIStackView | `UIMenuController` + 自定义 items |
|
||||
| 手势模型 | 无 pan 手势(UITextView 自带拖拽) | long-press 启动 + pan 扩展,`isSelecting` 控制 pan 启停 |
|
||||
| 视图层级 | 3 层 overlay(background + text + foreground) | 单一 WRPageView 统一绘制 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 移除 UITextView,改用自定义手势 + CoreText 命中测试
|
||||
|
||||
### 1.1 修改 `RDEPUBPageInteractionController.swift`
|
||||
|
||||
当前已正确封装 DTCoreText 的 `stringIndex(forPosition:)` 和 `offset(forStringIndex:)`,算法与 WXRead 一致。
|
||||
|
||||
**新增方法:**
|
||||
- `characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView)` — 将 UIKit 坐标转为相对于 content view 的坐标后调用 `characterIndex(at:)`,对应 WXRead 的 `stringIndexForPoint:` + `WRSFlipPointForCoreText`
|
||||
|
||||
> 注意:DTCoreText 已在内部处理了 UIKit↔CoreText 坐标翻转(`line.baselineOrigin` 是 UIKit 坐标),所以不需要手动翻转 Y 轴。但 WXRead 的自定义 DTCoreText 需要手动翻转。当前项目用的是原版 DTCoreText pod,行为已正确。
|
||||
|
||||
### 1.2 重构 `RDEPUBTextSelectionController.swift`
|
||||
|
||||
**当前状态:** 遵循 `UITextViewDelegate`,通过 `textViewDidChangeSelection` 接收选区变化。`handleLongPress` 方法存在但未被任何手势调用。
|
||||
|
||||
**改为:**
|
||||
- 移除 `UITextViewDelegate` 遵循
|
||||
- 移除 `textViewDidChangeSelection(_:)` 和 `textViewDidChangeSelection(_:, page:)`
|
||||
- 新增状态属性:`selectionStartIndex: Int = NSNotFound`、`selectionEndIndex: Int = NSNotFound`、`isSelecting: Bool = false`
|
||||
- 重构 `handleLongPress`:
|
||||
- `.began`:调用 `characterIndex(at:)` 设置 `selectionStartIndex = selectionEndIndex = index`,设 `isSelecting = true`
|
||||
- `.ended`:设 `isSelectionFromInteraction = false`(保留,供后续扩展)
|
||||
- 新增 `handlePan(_ gesture:, page:, renderView:, interactionController:)`:
|
||||
- `.changed`:计算字符索引,更新 `selectionEndIndex`,计算 range = `(min, max - min)`,计算 rects,更新 renderView
|
||||
- 移除 `clearSelection` 的 `textView:` 参数
|
||||
- `makeSelection(from:, page:)` 保持不变(已正确基于绝对偏移构建 `RDEPUBSelection`)
|
||||
|
||||
### 1.3 重构 `RDEPUBTextContentView.swift` — 移除 UITextView
|
||||
|
||||
**删除:**
|
||||
- `textView: RDEPUBSelectableTextView` 属性及其初始化
|
||||
- `selectionProxyContent(from:)` 方法
|
||||
- `textView.delegate = selectionController` 等 textView 配置代码
|
||||
- `textView.frame = ...` 在 `layoutSubviews` 中的设置
|
||||
- `configure(page:...)` 中所有 `textView.attributedText = ...`、`textView.selectedRange = ...`、`textView.isHidden = ...` 赋值
|
||||
|
||||
**新增手势识别器(对齐 WXRead 的 WRPageView):**
|
||||
```swift
|
||||
private let longPressGR = UILongPressGestureRecognizer(target: ..., action: #selector(handleLongPress))
|
||||
private let panGR = UIPanGestureRecognizer(target: ..., action: #selector(handlePan))
|
||||
private let tapGR = UITapGestureRecognizer(target: ..., action: #selector(handleTap))
|
||||
```
|
||||
- `longPressGR.minimumPressDuration = 0.5`(与 WXRead 一致)
|
||||
- `panGR.isEnabled = false`(初始禁用,long-press began 时启用)
|
||||
- `tapGR.require(toFail: longPressGR)`(与 WXRead 一致)
|
||||
- 三个手势都添加到 contentView 自身
|
||||
|
||||
**手势响应:**
|
||||
- `handleLongPress`:转发给 `selectionController.handleLongPress`,启用 `panGR`
|
||||
- `handlePan`:转发给 `selectionController.handlePan`
|
||||
- `handleTap`:如果 `selectionController.isSelecting` 则 `clearSelection()`,否则转发给 delegate 做工具栏切换
|
||||
|
||||
**菜单改为 UIMenuController(对齐 WXRead):**
|
||||
- 删除 `selectionActionBar: UIStackView` 及相关方法(`showSelectionActionBarIfNeeded`、`hideSelectionActionBar`、`updateSelectionActionBarFrame`、`selectionMenuButton`)
|
||||
- 在 `selectionController.onSelectionChanged` 回调中,当 selection 非 nil 时调用 `showSelectionMenu(in:anchorRect:)`
|
||||
- `RDEPUBTextContentView` 设为 `canBecomeFirstResponder = true`,override `canPerformAction` 仅允许三个自定义 selector
|
||||
- 使用 `UIMenuController.shared` 配置 "拷贝"/"高亮"/"批注" 三个 `UIMenuItem`
|
||||
|
||||
**调整 `clearSelection()`:**
|
||||
```swift
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
panGR.isEnabled = false
|
||||
selectionController.clearSelection(overlayView: overlayView, backgroundOverlayView: backgroundOverlayView)
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 删除 `RDEPUBSelectableTextView.swift`
|
||||
|
||||
该文件的功能(屏蔽系统菜单、暴露自定义 action)已被 UIMenuController 方案替代,直接删除。
|
||||
|
||||
### 1.5 更新 `RDEPUBReaderController+ContentDelegates.swift`
|
||||
|
||||
`textContentView(_:, didRequestSelectionAction:, selection:)` 中的 `contentView.clearSelection()` 调用无需改动,新的 `clearSelection()` 签名兼容。
|
||||
|
||||
### Phase 1 验证
|
||||
- UI 测试 `ReaderAnnotationTests.testSelectionMenuCreatesHighlight` 必须通过
|
||||
- 手动验证:长按选词 → 弹出 UIMenuController → 点击"高亮" → 高亮创建成功
|
||||
- 手动验证:拖拽扩展选区 → 蓝色选区矩形正确绘制
|
||||
- 手动验证:单击空白处 → 选区清除
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 统一绘制循环 — 高亮/选区在 draw(_:) 中绘制
|
||||
|
||||
### 2.1 扩展 `RDEPUBTextPageRenderView.swift`
|
||||
|
||||
**当前状态:** 仅调用 `layoutFrame.draw(in: context, options:)` 绘制文字。
|
||||
|
||||
**新增属性:**
|
||||
```swift
|
||||
var highlightRanges: [(range: NSRange, color: UIColor)] = [] { didSet { setNeedsDisplay() } }
|
||||
var underlineRanges: [(range: NSRange, color: UIColor, style: Int)] = [] { didSet { setNeedsDisplay() } }
|
||||
var selectionRects: [CGRect] = [] { didSet { setNeedsDisplay() } }
|
||||
var selectionColor: UIColor = UIColor(red: 70/255, green: 140/255, blue: 1, alpha: 0.24)
|
||||
```
|
||||
|
||||
**扩展 `draw(_:)`:**
|
||||
```swift
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(), let layoutFrame else { return }
|
||||
context.saveGState()
|
||||
|
||||
// 1. 绘制高亮背景(在文字下方,匹配 WXRead 的 drawHighlightsInContext:)
|
||||
drawHighlights(in: context, layoutFrame: layoutFrame)
|
||||
|
||||
// 2. 绘制文字
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
|
||||
// 3. 绘制选区(在文字上方,匹配 WXRead 的 _drawSelectionInContext:)
|
||||
drawSelection(in: context)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
```
|
||||
|
||||
**`drawHighlights` 算法(对齐 WXRead `WRCoreTextLayoutFrame.drawHighlightsInContext:`):**
|
||||
```swift
|
||||
private func drawHighlights(in context: CGContext, layoutFrame: DTCoreTextLayoutFrame) {
|
||||
for (range, color) in highlightRanges {
|
||||
let lines = layoutFrame.lines as! [DTCoreTextLayoutLine]
|
||||
for line in lines {
|
||||
let overlap = NSIntersectionRange(range, line.stringRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let startX = line.offset(forStringIndex: overlap.location)
|
||||
let endX = line.offset(forStringIndex: overlap.location + overlap.length)
|
||||
let rect = CGRect(
|
||||
x: line.baselineOrigin.x + startX,
|
||||
y: line.baselineOrigin.y - line.ascent,
|
||||
width: endX - startX,
|
||||
height: line.ascent + line.descent
|
||||
)
|
||||
color.withAlphaComponent(0.35).setFill() // WXRead 使用 35% alpha
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 说明:WXRead 使用 `[color colorWithAlphaComponent:0.3]`,WRPageHighlight 的预设色本身已是 35% alpha,最终效果等同。当前项目使用 0.45 alpha,需调整为 0.35 以完全对齐。
|
||||
|
||||
**`drawSelection` 算法(对齐 WXRead `_drawSelectionInContext:`):**
|
||||
```swift
|
||||
private func drawSelection(in context: CGContext) {
|
||||
guard !selectionRects.isEmpty else { return }
|
||||
selectionColor.setFill()
|
||||
for rect in selectionRects {
|
||||
context.fill(rect)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 简化 `RDEPUBTextContentView.swift` 视图层级
|
||||
|
||||
**删除/保留:**
|
||||
- 删除 `backgroundOverlayView` 属性(高亮背景现在由 renderView 在文字下方绘制)
|
||||
- 保留 `overlayView`(用于非 DTCoreText 回退路径和搜索高亮)
|
||||
- `configure(page:...)` 中,将 highlight 数据传给 `coreTextContentView` 而非 overlayView:
|
||||
|
||||
```swift
|
||||
// DTCoreText 路径
|
||||
coreTextContentView.highlightRanges = highlights.compactMap { highlight -> (NSRange, UIColor)? in
|
||||
guard let rangeInfo = highlight.rangeInfo,
|
||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { return nil }
|
||||
let absoluteRange = info.nsRange
|
||||
let overlap = NSIntersectionRange(absoluteRange, pageAbsoluteRange)
|
||||
guard overlap.length > 0 else { return nil }
|
||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
||||
return (relativeRange, highlight.uiColor)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 更新 `RDEPUBTextSelectionController.swift` — 直接更新 renderView
|
||||
|
||||
- `handleLongPress` 和 `handlePan` 现在直接更新 `renderView.selectionRects` 并调用 `renderView.setNeedsDisplay()`
|
||||
- 移除 `overlayView.updateSelection(absoluteRange:, rects:)` 调用
|
||||
|
||||
### Phase 2 验证
|
||||
- 视觉对比:高亮矩形与文字像素对齐
|
||||
- 性能测试:单次 `draw(_:)` 耗时应与之前持平或更快
|
||||
- 回归测试:搜索高亮仍正常显示
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 高亮属性注入 NSAttributedString(对齐 WXRead `com.weread.highlight`)
|
||||
|
||||
### 3.1 定义自定义属性常量
|
||||
|
||||
```swift
|
||||
// 对齐 WXRead 的 kWRHighlightAttributeName / kWRUnderlineAttributeName
|
||||
let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
|
||||
let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
|
||||
```
|
||||
|
||||
### 3.2 新增 `RDEPUBChapterData.applyHighlights(to:page:highlights:)`
|
||||
|
||||
对齐 WXRead 的 `WRChapterData.addHighlightInRange:key:itemId:color:`:
|
||||
|
||||
```swift
|
||||
func applyHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight]
|
||||
) {
|
||||
for highlight in highlights {
|
||||
guard let rangeInfo = highlight.rangeInfo,
|
||||
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo) else { continue }
|
||||
let absoluteRange = info.nsRange
|
||||
let pageRange = NSRange(location: page.pageStartOffset, length: page.pageEndOffset - page.pageStartOffset)
|
||||
let overlap = NSIntersectionRange(absoluteRange, pageRange)
|
||||
guard overlap.length > 0 else { continue }
|
||||
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
|
||||
|
||||
switch highlight.style {
|
||||
case .highlight:
|
||||
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
case .underline:
|
||||
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 `RDEPUBTextPageRenderView.draw(_:)` 从属性读取高亮
|
||||
|
||||
替代 Phase 2 的 `highlightRanges` 属性方案,改为在 `draw(_:)` 中枚举 attributed string 的自定义属性:
|
||||
|
||||
```swift
|
||||
private func drawHighlightsFromAttributes(in context: CGContext, attributedString: NSAttributedString) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeRects(for: range) // 复用 line 迭代 + CTLineGetOffsetForStringIndex
|
||||
color.withAlphaComponent(0.35).setFill()
|
||||
for rect in rects { context.fill(rect) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 更新 `RDEPUBTextContentView.configure(page:...)`
|
||||
|
||||
```swift
|
||||
// 在传给 renderView 之前注入高亮属性
|
||||
let displayContent = darkImageAdjustedContentIfNeeded(...)
|
||||
chapterData.applyHighlights(to: displayContent, page: page, highlights: highlights)
|
||||
coreTextContentView.attributedDisplayContent = displayContent // 新增属性
|
||||
```
|
||||
|
||||
### Phase 3 验证
|
||||
- 高亮在翻页后仍正确显示(属性嵌入 attributed string,不依赖外部状态)
|
||||
- 高亮颜色、alpha、rect 与 WXRead 截图一致
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 手势模型对齐 — long-press + pan + isSelecting
|
||||
|
||||
### 4.1 手势冲突处理
|
||||
|
||||
**风险:** pan 手势(选区扩展)可能与 RDReaderView 的翻页手势冲突。
|
||||
|
||||
**解决方案(对齐 WXRead):**
|
||||
- `panGR` 初始 `isEnabled = false`,仅在 `isSelecting = true` 时启用
|
||||
- `clearSelection()` 时禁用 `panGR`
|
||||
- 新增 delegate 方法通知父视图:
|
||||
```swift
|
||||
func textContentViewDidBeginSelection(_ contentView: RDEPUBTextContentView)
|
||||
func textContentViewDidEndSelection(_ contentView: RDEPUBTextContentView)
|
||||
```
|
||||
- `RDReaderView` 在 `didBeginSelection` 时禁用翻页手势,在 `didEndSelection` 时恢复
|
||||
|
||||
### 4.2 WXRead 手势时序对齐
|
||||
|
||||
WXRead 的手势流程:
|
||||
1. long-press `.began` → 设置 `selectionStartIndex = selectionEndIndex = index`,`isSelecting = true`,启用 panGR,`setNeedsDisplay`
|
||||
2. long-press `.ended` → 无额外操作(保留选区)
|
||||
3. pan `.changed` → 更新 `selectionEndIndex`,计算 rects,`setNeedsDisplay`
|
||||
4. single tap → `clearSelection()`,禁用 panGR
|
||||
|
||||
### Phase 4 验证
|
||||
- 长按选词 → 拖拽扩展 → 单击取消,全流程流畅
|
||||
- 无选区时翻页手势正常
|
||||
- 有选区时翻页手势被禁用
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 菜单系统对齐 + 清理
|
||||
|
||||
### 5.1 UIMenuController 替换 selectionActionBar
|
||||
|
||||
已在 Phase 1 中完成。此阶段仅做清理:
|
||||
- 删除 `RDEPUBSelectableTextView.swift`
|
||||
- 标记 `RDEPUBSelectionOverlayView.swift` 和 `RDEPUBTextPageDecorationView.swift` 为 deprecated(保留给非 DTCoreText 回退路径)
|
||||
|
||||
### 5.2 更新 UI 测试
|
||||
|
||||
`ReaderAnnotationTests` 中查找菜单项的方式需更新:
|
||||
- 当前:`app.buttons["高亮"]`(UIStackView 中的按钮)
|
||||
- 改为:`app.menuItems["高亮"]`(UIMenuController 的菜单项)
|
||||
- 或者:保留 `accessibilityIdentifier` 在 RDEPUBTextContentView 上以便测试定位
|
||||
|
||||
### 5.3 高亮颜色对齐
|
||||
|
||||
WXRead 的 5 种预设色(35% alpha):
|
||||
- Yellow: `(1.0, 0.92, 0.23, 0.35)`
|
||||
- Blue: `(0.26, 0.65, 0.96, 0.35)`
|
||||
- Red: `(0.96, 0.26, 0.26, 0.35)`
|
||||
- Green: `(0.30, 0.85, 0.39, 0.35)`
|
||||
- Purple: `(0.67, 0.33, 0.97, 0.35)`
|
||||
|
||||
当前项目使用 CSS hex 颜色 + 0.45 alpha,需对齐为 WXRead 的 RGBA 值。
|
||||
|
||||
---
|
||||
|
||||
## 文件变更清单
|
||||
|
||||
| 文件 | 操作 | Phase |
|
||||
|------|------|-------|
|
||||
| `RDEPUBPageInteractionController.swift` | 修改:新增 `characterIndexForViewPoint` | 1 |
|
||||
| `RDEPUBTextSelectionController.swift` | 重构:移除 UITextViewDelegate,新增 pan 处理、状态机 | 1 |
|
||||
| `RDEPUBTextContentView.swift` | 重构:移除 textView,新增手势,替换菜单,简化层级 | 1,2,4 |
|
||||
| `RDEPUBSelectableTextView.swift` | **删除** | 1 |
|
||||
| `RDEPUBTextPageRenderView.swift` | 扩展:新增高亮/选区/下划线绘制逻辑 | 2,3 |
|
||||
| `RDEPUBChapterData.swift` | 新增:`applyHighlights(to:page:highlights:)` | 3 |
|
||||
| `RDEPUBReaderController+ContentDelegates.swift` | 小改:适配新 clearSelection 签名 | 1 |
|
||||
| `RDReaderView.swift` | 新增:选区期间禁用翻页手势 | 4 |
|
||||
| `RDEPUBSelectionOverlayView.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `RDEPUBTextPageDecorationView.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `RDEPUBTextAnnotationOverlay.swift` | 保留(deprecated for DTCoreText path) | 5 |
|
||||
| `ReaderAnnotationTests.swift` | 更新:菜单项查找方式 | 5 |
|
||||
|
||||
## 验证方案
|
||||
|
||||
1. **UI 测试**:`ReaderAnnotationTests` 全部通过
|
||||
2. **手动测试**:
|
||||
- 长按选词 → 蓝色选区高亮显示
|
||||
- 拖拽扩展选区 → 选区跟随手指
|
||||
- 点击"高亮" → 黄色高亮创建成功
|
||||
- 翻页后返回 → 高亮仍存在
|
||||
- 点击"拷贝" → 文本已复制
|
||||
- 点击"批注" → 弹出笔记输入框
|
||||
- 单击空白处 → 选区清除
|
||||
3. **性能测试**:`draw(_:)` 耗时 ≤ 之前(单次绘制 vs 三次绘制)
|
||||
4. **对比验证**:与 WXRead 截图对比高亮颜色、alpha、rect 位置
|
||||
Reference in New Issue
Block a user