feat: improve native text selection and annotation actions

This commit is contained in:
shen
2026-06-01 21:29:41 +08:00
parent 70133e4e6e
commit 1c6108061c
8 changed files with 359 additions and 216 deletions
@@ -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)
}
}