2 Commits
Author SHA1 Message Date
shenandshen 1c6108061c feat: improve native text selection and annotation actions 2026-06-01 21:29:41 +08:00
shenleiandClaude Opus 4.7 70133e4e6e 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>
2026-06-01 20:57:27 +08:00
16 changed files with 1046 additions and 612 deletions
@@ -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 位置
-4
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 */,
@@ -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>
@@ -11,7 +11,7 @@ final class ReaderAnnotationTests: XCTestCase {
app.launchAndOpenSampleBook(pageNumber: 2) app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader() app.waitForReader()
let content = app.textViews[IDs.readerSelectionText].firstMatch let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现") XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42)) let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
@@ -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.otherElements[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 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
@@ -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)
@@ -83,7 +83,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
updateCurrentSelection(nil) updateCurrentSelection(nil)
return return
} }
updateCurrentSelection(normalizedTextSelection(selection)) updateCurrentSelection(selection)
} }
/// ///
@@ -92,35 +92,18 @@ 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()
} }
/// func textContentView(
private func normalizedTextSelection(_ selection: RDEPUBSelection) -> RDEPUBSelection? { _ contentView: RDEPUBTextContentView,
guard let textBook, didRequestHighlightActions highlight: RDEPUBHighlight,
let chapterData = textBook.chapterData(for: selection.location.href) else { sourceRect: CGRect
return scopedSelection(selection, relativeToSpineIndex: nil) ) {
} runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
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? {
@@ -225,6 +225,28 @@ final class RDEPUBReaderAnnotationCoordinator {
presentAnnotationActionSheet(for: currentSelection) presentAnnotationActionSheet(for: currentSelection)
} }
/// /
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
guard let controller else { return }
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
_ = self?.removeHighlight(id: highlight.id)
})
if highlight.hasNote {
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = sourceView
popover.sourceRect = sourceRect
}
controller.present(alert, animated: true)
}
/// ///
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return } guard let selection else { return }
@@ -182,6 +182,10 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.presentAnnotationCreation() annotationCoordinator.presentAnnotationCreation()
} }
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) { func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
annotationCoordinator.handleSelectionMenuAction(action, selection: selection) annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
} }
@@ -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 }
@@ -47,7 +53,11 @@ final class RDEPUBPageInteractionController {
) )
let idx = dtLine.stringIndex(forPosition: relativePoint) let idx = dtLine.stringIndex(forPosition: relativePoint)
guard idx != NSNotFound, idx >= 0 else { return nil } guard idx != NSNotFound, idx >= 0 else { return nil }
return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange) return normalizedIndex(
idx + pageOffset(for: snapshot),
lineRange: line.stringRange,
pageRange: snapshot.pageContentRange
)
#else #else
return nil return nil
#endif #endif
@@ -75,9 +85,10 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue } guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
let startX = dtLine.offset(forStringIndex: overlap.location) let pageOffset = pageOffset(for: snapshot)
let startX = dtLine.offset(forStringIndex: overlap.location - pageOffset)
let endIdx = overlap.location + overlap.length let endIdx = overlap.location + overlap.length
let endX = dtLine.offset(forStringIndex: endIdx) let endX = dtLine.offset(forStringIndex: endIdx - pageOffset)
#else #else
let startX: CGFloat = 0 let startX: CGFloat = 0
let endX: CGFloat = line.frame.width let endX: CGFloat = line.frame.width
@@ -135,7 +146,7 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil } guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
let offsetX = dtLine.offset(forStringIndex: index) let offsetX = dtLine.offset(forStringIndex: index - pageOffset(for: snapshot))
#else #else
let offsetX: CGFloat = 0 let offsetX: CGFloat = 0
#endif #endif
@@ -186,11 +197,16 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText) #if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? { private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame else { return nil } guard let dtLayoutFrame, let snapshot else { return nil }
return dtLayoutFrame.lineContaining(UInt(range.location)) let localLocation = max(range.location - pageOffset(for: snapshot), 0)
return dtLayoutFrame.lineContaining(UInt(localLocation))
} }
#endif #endif
private func pageOffset(for snapshot: RDEPUBPageLayoutSnapshot) -> Int {
snapshot.page.pageStartOffset
}
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] { private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
guard rects.count > 1 else { return rects } guard rects.count > 1 else { return rects }
@@ -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: 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,
@@ -7,34 +7,32 @@ 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,
selection: RDEPUBSelection? selection: RDEPUBSelection?
) )
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
)
} }
// MARK: - // MARK: -
/// EPUB /// EPUB
/// /// WXRead CoreText drawRect
/// 1. DTCoreText CoreText final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
/// 2. 退 UITextView attributedText
///
///
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
private var currentPage: RDEPUBTextPage? private var currentPage: RDEPUBTextPage?
private var currentSelection: RDEPUBSelection? private var currentSelection: RDEPUBSelection?
private var menuSelection: RDEPUBSelection? private var menuSelection: RDEPUBSelection?
private var currentHighlights: [RDEPUBHighlight] = []
weak var delegate: RDEPUBTextContentViewDelegate? weak var delegate: RDEPUBTextContentViewDelegate?
#if canImport(DTCoreText) #if canImport(DTCoreText)
@@ -62,18 +60,6 @@ final class RDEPUBTextContentView: UIView {
return view return view
}() }()
private let textView: RDEPUBSelectableTextView = {
let view = RDEPUBSelectableTextView()
view.isEditable = false
view.isScrollEnabled = false
view.isSelectable = true
view.backgroundColor = .clear
view.accessibilityIdentifier = "epub.reader.selection.text"
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
return view
}()
private let coverImageView: UIImageView = { private let coverImageView: UIImageView = {
let view = UIImageView() let view = UIImageView()
view.contentMode = .scaleAspectFit view.contentMode = .scaleAspectFit
@@ -87,110 +73,84 @@ final class RDEPUBTextContentView: UIView {
return label return label
}() }()
private lazy var selectionLongPressGesture: UILongPressGestureRecognizer = { private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.4 gesture.minimumPressDuration = 0.5
gesture.delegate = self
return gesture return gesture
}() }()
private lazy var selectionTapGesture: UITapGestureRecognizer = { private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
gesture.isEnabled = false
gesture.delegate = self
return gesture
}()
private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
gesture.numberOfTapsRequired = 1 gesture.delegate = self
gesture.require(toFail: selectionLongPressGesture)
return gesture return gesture
}() }()
private lazy var selectionActionBar: UIStackView = { // MARK: - Init
let stack = UIStackView(arrangedSubviews: [
selectionMenuButton(title: "拷贝", action: #selector(rd_copy(_:))),
selectionMenuButton(title: "高亮", action: #selector(rd_highlight(_:))),
selectionMenuButton(title: "批注", action: #selector(rd_annotate(_:)))
])
stack.axis = .horizontal
stack.alignment = .fill
stack.distribution = .fillEqually
stack.spacing = 1
stack.backgroundColor = UIColor(white: 0.12, alpha: 0.96)
stack.layer.cornerRadius = 10
stack.layer.masksToBounds = true
stack.layer.zPosition = 100
stack.isHidden = true
stack.accessibilityIdentifier = "epub.reader.selection.menu"
return stack
}()
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)
addSubview(coreTextContentView) addSubview(coreTextContentView)
#endif #endif
addSubview(overlayView) addSubview(overlayView)
addSubview(textView)
addSubview(pageNumberLabel) addSubview(pageNumberLabel)
addSubview(selectionActionBar) addGestureRecognizer(longPressGestureRecognizer)
textView.delegate = selectionController addGestureRecognizer(panGestureRecognizer)
textView.onSelectionAction = { [weak self] action in addGestureRecognizer(tapGestureRecognizer)
guard let self else { return } tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
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 }
if let selection {
self.currentSelection = selection self.currentSelection = selection
if let selection {
self.menuSelection = selection
} else {
self.hideSelectionMenu()
} }
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) @objc func rd_highlight(_ sender: Any?) {
#endif performSelectionAction(.highlight)
} }
@objc func rd_annotate(_ sender: Any?) {
performSelectionAction(.annotate)
}
// MARK: - Layout
override func layoutSubviews() { override func layoutSubviews() {
super.layoutSubviews() super.layoutSubviews()
@@ -200,7 +160,6 @@ final class RDEPUBTextContentView: UIView {
updateCoreTextLayoutFrameIfNeeded() updateCoreTextLayoutFrameIfNeeded()
#endif #endif
overlayView.frame = bounds.inset(by: contentInsets) overlayView.frame = bounds.inset(by: contentInsets)
textView.frame = bounds.inset(by: contentInsets)
coverImageView.frame = bounds.inset(by: contentInsets) coverImageView.frame = bounds.inset(by: contentInsets)
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
@@ -210,9 +169,10 @@ final class RDEPUBTextContentView: UIView {
width: labelSize.width, width: labelSize.width,
height: labelSize.height height: labelSize.height
) )
updateSelectionActionBarFrame()
} }
// MARK: - Configure
func configure( func configure(
page: RDEPUBTextPage, page: RDEPUBTextPage,
pageNumber: Int, pageNumber: Int,
@@ -224,7 +184,8 @@ final class RDEPUBTextContentView: UIView {
currentPage = page currentPage = page
currentSelection = nil currentSelection = nil
menuSelection = nil menuSelection = nil
hideSelectionActionBar() currentHighlights = highlights
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor pageNumberLabel.textColor = configuration.theme.contentTextColor
@@ -236,11 +197,7 @@ final class RDEPUBTextContentView: UIView {
coreTextContentView.layoutFrame = nil coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil coreTextDisplayContent = nil
coreTextDisplayRange = nil coreTextDisplayRange = nil
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif #endif
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 +206,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,29 +217,25 @@ 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
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
coreTextDisplayContent = displayContent coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length) coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
textView.isHidden = false coreTextContentView.attributedDisplayContent = displayContent
textView.isUserInteractionEnabled = true
textView.tintColor = configuration.theme.toolControlTextColor
textView.attributedText = selectionProxyContent(from: selectionContent)
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.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)
#endif #endif
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
@@ -298,7 +243,7 @@ final class RDEPUBTextContentView: UIView {
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( let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page, page: page,
highlights: highlights, highlights: [],
searchState: searchState, searchState: searchState,
interactionController: interactionController interactionController: interactionController
) )
@@ -313,178 +258,23 @@ final class RDEPUBTextContentView: UIView {
func clearSelection() { func clearSelection() {
currentSelection = nil currentSelection = nil
menuSelection = nil menuSelection = nil
hideSelectionActionBar() panGestureRecognizer.isEnabled = false
selectionController.clearSelection( selectionController.clearSelection(renderView: coreTextRenderView)
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() hideSelectionMenu()
selectionController.handleTap(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
} }
@objc private func rd_copy(_ sender: Any?) { // MARK: - Cover Image
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)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14)
button.backgroundColor = .clear
button.accessibilityLabel = title
button.accessibilityIdentifier = "epub.reader.selection.\(title)"
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
private func showSelectionActionBarIfNeeded() -> Bool {
guard currentSelectionMenuTargetRect() != nil else { return false }
selectionActionBar.isHidden = false
updateSelectionActionBarFrame()
bringSubviewToFront(selectionActionBar)
return true
}
private func hideSelectionActionBar() {
selectionActionBar.isHidden = true
}
private func updateSelectionActionBarFrame() {
guard !selectionActionBar.isHidden,
let targetRect = currentSelectionMenuTargetRect() else {
return
}
let fittingSize = selectionActionBar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
let width = max(fittingSize.width, 168)
let height = max(fittingSize.height, 42)
let horizontalPadding: CGFloat = 12
let x = min(
max(targetRect.midX - width / 2, horizontalPadding),
max(horizontalPadding, bounds.width - width - horizontalPadding)
)
let preferredY = targetRect.minY - height - 8
let y = preferredY >= 8 ? preferredY : min(targetRect.maxY + 8, bounds.height - height - 8)
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)
}
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool { private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
guard page.pageIndexInChapter == 0, guard page.pageIndexInChapter == 0,
@@ -500,10 +290,7 @@ final class RDEPUBTextContentView: UIView {
coreTextContentView.layoutFrame = nil coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil coreTextDisplayContent = nil
coreTextDisplayRange = nil coreTextDisplayRange = nil
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif #endif
textView.attributedText = nil
return true return true
} }
@@ -526,20 +313,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 +339,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 +357,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 +387,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,43 +403,41 @@ final class RDEPUBTextContentView: UIView {
} }
#endif #endif
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString { // MARK: - WXRead WRChapterData.addHighlightInRange:
let proxy = NSMutableAttributedString(attributedString: content)
let fullRange = NSRange(location: 0, length: proxy.length)
proxy.removeAttribute(.backgroundColor, range: fullRange)
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
var attachmentRanges: [NSRange] = [] private func applyHighlightsToContent(
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in _ content: NSMutableAttributedString,
guard value != nil else { return } highlights: [RDEPUBHighlight],
attachmentRanges.append(range) 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)
}
}
} }
for range in attachmentRanges.reversed() { // MARK: - Content Normalization
let replacement = NSAttributedString(
string: String(repeating: " ", count: max(range.length, 1)),
attributes: [
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
.foregroundColor: UIColor.clear
]
)
proxy.replaceCharacters(in: range, with: replacement)
}
return proxy
}
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 +446,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,
@@ -715,63 +485,159 @@ final class RDEPUBTextContentView: UIView {
} }
#endif #endif
} // MARK: - Gestures
@available(iOS 16.0, *) @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate { #if canImport(DTCoreText)
func editMenuInteraction( selectionController.handleLongPress(
_ interaction: UIEditMenuInteraction, gesture,
menuFor configuration: UIEditMenuConfiguration, renderView: coreTextRenderView,
suggestedActions: [UIMenuElement] interactionController: interactionController
) -> 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()
) )
switch gesture.state {
case .began:
panGestureRecognizer.isEnabled = true
case .ended:
panGestureRecognizer.isEnabled = false
showSelectionMenuIfNeeded()
case .cancelled, .failed:
panGestureRecognizer.isEnabled = false
default:
break
} }
]) #endif
} }
func editMenuInteraction( @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
_ interaction: UIEditMenuInteraction, #if canImport(DTCoreText)
targetRectFor configuration: UIEditMenuConfiguration selectionController.handlePan(
) -> CGRect { gesture,
currentSelectionMenuTargetRect() ?? bounds renderView: coreTextRenderView,
interactionController: interactionController
)
switch gesture.state {
case .ended:
panGestureRecognizer.isEnabled = false
showSelectionMenuIfNeeded()
case .cancelled, .failed:
panGestureRecognizer.isEnabled = false
default:
break
}
#endif
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: overlayView)
if currentSelection != nil {
clearSelection()
return
}
guard let highlight = highlight(at: point),
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
return
}
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
}
private func showSelectionMenuIfNeeded() {
guard currentSelection != nil,
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty else {
return
}
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
menuController.setMenuVisible(true, animated: true)
}
private func hideSelectionMenu() {
UIMenuController.shared.setMenuVisible(false, animated: true)
}
private var coreTextRenderView: RDEPUBTextPageRenderView? {
#if canImport(DTCoreText)
return coreTextContentView
#else
return nil
#endif
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil }
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
guard let absoluteRange else { return nil }
let matches = currentHighlights.filter { highlight in
guard highlight.location.href == page.href,
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
return false
}
return NSIntersectionRange(range, absoluteRange).length > 0
}
return matches.sorted { lhs, rhs in
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
if lhs.hasNote != rhs.hasNote {
return lhs.hasNote && !rhs.hasNote
}
return lhsRange < rhsRange
}.first
}
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
let rects = interactionController.selectionRects(for: range)
if let first = rects.first {
var unionRect = first
for rect in rects.dropFirst() {
unionRect = unionRect.union(rect)
}
return overlayView.convert(unionRect, to: self)
}
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
return selectionController.isSelecting
}
if gestureRecognizer === tapGestureRecognizer {
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
return false
}
if selectionController.hasActiveSelection {
return true
}
let point = tapGestureRecognizer.location(in: overlayView)
return highlight(at: point) != nil
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer
} }
} }
// MARK: - UIColor Extension
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
} }
} }
@@ -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
@@ -1,133 +1,146 @@
import UIKit import UIKit
/// /// CoreText
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate { /// WXRead
private var isSelectionFromInteraction = false final class RDEPUBTextSelectionController: NSObject {
private var selectionAnchorPoint: CGPoint?
private var selectionMenuAnchorRect: CGRect?
private(set) var isSelecting = false
private var selectionStartIndex: Int = NSNotFound
private var selectionEndIndex: Int = NSNotFound
/// UI
var onSelectionChanged: ((RDEPUBSelection?) -> Void)? var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
///
var pageProvider: (() -> RDEPUBTextPage?)?
func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool { var hasActiveSelection: Bool {
overlayView.selectionRange?.length ?? 0 > 0 selectedAbsoluteRange != nil
} }
func clearSelection( var selectedAbsoluteRange: NSRange? {
textView: UITextView, guard selectionStartIndex != NSNotFound,
overlayView: RDEPUBSelectionOverlayView, selectionEndIndex != NSNotFound else {
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil return nil
) { }
textView.selectedRange = NSRange(location: 0, length: 0) let lower = min(selectionStartIndex, selectionEndIndex)
overlayView.clearSelection() let upper = max(selectionStartIndex, selectionEndIndex)
backgroundOverlayView?.clearSelection() return NSRange(location: lower, length: max(upper - lower + 1, 1))
selectionAnchorPoint = nil
selectionMenuAnchorRect = nil
isSelectionFromInteraction = false
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
} }
func handleLongPress( func handleLongPress(
_ gesture: UILongPressGestureRecognizer, _ gesture: UILongPressGestureRecognizer,
page: RDEPUBTextPage?, renderView: RDEPUBTextPageRenderView?,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController interactionController: RDEPUBPageInteractionController
) { ) {
let point = gesture.location(in: overlayView) guard let renderView else { return }
let point = gesture.location(in: renderView)
switch gesture.state { switch gesture.state {
case .began: case .began:
selectionAnchorPoint = point beginSelection(at: point, renderView: renderView, interactionController: interactionController)
isSelectionFromInteraction = true
handleSelectionFromInteraction(
point: point,
anchorPoint: nil,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .changed: case .changed:
guard let anchor = selectionAnchorPoint else { return } updateSelection(at: point, renderView: renderView, interactionController: interactionController)
handleSelectionFromInteraction(
point: point,
anchorPoint: anchor,
page: page,
overlayView: overlayView,
interactionController: interactionController
)
case .ended: case .ended:
isSelectionFromInteraction = false isSelecting = false
case .cancelled, .failed:
clearSelection(renderView: renderView)
default: default:
break break
} }
} }
func handleTap( func handlePan(
textView: UITextView, _ gesture: UIPanGestureRecognizer,
overlayView: RDEPUBSelectionOverlayView, renderView: RDEPUBTextPageRenderView?,
backgroundOverlayView: RDEPUBSelectionOverlayView? = nil interactionController: RDEPUBPageInteractionController
) { ) {
clearSelection( guard isSelecting, let renderView else { return }
textView: textView, let point = gesture.location(in: renderView)
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView switch gesture.state {
) case .began, .changed:
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
case .ended:
isSelecting = false
case .cancelled, .failed:
clearSelection(renderView: renderView)
default:
break
}
} }
func showSelectionMenuIfNeeded( func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? {
in hostView: UIView, guard let absoluteRange = selectedAbsoluteRange else { return nil }
overlayView: RDEPUBSelectionOverlayView, return interactionController.menuAnchorRect(for: absoluteRange)
interactionController: RDEPUBPageInteractionController, }
copyAction: Selector,
highlightAction: Selector, func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
annotateAction: Selector isSelecting = false
selectionStartIndex = NSNotFound
selectionEndIndex = NSNotFound
renderView?.selectionRects = []
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}
private func beginSelection(
at point: CGPoint,
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) { ) {
guard overlayView.selectionRange?.length ?? 0 > 0, guard let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
let anchorRect = selectionMenuAnchorRect ?? overlayView.selectionRange.flatMap({ interactionController.menuAnchorRect(for: $0) }) else { clearSelection(renderView: renderView)
return
}
selectionStartIndex = index
selectionEndIndex = index
isSelecting = true
applySelection(renderView: renderView, interactionController: interactionController)
}
private func updateSelection(
at point: CGPoint,
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard selectionStartIndex != NSNotFound,
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
return
}
selectionEndIndex = index
applySelection(renderView: renderView, interactionController: interactionController)
}
private func applySelection(
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard let absoluteRange = selectedAbsoluteRange,
let page = pageProvider?() else {
clearSelection(renderView: renderView)
return return
} }
hostView.becomeFirstResponder() renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
let menuRect = overlayView.convert(anchorRect, to: hostView) onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
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?) { private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
guard !isSelectionFromInteraction else { return } guard absoluteRange.location != NSNotFound,
guard let page else { absoluteRange.length > 0,
onSelectionChanged?(nil) NSMaxRange(absoluteRange) <= page.chapterContent.length else {
return return nil
} }
let selectedRange = textView.selectedRange let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string
guard selectedRange.location != NSNotFound, guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
selectedRange.length > 0, return nil
let attributedText = textView.attributedText else {
onSelectionChanged?(nil)
return
} }
let source = attributedText.string as NSString
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
onSelectionChanged?(nil)
return
}
let globalStart = page.pageStartOffset + selectedRange.location
let globalEnd = globalStart + selectedRange.length
let totalLength = max(page.chapterContent.length - 1, 1) let totalLength = max(page.chapterContent.length - 1, 1)
let selection = RDEPUBSelection( let globalStart = absoluteRange.location
let globalEnd = absoluteRange.location + absoluteRange.length
return RDEPUBSelection(
location: RDEPUBLocation( location: RDEPUBLocation(
href: page.href, href: page.href,
progression: Double(globalStart) / Double(totalLength), progression: Double(globalStart) / Double(totalLength),
@@ -137,53 +150,5 @@ final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
text: selectedText, text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString() rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
) )
onSelectionChanged?(selection)
}
private func handleSelectionFromInteraction(
point: CGPoint,
anchorPoint: CGPoint?,
page: RDEPUBTextPage?,
overlayView: RDEPUBSelectionOverlayView,
interactionController: RDEPUBPageInteractionController
) {
guard let page 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
}
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()
)
} }
} }