diff --git a/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate b/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate index 904f89c..71507a1 100644 Binary files a/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate and b/ReadViewDemo/ReadViewDemo.xcworkspace/xcuserdata/shen.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift index 0018b1e..c7c213e 100644 --- a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ReaderAnnotationTests.swift @@ -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)) diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift index 4372ff0..2eb1c96 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift @@ -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? { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift index fa1d27c..ea12b6c 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderAnnotationCoordinator.swift @@ -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 } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift index bb5cf20..64bef98 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift @@ -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) } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift index 4dc274d..4008522 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBPageInteractionController.swift @@ -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 } diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift index 3595caa..e722fd3 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -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() @@ -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 diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift index 3089a61..6daeac6 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextSelectionController.swift @@ -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) } }