644 lines
25 KiB
Swift
644 lines
25 KiB
Swift
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,
|
||
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] = []
|
||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||
|
||
#if canImport(DTCoreText)
|
||
private let coreTextContentView: RDEPUBTextPageRenderView = {
|
||
let view = RDEPUBTextPageRenderView()
|
||
view.backgroundColor = .clear
|
||
view.isOpaque = false
|
||
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.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
|
||
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)
|
||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||
page: page,
|
||
highlights: [],
|
||
searchState: searchState,
|
||
interactionController: interactionController
|
||
)
|
||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||
overlayView.applyDecorations(fgDecorations)
|
||
#endif
|
||
|
||
delegate?.textContentView(self, didChangeSelection: nil)
|
||
setNeedsLayout()
|
||
}
|
||
|
||
func clearSelection() {
|
||
currentSelection = nil
|
||
menuSelection = nil
|
||
panGestureRecognizer.isEnabled = false
|
||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||
overlayView.clearSelection()
|
||
backgroundOverlayView.clearSelection()
|
||
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)
|
||
}
|
||
#endif
|
||
|
||
// MARK: - Gestures
|
||
|
||
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
|
||
#if canImport(DTCoreText)
|
||
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
|
||
}
|
||
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 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 highlight.location.href == page.href,
|
||
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)
|
||
}
|
||
|
||
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 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
|
||
}
|
||
}
|