ReadViewSDK/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift
shen 1efb9d172f feat(reader): 增强阅读器功能与 UI 测试支持
- 新增字体选择(系统/宋体/圆体/等宽)与暗色图片柔化配置
- 文本选择改为自定义手势+操作栏(拷贝/高亮/批注)
- 添加 accessibilityIdentifier 支持自动化 UI 测试
- 新增 UITests 覆盖阅读器打开/关闭、工具栏、设置面板、批注等
- 添加 Demo 测试用 EPUB 书源(宝山辽墓材料与释读)
- 新增文档:UI 自动化测试、功能开发计划、阅读器规划
2026-05-31 23:56:54 +08:00

778 lines
30 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?
)
}
// MARK: -
/// EPUB
///
/// 1. DTCoreText CoreText
/// 2. 退 UITextView attributedText
///
///
final class RDEPUBTextContentView: UIView {
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
private var contentInsets: UIEdgeInsets = .zero
private var currentPage: RDEPUBTextPage?
private var currentSelection: RDEPUBSelection?
private var menuSelection: RDEPUBSelection?
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 textView: RDEPUBSelectableTextView = {
let view = RDEPUBSelectableTextView()
view.isEditable = false
view.isScrollEnabled = false
view.isSelectable = true
view.backgroundColor = .clear
view.accessibilityIdentifier = "epub.reader.selection.text"
view.textContainerInset = .zero
view.textContainer.lineFragmentPadding = 0
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 selectionLongPressGesture: UILongPressGestureRecognizer = {
let gesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress(_:)))
gesture.minimumPressDuration = 0.4
return gesture
}()
private lazy var selectionTapGesture: UITapGestureRecognizer = {
let gesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
gesture.numberOfTapsRequired = 1
gesture.require(toFail: selectionLongPressGesture)
return gesture
}()
private lazy var selectionActionBar: UIStackView = {
let stack = UIStackView(arrangedSubviews: [
selectionMenuButton(title: "拷贝", action: #selector(rd_copy(_:))),
selectionMenuButton(title: "高亮", action: #selector(rd_highlight(_:))),
selectionMenuButton(title: "批注", action: #selector(rd_annotate(_:)))
])
stack.axis = .horizontal
stack.alignment = .fill
stack.distribution = .fillEqually
stack.spacing = 1
stack.backgroundColor = UIColor(white: 0.12, alpha: 0.96)
stack.layer.cornerRadius = 10
stack.layer.masksToBounds = true
stack.layer.zPosition = 100
stack.isHidden = true
stack.accessibilityIdentifier = "epub.reader.selection.menu"
return stack
}()
override init(frame: CGRect) {
super.init(frame: frame)
addSubview(coverImageView)
#if canImport(DTCoreText)
addSubview(backgroundOverlayView)
addSubview(coreTextContentView)
#endif
addSubview(overlayView)
addSubview(textView)
addSubview(pageNumberLabel)
addSubview(selectionActionBar)
textView.delegate = selectionController
textView.onSelectionAction = { [weak self] action in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: action,
selection: self.resolvedCurrentSelection()
)
}
selectionController.onSelectionChanged = { [weak self] selection in
guard let self else { return }
if let selection {
self.currentSelection = selection
}
self.delegate?.textContentView(self, didChangeSelection: selection)
}
addGestureRecognizer(selectionLongPressGesture)
addGestureRecognizer(selectionTapGesture)
if #available(iOS 16.0, *) {
addInteraction(UIEditMenuInteraction(delegate: self))
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override var canBecomeFirstResponder: Bool { true }
override func target(forAction action: Selector, withSender sender: Any?) -> Any? {
#if canImport(DTCoreText)
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return self
default:
return super.target(forAction: action, withSender: sender)
}
#else
return super.target(forAction: action, withSender: sender)
#endif
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
#if canImport(DTCoreText)
switch action {
case #selector(rd_copy(_:)),
#selector(rd_highlight(_:)),
#selector(rd_annotate(_:)):
return selectionController.canPerformSelectionAction(in: overlayView)
default:
return false
}
#else
return super.canPerformAction(action, withSender: sender)
#endif
}
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)
textView.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
)
updateSelectionActionBarFrame()
}
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
totalPages: Int,
configuration: RDEPUBReaderConfiguration,
highlights: [RDEPUBHighlight] = [],
searchState: RDEPUBSearchState? = nil
) {
currentPage = page
currentSelection = nil
menuSelection = nil
hideSelectionActionBar()
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
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif
textView.attributedText = nil
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
}
coverImageView.isHidden = true
coverImageView.image = nil
let selectionContent = normalizedPageContent(from: page)
let selectionRange = NSRange(location: 0, length: selectionContent.length)
selectionContent.addAttribute(
.foregroundColor,
value: configuration.theme.contentTextColor,
range: selectionRange
)
#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
)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
textView.isHidden = false
textView.isUserInteractionEnabled = true
textView.tintColor = configuration.theme.toolControlTextColor
textView.attributedText = selectionProxyContent(from: selectionContent)
textView.selectedRange = NSRange(location: 0, length: 0)
updateSelectionInteractionMode(usingNativeTextSelection: true)
updateCoreTextLayoutFrameIfNeeded()
#else
overlayView.applyHighlights(highlights, to: selectionContent, page: page, contentBaseOffset: page.pageStartOffset)
overlayView.applySearchHighlights(to: selectionContent, page: page, searchState: searchState, contentBaseOffset: page.pageStartOffset)
textView.isHidden = false
textView.isUserInteractionEnabled = true
updateSelectionInteractionMode(usingNativeTextSelection: false)
#endif
#if !canImport(DTCoreText)
textView.tintColor = configuration.theme.toolControlTextColor
textView.attributedText = selectionProxyContent(from: selectionContent)
textView.selectedRange = NSRange(location: 0, length: 0)
#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: highlights,
searchState: searchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
#endif
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
}
func clearSelection() {
currentSelection = nil
menuSelection = nil
hideSelectionActionBar()
selectionController.clearSelection(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
}
// MARK: - Gesture Handling
@objc private func handleLongPress(_ gesture: UILongPressGestureRecognizer) {
selectionController.handleLongPress(
gesture,
page: currentPage,
overlayView: overlayView,
interactionController: interactionController
)
if gesture.state == .ended {
showSelectionMenuIfNeeded()
}
}
@objc private func handleTap(_ gesture: UITapGestureRecognizer) {
currentSelection = nil
menuSelection = nil
hideSelectionActionBar()
selectionController.handleTap(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
}
@objc private func rd_copy(_ sender: Any?) {
delegate?.textContentView(self, didRequestSelectionAction: .copy, selection: resolvedCurrentSelection())
}
@objc private func rd_highlight(_ sender: Any?) {
delegate?.textContentView(self, didRequestSelectionAction: .highlight, selection: resolvedCurrentSelection())
}
@objc private func rd_annotate(_ sender: Any?) {
delegate?.textContentView(self, didRequestSelectionAction: .annotate, selection: resolvedCurrentSelection())
}
private func resolvedCurrentSelection() -> RDEPUBSelection? {
currentSelection ?? menuSelection ?? selectionFromOverlayRange()
}
private func selectionFromOverlayRange() -> RDEPUBSelection? {
guard let page = currentPage,
let range = overlayView.selectionRange,
range.length > 0 else {
return nil
}
let source = page.chapterContent.string as NSString
let safeRange = NSIntersectionRange(
range,
NSRange(location: 0, length: page.chapterContent.length)
)
guard safeRange.length > 0 else { return nil }
let selectedText = source.substring(with: safeRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else { return nil }
let chapterLength = max(page.chapterContent.length - 1, 1)
let chapterStart = max(safeRange.location, 0)
let chapterEnd = max(chapterStart + safeRange.length - 1, chapterStart)
return RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(chapterStart) / Double(chapterLength),
lastProgression: Double(chapterEnd) / Double(chapterLength),
fragment: nil
),
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(
href: page.href,
start: safeRange.location,
end: safeRange.location + safeRange.length
).jsonString()
)
}
private func showSelectionMenuIfNeeded() {
#if canImport(DTCoreText)
guard selectionLongPressGesture.isEnabled else { return }
menuSelection = resolvedCurrentSelection()
guard menuSelection != nil else { return }
if showSelectionActionBarIfNeeded() {
return
}
if #available(iOS 16.0, *),
let editMenuInteraction = interactions.compactMap({ $0 as? UIEditMenuInteraction }).first,
let targetRect = currentSelectionMenuTargetRect() {
becomeFirstResponder()
let sourcePoint = CGPoint(x: targetRect.midX, y: targetRect.midY)
editMenuInteraction.presentEditMenu(
with: UIEditMenuConfiguration(identifier: nil, sourcePoint: sourcePoint)
)
return
}
selectionController.showSelectionMenuIfNeeded(
in: self,
overlayView: overlayView,
interactionController: interactionController,
copyAction: #selector(RDEPUBTextContentView.rd_copy(_:)),
highlightAction: #selector(RDEPUBTextContentView.rd_highlight(_:)),
annotateAction: #selector(RDEPUBTextContentView.rd_annotate(_:))
)
#endif
}
private func selectionMenuButton(title: String, action: Selector) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
button.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
button.contentEdgeInsets = UIEdgeInsets(top: 10, left: 14, bottom: 10, right: 14)
button.backgroundColor = .clear
button.accessibilityLabel = title
button.accessibilityIdentifier = "epub.reader.selection.\(title)"
button.addTarget(self, action: action, for: .touchUpInside)
return button
}
private func showSelectionActionBarIfNeeded() -> Bool {
guard currentSelectionMenuTargetRect() != nil else { return false }
selectionActionBar.isHidden = false
updateSelectionActionBarFrame()
bringSubviewToFront(selectionActionBar)
return true
}
private func hideSelectionActionBar() {
selectionActionBar.isHidden = true
}
private func updateSelectionActionBarFrame() {
guard !selectionActionBar.isHidden,
let targetRect = currentSelectionMenuTargetRect() else {
return
}
let fittingSize = selectionActionBar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
let width = max(fittingSize.width, 168)
let height = max(fittingSize.height, 42)
let horizontalPadding: CGFloat = 12
let x = min(
max(targetRect.midX - width / 2, horizontalPadding),
max(horizontalPadding, bounds.width - width - horizontalPadding)
)
let preferredY = targetRect.minY - height - 8
let y = preferredY >= 8 ? preferredY : min(targetRect.maxY + 8, bounds.height - height - 8)
selectionActionBar.frame = CGRect(x: x, y: max(8, y), width: width, height: height)
}
private func updateSelectionInteractionMode(usingNativeTextSelection: Bool) {
selectionLongPressGesture.isEnabled = !usingNativeTextSelection
selectionTapGesture.isEnabled = !usingNativeTextSelection
}
private func currentSelectionMenuTargetRect() -> CGRect? {
guard let range = overlayView.selectionRange,
range.length > 0,
let anchorRect = interactionController.menuAnchorRect(for: range) else {
return nil
}
return overlayView.convert(anchorRect, to: self)
}
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
textView.isHidden = true
textView.isUserInteractionEnabled = false
#endif
textView.attributedText = nil
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 selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
let proxy = NSMutableAttributedString(attributedString: content)
let fullRange = NSRange(location: 0, length: proxy.length)
proxy.removeAttribute(.backgroundColor, range: fullRange)
proxy.addAttribute(.foregroundColor, value: UIColor.clear, range: fullRange)
var attachmentRanges: [NSRange] = []
proxy.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
guard value != nil else { return }
attachmentRanges.append(range)
}
for range in attachmentRanges.reversed() {
let replacement = NSAttributedString(
string: String(repeating: " ", count: max(range.length, 1)),
attributes: [
.font: proxy.attribute(.font, at: max(range.location - 1, 0), effectiveRange: nil) as Any,
.foregroundColor: UIColor.clear
]
)
proxy.replaceCharacters(in: range, with: replacement)
}
return proxy
}
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)
}
#endif
}
@available(iOS 16.0, *)
extension RDEPUBTextContentView: UIEditMenuInteractionDelegate {
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
menuFor configuration: UIEditMenuConfiguration,
suggestedActions: [UIMenuElement]
) -> UIMenu? {
guard resolvedCurrentSelection() != nil else { return nil }
return UIMenu(children: [
UIAction(title: "拷贝") { [weak self] _ in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: .copy,
selection: self.resolvedCurrentSelection()
)
},
UIAction(title: "高亮") { [weak self] _ in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: .highlight,
selection: self.resolvedCurrentSelection()
)
},
UIAction(title: "批注") { [weak self] _ in
guard let self else { return }
self.delegate?.textContentView(
self,
didRequestSelectionAction: .annotate,
selection: self.resolvedCurrentSelection()
)
}
])
}
func editMenuInteraction(
_ interaction: UIEditMenuInteraction,
targetRectFor configuration: UIEditMenuConfiguration
) -> CGRect {
currentSelectionMenuTargetRect() ?? bounds
}
}
private extension UIColor {
var rd_isDarkReaderBackground: Bool {
var red: CGFloat = 0
var green: CGFloat = 0
var blue: CGFloat = 0
var alpha: CGFloat = 0
guard getRed(&red, green: &green, blue: &blue, alpha: &alpha) else {
return false
}
let luminance = 0.2126 * red + 0.7152 * green + 0.0722 * blue
return luminance < 0.35
}
}