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 { enum SelectionInteractionState: Equatable { case idle case selectionPending case selecting case selectionActive case adjustingHandle } private static let darkAdjustedImageCache = NSCache() 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 var activeSelectionHandle: RDEPUBTextSelectionController.BoundaryHandle? private let selectionLoupeView = RDEPUBSelectionLoupeView() private var selectionInteractionState: SelectionInteractionState = .idle 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.delegate = self return gesture }() private lazy var tapGestureRecognizer: UITapGestureRecognizer = { let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) gesture.delegate = self return gesture }() 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.updateAccessibilityDecorationSummary() self.delegate?.textContentView(self, didChangeSelection: selection) } selectionController.interactionStateDidChange = { [weak self] state in self?.handleSelectionControllerStateChange(state) } 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 { selectionInteractionState != .idle } 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 updateSelectionInteractionState(.idle) 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 delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() return } coverImageView.isHidden = true coverImageView.image = nil #if canImport(DTCoreText) let displayContent = darkImageAdjustedContentIfNeeded( 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 updateAccessibilityDecorationSummary() delegate?.textContentView(self, didChangeSelection: nil) setNeedsLayout() } func configureLoading( pageNumber: Int, totalPages: Int, configuration: RDEPUBReaderConfiguration ) { currentPage = nil currentChapterCFIMap = nil currentChapterFragmentOffsets = [:] currentSelection = nil menuSelection = nil activeSelectionHandle = nil updateSelectionInteractionState(.idle) 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() delegate?.textContentView(self, didChangeSelection: nil) loadingSpinner.startAnimating() setNeedsLayout() } func clearSelection() { currentSelection = nil menuSelection = nil currentSearchState = nil activeSelectionHandle = nil updateSelectionInteractionState(.idle) selectionLoupeView.dismiss() selectionController.clearSelection(renderView: coreTextRenderView) overlayView.clearSelection() backgroundOverlayView.clearSelection() 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 } #if canImport(DTCoreText) private func darkImageAdjustedContentIfNeeded( _ content: NSMutableAttributedString, configuration: RDEPUBReaderConfiguration ) -> NSMutableAttributedString { guard configuration.darkImageAdjustmentEnabled, configuration.darkImageBlendRatio > 0, configuration.theme.contentBackgroundColor.rd_isDarkReaderBackground else { return content } let fullRange = NSRange(location: 0, length: content.length) content.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in guard let attachment = value as? DTImageTextAttachment, !isCoverAttachment(attachment), let image = attachment.image, shouldAdjustDarkImage(image) else { return } let adjustedAttachment = DTImageTextAttachment() adjustedAttachment.image = adjustedImage( image, backgroundColor: configuration.theme.contentBackgroundColor, blendRatio: configuration.darkImageBlendRatio, cacheKey: darkImageCacheKey(for: attachment, image: image, configuration: configuration) ) adjustedAttachment.originalSize = attachment.originalSize adjustedAttachment.displaySize = attachment.displaySize adjustedAttachment.verticalAlignment = attachment.verticalAlignment adjustedAttachment.contentURL = attachment.contentURL adjustedAttachment.hyperLinkURL = attachment.hyperLinkURL adjustedAttachment.hyperLinkGUID = attachment.hyperLinkGUID adjustedAttachment.attributes = attachment.attributes content.addAttribute(.attachment, value: adjustedAttachment, range: range) } return content } private func isCoverAttachment(_ attachment: DTTextAttachment) -> Bool { let lowercasedClasses = ((attachment.attributes["class"] as? String) ?? "").lowercased() let lowercasedPath = attachment.contentURL?.lastPathComponent.lowercased() ?? ((attachment.attributes["src"] as? String) ?? "").lowercased() return lowercasedClasses.contains("rd-front-cover-image") || lowercasedPath.contains("cover") } private func shouldAdjustDarkImage(_ image: UIImage) -> Bool { image.size.width >= 80 && image.size.height >= 80 } private func darkImageCacheKey( for attachment: DTImageTextAttachment, image: UIImage, configuration: RDEPUBReaderConfiguration ) -> NSString { let source = attachment.contentURL?.absoluteString ?? "\(Unmanaged.passUnretained(image).toOpaque())" return "\(source)|\(image.size.width)x\(image.size.height)|\(configuration.theme.contentBackgroundColor.ss_cssString)|\(configuration.darkImageBlendRatio)" as NSString } private func adjustedImage( _ image: UIImage, backgroundColor: UIColor, blendRatio: CGFloat, cacheKey: NSString ) -> UIImage { if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) { return cached } let format = UIGraphicsImageRendererFormat() format.scale = image.scale format.opaque = false let renderer = UIGraphicsImageRenderer(size: image.size, format: format) let adjusted = renderer.image { context in image.draw(in: CGRect(origin: .zero, size: image.size)) backgroundColor.withAlphaComponent(max(0, min(0.35, blendRatio))).setFill() context.cgContext.setBlendMode(.sourceAtop) context.fill(CGRect(origin: .zero, size: image.size)) } Self.darkAdjustedImageCache.setObject(adjusted, forKey: cacheKey) return adjusted } #endif 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) { #if canImport(DTCoreText) layoutIfNeeded() selectionController.handleLongPress( gesture, renderView: coreTextRenderView, interactionController: interactionController ) switch gesture.state { case .began: activeSelectionHandle = nil updateSelectionInteractionState(.selecting) hideSelectionMenu() updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self)) case .changed: updateSelectionInteractionState(.selecting) updateSelectionLoupe(for: gesture.location(in: coreTextRenderView ?? self)) case .ended: selectionLoupeView.dismiss() showSelectionMenuIfNeeded() case .cancelled, .failed: activeSelectionHandle = nil selectionLoupeView.dismiss() default: break } #endif } @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { #if canImport(DTCoreText) layoutIfNeeded() let point = gesture.location(in: coreTextRenderView ?? self) if gesture.state == .began, activeSelectionHandle == nil { if let renderView = coreTextRenderView, let handle = renderView.selectionHandle(at: point) { activeSelectionHandle = handle == .start ? .start : .end updateSelectionInteractionState(.adjustingHandle) hideSelectionMenu() updateSelectionLoupe(for: point) } } if let activeSelectionHandle, let renderView = coreTextRenderView { selectionController.updateSelection( byAdjusting: activeSelectionHandle, at: point, renderView: renderView, interactionController: interactionController ) updateSelectionLoupe(for: point) } else { selectionController.handlePan( gesture, renderView: coreTextRenderView, interactionController: interactionController ) if selectionController.isSelecting { updateSelectionLoupe(for: point) } } switch gesture.state { case .ended: activeSelectionHandle = nil updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive) selectionLoupeView.dismiss() showSelectionMenuIfNeeded() case .cancelled, .failed: activeSelectionHandle = nil updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive) selectionLoupeView.dismiss() default: break } #endif } @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 func handleSelectionControllerStateChange(_ state: RDEPUBTextSelectionController.InteractionState) { switch state { case .idle: if currentSelection == nil, activeSelectionHandle == nil { updateSelectionInteractionState(.idle) } case .selecting: updateSelectionInteractionState(.selecting) case .selectionActive: updateSelectionInteractionState(currentSelection == nil ? .idle : .selectionActive) case .adjustingHandle: updateSelectionInteractionState(.adjustingHandle) } } private func updateSelectionInteractionState(_ state: SelectionInteractionState) { let previousTapSuppressed = selectionInteractionState != .idle let previousPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState) selectionInteractionState = state let currentTapSuppressed = selectionInteractionState != .idle let currentPagingSuppressed = shouldSuppressPagingInteraction(for: selectionInteractionState) if previousTapSuppressed != currentTapSuppressed { selectionTapSuppressionDidChange?(currentTapSuppressed) } if previousPagingSuppressed != currentPagingSuppressed { selectionPagingSuppressionDidChange?(currentPagingSuppressed) } } private func shouldSuppressPagingInteraction(for state: SelectionInteractionState) -> Bool { switch state { case .idle, .selectionPending, .selectionActive: return false case .selecting, .adjustingHandle: return true } } 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 let page = currentPage 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 selectionInteractionState == .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 selectionInteractionState { 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) #if canImport(DTCoreText) guard selectionInteractionState == .idle, activeSelectionHandle == nil, currentPage != nil, let touch = touches.first, let renderView = coreTextRenderView else { return } let point = touch.location(in: renderView) guard renderView.selectionHandle(at: point) == nil else { return } updateSelectionInteractionState(.selectionPending) #endif } override func touchesEnded(_ touches: Set, with event: UIEvent?) { super.touchesEnded(touches, with: event) resetSelectionPendingIfNeeded() } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { super.touchesCancelled(touches, with: event) resetSelectionPendingIfNeeded() } private func resetSelectionPendingIfNeeded() { guard selectionInteractionState == .selectionPending else { return } if currentSelection != nil { updateSelectionInteractionState(.selectionActive) } else { updateSelectionInteractionState(.idle) } } override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { if gestureRecognizer === panGestureRecognizer { if selectionController.isSelecting { return true } guard let pan = gestureRecognizer as? UIPanGestureRecognizer, let renderView = coreTextRenderView else { return false } let point = pan.location(in: renderView) return renderView.selectionHandle(at: point) != nil } if gestureRecognizer === longPressGestureRecognizer { guard let longPress = gestureRecognizer as? UILongPressGestureRecognizer, let renderView = coreTextRenderView else { return true } let point = longPress.location(in: renderView) if renderView.selectionHandle(at: point) != nil { return false } if selectionController.hasActiveSelection, renderView.selectionContains(point) { return false } return true } 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 true } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch ) -> Bool { guard let renderView = coreTextRenderView else { return true } let point = touch.location(in: renderView) if let handle = renderView.selectionHandle(at: point) { if gestureRecognizer === panGestureRecognizer { activeSelectionHandle = handle == .start ? .start : .end updateSelectionInteractionState(.adjustingHandle) hideSelectionMenu() return true } if gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === tapGestureRecognizer { return false } } return true } func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer } } private final class RDEPUBSelectionLoupeView: UIView { private let imageView = UIImageView() private let magnification: CGFloat = 1.45 private let captureSize = CGSize(width: 84, height: 84) override init(frame: CGRect) { super.init(frame: CGRect(origin: .zero, size: CGSize(width: 96, height: 96))) isUserInteractionEnabled = false backgroundColor = .clear layer.shadowColor = UIColor.black.cgColor layer.shadowOpacity = 0.18 layer.shadowRadius = 10 layer.shadowOffset = CGSize(width: 0, height: 5) imageView.frame = bounds imageView.autoresizingMask = [.flexibleWidth, .flexibleHeight] imageView.layer.cornerRadius = bounds.width / 2 imageView.layer.cornerCurve = .continuous imageView.layer.borderWidth = 1.5 imageView.layer.borderColor = UIColor(white: 0.82, alpha: 0.95).cgColor imageView.clipsToBounds = true addSubview(imageView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func present(sourceView: UIView, focusPoint: CGPoint, hostBounds: CGRect, targetPoint: CGPoint) { imageView.image = snapshot(from: sourceView, focusPoint: focusPoint) let targetCenter = CGPoint( x: min(max(targetPoint.x, hostBounds.minX + bounds.width / 2), hostBounds.maxX - bounds.width / 2), y: min( max(hostBounds.minY + bounds.height / 2, targetPoint.y - 74), hostBounds.maxY - bounds.height / 2 ) ) center = targetCenter if isHidden { alpha = 0 transform = CGAffineTransform(scaleX: 0.92, y: 0.92) isHidden = false UIView.animate(withDuration: 0.12) { self.alpha = 1 self.transform = .identity } } } func dismiss() { guard !isHidden else { return } isHidden = true alpha = 0 imageView.image = nil } private func snapshot(from sourceView: UIView, focusPoint: CGPoint) -> UIImage { let renderer = UIGraphicsImageRenderer(size: captureSize) return renderer.image { context in let cgContext = context.cgContext cgContext.setFillColor(UIColor.systemBackground.cgColor) cgContext.fill(CGRect(origin: .zero, size: captureSize)) cgContext.translateBy( x: captureSize.width / 2 - focusPoint.x * magnification, y: captureSize.height / 2 - focusPoint.y * magnification ) cgContext.scaleBy(x: magnification, y: magnification) sourceView.layer.render(in: cgContext) } } } private extension UIColor { var rd_isDarkReaderBackground: Bool { var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false } return (0.2126 * red + 0.7152 * green + 0.0722 * blue) < 0.35 } }