import UIKit final class RDEPUBTextSelectionController: NSObject { enum BoundaryHandle { case start case end } enum InteractionState: Equatable { case idle case selecting case selectionActive case adjustingHandle } private enum SelectionGranularity { case character case word } private(set) var isSelecting = false private var selectionStartIndex: Int = NSNotFound private var selectionEndIndex: Int = NSNotFound private var activeGranularity: SelectionGranularity = .character private var selectionAnchorIndex: Int = NSNotFound private(set) var interactionState: InteractionState = .idle { didSet { guard interactionState != oldValue else { return } interactionStateDidChange?(interactionState) } } var onSelectionChanged: ((RDEPUBSelection?) -> Void)? var interactionStateDidChange: ((InteractionState) -> Void)? var pageProvider: (() -> RDEPUBTextPage?)? var chapterCFIMapProvider: (() -> RDEPUBCFIMap?)? var chapterFragmentOffsetsProvider: (() -> [String: Int])? 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: setInteractionState(.selecting) beginSelection(at: point, renderView: renderView, interactionController: interactionController) case .changed: setInteractionState(.selecting) updateSelection(at: point, renderView: renderView, interactionController: interactionController) case .ended: isSelecting = false setInteractionState(hasActiveSelection ? .selectionActive : .idle) 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: setInteractionState(.selecting) updateSelection(at: point, renderView: renderView, interactionController: interactionController) case .ended, .cancelled, .failed: isSelecting = false setInteractionState(hasActiveSelection ? .selectionActive : .idle) default: break } } func updateSelection( byAdjusting handle: BoundaryHandle, at point: CGPoint, renderView: RDEPUBTextPageRenderView?, interactionController: RDEPUBPageInteractionController ) { guard let renderView, let index = interactionController.characterIndexForViewPoint(at: point, in: renderView), selectedAbsoluteRange != nil else { return } setInteractionState(.adjustingHandle) switch handle { case .start: selectionStartIndex = snappedBoundaryIndex(for: index, handle: .start) if selectionEndIndex != NSNotFound { selectionStartIndex = min(selectionStartIndex, selectionEndIndex) } case .end: selectionEndIndex = snappedBoundaryIndex(for: index, handle: .end) if selectionStartIndex != NSNotFound { selectionEndIndex = max(selectionEndIndex, selectionStartIndex) } } applySelection(renderView: renderView, interactionController: interactionController) } func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? { guard let absoluteRange = selectedAbsoluteRange else { return nil } return interactionController.menuAnchorRect(for: absoluteRange) } func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) { isSelecting = false selectionAnchorIndex = NSNotFound activeGranularity = .character selectionStartIndex = NSNotFound selectionEndIndex = NSNotFound setInteractionState(.idle) 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 } selectionAnchorIndex = index activeGranularity = .word isSelecting = true applySelection( range: selectionRange(for: index, granularity: .word), renderView: renderView, interactionController: interactionController ) } private func selectionRange(for focusIndex: Int, granularity: SelectionGranularity) -> NSRange? { guard let page = pageProvider?() else { return nil } switch granularity { case .character: return NSRange(location: focusIndex, length: 1) case .word: if let wordRange = wordRange(containing: focusIndex, in: page.chapterContent.string as NSString) { return wordRange } return NSRange(location: focusIndex, length: 1) } } private func snappedBoundaryIndex(for index: Int, handle: BoundaryHandle) -> Int { guard let page = pageProvider?() else { return index } let text = page.chapterContent.string as NSString guard let wordRange = wordRange(containing: index, in: text) else { return index } switch handle { case .start: return wordRange.location case .end: return max(wordRange.location + wordRange.length - 1, wordRange.location) } } private func wordRange(containing index: Int, in text: NSString) -> NSRange? { guard text.length > 0 else { return nil } let safeIndex = min(max(index, 0), max(text.length - 1, 0)) if let scalar = UnicodeScalar(text.character(at: safeIndex)), CharacterSet.whitespacesAndNewlines.contains(scalar) { return nearestWordRange(to: safeIndex, in: text) ?? text.rangeOfComposedCharacterSequence(at: safeIndex) } let characterRange = text.rangeOfComposedCharacterSequence(at: safeIndex) let probeRange = NSRange(location: safeIndex, length: 1) var matchedWordRange: NSRange? text.enumerateSubstrings( in: NSRange(location: 0, length: text.length), options: [.byWords, .substringNotRequired] ) { _, substringRange, _, stop in guard substringRange.length > 0 else { return } if NSIntersectionRange(substringRange, probeRange).length > 0 || NSLocationInRange(characterRange.location, substringRange) { matchedWordRange = substringRange stop.pointee = true } } if let matchedWordRange { let trimmedRange = trimmed(range: matchedWordRange, in: text) if trimmedRange.length > 0 { return trimmedRange } } return characterRange } private func nearestWordRange(to index: Int, in text: NSString) -> NSRange? { var nearestRange: NSRange? var nearestDistance = Int.max text.enumerateSubstrings( in: NSRange(location: 0, length: text.length), options: [.byWords, .substringNotRequired] ) { _, substringRange, _, _ in guard substringRange.length > 0 else { return } let trimmedRange = self.trimmed(range: substringRange, in: text) guard trimmedRange.length > 0 else { return } let distance: Int if index < trimmedRange.location { distance = trimmedRange.location - index } else if index >= trimmedRange.location + trimmedRange.length { distance = index - (trimmedRange.location + trimmedRange.length - 1) } else { distance = 0 } if distance < nearestDistance { nearestDistance = distance nearestRange = trimmedRange } } return nearestRange } private func trimmed(range: NSRange, in text: NSString) -> NSRange { guard range.length > 0 else { return range } var lowerBound = range.location var upperBound = range.location + range.length while lowerBound < upperBound, let scalar = UnicodeScalar(text.character(at: lowerBound)), CharacterSet.whitespacesAndNewlines.contains(scalar) { lowerBound += 1 } while upperBound > lowerBound, let scalar = UnicodeScalar(text.character(at: upperBound - 1)), CharacterSet.whitespacesAndNewlines.contains(scalar) { upperBound -= 1 } return NSRange(location: lowerBound, length: max(upperBound - lowerBound, 0)) } private func applySelection( range: NSRange?, renderView: RDEPUBTextPageRenderView, interactionController: RDEPUBPageInteractionController ) { guard let range, range.location != NSNotFound, range.length > 0 else { clearSelection(renderView: renderView) return } selectionStartIndex = range.location selectionEndIndex = range.location + range.length - 1 applySelection(renderView: renderView, interactionController: interactionController) } private func updateSelection( at point: CGPoint, renderView: RDEPUBTextPageRenderView, interactionController: RDEPUBPageInteractionController ) { guard selectionAnchorIndex != NSNotFound, let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else { return } let clampedIndex: Int switch activeGranularity { case .character: clampedIndex = index case .word: clampedIndex = snappedBoundaryIndex( for: index, handle: index >= selectionAnchorIndex ? .end : .start ) } selectionStartIndex = selectionAnchorIndex selectionEndIndex = clampedIndex 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) setInteractionState(isSelecting ? .selecting : .selectionActive) onSelectionChanged?(makeSelection(from: absoluteRange, page: page)) } private func setInteractionState(_ state: InteractionState) { interactionState = state } 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 globalStart = absoluteRange.location let globalEnd = absoluteRange.location + absoluteRange.length let chapterData = makeChapterData(for: page) let location = chapterData.location(for: absoluteRange, bookIdentifier: nil) return RDEPUBSelection( location: location, text: selectedText, rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString() ) } private func makeChapterData(for page: RDEPUBTextPage) -> RDEPUBChapterData { let textChapter = RDEPUBTextChapter( chapterIndex: page.chapterIndex, spineIndex: page.spineIndex, href: page.href, title: page.chapterTitle, attributedContent: page.chapterContent, fragmentOffsets: chapterFragmentOffsetsProvider?() ?? [:], cfiMap: chapterCFIMapProvider?(), pageBreakReasons: [], pages: [page] ) return RDEPUBChapterData( chapter: textChapter, indexTable: RDEPUBTextIndexTable(chapters: [textChapter]) ) } }