- 新增 RDEPUBResourceDataProvider 解密钩子协议 + 访问登记表,收敛章节 HTML/ 图片/内联 CSS/脚注/封面/图片查看器/正文取图等读取点,scheme handler 对加密书 禁用流式分支;新增 RDEPUBDecryptingImageAttachment 解密 DTCoreText 图片附件; RDEPUBReaderDependencies.live(resourceDataProvider:) 便捷注入。明文书零影响。 - 试读墙:configuration.trialPolicy + delegate epubReaderTrialWallView/ DidReachTrialWall,UI 由宿主提供(RDEPUBReaderController+Trial)。 - 工具栏定制:RDEPUBReaderTop/BottomToolViewProtocol 协议 + dependencies 工厂注入, 内置栏已 conform,configureTopToolView 改协议类型。 - 朝向锁定:RDEPUBMetadata.orientation(OPF rendition:orientation 主, iBooks display-options 兜底)+ RDEPUBReaderController+Orientation 重写支持朝向、 打开后主动转向。 注:RDEPUBReaderController+LocationResolution / PaginationCoordinator 为本次改动前 即存在的工作区修改,一并纳入。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1172 lines
48 KiB
Swift
1172 lines
48 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,
|
||
didActivateImage image: UIImage,
|
||
sourceRect: CGRect,
|
||
altText: String?
|
||
)
|
||
func textContentView(
|
||
_ contentView: RDEPUBTextContentView,
|
||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||
sourceRect: CGRect
|
||
)
|
||
}
|
||
|
||
extension RDEPUBTextContentViewDelegate {
|
||
func textContentView(
|
||
_ contentView: RDEPUBTextContentView,
|
||
didActivateImage image: UIImage,
|
||
sourceRect: CGRect,
|
||
altText: String?
|
||
) {
|
||
// Default: no-op. Implementors can present an image viewer.
|
||
}
|
||
}
|
||
|
||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding {
|
||
|
||
private static let pageNumberTrailingPadding: CGFloat = 4
|
||
private static let pageNumberFooterPadding: CGFloat = 8
|
||
private static let pageNumberReservedHeight = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
|
||
|
||
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?
|
||
|
||
// M-15: Backing store for UIEditMenuInteraction (iOS 16+).
|
||
// Stored as Any? to avoid @available on stored property restriction.
|
||
private var _editMenuInteraction: Any?
|
||
|
||
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
|
||
}()
|
||
|
||
/// Chapter-level shared display string owned by the display cache.
|
||
/// Read-only from the page's perspective: theme color and dark-image
|
||
/// adjustment are baked in at build time; page decoration goes through
|
||
/// the overlay views.
|
||
private var coreTextDisplayContent: NSAttributedString?
|
||
|
||
private var coreTextDisplayRange: NSRange?
|
||
|
||
/// Shared chapter-level layouter (owned by the display cache); layout
|
||
/// frames are recomputed per page range and bounds change.
|
||
private var coreTextLayouter: DTCoreTextLayouter?
|
||
#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
|
||
}()
|
||
|
||
#if DEBUG
|
||
/// Debug-only outline of the readable content area (bounds inset by contentInsets).
|
||
private let debugContentAreaBorderView: UIView = {
|
||
let view = UIView()
|
||
view.isUserInteractionEnabled = false
|
||
view.backgroundColor = .clear
|
||
view.layer.borderColor = UIColor.systemRed.withAlphaComponent(0.6).cgColor
|
||
view.layer.borderWidth = 1
|
||
view.accessibilityIdentifier = "epub.reader.debug.contentAreaBorder"
|
||
return view
|
||
}()
|
||
#endif
|
||
|
||
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)
|
||
#if DEBUG
|
||
addSubview(debugContentAreaBorderView)
|
||
#endif
|
||
addGestureRecognizer(longPressGestureRecognizer)
|
||
addGestureRecognizer(panGestureRecognizer)
|
||
addGestureRecognizer(tapGestureRecognizer)
|
||
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||
selectionLoupeView.isHidden = true
|
||
|
||
// M-15: Use UIEditMenuInteraction on iOS 16+ to replace deprecated UIMenuController
|
||
if #available(iOS 16.0, *) {
|
||
let interaction = UIEditMenuInteraction(delegate: self)
|
||
addInteraction(interaction)
|
||
self._editMenuInteraction = interaction
|
||
}
|
||
|
||
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 {
|
||
#if canImport(DTCoreText)
|
||
// In-context text layout keeps a chapter-length attributed copy and
|
||
// layouter alive for the visible page. Avoid reader-level page
|
||
// caching so preloading does not retain several full-chapter
|
||
// display copies at once.
|
||
// Do NOT relax this until chapter-level shared display content
|
||
// lands (LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md P1-1): cached
|
||
// pages would each pin their own full-chapter copy again.
|
||
return currentPage == nil
|
||
|| loadingSpinner.isAnimating
|
||
|| coreTextDisplayContent != nil
|
||
#else
|
||
currentPage == nil
|
||
|| loadingSpinner.isAnimating
|
||
#endif
|
||
}
|
||
|
||
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 contentRect = bounds.inset(by: contentInsets)
|
||
#if DEBUG
|
||
debugContentAreaBorderView.frame = contentRect
|
||
#endif
|
||
let labelSize = pageNumberLabel.sizeThatFits(
|
||
CGSize(width: contentRect.width, height: Self.pageNumberReservedHeight)
|
||
)
|
||
let footerOriginY = bounds.maxY - contentInsets.bottom
|
||
let footerVerticalInset = max((contentInsets.bottom - labelSize.height) / 2, 0)
|
||
pageNumberLabel.frame = CGRect(
|
||
x: bounds.maxX - contentInsets.right - labelSize.width - Self.pageNumberTrailingPadding,
|
||
y: footerOriginY + footerVerticalInset,
|
||
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,
|
||
displayCache: RDEPUBChapterDisplayContentCache? = nil
|
||
) {
|
||
loadingSpinner.stopAnimating()
|
||
currentPage = page
|
||
currentChapterCFIMap = chapterCFIMap
|
||
currentChapterFragmentOffsets = chapterFragmentOffsets
|
||
currentSelection = nil
|
||
menuSelection = nil
|
||
interactionCoordinator.reset()
|
||
currentHighlights = highlights
|
||
currentSearchState = searchState
|
||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||
contentInsets = Self.safeContentInsets(
|
||
configuration: configuration,
|
||
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||
)
|
||
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
|
||
coreTextLayouter = nil
|
||
#endif
|
||
updateStaticGestureAvailability()
|
||
updateSelectionPanAvailability()
|
||
updateViewInteractionAvailability()
|
||
delegate?.textContentView(self, didChangeSelection: nil)
|
||
setNeedsLayout()
|
||
return
|
||
}
|
||
|
||
coverImageView.isHidden = true
|
||
coverImageView.image = nil
|
||
|
||
#if canImport(DTCoreText)
|
||
// In-context display: lay out the full chapter string and render only
|
||
// the page's range. CTTypesetter line breaks are context-sensitive, so
|
||
// re-wrapping a page substring can break ±1 character away from the
|
||
// paginator's line ends (lone characters spilling onto the last line);
|
||
// laying out the same chapter string the paginator used cannot.
|
||
// String indices in the layout frame are therefore chapter-absolute.
|
||
// The chapter string and layouter are shared per chapter via the
|
||
// display cache; this view must not mutate the shared string.
|
||
let display = (displayCache ?? RDEPUBChapterDisplayContentCache())
|
||
.entry(for: page, configuration: configuration)
|
||
let pageRange = NSIntersectionRange(
|
||
page.contentRange,
|
||
NSRange(location: 0, length: display.content.length)
|
||
)
|
||
coreTextContentView.isHidden = false
|
||
coreTextContentView.backgroundColor = .clear
|
||
coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text"
|
||
coreTextDisplayContent = display.content
|
||
coreTextDisplayRange = pageRange
|
||
coreTextLayouter = display.layouter
|
||
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 = Self.safeContentInsets(
|
||
configuration: configuration,
|
||
safeAreaInsets: RDEPUBSafeArea.resolve(safeAreaInsets)
|
||
)
|
||
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.layoutFrame = nil
|
||
coreTextDisplayContent = nil
|
||
coreTextDisplayRange = nil
|
||
coreTextLayouter = 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()
|
||
hideSelectionMenu()
|
||
}
|
||
|
||
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
|
||
coreTextLayouter = 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? DTImageTextAttachment,
|
||
let image = attachment.image {
|
||
return image
|
||
}
|
||
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 imageFromPage(attachment: RDEPUBPageAttachment, page: RDEPUBTextPage) -> UIImage? {
|
||
guard page.chapterContent.length > attachment.stringRange.location else { return nil }
|
||
let attachmentValue = page.chapterContent.attribute(
|
||
.attachment,
|
||
at: attachment.stringRange.location,
|
||
effectiveRange: nil
|
||
)
|
||
return image(from: attachmentValue)
|
||
}
|
||
|
||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||
let content = NSMutableAttributedString(attributedString: page.content)
|
||
guard content.length > 0 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 }
|
||
|
||
if shouldNormalizeLeadingParagraphSpacing(for: page) {
|
||
let leadingRange = firstNonWhitespaceParagraphRange(in: content) ?? firstParagraphRange
|
||
updateParagraphStyle(in: content, range: leadingRange) { style in
|
||
style.paragraphSpacingBefore = 0
|
||
}
|
||
}
|
||
|
||
guard shouldNormalizeContinuationParagraph(for: page),
|
||
!firstParagraphUsesNonLeadingAlignment(in: content, range: firstParagraphRange) else {
|
||
return content
|
||
}
|
||
|
||
updateParagraphStyle(in: content, range: firstParagraphRange) { style in
|
||
style.firstLineHeadIndent = style.headIndent
|
||
style.paragraphSpacingBefore = 0
|
||
}
|
||
return content
|
||
}
|
||
|
||
private func shouldNormalizeLeadingParagraphSpacing(for page: RDEPUBTextPage) -> Bool {
|
||
page.pageStartOffset == 0
|
||
}
|
||
|
||
private func firstNonWhitespaceParagraphRange(in content: NSAttributedString) -> NSRange? {
|
||
let text = content.string as NSString
|
||
var index = 0
|
||
while index < text.length {
|
||
guard let scalar = UnicodeScalar(text.character(at: index)) else { break }
|
||
if !CharacterSet.whitespacesAndNewlines.contains(scalar) {
|
||
return text.paragraphRange(for: NSRange(location: index, length: 0))
|
||
}
|
||
index += 1
|
||
}
|
||
return nil
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
private func updateParagraphStyle(
|
||
in content: NSMutableAttributedString,
|
||
range: NSRange,
|
||
transform: (NSMutableParagraphStyle) -> Void
|
||
) {
|
||
content.enumerateAttribute(.paragraphStyle, in: range) { value, attributeRange, _ in
|
||
guard let style = value as? NSParagraphStyle else { return }
|
||
let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
|
||
transform(mutableStyle)
|
||
content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: attributeRange)
|
||
}
|
||
}
|
||
|
||
|
||
private func firstParagraphUsesNonLeadingAlignment(
|
||
in content: NSAttributedString,
|
||
range: NSRange
|
||
) -> Bool {
|
||
var usesNonLeadingAlignment = false
|
||
content.enumerateAttribute(.paragraphStyle, in: range) { value, _, stop in
|
||
guard let style = value as? NSParagraphStyle else { return }
|
||
if style.alignment == .right || style.alignment == .center {
|
||
usesNonLeadingAlignment = true
|
||
stop.pointee = true
|
||
}
|
||
}
|
||
return usesNonLeadingAlignment
|
||
}
|
||
|
||
#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 = coreTextLayouter else {
|
||
coreTextContentView.layoutFrame = nil
|
||
interactionController.configure(layoutFrame: nil, page: page)
|
||
return
|
||
}
|
||
let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange)
|
||
if let layoutFrame {
|
||
RDEPUBTextPageBoundaryValidator.validate(
|
||
page: page,
|
||
displayLayoutFrame: layoutFrame,
|
||
displayContent: displayContent,
|
||
displayBounds: coreTextContentView.bounds
|
||
)
|
||
RDEPUBTextPageVerticalJustifier.justify(
|
||
layoutFrame,
|
||
contentHeight: coreTextContentView.bounds.height,
|
||
isChapterLastPage: page.pageIndexInChapter >= page.totalPagesInChapter - 1,
|
||
pixelScale: window?.screen.scale ?? UIScreen.main.scale
|
||
)
|
||
}
|
||
coreTextContentView.layoutFrame = layoutFrame
|
||
interactionController.configure(layoutFrame: layoutFrame, page: page)
|
||
overlayView.updateSnapshot(interactionController.snapshot)
|
||
backgroundOverlayView.updateSnapshot(interactionController.snapshot)
|
||
|
||
if let page = currentPage {
|
||
// Highlights/underlines and search are all drawn as overlay
|
||
// decorations; the shared chapter display string carries no
|
||
// page-level annotation attributes.
|
||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||
page: page,
|
||
highlights: currentHighlights,
|
||
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
|
||
RDReaderTapDebug.log(
|
||
"TextContentView.interactionAvailability",
|
||
"updated isUserInteractionEnabled=\(shouldEnableInteraction) currentPage=\(currentPage?.absolutePageIndex ?? -1) loading=\(loadingSpinner.isAnimating) selecting=\(selectionController.isSelecting) hasSelection=\(selectionController.hasActiveSelection) state=\(interactionCoordinator.interactionState)"
|
||
)
|
||
}
|
||
|
||
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
|
||
RDReaderTapDebug.log(
|
||
"TextContentView.gestureAvailability",
|
||
"updated longPressEnabled=\(shouldEnableLongPress) tapEnabled=\(shouldEnableTap) hasInteractiveTextContent=\(hasInteractiveTextContent) loading=\(loadingSpinner.isAnimating) currentPage=\(currentPage?.absolutePageIndex ?? -1)"
|
||
)
|
||
}
|
||
|
||
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
|
||
let point = gesture.location(in: overlayView)
|
||
RDReaderTapDebug.log(
|
||
"TextContentView.handleTap",
|
||
"received point=\(RDReaderTapDebug.describe(point)) currentPage=\(currentPage?.absolutePageIndex ?? -1) selectionState=\(interactionCoordinator.interactionState) hasSelection=\(currentSelection != nil) loading=\(loadingSpinner.isAnimating)"
|
||
)
|
||
if let renderView = coreTextRenderView {
|
||
let renderPoint = gesture.location(in: renderView)
|
||
if renderView.selectionHandle(at: renderPoint) != nil {
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle")
|
||
return
|
||
}
|
||
if currentSelection != nil {
|
||
if renderView.selectionContains(renderPoint) {
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu")
|
||
showSelectionMenuIfNeeded()
|
||
return
|
||
}
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection")
|
||
clearSelection()
|
||
return
|
||
}
|
||
} else if currentSelection != nil {
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "selection exists without renderView; clearing selection")
|
||
clearSelection()
|
||
return
|
||
}
|
||
// PRIORITY: Check for attachment tap
|
||
if let page = currentPage,
|
||
let attachment = interactionController.snapshot?.attachment(at: point) {
|
||
let kind = attachment.kind
|
||
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) ?? CGRect.zero
|
||
// Footnote attachments carry their note text in alt/accessibility metadata.
|
||
// Prefer that semantic text over image preview even if attachment kind metadata is incomplete.
|
||
if let footnoteText = attachmentText(at: point), kind == .footnote || !footnoteText.isEmpty {
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "resolved footnote attachment tap")
|
||
delegate?.textContentView(
|
||
self,
|
||
didActivateAttachmentText: footnoteText,
|
||
sourceRect: sourceRect,
|
||
sourcePoint: convert(point, from: overlayView)
|
||
)
|
||
return
|
||
}
|
||
// Regular image attachments: present image viewer
|
||
if let image = imageFromPage(attachment: attachment, page: page) {
|
||
let altText = attachmentText(at: point)
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "resolved image attachment tap altTextPresent=\(altText?.isEmpty == false)")
|
||
delegate?.textContentView(self, didActivateImage: image, sourceRect: sourceRect, altText: altText)
|
||
return
|
||
}
|
||
}
|
||
if let attachmentText = attachmentText(at: point),
|
||
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap")
|
||
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 {
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate")
|
||
delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView))
|
||
return
|
||
}
|
||
RDReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightId=\(highlight.id)")
|
||
delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect)
|
||
}
|
||
|
||
private func showSelectionMenuIfNeeded() {
|
||
guard currentSelection != nil,
|
||
let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||
!targetRect.isEmpty else {
|
||
return
|
||
}
|
||
let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect)
|
||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||
becomeFirstResponder()
|
||
let anchor = CGPoint(x: resolvedTargetRect.midX, y: resolvedTargetRect.midY)
|
||
let config = UIEditMenuConfiguration(identifier: "SelectionMenu", sourcePoint: anchor)
|
||
DispatchQueue.main.async { [weak self, weak interaction] in
|
||
guard let self, self.currentSelection != nil else { return }
|
||
interaction?.presentEditMenu(with: config)
|
||
}
|
||
} else {
|
||
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(resolvedTargetRect, in: self)
|
||
menuController.setMenuVisible(true, animated: true)
|
||
}
|
||
}
|
||
|
||
private func hideSelectionMenu() {
|
||
if #available(iOS 16.0, *), let interaction = _editMenuInteraction as? UIEditMenuInteraction {
|
||
interaction.dismissMenu()
|
||
} else {
|
||
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 resolvedSelectionMenuTargetRect(from rect: CGRect) -> CGRect {
|
||
guard let renderView = coreTextRenderView else { return rect }
|
||
return convert(rect, from: renderView)
|
||
}
|
||
|
||
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 matches = currentHighlights.filter { highlight in
|
||
guard highlight.location.href == page.href,
|
||
let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else {
|
||
return false
|
||
}
|
||
let overlap = NSIntersectionRange(range, page.contentRange)
|
||
guard overlap.length > 0 else { return false }
|
||
return interactionController.selectionRects(for: overlap).contains {
|
||
$0.insetBy(dx: -4, dy: -4).contains(point)
|
||
}
|
||
}
|
||
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 {
|
||
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending")
|
||
return true
|
||
}
|
||
|
||
guard let renderView = coreTextRenderView else {
|
||
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil")
|
||
return false
|
||
}
|
||
let renderPoint = convert(point, to: renderView)
|
||
if renderView.selectionHandle(at: renderPoint) != nil {
|
||
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle")
|
||
return true
|
||
}
|
||
if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) {
|
||
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection")
|
||
return true
|
||
}
|
||
switch interactionCoordinator.interactionState {
|
||
case .idle:
|
||
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle")
|
||
return false
|
||
case .selectionPending, .selecting, .selectionActive, .adjustingHandle:
|
||
RDReaderTapDebug.log(
|
||
"TextContentView.shouldSuppressReaderTap",
|
||
"return true because interactionState=\(interactionCoordinator.interactionState)"
|
||
)
|
||
return true
|
||
}
|
||
#else
|
||
RDReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable")
|
||
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 {
|
||
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil")
|
||
return true
|
||
}
|
||
|
||
let point = touch.location(in: renderView)
|
||
if renderView.selectionHandle(at: point) != nil {
|
||
RDReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle")
|
||
return false
|
||
}
|
||
}
|
||
|
||
let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch)
|
||
RDReaderTapDebug.log(
|
||
"TextContentView.gestureShouldReceive",
|
||
"gesture=\(type(of: gestureRecognizer)) touchView=\(RDReaderTapDebug.describe(touch.view)) result=\(result)"
|
||
)
|
||
return result
|
||
}
|
||
|
||
func gestureRecognizer(
|
||
_ gestureRecognizer: UIGestureRecognizer,
|
||
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
|
||
) -> Bool {
|
||
gestureRecognizer === tapGestureRecognizer
|
||
|| interactionCoordinator.gestureRecognizer(
|
||
gestureRecognizer,
|
||
shouldRecognizeSimultaneouslyWith: otherGestureRecognizer
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - UIEditMenuInteractionDelegate (iOS 16+)
|
||
|
||
@available(iOS 16.0, *)
|
||
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
|
||
func editMenuInteraction(
|
||
_ interaction: UIEditMenuInteraction,
|
||
menuFor configuration: UIEditMenuConfiguration,
|
||
suggestedActions: [UIMenuElement]
|
||
) -> UIMenu? {
|
||
UIMenu(children: [
|
||
UICommand(title: "拷贝", action: #selector(rd_copy(_:))),
|
||
UICommand(title: "高亮", action: #selector(rd_highlight(_:))),
|
||
UICommand(title: "批注", action: #selector(rd_annotate(_:)))
|
||
])
|
||
}
|
||
|
||
func editMenuInteraction(
|
||
_ interaction: UIEditMenuInteraction,
|
||
targetRectFor configuration: UIEditMenuConfiguration
|
||
) -> CGRect {
|
||
if let targetRect = selectionController.menuAnchorRect(interactionController: interactionController),
|
||
!targetRect.isEmpty {
|
||
return resolvedSelectionMenuTargetRect(from: targetRect)
|
||
}
|
||
return bounds
|
||
}
|
||
}
|
||
|
||
// MARK: - Safe Content Insets
|
||
|
||
extension RDEPUBTextContentView {
|
||
|
||
/// Computes content insets that account for safe areas (Dynamic Island, home indicator).
|
||
/// Uses the larger of safeAreaInsets and reflowableContentInsets for each edge,
|
||
/// ensuring content is never hidden under the Dynamic Island or home indicator area.
|
||
private static func safeContentInsets(
|
||
configuration: RDEPUBReaderConfiguration,
|
||
safeAreaInsets: UIEdgeInsets
|
||
) -> UIEdgeInsets {
|
||
let configInsets = configuration.reflowableContentInsets
|
||
return UIEdgeInsets(
|
||
top: max(configInsets.top, safeAreaInsets.top),
|
||
left: max(configInsets.left, safeAreaInsets.left),
|
||
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
|
||
right: max(configInsets.right, safeAreaInsets.right)
|
||
)
|
||
}
|
||
}
|