From 70133e4e6ee155b625a2ae057d1d0e9c23509107 Mon Sep 17 00:00:00 2001 From: shenlei Date: Mon, 1 Jun 2026 20:57:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor(reader):=20=E9=87=8D=E6=9E=84=E9=AB=98?= =?UTF-8?q?=E4=BA=AE=E9=80=89=E5=8C=BA=E7=BB=98=E5=88=B6=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=20WXRead=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 RDEPUBSelectableTextView,改用原生 UITextView - 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮 - RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区 - RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制 - 新增高亮选区复刻 WXRead 实现方案文档 - UI 测试适配新架构 Co-Authored-By: Claude Opus 4.7 --- .../高亮选区复刻WXRead实现方案.md | 365 +++++++++++++++ .../Pods/Pods.xcodeproj/project.pbxproj | 4 - .../xcschemes/ReadViewDemo.xcscheme | 89 ++++ .../ReaderUITests/ReaderAnnotationTests.swift | 25 +- .../Models/RDEPUBAnnotationModels.swift | 17 + .../EPUBTextRendering/RDEPUBChapterData.swift | 27 ++ ...PUBReaderController+ContentDelegates.swift | 29 +- .../RDEPUBPageInteractionController.swift | 6 + .../TextPage/RDEPUBSelectableTextView.swift | 32 -- .../RDEPUBTextAnnotationOverlay.swift | 6 +- .../TextPage/RDEPUBTextContentView.swift | 432 ++++++------------ .../TextPage/RDEPUBTextPageRenderView.swift | 103 +++++ .../RDEPUBTextSelectionController.swift | 176 ++----- 13 files changed, 801 insertions(+), 510 deletions(-) create mode 100644 Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md create mode 100644 ReadViewDemo/ReadViewDemo.xcodeproj/xcshareddata/xcschemes/ReadViewDemo.xcscheme delete mode 100644 Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectableTextView.swift diff --git a/Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md b/Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md new file mode 100644 index 0000000..0b448bb --- /dev/null +++ b/Doc/FeatureSolution/高亮选区复刻WXRead实现方案.md @@ -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 位置 diff --git a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj index b4bfd12..bfe94f1 100644 --- a/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj +++ b/ReadViewDemo/Pods/Pods.xcodeproj/project.pbxproj @@ -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 = ""; }; 09C957F3199C596781EFA1DEA1DDCEC8 /* NSFileWrapper+DTCopying.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = "NSFileWrapper+DTCopying.h"; path = "Core/Source/NSFileWrapper+DTCopying.h"; sourceTree = ""; }; 0A294C65CC6C154EF2122380953FEFE3 /* RDReaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDReaderView.swift; sourceTree = ""; }; - 0A65FCB87389CF471A9A4BD659D58346 /* RDEPUBSelectableTextView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBSelectableTextView.swift; sourceTree = ""; }; 0B10D3EC1701BBA4D69926915F942558 /* SnapKit-umbrella.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = "SnapKit-umbrella.h"; sourceTree = ""; }; 0B7771FDE9AB1688166584C0AA03F12A /* DTCoreTextFontCollection.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = DTCoreTextFontCollection.h; path = Core/Source/DTCoreTextFontCollection.h; sourceTree = ""; }; 0B832537E4E508747319F7DF89BE256A /* RDEPUBChapterTailNormalizer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = RDEPUBChapterTailNormalizer.swift; sourceTree = ""; }; @@ -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 */, diff --git a/ReadViewDemo/ReadViewDemo.xcodeproj/xcshareddata/xcschemes/ReadViewDemo.xcscheme b/ReadViewDemo/ReadViewDemo.xcodeproj/xcshareddata/xcschemes/ReadViewDemo.xcscheme new file mode 100644 index 0000000..56a06a1 --- /dev/null +++ b/ReadViewDemo/ReadViewDemo.xcodeproj/xcshareddata/xcschemes/ReadViewDemo.xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift index f535b8f..0018b1e 100644 --- a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift @@ -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) + } } diff --git a/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift b/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift index afa1721..ff4d062 100644 --- a/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift +++ b/Sources/RDReaderView/EPUBCore/Models/RDEPUBAnnotationModels.swift @@ -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 diff --git a/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift b/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift index 6b4a336..8efe6c3 100644 --- a/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift +++ b/Sources/RDReaderView/EPUBTextRendering/RDEPUBChapterData.swift @@ -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) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift index bd977e3..4372ff0 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift @@ -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? { diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift index c02cb42..4dc274d 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift @@ -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 } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectableTextView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectableTextView.swift deleted file mode 100644 index af715ca..0000000 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBSelectableTextView.swift +++ /dev/null @@ -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) - } -} diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift index 39345a4..ad1cc1f 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextAnnotationOverlay.swift @@ -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, diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift index 3c2e633..3595caa 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -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() 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 } + self.currentSelection = selection if let selection { - self.currentSelection = 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 - } -#else - return super.canPerformAction(action, withSender: sender) -#endif + @objc func rd_copy(_ sender: Any?) { + performSelectionAction(.copy) } + @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 } } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift index ee68d18..9516993 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextPageRenderView.swift @@ -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 diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift index 16f668d..3089a61 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift @@ -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) } }