refactor(reader): 重构高亮选区绘制架构,对齐 WXRead 实现方案

- 移除 RDEPUBSelectableTextView,改用原生 UITextView
- 新增 NSAttributedString 自定义属性(com.rdreader.highlight/underline)注入高亮
- RDEPUBTextPageRenderView 统一绘制高亮背景、文字和选区
- RDEPUBTextSelectionController 精简,选区矩形传递给 RenderView 绘制
- 新增高亮选区复刻 WXRead 实现方案文档
- UI 测试适配新架构

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-01 20:57:27 +08:00
co-authored by Claude Opus 4.7
parent 948004eed1
commit 70133e4e6e
13 changed files with 801 additions and 510 deletions
@@ -7,12 +7,8 @@ import DTCoreText
// MARK: -
///
///
protocol RDEPUBTextContentViewDelegate: AnyObject {
///
func textContentView(_ contentView: RDEPUBTextContentView, didChangeSelection selection: RDEPUBSelection?)
/// //
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
@@ -23,12 +19,9 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
// MARK: -
/// EPUB
///
/// 1. DTCoreText CoreText
/// 2. 退 UITextView attributedText
///
///
final class RDEPUBTextContentView: UIView {
/// WXRead NSAttributedString drawRect
/// UITextView XCUITest
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
private var contentInsets: UIEdgeInsets = .zero
@@ -62,8 +55,9 @@ final class RDEPUBTextContentView: UIView {
return view
}()
private let textView: RDEPUBSelectableTextView = {
let view = RDEPUBSelectableTextView()
/// UITextView XCUITest
private let textView: UITextView = {
let view = UITextView()
view.isEditable = false
view.isScrollEnabled = false
view.isSelectable = true
@@ -87,24 +81,13 @@ final class RDEPUBTextContentView: UIView {
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
}()
// MARK: - Selection Action Bar
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(_:)))
selectionMenuButton(title: "拷贝", menuAction: .copy),
selectionMenuButton(title: "高亮", menuAction: .highlight),
selectionMenuButton(title: "批注", menuAction: .annotate)
])
stack.axis = .horizontal
stack.alignment = .fill
@@ -119,8 +102,11 @@ final class RDEPUBTextContentView: UIView {
return stack
}()
// MARK: - Init
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content.view"
addSubview(coverImageView)
#if canImport(DTCoreText)
addSubview(backgroundOverlayView)
@@ -130,67 +116,50 @@ final class RDEPUBTextContentView: UIView {
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 }
self.currentSelection = selection
if let selection {
self.currentSelection = selection
self.menuSelection = selection
self.showSelectionActionBarIfNeeded()
} else {
self.hideSelectionActionBar()
}
self.delegate?.textContentView(self, didChangeSelection: selection)
}
addGestureRecognizer(selectionLongPressGesture)
addGestureRecognizer(selectionTapGesture)
if #available(iOS 16.0, *) {
addInteraction(UIEditMenuInteraction(delegate: self))
}
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 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 {
action == #selector(rd_copy(_:))
|| action == #selector(rd_highlight(_:))
|| action == #selector(rd_annotate(_:))
}
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
@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()
@@ -213,6 +182,8 @@ final class RDEPUBTextContentView: UIView {
updateSelectionActionBarFrame()
}
// MARK: - Configure
func configure(
page: RDEPUBTextPage,
pageNumber: Int,
@@ -224,7 +195,7 @@ final class RDEPUBTextContentView: UIView {
currentPage = page
currentSelection = nil
menuSelection = nil
hideSelectionActionBar()
selectionController.clearSelection()
contentInsets = configuration.reflowableContentInsets
backgroundColor = configuration.theme.contentBackgroundColor
pageNumberLabel.textColor = configuration.theme.contentTextColor
@@ -240,7 +211,6 @@ final class RDEPUBTextContentView: UIView {
textView.isUserInteractionEnabled = false
#endif
textView.attributedText = nil
textView.selectedRange = NSRange(location: 0, length: 0)
delegate?.textContentView(self, didChangeSelection: nil)
setNeedsLayout()
return
@@ -249,14 +219,6 @@ final class RDEPUBTextContentView: UIView {
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),
@@ -268,26 +230,31 @@ final class RDEPUBTextContentView: UIView {
value: configuration.theme.contentTextColor,
range: fullRange
)
// /线 WXRead WRChapterData.addHighlightInRange:
applyHighlightsToContent(displayContent, highlights: highlights, page: page)
coreTextContentView.isHidden = false
coreTextContentView.backgroundColor = .clear
coreTextDisplayContent = displayContent
coreTextDisplayRange = NSRange(location: 0, length: displayContent.length)
coreTextContentView.attributedDisplayContent = displayContent
textView.isHidden = false
textView.isUserInteractionEnabled = true
textView.tintColor = configuration.theme.toolControlTextColor
textView.attributedText = selectionProxyContent(from: selectionContent)
textView.attributedText = selectionProxyContent(from: displayContent)
textView.selectedRange = NSRange(location: 0, length: 0)
updateSelectionInteractionMode(usingNativeTextSelection: true)
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)
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)
@@ -296,13 +263,13 @@ final class RDEPUBTextContentView: UIView {
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(
// /线 attributed string renderView draw(_:)
let (_, fgDecorations) = overlayView.buildDecorations(
page: page,
highlights: highlights,
highlights: [],
searchState: searchState,
interactionController: interactionController
)
backgroundOverlayView.applyDecorations(bgDecorations)
overlayView.applyDecorations(fgDecorations)
#endif
@@ -313,122 +280,23 @@ final class RDEPUBTextContentView: UIView {
func clearSelection() {
currentSelection = nil
menuSelection = nil
hideSelectionActionBar()
selectionController.clearSelection(
textView: textView,
overlayView: overlayView,
backgroundOverlayView: backgroundOverlayView
)
selectionController.clearSelection()
textView.selectedRange = NSRange(location: 0, length: 0)
overlayView.clearSelection()
backgroundOverlayView.clearSelection()
UIMenuController.shared.setMenuVisible(false, animated: true)
}
// MARK: - Gesture Handling
// MARK: -
@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
private func performSelectionAction(_ action: RDEPUBAnnotationMenuAction) {
let selection = currentSelection ?? menuSelection
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
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 {
private func selectionMenuButton(title: String, menuAction: RDEPUBAnnotationMenuAction) -> UIButton {
let button = UIButton(type: .system)
button.setTitle(title, for: .normal)
button.setTitleColor(.white, for: .normal)
@@ -437,16 +305,23 @@ final class RDEPUBTextContentView: UIView {
button.backgroundColor = .clear
button.accessibilityLabel = title
button.accessibilityIdentifier = "epub.reader.selection.\(title)"
button.addTarget(self, action: action, for: .touchUpInside)
button.addAction(
UIAction { [weak self] _ in
self?.performSelectionAction(menuAction)
},
for: .touchUpInside
)
return button
}
private func showSelectionActionBarIfNeeded() -> Bool {
guard currentSelectionMenuTargetRect() != nil else { return false }
private func showSelectionActionBarIfNeeded() {
guard textView.selectedRange.length > 0,
let textRange = textView.selectedTextRange else { return }
let rect = textView.firstRect(for: textRange)
guard !rect.isNull, !rect.isEmpty else { return }
selectionActionBar.isHidden = false
updateSelectionActionBarFrame()
bringSubviewToFront(selectionActionBar)
return true
}
private func hideSelectionActionBar() {
@@ -455,9 +330,10 @@ final class RDEPUBTextContentView: UIView {
private func updateSelectionActionBarFrame() {
guard !selectionActionBar.isHidden,
let targetRect = currentSelectionMenuTargetRect() else {
return
}
let textRange = textView.selectedTextRange else { return }
let rect = textView.firstRect(for: textRange)
guard !rect.isNull, !rect.isEmpty else { return }
let targetRect = convert(rect, from: textView)
let fittingSize = selectionActionBar.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
let width = max(fittingSize.width, 168)
@@ -472,19 +348,7 @@ final class RDEPUBTextContentView: UIView {
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)
}
// MARK: - Cover Image
private func configureCoverIfNeeded(for page: RDEPUBTextPage) -> Bool {
guard page.pageIndexInChapter == 0,
@@ -526,20 +390,16 @@ final class RDEPUBTextContentView: UIView {
}
#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 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)
}
let data = fileWrapper.regularFileContents { return UIImage(data: data) }
}
return nil
}
// MARK: - Dark Image Adjustment
#if canImport(DTCoreText)
private func darkImageAdjustedContentIfNeeded(
_ content: NSMutableAttributedString,
@@ -556,9 +416,7 @@ final class RDEPUBTextContentView: UIView {
guard let attachment = value as? DTImageTextAttachment,
!isCoverAttachment(attachment),
let image = attachment.image,
shouldAdjustDarkImage(image) else {
return
}
shouldAdjustDarkImage(image) else { return }
let adjustedAttachment = DTImageTextAttachment()
adjustedAttachment.image = adjustedImage(
@@ -576,7 +434,6 @@ final class RDEPUBTextContentView: UIView {
adjustedAttachment.attributes = attachment.attributes
content.addAttribute(.attachment, value: adjustedAttachment, range: range)
}
return content
}
@@ -607,10 +464,7 @@ final class RDEPUBTextContentView: UIView {
blendRatio: CGFloat,
cacheKey: NSString
) -> UIImage {
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) {
return cached
}
if let cached = Self.darkAdjustedImageCache.object(forKey: cacheKey) { return cached }
let format = UIGraphicsImageRendererFormat()
format.scale = image.scale
format.opaque = false
@@ -626,6 +480,34 @@ final class RDEPUBTextContentView: UIView {
}
#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: - Selection Proxy Content
private func selectionProxyContent(from content: NSAttributedString) -> NSAttributedString {
let proxy = NSMutableAttributedString(attributedString: content)
let fullRange = NSRange(location: 0, length: proxy.length)
@@ -648,21 +530,18 @@ final class RDEPUBTextContentView: UIView {
)
proxy.replaceCharacters(in: range, with: replacement)
}
return proxy
}
// MARK: - Content Normalization
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
let content = NSMutableAttributedString(attributedString: page.content)
guard shouldNormalizeContinuationParagraph(for: page) else {
return 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
}
guard firstParagraphRange.length > 0 else { return content }
content.enumerateAttribute(.paragraphStyle, in: firstParagraphRange) { value, range, _ in
guard let style = value as? NSParagraphStyle else { return }
@@ -671,24 +550,19 @@ final class RDEPUBTextContentView: UIView {
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
}
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
}
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,
@@ -714,64 +588,14 @@ final class RDEPUBTextContentView: UIView {
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
}
}
// MARK: - UIColor Extension
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
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
}
}