import UIKit /// 负责管理文本选区和 CoreText 命中测试。 /// 对齐 WXRead 的选择模型:触摸命中和最终高亮共用同一套字符范围。 final class RDEPUBTextSelectionController: NSObject { private(set) var isSelecting = false private var selectionStartIndex: Int = NSNotFound private var selectionEndIndex: Int = NSNotFound /// 选区变化回调,通知视图层更新 UI var onSelectionChanged: ((RDEPUBSelection?) -> Void)? /// 获取当前页面数据 var pageProvider: (() -> RDEPUBTextPage?)? var hasActiveSelection: Bool { selectedAbsoluteRange != 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, .cancelled, .failed: isSelecting = false 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 { if !isSelecting { 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 } 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 selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } let totalLength = max(page.chapterContent.length - 1, 1) let globalStart = absoluteRange.location let globalEnd = absoluteRange.location + absoluteRange.length let startAnchor = RDEPUBTextAnchor( fileIndex: page.spineIndex, row: 0, column: 0, chapterOffset: globalStart ) let endAnchor = RDEPUBTextAnchor( fileIndex: page.spineIndex, row: 0, column: 0, chapterOffset: globalEnd ) return RDEPUBSelection( location: RDEPUBLocation( href: page.href, progression: Double(globalStart) / Double(totalLength), lastProgression: Double(max(globalEnd - 1, globalStart)) / Double(totalLength), fragment: nil, rangeAnchor: RDEPUBTextRangeAnchor(start: startAnchor, end: endAnchor) ), text: selectedText, rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString() ) } }