ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

390 lines
14 KiB
Swift

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])
)
}
}