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, didRequestHighlightMenuAction action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight ) } enum RDEPUBExistingHighlightMenuAction { case copy case createUnderline case deleteUnderline case annotate case deleteAnnotation } 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, RDEpubReaderCachePolicyProviding { 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? /// The underline whose compact text menu is currently visible. Keeping this /// separate from `currentSelection` means a marked paragraph is still fully /// selectable with a long press. private var menuHighlight: RDEPUBHighlight? private var menuUnderlineHighlight: RDEPUBHighlight? private var menuAnnotationHighlight: RDEPUBHighlight? // 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 { if menuHighlight != nil { return action == #selector(rd_copy(_:)) || action == #selector(rd_highlight(_:)) || action == #selector(rd_deleteUnderline(_:)) || action == #selector(rd_annotate(_:)) || action == #selector(rd_deleteAnnotation(_:)) } return action == #selector(rd_copy(_:)) || action == #selector(rd_highlight(_:)) || action == #selector(rd_annotate(_:)) } @objc func rd_copy(_ sender: Any?) { if let highlight = menuHighlight { performHighlightMenuAction(.copy, highlight: highlight) return } performSelectionAction(.copy) } @objc func rd_highlight(_ sender: Any?) { if let highlight = menuAnnotationHighlight { performHighlightMenuAction(.createUnderline, highlight: highlight) return } performSelectionAction(.highlight) } @objc func rd_annotate(_ sender: Any?) { if let highlight = menuHighlight { performHighlightMenuAction(.annotate, highlight: highlight) return } performSelectionAction(.annotate) } @objc func rd_deleteUnderline(_ sender: Any?) { guard let highlight = menuUnderlineHighlight else { return } performHighlightMenuAction(.deleteUnderline, highlight: highlight) } @objc func rd_deleteAnnotation(_ sender: Any?) { guard let highlight = menuAnnotationHighlight else { return } performHighlightMenuAction(.deleteAnnotation, highlight: highlight) } 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, contentInsets: UIEdgeInsets? = nil, 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) self.contentInsets = 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, contentInsets: UIEdgeInsets? = nil ) { currentPage = nil currentChapterCFIMap = nil currentChapterFragmentOffsets = [:] currentSelection = nil menuSelection = nil interactionCoordinator.reset() currentHighlights = [] currentSearchState = nil selectionController.clearSelection(renderView: coreTextRenderView) self.contentInsets = 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 RDEpubReaderTapDebug.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 RDEpubReaderTapDebug.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) RDEpubReaderTapDebug.log( "TextContentView.handleTap", "received point=\(RDEpubReaderTapDebug.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 { RDEpubReaderTapDebug.log("TextContentView.handleTap", "ignored because tap hit selection handle") return } if currentSelection != nil { if renderView.selectionContains(renderPoint) { RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap stayed inside selection; showing selection menu") showSelectionMenuIfNeeded() return } RDEpubReaderTapDebug.log("TextContentView.handleTap", "selection exists and tap moved outside selection; clearing selection") clearSelection() return } } else if currentSelection != nil { RDEpubReaderTapDebug.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 { RDEpubReaderTapDebug.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) RDEpubReaderTapDebug.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) { RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved fallback attachment text tap") delegate?.textContentView( self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect, sourcePoint: convert(point, from: overlayView) ) return } let highlights = highlights(at: point) guard let highlight = highlights.first, let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { RDEpubReaderTapDebug.log("TextContentView.handleTap", "forwarding plain reader tap to delegate") delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView)) return } RDEpubReaderTapDebug.log("TextContentView.handleTap", "resolved highlight tap highlightIds=\(highlights.map(\.id).joined(separator: ","))") showHighlightMenu(for: highlights, sourceRect: sourceRect) } private func showHighlightMenu(for highlights: [RDEPUBHighlight], sourceRect: CGRect) { // A short tap opens the compact system menu. Long-press selection is // intentionally handled by the separate recognizer, which the tap // recognizer already waits on, so text inside a marking remains // selectable. let underline = highlights.first { !$0.isAnnotationOnly } let annotation = highlights.first { $0.isAnnotationOnly } ?? highlights.first { $0.hasNote } menuHighlight = annotation ?? underline menuUnderlineHighlight = underline menuAnnotationHighlight = annotation guard menuHighlight != nil else { return } becomeFirstResponder() let menuController = UIMenuController.shared var items = [UIMenuItem(title: "复制", action: #selector(rd_copy(_:)))] if underline != nil { items.append(UIMenuItem(title: "删除划线", action: #selector(rd_deleteUnderline(_:)))) } else if annotation != nil { items.append(UIMenuItem(title: "划线", action: #selector(rd_highlight(_:)))) } if let annotation, annotation.hasNote { items.append(UIMenuItem(title: "删除注释", action: #selector(rd_deleteAnnotation(_:)))) } else if let underline, !underline.hasNote { items.append(UIMenuItem(title: "注释", action: #selector(rd_annotate(_:)))) } menuController.menuItems = items menuController.setTargetRect(sourceRect, in: self) menuController.setMenuVisible(true, animated: true) } private func performHighlightMenuAction( _ action: RDEPUBExistingHighlightMenuAction, highlight: RDEPUBHighlight ) { menuHighlight = nil menuUnderlineHighlight = nil menuAnnotationHighlight = nil UIMenuController.shared.setMenuVisible(false, animated: true) delegate?.textContentView(self, didRequestHighlightMenuAction: action, highlight: highlight) } private func showSelectionMenuIfNeeded() { menuHighlight = nil menuUnderlineHighlight = nil menuAnnotationHighlight = nil guard currentSelection != nil, let targetRect = selectionController.menuAnchorRect(interactionController: interactionController), !targetRect.isEmpty else { return } let resolvedTargetRect = resolvedSelectionMenuTargetRect(from: targetRect) 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() { menuHighlight = nil menuUnderlineHighlight = nil menuAnnotationHighlight = nil 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? { highlights(at: point).first } private func highlights(at point: CGPoint) -> [RDEPUBHighlight] { guard let page = currentPage else { return [] } 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 lhsRange != rhsRange { // In an overlap, the narrower marking is the one the reader // most likely meant to touch (e.g. underline A inside note B). return lhsRange < rhsRange } return lhs.createdAt > rhs.createdAt } } 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 { RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because interactionState is selectionPending") return true } guard let renderView = coreTextRenderView else { RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because renderView is nil") return false } let renderPoint = convert(point, to: renderView) if renderView.selectionHandle(at: renderPoint) != nil { RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap hit selection handle") return true } if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) { RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return true because tap is inside active selection") return true } switch interactionCoordinator.interactionState { case .idle: RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because interactionState is idle") return false case .selectionPending, .selecting, .selectionActive, .adjustingHandle: RDEpubReaderTapDebug.log( "TextContentView.shouldSuppressReaderTap", "return true because interactionState=\(interactionCoordinator.interactionState)" ) return true } #else RDEpubReaderTapDebug.log("TextContentView.shouldSuppressReaderTap", "return false because DTCoreText is unavailable") return false #endif } override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) interactionCoordinator.touchesBegan(touches, with: event) } override func touchesEnded(_ touches: Set, with event: UIEvent?) { super.touchesEnded(touches, with: event) interactionCoordinator.touchesEnded(touches, with: event) } override func touchesCancelled(_ touches: Set, 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 { RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return true because renderView is nil") return true } let point = touch.location(in: renderView) if renderView.selectionHandle(at: point) != nil { RDEpubReaderTapDebug.log("TextContentView.gestureShouldReceive", "return false because tap hit selection handle") return false } } let result = interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch) RDEpubReaderTapDebug.log( "TextContentView.gestureShouldReceive", "gesture=\(type(of: gestureRecognizer)) touchView=\(RDEpubReaderTapDebug.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) ) } }