- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
168 lines
6.1 KiB
Swift
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()
|
|
)
|
|
}
|
|
}
|