ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
shenlei d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:47:24 +08:00

888 lines
35 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, RDReaderCachePolicyProviding {
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 let selectionLoupeView = RDEPUBSelectionLoupeView()
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.isEnabled = false
gesture.delegate = self
return gesture
}()
private lazy var tapGestureRecognizer: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
gesture.delegate = self
return gesture
}()
private lazy var interactionCoordinator: RDEPUBTextContentInteractionCoordinator = {
RDEPUBTextContentInteractionCoordinator(
dependencies: .init(
hasRenderableContent: { [weak self] in
self?.hasInteractiveTextContent ?? false
},
currentSelectionProvider: { [weak self] in self?.currentSelection },
isSelectionControllerSelecting: { [weak self] in
self?.selectionController.isSelecting ?? false
},
hasActiveSelection: { [weak self] in
self?.selectionController.hasActiveSelection ?? false
},
selectionHandleAtPoint: { [weak self] point in
guard let renderView = self?.coreTextRenderView else { return nil }
let handle = renderView.selectionHandle(at: point)
switch handle {
case .start:
return .start
case .end:
return .end
case nil:
return nil
}
},
selectionContainsPoint: { [weak self] point in
self?.coreTextRenderView?.selectionContains(point) ?? false
},
renderPointForGesture: { [weak self] gesture in
guard let self else { return .zero }
return gesture.location(in: self.coreTextRenderView ?? self)
},
renderPointForTouch: { [weak self] touch in
guard let self else { return .zero }
return touch.location(in: self.coreTextRenderView ?? self)
},
performLongPressSelection: { [weak self] gesture in
guard let self else { return }
self.layoutIfNeeded()
self.selectionController.handleLongPress(
gesture,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
performPanSelection: { [weak self] gesture in
guard let self else { return }
self.layoutIfNeeded()
self.selectionController.handlePan(
gesture,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
adjustSelection: { [weak self] handle, point in
guard let self else { return }
self.selectionController.updateSelection(
byAdjusting: handle,
at: point,
renderView: self.coreTextRenderView,
interactionController: self.interactionController
)
},
presentLoupeAtPoint: { [weak self] point in
self?.updateSelectionLoupe(for: point)
},
dismissLoupe: { [weak self] in
self?.selectionLoupeView.dismiss()
},
showSelectionMenu: { [weak self] in
self?.showSelectionMenuIfNeeded()
},
hideSelectionMenu: { [weak self] in
self?.hideSelectionMenu()
},
selectionTapSuppressionDidChange: { [weak self] isSuppressed in
self?.selectionTapSuppressionDidChange?(isSuppressed)
},
selectionPagingSuppressionDidChange: { [weak self] isSuppressed in
self?.selectionPagingSuppressionDidChange?(isSuppressed)
}
)
)
}()
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.updateSelectionPanAvailability()
self.updateViewInteractionAvailability()
self.updateAccessibilityDecorationSummary()
self.delegate?.textContentView(self, didChangeSelection: selection)
}
selectionController.interactionStateDidChange = { [weak self] state in
guard let self else { return }
self.interactionCoordinator.selectionControllerStateDidChange(state)
self.updateSelectionPanAvailability()
self.updateViewInteractionAvailability()
}
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 {
interactionCoordinator.isInteractionInProgress
}
var shouldAvoidReaderPageCaching: Bool {
currentPage == nil || loadingSpinner.isAnimating
}
private var hasInteractiveTextContent: Bool {
#if canImport(DTCoreText)
currentPage != nil && coreTextRenderView?.isHidden == false
#else
currentPage != nil
#endif
}
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
interactionCoordinator.reset()
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
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
}
coverImageView.isHidden = true
coverImageView.image = nil
#if canImport(DTCoreText)
let displayContent = RDEPUBDarkImageAdjuster.adjustIfNeeded(
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
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
updateAccessibilityDecorationSummary()
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
}
func configureLoading(
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration
) {
currentPage = nil
currentChapterCFIMap = nil
currentChapterFragmentOffsets = [:]
currentSelection = nil
menuSelection = nil
interactionCoordinator.reset()
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()
updateStaticGestureAvailability()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
delegate?.textContentView(self, didChangeSelection: nil)
loadingSpinner.startAnimating()
setNeedsLayout()
}
func clearSelection() {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
interactionCoordinator.reset()
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateSelectionPanAvailability()
updateViewInteractionAvailability()
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
}
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) {
interactionCoordinator.handleLongPress(gesture)
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
interactionCoordinator.handlePan(gesture)
}
private func updateSelectionPanAvailability() {
let shouldEnablePan = selectionController.isSelecting
|| selectionController.hasActiveSelection
|| interactionCoordinator.interactionState == .adjustingHandle
guard panGestureRecognizer.isEnabled != shouldEnablePan else { return }
panGestureRecognizer.isEnabled = shouldEnablePan
}
private func updateViewInteractionAvailability() {
let shouldEnableInteraction = hasInteractiveTextContent
|| selectionController.isSelecting
|| selectionController.hasActiveSelection
|| interactionCoordinator.interactionState != .idle
guard isUserInteractionEnabled != shouldEnableInteraction else { return }
isUserInteractionEnabled = shouldEnableInteraction
}
private func updateStaticGestureAvailability() {
let shouldEnableLongPress = hasInteractiveTextContent && loadingSpinner.isAnimating == false
let shouldEnableTap = hasInteractiveTextContent && loadingSpinner.isAnimating == false
let longPressChanged = longPressGestureRecognizer.isEnabled != shouldEnableLongPress
let tapChanged = tapGestureRecognizer.isEnabled != shouldEnableTap
guard longPressChanged || tapChanged else { return }
longPressGestureRecognizer.isEnabled = shouldEnableLongPress
tapGestureRecognizer.isEnabled = shouldEnableTap
}
@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 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 currentPage != nil 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 interactionCoordinator.interactionState == .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 interactionCoordinator.interactionState {
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)
interactionCoordinator.touchesBegan(touches, with: event)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesEnded(touches, with: event)
interactionCoordinator.touchesEnded(touches, with: event)
}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
super.touchesCancelled(touches, with: event)
interactionCoordinator.touchesCancelled(touches, with: event)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
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 interactionCoordinator.gestureRecognizerShouldBegin(gestureRecognizer)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldReceive touch: UITouch
) -> Bool {
if gestureRecognizer === tapGestureRecognizer {
guard let renderView = coreTextRenderView else {
return true
}
let point = touch.location(in: renderView)
if renderView.selectionHandle(at: point) != nil {
return false
}
}
return interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === tapGestureRecognizer
|| interactionCoordinator.gestureRecognizer(
gestureRecognizer,
shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
)
}
}