155 lines
5.7 KiB
Swift
155 lines
5.7 KiB
Swift
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:
|
||
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
|
||
}
|
||
|
||
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
|
||
return 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()
|
||
)
|
||
}
|
||
}
|