feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
@@ -4,17 +4,14 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面交互控制器
|
||||
|
||||
/// 页面交互控制器,负责文本层面的坐标映射与选择逻辑
|
||||
/// 在 DTCoreText 排版结果和用户交互之间充当桥梁
|
||||
/// 提供字符索引查找、选择范围计算、选择矩形计算等功能
|
||||
final class RDEPUBPageInteractionController {
|
||||
|
||||
var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
private var dtLayoutFrame: DTCoreTextLayoutFrame?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
func configure(layoutFrame: DTCoreTextLayoutFrame?, page: RDEPUBTextPage?) {
|
||||
dtLayoutFrame = layoutFrame
|
||||
if let layoutFrame, let page {
|
||||
@@ -25,9 +22,6 @@ final class RDEPUBPageInteractionController {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Hit Testing
|
||||
|
||||
/// 将视图坐标系的触摸点转为字符索引(对齐 WXRead 的 stringIndexForPoint:)
|
||||
func characterIndexForViewPoint(at viewPoint: CGPoint, in view: UIView) -> Int? {
|
||||
let localPoint = CGPoint(x: viewPoint.x, y: viewPoint.y)
|
||||
return characterIndex(at: localPoint)
|
||||
@@ -36,7 +30,6 @@ final class RDEPUBPageInteractionController {
|
||||
func characterIndex(at point: CGPoint) -> Int? {
|
||||
guard let snapshot else { return nil }
|
||||
|
||||
// Attachment rect priority (6pt inset for easier tapping)
|
||||
for attachment in snapshot.attachments {
|
||||
if attachment.frame.insetBy(dx: -6, dy: -6).contains(point) {
|
||||
return attachment.stringRange.location
|
||||
@@ -63,8 +56,6 @@ final class RDEPUBPageInteractionController {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Selection Range
|
||||
|
||||
func selectionRange(from startPoint: CGPoint, to endPoint: CGPoint) -> NSRange? {
|
||||
guard let start = characterIndex(at: startPoint),
|
||||
let end = characterIndex(at: endPoint) else { return nil }
|
||||
@@ -73,8 +64,6 @@ final class RDEPUBPageInteractionController {
|
||||
return NSRange(location: lower, length: max(upper - lower, 1))
|
||||
}
|
||||
|
||||
// MARK: - Selection Rects
|
||||
|
||||
func selectionRects(for absoluteRange: NSRange) -> [CGRect] {
|
||||
guard let snapshot else { return [] }
|
||||
var rects: [CGRect] = []
|
||||
@@ -136,8 +125,6 @@ final class RDEPUBPageInteractionController {
|
||||
return CGRect(x: minX, y: minY, width: max(maxX - minX, 2), height: max(maxY - minY, 2))
|
||||
}
|
||||
|
||||
// MARK: - Caret Rect
|
||||
|
||||
func caretRect(at index: Int) -> CGRect? {
|
||||
guard let snapshot else { return nil }
|
||||
guard let line = snapshot.lines.first(where: { NSLocationInRange(index, $0.stringRange) }) else {
|
||||
@@ -159,8 +146,6 @@ final class RDEPUBPageInteractionController {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func nearestLine(to point: CGPoint, in lines: [RDEPUBPageLine]) -> RDEPUBPageLine? {
|
||||
var bestLine: RDEPUBPageLine?
|
||||
var bestDistance: CGFloat = .greatestFiniteMagnitude
|
||||
@@ -196,6 +181,7 @@ final class RDEPUBPageInteractionController {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
|
||||
guard let dtLayoutFrame, let snapshot else { return nil }
|
||||
let localLocation = max(range.location - pageOffset(for: snapshot), 0)
|
||||
|
||||
@@ -4,63 +4,56 @@ import UIKit
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 页面排版快照数据结构
|
||||
|
||||
/// 文本行信息
|
||||
/// 描述 DTCoreText 排版后的一行文本的位置、范围和基线信息
|
||||
struct RDEPUBPageLine {
|
||||
/// 行内文本在 chapterContent 中的绝对字符范围
|
||||
|
||||
let stringRange: NSRange
|
||||
/// 行的框架(相对于排版区域)
|
||||
|
||||
let frame: CGRect
|
||||
/// 行的基线原点
|
||||
|
||||
let baselineOrigin: CGPoint
|
||||
/// 基线以上的高度(升部)
|
||||
|
||||
let ascent: CGFloat
|
||||
/// 基线以下的高度(降部)
|
||||
|
||||
let descent: CGFloat
|
||||
/// 行间距
|
||||
|
||||
let leading: CGFloat
|
||||
}
|
||||
|
||||
/// 文本 Run 信息
|
||||
/// 描述一行内某个连续绘制单元(如普通文字或附件)
|
||||
struct RDEPUBPageRun {
|
||||
/// Run 内文本的绝对字符范围
|
||||
|
||||
let stringRange: NSRange
|
||||
/// Run 的绘制框架
|
||||
|
||||
let frame: CGRect
|
||||
/// 是否为附件(图片等)
|
||||
|
||||
let isAttachment: Bool
|
||||
}
|
||||
|
||||
/// 附件信息
|
||||
/// 描述嵌入在文本中的图片或其他附件
|
||||
struct RDEPUBPageAttachment {
|
||||
/// 附件在文本中的绝对字符范围
|
||||
|
||||
let stringRange: NSRange
|
||||
/// 附件的显示框架
|
||||
|
||||
let frame: CGRect
|
||||
/// 附件的建议显示尺寸
|
||||
|
||||
let displaySize: CGSize
|
||||
/// 附件的布局方式(行内/浮动等)
|
||||
|
||||
let placement: RDEPUBTextAttachmentPlacement?
|
||||
/// 附件类型(封面图/普通图片等)
|
||||
|
||||
let kind: RDEPUBTextAttachmentKind?
|
||||
}
|
||||
|
||||
// MARK: - 页面排版快照
|
||||
|
||||
/// 页面排版快照
|
||||
/// 封装 DTCoreText 的排版结果,提供高效的文本位置查询能力
|
||||
/// 用于支持文本选择、高亮渲染和搜索结果定位
|
||||
struct RDEPUBPageLayoutSnapshot {
|
||||
|
||||
let page: RDEPUBTextPage
|
||||
|
||||
let lines: [RDEPUBPageLine]
|
||||
|
||||
let runs: [RDEPUBPageRun]
|
||||
|
||||
let attachments: [RDEPUBPageAttachment]
|
||||
|
||||
let pageContentRange: NSRange
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
let layoutFrame: DTCoreTextLayoutFrame
|
||||
#endif
|
||||
|
||||
@@ -128,6 +121,7 @@ struct RDEPUBPageLayoutSnapshot {
|
||||
}
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
static func build(
|
||||
from layoutFrame: DTCoreTextLayoutFrame,
|
||||
page: RDEPUBTextPage
|
||||
|
||||
@@ -1,47 +1,43 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - 覆盖层装饰类型
|
||||
|
||||
/// 覆盖层装饰的数据结构
|
||||
/// 表示一个需要绘制在文本上方或下方的视觉装饰(高亮、搜索结果、选区等)
|
||||
struct RDEPUBTextOverlayDecoration {
|
||||
/// 装饰类型枚举
|
||||
|
||||
enum Kind: String {
|
||||
/// 文本选区(蓝色半透明)
|
||||
|
||||
case selection
|
||||
/// 用户高亮标注
|
||||
|
||||
case highlight
|
||||
/// 用户划线标注
|
||||
|
||||
case underline
|
||||
/// 搜索匹配项(普通)
|
||||
|
||||
case search
|
||||
/// 搜索匹配项(当前高亮)
|
||||
|
||||
case activeSearch
|
||||
/// 定位指示(跳转到位置时的动画目标)
|
||||
|
||||
case locate
|
||||
}
|
||||
|
||||
/// 装饰类型
|
||||
var kind: Kind
|
||||
/// 装饰对应的绝对文本范围
|
||||
|
||||
var absoluteRange: NSRange
|
||||
/// 装饰的绘制矩形数组(每行一个矩形)
|
||||
|
||||
var rects: [CGRect]
|
||||
/// 装饰的颜色
|
||||
|
||||
var color: UIColor
|
||||
}
|
||||
|
||||
// MARK: - 选择覆盖层视图
|
||||
|
||||
/// 文本选择和装饰的覆盖层绘制视图
|
||||
/// 位于文本内容上方,负责绘制选区、高亮、搜索结果等视觉效果
|
||||
/// 使用 Core Graphics 直接绘制,支持填充矩形和下划线两种绘制模式
|
||||
class RDEPUBSelectionOverlayView: UIView {
|
||||
|
||||
private(set) var page: RDEPUBTextPage?
|
||||
|
||||
private var snapshot: RDEPUBPageLayoutSnapshot?
|
||||
|
||||
private(set) var selectionRange: NSRange?
|
||||
|
||||
private var selectionRects: [CGRect] = []
|
||||
|
||||
private var decorations: [RDEPUBTextOverlayDecoration] = []
|
||||
|
||||
private let selectionVerticalAdjustment: CGFloat = -1
|
||||
|
||||
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24) {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import UIKit
|
||||
|
||||
/// 原生文本渲染路径的批注覆盖层,负责绘制用户高亮、搜索命中高亮和当前选区装饰。
|
||||
final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
|
||||
private let normalSearchColor = UIColor(red: 0.21, green: 0.48, blue: 0.95, alpha: 0.16)
|
||||
|
||||
private let activeSearchColor = UIColor(red: 0.14, green: 0.42, blue: 0.95, alpha: 0.34)
|
||||
|
||||
/// 将用户高亮批注以富文本属性形式应用到页面内容上
|
||||
/// - Parameters:
|
||||
/// - highlights: 高亮批注数组
|
||||
/// - content: 待修改的富文本
|
||||
/// - page: 目标文本页
|
||||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||||
func applyHighlights(
|
||||
_ highlights: [RDEPUBHighlight],
|
||||
to content: NSMutableAttributedString,
|
||||
@@ -47,12 +42,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将搜索命中高亮以富文本背景色形式应用到页面内容上
|
||||
/// - Parameters:
|
||||
/// - content: 待修改的富文本
|
||||
/// - page: 目标文本页
|
||||
/// - searchState: 当前搜索状态
|
||||
/// - contentBaseOffset: 内容在全局偏移中的起始位置
|
||||
func applySearchHighlights(
|
||||
to content: NSMutableAttributedString,
|
||||
page: RDEPUBTextPage,
|
||||
@@ -78,13 +67,6 @@ final class RDEPUBTextAnnotationOverlay: RDEPUBSelectionOverlayView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建页面的背景和前景装饰数组(搜索高亮为背景,下划线批注为前景)
|
||||
/// - Parameters:
|
||||
/// - page: 目标文本页
|
||||
/// - highlights: 高亮批注数组
|
||||
/// - searchState: 当前搜索状态
|
||||
/// - interactionController: 用于计算选区矩形的交互控制器
|
||||
/// - Returns: 分离的背景和前景装饰数组
|
||||
func buildDecorations(
|
||||
page: RDEPUBTextPage,
|
||||
highlights: [RDEPUBHighlight],
|
||||
|
||||
@@ -5,10 +5,9 @@ import Foundation
|
||||
import DTCoreText
|
||||
#endif
|
||||
|
||||
// MARK: - 文本内容视图代理
|
||||
|
||||
protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
|
||||
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
@@ -27,22 +26,46 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 对齐 WXRead 架构:选区和高亮共享 CoreText 命中测试与 drawRect 绘制结果。
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
enum SelectionInteractionState: Equatable {
|
||||
case idle
|
||||
case selectionPending
|
||||
case selecting
|
||||
case selectionActive
|
||||
case adjustingHandle
|
||||
}
|
||||
|
||||
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
private var contentInsets: UIEdgeInsets = .zero
|
||||
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var currentChapterCFIMap: RDEPUBCFIMap?
|
||||
private var currentChapterFragmentOffsets: [String: Int] = [:]
|
||||
|
||||
private var currentSelection: RDEPUBSelection?
|
||||
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
|
||||
private var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle?
|
||||
|
||||
private let selectionLoupeView = RDEPUBSelectionLoupeView()
|
||||
|
||||
private var selectionInteractionState: SelectionInteractionState = .idle
|
||||
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
|
||||
private var currentSearchState: RDEPUBSearchState?
|
||||
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
var selectionTapSuppressionDidChange: ((Bool) -> Void)?
|
||||
|
||||
var selectionPagingSuppressionDidChange: ((Bool) -> Void)?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private let coreTextContentView: RDEPUBTextPageRenderView = {
|
||||
let view = RDEPUBTextPageRenderView()
|
||||
view.backgroundColor = .clear
|
||||
@@ -53,10 +76,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}()
|
||||
|
||||
private var coreTextDisplayContent: NSAttributedString?
|
||||
|
||||
private var coreTextDisplayRange: NSRange?
|
||||
#endif
|
||||
|
||||
private let interactionController = RDEPUBPageInteractionController()
|
||||
|
||||
private let selectionController = RDEPUBTextSelectionController()
|
||||
|
||||
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
|
||||
@@ -91,7 +116,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
|
||||
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
||||
gesture.isEnabled = false
|
||||
gesture.delegate = self
|
||||
return gesture
|
||||
}()
|
||||
@@ -102,8 +126,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return gesture
|
||||
}()
|
||||
|
||||
// MARK: - Init
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
accessibilityIdentifier = "epub.reader.content.view"
|
||||
@@ -114,10 +136,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(pageNumberLabel)
|
||||
addSubview(selectionLoupeView)
|
||||
addGestureRecognizer(longPressGestureRecognizer)
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||||
selectionLoupeView.isHidden = true
|
||||
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
@@ -130,17 +154,30 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
self.updateAccessibilityDecorationSummary()
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
selectionController.interactionStateDidChange = { [weak self] state in
|
||||
self?.handleSelectionControllerStateChange(state)
|
||||
}
|
||||
selectionController.pageProvider = { [weak self] in self?.currentPage }
|
||||
selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap }
|
||||
selectionController.chapterFragmentOffsetsProvider = { [weak self] in
|
||||
self?.currentChapterFragmentOffsets ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - First Responder
|
||||
|
||||
override var canBecomeFirstResponder: Bool { true }
|
||||
|
||||
var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer {
|
||||
longPressGestureRecognizer
|
||||
}
|
||||
|
||||
var isSelectionInteractionInProgress: Bool {
|
||||
selectionInteractionState != .idle
|
||||
}
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
action == #selector(rd_copy(_:))
|
||||
|| action == #selector(rd_highlight(_:))
|
||||
@@ -159,8 +196,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
performSelectionAction(.annotate)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
@@ -181,19 +216,22 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Configure
|
||||
|
||||
func configure(
|
||||
page: RDEPUBTextPage,
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||
chapterFragmentOffsets: [String: Int] = [:],
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
) {
|
||||
currentPage = page
|
||||
currentChapterCFIMap = chapterCFIMap
|
||||
currentChapterFragmentOffsets = chapterFragmentOffsets
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
currentHighlights = highlights
|
||||
currentSearchState = searchState
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
@@ -228,7 +266,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
value: configuration.theme.contentTextColor,
|
||||
range: fullRange
|
||||
)
|
||||
// 注入高亮/下划线自定义属性(对齐 WXRead 的 WRChapterData.addHighlightInRange:)
|
||||
|
||||
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
|
||||
coreTextContentView.isHidden = false
|
||||
coreTextContentView.backgroundColor = .clear
|
||||
@@ -263,7 +301,9 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
currentSearchState = nil
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.idle)
|
||||
selectionLoupeView.dismiss()
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
@@ -271,8 +311,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
|
||||
// MARK: - 选择操作
|
||||
|
||||
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
|
||||
let selection = currentSelection ?? menuSelection
|
||||
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
|
||||
@@ -280,8 +318,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
hideSelectionMenu()
|
||||
}
|
||||
|
||||
// MARK: - Cover Image
|
||||
|
||||
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
|
||||
guard page.pageIndexInChapter == 0,
|
||||
page.href.lowercased().contains("cover"),
|
||||
@@ -327,9 +363,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Dark Image Adjustment
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func darkImageAdjustedContentIfNeeded(
|
||||
_ content: NSMutableAttributedString,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
@@ -409,8 +444,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - 高亮属性注入(对齐 WXRead 的 WRChapterData.addHighlightInRange:)
|
||||
|
||||
private func applyHighlightsToContent(
|
||||
_ content: NSMutableAttributedString,
|
||||
highlights: [RDEPUBHighlight],
|
||||
@@ -435,8 +468,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Content Normalization
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
let content = NSMutableAttributedString(attributedString: page.content)
|
||||
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
|
||||
@@ -463,9 +494,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return !CharacterSet.newlines.contains(previousScalar)
|
||||
}
|
||||
|
||||
// MARK: - CoreText Layout
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
|
||||
private func updateCoreTextLayoutFrameIfNeeded() {
|
||||
guard !coreTextContentView.isHidden,
|
||||
let displayContent = coreTextDisplayContent,
|
||||
@@ -502,8 +532,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Gestures
|
||||
|
||||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||||
#if canImport(DTCoreText)
|
||||
layoutIfNeeded()
|
||||
@@ -514,12 +542,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
)
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
panGestureRecognizer.isEnabled = true
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(.selecting)
|
||||
hideSelectionMenu()
|
||||
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
|
||||
case .changed:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self))
|
||||
case .ended:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
selectionLoupeView.dismiss()
|
||||
showSelectionMenuIfNeeded()
|
||||
case .cancelled, .failed:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
selectionLoupeView.dismiss()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -528,17 +563,46 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
||||
#if canImport(DTCoreText)
|
||||
selectionController.handlePan(
|
||||
gesture,
|
||||
renderView: coreTextRenderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
layoutIfNeeded()
|
||||
let point = gesture.location(in: coreTextRenderView ?? self)
|
||||
if gesture.state == .began, activeSelectionHandle == nil {
|
||||
if let renderView = coreTextRenderView,
|
||||
let handle = renderView.selectionHandle(at: point) {
|
||||
activeSelectionHandle = handle == .start ? .start : .end
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
hideSelectionMenu()
|
||||
updateSelectionLoupe(for: point)
|
||||
}
|
||||
}
|
||||
|
||||
if let activeSelectionHandle, let renderView = coreTextRenderView {
|
||||
selectionController.updateSelection(
|
||||
byAdjusting: activeSelectionHandle,
|
||||
at: point,
|
||||
renderView: renderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
updateSelectionLoupe(for: point)
|
||||
} else {
|
||||
selectionController.handlePan(
|
||||
gesture,
|
||||
renderView: coreTextRenderView,
|
||||
interactionController: interactionController
|
||||
)
|
||||
if selectionController.isSelecting {
|
||||
updateSelectionLoupe(for: point)
|
||||
}
|
||||
}
|
||||
switch gesture.state {
|
||||
case .ended:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
selectionLoupeView.dismiss()
|
||||
showSelectionMenuIfNeeded()
|
||||
case .cancelled, .failed:
|
||||
panGestureRecognizer.isEnabled = false
|
||||
activeSelectionHandle = nil
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
selectionLoupeView.dismiss()
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -547,7 +611,20 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
|
||||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||||
let point = gesture.location(in: overlayView)
|
||||
if currentSelection != nil {
|
||||
if let renderView = coreTextRenderView {
|
||||
let renderPoint = gesture.location(in: renderView)
|
||||
if renderView.selectionHandle(at: renderPoint) != nil {
|
||||
return
|
||||
}
|
||||
if currentSelection != nil {
|
||||
if renderView.selectionContains(renderPoint) {
|
||||
showSelectionMenuIfNeeded()
|
||||
return
|
||||
}
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
} else if currentSelection != nil {
|
||||
clearSelection()
|
||||
return
|
||||
}
|
||||
@@ -563,6 +640,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
guard let highlight = highlight(at: point),
|
||||
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
|
||||
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
||||
return
|
||||
}
|
||||
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
||||
@@ -589,6 +667,57 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
}
|
||||
|
||||
private func updateSelectionLoupe(for point: CGPoint) {
|
||||
#if canImport(DTCoreText)
|
||||
guard let renderView = coreTextRenderView else { return }
|
||||
let localPoint = convert(point, from: renderView)
|
||||
selectionLoupeView.present(
|
||||
sourceView: renderView,
|
||||
focusPoint: point,
|
||||
hostBounds: bounds.inset(by: contentInsets),
|
||||
targetPoint: localPoint
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) {
|
||||
switch state {
|
||||
case .idle:
|
||||
if currentSelection == nil, activeSelectionHandle == nil {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
case .selecting:
|
||||
updateSelectionInteractionState(.selecting)
|
||||
case .selectionActive:
|
||||
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
|
||||
case .adjustingHandle:
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSelectionInteractionState(_ state: SelectionInteractionState) {
|
||||
let previousTapSuppressed = selectionInteractionState != .idle
|
||||
let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
|
||||
selectionInteractionState = state
|
||||
let currentTapSuppressed = selectionInteractionState != .idle
|
||||
let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState)
|
||||
if previousTapSuppressed != currentTapSuppressed {
|
||||
selectionTapSuppressionDidChange?(currentTapSuppressed)
|
||||
}
|
||||
if previousPagingSuppressed != currentPagingSuppressed {
|
||||
selectionPagingSuppressionDidChange?(currentPagingSuppressed)
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool {
|
||||
switch state {
|
||||
case .idle, .selectionPending, .selectionActive:
|
||||
return false
|
||||
case .selecting, .adjustingHandle:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private var coreTextRenderView: RDEPUBTextPageRenderView? {
|
||||
#if canImport(DTCoreText)
|
||||
return coreTextContentView
|
||||
@@ -678,23 +807,140 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return overlayView.convert(fallbackRect, to: self)
|
||||
}
|
||||
|
||||
func shouldSuppressReaderTap(at point: CGPoint) -> Bool {
|
||||
#if canImport(DTCoreText)
|
||||
if selectionInteractionState == .selectionPending {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let renderView = coreTextRenderView else {
|
||||
return false
|
||||
}
|
||||
let renderPoint = convert(point, to: renderView)
|
||||
if renderView.selectionHandle(at: renderPoint) != nil {
|
||||
return true
|
||||
}
|
||||
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
|
||||
return true
|
||||
}
|
||||
switch selectionInteractionState {
|
||||
case .idle:
|
||||
return false
|
||||
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
|
||||
return true
|
||||
}
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
#if canImport(DTCoreText)
|
||||
guard selectionInteractionState == .idle,
|
||||
activeSelectionHandle == nil,
|
||||
currentPage != nil,
|
||||
let touch = touches.first,
|
||||
let renderView = coreTextRenderView else {
|
||||
return
|
||||
}
|
||||
let point = touch.location(in: renderView)
|
||||
guard renderView.selectionHandle(at: point) == nil else { return }
|
||||
updateSelectionInteractionState(.selectionPending)
|
||||
#endif
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesEnded(touches, with: event)
|
||||
resetSelectionPendingIfNeeded()
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesCancelled(touches, with: event)
|
||||
resetSelectionPendingIfNeeded()
|
||||
}
|
||||
|
||||
private func resetSelectionPendingIfNeeded() {
|
||||
guard selectionInteractionState == .selectionPending else { return }
|
||||
if currentSelection != nil {
|
||||
updateSelectionInteractionState(.selectionActive)
|
||||
} else {
|
||||
updateSelectionInteractionState(.idle)
|
||||
}
|
||||
}
|
||||
|
||||
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
return selectionController.isSelecting
|
||||
if selectionController.isSelecting {
|
||||
return true
|
||||
}
|
||||
guard let pan = gestureRecognizer as? UIPanGestureRecognizer,
|
||||
let renderView = coreTextRenderView else {
|
||||
return false
|
||||
}
|
||||
let point = pan.location(in: renderView)
|
||||
return renderView.selectionHandle(at: point) != nil
|
||||
}
|
||||
if gestureRecognizer === longPressGestureRecognizer {
|
||||
guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer,
|
||||
let renderView = coreTextRenderView else {
|
||||
return true
|
||||
}
|
||||
let point = longPress.location(in: renderView)
|
||||
if renderView.selectionHandle(at: point) != nil {
|
||||
return false
|
||||
}
|
||||
if selectionController.hasActiveSelection, renderView.selectionContains(point) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === tapGestureRecognizer {
|
||||
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
|
||||
return false
|
||||
}
|
||||
if selectionController.hasActiveSelection {
|
||||
if let renderView = coreTextRenderView {
|
||||
let point = tapGestureRecognizer.location(in: renderView)
|
||||
if renderView.selectionHandle(at: point) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
let point = tapGestureRecognizer.location(in: overlayView)
|
||||
return attachmentText(at: point) != nil || highlight(at: point) != nil
|
||||
if attachmentText(at: point) != nil || highlight(at: point) != nil {
|
||||
return true
|
||||
}
|
||||
return currentPage != nil
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldReceive touch: UITouch
|
||||
) -> Bool {
|
||||
guard let renderView = coreTextRenderView else {
|
||||
return true
|
||||
}
|
||||
|
||||
let point = touch.location(in: renderView)
|
||||
if let handle = renderView.selectionHandle(at: point) {
|
||||
if gestureRecognizer === panGestureRecognizer {
|
||||
activeSelectionHandle = handle == .start ? .start : .end
|
||||
updateSelectionInteractionState(.adjustingHandle)
|
||||
hideSelectionMenu()
|
||||
return true
|
||||
}
|
||||
if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_ gestureRecognizer: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||||
@@ -703,9 +949,85 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIColor Extension
|
||||
private final class RDEPUBSelectionLoupeView: UIView {
|
||||
|
||||
private let imageView = UIImageView()
|
||||
|
||||
private let magnification: CGFloat = 1.45
|
||||
|
||||
private let captureSize = CGSize(width: 84, height: 84)
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96)))
|
||||
isUserInteractionEnabled = false
|
||||
backgroundColor = .clear
|
||||
layer.shadowColor = UIColor.black.cgColor
|
||||
layer.shadowOpacity = 0.18
|
||||
layer.shadowRadius = 10
|
||||
layer.shadowOffset = CGSize(width: 0, height: 5)
|
||||
|
||||
imageView.frame = bounds
|
||||
imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
imageView.layer.cornerRadius = bounds.width / 2
|
||||
imageView.layer.cornerCurve = .continuous
|
||||
imageView.layer.borderWidth = 1.5
|
||||
imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor
|
||||
imageView.clipsToBounds = true
|
||||
addSubview(imageView)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) {
|
||||
imageView.image = snapshot(from: sourceView, focusPoint: focusPoint)
|
||||
|
||||
let targetCenter = CGPoint(
|
||||
x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2),
|
||||
y: min(
|
||||
max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74),
|
||||
hostBounds.maxY - bounds.height / 2
|
||||
)
|
||||
)
|
||||
|
||||
center = targetCenter
|
||||
if isHidden {
|
||||
alpha = 0
|
||||
transform = CGAffineTransform(scaleX: 0.92, y: 0.92)
|
||||
isHidden = false
|
||||
UIView.animate(withDuration: 0.12) {
|
||||
self.alpha = 1
|
||||
self.transform = .identity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dismiss() {
|
||||
guard !isHidden else { return }
|
||||
isHidden = true
|
||||
alpha = 0
|
||||
imageView.image = nil
|
||||
}
|
||||
|
||||
private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage {
|
||||
let renderer = UIGraphicsImageRenderer(size: captureSize)
|
||||
return renderer.image { context in
|
||||
let cgContext = context.cgContext
|
||||
cgContext.setFillColor(UIColor.systemBackground.cgColor)
|
||||
cgContext.fill(CGRect(origin: .zero, size: captureSize))
|
||||
cgContext.translateBy(
|
||||
x: captureSize.width / 2 - focusPoint.x * magnification,
|
||||
y: captureSize.height / 2 - focusPoint.y * magnification
|
||||
)
|
||||
cgContext.scaleBy(x: magnification, y: magnification)
|
||||
sourceView.layer.render(in: cgContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension UIColor {
|
||||
|
||||
var rd_isDarkReaderBackground: Bool {
|
||||
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
|
||||
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import UIKit
|
||||
|
||||
/// 原生文本渲染路径的页级背景覆盖层。
|
||||
/// 继承自 RDEPUBSelectionOverlayView,负责绘制位于正文下方的页内装饰(如搜索高亮背景、批注背景等)。
|
||||
final class RDEPUBTextPageDecorationView: RDEPUBSelectionOverlayView {}
|
||||
|
||||
@@ -3,10 +3,13 @@ import UIKit
|
||||
#if canImport(DTCoreText)
|
||||
import DTCoreText
|
||||
|
||||
/// 基于 DTCoreText 的 Core Text 直接绘制视图
|
||||
/// 将 DTCoreText 的排版结果直接绘制到 UIView 上,跳过 UITextView 的间接渲染。
|
||||
/// 对齐 WXRead 的 WRPageView:在同一 drawRect 中绘制高亮背景、文字和选区。
|
||||
final class RDEPUBTextPageRenderView: UIView {
|
||||
|
||||
enum SelectionHandle {
|
||||
case start
|
||||
case end
|
||||
}
|
||||
|
||||
var layoutFrame: DTCoreTextLayoutFrame? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
@@ -19,26 +22,27 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 用于高亮绘制的 attributed string(包含 com.rdreader.highlight 等自定义属性)
|
||||
var attributedDisplayContent: NSAttributedString? {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 选区绘制(对齐 WXRead 的 _drawSelectionInContext:)
|
||||
|
||||
/// 选区矩形数组,由 RDEPUBTextSelectionController 设置
|
||||
var selectionRects: [CGRect] = [] {
|
||||
didSet {
|
||||
setNeedsDisplay()
|
||||
}
|
||||
}
|
||||
|
||||
/// 选区颜色(蓝色半透明,对齐 WXRead)
|
||||
var selectionColor: UIColor = UIColor(red: 70 / 255, green: 140 / 255, blue: 1, alpha: 0.24)
|
||||
|
||||
// MARK: - Init
|
||||
private let selectionHandleColor = UIColor(red: 20 / 255, green: 122 / 255, blue: 1, alpha: 1)
|
||||
|
||||
private let selectionHandleStemWidth: CGFloat = 2.5
|
||||
|
||||
private let selectionHandleKnobRadius: CGFloat = 7
|
||||
|
||||
private let selectionHandleHitSlop: CGFloat = 20
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
@@ -51,30 +55,23 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
// MARK: - Draw
|
||||
|
||||
override func draw(_ rect: CGRect) {
|
||||
guard let context = UIGraphicsGetCurrentContext(),
|
||||
let layoutFrame else { return }
|
||||
|
||||
context.saveGState()
|
||||
|
||||
// 1. 绘制高亮背景(在文字下方,对齐 WXRead 的 drawHighlightsInContext:)
|
||||
if let attributedDisplayContent {
|
||||
drawHighlights(in: context, attributedString: attributedDisplayContent, layoutFrame: layoutFrame)
|
||||
}
|
||||
|
||||
// 2. 绘制文字
|
||||
layoutFrame.draw(in: context, options: drawOptions)
|
||||
|
||||
// 3. 绘制选区(在文字上方)
|
||||
drawSelection(in: context)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
// MARK: - 高亮绘制(对齐 WXRead 的 WRCoreTextLayoutFrame.drawHighlightsInContext:)
|
||||
|
||||
private func drawHighlights(
|
||||
in context: CGContext,
|
||||
attributedString: NSAttributedString,
|
||||
@@ -82,7 +79,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
) {
|
||||
let fullRange = NSRange(location: 0, length: attributedString.length)
|
||||
|
||||
// 绘制高亮背景
|
||||
attributedString.enumerateAttribute(kRDEPUBHighlightAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
||||
@@ -92,7 +88,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制下划线
|
||||
attributedString.enumerateAttribute(kRDEPUBUnderlineAttributeName, in: fullRange) { value, range, _ in
|
||||
guard let color = value as? UIColor else { return }
|
||||
let rects = computeHighlightRects(for: range, layoutFrame: layoutFrame)
|
||||
@@ -107,7 +102,6 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算指定字符范围的视觉矩形(对齐 WXRead 的 rectsForRange: 算法)
|
||||
private func computeHighlightRects(for range: NSRange, layoutFrame: DTCoreTextLayoutFrame) -> [CGRect] {
|
||||
var rects: [CGRect] = []
|
||||
guard let lines = layoutFrame.lines as? [DTCoreTextLayoutLine] else { return rects }
|
||||
@@ -131,14 +125,110 @@ final class RDEPUBTextPageRenderView: UIView {
|
||||
return rects
|
||||
}
|
||||
|
||||
// MARK: - 选区绘制
|
||||
|
||||
private func drawSelection(in context: CGContext) {
|
||||
guard !selectionRects.isEmpty else { return }
|
||||
selectionColor.setFill()
|
||||
for rect in selectionRects {
|
||||
context.fill(rect)
|
||||
}
|
||||
drawSelectionHandles(in: context)
|
||||
}
|
||||
|
||||
func selectionHandle(at point: CGPoint) -> SelectionHandle? {
|
||||
guard let handleGeometry = selectionHandleGeometry else { return nil }
|
||||
|
||||
let startDistance = point.distance(to: handleGeometry.startKnobCenter)
|
||||
let endDistance = point.distance(to: handleGeometry.endKnobCenter)
|
||||
let maxDistance = selectionHandleKnobRadius + selectionHandleHitSlop
|
||||
|
||||
let startMatched = startDistance <= maxDistance
|
||||
let endMatched = endDistance <= maxDistance
|
||||
|
||||
switch (startMatched, endMatched) {
|
||||
case (true, true):
|
||||
return startDistance <= endDistance ? .start : .end
|
||||
case (true, false):
|
||||
return .start
|
||||
case (false, true):
|
||||
return .end
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func selectionContains(_ point: CGPoint) -> Bool {
|
||||
selectionRects.contains { rect in
|
||||
rect.insetBy(dx: -6, dy: -8).contains(point)
|
||||
}
|
||||
}
|
||||
|
||||
private var selectionHandleGeometry: (startKnobCenter: CGPoint, endKnobCenter: CGPoint)? {
|
||||
guard let firstRect = selectionRects.first,
|
||||
let lastRect = selectionRects.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let startKnobCenter = CGPoint(
|
||||
x: firstRect.minX,
|
||||
y: firstRect.minY - selectionHandleKnobRadius
|
||||
)
|
||||
let endKnobCenter = CGPoint(
|
||||
x: lastRect.maxX,
|
||||
y: lastRect.maxY + selectionHandleKnobRadius
|
||||
)
|
||||
|
||||
return (startKnobCenter: startKnobCenter, endKnobCenter: endKnobCenter)
|
||||
}
|
||||
|
||||
private func drawSelectionHandles(in context: CGContext) {
|
||||
guard let firstRect = selectionRects.first,
|
||||
let lastRect = selectionRects.last else {
|
||||
return
|
||||
}
|
||||
|
||||
context.saveGState()
|
||||
context.setFillColor(selectionHandleColor.cgColor)
|
||||
|
||||
let stemHalfWidth = selectionHandleStemWidth / 2
|
||||
|
||||
let startStem = CGRect(
|
||||
x: firstRect.minX - stemHalfWidth,
|
||||
y: firstRect.minY - selectionHandleKnobRadius * 2,
|
||||
width: selectionHandleStemWidth,
|
||||
height: firstRect.height + selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fill(startStem)
|
||||
let startKnob = CGRect(
|
||||
x: firstRect.minX - selectionHandleKnobRadius,
|
||||
y: firstRect.minY - selectionHandleKnobRadius * 2,
|
||||
width: selectionHandleKnobRadius * 2,
|
||||
height: selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fillEllipse(in: startKnob)
|
||||
|
||||
let endStem = CGRect(
|
||||
x: lastRect.maxX - stemHalfWidth,
|
||||
y: lastRect.minY,
|
||||
width: selectionHandleStemWidth,
|
||||
height: lastRect.height + selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fill(endStem)
|
||||
let endKnob = CGRect(
|
||||
x: lastRect.maxX - selectionHandleKnobRadius,
|
||||
y: lastRect.maxY,
|
||||
width: selectionHandleKnobRadius * 2,
|
||||
height: selectionHandleKnobRadius * 2
|
||||
)
|
||||
context.fillEllipse(in: endKnob)
|
||||
|
||||
context.restoreGState()
|
||||
}
|
||||
}
|
||||
|
||||
private extension CGPoint {
|
||||
|
||||
func distance(to point: CGPoint) -> CGFloat {
|
||||
hypot(x - point.x, y - point.y)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
import UIKit
|
||||
|
||||
/// 负责管理文本选区和 CoreText 命中测试。
|
||||
/// 对齐 WXRead 的选择模型:触摸命中和最终高亮共用同一套字符范围。
|
||||
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
|
||||
|
||||
/// 选区变化回调,通知视图层更新 UI
|
||||
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
|
||||
}
|
||||
@@ -37,11 +70,14 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
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:
|
||||
@@ -59,14 +95,46 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
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)
|
||||
@@ -74,8 +142,11 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
|
||||
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)
|
||||
@@ -92,9 +163,141 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
}
|
||||
return
|
||||
}
|
||||
selectionStartIndex = index
|
||||
selectionEndIndex = index
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -103,11 +306,24 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
renderView: RDEPUBTextPageRenderView,
|
||||
interactionController: RDEPUBPageInteractionController
|
||||
) {
|
||||
guard selectionStartIndex != NSNotFound,
|
||||
guard selectionAnchorIndex != NSNotFound,
|
||||
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
|
||||
return
|
||||
}
|
||||
selectionEndIndex = index
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -122,9 +338,14 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -137,31 +358,32 @@ final class RDEPUBTextSelectionController: NSObject {
|
||||
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
|
||||
)
|
||||
let chapterData = makeChapterData(for: page)
|
||||
let location = chapterData.location(for: absoluteRange, bookIdentifier: nil)
|
||||
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)
|
||||
),
|
||||
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])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user