ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
shen 7de661eb54 feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖
Phase 1: Context 拆分
- 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices
- RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services

Phase 2: Runtime 拆分
- 新增 RDEPUBPresentationRuntime 处理分页状态管理
- 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排
- RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade

Phase 0.5: 性能优化
- prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false)
- extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载
- RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争
- CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded)
- RDEPUBTextPageRenderView 引入静态位图缓存
- RDEPUBTextContentView 新增 loadingSpinner 占位页

Phase 3: 状态机
- 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验)
- 新增 RDEPUBPaginationState 记录分页来源

Review 修复
- makeSummary 重复方法合并
- ensureNavigationTargetAvailable 同步路径加注释标记

UI 测试
- 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景)
- 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
2026-06-23 08:17:08 +08:00

1085 lines
42 KiB
Swift

import UIKit
import Foundation
#if canImport(DTCoreText)
import DTCoreText
#endif
protocol RDEPUBTextContentViewDelegate: AnyObject {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection?
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect,
sourcePoint: CGPoint
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
)
}
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
view.isOpaque = false
view.isAccessibilityElement = true
view.accessibilityTraits = .staticText
return view
}()
private var coreTextDisplayContent: NSAttributedString?
private var coreTextDisplayRange: NSRange?
#endif
private let interactionController = RDEPUBPageInteractionController()
private let selectionController = RDEPUBTextSelectionController()
private let backgroundOverlayView: RDEPUBTextPageDecorationView = {
let view = RDEPUBTextPageDecorationView()
return view
}()
private let overlayView: RDEPUBTextAnnotationOverlay = {
let view = RDEPUBTextAnnotationOverlay()
return view
}()
private let coverImageView: UIImageView = {
let view = UIImageView()
view.contentMode = .scaleAspectFit
view.isHidden = true
return view
}()
private let pageNumberLabel: UILabel = {
let label = UILabel()
label.font = UIFont.systemFont(ofSize: 13)
return label
}()
private let loadingSpinner: UIActivityIndicatorView = {
let spinner = UIActivityIndicatorView(style: .medium)
spinner.hidesWhenStopped = true
spinner.accessibilityIdentifier = "epub.reader.loadingSpinner"
return spinner
}()
private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.5
gesture.delegate = self
return gesture
}()
private lazy var panGestureRecognizer: UIPanGestureRecognizer = {
let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
gesture.delegate = self
return gesture
}()
private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
gesture.delegate = self
return gesture
}()
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content.view"
addSubview(coverImageView)
#if canImport(DTCoreText)
addSubview(backgroundOverlayView)
addSubview(coreTextContentView)
#endif
addSubview(overlayView)
addSubview(pageNumberLabel)
addSubview(loadingSpinner)
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 }
self.currentSelection = selection
if let selection {
self.menuSelection = selection
} else {
self.hideSelectionMenu()
}
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")
}
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(_:))
|| action == #selector(rd_annotate(_:))
}
@objc func rd_copy(_ sender: Any?) {
performSelectionAction(.copy)
}
@objc func rd_highlight(_ sender: Any?) {
performSelectionAction(.highlight)
}
@objc func rd_annotate(_ sender: Any?) {
performSelectionAction(.annotate)
}
override func layoutSubviews() {
super.layoutSubviews()
#if canImport(DTCoreText)
backgroundOverlayView.frame = bounds.inset(by: contentInsets)
coreTextContentView.frame = bounds.inset(by: contentInsets)
updateCoreTextLayoutFrameIfNeeded()
#endif
overlayView.frame = bounds.inset(by: contentInsets)
coverImageView.frame = bounds.inset(by: contentInsets)
let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20))
pageNumberLabel.frame = CGRect(
x: bounds.width - labelSize.width - 24,
y: bounds.height - labelSize.height - 20,
width: labelSize.width,
height: labelSize.height
)
loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY)
}
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration,
chapterCFIMap: RDEPUBCFIMap? = nil,
chapterFragmentOffsets: [String: Int] = [:],
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
loadingSpinner.stopAnimating()
currentPage = page
currentChapterCFIMap = chapterCFIMap
currentChapterFragmentOffsets = chapterFragmentOffsets
currentSelection = nil
menuSelection = nil
updateSelectionInteractionState(.idle)
currentHighlights = highlights
currentSearchState = searchState
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
if configureCoverIfNeeded(for: page) {
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
}
coverImageView.isHidden = true
coverImageView.image = nil
#if canImport(DTCoreText)
let displayContent = darkImageAdjustedContentIfNeeded(
normalizedPageContent(from: page),
configuration: configuration
)
let fullRange = NSRange(location: 0, length: displayContent.length)
displayContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: fullRange
)
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
coreTextContentView.attributedDisplayContent = displayContent
updateCoreTextLayoutFrameIfNeeded()
#else
let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
#endif
overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#if canImport(DTCoreText)
backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot)
#endif
updateAccessibilityDecorationSummary()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
}
func configureLoading(
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration
) {
currentPage = nil
currentChapterCFIMap = nil
currentChapterFragmentOffsets = [:]
currentSelection = nil
menuSelection = nil
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
currentHighlights = []
currentSearchState = nil
selectionController.clearSelection(renderView: coreTextRenderView)
contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
coverImageView.isHidden = true
coverImageView.image = nil
#if canImport(DTCoreText)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextContentView.attributedDisplayContent = nil
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
delegate?.textContentView(self, didChangeSelection: nil)
loadingSpinner.startAnimating()
setNeedsLayout()
}
func clearSelection() {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
activeSelectionHandle = nil
updateSelectionInteractionState(.idle)
selectionLoupeView.dismiss()
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true)
}
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
let selection = currentSelection ?? menuSelection
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
menuSelection = nil
hideSelectionMenu()
}
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
guard page.pageIndexInChapter == 0,
page.href.lowercased().contains("cover"),
let image = coverImage(from: page.content) else {
return false
}
coverImageView.image = image
coverImageView.isHidden = false
#if canImport(DTCoreText)
coreTextContentView.isHidden = true
coreTextContentView.layoutFrame = nil
coreTextDisplayContent = nil
coreTextDisplayRange = nil
#endif
return true
}
private func coverImage(from content: NSAttributedString) -> UIImage? {
guard content.length > 0 else { return nil }
var resolvedImage: UIImage?
content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in
guard let image = image(from: value) else { return }
resolvedImage = image
stop.pointee = true
}
return resolvedImage
}
private func image(from attachmentValue: Any?) -> UIImage? {
#if canImport(DTCoreText)
if let attachment = attachmentValue as? DTTextAttachment,
let url = attachment.contentURL {
return UIImage(contentsOfFile: url.path)
}
#endif
if let attachment = attachmentValue as? NSTextAttachment {
if let image = attachment.image { return image }
if let data = attachment.contents { return UIImage(data: data) }
if let fileWrapper = attachment.fileWrapper,
let data = fileWrapper.regularFileContents { return UIImage(data: data) }
}
return nil
}
#if canImport(DTCoreText)
private func darkImageAdjustedContentIfNeeded(
_ content: NSMutableAttributedString,
configuration: RDEPUBReaderConfiguration
) -> NSMutableAttributedString {
guard configuration.darkImageAdjustmentEnabled,
configuration.darkImageBlendRatio > 0,
configuration.theme.contentBackgroundColor.rd_isDarkReaderBackground else {
return content
}
let fullRange = NSRange(location: 0, length: content.length)
content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment),
let image = attachment.image,
shouldAdjustDarkImage(image) else { return }
let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage(
image,
backgroundColor: configuration.theme.contentBackgroundColor,
blendRatio: configuration.darkImageBlendRatio,
cacheKey: darkImageCacheKey(for: attachment, image: image, configuration: configuration)
)
adjustedAttachment.originalSize = attachment.originalSize
adjustedAttachment.displaySize = attachment.displaySize
adjustedAttachment.verticalAlignment = attachment.verticalAlignment
adjustedAttachment.contentURL = attachment.contentURL
adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL
adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID
adjustedAttachment.attributes = attachment.attributes
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
}
return content
}
private func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool {
let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased()
let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased()
?? ((attachment.attributes["src"] as? String) ?? "").lowercased()
return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover")
}
private func shouldAdjustDarkImage(_ image: UIImage) -> Bool {
image.size.width >= 80 && image.size.height >= 80
}
private func darkImageCacheKey(
for attachment: DTImageTextAttachment,
image: UIImage,
configuration: RDEPUBReaderConfiguration
) -> NSString {
let source = attachment.contentURL?.absoluteString
?? "\(Unmanaged.passUnretained(image).toOpaque())"
return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.ss_cssString)|\(configuration.darkImageBlendRatio)" as NSString
}
private func adjustedImage(
_ image: UIImage,
backgroundColor: UIColor,
blendRatio: CGFloat,
cacheKey: NSString
) -> UIImage {
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) { return cached }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
let renderer = UIGraphicsImageRenderer(size: image.size, format: format)
let adjusted = renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: image.size))
backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill()
context.cgContext.setBlendMode(.sourceAtop)
context.fill(CGRect(origin: .zero, size: image.size))
}
Self.darkAdjustedImageCache.setObject(adjusted, forKey: cacheKey)
return adjusted
}
#endif
private func applyHighlightsToContent(
_ content: NSMutableAttributedString,
highlights: [RDEPUBHighlight],
page: RDEPUBTextPage
) {
for highlight in highlights {
guard let rangeInfo = highlight.rangeInfo,
let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo),
let absoluteRange = info.nsRange else { continue }
let overlap = NSIntersectionRange(absoluteRange, page.contentRange)
guard overlap.length > 0 else { continue }
let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length)
guard relativeRange.location >= 0,
relativeRange.location + relativeRange.length <= content.length else { continue }
switch highlight.style {
case .highlight:
content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange)
case .underline:
content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange)
}
}
}
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else { return content }
let text = content.string as NSString
let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0))
guard firstParagraphRange.length > 0 else { return content }
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return }
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
mutableStyle.firstLineHeadIndent = mutableStyle.headIndent
mutableStyle.paragraphSpacingBefore = 0
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range)
}
return content
}
private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool {
let pageStart = page.pageStartOffset
guard pageStart > 0, pageStart < page.chapterContent.length else { return false }
let chapterText = page.chapterContent.string as NSString
guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false }
return !CharacterSet.newlines.contains(previousScalar)
}
#if canImport(DTCoreText)
private func updateCoreTextLayoutFrameIfNeeded() {
guard !coreTextContentView.isHidden,
let displayContent = coreTextDisplayContent,
let displayRange = coreTextDisplayRange,
let page = currentPage,
coreTextContentView.bounds.width > 0,
coreTextContentView.bounds.height > 0 else {
interactionController.configure(layoutFrame: nil, page: currentPage)
return
}
guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else {
coreTextContentView.layoutFrame = nil
interactionController.configure(layoutFrame: nil, page: page)
return
}
layouter.shouldCacheLayoutFrames = false
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
coreTextContentView.layoutFrame = layoutFrame
interactionController.configure(layoutFrame: layoutFrame, page: page)
overlayView.updateSnapshot(interactionController.snapshot)
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
if let page = currentPage {
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
page: page,
highlights: [],
searchState: currentSearchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
}
}
#endif
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
#if canImport(DTCoreText)
layoutIfNeeded()
selectionController.handleLongPress(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
switch gesture.state {
case .began:
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:
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
activeSelectionHandle = nil
selectionLoupeView.dismiss()
default:
break
}
#endif
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
#if canImport(DTCoreText)
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:
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
showSelectionMenuIfNeeded()
case .cancelled, .failed:
activeSelectionHandle = nil
updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive)
selectionLoupeView.dismiss()
default:
break
}
#endif
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: overlayView)
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
}
if let attachmentText = attachmentText(at: point),
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
delegate?.textContentView(
self,
didActivateAttachmentText: attachmentText,
sourceRect: sourceRect,
sourcePoint: convert(point, from: overlayView)
)
return
}
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)
}
private func showSelectionMenuIfNeeded() {
guard currentSelection != nil,
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
!targetRect.isEmpty else {
return
}
becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(rd_annotate(_:)))
]
menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self)
menuController.setMenuVisible(true, animated: true)
}
private func hideSelectionMenu() {
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
#else
return nil
#endif
}
private func updateAccessibilityDecorationSummary() {
#if canImport(DTCoreText)
coreTextContentView.accessibilityValue = [
backgroundOverlayView.decorationSummary(),
overlayView.decorationSummary()
].joined(separator: " | ")
#endif
}
private func highlight(at point: CGPoint) -> RDEPUBHighlight? {
guard let page = currentPage else { return nil }
let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point)
guard let absoluteRange else { return nil }
let matches = currentHighlights.filter { highlight in
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
return false
}
return NSIntersectionRange(range, absoluteRange).length > 0
}
return matches.sorted { lhs, rhs in
let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max
let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max
if lhs.hasNote != rhs.hasNote {
return lhs.hasNote && !rhs.hasNote
}
return lhsRange < rhsRange
}.first
}
private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? {
guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
let rects = interactionController.selectionRects(for: range)
if let first = rects.first {
var unionRect = first
for rect in rects.dropFirst() {
unionRect = unionRect.union(rect)
}
return overlayView.convert(unionRect, to: self)
}
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
return overlayView.convert(fallbackRect, to: self)
}
private func attachmentText(at point: CGPoint) -> String? {
guard let page = currentPage else { return nil }
guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else {
return nil
}
guard page.chapterContent.length > attachmentRange.location else {
return nil
}
let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil)
#if canImport(DTCoreText)
if let textAttachment = attachment as? DTTextAttachment,
let altText = (textAttachment.attributes["alt"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
#endif
if let fileAttachment = attachment as? NSTextAttachment,
let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines),
!altText.isEmpty {
return altText
}
return nil
}
private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? {
if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame {
return overlayView.convert(attachmentRect, to: self)
}
let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1))
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 {
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)
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
) -> Bool {
gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer
}
}
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 }
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35
}
}