ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
shenlei d5a7755702 feat: 支持加密 EPUB、试读墙、工具栏定制与朝向锁定
- 新增 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>
2026-07-10 12:25:17 +09:00

1172 lines
48 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
)
}
}