refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案
- 移除 RDEPUBSelectableTextView,改用原生 UITextView - 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮 - RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区 - RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制 - 新增高亮选区复刻 WXRead 实现方案文档 - UI 测试适配新架构 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
948004eed1
commit
70133e4e6e
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user