feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -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])
)
}
}