feat: improve native text selection and annotation actions
This commit is contained in:
@@ -1,49 +1,146 @@
|
||||
import UIKit
|
||||
|
||||
/// 负责管理文本选区、UITextView 代理和菜单定位。
|
||||
/// 对齐 WXRead 的选择模型:UITextView 处理原生选择,高亮通过 attributed string 属性绘制。
|
||||
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
||||
/// 负责管理文本选区和 CoreText 命中测试。
|
||||
/// 对齐 WXRead 的选择模型:触摸命中和最终高亮共用同一套字符范围。
|
||||
final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
private var isSelectionFromInteraction = false
|
||||
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 pageProvider: (() -> RDEPUBTextPage?)?
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView) {
|
||||
textViewDidChangeSelection(textView, page: pageProvider?())
|
||||
var hasActiveSelection: Bool {
|
||||
selectedAbsoluteRange != nil
|
||||
}
|
||||
|
||||
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
|
||||
guard !isSelectionFromInteraction else { return }
|
||||
guard let page else {
|
||||
onSelectionChanged?(nil)
|
||||
var selectedAbsoluteRange: NSRange? {
|
||||
guard selectionStartIndex != NSNotFound,
|
||||
selectionEndIndex != NSNotFound else {
|
||||
return nil
|
||||
}
|
||||
let lower = min(selectionStartIndex, selectionEndIndex)
|
||||
let upper = max(selectionStartIndex, selectionEndIndex)
|
||||
return NSRange(location: lower, length: max(upper - lower + 1, 1))
|
||||
}
|
||||
|
||||
func handleLongPress(
|
||||
_ gesture: UILongPressGestureRecognizer,
|
||||
renderView: RDEPUBTextPageRenderView?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard let renderView else { return }
|
||||
let point = gesture.location(in: renderView)
|
||||
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
beginSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .changed:
|
||||
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
|
||||
case .ended:
|
||||
isSelecting = false
|
||||
case .cancelled, .failed:
|
||||
clearSelection(renderView: renderView)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handlePan(
|
||||
_ gesture: UIPanGestureRecognizer,
|
||||
renderView: RDEPUBTextPageRenderView?,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard isSelecting, let renderView else { return }
|
||||
let point = gesture.location(in: renderView)
|
||||
|
||||
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 menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? {
|
||||
guard let absoluteRange = selectedAbsoluteRange else { return nil }
|
||||
return interactionController.menuAnchorRect(for: absoluteRange)
|
||||
}
|
||||
|
||||
func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
|
||||
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 let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) 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
|
||||
}
|
||||
|
||||
let selectedRange = textView.selectedRange
|
||||
guard selectedRange.location != NSNotFound,
|
||||
selectedRange.length > 0,
|
||||
let attributedText = textView.attributedText else {
|
||||
onSelectionChanged?(nil)
|
||||
return
|
||||
renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
|
||||
onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
|
||||
}
|
||||
|
||||
private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
|
||||
guard absoluteRange.location != NSNotFound,
|
||||
absoluteRange.length > 0,
|
||||
NSMaxRange(absoluteRange) <= page.chapterContent.length else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let source = attributedText.string as NSString
|
||||
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !selectedText.isEmpty else {
|
||||
onSelectionChanged?(nil)
|
||||
return
|
||||
let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string
|
||||
guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let globalStart = page.pageStartOffset + selectedRange.location
|
||||
let globalEnd = globalStart + selectedRange.length
|
||||
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(
|
||||
href: page.href,
|
||||
progression: Double(globalStart) / Double(totalLength),
|
||||
@@ -53,45 +150,5 @@ final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user