import UIKit /// 负责管理文本选区、长按交互和菜单定位。 final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate { private var isSelectionFromInteraction = false private var selectionAnchorPoint: CGPoint? private var selectionMenuAnchorRect: CGRect? var onSelectionChanged: ((RDEPUBSelection?) -> Void)? func canPerformSelectionAction(in overlayView: RDEPUBSelectionOverlayView) -> Bool { overlayView.selectionRange?.length ?? 0 > 0 } 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, 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) } private func handleSelectionFromInteraction( point: CGPoint, anchorPoint: CGPoint?, page: RDEPUBTextPage?, overlayView: RDEPUBSelectionOverlayView, interactionController: RDEPUBPageInteractionController ) { guard let page 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 } 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() ) } }