feat: improve native text selection and annotation actions

This commit is contained in:
shen 2026-06-01 21:29:41 +08:00
parent 70133e4e6e
commit 1c6108061c
8 changed files with 359 additions and 216 deletions

View File

@ -11,7 +11,7 @@ final class ReaderAnnotationTests: XCTestCase {
app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader()
let content = app.textViews[IDs.readerSelectionText].firstMatch
let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))
@ -37,7 +37,7 @@ final class ReaderAnnotationTests: XCTestCase {
app.launchAndOpenSampleBook(pageNumber: 2)
app.waitForReader()
let content = app.textViews[IDs.readerSelectionText].firstMatch
let content = app.otherElements[IDs.readerSelectionText].firstMatch
XCTAssertTrue(content.waitForExistence(timeout: 5), "阅读内容区域未出现")
let start = content.coordinate(withNormalizedOffset: CGVector(dx: 0.35, dy: 0.42))

View File

@ -96,6 +96,14 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
contentView.clearSelection()
}
func textContentView(
_ contentView: RDEPUBTextContentView,
didRequestHighlightActions highlight: RDEPUBHighlight,
sourceRect: CGRect
) {
runtime.presentHighlightActions(for: highlight, sourceView: contentView, sourceRect: sourceRect)
}
/// EPUB
func pageNumber(for location: RDEPUBLocation) -> Int? {

View File

@ -225,6 +225,28 @@ final class RDEPUBReaderAnnotationCoordinator {
presentAnnotationActionSheet(for: currentSelection)
}
/// /
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
guard let controller else { return }
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
alert.addAction(UIAlertAction(title: "删除高亮", style: .destructive) { [weak self] _ in
_ = self?.removeHighlight(id: highlight.id)
})
if highlight.hasNote {
alert.addAction(UIAlertAction(title: "删除批注", style: .destructive) { [weak self] _ in
_ = self?.updateHighlightNote(id: highlight.id, note: nil)
})
}
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = sourceView
popover.sourceRect = sourceRect
}
controller.present(alert, animated: true)
}
///
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
guard let selection else { return }

View File

@ -182,6 +182,10 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.presentAnnotationCreation()
}
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
}

View File

@ -53,7 +53,11 @@ final class RDEPUBPageInteractionController {
)
let idx = dtLine.stringIndex(forPosition: relativePoint)
guard idx != NSNotFound, idx >= 0 else { return nil }
return normalizedIndex(idx, lineRange: line.stringRange, pageRange: snapshot.pageContentRange)
return normalizedIndex(
idx + pageOffset(for: snapshot),
lineRange: line.stringRange,
pageRange: snapshot.pageContentRange
)
#else
return nil
#endif
@ -81,9 +85,10 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { continue }
let startX = dtLine.offset(forStringIndex: overlap.location)
let pageOffset = pageOffset(for: snapshot)
let startX = dtLine.offset(forStringIndex: overlap.location - pageOffset)
let endIdx = overlap.location + overlap.length
let endX = dtLine.offset(forStringIndex: endIdx)
let endX = dtLine.offset(forStringIndex: endIdx - pageOffset)
#else
let startX: CGFloat = 0
let endX: CGFloat = line.frame.width
@ -141,7 +146,7 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText)
guard let dtLine = dtLineContaining(range: line.stringRange) else { return nil }
let offsetX = dtLine.offset(forStringIndex: index)
let offsetX = dtLine.offset(forStringIndex: index - pageOffset(for: snapshot))
#else
let offsetX: CGFloat = 0
#endif
@ -192,11 +197,16 @@ final class RDEPUBPageInteractionController {
#if canImport(DTCoreText)
private func dtLineContaining(range: NSRange) -> DTCoreTextLayoutLine? {
guard let dtLayoutFrame else { return nil }
return dtLayoutFrame.lineContaining(UInt(range.location))
guard let dtLayoutFrame, let snapshot else { return nil }
let localLocation = max(range.location - pageOffset(for: snapshot), 0)
return dtLayoutFrame.lineContaining(UInt(localLocation))
}
#endif
private func pageOffset(for snapshot: RDEPUBPageLayoutSnapshot) -> Int {
snapshot.page.pageStartOffset
}
private func mergeAdjacentRects(_ rects: [CGRect]) -> [CGRect] {
guard rects.count > 1 else { return rects }

View File

@ -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

View File

@ -1,49 +1,146 @@
import UIKit
/// UITextView
/// WXRead UITextView attributed string
final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
/// CoreText
/// WXRead
final class RDEPUBTextSelectionController: NSObject {
private var isSelectionFromInteraction = false
private var selectionMenuAnchorRect: CGRect?
private(set) var isSelecting = false
private var selectionStartIndex: Int = NSNotFound
private var selectionEndIndex: Int = NSNotFound
/// UI
var onSelectionChanged: ((RDEPUBSelection?) -> Void)?
///
var pageProvider: (() -> RDEPUBTextPage?)?
// MARK: - UITextViewDelegate
func textViewDidChangeSelection(_ textView: UITextView) {
textViewDidChangeSelection(textView, page: pageProvider?())
var hasActiveSelection: Bool {
selectedAbsoluteRange != nil
}
func textViewDidChangeSelection(_ textView: UITextView, page: RDEPUBTextPage?) {
guard !isSelectionFromInteraction else { return }
guard let page else {
onSelectionChanged?(nil)
var selectedAbsoluteRange: NSRange? {
guard selectionStartIndex != NSNotFound,
selectionEndIndex != NSNotFound else {
return nil
}
let lower = min(selectionStartIndex, selectionEndIndex)
let upper = max(selectionStartIndex, selectionEndIndex)
return NSRange(location: lower, length: max(upper - lower + 1, 1))
}
func handleLongPress(
_ gesture: UILongPressGestureRecognizer,
renderView: RDEPUBTextPageRenderView?,
interactionController: RDEPUBPageInteractionController
) {
guard let renderView else { return }
let point = gesture.location(in: renderView)
switch gesture.state {
case .began:
beginSelection(at: point, renderView: renderView, interactionController: interactionController)
case .changed:
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
case .ended:
isSelecting = false
case .cancelled, .failed:
clearSelection(renderView: renderView)
default:
break
}
}
func handlePan(
_ gesture: UIPanGestureRecognizer,
renderView: RDEPUBTextPageRenderView?,
interactionController: RDEPUBPageInteractionController
) {
guard isSelecting, let renderView else { return }
let point = gesture.location(in: renderView)
switch gesture.state {
case .began, .changed:
updateSelection(at: point, renderView: renderView, interactionController: interactionController)
case .ended:
isSelecting = false
case .cancelled, .failed:
clearSelection(renderView: renderView)
default:
break
}
}
func menuAnchorRect(interactionController: RDEPUBPageInteractionController) -> CGRect? {
guard let absoluteRange = selectedAbsoluteRange else { return nil }
return interactionController.menuAnchorRect(for: absoluteRange)
}
func clearSelection(renderView: RDEPUBTextPageRenderView? = nil) {
isSelecting = false
selectionStartIndex = NSNotFound
selectionEndIndex = NSNotFound
renderView?.selectionRects = []
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}
private func beginSelection(
at point: CGPoint,
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
clearSelection(renderView: renderView)
return
}
selectionStartIndex = index
selectionEndIndex = index
isSelecting = true
applySelection(renderView: renderView, interactionController: interactionController)
}
private func updateSelection(
at point: CGPoint,
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard selectionStartIndex != NSNotFound,
let index = interactionController.characterIndexForViewPoint(at: point, in: renderView) else {
return
}
selectionEndIndex = index
applySelection(renderView: renderView, interactionController: interactionController)
}
private func applySelection(
renderView: RDEPUBTextPageRenderView,
interactionController: RDEPUBPageInteractionController
) {
guard let absoluteRange = selectedAbsoluteRange,
let page = pageProvider?() else {
clearSelection(renderView: renderView)
return
}
let selectedRange = textView.selectedRange
guard selectedRange.location != NSNotFound,
selectedRange.length > 0,
let attributedText = textView.attributedText else {
onSelectionChanged?(nil)
return
renderView.selectionRects = interactionController.selectionRects(for: absoluteRange)
onSelectionChanged?(makeSelection(from: absoluteRange, page: page))
}
private func makeSelection(from absoluteRange: NSRange, page: RDEPUBTextPage) -> RDEPUBSelection? {
guard absoluteRange.location != NSNotFound,
absoluteRange.length > 0,
NSMaxRange(absoluteRange) <= page.chapterContent.length else {
return nil
}
let source = attributedText.string as NSString
let selectedText = source.substring(with: selectedRange).trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedText.isEmpty else {
onSelectionChanged?(nil)
return
let selectedText = page.chapterContent.attributedSubstring(from: absoluteRange).string
guard !selectedText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let globalStart = page.pageStartOffset + selectedRange.location
let globalEnd = globalStart + selectedRange.length
let totalLength = max(page.chapterContent.length - 1, 1)
let selection = RDEPUBSelection(
let globalStart = absoluteRange.location
let globalEnd = absoluteRange.location + absoluteRange.length
return RDEPUBSelection(
location: RDEPUBLocation(
href: page.href,
progression: Double(globalStart) / Double(totalLength),
@ -53,45 +150,5 @@ final class RDEPUBTextSelectionController: NSObject, UITextViewDelegate {
text: selectedText,
rangeInfo: RDEPUBTextOffsetRangeInfo(href: page.href, start: globalStart, end: globalEnd).jsonString()
)
onSelectionChanged?(selection)
}
// MARK: -
func clearSelection() {
isSelectionFromInteraction = false
selectionMenuAnchorRect = nil
UIMenuController.shared.setMenuVisible(false, animated: true)
onSelectionChanged?(nil)
}
func clearSelection(textView: UITextView) {
textView.selectedRange = NSRange(location: 0, length: 0)
clearSelection()
}
// MARK: -
func showSelectionMenuIfNeeded(
in hostView: UIView,
textView: UITextView,
interactionController: RDEPUBPageInteractionController
) {
guard textView.selectedRange.length > 0,
let textRange = textView.selectedTextRange else { return }
let rect = textView.firstRect(for: textRange)
guard !rect.isNull, !rect.isEmpty else { return }
let menuRect = hostView.convert(rect, from: textView)
hostView.becomeFirstResponder()
let menuController = UIMenuController.shared
menuController.menuItems = [
UIMenuItem(title: "拷贝", action: #selector(RDEPUBTextContentView.rd_copy(_:))),
UIMenuItem(title: "高亮", action: #selector(RDEPUBTextContentView.rd_highlight(_:))),
UIMenuItem(title: "批注", action: #selector(RDEPUBTextContentView.rd_annotate(_:)))
]
menuController.setTargetRect(menuRect, in: hostView)
menuController.setMenuVisible(true, animated: true)
}
}