ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
shen 6f75b083f7 feat: EPUB 阅读器搜索、选中注释、书签 chrome 状态及大量重构优化
- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态
- 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试
- 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证)
- 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互
- 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache)
- 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy
- 更新 epub-bridge.js 与 JS bridge 通信协议
- 全面更新现有 UI 测试以适配新的 helper 和状态管理
2026-06-13 22:48:56 +08:00

709 lines
28 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

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

import UIKit
import Foundation
#if canImport(DTCoreText)
import DTCoreText
#endif
// MARK: -
protocol RDEPUBTextContentViewDelegate: AnyObject {
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
selection: RDEPUBSelection?
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String,
sourceRect: CGRect
)
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
)
}
// MARK: -
/// EPUB
/// WXRead CoreText drawRect
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
private var currentSelection: RDEPUBSelection?
private var menuSelection: RDEPUBSelection?
private var currentHighlights: [RDEPUBHighlight] = []
private var currentSearchState: RDEPUBSearchState?
weak var delegate: RDEPUBTextContentViewDelegate?
#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 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
}()
// MARK: - Init
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)
addGestureRecognizer(longPressGestureRecognizer)
addGestureRecognizer(panGestureRecognizer)
addGestureRecognizer(tapGestureRecognizer)
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
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.pageProvider = { [weak self] in self?.currentPage }
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: - First Responder
override var canBecomeFirstResponder: Bool { true }
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)
}
// MARK: - Layout
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
)
}
// MARK: - Configure
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration,
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
currentPage = page
currentSelection = nil
menuSelection = nil
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
)
// /线 WXRead WRChapterData.addHighlightInRange:
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 clearSelection() {
currentSelection = nil
menuSelection = nil
currentSearchState = nil
panGestureRecognizer.isEnabled = false
selectionController.clearSelection(renderView: coreTextRenderView)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
updateAccessibilityDecorationSummary()
UIMenuController.shared.setMenuVisible(false, animated: true)
}
// MARK: -
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
let selection = currentSelection ?? menuSelection
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
menuSelection = nil
hideSelectionMenu()
}
// MARK: - Cover Image
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
}
// MARK: - Dark Image Adjustment
#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
// MARK: - WXRead WRChapterData.addHighlightInRange:
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)
}
}
}
// MARK: - Content Normalization
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)
}
// MARK: - CoreText Layout
#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
// MARK: - Gestures
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
#if canImport(DTCoreText)
layoutIfNeeded()
selectionController.handleLongPress(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
switch gesture.state {
case .began:
panGestureRecognizer.isEnabled = true
case .ended:
panGestureRecognizer.isEnabled = false
showSelectionMenuIfNeeded()
case .cancelled, .failed:
panGestureRecognizer.isEnabled = false
default:
break
}
#endif
}
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
#if canImport(DTCoreText)
selectionController.handlePan(
gesture,
renderView: coreTextRenderView,
interactionController: interactionController
)
switch gesture.state {
case .ended:
panGestureRecognizer.isEnabled = false
showSelectionMenuIfNeeded()
case .cancelled, .failed:
panGestureRecognizer.isEnabled = false
default:
break
}
#endif
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
let point = gesture.location(in: overlayView)
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)
return
}
guard let highlight = highlight(at: point),
let sourceRect = highlightSourceRect(for: highlight, fallbackPoint: point) else {
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 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)
}
override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
if gestureRecognizer === panGestureRecognizer {
return selectionController.isSelecting
}
if gestureRecognizer === tapGestureRecognizer {
guard let tapGestureRecognizer = gestureRecognizer as? UITapGestureRecognizer else {
return false
}
if selectionController.hasActiveSelection {
return true
}
let point = tapGestureRecognizer.location(in: overlayView)
return attachmentText(at: point) != nil || highlight(at: point) != nil
}
return true
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === longPressGestureRecognizer || gestureRecognizer === panGestureRecognizer
}
}
// MARK: - UIColor Extension
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
}
}