import UIKit import Foundation #if canImport(DTCoreText) import DTCoreText #endif protocol RDEPUBTextContentViewDelegate: AnyObject { func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?) func textContentView(_ contentView: RDEPUBTextContentView, didRequestReaderTapAt point: CGPoint) func textContentView( _ contentView: RDEPUBTextContentView, didRequestSelectionAction action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection? ) func textContentView( _ contentView: RDEPUBTextContentView, didActivateAttachmentText text: String, sourceRect: CGRect, sourcePoint: CGPoint ) func textContentView( _ contentView: RDEPUBTextContentView, didRequestHighlightActions highlight: RDEPUBHighlight, sourceRect: CGRect ) } final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReaderCachePolicyProviding { private var contentInsets: UIEdgeInsets = .zero private var currentPage: RDEPUBTextPage? private var currentChapterCFIMap: RDEPUBCFIMap? private var currentChapterFragmentOffsets: [String: Int] = [:] private var currentSelection: RDEPUBSelection? private var menuSelection: RDEPUBSelection? private let selectionLoupeView = RDEPUBSelectionLoupeView() private var currentHighlights: [RDEPUBHighlight] = [] private var currentSearchState: RDEPUBSearchState? weak var delegate: RDEPUBTextContentViewDelegate? var selectionTapSuppressionDidChange: ((Bool) -> Void)? var selectionPagingSuppressionDidChange: ((Bool) -> Void)? #if canImport(DTCoreText) private let coreTextContentView: RDEPUBTextPageRenderView = { let view = RDEPUBTextPageRenderView() view.backgroundColor = .clear view.isOpaque = false view.isAccessibilityElement = true view.accessibilityTraits = .staticText return view }() private var coreTextDisplayContent: NSAttributedString? private var coreTextDisplayRange: NSRange? #endif private let interactionController = RDEPUBPageInteractionController() private let selectionController = RDEPUBTextSelectionController() private let backgroundOverlayView: RDEPUBTextPageDecorationView = { let view = RDEPUBTextPageDecorationView() return view }() private let overlayView: RDEPUBTextAnnotationOverlay = { let view = RDEPUBTextAnnotationOverlay() return view }() private let coverImageView: UIImageView = { let view = UIImageView() view.contentMode = .scaleAspectFit view.isHidden = true return view }() private let pageNumberLabel: UILabel = { let label = UILabel() label.font = UIFont.systemFont(ofSize: 13) return label }() private let loadingSpinner: UIActivityIndicatorView = { let spinner = UIActivityIndicatorView(style: .medium) spinner.hidesWhenStopped = true spinner.accessibilityIdentifier = "epub.reader.loadingSpinner" return spinner }() private lazy var longPressGestureRecognizer: UILongPressGestureRecognizer = { let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:))) gesture.minimumPressDuration = 0.5 gesture.delegate = self return gesture }() private lazy var panGestureRecognizer: UIPanGestureRecognizer = { let gesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) gesture.isEnabled = false gesture.delegate = self return gesture }() private lazy var tapGestureRecognizer: UITapGestureRecognizer = { let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) gesture.delegate = self return gesture }() private lazy var interactionCoordinator: RDEPUBTextContentInteractionCoordinator = { RDEPUBTextContentInteractionCoordinator( dependencies: .init( hasRenderableContent: { [weak self] in self?.hasInteractiveTextContent ?? false }, currentSelectionProvider: { [weak self] in self?.currentSelection }, isSelectionControllerSelecting: { [weak self] in self?.selectionController.isSelecting ?? false }, hasActiveSelection: { [weak self] in self?.selectionController.hasActiveSelection ?? false }, selectionHandleAtPoint: { [weak self] point in guard let renderView = self?.coreTextRenderView else { return nil } let handle = renderView.selectionHandle(at: point) switch handle { case .start: return .start case .end: return .end case nil: return nil } }, selectionContainsPoint: { [weak self] point in self?.coreTextRenderView?.selectionContains(point) ?? false }, renderPointForGesture: { [weak self] gesture in guard let self else { return .zero } return gesture.location(in: self.coreTextRenderView ?? self) }, renderPointForTouch: { [weak self] touch in guard let self else { return .zero } return touch.location(in: self.coreTextRenderView ?? self) }, performLongPressSelection: { [weak self] gesture in guard let self else { return } self.layoutIfNeeded() self.selectionController.handleLongPress( gesture, renderView: self.coreTextRenderView, interactionController: self.interactionController ) }, performPanSelection: { [weak self] gesture in guard let self else { return } self.layoutIfNeeded() self.selectionController.handlePan( gesture, renderView: self.coreTextRenderView, interactionController: self.interactionController ) }, adjustSelection: { [weak self] handle, point in guard let self else { return } self.selectionController.updateSelection( byAdjusting: handle, at: point, renderView: self.coreTextRenderView, interactionController: self.interactionController ) }, presentLoupeAtPoint: { [weak self] point in self?.updateSelectionLoupe(for: point) }, dismissLoupe: { [weak self] in self?.selectionLoupeView.dismiss() }, showSelectionMenu: { [weak self] in self?.showSelectionMenuIfNeeded() }, hideSelectionMenu: { [weak self] in self?.hideSelectionMenu() }, selectionTapSuppressionDidChange: { [weak self] isSuppressed in self?.selectionTapSuppressionDidChange?(isSuppressed) }, selectionPagingSuppressionDidChange: { [weak self] isSuppressed in self?.selectionPagingSuppressionDidChange?(isSuppressed) } ) ) }() override init(frame: CGRect) { super.init(frame: frame) accessibilityIdentifier = "epub.reader.content.view" addSubview(coverImageView) #if canImport(DTCoreText) addSubview(backgroundOverlayView) addSubview(coreTextContentView) #endif addSubview(overlayView) addSubview(pageNumberLabel) addSubview(loadingSpinner) addSubview(selectionLoupeView) addGestureRecognizer(longPressGestureRecognizer) addGestureRecognizer(panGestureRecognizer) addGestureRecognizer(tapGestureRecognizer) tapGestureRecognizer.require(toFail: longPressGestureRecognizer) selectionLoupeView.isHidden = true selectionController.onSelectionChanged = { [weak self] selection in guard let self else { return } self.currentSelection = selection if let selection { self.menuSelection = selection } else { self.hideSelectionMenu() } self.updateSelectionPanAvailability() self.updateViewInteractionAvailability() self.updateAccessibilityDecorationSummary() self.delegate?.textContentView(self, didChangeSelection: selection) } selectionController.interactionStateDidChange = { [weak self] state in guard let self else { return } self.interactionCoordinator.selectionControllerStateDidChange(state) self.updateSelectionPanAvailability() self.updateViewInteractionAvailability() } selectionController.pageProvider = { [weak self] in self?.currentPage } selectionController.chapterCFIMapProvider = { [weak self] in self?.currentChapterCFIMap } selectionController.chapterFragmentOffsetsProvider = { [weak self] in self?.currentChapterFragmentOffsets ?? [:] } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var canBecomeFirstResponder: Bool { true } var selectionLongPressGestureRecognizer: UILongPressGestureRecognizer { longPressGestureRecognizer } var isSelectionInteractionInProgress: Bool { interactionCoordinator.isInteractionInProgress } var shouldAvoidReaderPageCaching: Bool { currentPage == nil || loadingSpinner.isAnimating } private var hasInteractiveTextContent: Bool { #if canImport(DTCoreText) currentPage != nil && coreTextRenderView?.isHidden == false #else currentPage != nil #endif } override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { action == #selector(rd_copy(_:)) || action == #selector(rd_highlight(_:)) || action == #selector(rd_annotate(_:)) } @objc func rd_copy(_ sender: Any?) { performSelectionAction(.copy) } @objc func rd_highlight(_ sender: Any?) { performSelectionAction(.highlight) } @objc func rd_annotate(_ sender: Any?) { performSelectionAction(.annotate) } override func layoutSubviews() { super.layoutSubviews() #if canImport(DTCoreText) backgroundOverlayView.frame = bounds.inset(by: contentInsets) coreTextContentView.frame = bounds.inset(by: contentInsets) updateCoreTextLayoutFrameIfNeeded() #endif overlayView.frame = bounds.inset(by: contentInsets) coverImageView.frame = bounds.inset(by: contentInsets) let labelSize = pageNumberLabel.sizeThatFits(CGSize(width: bounds.width, height: 20)) pageNumberLabel.frame = CGRect( x: bounds.width - labelSize.width - 24, y: bounds.height - labelSize.height - 20, width: labelSize.width, height: labelSize.height ) loadingSpinner.center = CGPoint(x: bounds.midX, y: bounds.midY) } func configure( page: RDEPUBTextPage, pageNumber: Int, totalPages: Int, configuration: RDEPUBReaderConfiguration, chapterCFIMap: RDEPUBCFIMap? = nil, chapterFragmentOffsets: [String: Int] = [:], highlights: [RDEPUBHighlight] = [], searchState: RDEPUBSearchState? = nil ) { loadingSpinner.stopAnimating() currentPage = page currentChapterCFIMap = chapterCFIMap currentChapterFragmentOffsets = chapterFragmentOffsets currentSelection = nil menuSelection = nil interactionCoordinator.reset() currentHighlights = highlights currentSearchState = searchState selectionController.clearSelection(renderView: coreTextRenderView) contentInsets = configuration.reflowableContentInsets backgroundColor = configuration.theme.contentBackgroundColor pageNumberLabel.textColor = configuration.theme.contentTextColor pageNumberLabel.text = "\(pageNumber) / \(totalPages)" if configureCoverIfNeeded(for: page) { #if canImport(DTCoreText) coreTextContentView.isHidden = true coreTextContentView.layoutFrame = nil coreTextDisplayContent = nil coreTextDisplayRange = nil #endif updateStaticGestureAvailability() updateSelectionPanAvailability() updateViewInteractionAvailability() delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() return } coverImageView.isHidden = true coverImageView.image = nil #if canImport(DTCoreText) let displayContent = RDEPUBDarkImageAdjuster.adjustIfNeeded( normalizedPageContent(from: page), configuration: configuration ) let fullRange = NSRange(location: 0, length: displayContent.length) displayContent.addAttribute( .foregroundColor, value: configuration.theme.contentTextColor, range: fullRange ) applyHighlightsToContent(displayContent, highlights: highlights, page: page) coreTextContentView.isHidden = false coreTextContentView.backgroundColor = .clear coreTextContentView.accessibilityIdentifier = "epub.reader.selection.text" coreTextDisplayContent = displayContent coreTextDisplayRange = NSRange(location: 0, length: displayContent.length) coreTextContentView.attributedDisplayContent = displayContent updateCoreTextLayoutFrameIfNeeded() #else let selectionContent = normalizedPageContent(from: page) let selectionRange = NSRange(location: 0, length: selectionContent.length) selectionContent.addAttribute( .foregroundColor, value: configuration.theme.contentTextColor, range: selectionRange ) overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset) overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset) #endif overlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) #if canImport(DTCoreText) backgroundOverlayView.configure(page: page, selectionColor: overlayView.selectionColor, snapshot: interactionController.snapshot) #endif updateStaticGestureAvailability() updateSelectionPanAvailability() updateViewInteractionAvailability() updateAccessibilityDecorationSummary() delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() } func configureLoading( pageNumber: Int, totalPages: Int, configuration: RDEPUBReaderConfiguration ) { currentPage = nil currentChapterCFIMap = nil currentChapterFragmentOffsets = [:] currentSelection = nil menuSelection = nil interactionCoordinator.reset() currentHighlights = [] currentSearchState = nil selectionController.clearSelection(renderView: coreTextRenderView) contentInsets = configuration.reflowableContentInsets backgroundColor = configuration.theme.contentBackgroundColor pageNumberLabel.textColor = configuration.theme.contentTextColor pageNumberLabel.text = "\(pageNumber) / \(totalPages)" coverImageView.isHidden = true coverImageView.image = nil #if canImport(DTCoreText) coreTextContentView.isHidden = false coreTextContentView.backgroundColor = .clear coreTextContentView.attributedDisplayContent = nil coreTextContentView.layoutFrame = nil coreTextDisplayContent = nil coreTextDisplayRange = nil #endif overlayView.clearSelection() backgroundOverlayView.clearSelection() updateStaticGestureAvailability() updateSelectionPanAvailability() updateViewInteractionAvailability() delegate?.textContentView(self, didChangeSelection: nil) loadingSpinner.startAnimating() setNeedsLayout() } func clearSelection() { currentSelection = nil menuSelection = nil currentSearchState = nil interactionCoordinator.reset() selectionController.clearSelection(renderView: coreTextRenderView) overlayView.clearSelection() backgroundOverlayView.clearSelection() updateSelectionPanAvailability() updateViewInteractionAvailability() updateAccessibilityDecorationSummary() UIMenuController.shared.setMenuVisible(false, animated: true) } private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) { let selection = currentSelection ?? menuSelection delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection) menuSelection = nil hideSelectionMenu() } private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool { guard page.pageIndexInChapter == 0, page.href.lowercased().contains("cover"), let image = coverImage(from: page.content) else { return false } coverImageView.image = image coverImageView.isHidden = false #if canImport(DTCoreText) coreTextContentView.isHidden = true coreTextContentView.layoutFrame = nil coreTextDisplayContent = nil coreTextDisplayRange = nil #endif return true } private func coverImage(from content: NSAttributedString) -> UIImage? { guard content.length > 0 else { return nil } var resolvedImage: UIImage? content.enumerateAttribute(.attachment, in: NSRange(location: 0, length: content.length)) { value, _, stop in guard let image = image(from: value) else { return } resolvedImage = image stop.pointee = true } return resolvedImage } private func image(from attachmentValue: Any?) -> UIImage? { #if canImport(DTCoreText) if let attachment = attachmentValue as? DTTextAttachment, let url = attachment.contentURL { return UIImage(contentsOfFile: url.path) } #endif if let attachment = attachmentValue as? NSTextAttachment { if let image = attachment.image { return image } if let data = attachment.contents { return UIImage(data: data) } if let fileWrapper = attachment.fileWrapper, let data = fileWrapper.regularFileContents { return UIImage(data: data) } } return nil } private func applyHighlightsToContent( _ content: NSMutableAttributedString, highlights: [RDEPUBHighlight], page: RDEPUBTextPage ) { for highlight in highlights { guard let rangeInfo = highlight.rangeInfo, let info = RDEPUBTextOffsetRangeInfo.decode(from: rangeInfo), let absoluteRange = info.nsRange else { continue } let overlap = NSIntersectionRange(absoluteRange, page.contentRange) guard overlap.length > 0 else { continue } let relativeRange = NSRange(location: overlap.location - page.pageStartOffset, length: overlap.length) guard relativeRange.location >= 0, relativeRange.location + relativeRange.length <= content.length else { continue } switch highlight.style { case .highlight: content.addAttribute(kRDEPUBHighlightAttributeName, value: highlight.uiColor, range: relativeRange) case .underline: content.addAttribute(kRDEPUBUnderlineAttributeName, value: highlight.uiColor, range: relativeRange) } } } private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString { let content = NSMutableAttributedString(attributedString: page.content) guard shouldNormalizeContinuationParagraph(for: page) else { return content } let text = content.string as NSString let firstParagraphRange = text.paragraphRange(for: NSRange(location: 0, length: 0)) guard firstParagraphRange.length > 0 else { return content } content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in guard let style = value as? NSParagraphStyle else { return } let mutableStyle = (style.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle() mutableStyle.firstLineHeadIndent = mutableStyle.headIndent mutableStyle.paragraphSpacingBefore = 0 content.addAttribute(.paragraphStyle, value: mutableStyle.copy() as Any, range: range) } return content } private func shouldNormalizeContinuationParagraph(for page: RDEPUBTextPage) -> Bool { let pageStart = page.pageStartOffset guard pageStart > 0, pageStart < page.chapterContent.length else { return false } let chapterText = page.chapterContent.string as NSString guard let previousScalar = UnicodeScalar(chapterText.character(at: pageStart - 1)) else { return false } return !CharacterSet.newlines.contains(previousScalar) } #if canImport(DTCoreText) private func updateCoreTextLayoutFrameIfNeeded() { guard !coreTextContentView.isHidden, let displayContent = coreTextDisplayContent, let displayRange = coreTextDisplayRange, let page = currentPage, coreTextContentView.bounds.width > 0, coreTextContentView.bounds.height > 0 else { interactionController.configure(layoutFrame: nil, page: currentPage) return } guard let layouter = DTCoreTextLayouter(attributedString: displayContent) else { coreTextContentView.layoutFrame = nil interactionController.configure(layoutFrame: nil, page: page) return } layouter.shouldCacheLayoutFrames = false let layoutFrame = layouter.layoutFrame(with: coreTextContentView.bounds, range: displayRange) coreTextContentView.layoutFrame = layoutFrame interactionController.configure(layoutFrame: layoutFrame, page: page) overlayView.updateSnapshot(interactionController.snapshot) backgroundOverlayView.updateSnapshot(interactionController.snapshot) if let page = currentPage { let (bgDecorations, fgDecorations) = overlayView.buildDecorations( page: page, highlights: [], searchState: currentSearchState, interactionController: interactionController ) backgroundOverlayView.applyDecorations(bgDecorations) overlayView.applyDecorations(fgDecorations) } } #endif @objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) { interactionCoordinator.handleLongPress(gesture) } @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { interactionCoordinator.handlePan(gesture) } private func updateSelectionPanAvailability() { let shouldEnablePan = selectionController.isSelecting || selectionController.hasActiveSelection || interactionCoordinator.interactionState == .adjustingHandle guard panGestureRecognizer.isEnabled != shouldEnablePan else { return } panGestureRecognizer.isEnabled = shouldEnablePan } private func updateViewInteractionAvailability() { let shouldEnableInteraction = hasInteractiveTextContent || selectionController.isSelecting || selectionController.hasActiveSelection || interactionCoordinator.interactionState != .idle guard isUserInteractionEnabled != shouldEnableInteraction else { return } isUserInteractionEnabled = shouldEnableInteraction } private func updateStaticGestureAvailability() { let shouldEnableLongPress = hasInteractiveTextContent && loadingSpinner.isAnimating == false let shouldEnableTap = hasInteractiveTextContent && loadingSpinner.isAnimating == false let longPressChanged = longPressGestureRecognizer.isEnabled != shouldEnableLongPress let tapChanged = tapGestureRecognizer.isEnabled != shouldEnableTap guard longPressChanged || tapChanged else { return } longPressGestureRecognizer.isEnabled = shouldEnableLongPress tapGestureRecognizer.isEnabled = shouldEnableTap } @objc private func handleTap(_ gesture: UITapGestureRecognizer) { let point = gesture.location(in: overlayView) if let renderView = coreTextRenderView { let renderPoint = gesture.location(in: renderView) if renderView.selectionHandle(at: renderPoint) != nil { return } if currentSelection != nil { if renderView.selectionContains(renderPoint) { showSelectionMenuIfNeeded() return } clearSelection() return } } else if currentSelection != nil { clearSelection() return } if let attachmentText = attachmentText(at: point), let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) { delegate?.textContentView( self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect, sourcePoint: convert(point, from: overlayView) ) return } guard let highlight = highlight(at: point), let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else { delegate?.textContentView(self, didRequestReaderTapAt: convert(point, from: overlayView)) return } delegate?.textContentView(self, didRequestHighlightActions: highlight, sourceRect: sourceRect) } private func showSelectionMenuIfNeeded() { guard currentSelection != nil, let targetRect = selectionController.menuAnchorRect(interactionController: interactionController), !targetRect.isEmpty else { return } becomeFirstResponder() let menuController = UIMenuController.shared menuController.menuItems = [ UIMenuItem(title: "拷贝", action: #selector(rd_copy(_:))), UIMenuItem(title: "高亮", action: #selector(rd_highlight(_:))), UIMenuItem(title: "批注", action: #selector(rd_annotate(_:))) ] menuController.setTargetRect(targetRect, in: coreTextRenderView ?? self) menuController.setMenuVisible(true, animated: true) } private func hideSelectionMenu() { UIMenuController.shared.setMenuVisible(false, animated: true) } private func updateSelectionLoupe(for point: CGPoint) { #if canImport(DTCoreText) guard let renderView = coreTextRenderView else { return } let localPoint = convert(point, from: renderView) selectionLoupeView.present( sourceView: renderView, focusPoint: point, hostBounds: bounds.inset(by: contentInsets), targetPoint: localPoint ) #endif } private var coreTextRenderView: RDEPUBTextPageRenderView? { #if canImport(DTCoreText) return coreTextContentView #else return nil #endif } private func updateAccessibilityDecorationSummary() { #if canImport(DTCoreText) coreTextContentView.accessibilityValue = [ backgroundOverlayView.decorationSummary(), overlayView.decorationSummary() ].joined(separator: " | ") #endif } private func highlight(at point: CGPoint) -> RDEPUBHighlight? { guard currentPage != nil else { return nil } let absoluteRange = backgroundOverlayView.absoluteRange(at: point) ?? overlayView.absoluteRange(at: point) guard let absoluteRange else { return nil } let matches = currentHighlights.filter { highlight in guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { return false } return NSIntersectionRange(range, absoluteRange).length > 0 } return matches.sorted { lhs, rhs in let lhsRange = RDEPUBTextOffsetRangeInfo.decode(from: lhs.rangeInfo)?.nsRange?.length ?? .max let rhsRange = RDEPUBTextOffsetRangeInfo.decode(from: rhs.rangeInfo)?.nsRange?.length ?? .max if lhs.hasNote != rhs.hasNote { return lhs.hasNote && !rhs.hasNote } return lhsRange < rhsRange }.first } private func highlightSourceRect(for highlight: RDEPUBHighlight, fallbackPoint: CGPoint) -> CGRect? { guard let range = RDEPUBTextOffsetRangeInfo.decode(from: highlight.rangeInfo)?.nsRange else { let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1)) return overlayView.convert(fallbackRect, to: self) } let rects = interactionController.selectionRects(for: range) if let first = rects.first { var unionRect = first for rect in rects.dropFirst() { unionRect = unionRect.union(rect) } return overlayView.convert(unionRect, to: self) } let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1)) return overlayView.convert(fallbackRect, to: self) } private func attachmentText(at point: CGPoint) -> String? { guard let page = currentPage else { return nil } guard let attachmentRange = interactionController.snapshot?.attachment(at: point)?.stringRange else { return nil } guard page.chapterContent.length > attachmentRange.location else { return nil } let attachment = page.chapterContent.attribute(.attachment, at: attachmentRange.location, effectiveRange: nil) #if canImport(DTCoreText) if let textAttachment = attachment as? DTTextAttachment, let altText = (textAttachment.attributes["alt"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines), !altText.isEmpty { return altText } #endif if let fileAttachment = attachment as? NSTextAttachment, let altText = fileAttachment.accessibilityLabel?.trimmingCharacters(in: .whitespacesAndNewlines), !altText.isEmpty { return altText } return nil } private func attachmentSourceRect(at point: CGPoint, fallbackPoint: CGPoint) -> CGRect? { if let attachmentRect = interactionController.snapshot?.attachment(at: point)?.frame { return overlayView.convert(attachmentRect, to: self) } let fallbackRect = CGRect(origin: fallbackPoint, size: CGSize(width: 1, height: 1)) return overlayView.convert(fallbackRect, to: self) } func shouldSuppressReaderTap(at point: CGPoint) -> Bool { #if canImport(DTCoreText) if interactionCoordinator.interactionState == .selectionPending { return true } guard let renderView = coreTextRenderView else { return false } let renderPoint = convert(point, to: renderView) if renderView.selectionHandle(at: renderPoint) != nil { return true } if selectionController.hasActiveSelection, renderView.selectionContains(renderPoint) { return true } switch interactionCoordinator.interactionState { case .idle: return false case .selectionPending, .selecting, .selectionActive, .adjustingHandle: return true } #else return false #endif } override func touchesBegan(_ touches: Set, 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 { return true } let point = touch.location(in: renderView) if renderView.selectionHandle(at: point) != nil { return false } } return interactionCoordinator.gestureRecognizer(gestureRecognizer, shouldReceive: touch) } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { gestureRecognizer === tapGestureRecognizer || interactionCoordinator.gestureRecognizer( gestureRecognizer, shouldRecognizeSimultaneouslyWith: otherGestureRecognizer ) } }