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:
parent
948004eed1
commit
70133e4e6e
365
Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md
Normal file
365
Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md
Normal 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 层 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 位置
|
||||
4
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
4
ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj
generated
@ -60,7 +60,6 @@
|
||||
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, ); }; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
@ -559,7 +558,6 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@ -1460,7 +1458,6 @@
|
||||
children = (
|
||||
CA31C1F2D2AF87B527C261FF3EF62C58 /* RDEPUBPageInteractionController.swift */,
|
||||
19F9801AD3675102B14F64AFAFB99D89 /* RDEPUBPageLayoutSnapshot.swift */,
|
||||
0A65FCB87389CF471A9A4BD659D58346 /* RDEPUBSelectableTextView.swift */,
|
||||
185CD1A4E30E72715AF3129924F063E6 /* RDEPUBSelectionOverlayView.swift */,
|
||||
2F910EB56A7325328DCD94F082E99648 /* RDEPUBTextAnnotationOverlay.swift */,
|
||||
4B789EEAB8791D76E537C6186EA1AC1D /* RDEPUBTextContentView.swift */,
|
||||
@ -2521,7 +2518,6 @@
|
||||
7C026FA594F06339EAAEB774A776A030 /* RDEPUBResourceURLSchemeHandler.swift in Sources */,
|
||||
4D00F4DD1B8D720FFA715756C7D70D40 /* RDEPUBSearchEngine.swift in Sources */,
|
||||
53240F8A0E98A48B59C6B2FA88270310 /* RDEPUBSearchModels.swift in Sources */,
|
||||
22FD1F39B4B94E9E33FDDF0C6880AA53 /* RDEPUBSelectableTextView.swift in Sources */,
|
||||
44B918236776349CDE1D85C70EDD7328 /* RDEPUBSelectionOverlayView.swift in Sources */,
|
||||
F77B604D4BCCE6CBE8520E588343F0A1 /* RDEPUBSemanticMarkerInjector.swift in Sources */,
|
||||
0A37B6C1B26290A715DB47A6D750293D /* RDEPUBStyleSheetBuilder.swift in Sources */,
|
||||
|
||||
@ -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>
|
||||
@ -21,17 +21,30 @@ final class ReaderAnnotationTests: XCTestCase {
|
||||
app.waitForReaderState(containing: "selection=1", timeout: 5)
|
||||
|
||||
let highlightMenuItem = app.menuItems["高亮"]
|
||||
let highlightButton = app.buttons["高亮"]
|
||||
let highlightActionButton = app.buttons[IDs.readerSelectionHighlight]
|
||||
let highlightButton = app.buttons[IDs.readerSelectionHighlight]
|
||||
if highlightMenuItem.waitForExistence(timeout: 3) {
|
||||
highlightMenuItem.tap()
|
||||
} else if highlightActionButton.waitForExistence(timeout: 3) {
|
||||
highlightActionButton.tap()
|
||||
} else {
|
||||
XCTAssertTrue(highlightButton.waitForExistence(timeout: 3), "选中文本后未出现高亮菜单")
|
||||
} else if highlightButton.waitForExistence(timeout: 3) {
|
||||
highlightButton.tap()
|
||||
} else {
|
||||
XCTFail("选中文本后未出现高亮菜单")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,15 @@
|
||||
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 {
|
||||
/// 书籍标识符
|
||||
@ -151,6 +162,12 @@ public struct RDEPUBHighlight: Codable, Equatable {
|
||||
note?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
|
||||
}
|
||||
|
||||
/// 将 CSS hex 颜色转为 UIColor(对齐 WXRead 的 UIColorForHighlightColor,35% 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 序列化/反序列化
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
|
||||
@ -297,6 +297,33 @@ public final class RDEPUBChapterData {
|
||||
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: - 私有工具方法
|
||||
|
||||
/// 将页面的起止偏移量转换为半开区间 [start, end+1)
|
||||
|
||||
@ -83,7 +83,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
updateCurrentSelection(nil)
|
||||
return
|
||||
}
|
||||
updateCurrentSelection(normalizedTextSelection(selection))
|
||||
updateCurrentSelection(selection)
|
||||
}
|
||||
|
||||
/// 文本内容视图选区菜单操作回调
|
||||
@ -92,35 +92,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
selection: RDEPUBSelection?
|
||||
) {
|
||||
let normalizedSelection = selection.flatMap(normalizedTextSelection)
|
||||
handleSelectionMenuAction(action, selection: normalizedSelection ?? currentSelection)
|
||||
handleSelectionMenuAction(action, selection: selection ?? currentSelection)
|
||||
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 位置计算对应的页码
|
||||
func pageNumber(for location: RDEPUBLocation) -> Int? {
|
||||
|
||||
@ -27,6 +27,12 @@ final class RDEPUBPageInteractionController {
|
||||
|
||||
// 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? {
|
||||
guard let snapshot else { return nil }
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -32,7 +32,7 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
case .highlight:
|
||||
content.addAttribute(
|
||||
.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
|
||||
)
|
||||
case .underline:
|
||||
@ -128,8 +128,8 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
let rects = interactionController.selectionRects(for: absoluteRange)
|
||||
guard !rects.isEmpty else { continue }
|
||||
|
||||
let color = UIColor(rdHexString: highlight.color, alpha: 0.45)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.45)
|
||||
let color = UIColor(rdHexString: highlight.color, alpha: 0.35)
|
||||
?? UIColor(red: 248 / 255, green: 225 / 255, blue: 108 / 255, alpha: 0.35)
|
||||
let decoration = RDEPUBTextOverlayDecoration(
|
||||
kind: highlight.style == .underline ? .underline : .highlight,
|
||||
absoluteRange: absoluteRange,
|
||||
|
||||
@ -7,12 +7,8 @@ import DTCoreText
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
/// 文本内容视图的代理协议
|
||||
/// 通知控制器文本选择变化和选择菜单操作
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
/// 用户选中文本发生变化时调用
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
/// 用户从选择菜单中触发操作(拷贝/高亮/批注)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
@ -23,12 +19,9 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 支持两种渲染路径:
|
||||
/// 1. DTCoreText 路径:直接绘制到 CoreText 视图,支持精确的排版控制
|
||||
/// 2. 回退路径:通过 UITextView 的 attributedText 渲染
|
||||
///
|
||||
/// 内置能力:高亮覆盖、搜索高亮、文本选择、长按菜单、封面图显示
|
||||
final class RDEPUBTextContentView: UIView {
|
||||
/// 对齐 WXRead 架构:高亮通过 NSAttributedString 自定义属性注入,在统一 drawRect 中绘制。
|
||||
/// 保留 UITextView 用于 XCUITest 兼容和系统级文本选择。
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
@ -62,8 +55,9 @@ final class RDEPUBTextContentView: UIView {
|
||||
return view
|
||||
}()
|
||||
|
||||
private let textView: RDEPUBSelectableTextView = {
|
||||
let view = RDEPUBSelectableTextView()
|
||||
/// 保留 UITextView 用于系统文本选择和 XCUITest 兼容
|
||||
private let textView: UITextView = {
|
||||
let view = UITextView()
|
||||
view.isEditable = false
|
||||
view.isScrollEnabled = false
|
||||
view.isSelectable = true
|
||||
@ -87,24 +81,13 @@ final class RDEPUBTextContentView: UIView {
|
||||
return label
|
||||
}()
|
||||
|
||||
private lazy var selectionLongPressGesture: UILongPressGestureRecognizer = {
|
||||
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
|
||||
}()
|
||||
// MARK: - Selection Action Bar
|
||||
|
||||
private lazy var selectionActionBar: UIStackView = {
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
selectionMenuButton(title: "拷贝", action: #selector(rd_copy(_:))),
|
||||
selectionMenuButton(title: "高亮", action: #selector(rd_highlight(_:))),
|
||||
selectionMenuButton(title: "批注", action: #selector(rd_annotate(_:)))
|
||||
selectionMenuButton(title: "拷贝", menuAction: .copy),
|
||||
selectionMenuButton(title: "高亮", menuAction: .highlight),
|
||||
selectionMenuButton(title: "批注", menuAction: .annotate)
|
||||
])
|
||||
stack.axis = .horizontal
|
||||
stack.alignment = .fill
|
||||
@ -119,8 +102,11 @@ final class RDEPUBTextContentView: UIView {
|
||||
return stack
|
||||
}()
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.content.view"
|
||||
addSubview(coverImageView)
|
||||
#if canImport(DTCoreText)
|
||||
addSubview(backgroundOverlayView)
|
||||
@ -130,67 +116,50 @@ final class RDEPUBTextContentView: UIView {
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
addSubview(selectionActionBar)
|
||||
|
||||
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
|
||||
guard let self else { return }
|
||||
if let selection {
|
||||
self.currentSelection = selection
|
||||
if let selection {
|
||||
self.menuSelection = selection
|
||||
self.showSelectionActionBarIfNeeded()
|
||||
} else {
|
||||
self.hideSelectionActionBar()
|
||||
}
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
|
||||
addGestureRecognizer(selectionLongPressGesture)
|
||||
addGestureRecognizer(selectionTapGesture)
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
addInteraction(UIEditMenuInteraction(delegate: self))
|
||||
}
|
||||
selectionController.pageProvider = { [weak self] in self?.currentPage }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - First Responder
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#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 {
|
||||
action == #selector(rd_copy(_:))
|
||||
|| action == #selector(rd_highlight(_:))
|
||||
|| action == #selector(rd_annotate(_:))
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
switch action {
|
||||
case #selector(rd_copy(_:)),
|
||||
#selector(rd_highlight(_:)),
|
||||
#selector(rd_annotate(_:)):
|
||||
return selectionController.canPerformSelectionAction(in: overlayView)
|
||||
default:
|
||||
return false
|
||||
@objc func rd_copy(_ sender: Any?) {
|
||||
performSelectionAction(.copy)
|
||||
}
|
||||
#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() {
|
||||
super.layoutSubviews()
|
||||
|
||||
@ -213,6 +182,8 @@ final class RDEPUBTextContentView: UIView {
|
||||
updateSelectionActionBarFrame()
|
||||
}
|
||||
|
||||
// MARK: - Configure
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
@ -224,7 +195,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
currentPage = page
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
hideSelectionActionBar()
|
||||
selectionController.clearSelection()
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
@ -240,7 +211,6 @@ final class RDEPUBTextContentView: UIView {
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
return
|
||||
@ -249,14 +219,6 @@ final class RDEPUBTextContentView: UIView {
|
||||
coverImageView.isHidden = true
|
||||
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)
|
||||
let displayContent = darkImageAdjustedContentIfNeeded(
|
||||
normalizedPageContent(from: page),
|
||||
@ -268,26 +230,31 @@ final class RDEPUBTextContentView: UIView {
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
// 注入高亮/下划线自定义属性(对齐 WXRead 的 WRChapterData.addHighlightInRange:)
|
||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
coreTextDisplayContent = displayContent
|
||||
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
|
||||
coreTextContentView.attributedDisplayContent = displayContent
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
textView.attributedText = selectionProxyContent(from: displayContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
updateSelectionInteractionMode(usingNativeTextSelection: true)
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#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.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
updateSelectionInteractionMode(usingNativeTextSelection: false)
|
||||
#endif
|
||||
|
||||
#if !canImport(DTCoreText)
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: selectionContent)
|
||||
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)
|
||||
#if canImport(DTCoreText)
|
||||
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,
|
||||
highlights: highlights,
|
||||
highlights: [],
|
||||
searchState: searchState,
|
||||
interactionController: interactionController
|
||||
)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
#endif
|
||||
|
||||
@ -313,122 +280,23 @@ final class RDEPUBTextContentView: UIView {
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
hideSelectionActionBar()
|
||||
selectionController.clearSelection(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
backgroundOverlayView: backgroundOverlayView
|
||||
)
|
||||
selectionController.clearSelection()
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - Gesture Handling
|
||||
// MARK: - 选择操作
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
selectionController.handleLongPress(
|
||||
gesture,
|
||||
page: currentPage,
|
||||
overlayView: overlayView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
|
||||
if gesture.state == .ended {
|
||||
showSelectionMenuIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
currentSelection = nil
|
||||
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
let selection = currentSelection ?? menuSelection
|
||||
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
|
||||
menuSelection = nil
|
||||
hideSelectionActionBar()
|
||||
selectionController.handleTap(
|
||||
textView: textView,
|
||||
overlayView: overlayView,
|
||||
backgroundOverlayView: backgroundOverlayView
|
||||
)
|
||||
}
|
||||
|
||||
@objc private func rd_copy(_ sender: Any?) {
|
||||
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 {
|
||||
private func selectionMenuButton(title: String, menuAction: RDEPUBAnnotationMenuAction) -> UIButton {
|
||||
let button = UIButton(type: .system)
|
||||
button.setTitle(title, for: .normal)
|
||||
button.setTitleColor(.white, for: .normal)
|
||||
@ -437,16 +305,23 @@ final class RDEPUBTextContentView: UIView {
|
||||
button.backgroundColor = .clear
|
||||
button.accessibilityLabel = 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
|
||||
}
|
||||
|
||||
private func showSelectionActionBarIfNeeded() -> Bool {
|
||||
guard currentSelectionMenuTargetRect() != nil else { return false }
|
||||
private func showSelectionActionBarIfNeeded() {
|
||||
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
|
||||
updateSelectionActionBarFrame()
|
||||
bringSubviewToFront(selectionActionBar)
|
||||
return true
|
||||
}
|
||||
|
||||
private func hideSelectionActionBar() {
|
||||
@ -455,9 +330,10 @@ final class RDEPUBTextContentView: UIView {
|
||||
|
||||
private func updateSelectionActionBarFrame() {
|
||||
guard !selectionActionBar.isHidden,
|
||||
let targetRect = currentSelectionMenuTargetRect() else {
|
||||
return
|
||||
}
|
||||
let textRange = textView.selectedTextRange else { 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 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)
|
||||
}
|
||||
|
||||
private func updateSelectionInteractionMode(usingNativeTextSelection: Bool) {
|
||||
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)
|
||||
}
|
||||
// MARK: - Cover Image
|
||||
|
||||
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||
guard page.pageIndexInChapter == 0,
|
||||
@ -526,20 +390,16 @@ final class RDEPUBTextContentView: UIView {
|
||||
}
|
||||
#endif
|
||||
if let attachment = attachmentValue as? NSTextAttachment {
|
||||
if let image = attachment.image {
|
||||
return image
|
||||
}
|
||||
if let data = attachment.contents {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
if let image = attachment.image { return image }
|
||||
if let data = attachment.contents { return UIImage(data: data) }
|
||||
if let fileWrapper = attachment.fileWrapper,
|
||||
let data = fileWrapper.regularFileContents {
|
||||
return UIImage(data: data)
|
||||
}
|
||||
let data = fileWrapper.regularFileContents { return UIImage(data: data) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Dark Image Adjustment
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func darkImageAdjustedContentIfNeeded(
|
||||
_ content: NSMutableAttributedString,
|
||||
@ -556,9 +416,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
guard let attachment = value as? DTImageTextAttachment,
|
||||
!isCoverAttachment(attachment),
|
||||
let image = attachment.image,
|
||||
shouldAdjustDarkImage(image) else {
|
||||
return
|
||||
}
|
||||
shouldAdjustDarkImage(image) else { return }
|
||||
|
||||
let adjustedAttachment = DTImageTextAttachment()
|
||||
adjustedAttachment.image = adjustedImage(
|
||||
@ -576,7 +434,6 @@ final class RDEPUBTextContentView: UIView {
|
||||
adjustedAttachment.attributes = attachment.attributes
|
||||
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
@ -607,10 +464,7 @@ final class RDEPUBTextContentView: UIView {
|
||||
blendRatio: CGFloat,
|
||||
cacheKey: NSString
|
||||
) -> UIImage {
|
||||
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) { return cached }
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = image.scale
|
||||
format.opaque = false
|
||||
@ -626,6 +480,34 @@ final class RDEPUBTextContentView: UIView {
|
||||
}
|
||||
#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 {
|
||||
let proxy = NSMutableAttributedString(attributedString: content)
|
||||
let fullRange = NSRange(location: 0, length: proxy.length)
|
||||
@ -648,21 +530,18 @@ final class RDEPUBTextContentView: UIView {
|
||||
)
|
||||
proxy.replaceCharacters(in: range, with: replacement)
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
// MARK: - Content Normalization
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else {
|
||||
return content
|
||||
}
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
|
||||
|
||||
let text = content.string as NSString
|
||||
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
|
||||
guard firstParagraphRange.length > 0 else {
|
||||
return content
|
||||
}
|
||||
guard firstParagraphRange.length > 0 else { return content }
|
||||
|
||||
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
|
||||
guard let style = value as? NSParagraphStyle else { return }
|
||||
@ -671,24 +550,19 @@ final class RDEPUBTextContentView: UIView {
|
||||
mutableStyle.paragraphSpacingBefore = 0
|
||||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
|
||||
let pageStart = page.pageStartOffset
|
||||
guard pageStart > 0, pageStart < page.chapterContent.length else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
|
||||
let chapterText = page.chapterContent.string as NSString
|
||||
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false }
|
||||
return !CharacterSet.newlines.contains(previousScalar)
|
||||
}
|
||||
|
||||
// MARK: - CoreText Layout
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||
guard !coreTextContentView.isHidden,
|
||||
@ -714,64 +588,14 @@ final class RDEPUBTextContentView: UIView {
|
||||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||||
}
|
||||
#endif
|
||||
|
||||
}
|
||||
|
||||
@available(iOS 16.0, *)
|
||||
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
|
||||
}
|
||||
}
|
||||
// MARK: - UIColor Extension
|
||||
|
||||
private extension UIColor {
|
||||
var rd_isDarkReaderBackground: Bool {
|
||||
var red: CGFloat = 0
|
||||
var green: CGFloat = 0
|
||||
var blue: CGFloat = 0
|
||||
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
|
||||
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
||||
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
|
||||
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import DTCoreText
|
||||
|
||||
/// 基于 DTCoreText 的 Core Text 直接绘制视图
|
||||
/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染。
|
||||
/// 对齐 WXRead 的 WRPageView:在同一 drawRect 中绘制高亮背景、文字和选区。
|
||||
final class RDEPUBTextPageRenderView: UIView {
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
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) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
@ -29,13 +51,94 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Draw
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(),
|
||||
let layoutFrame else { return }
|
||||
|
||||
context.saveGState()
|
||||
|
||||
// 1. 绘制高亮背景(在文字下方,对齐 WXRead 的 drawHighlightsInContext:)
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
}
|
||||
|
||||
// 2. 绘制文字
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
|
||||
// 3. 绘制选区(在文字上方)
|
||||
drawSelection(in: context)
|
||||
|
||||
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
|
||||
|
||||
@ -1,105 +1,21 @@
|
||||
import UIKit
|
||||
|
||||
/// 负责管理文本选区、长按交互和菜单定位。
|
||||
/// 负责管理文本选区、UITextView 代理和菜单定位。
|
||||
/// 对齐 WXRead 的选择模型:UITextView 处理原生选择,高亮通过 attributed string 属性绘制。
|
||||
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
||||
|
||||
private var isSelectionFromInteraction = false
|
||||
private var selectionAnchorPoint: CGPoint?
|
||||
private var selectionMenuAnchorRect: CGRect?
|
||||
|
||||
/// 选区变化回调,通知视图层更新 UI
|
||||
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
||||
/// 获取当前页面数据
|
||||
var pageProvider: (() -> RDEPUBTextPage?)?
|
||||
|
||||
func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool {
|
||||
overlayView.selectionRange?.length ?? 0 > 0
|
||||
}
|
||||
// MARK: - UITextViewDelegate
|
||||
|
||||
func clearSelection(
|
||||
textView: UITextView,
|
||||
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) {
|
||||
textViewDidChangeSelection(textView, page: pageProvider?())
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
|
||||
@ -140,50 +56,42 @@ final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
||||
onSelectionChanged?(selection)
|
||||
}
|
||||
|
||||
private func handleSelectionFromInteraction(
|
||||
point: CGPoint,
|
||||
anchorPoint: CGPoint?,
|
||||
page: RDEPUBTextPage?,
|
||||
overlayView: RDEPUBSelectionOverlayView,
|
||||
// MARK: - 选区清除
|
||||
|
||||
func clearSelection() {
|
||||
isSelectionFromInteraction = false
|
||||
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
|
||||
) {
|
||||
guard let page else { return }
|
||||
guard textView.selectedRange.length > 0,
|
||||
let textRange = textView.selectedTextRange else { return }
|
||||
|
||||
let range: NSRange?
|
||||
if let anchorPoint {
|
||||
range = interactionController.selectionRange(from: anchorPoint, to: point)
|
||||
} else if let idx = interactionController.characterIndex(at: point) {
|
||||
range = NSRange(location: idx, length: 1)
|
||||
} else {
|
||||
range = nil
|
||||
}
|
||||
let rect = textView.firstRect(for: textRange)
|
||||
guard !rect.isNull, !rect.isEmpty else { return }
|
||||
|
||||
guard let range else { return }
|
||||
let rects = interactionController.selectionRects(for: range)
|
||||
overlayView.updateSelection(absoluteRange: range, rects: rects)
|
||||
selectionMenuAnchorRect = interactionController.menuAnchorRect(for: range)
|
||||
onSelectionChanged?(makeSelection(from: range, page: page))
|
||||
}
|
||||
|
||||
private func makeSelection(from range: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
|
||||
let source = page.chapterContent.string as NSString
|
||||
let selectedText = source.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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()
|
||||
)
|
||||
let menuRect = hostView.convert(rect, from: textView)
|
||||
hostView.becomeFirstResponder()
|
||||
let menuController = UIMenuController.shared
|
||||
menuController.menuItems = [
|
||||
UIMenuItem(title: "拷贝", action: #selector(RDEPUBTextContentView.rd_copy(_:))),
|
||||
UIMenuItem(title: "高亮", action: #selector(RDEPUBTextContentView.rd_highlight(_:))),
|
||||
UIMenuItem(title: "批注", action: #selector(RDEPUBTextContentView.rd_annotate(_:)))
|
||||
]
|
||||
menuController.setTargetRect(menuRect, in: hostView)
|
||||
menuController.setMenuVisible(true, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user