refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案

- 移除 RDEPUBSelectableTextView,改用原生 UITextView
- 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮
- RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区
- RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制
- 新增高亮选区复刻 WXRead 实现方案文档
- UI 测试适配新架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei 2026-06-01 20:57:27 +08:00
parent 948004eed1
commit 70133e4e6e
13 changed files with 801 additions and 510 deletions

View File

@ -0,0 +1,365 @@
# 将高亮选区实现 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 层 overlaybackground + 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 位置

View File

@ -60,7 +60,6 @@
21A7DDFC2A430E9105419DF3DC2D4AA5 /* RDEPUBTextBuildPipelineInterfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2055F08A4A307EA51828460481FE8276 /* RDEPUBTextBuildPipelineInterfaces.swift */; }; 21A7DDFC2A430E9105419DF3DC2D4AA5 /* RDEPUBTextBuildPipelineInterfaces.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2055F08A4A307EA51828460481FE8276 /* RDEPUBTextBuildPipelineInterfaces.swift */; };
2203356EEA94A9172BC7902923E43CBE /* DTCoreTextFontCollection.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B7771FDE9AB1688166584C0AA03F12A /* DTCoreTextFontCollection.h */; settings = {ATTRIBUTES = (Public, ); }; }; 2203356EEA94A9172BC7902923E43CBE /* DTCoreTextFontCollection.h in Headers */ = {isa = PBXBuildFile; fileRef = 0B7771FDE9AB1688166584C0AA03F12A /* DTCoreTextFontCollection.h */; settings = {ATTRIBUTES = (Public, ); }; };
22AEC3422EFA234293F90AB3738CE694 /* RDEPUBReaderTableOfContentsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F09F20128209EC507BBD2E73457DCD /* RDEPUBReaderTableOfContentsItem.swift */; }; 22AEC3422EFA234293F90AB3738CE694 /* RDEPUBReaderTableOfContentsItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F09F20128209EC507BBD2E73457DCD /* RDEPUBReaderTableOfContentsItem.swift */; };
22FD1F39B4B94E9E33FDDF0C6880AA53 /* RDEPUBSelectableTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A65FCB87389CF471A9A4BD659D58346 /* RDEPUBSelectableTextView.swift */; };
24395BEB3C360D9DA5180BFB955B04A0 /* DTAttributedLabel.m in Sources */ = {isa = PBXBuildFile; fileRef = 21BAA93E67D8CBC5297302D798FB4F7A /* DTAttributedLabel.m */; }; 24395BEB3C360D9DA5180BFB955B04A0 /* DTAttributedLabel.m in Sources */ = {isa = PBXBuildFile; fileRef = 21BAA93E67D8CBC5297302D798FB4F7A /* DTAttributedLabel.m */; };
2445709667CB3820E51CD4AA915E6943 /* DTHTMLParserTextNode.h in Headers */ = {isa = PBXBuildFile; fileRef = C30D143134EEC5D4BA99459104B76A2E /* DTHTMLParserTextNode.h */; settings = {ATTRIBUTES = (Public, ); }; }; 2445709667CB3820E51CD4AA915E6943 /* DTHTMLParserTextNode.h in Headers */ = {isa = PBXBuildFile; fileRef = C30D143134EEC5D4BA99459104B76A2E /* DTHTMLParserTextNode.h */; settings = {ATTRIBUTES = (Public, ); }; };
24C5B52FD2AA9C49731DC6859ABA7CDE /* NSDictionary+DTCoreText.m in Sources */ = {isa = PBXBuildFile; fileRef = 2C50CAB4241C141EF6EC9ED22AB3A7E8 /* NSDictionary+DTCoreText.m */; }; 24C5B52FD2AA9C49731DC6859ABA7CDE /* NSDictionary+DTCoreText.m in Sources */ = {isa = PBXBuildFile; fileRef = 2C50CAB4241C141EF6EC9ED22AB3A7E8 /* NSDictionary+DTCoreText.m */; };
@ -559,7 +558,6 @@
09C2D4A3D4F602D125FF38B56C91CF84 /* ZIPFoundation.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = ZIPFoundation.modulemap; sourceTree = "<group>"; }; 09C2D4A3D4F602D125FF38B56C91CF84 /* ZIPFoundation.modulemap */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.module; path = ZIPFoundation.modulemap; sourceTree = "<group>"; };
09C957F3199C596781EFA1DEA1DDCEC8 /* NSFileWrapper+DTCopying.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSFileWrapper+DTCopying.h"; path = "Core/Source/NSFileWrapper+DTCopying.h"; sourceTree = "<group>"; }; 09C957F3199C596781EFA1DEA1DDCEC8 /* NSFileWrapper+DTCopying.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSFileWrapper+DTCopying.h"; path = "Core/Source/NSFileWrapper+DTCopying.h"; sourceTree = "<group>"; };
0A294C65CC6C154EF2122380953FEFE3 /* RDReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderView.swift; sourceTree = "<group>"; }; 0A294C65CC6C154EF2122380953FEFE3 /* RDReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderView.swift; sourceTree = "<group>"; };
0A65FCB87389CF471A9A4BD659D58346 /* RDEPUBSelectableTextView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectableTextView.swift; sourceTree = "<group>"; };
0B10D3EC1701BBA4D69926915F942558 /* SnapKit-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SnapKit-umbrella.h"; sourceTree = "<group>"; }; 0B10D3EC1701BBA4D69926915F942558 /* SnapKit-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SnapKit-umbrella.h"; sourceTree = "<group>"; };
0B7771FDE9AB1688166584C0AA03F12A /* DTCoreTextFontCollection.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextFontCollection.h; path = Core/Source/DTCoreTextFontCollection.h; sourceTree = "<group>"; }; 0B7771FDE9AB1688166584C0AA03F12A /* DTCoreTextFontCollection.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextFontCollection.h; path = Core/Source/DTCoreTextFontCollection.h; sourceTree = "<group>"; };
0B832537E4E508747319F7DF89BE256A /* RDEPUBChapterTailNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterTailNormalizer.swift; sourceTree = "<group>"; }; 0B832537E4E508747319F7DF89BE256A /* RDEPUBChapterTailNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterTailNormalizer.swift; sourceTree = "<group>"; };
@ -1460,7 +1458,6 @@
children = ( children = (
CA31C1F2D2AF87B527C261FF3EF62C58 /* RDEPUBPageInteractionController.swift */, CA31C1F2D2AF87B527C261FF3EF62C58 /* RDEPUBPageInteractionController.swift */,
19F9801AD3675102B14F64AFAFB99D89 /* RDEPUBPageLayoutSnapshot.swift */, 19F9801AD3675102B14F64AFAFB99D89 /* RDEPUBPageLayoutSnapshot.swift */,
0A65FCB87389CF471A9A4BD659D58346 /* RDEPUBSelectableTextView.swift */,
185CD1A4E30E72715AF3129924F063E6 /* RDEPUBSelectionOverlayView.swift */, 185CD1A4E30E72715AF3129924F063E6 /* RDEPUBSelectionOverlayView.swift */,
2F910EB56A7325328DCD94F082E99648 /* RDEPUBTextAnnotationOverlay.swift */, 2F910EB56A7325328DCD94F082E99648 /* RDEPUBTextAnnotationOverlay.swift */,
4B789EEAB8791D76E537C6186EA1AC1D /* RDEPUBTextContentView.swift */, 4B789EEAB8791D76E537C6186EA1AC1D /* RDEPUBTextContentView.swift */,
@ -2521,7 +2518,6 @@
7C026FA594F06339EAAEB774A776A030 /* RDEPUBResourceURLSchemeHandler.swift in Sources */, 7C026FA594F06339EAAEB774A776A030 /* RDEPUBResourceURLSchemeHandler.swift in Sources */,
4D00F4DD1B8D720FFA715756C7D70D40 /* RDEPUBSearchEngine.swift in Sources */, 4D00F4DD1B8D720FFA715756C7D70D40 /* RDEPUBSearchEngine.swift in Sources */,
53240F8A0E98A48B59C6B2FA88270310 /* RDEPUBSearchModels.swift in Sources */, 53240F8A0E98A48B59C6B2FA88270310 /* RDEPUBSearchModels.swift in Sources */,
22FD1F39B4B94E9E33FDDF0C6880AA53 /* RDEPUBSelectableTextView.swift in Sources */,
44B918236776349CDE1D85C70EDD7328 /* RDEPUBSelectionOverlayView.swift in Sources */, 44B918236776349CDE1D85C70EDD7328 /* RDEPUBSelectionOverlayView.swift in Sources */,
F77B604D4BCCE6CBE8520E588343F0A1 /* RDEPUBSemanticMarkerInjector.swift in Sources */, F77B604D4BCCE6CBE8520E588343F0A1 /* RDEPUBSemanticMarkerInjector.swift in Sources */,
0A37B6C1B26290A715DB47A6D750293D /* RDEPUBStyleSheetBuilder.swift in Sources */, 0A37B6C1B26290A715DB47A6D750293D /* RDEPUBStyleSheetBuilder.swift in Sources */,

View File

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1540"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DE070C1D2FBF0CC900ED065F"
BuildableName = "ReadViewDemo.app"
BlueprintName = "ReadViewDemo"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
codeCoverageEnabled = "NO">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7BE8E325ADE250D48D1A997A"
BuildableName = "ReadViewDemoUITests.xctest"
BlueprintName = "ReadViewDemoUITests"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DE070C1D2FBF0CC900ED065F"
BuildableName = "ReadViewDemo.app"
BlueprintName = "ReadViewDemo"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DE070C1D2FBF0CC900ED065F"
BuildableName = "ReadViewDemo.app"
BlueprintName = "ReadViewDemo"
ReferencedContainer = "container:ReadViewDemo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -21,17 +21,30 @@ final class ReaderAnnotationTests: XCTestCase {
app.waitForReaderState(containing: "selection=1", timeout: 5) app.waitForReaderState(containing: "selection=1", timeout: 5)
let highlightMenuItem = app.menuItems["高亮"] let highlightMenuItem = app.menuItems["高亮"]
let highlightButton = app.buttons["高亮"] let highlightButton = app.buttons[IDs.readerSelectionHighlight]
let highlightActionButton = app.buttons[IDs.readerSelectionHighlight]
if highlightMenuItem.waitForExistence(timeout: 3) { if highlightMenuItem.waitForExistence(timeout: 3) {
highlightMenuItem.tap() highlightMenuItem.tap()
} else if highlightActionButton.waitForExistence(timeout: 3) { } else if highlightButton.waitForExistence(timeout: 3) {
highlightActionButton.tap()
} else {
XCTAssertTrue(highlightButton.waitForExistence(timeout: 3), "选中文本后未出现高亮菜单")
highlightButton.tap() highlightButton.tap()
} else {
XCTFail("选中文本后未出现高亮菜单")
} }
app.waitForReaderState(containing: "highlights=1", timeout: 8) app.waitForReaderState(containing: "highlights=1", timeout: 8)
} }
func testLongPressSelectionDoesNotShowToolbars() {
app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader()
let content = app.textViews[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
let end = content.coordinate(withNormalizedOffset: CGVector(dx: 0.68, dy: 0.42))
start.press(forDuration: 0.6, thenDragTo: end)
app.waitForReaderState(containing: "selection=1", timeout: 5)
app.waitForReaderState(containing: "toolbar=hidden", timeout: 5)
}
} }

View File

@ -1,4 +1,15 @@
import Foundation import Foundation
import UIKit
// MARK: - NSAttributedString WXRead com.weread.highlight / com.weread.underline
/// NSAttributedString CoreText
/// WXRead kWRHighlightAttributeName = "com.weread.highlight"
public let kRDEPUBHighlightAttributeName = NSAttributedString.Key("com.rdreader.highlight")
/// 线
/// WXRead kWRUnderlineAttributeName = "com.weread.underline"
public let kRDEPUBUnderlineAttributeName = NSAttributedString.Key("com.rdreader.underline")
public struct RDEPUBSelection: Codable, Equatable { public struct RDEPUBSelection: Codable, Equatable {
/// ///
@ -151,6 +162,12 @@ public struct RDEPUBHighlight: Codable, Equatable {
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
} }
/// CSS hex UIColor WXRead UIColorForHighlightColor35% alpha
public var uiColor: UIColor {
UIColor(rdHexString: color, alpha: 0.35)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
}
/// Codable / /// Codable /
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case id case id

View File

@ -297,6 +297,33 @@ public final class RDEPUBChapterData {
tableOfContentsItems(from: items, normalizer: normalizer).first tableOfContentsItems(from: items, normalizer: normalizer).first
} }
// MARK: - WXRead WRChapterData.addHighlightInRange:key:itemId:color:
/// /线 NSAttributedString
/// CoreText WXRead com.weread.highlight / com.weread.underline
public func applyHighlights(
to content: NSMutableAttributedString,
page: RDEPUBTextPage,
highlights: [RDEPUBHighlight]
) {
for highlight in highlights {
guard highlight.location.href == chapter.href else { continue }
guard let range = absoluteRange(for: highlight) else { continue }
let overlap = NSIntersectionRange(range, page.contentRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
guard relativeRange.location >= 0,
relativeRange.location + relativeRange.length <= content.length else { continue }
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
}
}
}
// MARK: - // MARK: -
/// [start, end+1) /// [start, end+1)

View File

@ -83,7 +83,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
updateCurrentSelection(nil) updateCurrentSelection(nil)
return return
} }
updateCurrentSelection(normalizedTextSelection(selection)) updateCurrentSelection(selection)
} }
/// ///
@ -92,35 +92,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
didRequestSelectionAction action: RDEPUBAnnotationMenuAction, didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection? selection: RDEPUBSelection?
) { ) {
let normalizedSelection = selection.flatMap(normalizedTextSelection) handleSelectionMenuAction(action, selection: selection ?? currentSelection)
handleSelectionMenuAction(action, selection: normalizedSelection ?? currentSelection)
contentView.clearSelection() contentView.clearSelection()
} }
///
private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? {
guard let textBook,
let chapterData = textBook.chapterData(for: selection.location.href) else {
return scopedSelection(selection, relativeToSpineIndex: nil)
}
guard let payload = RDEPUBTextOffsetRangeInfo.decode(from: selection.rangeInfo) else {
return scopedSelection(selection, relativeToSpineIndex: nil)
}
let contentLength = max(chapterData.attributedContent.length, 1)
let lastInclusiveOffset = max(contentLength - 1, 1)
let start = max(0, min(payload.start, lastInclusiveOffset))
let endExclusive = max(start + 1, min(payload.end, contentLength))
let absoluteRange = NSRange(location: start, length: endExclusive - start)
let location = chapterData.location(for: absoluteRange, bookIdentifier: currentBookIdentifier)
return RDEPUBSelection(
bookIdentifier: currentBookIdentifier,
location: location,
text: selection.text,
rangeInfo: selection.rangeInfo,
createdAt: selection.createdAt
)
}
/// EPUB /// EPUB
func pageNumber(for location: RDEPUBLocation) -> Int? { func pageNumber(for location: RDEPUBLocation) -> Int? {

View File

@ -27,6 +27,12 @@ final class RDEPUBPageInteractionController {
// MARK: - Hit Testing // MARK: - Hit Testing
/// WXRead stringIndexForPoint:
func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? {
let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y)
return characterIndex(at: localPoint)
}
func characterIndex(at point: CGPoint) -> Int? { func characterIndex(at point: CGPoint) -> Int? {
guard let snapshot else { return nil } guard let snapshot else { return nil }

View File

@ -1,32 +0,0 @@
import UIKit
/// UITextView
/// UIMenuItem `onSelectionAction`
final class RDEPUBSelectableTextView: UITextView {
///
var onSelectionAction: ((RDEPUBAnnotationMenuAction) -> Void)?
///
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return selectedRange.location != NSNotFound && selectedRange.length > 0
default:
return false
}
}
@objc func rd_copy(_ sender: Any?) {
onSelectionAction?(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
onSelectionAction?(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
onSelectionAction?(.annotate)
}
}

View File

@ -32,7 +32,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
case .highlight: case .highlight:
content.addAttribute( content.addAttribute(
.backgroundColor, .backgroundColor,
value: UIColor(rdHexString: highlight.color, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45), value: UIColor(rdHexString: highlight.color, alpha: 0.35) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35),
range: relativeRange range: relativeRange
) )
case .underline: case .underline:
@ -128,8 +128,8 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
let rects = interactionController.selectionRects(for: absoluteRange) let rects = interactionController.selectionRects(for: absoluteRange)
guard !rects.isEmpty else { continue } guard !rects.isEmpty else { continue }
let color = UIColor(rdHexString: highlight.color, alpha: 0.45) let color = UIColor(rdHexString: highlight.color, alpha: 0.35)
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45) ?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
let decoration = RDEPUBTextOverlayDecoration( let decoration = RDEPUBTextOverlayDecoration(
kind: highlight.style == .underline ? .underline : .highlight, kind: highlight.style == .underline ? .underline : .highlight,
absoluteRange: absoluteRange, absoluteRange: absoluteRange,

View File

@ -7,12 +7,8 @@ import DTCoreText
// MARK: - // MARK: -
///
///
protocol RDEPUBTextContentViewDelegate: AnyObject { protocol RDEPUBTextContentViewDelegate: AnyObject {
///
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
/// //
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction, didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
@ -23,12 +19,9 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
// MARK: - // MARK: -
/// EPUB /// EPUB
/// /// WXRead NSAttributedString drawRect
/// 1. DTCoreText CoreText /// UITextView XCUITest
/// 2. 退 UITextView attributedText final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
///
///
final class RDEPUBTextContentView: UIView {
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>() private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
private var contentInsets: UIEdgeInsets = .zero private var contentInsets: UIEdgeInsets = .zero
@ -62,8 +55,9 @@ final class RDEPUBTextContentView: UIView {
return view return view
}() }()
private let textView: RDEPUBSelectableTextView = { /// UITextView XCUITest
let view = RDEPUBSelectableTextView() private let textView: UITextView = {
let view = UITextView()
view.isEditable = false view.isEditable = false
view.isScrollEnabled = false view.isScrollEnabled = false
view.isSelectable = true view.isSelectable = true
@ -87,24 +81,13 @@ final class RDEPUBTextContentView: UIView {
return label return label
}() }()
private lazy var selectionLongPressGesture: UILongPressGestureRecognizer = { // MARK: - Selection Action Bar
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.4
return gesture
}()
private lazy var selectionTapGesture: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
gesture.numberOfTapsRequired = 1
gesture.require(toFail: selectionLongPressGesture)
return gesture
}()
private lazy var selectionActionBar: UIStackView = { private lazy var selectionActionBar: UIStackView = {
let stack = UIStackView(arrangedSubviews: [ let stack = UIStackView(arrangedSubviews: [
selectionMenuButton(title: "拷贝", action: #selector(rd_copy(_:))), selectionMenuButton(title: "拷贝", menuAction: .copy),
selectionMenuButton(title: "高亮", action: #selector(rd_highlight(_:))), selectionMenuButton(title: "高亮", menuAction: .highlight),
selectionMenuButton(title: "批注", action: #selector(rd_annotate(_:))) selectionMenuButton(title: "批注", menuAction: .annotate)
]) ])
stack.axis = .horizontal stack.axis = .horizontal
stack.alignment = .fill stack.alignment = .fill
@ -119,8 +102,11 @@ final class RDEPUBTextContentView: UIView {
return stack return stack
}() }()
// MARK: - Init
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content.view"
addSubview(coverImageView) addSubview(coverImageView)
#if canImport(DTCoreText) #if canImport(DTCoreText)
addSubview(backgroundOverlayView) addSubview(backgroundOverlayView)
@ -130,67 +116,50 @@ final class RDEPUBTextContentView: UIView {
addSubview(textView) addSubview(textView)
addSubview(pageNumberLabel) addSubview(pageNumberLabel)
addSubview(selectionActionBar) addSubview(selectionActionBar)
textView.delegate = selectionController textView.delegate = selectionController
textView.onSelectionAction = { [weak self] action in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: action,
selection: self.resolvedCurrentSelection()
)
}
selectionController.onSelectionChanged = { [weak self] selection in selectionController.onSelectionChanged = { [weak self] selection in
guard let self else { return } guard let self else { return }
self.currentSelection = selection
if let selection { if let selection {
self.currentSelection = selection self.menuSelection = selection
self.showSelectionActionBarIfNeeded()
} else {
self.hideSelectionActionBar()
} }
self.delegate?.textContentView(self, didChangeSelection: selection) self.delegate?.textContentView(self, didChangeSelection: selection)
} }
selectionController.pageProvider = { [weak self] in self?.currentPage }
addGestureRecognizer(selectionLongPressGesture)
addGestureRecognizer(selectionTapGesture)
if #available(iOS 16.0, *) {
addInteraction(UIEditMenuInteraction(delegate: self))
}
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// MARK: - First Responder
override var canBecomeFirstResponder: Bool { true } override var canBecomeFirstResponder: Bool { true }
override func target(forAction action: Selector, withSender sender: Any?) -> Any? { override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
#if canImport(DTCoreText) action == #selector(rd_copy(_:))
switch action { || action == #selector(rd_highlight(_:))
case #selector(rd_copy(_:)), || action == #selector(rd_annotate(_:))
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return self
default:
return super.target(forAction: action, withSender: sender)
}
#else
return super.target(forAction: action, withSender: sender)
#endif
} }
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { @objc func rd_copy(_ sender: Any?) {
#if canImport(DTCoreText) performSelectionAction(.copy)
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return selectionController.canPerformSelectionAction(in: overlayView)
default:
return false
}
#else
return super.canPerformAction(action, withSender: sender)
#endif
} }
@objc func rd_highlight(_ sender: Any?) {
performSelectionAction(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
performSelectionAction(.annotate)
}
// MARK: - Layout
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
@ -213,6 +182,8 @@ final class RDEPUBTextContentView: UIView {
updateSelectionActionBarFrame() updateSelectionActionBarFrame()
} }
// MARK: - Configure
func configure( func configure(
page: RDEPUBTextPage, page: RDEPUBTextPage,
pageNumber: Int, pageNumber: Int,
@ -224,7 +195,7 @@ final class RDEPUBTextContentView: UIView {
currentPage = page currentPage = page
currentSelection = nil currentSelection = nil
menuSelection = nil menuSelection = nil
hideSelectionActionBar() selectionController.clearSelection()
contentInsets = configuration.reflowableContentInsets contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor pageNumberLabel.textColor = configuration.theme.contentTextColor
@ -240,7 +211,6 @@ final class RDEPUBTextContentView: UIView {
textView.isUserInteractionEnabled = false textView.isUserInteractionEnabled = false
#endif #endif
textView.attributedText = nil textView.attributedText = nil
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil) delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout() setNeedsLayout()
return return
@ -249,14 +219,6 @@ final class RDEPUBTextContentView: UIView {
coverImageView.isHidden = true coverImageView.isHidden = true
coverImageView.image = nil coverImageView.image = nil
let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
#if canImport(DTCoreText) #if canImport(DTCoreText)
let displayContent = darkImageAdjustedContentIfNeeded( let displayContent = darkImageAdjustedContentIfNeeded(
normalizedPageContent(from: page), normalizedPageContent(from: page),
@ -268,26 +230,31 @@ final class RDEPUBTextContentView: UIView {
value: configuration.theme.contentTextColor, value: configuration.theme.contentTextColor,
range: fullRange range: fullRange
) )
// /线 WXRead WRChapterData.addHighlightInRange:
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
coreTextContentView.isHidden = false coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length) coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
coreTextContentView.attributedDisplayContent = displayContent
textView.isHidden = false textView.isHidden = false
textView.isUserInteractionEnabled = true textView.isUserInteractionEnabled = true
textView.tintColor = configuration.theme.toolControlTextColor textView.tintColor = configuration.theme.toolControlTextColor
textView.attributedText = selectionProxyContent(from: selectionContent) textView.attributedText = selectionProxyContent(from: displayContent)
textView.selectedRange = NSRange(location: 0, length: 0) textView.selectedRange = NSRange(location: 0, length: 0)
updateSelectionInteractionMode(usingNativeTextSelection: true)
updateCoreTextLayoutFrameIfNeeded() updateCoreTextLayoutFrameIfNeeded()
#else #else
let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset) overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset) overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
textView.isHidden = false textView.isHidden = false
textView.isUserInteractionEnabled = true textView.isUserInteractionEnabled = true
updateSelectionInteractionMode(usingNativeTextSelection: false)
#endif
#if !canImport(DTCoreText)
textView.tintColor = configuration.theme.toolControlTextColor textView.tintColor = configuration.theme.toolControlTextColor
textView.attributedText = selectionProxyContent(from: selectionContent) textView.attributedText = selectionProxyContent(from: selectionContent)
textView.selectedRange = NSRange(location: 0, length: 0) textView.selectedRange = NSRange(location: 0, length: 0)
@ -296,13 +263,13 @@ final class RDEPUBTextContentView: UIView {
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#if canImport(DTCoreText) #if canImport(DTCoreText)
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
let (bgDecorations, fgDecorations) = overlayView.buildDecorations( // /线 attributed string renderView draw(_:)
let (_, fgDecorations) = overlayView.buildDecorations(
page: page, page: page,
highlights: highlights, highlights: [],
searchState: searchState, searchState: searchState,
interactionController: interactionController interactionController: interactionController
) )
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations) overlayView.applyDecorations(fgDecorations)
#endif #endif
@ -313,122 +280,23 @@ final class RDEPUBTextContentView: UIView {
func clearSelection() { func clearSelection() {
currentSelection = nil currentSelection = nil
menuSelection = nil menuSelection = nil
hideSelectionActionBar() selectionController.clearSelection()
selectionController.clearSelection( textView.selectedRange = NSRange(location: 0, length: 0)
textView: textView, overlayView.clearSelection()
overlayView: overlayView, backgroundOverlayView.clearSelection()
backgroundOverlayView: backgroundOverlayView UIMenuController.shared.setMenuVisible(false, animated: true)
)
} }
// MARK: - Gesture Handling // MARK: -
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
selectionController.handleLongPress( let selection = currentSelection ?? menuSelection
gesture, delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
page: currentPage,
overlayView: overlayView,
interactionController: interactionController
)
if gesture.state == .ended {
showSelectionMenuIfNeeded()
}
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
currentSelection = nil
menuSelection = nil menuSelection = nil
hideSelectionActionBar() hideSelectionActionBar()
selectionController.handleTap(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
} }
@objc private func rd_copy(_ sender: Any?) { private func selectionMenuButton(title: String, menuAction: RDEPUBAnnotationMenuAction) -> UIButton {
delegate?.textContentView(self, didRequestSelectionAction: .copy, selection: resolvedCurrentSelection())
}
@objc private func rd_highlight(_ sender: Any?) {
delegate?.textContentView(self, didRequestSelectionAction: .highlight, selection: resolvedCurrentSelection())
}
@objc private func rd_annotate(_ sender: Any?) {
delegate?.textContentView(self, didRequestSelectionAction: .annotate, selection: resolvedCurrentSelection())
}
private func resolvedCurrentSelection() -> RDEPUBSelection? {
currentSelection ?? menuSelection ?? selectionFromOverlayRange()
}
private func selectionFromOverlayRange() -> RDEPUBSelection? {
guard let page = currentPage,
let range = overlayView.selectionRange,
range.length > 0 else {
return nil
}
let source = page.chapterContent.string as NSString
let safeRange = NSIntersectionRange(
range,
NSRange(location: 0, length: page.chapterContent.length)
)
guard safeRange.length > 0 else { return nil }
let selectedText = source.substring(with: safeRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else { return nil }
let chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(safeRange.location, 0)
let chapterEnd = max(chapterStart + safeRange.length - 1, chapterStart)
return RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(chapterStart) / Double(chapterLength),
lastProgression: Double(chapterEnd) / Double(chapterLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(
href: page.href,
start: safeRange.location,
end: safeRange.location + safeRange.length
).jsonString()
)
}
private func showSelectionMenuIfNeeded() {
#if canImport(DTCoreText)
guard selectionLongPressGesture.isEnabled else { return }
menuSelection = resolvedCurrentSelection()
guard menuSelection != nil else { return }
if showSelectionActionBarIfNeeded() {
return
}
if #available(iOS 16.0, *),
let editMenuInteraction = interactions.compactMap({ $0 as? UIEditMenuInteraction }).first,
let targetRect = currentSelectionMenuTargetRect() {
becomeFirstResponder()
let sourcePoint = CGPoint(x: targetRect.midX, y: targetRect.midY)
editMenuInteraction.presentEditMenu(
with: UIEditMenuConfiguration(identifier: nil, sourcePoint: sourcePoint)
)
return
}
selectionController.showSelectionMenuIfNeeded(
in: self,
overlayView: overlayView,
interactionController: interactionController,
copyAction: #selector(RDEPUBTextContentView.rd_copy(_:)),
highlightAction: #selector(RDEPUBTextContentView.rd_highlight(_:)),
annotateAction: #selector(RDEPUBTextContentView.rd_annotate(_:))
)
#endif
}
private func selectionMenuButton(title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system) let button = UIButton(type: .system)
button.setTitle(title, for: .normal) button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal) button.setTitleColor(.white, for: .normal)
@ -437,16 +305,23 @@ final class RDEPUBTextContentView: UIView {
button.backgroundColor = .clear button.backgroundColor = .clear
button.accessibilityLabel = title button.accessibilityLabel = title
button.accessibilityIdentifier = "epub.reader.selection.\(title)" button.accessibilityIdentifier = "epub.reader.selection.\(title)"
button.addTarget(self, action: action, for: .touchUpInside) button.addAction(
UIAction { [weak self] _ in
self?.performSelectionAction(menuAction)
},
for: .touchUpInside
)
return button return button
} }
private func showSelectionActionBarIfNeeded() -> Bool { private func showSelectionActionBarIfNeeded() {
guard currentSelectionMenuTargetRect() != nil else { return false } guard textView.selectedRange.length > 0,
let textRange = textView.selectedTextRange else { return }
let rect = textView.firstRect(for: textRange)
guard !rect.isNull, !rect.isEmpty else { return }
selectionActionBar.isHidden = false selectionActionBar.isHidden = false
updateSelectionActionBarFrame() updateSelectionActionBarFrame()
bringSubviewToFront(selectionActionBar) bringSubviewToFront(selectionActionBar)
return true
} }
private func hideSelectionActionBar() { private func hideSelectionActionBar() {
@ -455,9 +330,10 @@ final class RDEPUBTextContentView: UIView {
private func updateSelectionActionBarFrame() { private func updateSelectionActionBarFrame() {
guard !selectionActionBar.isHidden, guard !selectionActionBar.isHidden,
let targetRect = currentSelectionMenuTargetRect() else { let textRange = textView.selectedTextRange else { return }
return let rect = textView.firstRect(for: textRange)
} guard !rect.isNull, !rect.isEmpty else { return }
let targetRect = convert(rect, from: textView)
let fittingSize = selectionActionBar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) let fittingSize = selectionActionBar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
let width = max(fittingSize.width, 168) let width = max(fittingSize.width, 168)
@ -472,19 +348,7 @@ final class RDEPUBTextContentView: UIView {
selectionActionBar.frame = CGRect(x: x, y: max(8, y), width: width, height: height) selectionActionBar.frame = CGRect(x: x, y: max(8, y), width: width, height: height)
} }
private func updateSelectionInteractionMode(usingNativeTextSelection: Bool) { // MARK: - Cover Image
selectionLongPressGesture.isEnabled = !usingNativeTextSelection
selectionTapGesture.isEnabled = !usingNativeTextSelection
}
private func currentSelectionMenuTargetRect() -> CGRect? {
guard let range = overlayView.selectionRange,
range.length > 0,
let anchorRect = interactionController.menuAnchorRect(for: range) else {
return nil
}
return overlayView.convert(anchorRect, to: self)
}
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool { private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
guard page.pageIndexInChapter == 0, guard page.pageIndexInChapter == 0,
@ -526,20 +390,16 @@ final class RDEPUBTextContentView: UIView {
} }
#endif #endif
if let attachment = attachmentValue as? NSTextAttachment { if let attachment = attachmentValue as? NSTextAttachment {
if let image = attachment.image { if let image = attachment.image { return image }
return image if let data = attachment.contents { return UIImage(data: data) }
}
if let data = attachment.contents {
return UIImage(data: data)
}
if let fileWrapper = attachment.fileWrapper, if let fileWrapper = attachment.fileWrapper,
let data = fileWrapper.regularFileContents { let data = fileWrapper.regularFileContents { return UIImage(data: data) }
return UIImage(data: data)
}
} }
return nil return nil
} }
// MARK: - Dark Image Adjustment
#if canImport(DTCoreText) #if canImport(DTCoreText)
private func darkImageAdjustedContentIfNeeded( private func darkImageAdjustedContentIfNeeded(
_ content: NSMutableAttributedString, _ content: NSMutableAttributedString,
@ -556,9 +416,7 @@ final class RDEPUBTextContentView: UIView {
guard let attachment = value as? DTImageTextAttachment, guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment), !isCoverAttachment(attachment),
let image = attachment.image, let image = attachment.image,
shouldAdjustDarkImage(image) else { shouldAdjustDarkImage(image) else { return }
return
}
let adjustedAttachment = DTImageTextAttachment() let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage( adjustedAttachment.image = adjustedImage(
@ -576,7 +434,6 @@ final class RDEPUBTextContentView: UIView {
adjustedAttachment.attributes = attachment.attributes adjustedAttachment.attributes = attachment.attributes
content.addAttribute(.attachment, value: adjustedAttachment, range: range) content.addAttribute(.attachment, value: adjustedAttachment, range: range)
} }
return content return content
} }
@ -607,10 +464,7 @@ final class RDEPUBTextContentView: UIView {
blendRatio: CGFloat, blendRatio: CGFloat,
cacheKey: NSString cacheKey: NSString
) -> UIImage { ) -> UIImage {
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) { if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) { return cached }
return cached
}
let format = UIGraphicsImageRendererFormat() let format = UIGraphicsImageRendererFormat()
format.scale = image.scale format.scale = image.scale
format.opaque = false format.opaque = false
@ -626,6 +480,34 @@ final class RDEPUBTextContentView: UIView {
} }
#endif #endif
// MARK: - WXRead WRChapterData.addHighlightInRange:
private func applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
page: RDEPUBTextPage
) {
for highlight in highlights {
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
let absoluteRange = info.nsRange else { continue }
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
guard relativeRange.location >= 0,
relativeRange.location + relativeRange.length <= content.length else { continue }
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
}
}
}
// MARK: - Selection Proxy Content
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString { private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
let proxy = NSMutableAttributedString(attributedString: content) let proxy = NSMutableAttributedString(attributedString: content)
let fullRange = NSRange(location: 0, length: proxy.length) let fullRange = NSRange(location: 0, length: proxy.length)
@ -648,21 +530,18 @@ final class RDEPUBTextContentView: UIView {
) )
proxy.replaceCharacters(in: range, with: replacement) proxy.replaceCharacters(in: range, with: replacement)
} }
return proxy return proxy
} }
// MARK: - Content Normalization
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString { private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content) let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else { guard shouldNormalizeContinuationParagraph(for: page) else { return content }
return content
}
let text = content.string as NSString let text = content.string as NSString
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0)) let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
guard firstParagraphRange.length > 0 else { guard firstParagraphRange.length > 0 else { return content }
return content
}
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return } guard let style = value as? NSParagraphStyle else { return }
@ -671,24 +550,19 @@ final class RDEPUBTextContentView: UIView {
mutableStyle.paragraphSpacingBefore = 0 mutableStyle.paragraphSpacingBefore = 0
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range) content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
} }
return content return content
} }
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool { private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
let pageStart = page.pageStartOffset let pageStart = page.pageStartOffset
guard pageStart > 0, pageStart < page.chapterContent.length else { guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
return false
}
let chapterText = page.chapterContent.string as NSString let chapterText = page.chapterContent.string as NSString
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false }
return false
}
return !CharacterSet.newlines.contains(previousScalar) return !CharacterSet.newlines.contains(previousScalar)
} }
// MARK: - CoreText Layout
#if canImport(DTCoreText) #if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() { private func updateCoreTextLayoutFrameIfNeeded() {
guard !coreTextContentView.isHidden, guard !coreTextContentView.isHidden,
@ -714,64 +588,14 @@ final class RDEPUBTextContentView: UIView {
backgroundOverlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot)
} }
#endif #endif
} }
@available(iOS 16.0, *) // MARK: - UIColor Extension
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration,
suggestedActions: [UIMenuElement]
) -> UIMenu? {
guard resolvedCurrentSelection() != nil else { return nil }
return UIMenu(children: [
UIAction(title: "拷贝") { [weak self] _ in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: .copy,
selection: self.resolvedCurrentSelection()
)
},
UIAction(title: "高亮") { [weak self] _ in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: .highlight,
selection: self.resolvedCurrentSelection()
)
},
UIAction(title: "批注") { [weak self] _ in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: .annotate,
selection: self.resolvedCurrentSelection()
)
}
])
}
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
targetRectFor configuration: UIEditMenuConfiguration
) -> CGRect {
currentSelectionMenuTargetRect() ?? bounds
}
}
private extension UIColor { private extension UIColor {
var rd_isDarkReaderBackground: Bool { var rd_isDarkReaderBackground: Bool {
var red: CGFloat = 0 var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
var green: CGFloat = 0 guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
var blue: CGFloat = 0 return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35
var alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return false
}
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return luminance < 0.35
} }
} }

View File

@ -5,6 +5,7 @@ import DTCoreText
/// DTCoreText Core Text /// DTCoreText Core Text
/// DTCoreText UIView UITextView /// DTCoreText UIView UITextView
/// WXRead WRPageView drawRect
final class RDEPUBTextPageRenderView: UIView { final class RDEPUBTextPageRenderView: UIView {
var layoutFrame: DTCoreTextLayoutFrame? { var layoutFrame: DTCoreTextLayoutFrame? {
didSet { didSet {
@ -18,6 +19,27 @@ final class RDEPUBTextPageRenderView: UIView {
} }
} }
/// attributed string com.rdreader.highlight
var attributedDisplayContent: NSAttributedString? {
didSet {
setNeedsDisplay()
}
}
// MARK: - WXRead _drawSelectionInContext:
/// RDEPUBTextSelectionController
var selectionRects: [CGRect] = [] {
didSet {
setNeedsDisplay()
}
}
/// WXRead
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
// MARK: - Init
override init(frame: CGRect) { override init(frame: CGRect) {
super.init(frame: frame) super.init(frame: frame)
backgroundColor = .clear backgroundColor = .clear
@ -29,13 +51,94 @@ final class RDEPUBTextPageRenderView: UIView {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
// MARK: - Draw
override func draw(_ rect: CGRect) { override func draw(_ rect: CGRect) {
guard let context = UIGraphicsGetCurrentContext(), guard let context = UIGraphicsGetCurrentContext(),
let layoutFrame else { return } let layoutFrame else { return }
context.saveGState() context.saveGState()
// 1. WXRead drawHighlightsInContext:
if let attributedDisplayContent {
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
}
// 2.
layoutFrame.draw(in: context, options: drawOptions) layoutFrame.draw(in: context, options: drawOptions)
// 3.
drawSelection(in: context)
context.restoreGState() context.restoreGState()
} }
// MARK: - WXRead WRCoreTextLayoutFrame.drawHighlightsInContext:
private func drawHighlights(
in context: CGContext,
attributedString: NSAttributedString,
layoutFrame: DTCoreTextLayoutFrame
) {
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 = computeHighlightRects(for: range, layoutFrame: layoutFrame)
color.setFill()
for rect in rects {
context.fill(rect)
}
}
// 线
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
guard let color = value as? UIColor else { return }
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
color.setStroke()
context.setLineWidth(2)
for rect in rects {
let y = rect.maxY - 1
context.move(to: CGPoint(x: rect.minX, y: y))
context.addLine(to: CGPoint(x: rect.maxX, y: y))
context.strokePath()
}
}
}
/// WXRead rectsForRange:
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
var rects: [CGRect] = []
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
for line in lines {
let lineRange = line.stringRange()
let overlap = NSIntersectionRange(range, lineRange)
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
)
rects.append(rect)
}
return rects
}
// MARK: -
private func drawSelection(in context: CGContext) {
guard !selectionRects.isEmpty else { return }
selectionColor.setFill()
for rect in selectionRects {
context.fill(rect)
}
}
} }
#endif #endif

View File

@ -1,105 +1,21 @@
import UIKit import UIKit
/// /// UITextView
/// WXRead UITextView attributed string
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate { final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
private var isSelectionFromInteraction = false private var isSelectionFromInteraction = false
private var selectionAnchorPoint: CGPoint?
private var selectionMenuAnchorRect: CGRect? private var selectionMenuAnchorRect: CGRect?
/// UI
var onSelectionChanged: ((RDEPUBSelection?) -> Void)? var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
///
var pageProvider: (() -> RDEPUBTextPage?)?
func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool { // MARK: - UITextViewDelegate
overlayView.selectionRange?.length ?? 0 > 0
}
func clearSelection( func textViewDidChangeSelection(_ textView: UITextView) {
textView: UITextView, textViewDidChangeSelection(textView, page: pageProvider?())
overlayView: RDEPUBSelectionOverlayView,
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
) {
textView.selectedRange = NSRange(location: 0, length: 0)
overlayView.clearSelection()
backgroundOverlayView?.clearSelection()
selectionAnchorPoint = nil
selectionMenuAnchorRect = nil
isSelectionFromInteraction = false
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}
func handleLongPress(
_ gesture: UILongPressGestureRecognizer,
page: RDEPUBTextPage?,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController
) {
let point = gesture.location(in: overlayView)
switch gesture.state {
case .began:
selectionAnchorPoint = point
isSelectionFromInteraction = true
handleSelectionFromInteraction(
point: point,
anchorPoint: nil,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .changed:
guard let anchor = selectionAnchorPoint else { return }
handleSelectionFromInteraction(
point: point,
anchorPoint: anchor,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .ended:
isSelectionFromInteraction = false
default:
break
}
}
func handleTap(
textView: UITextView,
overlayView: RDEPUBSelectionOverlayView,
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil
) {
clearSelection(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
}
func showSelectionMenuIfNeeded(
in hostView: UIView,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController,
copyAction: Selector,
highlightAction: Selector,
annotateAction: Selector
) {
guard overlayView.selectionRange?.length ?? 0 > 0,
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else {
return
}
hostView.becomeFirstResponder()
let menuRect = overlayView.convert(anchorRect, to: hostView)
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: copyAction),
UIMenuItem(title: "高亮", action: highlightAction),
UIMenuItem(title: "批注", action: annotateAction)
]
menuController.setTargetRect(menuRect, in: hostView)
menuController.setMenuVisible(true, animated: true)
} }
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) { func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
@ -140,50 +56,42 @@ final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
onSelectionChanged?(selection) onSelectionChanged?(selection)
} }
private func handleSelectionFromInteraction( // MARK: -
point: CGPoint,
anchorPoint: CGPoint?, func clearSelection() {
page: RDEPUBTextPage?, isSelectionFromInteraction = false
overlayView: RDEPUBSelectionOverlayView, selectionMenuAnchorRect = nil
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}
func clearSelection(textView: UITextView) {
textView.selectedRange = NSRange(location: 0, length: 0)
clearSelection()
}
// MARK: -
func showSelectionMenuIfNeeded(
in hostView: UIView,
textView: UITextView,
interactionController: RDEPUBPageInteractionController interactionController: RDEPUBPageInteractionController
) { ) {
guard let page else { return } guard textView.selectedRange.length > 0,
let textRange = textView.selectedTextRange else { return }
let range: NSRange? let rect = textView.firstRect(for: textRange)
if let anchorPoint { guard !rect.isNull, !rect.isEmpty else { return }
range = interactionController.selectionRange(from: anchorPoint, to: point)
} else if let idx = interactionController.characterIndex(at: point) {
range = NSRange(location: idx, length: 1)
} else {
range = nil
}
guard let range else { return } let menuRect = hostView.convert(rect, from: textView)
let rects = interactionController.selectionRects(for: range) hostView.becomeFirstResponder()
overlayView.updateSelection(absoluteRange: range, rects: rects) let menuController = UIMenuController.shared
selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range) menuController.menuItems = [
onSelectionChanged?(makeSelection(from: range, page: page)) UIMenuItem(title: "拷贝", action: #selector(RDEPUBTextContentView.rd_copy(_:))),
} UIMenuItem(title: "高亮", action: #selector(RDEPUBTextContentView.rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(RDEPUBTextContentView.rd_annotate(_:)))
private func makeSelection(from range: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? { ]
let source = page.chapterContent.string as NSString menuController.setTargetRect(menuRect, in: hostView)
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines) menuController.setMenuVisible(true, animated: true)
guard !selectedText.isEmpty else {
return nil
}
let chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(range.location, 0)
let chapterEnd = max(chapterStart + range.length - 1, chapterStart)
return RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(chapterStart) / Double(chapterLength),
lastProgression: Double(chapterEnd) / Double(chapterLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: range.location, end: range.location + range.length).jsonString()
)
} }
} }