- 移除 RDEPUBSelectableTextView,改用原生 UITextView - 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮 - RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区 - RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制 - 新增高亮选区复刻 WXRead 实现方案文档 - UI 测试适配新架构 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
98 lines
3.6 KiB
Swift
98 lines
3.6 KiB
Swift
import UIKit
|
||
|
||
/// 负责管理文本选区、UITextView 代理和菜单定位。
|
||
/// 对齐 WXRead 的选择模型:UITextView 处理原生选择,高亮通过 attributed string 属性绘制。
|
||
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
||
|
||
private var isSelectionFromInteraction = false
|
||
private var selectionMenuAnchorRect: CGRect?
|
||
|
||
/// 选区变化回调,通知视图层更新 UI
|
||
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
|
||
/// 获取当前页面数据
|
||
var pageProvider: (() -> RDEPUBTextPage?)?
|
||
|
||
// MARK: - UITextViewDelegate
|
||
|
||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||
textViewDidChangeSelection(textView, page: pageProvider?())
|
||
}
|
||
|
||
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
|
||
guard !isSelectionFromInteraction else { return }
|
||
guard let page else {
|
||
onSelectionChanged?(nil)
|
||
return
|
||
}
|
||
|
||
let selectedRange = textView.selectedRange
|
||
guard selectedRange.location != NSNotFound,
|
||
selectedRange.length > 0,
|
||
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 selection = RDEPUBSelection(
|
||
location: RDEPUBLocation(
|
||
href: page.href,
|
||
progression: Double(globalStart) / Double(totalLength),
|
||
lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength),
|
||
fragment: nil
|
||
),
|
||
text: selectedText,
|
||
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
|
||
)
|
||
onSelectionChanged?(selection)
|
||
}
|
||
|
||
// 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 textView.selectedRange.length > 0,
|
||
let textRange = textView.selectedTextRange else { return }
|
||
|
||
let rect = textView.firstRect(for: textRange)
|
||
guard !rect.isNull, !rect.isEmpty else { return }
|
||
|
||
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)
|
||
}
|
||
}
|