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:
co-authored by
Claude Opus 4.7
parent
948004eed1
commit
70133e4e6e
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user