Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift
T
shenandshen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

168 lines
6.1 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, .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()
)
}
}