feat: improve native text selection and annotation actions
This commit is contained in:
@@ -14,13 +14,17 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
|
||||
didRequestSelectionAction action: RDEPUBAnnotationMenuAction,
|
||||
selection: RDEPUBSelection?
|
||||
)
|
||||
func textContentView(
|
||||
_ contentView: RDEPUBTextContentView,
|
||||
didRequestHighlightActions highlight: RDEPUBHighlight,
|
||||
sourceRect: CGRect
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 文本内容视图
|
||||
|
||||
/// EPUB 流式排版的文本内容视图
|
||||
/// 对齐 WXRead 架构:高亮通过 NSAttributedString 自定义属性注入,在统一 drawRect 中绘制。
|
||||
/// 保留 UITextView 用于 XCUITest 兼容和系统级文本选择。
|
||||
/// 对齐 WXRead 架构:选区和高亮共享 CoreText 命中测试与 drawRect 绘制结果。
|
||||
final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
private static let darkAdjustedImageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
@@ -28,6 +32,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
private var currentPage: RDEPUBTextPage?
|
||||
private var currentSelection: RDEPUBSelection?
|
||||
private var menuSelection: RDEPUBSelection?
|
||||
private var currentHighlights: [RDEPUBHighlight] = []
|
||||
weak var delegate: RDEPUBTextContentViewDelegate?
|
||||
|
||||
#if canImport(DTCoreText)
|
||||
@@ -55,19 +60,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return view
|
||||
}()
|
||||
|
||||
/// 保留 UITextView 用于系统文本选择和 XCUITest 兼容
|
||||
private let textView: UITextView = {
|
||||
let view = UITextView()
|
||||
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
|
||||
@@ -81,25 +73,24 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
return label
|
||||
}()
|
||||
|
||||
// MARK: - Selection Action Bar
|
||||
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 selectionActionBar: UIStackView = {
|
||||
let stack = UIStackView(arrangedSubviews: [
|
||||
selectionMenuButton(title: "拷贝", menuAction: .copy),
|
||||
selectionMenuButton(title: "高亮", menuAction: .highlight),
|
||||
selectionMenuButton(title: "批注", menuAction: .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
|
||||
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
|
||||
@@ -113,19 +104,19 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
addSubview(coreTextContentView)
|
||||
#endif
|
||||
addSubview(overlayView)
|
||||
addSubview(textView)
|
||||
addSubview(pageNumberLabel)
|
||||
addSubview(selectionActionBar)
|
||||
addGestureRecognizer(longPressGestureRecognizer)
|
||||
addGestureRecognizer(panGestureRecognizer)
|
||||
addGestureRecognizer(tapGestureRecognizer)
|
||||
tapGestureRecognizer.require(toFail: longPressGestureRecognizer)
|
||||
|
||||
textView.delegate = selectionController
|
||||
selectionController.onSelectionChanged = { [weak self] selection in
|
||||
guard let self else { return }
|
||||
self.currentSelection = selection
|
||||
if let selection {
|
||||
self.menuSelection = selection
|
||||
self.showSelectionActionBarIfNeeded()
|
||||
} else {
|
||||
self.hideSelectionActionBar()
|
||||
self.hideSelectionMenu()
|
||||
}
|
||||
self.delegate?.textContentView(self, didChangeSelection: selection)
|
||||
}
|
||||
@@ -169,7 +160,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
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))
|
||||
@@ -179,7 +169,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
width: labelSize.width,
|
||||
height: labelSize.height
|
||||
)
|
||||
updateSelectionActionBarFrame()
|
||||
}
|
||||
|
||||
// MARK: - Configure
|
||||
@@ -195,7 +184,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
currentPage = page
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
selectionController.clearSelection()
|
||||
currentHighlights = highlights
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
contentInsets = configuration.reflowableContentInsets
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
@@ -207,10 +197,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
delegate?.textContentView(self, didChangeSelection: nil)
|
||||
setNeedsLayout()
|
||||
return
|
||||
@@ -234,14 +221,10 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
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
|
||||
textView.isHidden = false
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.tintColor = configuration.theme.toolControlTextColor
|
||||
textView.attributedText = selectionProxyContent(from: displayContent)
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
updateCoreTextLayoutFrameIfNeeded()
|
||||
#else
|
||||
let selectionContent = normalizedPageContent(from: page)
|
||||
@@ -253,23 +236,18 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
)
|
||||
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
|
||||
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)
|
||||
// 高亮/下划线已通过 attributed string 自定义属性注入,由 renderView 在 draw(_:) 中绘制
|
||||
let (_, fgDecorations) = overlayView.buildDecorations(
|
||||
let (bgDecorations, fgDecorations) = overlayView.buildDecorations(
|
||||
page: page,
|
||||
highlights: [],
|
||||
searchState: searchState,
|
||||
interactionController: interactionController
|
||||
)
|
||||
backgroundOverlayView.applyDecorations(bgDecorations)
|
||||
overlayView.applyDecorations(fgDecorations)
|
||||
#endif
|
||||
|
||||
@@ -280,8 +258,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
func clearSelection() {
|
||||
currentSelection = nil
|
||||
menuSelection = nil
|
||||
selectionController.clearSelection()
|
||||
textView.selectedRange = NSRange(location: 0, length: 0)
|
||||
panGestureRecognizer.isEnabled = false
|
||||
selectionController.clearSelection(renderView: coreTextRenderView)
|
||||
overlayView.clearSelection()
|
||||
backgroundOverlayView.clearSelection()
|
||||
UIMenuController.shared.setMenuVisible(false, animated: true)
|
||||
@@ -293,59 +271,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
let selection = currentSelection ?? menuSelection
|
||||
delegate?.textContentView(self, didRequestSelectionAction: action, selection: selection)
|
||||
menuSelection = nil
|
||||
hideSelectionActionBar()
|
||||
}
|
||||
|
||||
private func selectionMenuButton(title: String, menuAction: RDEPUBAnnotationMenuAction) -> 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.addAction(
|
||||
UIAction { [weak self] _ in
|
||||
self?.performSelectionAction(menuAction)
|
||||
},
|
||||
for: .touchUpInside
|
||||
)
|
||||
return button
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private func hideSelectionActionBar() {
|
||||
selectionActionBar.isHidden = true
|
||||
}
|
||||
|
||||
private func updateSelectionActionBarFrame() {
|
||||
guard !selectionActionBar.isHidden,
|
||||
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)
|
||||
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)
|
||||
hideSelectionMenu()
|
||||
}
|
||||
|
||||
// MARK: - Cover Image
|
||||
@@ -364,10 +290,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
coreTextContentView.layoutFrame = nil
|
||||
coreTextDisplayContent = nil
|
||||
coreTextDisplayRange = nil
|
||||
textView.isHidden = true
|
||||
textView.isUserInteractionEnabled = false
|
||||
#endif
|
||||
textView.attributedText = nil
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -506,33 +429,6 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection Proxy Content
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// MARK: - Content Normalization
|
||||
|
||||
private func normalizedPageContent(from page: RDEPUBTextPage) -> NSMutableAttributedString {
|
||||
@@ -588,6 +484,152 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user